@frockbot/kernel-contracts 0.0.0 → 0.1.1
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 +21 -6
- package/src/authoring.test.ts +143 -0
- package/src/authoring.ts +189 -0
- package/src/index.ts +11 -0
- package/src/isolate.test.ts +417 -0
- package/src/isolate.ts +704 -0
- package/src/model-invocation.ts +74 -0
- package/src/prompt-assembly.ts +54 -0
- package/src/send-to-user.test.ts +234 -0
- package/src/send-to-user.ts +384 -0
- package/src/session.test.ts +708 -0
- package/src/session.ts +521 -0
- package/src/skills.test.ts +123 -0
- package/src/skills.ts +164 -0
- package/src/tool-attachments.test.ts +100 -0
- package/src/tool-execution.ts +220 -0
- package/src/turn-history.test.ts +54 -0
- package/src/turn-history.ts +33 -0
- package/src/turn-type.test.ts +101 -0
- package/src/types.ts +1791 -0
- package/src/workspace.test.ts +913 -0
- package/src/workspace.ts +1176 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/src/types.ts
ADDED
|
@@ -0,0 +1,1791 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeSendToUserPayloadV1,
|
|
3
|
+
type SendToUserPayloadV1,
|
|
4
|
+
} from "./send-to-user.js";
|
|
5
|
+
import { decodeWorkspacePathV1, type WorkspacePathV1 } from "./workspace.js";
|
|
6
|
+
import {
|
|
7
|
+
decodeSkillRefV1,
|
|
8
|
+
decodeSkillRefsV1,
|
|
9
|
+
type SkillRefV1,
|
|
10
|
+
} from "./skills.js";
|
|
11
|
+
|
|
12
|
+
export interface ToolCall {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
input: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ToolCallOccurrence {
|
|
19
|
+
occurrenceId: string;
|
|
20
|
+
turn: number;
|
|
21
|
+
step: number;
|
|
22
|
+
ordinal: number;
|
|
23
|
+
call: ToolCall;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toolOccurrenceId(
|
|
27
|
+
turn: number,
|
|
28
|
+
step: number,
|
|
29
|
+
ordinal: number,
|
|
30
|
+
): string {
|
|
31
|
+
if (
|
|
32
|
+
!Number.isSafeInteger(turn) ||
|
|
33
|
+
turn <= 0 ||
|
|
34
|
+
!Number.isSafeInteger(step) ||
|
|
35
|
+
step <= 0 ||
|
|
36
|
+
!Number.isSafeInteger(ordinal) ||
|
|
37
|
+
ordinal < 0
|
|
38
|
+
) {
|
|
39
|
+
throw new Error("tool occurrence coordinates are invalid");
|
|
40
|
+
}
|
|
41
|
+
return `tool:${turn}:${step}:${ordinal}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toolCallOccurrences(
|
|
45
|
+
turn: number,
|
|
46
|
+
step: number,
|
|
47
|
+
calls: readonly ToolCall[],
|
|
48
|
+
): ToolCallOccurrence[] {
|
|
49
|
+
return calls.map((call, ordinal) => ({
|
|
50
|
+
occurrenceId: toolOccurrenceId(turn, step, ordinal),
|
|
51
|
+
turn,
|
|
52
|
+
step,
|
|
53
|
+
ordinal,
|
|
54
|
+
call,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toolIntentMatches(
|
|
59
|
+
call: ToolCall,
|
|
60
|
+
intent: { name: string; input: unknown },
|
|
61
|
+
): boolean {
|
|
62
|
+
if (call.name !== intent.name) return false;
|
|
63
|
+
try {
|
|
64
|
+
return JSON.stringify(call.input) === JSON.stringify(intent.input);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ToolSchema {
|
|
71
|
+
name: string;
|
|
72
|
+
description: string;
|
|
73
|
+
inputSchema: Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The media types a tool result attachment may carry. */
|
|
77
|
+
export const TOOL_ATTACHMENT_MEDIA_TYPES_V1 = [
|
|
78
|
+
"image/png",
|
|
79
|
+
"image/jpeg",
|
|
80
|
+
"image/webp",
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
export type ToolAttachmentMediaTypeV1 =
|
|
84
|
+
(typeof TOOL_ATTACHMENT_MEDIA_TYPES_V1)[number];
|
|
85
|
+
|
|
86
|
+
/** Most attachments one tool result may carry. */
|
|
87
|
+
export const TOOL_ATTACHMENT_LIMIT_V1 = 8;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A binary a tool produced, named by where it lives durably rather than by its
|
|
91
|
+
* bytes.
|
|
92
|
+
*
|
|
93
|
+
* The bytes are deliberately absent. An attachment is recorded in the session
|
|
94
|
+
* event log, and that log is one Durable Object value: a base64 screenshot in
|
|
95
|
+
* it would be a durable record that grows past what the object can hold. The
|
|
96
|
+
* Workspace holds the bytes, the content hash names exactly which bytes, and a
|
|
97
|
+
* model-invocation adapter that can show an image resolves them at request
|
|
98
|
+
* time. `dataBase64` is that resolution and is never durable — the session
|
|
99
|
+
* event decoder refuses it.
|
|
100
|
+
*/
|
|
101
|
+
export interface ToolAttachmentV1 {
|
|
102
|
+
kind: "image";
|
|
103
|
+
mediaType: ToolAttachmentMediaTypeV1;
|
|
104
|
+
/** The durable root and relative path the bytes were written to. */
|
|
105
|
+
workspacePath: WorkspacePathV1;
|
|
106
|
+
contentHash: string;
|
|
107
|
+
bytes: number;
|
|
108
|
+
/** Resolved bytes, in memory only, for one model request. */
|
|
109
|
+
dataBase64?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type LlmMessage =
|
|
113
|
+
| { role: "user"; content: string }
|
|
114
|
+
| { role: "assistant"; content: string; toolCalls: ToolCall[] }
|
|
115
|
+
| {
|
|
116
|
+
role: "tool";
|
|
117
|
+
callId: string;
|
|
118
|
+
name: string;
|
|
119
|
+
content: string;
|
|
120
|
+
isError: boolean;
|
|
121
|
+
attachments?: ToolAttachmentV1[];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export interface ModelBindingSnapshot {
|
|
125
|
+
connectionId: string;
|
|
126
|
+
connectionGeneration?: string;
|
|
127
|
+
catalogGeneration?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface NormalizedModelRequest {
|
|
131
|
+
requestId: string;
|
|
132
|
+
provider: string;
|
|
133
|
+
model: string;
|
|
134
|
+
system: string;
|
|
135
|
+
messages: LlmMessage[];
|
|
136
|
+
tools: ToolSchema[];
|
|
137
|
+
modelBinding?: ModelBindingSnapshot;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type LlmStreamEvent =
|
|
141
|
+
| { type: "text-delta"; text: string }
|
|
142
|
+
| { type: "tool-call"; call: ToolCall }
|
|
143
|
+
| { type: "finish"; reason: "completed" | "tool-calls" | "max-tokens" };
|
|
144
|
+
|
|
145
|
+
export type StepOutcome =
|
|
146
|
+
| "completed"
|
|
147
|
+
| "blocked"
|
|
148
|
+
| "cancelled"
|
|
149
|
+
| "interrupted"
|
|
150
|
+
| "model-error"
|
|
151
|
+
| "tool-error";
|
|
152
|
+
|
|
153
|
+
export type TurnOutcome = StepOutcome;
|
|
154
|
+
|
|
155
|
+
/** Longest `reason` a `turn/end` event may carry. */
|
|
156
|
+
export const TURN_END_REASON_MAX_LENGTH = 500;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Truncates a failure description to what a `turn/end` `reason` accepts.
|
|
160
|
+
* Returns `undefined` when nothing describable remains.
|
|
161
|
+
*/
|
|
162
|
+
export function turnEndReason(value: unknown): string | undefined {
|
|
163
|
+
const text =
|
|
164
|
+
value instanceof Error && value.message
|
|
165
|
+
? value.message
|
|
166
|
+
: typeof value === "string"
|
|
167
|
+
? value
|
|
168
|
+
: "";
|
|
169
|
+
const bounded = text.slice(0, TURN_END_REASON_MAX_LENGTH);
|
|
170
|
+
return bounded.length > 0 ? bounded : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The failure text a User sees for a Turn that did not complete. */
|
|
174
|
+
export function turnFailureMessage(
|
|
175
|
+
outcome: TurnOutcome,
|
|
176
|
+
reason?: string,
|
|
177
|
+
): string {
|
|
178
|
+
return reason
|
|
179
|
+
? `Bot turn ended with outcome ${outcome}: ${reason}`
|
|
180
|
+
: `Bot turn ended with outcome ${outcome}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The kind of Turn an Agent run was admitted as. GrokBot trims the tool
|
|
185
|
+
* catalog per turn type — the parity register's row 57 — so the kernel has to
|
|
186
|
+
* carry the value; which tools a turn type admits stays Package policy.
|
|
187
|
+
*
|
|
188
|
+
* All names are declared together because the value crosses the manifest, the
|
|
189
|
+
* isolate contract, and the durable run record: adding one later is a wire
|
|
190
|
+
* change in three places.
|
|
191
|
+
*/
|
|
192
|
+
export type TurnTypeV1 = "chat" | "automation" | "subagent";
|
|
193
|
+
|
|
194
|
+
/** The declared turn types, in their canonical order. */
|
|
195
|
+
export const TURN_TYPES_V1: readonly TurnTypeV1[] = [
|
|
196
|
+
"chat",
|
|
197
|
+
"automation",
|
|
198
|
+
"subagent",
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
/** The strict decoder for a turn type crossing any seam. */
|
|
202
|
+
export function decodeTurnTypeV1(
|
|
203
|
+
value: unknown,
|
|
204
|
+
label = "turn type",
|
|
205
|
+
): TurnTypeV1 {
|
|
206
|
+
const turnType = TURN_TYPES_V1.find((candidate) => candidate === value);
|
|
207
|
+
if (!turnType) throw new Error(`${label} is invalid`);
|
|
208
|
+
return turnType;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The Composition generation an admitted Turn is pinned to. */
|
|
212
|
+
export interface CompositionPinV1 {
|
|
213
|
+
generationId: string;
|
|
214
|
+
artifactSetHash: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The three Memory tiers a fact can be written to or injected from, named as
|
|
219
|
+
* the session log records them. Bot Memory is the Bot's own; the other two are
|
|
220
|
+
* shared roots sharded per writing Bot.
|
|
221
|
+
*/
|
|
222
|
+
export type MemoryScopeNameV1 = "bot" | "user" | "project";
|
|
223
|
+
|
|
224
|
+
export interface SessionEventMap {
|
|
225
|
+
"session/created": { createdAt: string };
|
|
226
|
+
/**
|
|
227
|
+
* `skills` is the Skills this input invoked with `/` or `@`. Optional
|
|
228
|
+
* because an input that invokes none carries no field at all, so every
|
|
229
|
+
* `input/queued` recorded before invocation existed still decodes.
|
|
230
|
+
*/
|
|
231
|
+
"input/queued": {
|
|
232
|
+
messageId: string;
|
|
233
|
+
text: string;
|
|
234
|
+
skills?: SkillRefV1[];
|
|
235
|
+
};
|
|
236
|
+
"input/admitted": { messageId: string; turn: number };
|
|
237
|
+
"input/cancelled": { messageId: string; reason: "user" | "shutdown" };
|
|
238
|
+
"turn/start": { turn: number };
|
|
239
|
+
"composition/pinned": {
|
|
240
|
+
turn: number;
|
|
241
|
+
generationId: string;
|
|
242
|
+
artifactSetHash: string;
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* The turn type this Turn was admitted as, recorded beside the Composition
|
|
246
|
+
* it pinned so the trimmed tool catalog the Turn ran on is auditable in
|
|
247
|
+
* durable state. Absent on Turns recorded before turn admission existed;
|
|
248
|
+
* they replay as `chat`.
|
|
249
|
+
*/
|
|
250
|
+
"turn/admission": { turn: number; turnType: TurnTypeV1 };
|
|
251
|
+
/**
|
|
252
|
+
* A user-facing send, recorded on the step whose tool call produced it.
|
|
253
|
+
* Row 57b: one send tool carries every typed payload, so the log holds the
|
|
254
|
+
* payload rather than a per-payload event, and the client projection reads
|
|
255
|
+
* it back. `occurrenceId` is the tool occurrence the send belongs to, which
|
|
256
|
+
* is what makes a replayed Turn produce exactly one of these per call.
|
|
257
|
+
*/
|
|
258
|
+
"send/to-user": {
|
|
259
|
+
turn: number;
|
|
260
|
+
step: number;
|
|
261
|
+
occurrenceId: string;
|
|
262
|
+
payload: SendToUserPayloadV1;
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* A child Turn's hand-off to its parent — the same Bot's user-visible
|
|
266
|
+
* conversation (row 40, §2.13). Recorded here so the hand-off is durable on
|
|
267
|
+
* the child's own log; delivering it to the parent is a later slice, and
|
|
268
|
+
* nothing reads this event yet.
|
|
269
|
+
*/
|
|
270
|
+
"wake/parent": {
|
|
271
|
+
turn: number;
|
|
272
|
+
step: number;
|
|
273
|
+
occurrenceId: string;
|
|
274
|
+
message: string;
|
|
275
|
+
};
|
|
276
|
+
"step/start": { turn: number; step: number };
|
|
277
|
+
"user/message": {
|
|
278
|
+
turn: number;
|
|
279
|
+
step: number;
|
|
280
|
+
messageId: string;
|
|
281
|
+
text: string;
|
|
282
|
+
};
|
|
283
|
+
"model/request": {
|
|
284
|
+
turn: number;
|
|
285
|
+
step: number;
|
|
286
|
+
request: NormalizedModelRequest;
|
|
287
|
+
};
|
|
288
|
+
"model/effect-not-started": {
|
|
289
|
+
turn: number;
|
|
290
|
+
step: number;
|
|
291
|
+
requestId: string;
|
|
292
|
+
reason: string;
|
|
293
|
+
};
|
|
294
|
+
"model/reconciliation-required": {
|
|
295
|
+
turn: number;
|
|
296
|
+
step: number;
|
|
297
|
+
requestId: string;
|
|
298
|
+
reason: string;
|
|
299
|
+
};
|
|
300
|
+
"assistant/chunk": {
|
|
301
|
+
turn: number;
|
|
302
|
+
step: number;
|
|
303
|
+
requestId: string;
|
|
304
|
+
text: string;
|
|
305
|
+
};
|
|
306
|
+
"assistant/message": {
|
|
307
|
+
turn: number;
|
|
308
|
+
step: number;
|
|
309
|
+
requestId: string;
|
|
310
|
+
text: string;
|
|
311
|
+
toolCalls: ToolCall[];
|
|
312
|
+
};
|
|
313
|
+
"tool/call": {
|
|
314
|
+
turn: number;
|
|
315
|
+
step: number;
|
|
316
|
+
occurrenceId: string;
|
|
317
|
+
name: string;
|
|
318
|
+
input: unknown;
|
|
319
|
+
};
|
|
320
|
+
"tool/result": {
|
|
321
|
+
turn: number;
|
|
322
|
+
step: number;
|
|
323
|
+
occurrenceId: string;
|
|
324
|
+
name: string;
|
|
325
|
+
content: string;
|
|
326
|
+
isError: boolean;
|
|
327
|
+
status: "completed" | "interrupted";
|
|
328
|
+
/** Durable references to binaries the tool produced. Never their bytes. */
|
|
329
|
+
attachments?: ToolAttachmentV1[];
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* The Bot recorded the intent to author a Package, before the bundler ran.
|
|
333
|
+
* Constitution, Durable effects: intent is recorded before the effect.
|
|
334
|
+
*/
|
|
335
|
+
"package/author-intent": {
|
|
336
|
+
turn: number;
|
|
337
|
+
step: number;
|
|
338
|
+
effectId: string;
|
|
339
|
+
packageId: string;
|
|
340
|
+
sourceHash: string;
|
|
341
|
+
};
|
|
342
|
+
/** The authored artifact and the pending Composition generation it produced. */
|
|
343
|
+
"package/authored": {
|
|
344
|
+
turn: number;
|
|
345
|
+
step: number;
|
|
346
|
+
effectId: string;
|
|
347
|
+
packageId: string;
|
|
348
|
+
version: string;
|
|
349
|
+
contentHash: string;
|
|
350
|
+
generationId: string;
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* The Skills this Turn loaded as instructions, and the candidates it
|
|
354
|
+
* refused. Constitution, Memory: "the session event log records exactly what
|
|
355
|
+
* was injected, so an injection gap is visible in durable state rather than
|
|
356
|
+
* silently changing the Bot's behavior." A Skill is an instruction, so its
|
|
357
|
+
* injection is recorded on the Turn that used it, with the exact generation
|
|
358
|
+
* — "the exact Skill generation each Turn used is reconstructable".
|
|
359
|
+
*/
|
|
360
|
+
"skill/injected": {
|
|
361
|
+
turn: number;
|
|
362
|
+
skills: Array<{
|
|
363
|
+
path: string;
|
|
364
|
+
name: string;
|
|
365
|
+
generationId: string;
|
|
366
|
+
contentHash: string;
|
|
367
|
+
/**
|
|
368
|
+
* Who the Skill is attributed to, when the reading Bot did not author
|
|
369
|
+
* it: its User, or another Bot of that User writing the User-global
|
|
370
|
+
* instruction root. Absent for a Skill the Bot wrote itself, which is
|
|
371
|
+
* the common case and has nothing to disclose. It is rendered in the
|
|
372
|
+
* catalog block, so the durable record and the prompt agree on whose
|
|
373
|
+
* instruction the Turn ran under.
|
|
374
|
+
*/
|
|
375
|
+
by?: string;
|
|
376
|
+
}>;
|
|
377
|
+
refusals: Array<{ path: string; reason: string }>;
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* A Skill the User invoked from the composer, resolved to the exact
|
|
381
|
+
* generation this Turn expanded. Invocation is not disclosure-on-demand: the
|
|
382
|
+
* body is expanded into the Turn's first step, so what the model was told is
|
|
383
|
+
* reconstructable from `model/request` and *which* Skill the User asked for
|
|
384
|
+
* is reconstructable from here. One event per invoked ref.
|
|
385
|
+
*/
|
|
386
|
+
"skill/invoked": {
|
|
387
|
+
turn: number;
|
|
388
|
+
ref: SkillRefV1;
|
|
389
|
+
generationId: string;
|
|
390
|
+
contentHash: string;
|
|
391
|
+
};
|
|
392
|
+
/** The Bot recorded the intent to write a Skill, before the write ran. */
|
|
393
|
+
"skill/write-intent": {
|
|
394
|
+
turn: number;
|
|
395
|
+
step: number;
|
|
396
|
+
effectId: string;
|
|
397
|
+
path: string;
|
|
398
|
+
contentHash: string;
|
|
399
|
+
};
|
|
400
|
+
/** The generation the Skill write produced. */
|
|
401
|
+
"skill/written": {
|
|
402
|
+
turn: number;
|
|
403
|
+
step: number;
|
|
404
|
+
effectId: string;
|
|
405
|
+
path: string;
|
|
406
|
+
generationId: string;
|
|
407
|
+
contentHash: string;
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* The Memory this Turn injected, and what it left out. Constitution,
|
|
411
|
+
* Memory: "What Memory enters a model request, and when, is Package policy,
|
|
412
|
+
* and the session event log records exactly what was injected, so an
|
|
413
|
+
* injection gap is visible in durable state rather than silently changing
|
|
414
|
+
* the Bot's behavior." `sources` names every Memory file generation the
|
|
415
|
+
* render read; `facts` is every line that reached the prompt; `omissions`
|
|
416
|
+
* names each tier a cap or a failure cut short.
|
|
417
|
+
*
|
|
418
|
+
* `projectId` is `""` for the tiers that have none, so every entry has the
|
|
419
|
+
* same shape and the decoder needs no optional field.
|
|
420
|
+
*/
|
|
421
|
+
"memory/injected": {
|
|
422
|
+
turn: number;
|
|
423
|
+
sources: Array<{
|
|
424
|
+
scope: MemoryScopeNameV1;
|
|
425
|
+
projectId: string;
|
|
426
|
+
path: string;
|
|
427
|
+
generationId: string;
|
|
428
|
+
contentHash: string;
|
|
429
|
+
}>;
|
|
430
|
+
facts: Array<{
|
|
431
|
+
scope: MemoryScopeNameV1;
|
|
432
|
+
projectId: string;
|
|
433
|
+
tier: "profile" | "log";
|
|
434
|
+
via: string;
|
|
435
|
+
learnedAt: string;
|
|
436
|
+
text: string;
|
|
437
|
+
}>;
|
|
438
|
+
omissions: Array<{ scope: MemoryScopeNameV1; reason: string }>;
|
|
439
|
+
/**
|
|
440
|
+
* Marked facts (`[note] `/`[episode] `) the note fade dropped before the
|
|
441
|
+
* caps were applied, per scope. Deliberately distinct from `omissions`: an
|
|
442
|
+
* omission is a gap to repair, a fade is the note tier working.
|
|
443
|
+
*/
|
|
444
|
+
faded?: Array<{
|
|
445
|
+
scope: MemoryScopeNameV1;
|
|
446
|
+
projectId: string;
|
|
447
|
+
count: number;
|
|
448
|
+
}>;
|
|
449
|
+
/**
|
|
450
|
+
* `YYYY-MM-DD`: the oldest day a marked fact was still injected on, and
|
|
451
|
+
* the TTL it was derived from. Recorded because the fade is read-time, so
|
|
452
|
+
* the model request only reconstructs exactly if the day it used is on the
|
|
453
|
+
* log. Absent on an event written before the fade existed, which is the
|
|
454
|
+
* honest reading: no fade was applied.
|
|
455
|
+
*/
|
|
456
|
+
noteCutoff?: string;
|
|
457
|
+
noteTtlDays?: number;
|
|
458
|
+
};
|
|
459
|
+
/** The Bot recorded the intent to change Memory, before the write ran. */
|
|
460
|
+
"memory/write-intent": {
|
|
461
|
+
turn: number;
|
|
462
|
+
step: number;
|
|
463
|
+
effectId: string;
|
|
464
|
+
action: "write" | "forget";
|
|
465
|
+
scope: MemoryScopeNameV1;
|
|
466
|
+
projectId: string;
|
|
467
|
+
tier: "profile" | "log" | "note";
|
|
468
|
+
path: string;
|
|
469
|
+
contentHash: string;
|
|
470
|
+
};
|
|
471
|
+
/** The generation the Memory write produced. */
|
|
472
|
+
"memory/written": {
|
|
473
|
+
turn: number;
|
|
474
|
+
step: number;
|
|
475
|
+
effectId: string;
|
|
476
|
+
action: "write" | "forget";
|
|
477
|
+
scope: MemoryScopeNameV1;
|
|
478
|
+
projectId: string;
|
|
479
|
+
tier: "profile" | "log" | "note";
|
|
480
|
+
path: string;
|
|
481
|
+
generationId: string;
|
|
482
|
+
contentHash: string;
|
|
483
|
+
};
|
|
484
|
+
/** The Bot recorded the intent to change Project membership, before it ran. */
|
|
485
|
+
"memory/project-intent": {
|
|
486
|
+
turn: number;
|
|
487
|
+
step: number;
|
|
488
|
+
effectId: string;
|
|
489
|
+
action: "create" | "join" | "leave";
|
|
490
|
+
projectId: string;
|
|
491
|
+
};
|
|
492
|
+
/** The Project membership the durable authority holds after the change. */
|
|
493
|
+
"memory/project-changed": {
|
|
494
|
+
turn: number;
|
|
495
|
+
step: number;
|
|
496
|
+
effectId: string;
|
|
497
|
+
action: "create" | "join" | "leave";
|
|
498
|
+
projectId: string;
|
|
499
|
+
projects: string[];
|
|
500
|
+
};
|
|
501
|
+
/**
|
|
502
|
+
* The Bot recorded the intent to generate an image, before the model ran.
|
|
503
|
+
*
|
|
504
|
+
* "Record durable execution intent before invoking an external side effect.
|
|
505
|
+
* Only effects an interface declares read-only are exempt." Image generation
|
|
506
|
+
* is billed and durable, so the intent is recorded first and keyed by the
|
|
507
|
+
* effect, which is also the object's name under the Package's Workspace
|
|
508
|
+
* root. `promptHash` rather than the prompt: the prompt reaches the log once
|
|
509
|
+
* already, in `tool/call`, and this event exists to fence the effect, not to
|
|
510
|
+
* copy its input.
|
|
511
|
+
*/
|
|
512
|
+
"image/generate-intent": {
|
|
513
|
+
turn: number;
|
|
514
|
+
step: number;
|
|
515
|
+
effectId: string;
|
|
516
|
+
model: string;
|
|
517
|
+
promptHash: string;
|
|
518
|
+
width: number;
|
|
519
|
+
height: number;
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* The generation the image write produced. Recorded after the Workspace
|
|
523
|
+
* write settles, so recovery can tell an effect that reached storage from
|
|
524
|
+
* one that did not, and never bills a second time for one that did.
|
|
525
|
+
*/
|
|
526
|
+
"image/generated": {
|
|
527
|
+
turn: number;
|
|
528
|
+
step: number;
|
|
529
|
+
effectId: string;
|
|
530
|
+
model: string;
|
|
531
|
+
path: string;
|
|
532
|
+
generationId: string;
|
|
533
|
+
contentHash: string;
|
|
534
|
+
mimeType: string;
|
|
535
|
+
width: number;
|
|
536
|
+
height: number;
|
|
537
|
+
};
|
|
538
|
+
/**
|
|
539
|
+
* One run of the durable-root sync between the Computer's Workspace and
|
|
540
|
+
* object storage (ADR 0013), on a Turn that had the Computer open.
|
|
541
|
+
*
|
|
542
|
+
* "Connections to the Computer are expected to drop on every pause; every
|
|
543
|
+
* Computer client reconnects and resumes rather than treating a dropped
|
|
544
|
+
* connection as failure." A sync that could not run is therefore an
|
|
545
|
+
* `unavailable` outcome recorded here, never a thrown error and never a
|
|
546
|
+
* failed Turn — and a sync that did run leaves what it moved in durable
|
|
547
|
+
* state, so a missing pull is visible rather than silent.
|
|
548
|
+
*
|
|
549
|
+
* `reason` is why the sync ran: `open` before the Turn's first Computer tool
|
|
550
|
+
* call, `signal` when the on-Computer watcher reported a change mid-Turn,
|
|
551
|
+
* `turn-end` after a Turn that used the Computer.
|
|
552
|
+
*/
|
|
553
|
+
/**
|
|
554
|
+
* A background process on the Computer changed hands: it was launched,
|
|
555
|
+
* looked at, read, or ended. Recorded so a Turn's durable history says what
|
|
556
|
+
* became of a process that outlived it — including `unknown`, which is a
|
|
557
|
+
* first-class outcome and not an error.
|
|
558
|
+
*/
|
|
559
|
+
"computer/process": {
|
|
560
|
+
turn: number;
|
|
561
|
+
processId: string;
|
|
562
|
+
action: "launch" | "check" | "logs" | "stop";
|
|
563
|
+
status: "starting" | "running" | "exited" | "unknown";
|
|
564
|
+
exitCode?: number;
|
|
565
|
+
};
|
|
566
|
+
/**
|
|
567
|
+
* The dynamic Computer line this Turn added to its system prompt. An empty
|
|
568
|
+
* `text` is an explicit wake-free read that found no fresh human lease; a
|
|
569
|
+
* non-empty value records the exact line plus the durable lease generation
|
|
570
|
+
* fields that selected it.
|
|
571
|
+
*/
|
|
572
|
+
"computer/injected": {
|
|
573
|
+
turn: number;
|
|
574
|
+
text: string;
|
|
575
|
+
ownerId?: string;
|
|
576
|
+
expiresAt?: string;
|
|
577
|
+
};
|
|
578
|
+
"computer/sync": {
|
|
579
|
+
turn: number;
|
|
580
|
+
reason: "open" | "signal" | "turn-end";
|
|
581
|
+
status: "ok" | "unavailable" | "refused" | "skipped";
|
|
582
|
+
detail: string;
|
|
583
|
+
pulled: number;
|
|
584
|
+
pushed: number;
|
|
585
|
+
restored: number;
|
|
586
|
+
removed: number;
|
|
587
|
+
adopted: number;
|
|
588
|
+
conflicts: number;
|
|
589
|
+
failures: number;
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* The Bot's name changed, and who changed it. A rename is a durable write
|
|
593
|
+
* that happens outside any Turn — a User edits the Bot's settings, or (from
|
|
594
|
+
* the slice that gives a Bot its own profile tool) the Bot renames itself —
|
|
595
|
+
* so the event carries no `turn` or `step`. `namedBy` is the writer the
|
|
596
|
+
* durable profile now records, so the announcement and the provenance in
|
|
597
|
+
* `BotProfile.namedBy` can never disagree.
|
|
598
|
+
*/
|
|
599
|
+
"bot/renamed": {
|
|
600
|
+
from: string;
|
|
601
|
+
to: string;
|
|
602
|
+
namedBy: "user" | "bot";
|
|
603
|
+
/**
|
|
604
|
+
* The Bot and admitted Turn that wrote the name, when a Bot wrote it.
|
|
605
|
+
* `namedBy` says which kind of writer; this names the exact one, so a
|
|
606
|
+
* self-rename is attributable from the log alone. Absent on a User rename
|
|
607
|
+
* and on every announcement recorded before it existed.
|
|
608
|
+
*/
|
|
609
|
+
writer?: {
|
|
610
|
+
kind: "bot";
|
|
611
|
+
botId: string;
|
|
612
|
+
sessionId: string;
|
|
613
|
+
turnId: string;
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
/**
|
|
617
|
+
* A subagent Task this Turn dispatched (ADR 0017). Recorded on the *parent*
|
|
618
|
+
* Session, because the child's Session is its own durable state and never
|
|
619
|
+
* enters the visible transcript: this event is the only thing the
|
|
620
|
+
* conversation says about a task, and the client draws it as a chip.
|
|
621
|
+
*
|
|
622
|
+
* `taskType` is an opaque string here for the same reason `turnType` is a
|
|
623
|
+
* kernel value and a role catalog is not: which roles exist is Package
|
|
624
|
+
* policy, and the kernel only records the one that was used.
|
|
625
|
+
*/
|
|
626
|
+
"task/dispatched": {
|
|
627
|
+
turn: number;
|
|
628
|
+
step: number;
|
|
629
|
+
occurrenceId: string;
|
|
630
|
+
taskId: string;
|
|
631
|
+
taskType: string;
|
|
632
|
+
description: string;
|
|
633
|
+
model: string;
|
|
634
|
+
background: boolean;
|
|
635
|
+
};
|
|
636
|
+
/** A message the parent appended to a running task's bounded queue. */
|
|
637
|
+
"task/message": {
|
|
638
|
+
turn: number;
|
|
639
|
+
step: number;
|
|
640
|
+
occurrenceId: string;
|
|
641
|
+
taskId: string;
|
|
642
|
+
message: string;
|
|
643
|
+
};
|
|
644
|
+
/**
|
|
645
|
+
* A task reached its one terminal state. It carries no `turn`: a background
|
|
646
|
+
* task settles after the Turn that dispatched it is over, so this is durable
|
|
647
|
+
* Bot history rather than a step of any Turn — the `bot/renamed` shape.
|
|
648
|
+
*/
|
|
649
|
+
"task/settled": {
|
|
650
|
+
taskId: string;
|
|
651
|
+
status: "completed" | "failed" | "stopped";
|
|
652
|
+
summary?: string;
|
|
653
|
+
};
|
|
654
|
+
/**
|
|
655
|
+
* Explicit authenticated cancellation of a task, recorded before the child
|
|
656
|
+
* is asked to stop. `requestedBy` says which door it came through: the Bot's
|
|
657
|
+
* own `task_stop`, or the User's `POST /tasks/:taskId/stop`.
|
|
658
|
+
*/
|
|
659
|
+
"task/stopped": {
|
|
660
|
+
taskId: string;
|
|
661
|
+
requestedBy: "bot" | "user";
|
|
662
|
+
};
|
|
663
|
+
"step/end": { turn: number; step: number; outcome: StepOutcome };
|
|
664
|
+
/**
|
|
665
|
+
* `reason` states why a Turn ended in a non-`completed` outcome, so the
|
|
666
|
+
* failure a User sees names its cause instead of only its outcome. It is
|
|
667
|
+
* absent on a `completed` Turn and bounded to
|
|
668
|
+
* {@link TURN_END_REASON_MAX_LENGTH} characters.
|
|
669
|
+
*/
|
|
670
|
+
"turn/end": { turn: number; outcome: TurnOutcome; reason?: string };
|
|
671
|
+
"session/disposed": { disposedAt: string };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function eventRecord(value: unknown, label: string): Record<string, unknown> {
|
|
675
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
676
|
+
throw new Error(`${label} must be an object`);
|
|
677
|
+
}
|
|
678
|
+
return value as Record<string, unknown>;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function requireEventKeys(
|
|
682
|
+
value: Record<string, unknown>,
|
|
683
|
+
keys: readonly string[],
|
|
684
|
+
label: string,
|
|
685
|
+
): void {
|
|
686
|
+
if (
|
|
687
|
+
Object.keys(value).length !== keys.length ||
|
|
688
|
+
!keys.every((key) => Object.hasOwn(value, key))
|
|
689
|
+
) {
|
|
690
|
+
throw new Error(`${label} has invalid fields`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function eventString(
|
|
695
|
+
value: unknown,
|
|
696
|
+
label: string,
|
|
697
|
+
allowEmpty = false,
|
|
698
|
+
): string {
|
|
699
|
+
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
|
|
700
|
+
throw new Error(`${label} must be a string`);
|
|
701
|
+
}
|
|
702
|
+
return value;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function memoryScope(value: unknown, label: string): void {
|
|
706
|
+
if (value !== "bot" && value !== "user" && value !== "project") {
|
|
707
|
+
throw new Error(`${label} is invalid`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function memoryTier(value: unknown, label: string): void {
|
|
712
|
+
if (value !== "profile" && value !== "log" && value !== "note") {
|
|
713
|
+
throw new Error(`${label} is invalid`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function memoryAction(value: unknown, label: string): void {
|
|
718
|
+
if (value !== "write" && value !== "forget") {
|
|
719
|
+
throw new Error(`${label} is invalid`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function memoryProjectAction(value: unknown, label: string): void {
|
|
724
|
+
if (value !== "create" && value !== "join" && value !== "leave") {
|
|
725
|
+
throw new Error(`${label} is invalid`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function eventTimestamp(value: unknown, label: string): string {
|
|
730
|
+
const timestamp = eventString(value, label);
|
|
731
|
+
if (!Number.isFinite(Date.parse(timestamp))) {
|
|
732
|
+
throw new Error(`${label} must be a timestamp`);
|
|
733
|
+
}
|
|
734
|
+
return timestamp;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function eventInteger(value: unknown, label: string, minimum: number): number {
|
|
738
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
|
739
|
+
throw new Error(`${label} must be an integer`);
|
|
740
|
+
}
|
|
741
|
+
return value as number;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function requireJsonValue(value: unknown, label: string, depth = 0): void {
|
|
745
|
+
if (depth > 32) throw new Error(`${label} is too deeply nested`);
|
|
746
|
+
if (
|
|
747
|
+
value === null ||
|
|
748
|
+
typeof value === "string" ||
|
|
749
|
+
typeof value === "boolean" ||
|
|
750
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
751
|
+
) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (Array.isArray(value)) {
|
|
755
|
+
for (const entry of value) requireJsonValue(entry, label, depth + 1);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const record = eventRecord(value, label);
|
|
759
|
+
for (const entry of Object.values(record)) {
|
|
760
|
+
requireJsonValue(entry, label, depth + 1);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function requireToolCall(value: unknown, label: string): void {
|
|
765
|
+
const call = eventRecord(value, label);
|
|
766
|
+
requireEventKeys(call, ["id", "name", "input"], label);
|
|
767
|
+
eventString(call.id, `${label}.id`);
|
|
768
|
+
eventString(call.name, `${label}.name`);
|
|
769
|
+
requireJsonValue(call.input, `${label}.input`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function requireLlmMessage(value: unknown, label: string): void {
|
|
773
|
+
const message = eventRecord(value, label);
|
|
774
|
+
const role = eventString(message.role, `${label}.role`);
|
|
775
|
+
if (role === "user") {
|
|
776
|
+
requireEventKeys(message, ["role", "content"], label);
|
|
777
|
+
eventString(message.content, `${label}.content`, true);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (role === "assistant") {
|
|
781
|
+
requireEventKeys(message, ["role", "content", "toolCalls"], label);
|
|
782
|
+
eventString(message.content, `${label}.content`, true);
|
|
783
|
+
if (!Array.isArray(message.toolCalls)) {
|
|
784
|
+
throw new Error(`${label}.toolCalls must be an array`);
|
|
785
|
+
}
|
|
786
|
+
message.toolCalls.forEach((call, index) =>
|
|
787
|
+
requireToolCall(call, `${label}.toolCalls[${index}]`),
|
|
788
|
+
);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (role === "tool") {
|
|
792
|
+
requireEventKeys(
|
|
793
|
+
message,
|
|
794
|
+
[
|
|
795
|
+
"role",
|
|
796
|
+
"callId",
|
|
797
|
+
"name",
|
|
798
|
+
"content",
|
|
799
|
+
"isError",
|
|
800
|
+
...(Object.hasOwn(message, "attachments") ? ["attachments"] : []),
|
|
801
|
+
],
|
|
802
|
+
label,
|
|
803
|
+
);
|
|
804
|
+
if (message.attachments !== undefined) {
|
|
805
|
+
decodeToolAttachmentsV1(
|
|
806
|
+
message.attachments,
|
|
807
|
+
`${label}.attachments`,
|
|
808
|
+
false,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
eventString(message.callId, `${label}.callId`);
|
|
812
|
+
eventString(message.name, `${label}.name`);
|
|
813
|
+
eventString(message.content, `${label}.content`, true);
|
|
814
|
+
if (typeof message.isError !== "boolean") {
|
|
815
|
+
throw new Error(`${label}.isError must be a boolean`);
|
|
816
|
+
}
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
throw new Error(`${label}.role is invalid`);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* The exact v1 decoder for the attachments a tool result carries.
|
|
824
|
+
*
|
|
825
|
+
* `durable` refuses `dataBase64`: resolved bytes belong to one model request
|
|
826
|
+
* and never to the event log, so a record that carries them is a record that
|
|
827
|
+
* would grow without bound and is rejected at the seam rather than trimmed.
|
|
828
|
+
*/
|
|
829
|
+
export function decodeToolAttachmentsV1(
|
|
830
|
+
value: unknown,
|
|
831
|
+
label: string,
|
|
832
|
+
durable: boolean,
|
|
833
|
+
): ToolAttachmentV1[] {
|
|
834
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
835
|
+
if (value.length > TOOL_ATTACHMENT_LIMIT_V1) {
|
|
836
|
+
throw new Error(
|
|
837
|
+
`${label} must hold at most ${TOOL_ATTACHMENT_LIMIT_V1} attachments`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
return value.map((entry, index) => {
|
|
841
|
+
const item = `${label}[${index}]`;
|
|
842
|
+
const attachment = eventRecord(entry, item);
|
|
843
|
+
requireEventKeys(
|
|
844
|
+
attachment,
|
|
845
|
+
[
|
|
846
|
+
"kind",
|
|
847
|
+
"mediaType",
|
|
848
|
+
"workspacePath",
|
|
849
|
+
"contentHash",
|
|
850
|
+
"bytes",
|
|
851
|
+
...(Object.hasOwn(attachment, "dataBase64") ? ["dataBase64"] : []),
|
|
852
|
+
],
|
|
853
|
+
item,
|
|
854
|
+
);
|
|
855
|
+
if (attachment.kind !== "image") {
|
|
856
|
+
throw new Error(`${item}.kind is invalid`);
|
|
857
|
+
}
|
|
858
|
+
const mediaType = TOOL_ATTACHMENT_MEDIA_TYPES_V1.find(
|
|
859
|
+
(known) => known === attachment.mediaType,
|
|
860
|
+
);
|
|
861
|
+
if (!mediaType) throw new Error(`${item}.mediaType is invalid`);
|
|
862
|
+
if (
|
|
863
|
+
typeof attachment.bytes !== "number" ||
|
|
864
|
+
!Number.isSafeInteger(attachment.bytes) ||
|
|
865
|
+
attachment.bytes < 0
|
|
866
|
+
) {
|
|
867
|
+
throw new Error(`${item}.bytes must be a non-negative integer`);
|
|
868
|
+
}
|
|
869
|
+
const contentHash = eventString(
|
|
870
|
+
attachment.contentHash,
|
|
871
|
+
`${item}.contentHash`,
|
|
872
|
+
);
|
|
873
|
+
if (!/^[0-9a-f]{64}$/.test(contentHash)) {
|
|
874
|
+
throw new Error(`${item}.contentHash must be a sha-256 digest`);
|
|
875
|
+
}
|
|
876
|
+
if (durable && attachment.dataBase64 !== undefined) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`${item}.dataBase64 is never durable; the Workspace holds the bytes`,
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
kind: "image",
|
|
883
|
+
mediaType,
|
|
884
|
+
workspacePath: decodeWorkspacePathV1(
|
|
885
|
+
attachment.workspacePath,
|
|
886
|
+
`${item}.workspacePath`,
|
|
887
|
+
),
|
|
888
|
+
contentHash,
|
|
889
|
+
bytes: attachment.bytes,
|
|
890
|
+
...(attachment.dataBase64 === undefined
|
|
891
|
+
? {}
|
|
892
|
+
: {
|
|
893
|
+
dataBase64: eventString(
|
|
894
|
+
attachment.dataBase64,
|
|
895
|
+
`${item}.dataBase64`,
|
|
896
|
+
),
|
|
897
|
+
}),
|
|
898
|
+
} satisfies ToolAttachmentV1;
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function requireToolSchema(value: unknown, label: string): void {
|
|
903
|
+
const tool = eventRecord(value, label);
|
|
904
|
+
requireEventKeys(tool, ["name", "description", "inputSchema"], label);
|
|
905
|
+
eventString(tool.name, `${label}.name`);
|
|
906
|
+
eventString(tool.description, `${label}.description`, true);
|
|
907
|
+
const schema = eventRecord(tool.inputSchema, `${label}.inputSchema`);
|
|
908
|
+
requireJsonValue(schema, `${label}.inputSchema`);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* The exact v1 decoder for a normalized model request. Exported because the
|
|
913
|
+
* request crosses the Bot isolate boundary inbound — a Bot-authored model
|
|
914
|
+
* adapter composes it — and every inbound value is decoded at its seam.
|
|
915
|
+
*/
|
|
916
|
+
export function decodeNormalizedModelRequestV1(
|
|
917
|
+
value: unknown,
|
|
918
|
+
label = "normalized model request",
|
|
919
|
+
): NormalizedModelRequest {
|
|
920
|
+
requireNormalizedModelRequest(value, label);
|
|
921
|
+
// SAFETY: requireNormalizedModelRequest validated every field exactly.
|
|
922
|
+
return value as NormalizedModelRequest;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function requireNormalizedModelRequest(value: unknown, label: string): void {
|
|
926
|
+
const request = eventRecord(value, label);
|
|
927
|
+
requireEventKeys(
|
|
928
|
+
request,
|
|
929
|
+
[
|
|
930
|
+
"requestId",
|
|
931
|
+
"provider",
|
|
932
|
+
"model",
|
|
933
|
+
"system",
|
|
934
|
+
"messages",
|
|
935
|
+
"tools",
|
|
936
|
+
...(Object.hasOwn(request, "modelBinding") ? ["modelBinding"] : []),
|
|
937
|
+
],
|
|
938
|
+
label,
|
|
939
|
+
);
|
|
940
|
+
eventString(request.requestId, `${label}.requestId`);
|
|
941
|
+
eventString(request.provider, `${label}.provider`);
|
|
942
|
+
eventString(request.model, `${label}.model`);
|
|
943
|
+
eventString(request.system, `${label}.system`, true);
|
|
944
|
+
if (!Array.isArray(request.messages) || !Array.isArray(request.tools)) {
|
|
945
|
+
throw new Error(`${label} messages and tools must be arrays`);
|
|
946
|
+
}
|
|
947
|
+
request.messages.forEach((message, index) =>
|
|
948
|
+
requireLlmMessage(message, `${label}.messages[${index}]`),
|
|
949
|
+
);
|
|
950
|
+
request.tools.forEach((tool, index) =>
|
|
951
|
+
requireToolSchema(tool, `${label}.tools[${index}]`),
|
|
952
|
+
);
|
|
953
|
+
if (request.modelBinding !== undefined) {
|
|
954
|
+
const binding = eventRecord(request.modelBinding, `${label}.modelBinding`);
|
|
955
|
+
requireEventKeys(
|
|
956
|
+
binding,
|
|
957
|
+
[
|
|
958
|
+
"connectionId",
|
|
959
|
+
...(Object.hasOwn(binding, "connectionGeneration")
|
|
960
|
+
? ["connectionGeneration"]
|
|
961
|
+
: []),
|
|
962
|
+
...(Object.hasOwn(binding, "catalogGeneration")
|
|
963
|
+
? ["catalogGeneration"]
|
|
964
|
+
: []),
|
|
965
|
+
],
|
|
966
|
+
`${label}.modelBinding`,
|
|
967
|
+
);
|
|
968
|
+
eventString(binding.connectionId, `${label}.modelBinding.connectionId`);
|
|
969
|
+
if (binding.connectionGeneration !== undefined) {
|
|
970
|
+
eventString(
|
|
971
|
+
binding.connectionGeneration,
|
|
972
|
+
`${label}.modelBinding.connectionGeneration`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if (binding.catalogGeneration !== undefined) {
|
|
976
|
+
eventString(
|
|
977
|
+
binding.catalogGeneration,
|
|
978
|
+
`${label}.modelBinding.catalogGeneration`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const SESSION_EVENT_COMMON_KEYS = ["type", "seq", "timestamp"] as const;
|
|
985
|
+
|
|
986
|
+
export function decodeSessionEvent(input: unknown): SessionEvent {
|
|
987
|
+
const event = eventRecord(input, "session event");
|
|
988
|
+
const type = eventString(event.type, "session event.type");
|
|
989
|
+
eventInteger(event.seq, "session event.seq", 0);
|
|
990
|
+
eventTimestamp(event.timestamp, "session event.timestamp");
|
|
991
|
+
const keys = (...specific: string[]) => [
|
|
992
|
+
...SESSION_EVENT_COMMON_KEYS,
|
|
993
|
+
...specific,
|
|
994
|
+
];
|
|
995
|
+
const turn = () => eventInteger(event.turn, "session event.turn", 1);
|
|
996
|
+
const step = () => eventInteger(event.step, "session event.step", 1);
|
|
997
|
+
const text = () => eventString(event.text, "session event.text", true);
|
|
998
|
+
const requestId = () =>
|
|
999
|
+
eventString(event.requestId, "session event.requestId");
|
|
1000
|
+
switch (type) {
|
|
1001
|
+
case "session/created":
|
|
1002
|
+
requireEventKeys(event, keys("createdAt"), "session event");
|
|
1003
|
+
eventTimestamp(event.createdAt, "session event.createdAt");
|
|
1004
|
+
break;
|
|
1005
|
+
case "input/queued":
|
|
1006
|
+
// Exact keys either way: an input that invoked no Skill carries no
|
|
1007
|
+
// `skills` field, and one that did carries a bounded, decoded list.
|
|
1008
|
+
requireEventKeys(
|
|
1009
|
+
event,
|
|
1010
|
+
event.skills === undefined
|
|
1011
|
+
? keys("messageId", "text")
|
|
1012
|
+
: keys("messageId", "text", "skills"),
|
|
1013
|
+
"session event",
|
|
1014
|
+
);
|
|
1015
|
+
eventString(event.messageId, "session event.messageId");
|
|
1016
|
+
text();
|
|
1017
|
+
if (event.skills !== undefined) {
|
|
1018
|
+
decodeSkillRefsV1(event.skills, "session event.skills");
|
|
1019
|
+
}
|
|
1020
|
+
break;
|
|
1021
|
+
case "input/admitted":
|
|
1022
|
+
requireEventKeys(event, keys("messageId", "turn"), "session event");
|
|
1023
|
+
eventString(event.messageId, "session event.messageId");
|
|
1024
|
+
turn();
|
|
1025
|
+
break;
|
|
1026
|
+
case "input/cancelled":
|
|
1027
|
+
requireEventKeys(event, keys("messageId", "reason"), "session event");
|
|
1028
|
+
eventString(event.messageId, "session event.messageId");
|
|
1029
|
+
if (event.reason !== "user" && event.reason !== "shutdown") {
|
|
1030
|
+
throw new Error("session event.reason is invalid");
|
|
1031
|
+
}
|
|
1032
|
+
break;
|
|
1033
|
+
case "turn/start":
|
|
1034
|
+
requireEventKeys(event, keys("turn"), "session event");
|
|
1035
|
+
turn();
|
|
1036
|
+
break;
|
|
1037
|
+
case "composition/pinned":
|
|
1038
|
+
requireEventKeys(
|
|
1039
|
+
event,
|
|
1040
|
+
keys("turn", "generationId", "artifactSetHash"),
|
|
1041
|
+
"session event",
|
|
1042
|
+
);
|
|
1043
|
+
turn();
|
|
1044
|
+
eventString(event.generationId, "session event.generationId");
|
|
1045
|
+
eventString(event.artifactSetHash, "session event.artifactSetHash");
|
|
1046
|
+
break;
|
|
1047
|
+
case "turn/admission":
|
|
1048
|
+
requireEventKeys(event, keys("turn", "turnType"), "session event");
|
|
1049
|
+
turn();
|
|
1050
|
+
decodeTurnTypeV1(event.turnType, "session event.turnType");
|
|
1051
|
+
break;
|
|
1052
|
+
case "send/to-user":
|
|
1053
|
+
requireEventKeys(
|
|
1054
|
+
event,
|
|
1055
|
+
keys("turn", "step", "occurrenceId", "payload"),
|
|
1056
|
+
"session event",
|
|
1057
|
+
);
|
|
1058
|
+
turn();
|
|
1059
|
+
step();
|
|
1060
|
+
eventString(event.occurrenceId, "session event.occurrenceId");
|
|
1061
|
+
decodeSendToUserPayloadV1(event.payload, "session event.payload");
|
|
1062
|
+
break;
|
|
1063
|
+
case "wake/parent":
|
|
1064
|
+
requireEventKeys(
|
|
1065
|
+
event,
|
|
1066
|
+
keys("turn", "step", "occurrenceId", "message"),
|
|
1067
|
+
"session event",
|
|
1068
|
+
);
|
|
1069
|
+
turn();
|
|
1070
|
+
step();
|
|
1071
|
+
eventString(event.occurrenceId, "session event.occurrenceId");
|
|
1072
|
+
eventString(event.message, "session event.message");
|
|
1073
|
+
break;
|
|
1074
|
+
case "step/start":
|
|
1075
|
+
requireEventKeys(event, keys("turn", "step"), "session event");
|
|
1076
|
+
turn();
|
|
1077
|
+
step();
|
|
1078
|
+
break;
|
|
1079
|
+
case "user/message":
|
|
1080
|
+
requireEventKeys(
|
|
1081
|
+
event,
|
|
1082
|
+
keys("turn", "step", "messageId", "text"),
|
|
1083
|
+
"session event",
|
|
1084
|
+
);
|
|
1085
|
+
turn();
|
|
1086
|
+
step();
|
|
1087
|
+
eventString(event.messageId, "session event.messageId");
|
|
1088
|
+
text();
|
|
1089
|
+
break;
|
|
1090
|
+
case "model/request":
|
|
1091
|
+
requireEventKeys(event, keys("turn", "step", "request"), "session event");
|
|
1092
|
+
turn();
|
|
1093
|
+
step();
|
|
1094
|
+
requireNormalizedModelRequest(event.request, "session event.request");
|
|
1095
|
+
break;
|
|
1096
|
+
case "model/effect-not-started":
|
|
1097
|
+
case "model/reconciliation-required":
|
|
1098
|
+
requireEventKeys(
|
|
1099
|
+
event,
|
|
1100
|
+
keys("turn", "step", "requestId", "reason"),
|
|
1101
|
+
"session event",
|
|
1102
|
+
);
|
|
1103
|
+
turn();
|
|
1104
|
+
step();
|
|
1105
|
+
requestId();
|
|
1106
|
+
eventString(event.reason, "session event.reason");
|
|
1107
|
+
break;
|
|
1108
|
+
case "assistant/chunk":
|
|
1109
|
+
requireEventKeys(
|
|
1110
|
+
event,
|
|
1111
|
+
keys("turn", "step", "requestId", "text"),
|
|
1112
|
+
"session event",
|
|
1113
|
+
);
|
|
1114
|
+
turn();
|
|
1115
|
+
step();
|
|
1116
|
+
requestId();
|
|
1117
|
+
text();
|
|
1118
|
+
break;
|
|
1119
|
+
case "assistant/message":
|
|
1120
|
+
requireEventKeys(
|
|
1121
|
+
event,
|
|
1122
|
+
keys("turn", "step", "requestId", "text", "toolCalls"),
|
|
1123
|
+
"session event",
|
|
1124
|
+
);
|
|
1125
|
+
turn();
|
|
1126
|
+
step();
|
|
1127
|
+
requestId();
|
|
1128
|
+
text();
|
|
1129
|
+
if (!Array.isArray(event.toolCalls)) {
|
|
1130
|
+
throw new Error("session event.toolCalls must be an array");
|
|
1131
|
+
}
|
|
1132
|
+
event.toolCalls.forEach((call, index) =>
|
|
1133
|
+
requireToolCall(call, `session event.toolCalls[${index}]`),
|
|
1134
|
+
);
|
|
1135
|
+
break;
|
|
1136
|
+
case "tool/call":
|
|
1137
|
+
requireEventKeys(
|
|
1138
|
+
event,
|
|
1139
|
+
keys("turn", "step", "occurrenceId", "name", "input"),
|
|
1140
|
+
"session event",
|
|
1141
|
+
);
|
|
1142
|
+
turn();
|
|
1143
|
+
step();
|
|
1144
|
+
eventString(event.occurrenceId, "session event.occurrenceId");
|
|
1145
|
+
eventString(event.name, "session event.name");
|
|
1146
|
+
requireJsonValue(event.input, "session event.input");
|
|
1147
|
+
break;
|
|
1148
|
+
case "tool/result":
|
|
1149
|
+
requireEventKeys(
|
|
1150
|
+
event,
|
|
1151
|
+
keys(
|
|
1152
|
+
"turn",
|
|
1153
|
+
"step",
|
|
1154
|
+
"occurrenceId",
|
|
1155
|
+
"name",
|
|
1156
|
+
"content",
|
|
1157
|
+
"isError",
|
|
1158
|
+
"status",
|
|
1159
|
+
...(Object.hasOwn(event, "attachments") ? ["attachments"] : []),
|
|
1160
|
+
),
|
|
1161
|
+
"session event",
|
|
1162
|
+
);
|
|
1163
|
+
turn();
|
|
1164
|
+
step();
|
|
1165
|
+
eventString(event.occurrenceId, "session event.occurrenceId");
|
|
1166
|
+
eventString(event.name, "session event.name");
|
|
1167
|
+
eventString(event.content, "session event.content", true);
|
|
1168
|
+
if (typeof event.isError !== "boolean") {
|
|
1169
|
+
throw new Error("session event.isError must be a boolean");
|
|
1170
|
+
}
|
|
1171
|
+
if (event.status !== "completed" && event.status !== "interrupted") {
|
|
1172
|
+
throw new Error("session event.status is invalid");
|
|
1173
|
+
}
|
|
1174
|
+
if (event.attachments !== undefined) {
|
|
1175
|
+
decodeToolAttachmentsV1(
|
|
1176
|
+
event.attachments,
|
|
1177
|
+
"session event.attachments",
|
|
1178
|
+
true,
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
break;
|
|
1182
|
+
case "package/author-intent":
|
|
1183
|
+
requireEventKeys(
|
|
1184
|
+
event,
|
|
1185
|
+
keys("turn", "step", "effectId", "packageId", "sourceHash"),
|
|
1186
|
+
"session event",
|
|
1187
|
+
);
|
|
1188
|
+
turn();
|
|
1189
|
+
step();
|
|
1190
|
+
eventString(event.effectId, "session event.effectId");
|
|
1191
|
+
eventString(event.packageId, "session event.packageId");
|
|
1192
|
+
eventString(event.sourceHash, "session event.sourceHash");
|
|
1193
|
+
break;
|
|
1194
|
+
case "package/authored":
|
|
1195
|
+
requireEventKeys(
|
|
1196
|
+
event,
|
|
1197
|
+
keys(
|
|
1198
|
+
"turn",
|
|
1199
|
+
"step",
|
|
1200
|
+
"effectId",
|
|
1201
|
+
"packageId",
|
|
1202
|
+
"version",
|
|
1203
|
+
"contentHash",
|
|
1204
|
+
"generationId",
|
|
1205
|
+
),
|
|
1206
|
+
"session event",
|
|
1207
|
+
);
|
|
1208
|
+
turn();
|
|
1209
|
+
step();
|
|
1210
|
+
eventString(event.effectId, "session event.effectId");
|
|
1211
|
+
eventString(event.packageId, "session event.packageId");
|
|
1212
|
+
eventString(event.version, "session event.version");
|
|
1213
|
+
eventString(event.contentHash, "session event.contentHash");
|
|
1214
|
+
eventString(event.generationId, "session event.generationId");
|
|
1215
|
+
break;
|
|
1216
|
+
case "skill/injected": {
|
|
1217
|
+
requireEventKeys(
|
|
1218
|
+
event,
|
|
1219
|
+
keys("turn", "skills", "refusals"),
|
|
1220
|
+
"session event",
|
|
1221
|
+
);
|
|
1222
|
+
turn();
|
|
1223
|
+
if (!Array.isArray(event.skills) || !Array.isArray(event.refusals)) {
|
|
1224
|
+
throw new Error("session event skills and refusals must be arrays");
|
|
1225
|
+
}
|
|
1226
|
+
event.skills.forEach((skill, index) => {
|
|
1227
|
+
const label = `session event.skills[${index}]`;
|
|
1228
|
+
const entry = eventRecord(skill, label);
|
|
1229
|
+
// Exact keys either way: a Skill the Bot wrote itself carries no `by`,
|
|
1230
|
+
// and one written by its User or another of its User's Bots carries
|
|
1231
|
+
// the attribution the catalog block renders.
|
|
1232
|
+
requireEventKeys(
|
|
1233
|
+
entry,
|
|
1234
|
+
entry.by === undefined
|
|
1235
|
+
? ["path", "name", "generationId", "contentHash"]
|
|
1236
|
+
: ["path", "name", "generationId", "contentHash", "by"],
|
|
1237
|
+
label,
|
|
1238
|
+
);
|
|
1239
|
+
eventString(entry.path, `${label}.path`);
|
|
1240
|
+
eventString(entry.name, `${label}.name`);
|
|
1241
|
+
eventString(entry.generationId, `${label}.generationId`);
|
|
1242
|
+
eventString(entry.contentHash, `${label}.contentHash`);
|
|
1243
|
+
if (entry.by !== undefined) eventString(entry.by, `${label}.by`);
|
|
1244
|
+
});
|
|
1245
|
+
event.refusals.forEach((refusal, index) => {
|
|
1246
|
+
const label = `session event.refusals[${index}]`;
|
|
1247
|
+
const entry = eventRecord(refusal, label);
|
|
1248
|
+
requireEventKeys(entry, ["path", "reason"], label);
|
|
1249
|
+
eventString(entry.path, `${label}.path`);
|
|
1250
|
+
eventString(entry.reason, `${label}.reason`);
|
|
1251
|
+
});
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
case "skill/invoked":
|
|
1255
|
+
requireEventKeys(
|
|
1256
|
+
event,
|
|
1257
|
+
keys("turn", "ref", "generationId", "contentHash"),
|
|
1258
|
+
"session event",
|
|
1259
|
+
);
|
|
1260
|
+
turn();
|
|
1261
|
+
decodeSkillRefV1(event.ref, "session event.ref");
|
|
1262
|
+
eventString(event.generationId, "session event.generationId");
|
|
1263
|
+
eventString(event.contentHash, "session event.contentHash");
|
|
1264
|
+
break;
|
|
1265
|
+
case "skill/write-intent":
|
|
1266
|
+
requireEventKeys(
|
|
1267
|
+
event,
|
|
1268
|
+
keys("turn", "step", "effectId", "path", "contentHash"),
|
|
1269
|
+
"session event",
|
|
1270
|
+
);
|
|
1271
|
+
turn();
|
|
1272
|
+
step();
|
|
1273
|
+
eventString(event.effectId, "session event.effectId");
|
|
1274
|
+
eventString(event.path, "session event.path");
|
|
1275
|
+
eventString(event.contentHash, "session event.contentHash");
|
|
1276
|
+
break;
|
|
1277
|
+
case "skill/written":
|
|
1278
|
+
requireEventKeys(
|
|
1279
|
+
event,
|
|
1280
|
+
keys("turn", "step", "effectId", "path", "generationId", "contentHash"),
|
|
1281
|
+
"session event",
|
|
1282
|
+
);
|
|
1283
|
+
turn();
|
|
1284
|
+
step();
|
|
1285
|
+
eventString(event.effectId, "session event.effectId");
|
|
1286
|
+
eventString(event.path, "session event.path");
|
|
1287
|
+
eventString(event.generationId, "session event.generationId");
|
|
1288
|
+
eventString(event.contentHash, "session event.contentHash");
|
|
1289
|
+
break;
|
|
1290
|
+
case "memory/injected": {
|
|
1291
|
+
requireEventKeys(
|
|
1292
|
+
event,
|
|
1293
|
+
keys(
|
|
1294
|
+
"turn",
|
|
1295
|
+
"sources",
|
|
1296
|
+
"facts",
|
|
1297
|
+
"omissions",
|
|
1298
|
+
// The fade's bookkeeping arrived after the event did, so a session
|
|
1299
|
+
// logged before it still decodes: absent means no fade ran.
|
|
1300
|
+
...(Object.hasOwn(event, "faded") ? ["faded"] : []),
|
|
1301
|
+
...(Object.hasOwn(event, "noteCutoff") ? ["noteCutoff"] : []),
|
|
1302
|
+
...(Object.hasOwn(event, "noteTtlDays") ? ["noteTtlDays"] : []),
|
|
1303
|
+
),
|
|
1304
|
+
"session event",
|
|
1305
|
+
);
|
|
1306
|
+
turn();
|
|
1307
|
+
if (
|
|
1308
|
+
!Array.isArray(event.sources) ||
|
|
1309
|
+
!Array.isArray(event.facts) ||
|
|
1310
|
+
!Array.isArray(event.omissions)
|
|
1311
|
+
) {
|
|
1312
|
+
throw new Error(
|
|
1313
|
+
"session event sources, facts and omissions must be arrays",
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
event.sources.forEach((source, index) => {
|
|
1317
|
+
const label = `session event.sources[${index}]`;
|
|
1318
|
+
const entry = eventRecord(source, label);
|
|
1319
|
+
requireEventKeys(
|
|
1320
|
+
entry,
|
|
1321
|
+
["scope", "projectId", "path", "generationId", "contentHash"],
|
|
1322
|
+
label,
|
|
1323
|
+
);
|
|
1324
|
+
memoryScope(entry.scope, `${label}.scope`);
|
|
1325
|
+
eventString(entry.projectId, `${label}.projectId`, true);
|
|
1326
|
+
eventString(entry.path, `${label}.path`);
|
|
1327
|
+
eventString(entry.generationId, `${label}.generationId`);
|
|
1328
|
+
eventString(entry.contentHash, `${label}.contentHash`);
|
|
1329
|
+
});
|
|
1330
|
+
event.facts.forEach((fact, index) => {
|
|
1331
|
+
const label = `session event.facts[${index}]`;
|
|
1332
|
+
const entry = eventRecord(fact, label);
|
|
1333
|
+
requireEventKeys(
|
|
1334
|
+
entry,
|
|
1335
|
+
["scope", "projectId", "tier", "via", "learnedAt", "text"],
|
|
1336
|
+
label,
|
|
1337
|
+
);
|
|
1338
|
+
memoryScope(entry.scope, `${label}.scope`);
|
|
1339
|
+
eventString(entry.projectId, `${label}.projectId`, true);
|
|
1340
|
+
if (entry.tier !== "profile" && entry.tier !== "log") {
|
|
1341
|
+
throw new Error(`${label}.tier is invalid`);
|
|
1342
|
+
}
|
|
1343
|
+
eventString(entry.via, `${label}.via`, true);
|
|
1344
|
+
eventString(entry.learnedAt, `${label}.learnedAt`);
|
|
1345
|
+
eventString(entry.text, `${label}.text`);
|
|
1346
|
+
});
|
|
1347
|
+
event.omissions.forEach((omission, index) => {
|
|
1348
|
+
const label = `session event.omissions[${index}]`;
|
|
1349
|
+
const entry = eventRecord(omission, label);
|
|
1350
|
+
requireEventKeys(entry, ["scope", "reason"], label);
|
|
1351
|
+
memoryScope(entry.scope, `${label}.scope`);
|
|
1352
|
+
eventString(entry.reason, `${label}.reason`);
|
|
1353
|
+
});
|
|
1354
|
+
if (Object.hasOwn(event, "faded")) {
|
|
1355
|
+
if (!Array.isArray(event.faded)) {
|
|
1356
|
+
throw new Error("session event faded must be an array");
|
|
1357
|
+
}
|
|
1358
|
+
event.faded.forEach((fade, index) => {
|
|
1359
|
+
const label = `session event.faded[${index}]`;
|
|
1360
|
+
const entry = eventRecord(fade, label);
|
|
1361
|
+
requireEventKeys(entry, ["scope", "projectId", "count"], label);
|
|
1362
|
+
memoryScope(entry.scope, `${label}.scope`);
|
|
1363
|
+
eventString(entry.projectId, `${label}.projectId`, true);
|
|
1364
|
+
eventInteger(entry.count, `${label}.count`, 1);
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
if (Object.hasOwn(event, "noteCutoff")) {
|
|
1368
|
+
eventString(event.noteCutoff, "session event.noteCutoff");
|
|
1369
|
+
}
|
|
1370
|
+
if (Object.hasOwn(event, "noteTtlDays")) {
|
|
1371
|
+
eventInteger(event.noteTtlDays, "session event.noteTtlDays", 1);
|
|
1372
|
+
}
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1375
|
+
case "memory/write-intent":
|
|
1376
|
+
requireEventKeys(
|
|
1377
|
+
event,
|
|
1378
|
+
keys(
|
|
1379
|
+
"turn",
|
|
1380
|
+
"step",
|
|
1381
|
+
"effectId",
|
|
1382
|
+
"action",
|
|
1383
|
+
"scope",
|
|
1384
|
+
"projectId",
|
|
1385
|
+
"tier",
|
|
1386
|
+
"path",
|
|
1387
|
+
"contentHash",
|
|
1388
|
+
),
|
|
1389
|
+
"session event",
|
|
1390
|
+
);
|
|
1391
|
+
turn();
|
|
1392
|
+
step();
|
|
1393
|
+
eventString(event.effectId, "session event.effectId");
|
|
1394
|
+
memoryAction(event.action, "session event.action");
|
|
1395
|
+
memoryScope(event.scope, "session event.scope");
|
|
1396
|
+
eventString(event.projectId, "session event.projectId", true);
|
|
1397
|
+
memoryTier(event.tier, "session event.tier");
|
|
1398
|
+
eventString(event.path, "session event.path");
|
|
1399
|
+
eventString(event.contentHash, "session event.contentHash");
|
|
1400
|
+
break;
|
|
1401
|
+
case "memory/written":
|
|
1402
|
+
requireEventKeys(
|
|
1403
|
+
event,
|
|
1404
|
+
keys(
|
|
1405
|
+
"turn",
|
|
1406
|
+
"step",
|
|
1407
|
+
"effectId",
|
|
1408
|
+
"action",
|
|
1409
|
+
"scope",
|
|
1410
|
+
"projectId",
|
|
1411
|
+
"tier",
|
|
1412
|
+
"path",
|
|
1413
|
+
"generationId",
|
|
1414
|
+
"contentHash",
|
|
1415
|
+
),
|
|
1416
|
+
"session event",
|
|
1417
|
+
);
|
|
1418
|
+
turn();
|
|
1419
|
+
step();
|
|
1420
|
+
eventString(event.effectId, "session event.effectId");
|
|
1421
|
+
memoryAction(event.action, "session event.action");
|
|
1422
|
+
memoryScope(event.scope, "session event.scope");
|
|
1423
|
+
eventString(event.projectId, "session event.projectId", true);
|
|
1424
|
+
memoryTier(event.tier, "session event.tier");
|
|
1425
|
+
eventString(event.path, "session event.path");
|
|
1426
|
+
eventString(event.generationId, "session event.generationId");
|
|
1427
|
+
eventString(event.contentHash, "session event.contentHash");
|
|
1428
|
+
break;
|
|
1429
|
+
case "memory/project-intent":
|
|
1430
|
+
requireEventKeys(
|
|
1431
|
+
event,
|
|
1432
|
+
keys("turn", "step", "effectId", "action", "projectId"),
|
|
1433
|
+
"session event",
|
|
1434
|
+
);
|
|
1435
|
+
turn();
|
|
1436
|
+
step();
|
|
1437
|
+
eventString(event.effectId, "session event.effectId");
|
|
1438
|
+
memoryProjectAction(event.action, "session event.action");
|
|
1439
|
+
eventString(event.projectId, "session event.projectId");
|
|
1440
|
+
break;
|
|
1441
|
+
case "memory/project-changed":
|
|
1442
|
+
requireEventKeys(
|
|
1443
|
+
event,
|
|
1444
|
+
keys("turn", "step", "effectId", "action", "projectId", "projects"),
|
|
1445
|
+
"session event",
|
|
1446
|
+
);
|
|
1447
|
+
turn();
|
|
1448
|
+
step();
|
|
1449
|
+
eventString(event.effectId, "session event.effectId");
|
|
1450
|
+
memoryProjectAction(event.action, "session event.action");
|
|
1451
|
+
eventString(event.projectId, "session event.projectId");
|
|
1452
|
+
if (!Array.isArray(event.projects)) {
|
|
1453
|
+
throw new Error("session event.projects must be an array");
|
|
1454
|
+
}
|
|
1455
|
+
event.projects.forEach((project, index) =>
|
|
1456
|
+
eventString(project, `session event.projects[${index}]`),
|
|
1457
|
+
);
|
|
1458
|
+
break;
|
|
1459
|
+
case "image/generate-intent":
|
|
1460
|
+
requireEventKeys(
|
|
1461
|
+
event,
|
|
1462
|
+
keys(
|
|
1463
|
+
"turn",
|
|
1464
|
+
"step",
|
|
1465
|
+
"effectId",
|
|
1466
|
+
"model",
|
|
1467
|
+
"promptHash",
|
|
1468
|
+
"width",
|
|
1469
|
+
"height",
|
|
1470
|
+
),
|
|
1471
|
+
"session event",
|
|
1472
|
+
);
|
|
1473
|
+
turn();
|
|
1474
|
+
step();
|
|
1475
|
+
eventString(event.effectId, "session event.effectId");
|
|
1476
|
+
eventString(event.model, "session event.model");
|
|
1477
|
+
eventString(event.promptHash, "session event.promptHash");
|
|
1478
|
+
eventInteger(event.width, "session event.width", 1);
|
|
1479
|
+
eventInteger(event.height, "session event.height", 1);
|
|
1480
|
+
break;
|
|
1481
|
+
case "image/generated":
|
|
1482
|
+
requireEventKeys(
|
|
1483
|
+
event,
|
|
1484
|
+
keys(
|
|
1485
|
+
"turn",
|
|
1486
|
+
"step",
|
|
1487
|
+
"effectId",
|
|
1488
|
+
"model",
|
|
1489
|
+
"path",
|
|
1490
|
+
"generationId",
|
|
1491
|
+
"contentHash",
|
|
1492
|
+
"mimeType",
|
|
1493
|
+
"width",
|
|
1494
|
+
"height",
|
|
1495
|
+
),
|
|
1496
|
+
"session event",
|
|
1497
|
+
);
|
|
1498
|
+
turn();
|
|
1499
|
+
step();
|
|
1500
|
+
eventString(event.effectId, "session event.effectId");
|
|
1501
|
+
eventString(event.model, "session event.model");
|
|
1502
|
+
eventString(event.path, "session event.path");
|
|
1503
|
+
eventString(event.generationId, "session event.generationId");
|
|
1504
|
+
eventString(event.contentHash, "session event.contentHash");
|
|
1505
|
+
eventString(event.mimeType, "session event.mimeType");
|
|
1506
|
+
eventInteger(event.width, "session event.width", 1);
|
|
1507
|
+
eventInteger(event.height, "session event.height", 1);
|
|
1508
|
+
break;
|
|
1509
|
+
case "task/dispatched":
|
|
1510
|
+
requireEventKeys(
|
|
1511
|
+
event,
|
|
1512
|
+
keys(
|
|
1513
|
+
"turn",
|
|
1514
|
+
"step",
|
|
1515
|
+
"occurrenceId",
|
|
1516
|
+
"taskId",
|
|
1517
|
+
"taskType",
|
|
1518
|
+
"description",
|
|
1519
|
+
"model",
|
|
1520
|
+
"background",
|
|
1521
|
+
),
|
|
1522
|
+
"session event",
|
|
1523
|
+
);
|
|
1524
|
+
turn();
|
|
1525
|
+
step();
|
|
1526
|
+
eventString(event.occurrenceId, "session event.occurrenceId");
|
|
1527
|
+
eventString(event.taskId, "session event.taskId");
|
|
1528
|
+
eventString(event.taskType, "session event.taskType");
|
|
1529
|
+
eventString(event.description, "session event.description");
|
|
1530
|
+
eventString(event.model, "session event.model");
|
|
1531
|
+
if (typeof event.background !== "boolean") {
|
|
1532
|
+
throw new Error("session event.background must be a boolean");
|
|
1533
|
+
}
|
|
1534
|
+
break;
|
|
1535
|
+
case "task/message":
|
|
1536
|
+
requireEventKeys(
|
|
1537
|
+
event,
|
|
1538
|
+
keys("turn", "step", "occurrenceId", "taskId", "message"),
|
|
1539
|
+
"session event",
|
|
1540
|
+
);
|
|
1541
|
+
turn();
|
|
1542
|
+
step();
|
|
1543
|
+
eventString(event.occurrenceId, "session event.occurrenceId");
|
|
1544
|
+
eventString(event.taskId, "session event.taskId");
|
|
1545
|
+
eventString(event.message, "session event.message");
|
|
1546
|
+
break;
|
|
1547
|
+
case "task/settled":
|
|
1548
|
+
requireEventKeys(
|
|
1549
|
+
event,
|
|
1550
|
+
keys(
|
|
1551
|
+
"taskId",
|
|
1552
|
+
"status",
|
|
1553
|
+
...(Object.hasOwn(event, "summary") ? ["summary"] : []),
|
|
1554
|
+
),
|
|
1555
|
+
"session event",
|
|
1556
|
+
);
|
|
1557
|
+
eventString(event.taskId, "session event.taskId");
|
|
1558
|
+
if (
|
|
1559
|
+
event.status !== "completed" &&
|
|
1560
|
+
event.status !== "failed" &&
|
|
1561
|
+
event.status !== "stopped"
|
|
1562
|
+
) {
|
|
1563
|
+
throw new Error("session event.status is invalid");
|
|
1564
|
+
}
|
|
1565
|
+
if (event.summary !== undefined) {
|
|
1566
|
+
eventString(event.summary, "session event.summary");
|
|
1567
|
+
}
|
|
1568
|
+
break;
|
|
1569
|
+
case "task/stopped":
|
|
1570
|
+
requireEventKeys(event, keys("taskId", "requestedBy"), "session event");
|
|
1571
|
+
eventString(event.taskId, "session event.taskId");
|
|
1572
|
+
if (event.requestedBy !== "bot" && event.requestedBy !== "user") {
|
|
1573
|
+
throw new Error("session event.requestedBy is invalid");
|
|
1574
|
+
}
|
|
1575
|
+
break;
|
|
1576
|
+
case "computer/process": {
|
|
1577
|
+
requireEventKeys(
|
|
1578
|
+
event,
|
|
1579
|
+
keys(
|
|
1580
|
+
"turn",
|
|
1581
|
+
"processId",
|
|
1582
|
+
"action",
|
|
1583
|
+
"status",
|
|
1584
|
+
...(Object.hasOwn(event, "exitCode") ? ["exitCode"] : []),
|
|
1585
|
+
),
|
|
1586
|
+
"session event",
|
|
1587
|
+
);
|
|
1588
|
+
turn();
|
|
1589
|
+
eventString(event.processId, "session event.processId");
|
|
1590
|
+
if (
|
|
1591
|
+
event.action !== "launch" &&
|
|
1592
|
+
event.action !== "check" &&
|
|
1593
|
+
event.action !== "logs" &&
|
|
1594
|
+
event.action !== "stop"
|
|
1595
|
+
) {
|
|
1596
|
+
throw new Error("session event.action is invalid");
|
|
1597
|
+
}
|
|
1598
|
+
if (
|
|
1599
|
+
event.status !== "starting" &&
|
|
1600
|
+
event.status !== "running" &&
|
|
1601
|
+
event.status !== "exited" &&
|
|
1602
|
+
event.status !== "unknown"
|
|
1603
|
+
) {
|
|
1604
|
+
throw new Error("session event.status is invalid");
|
|
1605
|
+
}
|
|
1606
|
+
if (
|
|
1607
|
+
event.exitCode !== undefined &&
|
|
1608
|
+
(typeof event.exitCode !== "number" ||
|
|
1609
|
+
!Number.isSafeInteger(event.exitCode))
|
|
1610
|
+
) {
|
|
1611
|
+
throw new Error("session event.exitCode must be an integer");
|
|
1612
|
+
}
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1615
|
+
case "computer/injected": {
|
|
1616
|
+
const active = event.text !== "";
|
|
1617
|
+
requireEventKeys(
|
|
1618
|
+
event,
|
|
1619
|
+
keys("turn", "text", ...(active ? ["ownerId", "expiresAt"] : [])),
|
|
1620
|
+
"session event",
|
|
1621
|
+
);
|
|
1622
|
+
turn();
|
|
1623
|
+
eventString(event.text, "session event.text", true);
|
|
1624
|
+
if (active) {
|
|
1625
|
+
eventString(event.ownerId, "session event.ownerId");
|
|
1626
|
+
eventTimestamp(event.expiresAt, "session event.expiresAt");
|
|
1627
|
+
}
|
|
1628
|
+
break;
|
|
1629
|
+
}
|
|
1630
|
+
case "computer/sync": {
|
|
1631
|
+
requireEventKeys(
|
|
1632
|
+
event,
|
|
1633
|
+
keys(
|
|
1634
|
+
"turn",
|
|
1635
|
+
"reason",
|
|
1636
|
+
"status",
|
|
1637
|
+
"detail",
|
|
1638
|
+
"pulled",
|
|
1639
|
+
"pushed",
|
|
1640
|
+
"restored",
|
|
1641
|
+
"removed",
|
|
1642
|
+
"adopted",
|
|
1643
|
+
"conflicts",
|
|
1644
|
+
"failures",
|
|
1645
|
+
),
|
|
1646
|
+
"session event",
|
|
1647
|
+
);
|
|
1648
|
+
turn();
|
|
1649
|
+
if (!["open", "signal", "turn-end"].includes(event.reason as string)) {
|
|
1650
|
+
throw new Error("session event.reason is invalid");
|
|
1651
|
+
}
|
|
1652
|
+
if (
|
|
1653
|
+
!["ok", "unavailable", "refused", "skipped"].includes(
|
|
1654
|
+
event.status as string,
|
|
1655
|
+
)
|
|
1656
|
+
) {
|
|
1657
|
+
throw new Error("session event.status is invalid");
|
|
1658
|
+
}
|
|
1659
|
+
eventString(event.detail, "session event.detail", true);
|
|
1660
|
+
for (const field of [
|
|
1661
|
+
"pulled",
|
|
1662
|
+
"pushed",
|
|
1663
|
+
"restored",
|
|
1664
|
+
"removed",
|
|
1665
|
+
"adopted",
|
|
1666
|
+
"conflicts",
|
|
1667
|
+
"failures",
|
|
1668
|
+
] as const) {
|
|
1669
|
+
eventInteger(event[field], `session event.${field}`, 0);
|
|
1670
|
+
}
|
|
1671
|
+
break;
|
|
1672
|
+
}
|
|
1673
|
+
case "bot/renamed": {
|
|
1674
|
+
requireEventKeys(
|
|
1675
|
+
event,
|
|
1676
|
+
keys(
|
|
1677
|
+
"from",
|
|
1678
|
+
"to",
|
|
1679
|
+
"namedBy",
|
|
1680
|
+
...(Object.hasOwn(event, "writer") ? ["writer"] : []),
|
|
1681
|
+
),
|
|
1682
|
+
"session event",
|
|
1683
|
+
);
|
|
1684
|
+
eventString(event.from, "session event.from");
|
|
1685
|
+
eventString(event.to, "session event.to");
|
|
1686
|
+
if (event.namedBy !== "user" && event.namedBy !== "bot") {
|
|
1687
|
+
throw new Error("session event.namedBy is invalid");
|
|
1688
|
+
}
|
|
1689
|
+
if (event.writer !== undefined) {
|
|
1690
|
+
const writer = event.writer;
|
|
1691
|
+
if (
|
|
1692
|
+
typeof writer !== "object" ||
|
|
1693
|
+
writer === null ||
|
|
1694
|
+
Array.isArray(writer)
|
|
1695
|
+
) {
|
|
1696
|
+
throw new Error("session event.writer is invalid");
|
|
1697
|
+
}
|
|
1698
|
+
const fields = writer as Record<string, unknown>;
|
|
1699
|
+
requireEventKeys(
|
|
1700
|
+
fields,
|
|
1701
|
+
["kind", "botId", "sessionId", "turnId"],
|
|
1702
|
+
"session event.writer",
|
|
1703
|
+
);
|
|
1704
|
+
if (fields.kind !== "bot") {
|
|
1705
|
+
throw new Error("session event.writer.kind is invalid");
|
|
1706
|
+
}
|
|
1707
|
+
eventString(fields.botId, "session event.writer.botId");
|
|
1708
|
+
eventString(fields.sessionId, "session event.writer.sessionId");
|
|
1709
|
+
eventString(fields.turnId, "session event.writer.turnId");
|
|
1710
|
+
// Only a Bot writer exists, so a `user` provenance can never carry one.
|
|
1711
|
+
if (event.namedBy !== "bot") {
|
|
1712
|
+
throw new Error("session event.writer is invalid");
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
case "step/end":
|
|
1718
|
+
requireEventKeys(event, keys("turn", "step", "outcome"), "session event");
|
|
1719
|
+
turn();
|
|
1720
|
+
step();
|
|
1721
|
+
if (
|
|
1722
|
+
![
|
|
1723
|
+
"completed",
|
|
1724
|
+
"blocked",
|
|
1725
|
+
"cancelled",
|
|
1726
|
+
"interrupted",
|
|
1727
|
+
"model-error",
|
|
1728
|
+
"tool-error",
|
|
1729
|
+
].includes(event.outcome as string)
|
|
1730
|
+
) {
|
|
1731
|
+
throw new Error("session event.outcome is invalid");
|
|
1732
|
+
}
|
|
1733
|
+
break;
|
|
1734
|
+
case "turn/end":
|
|
1735
|
+
requireEventKeys(
|
|
1736
|
+
event,
|
|
1737
|
+
keys(
|
|
1738
|
+
"turn",
|
|
1739
|
+
"outcome",
|
|
1740
|
+
...(Object.hasOwn(event, "reason") ? ["reason"] : []),
|
|
1741
|
+
),
|
|
1742
|
+
"session event",
|
|
1743
|
+
);
|
|
1744
|
+
turn();
|
|
1745
|
+
if (event.reason !== undefined) {
|
|
1746
|
+
const reason = eventString(event.reason, "session event.reason");
|
|
1747
|
+
if (reason.length > TURN_END_REASON_MAX_LENGTH) {
|
|
1748
|
+
throw new Error("session event.reason is too long");
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
if (
|
|
1752
|
+
![
|
|
1753
|
+
"completed",
|
|
1754
|
+
"blocked",
|
|
1755
|
+
"cancelled",
|
|
1756
|
+
"interrupted",
|
|
1757
|
+
"model-error",
|
|
1758
|
+
"tool-error",
|
|
1759
|
+
].includes(event.outcome as string)
|
|
1760
|
+
) {
|
|
1761
|
+
throw new Error("session event.outcome is invalid");
|
|
1762
|
+
}
|
|
1763
|
+
break;
|
|
1764
|
+
case "session/disposed":
|
|
1765
|
+
requireEventKeys(event, keys("disposedAt"), "session event");
|
|
1766
|
+
eventTimestamp(event.disposedAt, "session event.disposedAt");
|
|
1767
|
+
break;
|
|
1768
|
+
default:
|
|
1769
|
+
throw new Error("session event.type is invalid");
|
|
1770
|
+
}
|
|
1771
|
+
// SAFETY: the exhaustive variant switch validates every SessionEvent field.
|
|
1772
|
+
return event as unknown as SessionEvent;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
export type SessionEventInput<
|
|
1776
|
+
T extends keyof SessionEventMap = keyof SessionEventMap,
|
|
1777
|
+
> = {
|
|
1778
|
+
[K in T]: { type: K } & SessionEventMap[K];
|
|
1779
|
+
}[T];
|
|
1780
|
+
|
|
1781
|
+
export type SessionEvent<
|
|
1782
|
+
T extends keyof SessionEventMap = keyof SessionEventMap,
|
|
1783
|
+
> = SessionEventInput<T> & {
|
|
1784
|
+
seq: number;
|
|
1785
|
+
timestamp: string;
|
|
1786
|
+
};
|
|
1787
|
+
|
|
1788
|
+
export interface SessionEventEnvelope {
|
|
1789
|
+
sessionId: string;
|
|
1790
|
+
event: SessionEvent;
|
|
1791
|
+
}
|