@frockbot/kernel-do 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,624 @@
1
+ import {
2
+ decodeSessionEvent,
3
+ decodeTurnTypeV1,
4
+ formatSkillRefV1,
5
+ type SessionEvent,
6
+ type SkillRefV1,
7
+ type TurnTypeV1,
8
+ } from "@frockbot/kernel-contracts";
9
+
10
+ /**
11
+ * The kernel records the Composition/configuration snapshot a Turn was admitted
12
+ * under, but never interprets it: the owning Package supplies the decoder.
13
+ */
14
+ export interface StoredRunCodecOptionsV1 {
15
+ decodeRunId(value: unknown): string;
16
+ decodeConfigurationSnapshot(value: unknown): unknown;
17
+ }
18
+
19
+ export interface StoredRunCodecV1<Snapshot> {
20
+ require(input: unknown): StoredRunV1<Snapshot>;
21
+ optional(input: unknown): StoredRunV1<Snapshot> | undefined;
22
+ }
23
+
24
+ export type StoredRunStatus =
25
+ "running" | "completed" | "failed" | "cancelled" | "reconciliation-required";
26
+
27
+ export type StoredEffectAdmissionOutcome = "admitted" | "fenced";
28
+
29
+ /** Durable linearization result for one exact provider or tool effect. */
30
+ export interface StoredEffectAdmission {
31
+ kind: "model" | "tool";
32
+ effectId: string;
33
+ outcome: StoredEffectAdmissionOutcome;
34
+ }
35
+
36
+ /** How a Turn that no person started came to be started. */
37
+ export type StoredRunTriggerV1 = "cron" | "webhook" | "integration" | "manual";
38
+
39
+ /** A Turn a Routine's firing produced. */
40
+ export interface StoredRunRoutineOriginV1 {
41
+ kind: "routine";
42
+ routineId: string;
43
+ fireId: string;
44
+ trigger: StoredRunTriggerV1;
45
+ }
46
+
47
+ /**
48
+ * A Turn a parent Turn dispatched as a subagent task.
49
+ *
50
+ * It is recorded in the *child* object: the Subagent Durable Object runs the
51
+ * Turn, and this is how the run it wrote says whose task it was and which of
52
+ * the parent's runs asked for it. The parent's own authority — the task record,
53
+ * the bounds, the terminal outcome — lives in the parent object and never here.
54
+ */
55
+ export interface StoredRunSubagentOriginV1 {
56
+ kind: "subagent";
57
+ taskId: string;
58
+ parentRunId: string;
59
+ }
60
+
61
+ /** What produced a Turn, when it was not a person speaking to the Bot. */
62
+ export type StoredRunOriginV1 =
63
+ StoredRunRoutineOriginV1 | StoredRunSubagentOriginV1;
64
+
65
+ const STORED_RUN_ORIGIN_TRIGGERS: readonly StoredRunTriggerV1[] = [
66
+ "cron",
67
+ "webhook",
68
+ "integration",
69
+ "manual",
70
+ ];
71
+
72
+ /**
73
+ * The turn type an admitted run was accepted as, and what produced it,
74
+ * recorded so recovery after eviction re-mounts the same catalog and the firing
75
+ * stays attributable. Absent means `chat` with no recorded origin: it is
76
+ * written only for a Turn that has one, so a record admitted before turn
77
+ * admission existed and a chat record written after it are byte-for-byte the
78
+ * same.
79
+ */
80
+ export interface StoredRunAdmissionV1 {
81
+ schemaVersion: 1;
82
+ turnType: TurnTypeV1;
83
+ /**
84
+ * The subagent role the Turn was admitted under, when it had one. Recorded
85
+ * for the same reason the turn type is: recovery after eviction has to
86
+ * re-mount the *same* catalog, and the role is half of what selects it.
87
+ */
88
+ subagentRole?: string;
89
+ origin?: StoredRunOriginV1;
90
+ }
91
+
92
+ export type StoredRunPhase =
93
+ "admitted" | "executing" | "reconciliation-required";
94
+
95
+ export interface StoredRunV1<Snapshot = unknown> {
96
+ runId: string;
97
+ commandFingerprint: string;
98
+ sessionId: string;
99
+ acceptedAt: string;
100
+ input: string;
101
+ events: SessionEvent[];
102
+ effectAdmissions: StoredEffectAdmission[];
103
+ status: StoredRunStatus;
104
+ responseText?: string;
105
+ failure?: string;
106
+ phase: StoredRunPhase;
107
+ /** Durable Stop intent; orthogonal to status and phase. */
108
+ stopRequestedAt?: string;
109
+ /** The Composition generation pinned in the same transaction that admitted the run. */
110
+ compositionGenerationId: string;
111
+ configurationSnapshot: Snapshot;
112
+ previousEventCount: number;
113
+ /** Absent ⇒ the run was admitted as a `chat` Turn. */
114
+ admission?: StoredRunAdmissionV1;
115
+ }
116
+
117
+ /** The subagent role a stored run re-mounts under, if any. */
118
+ export function storedRunSubagentRoleV1(run: {
119
+ admission?: StoredRunAdmissionV1;
120
+ }): string | undefined {
121
+ return run.admission?.subagentRole;
122
+ }
123
+
124
+ /** The turn type a stored run re-mounts on. */
125
+ export function storedRunTurnTypeV1(run: {
126
+ admission?: StoredRunAdmissionV1;
127
+ }): TurnTypeV1 {
128
+ return run.admission?.turnType ?? "chat";
129
+ }
130
+
131
+ /**
132
+ * The `admission` field a Turn records — nothing at all for a chat Turn with
133
+ * no recorded origin, so no stored bytes change for the Turn every producer
134
+ * writes today.
135
+ */
136
+ export function storedRunAdmissionV1(
137
+ turnType: TurnTypeV1 | undefined,
138
+ origin?: StoredRunOriginV1,
139
+ subagentRole?: string,
140
+ ): { admission?: StoredRunAdmissionV1 } {
141
+ const admitted = turnType ?? "chat";
142
+ if (admitted === "chat" && origin === undefined && subagentRole === undefined)
143
+ return {};
144
+ return {
145
+ admission: {
146
+ schemaVersion: 1,
147
+ turnType: admitted,
148
+ ...(subagentRole ? { subagentRole } : {}),
149
+ ...(origin ? { origin } : {}),
150
+ },
151
+ };
152
+ }
153
+
154
+ /** How long a recorded subagent role may be. It is an opaque bounded string. */
155
+ const STORED_RUN_SUBAGENT_ROLE_MAX = 64;
156
+
157
+ const STORED_RUN_STATUSES: readonly StoredRunStatus[] = [
158
+ "running",
159
+ "completed",
160
+ "failed",
161
+ "cancelled",
162
+ "reconciliation-required",
163
+ ];
164
+ const STORED_RUN_PHASES: readonly StoredRunPhase[] = [
165
+ "admitted",
166
+ "executing",
167
+ "reconciliation-required",
168
+ ];
169
+ const STORED_RUN_REQUIRED_KEYS = [
170
+ "runId",
171
+ "commandFingerprint",
172
+ "sessionId",
173
+ "acceptedAt",
174
+ "input",
175
+ "events",
176
+ "effectAdmissions",
177
+ "status",
178
+ "phase",
179
+ "compositionGenerationId",
180
+ "configurationSnapshot",
181
+ "previousEventCount",
182
+ ] as const;
183
+ const STORED_RUN_OPTIONAL_KEYS = [
184
+ "responseText",
185
+ "failure",
186
+ "stopRequestedAt",
187
+ "admission",
188
+ ] as const;
189
+ const UTF8_ENCODER = new TextEncoder();
190
+
191
+ function boundedString(
192
+ value: unknown,
193
+ maximum: number,
194
+ allowEmpty = false,
195
+ ): value is string {
196
+ return (
197
+ typeof value === "string" &&
198
+ (allowEmpty || value.length > 0) &&
199
+ UTF8_ENCODER.encode(value).byteLength <= maximum
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Exact fields, per origin kind. Each kind gets its own branch rather than a
205
+ * union of optional fields: a `routine` origin carrying a `taskId` is not a
206
+ * record with a spare field, it is a record this codec has never written.
207
+ */
208
+ function requireExactOriginFields(
209
+ candidate: Record<PropertyKey, unknown>,
210
+ fields: readonly string[],
211
+ runId: string,
212
+ ): void {
213
+ const ownKeys = Reflect.ownKeys(candidate);
214
+ if (
215
+ ownKeys.length !== fields.length ||
216
+ Object.keys(candidate).length !== fields.length ||
217
+ !fields.every((key) => Object.hasOwn(candidate, key))
218
+ ) {
219
+ throw new Error(`run "${runId}" has invalid admission origin fields`);
220
+ }
221
+ }
222
+
223
+ function decodeStoredRunOrigin(
224
+ value: unknown,
225
+ runId: string,
226
+ ): StoredRunOriginV1 {
227
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
228
+ throw new Error(`run "${runId}" has invalid admission origin`);
229
+ }
230
+ const candidate = value as Record<PropertyKey, unknown>;
231
+ if (candidate.kind === "subagent") {
232
+ requireExactOriginFields(
233
+ candidate,
234
+ ["kind", "taskId", "parentRunId"],
235
+ runId,
236
+ );
237
+ if (
238
+ !boundedString(candidate.taskId, 256) ||
239
+ !boundedString(candidate.parentRunId, 256)
240
+ ) {
241
+ throw new Error(`run "${runId}" has an invalid admission origin id`);
242
+ }
243
+ return {
244
+ kind: "subagent",
245
+ taskId: candidate.taskId,
246
+ parentRunId: candidate.parentRunId,
247
+ };
248
+ }
249
+ if (candidate.kind !== "routine") {
250
+ throw new Error(`run "${runId}" has an invalid admission origin kind`);
251
+ }
252
+ requireExactOriginFields(
253
+ candidate,
254
+ ["kind", "routineId", "fireId", "trigger"],
255
+ runId,
256
+ );
257
+ const trigger = STORED_RUN_ORIGIN_TRIGGERS.find(
258
+ (value) => value === candidate.trigger,
259
+ );
260
+ if (!trigger) {
261
+ throw new Error(`run "${runId}" has an invalid admission origin trigger`);
262
+ }
263
+ if (
264
+ !boundedString(candidate.routineId, 256) ||
265
+ !boundedString(candidate.fireId, 256)
266
+ ) {
267
+ throw new Error(`run "${runId}" has an invalid admission origin id`);
268
+ }
269
+ return {
270
+ kind: "routine",
271
+ routineId: candidate.routineId as string,
272
+ fireId: candidate.fireId as string,
273
+ trigger,
274
+ };
275
+ }
276
+
277
+ function decodeStoredRunAdmission(
278
+ value: unknown,
279
+ runId: string,
280
+ ): StoredRunAdmissionV1 {
281
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
282
+ throw new Error(`run "${runId}" has invalid admission`);
283
+ }
284
+ const candidate = value as Record<PropertyKey, unknown>;
285
+ const allowed = new Set([
286
+ "schemaVersion",
287
+ "turnType",
288
+ "subagentRole",
289
+ "origin",
290
+ ]);
291
+ const ownKeys = Reflect.ownKeys(candidate);
292
+ if (
293
+ ownKeys.length !== Object.keys(candidate).length ||
294
+ ownKeys.some((key) => typeof key !== "string" || !allowed.has(key)) ||
295
+ !Object.hasOwn(candidate, "schemaVersion") ||
296
+ !Object.hasOwn(candidate, "turnType") ||
297
+ candidate.schemaVersion !== 1
298
+ ) {
299
+ throw new Error(`run "${runId}" has invalid admission fields`);
300
+ }
301
+ let turnType: TurnTypeV1;
302
+ try {
303
+ turnType = decodeTurnTypeV1(candidate.turnType);
304
+ } catch {
305
+ throw new Error(`run "${runId}" has an invalid admission turn type`);
306
+ }
307
+ if (
308
+ candidate.subagentRole !== undefined &&
309
+ (typeof candidate.subagentRole !== "string" ||
310
+ candidate.subagentRole.trim().length === 0 ||
311
+ candidate.subagentRole.length > STORED_RUN_SUBAGENT_ROLE_MAX)
312
+ ) {
313
+ throw new Error(`run "${runId}" has an invalid admission subagent role`);
314
+ }
315
+ return {
316
+ schemaVersion: 1,
317
+ turnType,
318
+ ...(candidate.subagentRole === undefined
319
+ ? {}
320
+ : { subagentRole: candidate.subagentRole as string }),
321
+ ...(candidate.origin === undefined
322
+ ? {}
323
+ : { origin: decodeStoredRunOrigin(candidate.origin, runId) }),
324
+ };
325
+ }
326
+
327
+ function decodeStoredRunEvents(value: unknown): SessionEvent[] {
328
+ if (!Array.isArray(value)) throw new Error("stored run has invalid events");
329
+ return value.map(decodeSessionEvent);
330
+ }
331
+
332
+ const STORED_EFFECT_ADMISSIONS_MAX = 256;
333
+ const STORED_EFFECT_ID_MAX_BYTES = 512;
334
+
335
+ function decodeStoredEffectAdmissions(value: unknown): StoredEffectAdmission[] {
336
+ if (!Array.isArray(value) || value.length > STORED_EFFECT_ADMISSIONS_MAX) {
337
+ throw new Error("stored run has invalid effect admissions");
338
+ }
339
+ const effectIds = new Set<string>();
340
+ return value.map((entry) => {
341
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
342
+ throw new Error("stored run has invalid effect admission");
343
+ }
344
+ const candidate = entry as Record<PropertyKey, unknown>;
345
+ const ownKeys = Reflect.ownKeys(candidate);
346
+ if (
347
+ ownKeys.length !== 3 ||
348
+ Object.keys(candidate).length !== 3 ||
349
+ !["kind", "effectId", "outcome"].every((key) =>
350
+ Object.hasOwn(candidate, key),
351
+ )
352
+ ) {
353
+ throw new Error("stored run has invalid effect admission fields");
354
+ }
355
+ if (candidate.kind !== "model" && candidate.kind !== "tool") {
356
+ throw new Error("stored run has invalid effect admission kind");
357
+ }
358
+ if (!boundedString(candidate.effectId, STORED_EFFECT_ID_MAX_BYTES)) {
359
+ throw new Error("stored run has invalid effect admission id");
360
+ }
361
+ if (candidate.outcome !== "admitted" && candidate.outcome !== "fenced") {
362
+ throw new Error("stored run has invalid effect admission outcome");
363
+ }
364
+ if (effectIds.has(candidate.effectId)) {
365
+ throw new Error("stored run has colliding effect admissions");
366
+ }
367
+ effectIds.add(candidate.effectId);
368
+ return {
369
+ kind: candidate.kind,
370
+ effectId: candidate.effectId,
371
+ outcome: candidate.outcome,
372
+ };
373
+ });
374
+ }
375
+
376
+ export function createStoredRunCodecV1<Snapshot>(
377
+ options: StoredRunCodecOptionsV1,
378
+ ): StoredRunCodecV1<Snapshot> {
379
+ const require = (input: unknown): StoredRunV1<Snapshot> =>
380
+ requireStoredRunRecordV1(input, options);
381
+ return {
382
+ require,
383
+ optional: (input) => (input === undefined ? undefined : require(input)),
384
+ };
385
+ }
386
+
387
+ function requireStoredRunRecordV1<Snapshot>(
388
+ input: unknown,
389
+ options: StoredRunCodecOptionsV1,
390
+ ): StoredRunV1<Snapshot> {
391
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
392
+ throw new Error("stored run is invalid");
393
+ }
394
+ const candidate = input as Record<PropertyKey, unknown>;
395
+ const allowed = new Set<string>([
396
+ ...STORED_RUN_REQUIRED_KEYS,
397
+ ...STORED_RUN_OPTIONAL_KEYS,
398
+ ]);
399
+ // Exact decoding: a symbol-keyed or non-enumerable own property is a field
400
+ // this record does not have, so it is rejected rather than ignored.
401
+ const enumerableKeys = Object.keys(candidate);
402
+ const ownKeys = Reflect.ownKeys(candidate);
403
+ if (
404
+ ownKeys.length !== enumerableKeys.length ||
405
+ ownKeys.some((key) => typeof key !== "string" || !allowed.has(key)) ||
406
+ !STORED_RUN_REQUIRED_KEYS.every((key) => Object.hasOwn(candidate, key))
407
+ ) {
408
+ throw new Error("stored run has invalid fields");
409
+ }
410
+ let runId: string;
411
+ try {
412
+ runId = options.decodeRunId(candidate.runId);
413
+ } catch {
414
+ throw new Error("stored run has invalid runId");
415
+ }
416
+ if (!boundedString(candidate.commandFingerprint, 65_536)) {
417
+ throw new Error(`run "${runId}" has no valid command fingerprint`);
418
+ }
419
+ if (!boundedString(candidate.sessionId, 257)) {
420
+ throw new Error(`run "${runId}" has no valid session id`);
421
+ }
422
+ if (
423
+ !boundedString(candidate.acceptedAt, 64) ||
424
+ !Number.isFinite(Date.parse(candidate.acceptedAt))
425
+ ) {
426
+ throw new Error(`run "${runId}" has no valid acceptance time`);
427
+ }
428
+ if (!boundedString(candidate.input, 32_000)) {
429
+ throw new Error(`run "${runId}" has no valid input`);
430
+ }
431
+ const events = decodeStoredRunEvents(candidate.events);
432
+ const effectAdmissions = decodeStoredEffectAdmissions(
433
+ candidate.effectAdmissions,
434
+ );
435
+ const status = STORED_RUN_STATUSES.find(
436
+ (value) => value === candidate.status,
437
+ );
438
+ if (!status) {
439
+ throw new Error(`run "${runId}" has no valid status`);
440
+ }
441
+ const phase = STORED_RUN_PHASES.find((value) => value === candidate.phase);
442
+ if (!phase) {
443
+ throw new Error(`run "${runId}" has no valid phase`);
444
+ }
445
+ if (!boundedString(candidate.compositionGenerationId, 256)) {
446
+ throw new Error(`run "${runId}" has no valid Composition generation`);
447
+ }
448
+ if (
449
+ !Number.isSafeInteger(candidate.previousEventCount) ||
450
+ (candidate.previousEventCount as number) < 0
451
+ ) {
452
+ throw new Error(`run "${runId}" has no valid previous event count`);
453
+ }
454
+ options.decodeConfigurationSnapshot(candidate.configurationSnapshot);
455
+ if (
456
+ candidate.responseText !== undefined &&
457
+ !boundedString(candidate.responseText, 64_000, true)
458
+ ) {
459
+ throw new Error(`run "${runId}" has invalid responseText`);
460
+ }
461
+ if (
462
+ candidate.failure !== undefined &&
463
+ !boundedString(candidate.failure, 8_000)
464
+ ) {
465
+ throw new Error(`run "${runId}" has invalid failure`);
466
+ }
467
+ if (
468
+ candidate.stopRequestedAt !== undefined &&
469
+ (!boundedString(candidate.stopRequestedAt, 64) ||
470
+ !Number.isFinite(Date.parse(candidate.stopRequestedAt as string)))
471
+ ) {
472
+ throw new Error(`run "${runId}" has invalid stopRequestedAt`);
473
+ }
474
+ if (
475
+ status === "completed"
476
+ ? candidate.responseText === undefined || candidate.failure !== undefined
477
+ : candidate.responseText !== undefined
478
+ ) {
479
+ throw new Error(`run "${runId}" has invalid completion fields`);
480
+ }
481
+ if (
482
+ status === "failed" || status === "reconciliation-required"
483
+ ? candidate.failure === undefined
484
+ : candidate.failure !== undefined
485
+ ) {
486
+ throw new Error(`run "${runId}" has invalid failure fields`);
487
+ }
488
+ if (status === "cancelled" && candidate.stopRequestedAt === undefined) {
489
+ throw new Error(`run "${runId}" has no durable stop intent`);
490
+ }
491
+ if (
492
+ (status === "reconciliation-required") !==
493
+ (phase === "reconciliation-required")
494
+ ) {
495
+ throw new Error(`run "${runId}" has inconsistent recovery state`);
496
+ }
497
+ return {
498
+ runId,
499
+ commandFingerprint: candidate.commandFingerprint,
500
+ sessionId: candidate.sessionId,
501
+ acceptedAt: candidate.acceptedAt,
502
+ input: candidate.input,
503
+ events,
504
+ effectAdmissions,
505
+ status,
506
+ phase,
507
+ compositionGenerationId: candidate.compositionGenerationId,
508
+ configurationSnapshot: candidate.configurationSnapshot as Snapshot,
509
+ previousEventCount: candidate.previousEventCount as number,
510
+ ...(candidate.responseText === undefined
511
+ ? {}
512
+ : { responseText: candidate.responseText as string }),
513
+ ...(candidate.failure === undefined
514
+ ? {}
515
+ : { failure: candidate.failure as string }),
516
+ ...(candidate.stopRequestedAt === undefined
517
+ ? {}
518
+ : { stopRequestedAt: candidate.stopRequestedAt as string }),
519
+ ...(candidate.admission === undefined
520
+ ? {}
521
+ : { admission: decodeStoredRunAdmission(candidate.admission, runId) }),
522
+ };
523
+ }
524
+
525
+ export interface BotTurnCommand {
526
+ runId: string;
527
+ sessionId: string;
528
+ acceptedAt: string;
529
+ text: string;
530
+ /**
531
+ * Absent ⇒ `chat`. Only an in-Durable-Object producer may name another type;
532
+ * the HTTP Turn path always admits `chat`.
533
+ */
534
+ turnType?: TurnTypeV1;
535
+ /**
536
+ * The subagent role this Turn is admitted under. In-Durable-Object producers
537
+ * only, and only ever on a `subagent` Turn.
538
+ */
539
+ subagentRole?: string;
540
+ /**
541
+ * What produced this Turn. In-Durable-Object producers only; the HTTP Turn
542
+ * path never forwards it.
543
+ */
544
+ origin?: StoredRunOriginV1;
545
+ /**
546
+ * The Skills the User invoked with this message. Part of the command's
547
+ * identity: the same text with a different Skill attached is a different
548
+ * command, so it must not collide on an idempotency record.
549
+ */
550
+ skills?: SkillRefV1[];
551
+ }
552
+
553
+ /**
554
+ * A chat command keeps the exact v1 fingerprint bytes, so idempotency records
555
+ * written before turn admission existed still match the same command after
556
+ * deploy. Only a Turn carrying a turn type or an origin — neither of which any
557
+ * producer could have written before — emits v2, where both are part of the
558
+ * identity of the command.
559
+ */
560
+ export function botTurnCommandFingerprintV1(
561
+ command: BotTurnCommand & { userId: string; botId: string },
562
+ ): string {
563
+ const turnType = command.turnType ?? "chat";
564
+ const skills = command.skills ?? [];
565
+ if (
566
+ turnType !== "chat" ||
567
+ command.origin !== undefined ||
568
+ command.subagentRole !== undefined ||
569
+ skills.length > 0
570
+ ) {
571
+ return `bot-turn-command-v2:${JSON.stringify({
572
+ userId: command.userId,
573
+ botId: command.botId,
574
+ sessionId: command.sessionId,
575
+ text: command.text,
576
+ turnType,
577
+ ...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
578
+ ...(command.origin ? { origin: command.origin } : {}),
579
+ ...(skills.length > 0 ? { skills: skills.map(formatSkillRefV1) } : {}),
580
+ })}`;
581
+ }
582
+ return `bot-turn-command-v1:${JSON.stringify({
583
+ userId: command.userId,
584
+ botId: command.botId,
585
+ sessionId: command.sessionId,
586
+ text: command.text,
587
+ })}`;
588
+ }
589
+
590
+ export interface BotStopCommand {
591
+ commandId: string;
592
+ runId: string;
593
+ }
594
+
595
+ export function botStopCommandFingerprintV1(
596
+ command: BotStopCommand & { userId: string; botId: string },
597
+ ): string {
598
+ return `bot-stop-command-v1:${JSON.stringify({
599
+ userId: command.userId,
600
+ botId: command.botId,
601
+ runId: command.runId,
602
+ })}`;
603
+ }
604
+
605
+ export interface BotNotificationIntent {
606
+ notificationId: string;
607
+ runId: string;
608
+ createdAt: string;
609
+ title: string;
610
+ body: string;
611
+ /**
612
+ * How loudly the User is told. `critical` is for an intent the Bot's own
613
+ * notification policy does not gate — a question that has stopped the Bot
614
+ * rather than an update about one that finished. Absent means `normal`.
615
+ */
616
+ urgency?: "normal" | "critical";
617
+ }
618
+
619
+ export interface BotTurnCompletion {
620
+ runId: string;
621
+ text: string;
622
+ events: SessionEvent[];
623
+ notification?: BotNotificationIntent;
624
+ }