@frockbot/kernel-contracts 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-contracts",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -71,6 +71,13 @@ describe("Applet directory entry v1", () => {
71
71
  expect(() =>
72
72
  decodeAppletDirectoryEntryV1({ ...entry, appletId: "todo" }),
73
73
  ).toThrow("appletId is invalid");
74
+ // The owner half is a real User id, which Better Auth mints in mixed case.
75
+ expect(
76
+ decodeAppletDirectoryEntryV1({
77
+ ...entry,
78
+ appletId: "vgpqfaCcwnPlzjYdb2mIfNcOW1YV0SkG.todo",
79
+ }).appletId,
80
+ ).toBe("vgpqfaCcwnPlzjYdb2mIfNcOW1YV0SkG.todo");
74
81
  expect(() =>
75
82
  decodeAppletDirectoryEntryV1({ ...entry, status: "archived" }),
76
83
  ).toThrow("status is invalid");
package/src/applets.ts CHANGED
@@ -206,7 +206,14 @@ export const APPLET_SOURCE_MAX_BYTES_V1 = 512 * 1024;
206
206
  export const APPLET_SOURCE_MAX_FILES_V1 = 256;
207
207
  export const APPLET_SOURCE_MAX_DIAGNOSTICS_V1 = 64;
208
208
 
209
- export const APPLET_ID_V1 = /^[a-z0-9][a-z0-9-]{0,63}\.[a-z0-9-]{1,64}$/;
209
+ /**
210
+ * `<ownerUserId>.<random>`. The owner half is a User id exactly as the auth
211
+ * layer mints it — Better Auth ids are mixed case (`vgpqfaCcwnPlzjYdb2mI…`),
212
+ * so the pattern matches `templateShareIdV1`'s owner half rather than a slug.
213
+ * A lowercase-only owner half locked every real account out of Applets
214
+ * while the lowercase test ids sailed through (Bob, 2026-09-04).
215
+ */
216
+ export const APPLET_ID_V1 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}\.[a-z0-9-]{1,64}$/;
210
217
  export const APPLET_TOOL_NAME_V1 = /^[a-z][a-z0-9_]{0,63}$/;
211
218
  export const APPLET_MAX_TOOLS_V1 = 64;
212
219
  export const APPLET_MAX_GENERATIONS_PAGE_V1 = 64;
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./remote.js";
11
11
  export * from "./send-to-user.js";
12
12
  export * from "./session.js";
13
13
  export * from "./skills.js";
14
+ export * from "./structured-output.js";
14
15
  export * from "./tool-execution.js";
15
16
  export * from "./turn-deadline.js";
16
17
  export * from "./turn-history.js";
package/src/isolate.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  type NormalizedModelRequest,
32
32
  type ToolSchema,
33
33
  } from "./types.js";
34
+ import { STRUCTURED_OUTPUT_ISSUE_LIMIT_V1 } from "./structured-output.js";
34
35
 
35
36
  /**
36
37
  * The wire contract version the kernel wrapper emits. Version 2 added
@@ -1038,7 +1039,9 @@ export function decodeIsolateScheduleRequestV1(
1038
1039
  };
1039
1040
  }
1040
1041
 
1041
- const APPLET_CAPABILITY_ID = /^[a-z0-9][a-z0-9-]{0,63}\.[a-z0-9-]{1,64}$/;
1042
+ // Same shape as `APPLET_ID_V1`: a mixed-case User id, then the random half.
1043
+ const APPLET_CAPABILITY_ID =
1044
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}\.[a-z0-9-]{1,64}$/;
1042
1045
 
1043
1046
  export function decodeIsolateAppletsRequestV1(
1044
1047
  input: unknown,
@@ -1343,6 +1346,98 @@ export function decodeIsolateModelEventV1(
1343
1346
  },
1344
1347
  };
1345
1348
  }
1349
+ if (value.type === "response-format-note") {
1350
+ exactKeys(value, ["type", "note"], label);
1351
+ const note = record(value.note, `${label}.note`);
1352
+ exactKeys(
1353
+ note,
1354
+ ["code", "requested", "effective", "message"],
1355
+ `${label}.note`,
1356
+ );
1357
+ if (
1358
+ note.code !== "structured-output-downgraded" ||
1359
+ (note.requested !== "json_schema" && note.requested !== "json") ||
1360
+ (note.effective !== "json" && note.effective !== "prompt")
1361
+ ) {
1362
+ throw new Error(`${label}.note is invalid`);
1363
+ }
1364
+ return {
1365
+ type: "response-format-note",
1366
+ note: {
1367
+ code: note.code,
1368
+ requested: note.requested,
1369
+ effective: note.effective,
1370
+ message: boundedString(note.message, `${label}.note.message`, 1_024),
1371
+ },
1372
+ };
1373
+ }
1374
+ if (value.type === "structured-output-failure") {
1375
+ exactKeys(value, ["type", "failure"], label);
1376
+ const failure = record(value.failure, `${label}.failure`);
1377
+ if (failure.code === "invalid-json") {
1378
+ exactKeys(failure, ["code", "message"], `${label}.failure`);
1379
+ return {
1380
+ type: "structured-output-failure",
1381
+ failure: {
1382
+ code: "invalid-json",
1383
+ message: boundedString(
1384
+ failure.message,
1385
+ `${label}.failure.message`,
1386
+ 1_024,
1387
+ ),
1388
+ },
1389
+ };
1390
+ }
1391
+ exactKeys(failure, ["code", "message", "issues"], `${label}.failure`);
1392
+ if (failure.code !== "schema-mismatch" || !Array.isArray(failure.issues)) {
1393
+ throw new Error(`${label}.failure is invalid`);
1394
+ }
1395
+ if (failure.issues.length > STRUCTURED_OUTPUT_ISSUE_LIMIT_V1) {
1396
+ throw new Error(`${label}.failure.issues exceeds its limit`);
1397
+ }
1398
+ return {
1399
+ type: "structured-output-failure",
1400
+ failure: {
1401
+ code: "schema-mismatch",
1402
+ message: boundedString(
1403
+ failure.message,
1404
+ `${label}.failure.message`,
1405
+ 1_024,
1406
+ ),
1407
+ issues: failure.issues.map((candidate, index) => {
1408
+ const issue = record(candidate, `${label}.failure.issues[${index}]`);
1409
+ exactKeys(
1410
+ issue,
1411
+ ["path", "code", "message"],
1412
+ `${label}.failure.issues[${index}]`,
1413
+ );
1414
+ if (
1415
+ issue.code !== "type" &&
1416
+ issue.code !== "enum" &&
1417
+ issue.code !== "required" &&
1418
+ issue.code !== "additional-property"
1419
+ ) {
1420
+ throw new Error(
1421
+ `${label}.failure.issues[${index}].code is invalid`,
1422
+ );
1423
+ }
1424
+ return {
1425
+ path: boundedString(
1426
+ issue.path,
1427
+ `${label}.failure.issues[${index}].path`,
1428
+ 1_024,
1429
+ ),
1430
+ code: issue.code,
1431
+ message: boundedString(
1432
+ issue.message,
1433
+ `${label}.failure.issues[${index}].message`,
1434
+ 1_024,
1435
+ ),
1436
+ };
1437
+ }),
1438
+ },
1439
+ };
1440
+ }
1346
1441
  throw new Error(`${label}.type is invalid`);
1347
1442
  }
1348
1443
 
@@ -1,6 +1,19 @@
1
1
  // Importing the augmented module is what merges these declarations into cordis.
2
2
  import type {} from "cordis";
3
3
  import type { LlmStreamEvent, NormalizedModelRequest } from "./types.js";
4
+ import type {
5
+ JsonSchemaResponseFormatV1,
6
+ ModelProviderSupportsV1,
7
+ StructuredOutputFailureV1,
8
+ StructuredModelResultV1,
9
+ } from "./structured-output.js";
10
+
11
+ export class StructuredOutputValidationError extends Error {
12
+ constructor(readonly failure: StructuredOutputFailureV1) {
13
+ super(failure.message);
14
+ this.name = "StructuredOutputValidationError";
15
+ }
16
+ }
4
17
 
5
18
  export interface DurableModelEffect {
6
19
  providerEffectId: string;
@@ -160,6 +173,8 @@ export interface LlmReconciliationCapability {
160
173
 
161
174
  export interface LlmProvider {
162
175
  id: string;
176
+ /** Legacy/test adapters that omit this are treated as supporting nothing. */
177
+ supports?: ModelProviderSupportsV1;
163
178
  stream(
164
179
  request: NormalizedModelRequest,
165
180
  signal: AbortSignal,
@@ -173,6 +188,11 @@ export interface ModelInvocation {
173
188
  request: NormalizedModelRequest,
174
189
  signal: AbortSignal,
175
190
  ): AsyncIterable<LlmStreamEvent>;
191
+ structured<T>(
192
+ request: NormalizedModelRequest,
193
+ format: Omit<JsonSchemaResponseFormatV1, "type">,
194
+ signal: AbortSignal,
195
+ ): Promise<StructuredModelResultV1<T>>;
176
196
  reconcile(
177
197
  request: NormalizedModelRequest,
178
198
  signal: AbortSignal,
@@ -14,6 +14,14 @@ export interface PromptAssemblyContext {
14
14
  * what an absent turn type meant.
15
15
  */
16
16
  turnType: TurnTypeV1;
17
+ /**
18
+ * Where this request sits in the Turn's step budget: `current` is the step
19
+ * being assembled (1-based) and `max` the last step the loop will run. A
20
+ * section can tell the model it is about to be stopped, which is how a
21
+ * long tool-driven reply learns to send a status instead of going silent.
22
+ * Absent when the assembler is not inside a Turn.
23
+ */
24
+ step?: { current: number; max: number };
17
25
  }
18
26
 
19
27
  /** What a host assembles as when it is not running an admitted Turn. */
@@ -75,6 +75,38 @@ test("a Bot-isolate hook failure is an exact durable session event", () => {
75
75
  );
76
76
  });
77
77
 
78
+ test("a degraded Computer sync records bounded exclusions and decodes legacy rows", () => {
79
+ const legacy = {
80
+ type: "computer/sync",
81
+ turn: 1,
82
+ reason: "open",
83
+ status: "ok",
84
+ detail: "",
85
+ pulled: 1,
86
+ pushed: 0,
87
+ restored: 0,
88
+ removed: 0,
89
+ adopted: 0,
90
+ conflicts: 0,
91
+ failures: 0,
92
+ seq: 0,
93
+ timestamp,
94
+ } as const;
95
+ expect(decodeSessionEvent(legacy)).toEqual(legacy);
96
+
97
+ const degraded = {
98
+ ...legacy,
99
+ status: "degraded",
100
+ detail: "Excluded 1 reproducible Workspace item from sync.",
101
+ ignored: 1,
102
+ omitted: 0,
103
+ } as const;
104
+ expect(decodeSessionEvent(degraded)).toEqual(degraded);
105
+ expect(() => decodeSessionEvent({ ...degraded, ignored: -1 })).toThrow(
106
+ /ignored/,
107
+ );
108
+ });
109
+
78
110
  test("a compaction is an exact durable session event", () => {
79
111
  const intent = {
80
112
  type: "conversation/compaction-intent",
package/src/session.ts CHANGED
@@ -216,8 +216,8 @@ export class Session {
216
216
  * Resolved attachment bytes, keyed by content hash, held only while this
217
217
  * Session is resident.
218
218
  *
219
- * The session event log is one Durable Object value and a screenshot in it
220
- * would be a durable record that grows past what the object can hold, so an
219
+ * A screenshot in the session log would multiply durable storage and prompt
220
+ * size even though the Bot authority pages and chunks large events, so an
221
221
  * attachment records a Workspace path and a content hash and nothing else.
222
222
  * A tool that produced the bytes offers them here, and the request derived
223
223
  * while they are still held carries them to a model that can see images. On
@@ -399,6 +399,9 @@ export class Session {
399
399
  if (event.type === "model/effect-not-started") {
400
400
  unresolvedModelRequests.delete(event.requestId);
401
401
  }
402
+ if (event.type === "model/response-failed") {
403
+ unresolvedModelRequests.delete(event.requestId);
404
+ }
402
405
  if (event.type === "assistant/message") {
403
406
  unresolvedModelRequests.delete(event.requestId);
404
407
  if (openStep?.turn === event.turn && openStep.step === event.step) {
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeModelResponseFormatV1,
4
+ decodeStructuredOutputSchemaV1,
5
+ parseStructuredOutputJsonV1,
6
+ validateStructuredOutputV1,
7
+ } from "./structured-output.js";
8
+
9
+ const schema = decodeStructuredOutputSchemaV1({
10
+ type: "object",
11
+ properties: {
12
+ name: { type: "string" },
13
+ scores: { type: "array", items: { type: "number" } },
14
+ active: { type: "boolean", enum: [true] },
15
+ },
16
+ required: ["name", "scores", "active"],
17
+ additionalProperties: false,
18
+ });
19
+
20
+ describe("the structured-output schema subset", () => {
21
+ test("accepts nested objects, arrays, primitives and enums", () => {
22
+ expect(
23
+ validateStructuredOutputV1(
24
+ '{"name":"Ada","scores":[1,2.5],"active":true}',
25
+ schema,
26
+ ),
27
+ ).toEqual({
28
+ status: "completed",
29
+ value: { name: "Ada", scores: [1, 2.5], active: true },
30
+ raw: '{"name":"Ada","scores":[1,2.5],"active":true}',
31
+ });
32
+ });
33
+
34
+ test("returns a typed JSON failure without throwing", () => {
35
+ expect(validateStructuredOutputV1("not json", schema)).toEqual({
36
+ status: "failed",
37
+ failure: {
38
+ code: "invalid-json",
39
+ message: "The model response was not valid JSON",
40
+ },
41
+ raw: "not json",
42
+ });
43
+ });
44
+
45
+ test("validates schema-free JSON mode", () => {
46
+ expect(parseStructuredOutputJsonV1('[1,"two",true]')).toEqual({
47
+ status: "completed",
48
+ value: [1, "two", true],
49
+ raw: '[1,"two",true]',
50
+ });
51
+ expect(parseStructuredOutputJsonV1("not json")).toMatchObject({
52
+ status: "failed",
53
+ failure: { code: "invalid-json" },
54
+ });
55
+ });
56
+
57
+ test("compares object enum members as JSON values, independent of key order", () => {
58
+ const enumSchema = decodeStructuredOutputSchemaV1({
59
+ type: "object",
60
+ enum: [{ first: 1, second: 2 }],
61
+ });
62
+ expect(
63
+ validateStructuredOutputV1('{"second":2,"first":1}', enumSchema),
64
+ ).toMatchObject({ status: "completed" });
65
+ });
66
+
67
+ test("reports required, type, enum and additional-property failures", () => {
68
+ const result = validateStructuredOutputV1(
69
+ '{"scores":[1,"two"],"active":false,"extra":1}',
70
+ schema,
71
+ );
72
+ expect(result.status).toBe("failed");
73
+ if (result.status === "completed") return;
74
+ expect(result.failure.code).toBe("schema-mismatch");
75
+ if (result.failure.code === "invalid-json") return;
76
+ expect(result.failure.issues.map((issue) => issue.code)).toEqual([
77
+ "required",
78
+ "type",
79
+ "enum",
80
+ "additional-property",
81
+ ]);
82
+ });
83
+
84
+ test("refuses unsupported schema dialect at the seam", () => {
85
+ expect(() =>
86
+ decodeStructuredOutputSchemaV1({ type: "string", minLength: 1 }),
87
+ ).toThrow("minLength is not supported");
88
+ expect(() =>
89
+ decodeStructuredOutputSchemaV1({
90
+ type: "object",
91
+ properties: {},
92
+ required: ["missing"],
93
+ }),
94
+ ).toThrow('required names unknown property "missing"');
95
+ });
96
+
97
+ test("strictly decodes the normalized response format", () => {
98
+ expect(
99
+ decodeModelResponseFormatV1({
100
+ type: "json_schema",
101
+ name: "answer_v1",
102
+ schema: { type: "string" },
103
+ }),
104
+ ).toEqual({
105
+ type: "json_schema",
106
+ name: "answer_v1",
107
+ schema: { type: "string" },
108
+ });
109
+ expect(() =>
110
+ decodeModelResponseFormatV1({
111
+ type: "json_schema",
112
+ name: "answer v1",
113
+ schema: { type: "string" },
114
+ }),
115
+ ).toThrow("1-64 letters");
116
+ });
117
+ });
@@ -0,0 +1,415 @@
1
+ /** The JSON Schema subset accepted by the model contract (ADR 0034). */
2
+ export type StructuredOutputSchemaV1 =
3
+ | {
4
+ type: "object";
5
+ properties?: Record<string, StructuredOutputSchemaV1>;
6
+ required?: string[];
7
+ additionalProperties?: boolean;
8
+ enum?: unknown[];
9
+ title?: string;
10
+ description?: string;
11
+ }
12
+ | {
13
+ type: "array";
14
+ items: StructuredOutputSchemaV1;
15
+ enum?: unknown[];
16
+ title?: string;
17
+ description?: string;
18
+ }
19
+ | {
20
+ type: "string" | "number" | "boolean";
21
+ enum?: unknown[];
22
+ title?: string;
23
+ description?: string;
24
+ };
25
+
26
+ export interface JsonSchemaResponseFormatV1 {
27
+ type: "json_schema";
28
+ /** Provider-safe identifier for the schema, not a display label. */
29
+ name: string;
30
+ schema: StructuredOutputSchemaV1;
31
+ }
32
+
33
+ export interface JsonResponseFormatV1 {
34
+ type: "json";
35
+ }
36
+
37
+ export type ModelResponseFormatV1 =
38
+ JsonSchemaResponseFormatV1 | JsonResponseFormatV1;
39
+
40
+ export type StructuredOutputSupportV1 = "json_schema" | "json" | "none";
41
+
42
+ export interface ModelProviderSupportsV1 {
43
+ structuredOutput: StructuredOutputSupportV1;
44
+ }
45
+
46
+ export interface StructuredOutputIssueV1 {
47
+ path: string;
48
+ code: "type" | "enum" | "required" | "additional-property";
49
+ message: string;
50
+ }
51
+
52
+ /** A validation failure is durable, so its diagnostics cannot grow unbounded. */
53
+ export const STRUCTURED_OUTPUT_ISSUE_LIMIT_V1 = 100;
54
+
55
+ export type StructuredOutputFailureV1 =
56
+ | {
57
+ code: "invalid-json";
58
+ message: string;
59
+ }
60
+ | {
61
+ code: "schema-mismatch";
62
+ message: string;
63
+ issues: StructuredOutputIssueV1[];
64
+ };
65
+
66
+ export interface ResponseFormatNoteV1 {
67
+ code: "structured-output-downgraded";
68
+ requested: "json_schema" | "json";
69
+ effective: "json" | "prompt";
70
+ message: string;
71
+ }
72
+
73
+ export type StructuredModelResultV1<T> =
74
+ | { status: "completed"; value: T; raw: string }
75
+ | { status: "failed"; failure: StructuredOutputFailureV1; raw: string };
76
+
77
+ const SCHEMA_KEYS = new Set([
78
+ "type",
79
+ "properties",
80
+ "items",
81
+ "enum",
82
+ "required",
83
+ "additionalProperties",
84
+ "title",
85
+ "description",
86
+ ]);
87
+
88
+ /**
89
+ * Strictly decodes the deliberately small, dependency-free schema dialect.
90
+ * Unsupported JSON Schema keywords fail at admission instead of being
91
+ * interpreted differently by different providers.
92
+ */
93
+ export function decodeStructuredOutputSchemaV1(
94
+ value: unknown,
95
+ label = "structured output schema",
96
+ ): StructuredOutputSchemaV1 {
97
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
98
+ for (const key of Object.keys(value)) {
99
+ if (!SCHEMA_KEYS.has(key)) {
100
+ throw new Error(`${label}.${key} is not supported`);
101
+ }
102
+ }
103
+ if (
104
+ value.type !== "object" &&
105
+ value.type !== "array" &&
106
+ value.type !== "string" &&
107
+ value.type !== "number" &&
108
+ value.type !== "boolean"
109
+ ) {
110
+ throw new Error(
111
+ `${label}.type must be object, array, string, number, or boolean`,
112
+ );
113
+ }
114
+ requireOptionalString(value.title, `${label}.title`);
115
+ requireOptionalString(value.description, `${label}.description`);
116
+ if (value.enum !== undefined) {
117
+ if (!Array.isArray(value.enum) || value.enum.length === 0) {
118
+ throw new Error(`${label}.enum must be a non-empty array`);
119
+ }
120
+ for (const member of value.enum) requireJsonValue(member, `${label}.enum`);
121
+ }
122
+
123
+ if (value.type === "object") {
124
+ if (value.items !== undefined) {
125
+ throw new Error(`${label}.items is only valid for arrays`);
126
+ }
127
+ if (
128
+ value.additionalProperties !== undefined &&
129
+ typeof value.additionalProperties !== "boolean"
130
+ ) {
131
+ throw new Error(`${label}.additionalProperties must be a boolean`);
132
+ }
133
+ const properties: Record<string, StructuredOutputSchemaV1> = {};
134
+ if (value.properties !== undefined) {
135
+ if (!isRecord(value.properties)) {
136
+ throw new Error(`${label}.properties must be an object`);
137
+ }
138
+ for (const [name, schema] of Object.entries(value.properties)) {
139
+ properties[name] = decodeStructuredOutputSchemaV1(
140
+ schema,
141
+ `${label}.properties.${name}`,
142
+ );
143
+ }
144
+ }
145
+ let required: string[] | undefined;
146
+ if (value.required !== undefined) {
147
+ if (
148
+ !Array.isArray(value.required) ||
149
+ !value.required.every((entry) => typeof entry === "string")
150
+ ) {
151
+ throw new Error(`${label}.required must be an array of strings`);
152
+ }
153
+ required = [...value.required];
154
+ for (const name of required) {
155
+ if (!(name in properties)) {
156
+ throw new Error(`${label}.required names unknown property "${name}"`);
157
+ }
158
+ }
159
+ }
160
+ return {
161
+ type: "object",
162
+ ...(Object.keys(properties).length > 0 ? { properties } : {}),
163
+ ...(required ? { required } : {}),
164
+ ...(value.additionalProperties !== undefined
165
+ ? { additionalProperties: value.additionalProperties }
166
+ : {}),
167
+ ...(value.enum !== undefined ? { enum: value.enum } : {}),
168
+ ...(value.title !== undefined ? { title: value.title } : {}),
169
+ ...(value.description !== undefined
170
+ ? { description: value.description }
171
+ : {}),
172
+ };
173
+ }
174
+
175
+ if (
176
+ value.properties !== undefined ||
177
+ value.required !== undefined ||
178
+ value.additionalProperties !== undefined
179
+ ) {
180
+ throw new Error(`${label} contains object-only keywords`);
181
+ }
182
+ if (value.type === "array") {
183
+ if (value.items === undefined) {
184
+ throw new Error(`${label}.items is required for arrays`);
185
+ }
186
+ return {
187
+ type: "array",
188
+ items: decodeStructuredOutputSchemaV1(value.items, `${label}.items`),
189
+ ...(value.enum !== undefined ? { enum: value.enum } : {}),
190
+ ...(value.title !== undefined ? { title: value.title } : {}),
191
+ ...(value.description !== undefined
192
+ ? { description: value.description }
193
+ : {}),
194
+ };
195
+ }
196
+ if (value.items !== undefined) {
197
+ throw new Error(`${label}.items is only valid for arrays`);
198
+ }
199
+ return {
200
+ type: value.type,
201
+ ...(value.enum !== undefined ? { enum: value.enum } : {}),
202
+ ...(value.title !== undefined ? { title: value.title } : {}),
203
+ ...(value.description !== undefined
204
+ ? { description: value.description }
205
+ : {}),
206
+ };
207
+ }
208
+
209
+ export function decodeModelResponseFormatV1(
210
+ value: unknown,
211
+ label = "responseFormat",
212
+ ): ModelResponseFormatV1 {
213
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
214
+ if (value.type === "json") {
215
+ requireExactKeys(value, ["type"], label);
216
+ return { type: "json" };
217
+ }
218
+ if (value.type !== "json_schema") {
219
+ throw new Error(`${label}.type must be json_schema or json`);
220
+ }
221
+ requireExactKeys(value, ["type", "name", "schema"], label);
222
+ if (
223
+ typeof value.name !== "string" ||
224
+ !/^[A-Za-z0-9_-]{1,64}$/.test(value.name)
225
+ ) {
226
+ throw new Error(`${label}.name must be 1-64 letters, digits, _ or -`);
227
+ }
228
+ return {
229
+ type: "json_schema",
230
+ name: value.name,
231
+ schema: decodeStructuredOutputSchemaV1(value.schema, `${label}.schema`),
232
+ };
233
+ }
234
+
235
+ export function validateStructuredOutputV1(
236
+ text: string,
237
+ schema: StructuredOutputSchemaV1,
238
+ ): StructuredModelResultV1<unknown> {
239
+ const parsed = parseStructuredOutputJsonV1(text);
240
+ if (parsed.status === "failed") return parsed;
241
+ const value = parsed.value;
242
+ const issues: StructuredOutputIssueV1[] = [];
243
+ validateValue(value, schema, "$", issues);
244
+ return issues.length === 0
245
+ ? { status: "completed", value, raw: text }
246
+ : {
247
+ status: "failed",
248
+ failure: {
249
+ code: "schema-mismatch",
250
+ message: "The model response did not match the requested schema",
251
+ issues,
252
+ },
253
+ raw: text,
254
+ };
255
+ }
256
+
257
+ /** Parse-only validation for a request that asks for JSON without a schema. */
258
+ export function parseStructuredOutputJsonV1(
259
+ text: string,
260
+ ): StructuredModelResultV1<unknown> {
261
+ try {
262
+ return { status: "completed", value: JSON.parse(text), raw: text };
263
+ } catch {
264
+ return {
265
+ status: "failed",
266
+ failure: {
267
+ code: "invalid-json",
268
+ message: "The model response was not valid JSON",
269
+ },
270
+ raw: text,
271
+ };
272
+ }
273
+ }
274
+
275
+ function validateValue(
276
+ value: unknown,
277
+ schema: StructuredOutputSchemaV1,
278
+ path: string,
279
+ issues: StructuredOutputIssueV1[],
280
+ ): void {
281
+ if (issues.length >= STRUCTURED_OUTPUT_ISSUE_LIMIT_V1) return;
282
+ if (schema.enum && !schema.enum.some((member) => jsonEqual(member, value))) {
283
+ pushIssue(issues, {
284
+ path,
285
+ code: "enum",
286
+ message: `${path} is not one of the allowed values`,
287
+ });
288
+ return;
289
+ }
290
+ if (schema.type === "object") {
291
+ if (!isRecord(value)) return typeIssue(path, "object", issues);
292
+ for (const required of schema.required ?? []) {
293
+ if (!Object.hasOwn(value, required)) {
294
+ pushIssue(issues, {
295
+ path: `${path}.${required}`,
296
+ code: "required",
297
+ message: `${path}.${required} is required`,
298
+ });
299
+ }
300
+ }
301
+ for (const [name, member] of Object.entries(value)) {
302
+ const memberSchema = schema.properties?.[name];
303
+ if (memberSchema) {
304
+ validateValue(member, memberSchema, `${path}.${name}`, issues);
305
+ } else if (schema.additionalProperties === false) {
306
+ pushIssue(issues, {
307
+ path: `${path}.${name}`,
308
+ code: "additional-property",
309
+ message: `${path}.${name} is not allowed`,
310
+ });
311
+ }
312
+ }
313
+ return;
314
+ }
315
+ if (schema.type === "array") {
316
+ if (!Array.isArray(value)) return typeIssue(path, "array", issues);
317
+ value.forEach((member, index) =>
318
+ validateValue(member, schema.items, `${path}[${index}]`, issues),
319
+ );
320
+ return;
321
+ }
322
+ if (typeof value !== schema.type) typeIssue(path, schema.type, issues);
323
+ }
324
+
325
+ function typeIssue(
326
+ path: string,
327
+ expected: string,
328
+ issues: StructuredOutputIssueV1[],
329
+ ): void {
330
+ pushIssue(issues, {
331
+ path,
332
+ code: "type",
333
+ message: `${path} must be a ${expected}`,
334
+ });
335
+ }
336
+
337
+ function pushIssue(
338
+ issues: StructuredOutputIssueV1[],
339
+ issue: StructuredOutputIssueV1,
340
+ ): void {
341
+ if (issues.length < STRUCTURED_OUTPUT_ISSUE_LIMIT_V1) issues.push(issue);
342
+ }
343
+
344
+ function jsonEqual(left: unknown, right: unknown): boolean {
345
+ if (Object.is(left, right)) return true;
346
+ if (Array.isArray(left) || Array.isArray(right)) {
347
+ return (
348
+ Array.isArray(left) &&
349
+ Array.isArray(right) &&
350
+ left.length === right.length &&
351
+ left.every((member, index) => jsonEqual(member, right[index]))
352
+ );
353
+ }
354
+ if (isRecord(left) || isRecord(right)) {
355
+ if (!isRecord(left) || !isRecord(right)) return false;
356
+ const leftKeys = Object.keys(left).sort();
357
+ const rightKeys = Object.keys(right).sort();
358
+ return (
359
+ leftKeys.length === rightKeys.length &&
360
+ leftKeys.every(
361
+ (key, index) =>
362
+ key === rightKeys[index] && jsonEqual(left[key], right[key]),
363
+ )
364
+ );
365
+ }
366
+ return false;
367
+ }
368
+
369
+ function requireExactKeys(
370
+ value: Record<string, unknown>,
371
+ keys: readonly string[],
372
+ label: string,
373
+ ): void {
374
+ const allowed = new Set(keys);
375
+ for (const key of Object.keys(value)) {
376
+ if (!allowed.has(key)) throw new Error(`${label}.${key} is not supported`);
377
+ }
378
+ for (const key of keys) {
379
+ if (!Object.hasOwn(value, key))
380
+ throw new Error(`${label}.${key} is required`);
381
+ }
382
+ }
383
+
384
+ function requireOptionalString(
385
+ value: unknown,
386
+ label: string,
387
+ ): asserts value is string | undefined {
388
+ if (value !== undefined && typeof value !== "string") {
389
+ throw new Error(`${label} must be a string`);
390
+ }
391
+ }
392
+
393
+ function requireJsonValue(value: unknown, label: string): void {
394
+ if (
395
+ value === null ||
396
+ typeof value === "string" ||
397
+ typeof value === "boolean" ||
398
+ (typeof value === "number" && Number.isFinite(value))
399
+ ) {
400
+ return;
401
+ }
402
+ if (Array.isArray(value)) {
403
+ value.forEach((member) => requireJsonValue(member, label));
404
+ return;
405
+ }
406
+ if (isRecord(value)) {
407
+ Object.values(value).forEach((member) => requireJsonValue(member, label));
408
+ return;
409
+ }
410
+ throw new Error(`${label} must contain only JSON values`);
411
+ }
412
+
413
+ function isRecord(value: unknown): value is Record<string, unknown> {
414
+ return typeof value === "object" && value !== null && !Array.isArray(value);
415
+ }
@@ -1,10 +1,9 @@
1
1
  // Tool result attachments: what may be recorded, and what may not.
2
2
  //
3
3
  // The one rule worth a suite of its own is that resolved bytes are never
4
- // durable. The session event log is one Durable Object value; a base64
5
- // screenshot recorded in it would be a record that grows past what the object
6
- // can hold, so the decoder refuses `dataBase64` on the durable side rather
7
- // than trimming it somewhere further down.
4
+ // durable. A base64 screenshot recorded in the paged session log would still
5
+ // multiply durable storage and model-request size, so the decoder refuses
6
+ // `dataBase64` on the durable side rather than trimming it further down.
8
7
  import { describe, expect, test } from "bun:test";
9
8
  import {
10
9
  decodeToolAttachmentsV1,
@@ -15,7 +15,12 @@ function durable(event: Record<string, unknown>): unknown {
15
15
 
16
16
  describe("TurnTypeV1", () => {
17
17
  test("names every turn type the admission vocabulary declares", () => {
18
- expect([...TURN_TYPES_V1]).toEqual(["chat", "automation", "subagent"]);
18
+ expect([...TURN_TYPES_V1]).toEqual([
19
+ "chat",
20
+ "agent",
21
+ "automation",
22
+ "subagent",
23
+ ]);
19
24
  for (const turnType of TURN_TYPES_V1) {
20
25
  expect(decodeTurnTypeV1(turnType)).toBe(turnType);
21
26
  }
package/src/types.ts CHANGED
@@ -8,6 +8,13 @@ import {
8
8
  decodeSkillRefsV1,
9
9
  type SkillRefV1,
10
10
  } from "./skills.js";
11
+ import {
12
+ decodeModelResponseFormatV1,
13
+ STRUCTURED_OUTPUT_ISSUE_LIMIT_V1,
14
+ type ModelResponseFormatV1,
15
+ type ResponseFormatNoteV1,
16
+ type StructuredOutputFailureV1,
17
+ } from "./structured-output.js";
11
18
 
12
19
  export interface ToolCall {
13
20
  id: string;
@@ -134,12 +141,29 @@ export interface NormalizedModelRequest {
134
141
  system: string;
135
142
  messages: LlmMessage[];
136
143
  tools: ToolSchema[];
144
+ responseFormat?: ModelResponseFormatV1;
137
145
  modelBinding?: ModelBindingSnapshot;
138
146
  }
139
147
 
148
+ /** Provider-reported token accounting for one normalized model request. */
149
+ export interface LlmUsageV1 {
150
+ inputTokens: number;
151
+ outputTokens: number;
152
+ /** Input tokens served from a provider cache; included in `inputTokens`. */
153
+ cachedInputTokens?: number;
154
+ /** Reasoning tokens; included in `outputTokens`. */
155
+ reasoningTokens?: number;
156
+ }
157
+
140
158
  export type LlmStreamEvent =
141
159
  | { type: "text-delta"; text: string }
142
160
  | { type: "tool-call"; call: ToolCall }
161
+ | { type: "usage"; usage: LlmUsageV1 }
162
+ | { type: "response-format-note"; note: ResponseFormatNoteV1 }
163
+ | {
164
+ type: "structured-output-failure";
165
+ failure: StructuredOutputFailureV1;
166
+ }
143
167
  | { type: "finish"; reason: "completed" | "tool-calls" | "max-tokens" };
144
168
 
145
169
  export type StepOutcome =
@@ -206,11 +230,12 @@ export function turnFailureMessage(
206
230
  * isolate contract, and the durable run record: adding one later is a wire
207
231
  * change in three places.
208
232
  */
209
- export type TurnTypeV1 = "chat" | "automation" | "subagent";
233
+ export type TurnTypeV1 = "chat" | "agent" | "automation" | "subagent";
210
234
 
211
235
  /** The declared turn types, in their canonical order. */
212
236
  export const TURN_TYPES_V1: readonly TurnTypeV1[] = [
213
237
  "chat",
238
+ "agent",
214
239
  "automation",
215
240
  "subagent",
216
241
  ];
@@ -302,6 +327,25 @@ export interface SessionEventMap {
302
327
  step: number;
303
328
  request: NormalizedModelRequest;
304
329
  };
330
+ /**
331
+ * Bounded accounting for one model request. Providers report tokens when
332
+ * they can; otherwise the loop estimates from the exact durable request and
333
+ * response sizes. It deliberately carries no prompt or response content.
334
+ */
335
+ "model/usage": {
336
+ turn: number;
337
+ step: number;
338
+ requestId: string;
339
+ provider: string;
340
+ model: string;
341
+ modelBinding?: ModelBindingSnapshot;
342
+ inputTokens: number;
343
+ outputTokens: number;
344
+ cachedInputTokens?: number;
345
+ reasoningTokens?: number;
346
+ latencyMs: number;
347
+ estimated: boolean;
348
+ };
305
349
  "model/effect-not-started": {
306
350
  turn: number;
307
351
  step: number;
@@ -322,6 +366,18 @@ export interface SessionEventMap {
322
366
  requestId: string;
323
367
  reason: string;
324
368
  };
369
+ "model/response-format-note": {
370
+ turn: number;
371
+ step: number;
372
+ requestId: string;
373
+ note: ResponseFormatNoteV1;
374
+ };
375
+ "model/response-failed": {
376
+ turn: number;
377
+ step: number;
378
+ requestId: string;
379
+ failure: StructuredOutputFailureV1;
380
+ };
325
381
  "assistant/chunk": {
326
382
  turn: number;
327
383
  step: number;
@@ -693,13 +749,17 @@ export interface SessionEventMap {
693
749
  "computer/sync": {
694
750
  turn: number;
695
751
  reason: "open" | "signal" | "turn-end" | "publish";
696
- status: "ok" | "unavailable" | "refused" | "skipped";
752
+ status: "ok" | "degraded" | "unavailable" | "refused" | "skipped";
697
753
  detail: string;
698
754
  pulled: number;
699
755
  pushed: number;
700
756
  restored: number;
701
757
  removed: number;
702
758
  adopted: number;
759
+ /** Absent only on records written before bounded manifests shipped. */
760
+ ignored?: number;
761
+ /** Absent only on records written before bounded manifests shipped. */
762
+ omitted?: number;
703
763
  conflicts: number;
704
764
  failures: number;
705
765
  };
@@ -1083,6 +1143,44 @@ function requireToolSchema(value: unknown, label: string): void {
1083
1143
  requireJsonValue(schema, `${label}.inputSchema`);
1084
1144
  }
1085
1145
 
1146
+ function requireStructuredOutputFailureV1(value: unknown, label: string): void {
1147
+ const failure = eventRecord(value, label);
1148
+ if (failure.code === "invalid-json") {
1149
+ requireEventKeys(failure, ["code", "message"], label);
1150
+ eventString(failure.message, `${label}.message`);
1151
+ return;
1152
+ }
1153
+ if (failure.code !== "schema-mismatch") {
1154
+ throw new Error(`${label}.code is invalid`);
1155
+ }
1156
+ requireEventKeys(failure, ["code", "message", "issues"], label);
1157
+ eventString(failure.message, `${label}.message`);
1158
+ if (!Array.isArray(failure.issues)) {
1159
+ throw new Error(`${label}.issues must be an array`);
1160
+ }
1161
+ if (failure.issues.length > STRUCTURED_OUTPUT_ISSUE_LIMIT_V1) {
1162
+ throw new Error(`${label}.issues exceeds its limit`);
1163
+ }
1164
+ for (const [index, candidate] of failure.issues.entries()) {
1165
+ const issue = eventRecord(candidate, `${label}.issues[${index}]`);
1166
+ requireEventKeys(
1167
+ issue,
1168
+ ["path", "code", "message"],
1169
+ `${label}.issues[${index}]`,
1170
+ );
1171
+ eventString(issue.path, `${label}.issues[${index}].path`);
1172
+ eventString(issue.message, `${label}.issues[${index}].message`);
1173
+ if (
1174
+ issue.code !== "type" &&
1175
+ issue.code !== "enum" &&
1176
+ issue.code !== "required" &&
1177
+ issue.code !== "additional-property"
1178
+ ) {
1179
+ throw new Error(`${label}.issues[${index}].code is invalid`);
1180
+ }
1181
+ }
1182
+ }
1183
+
1086
1184
  /**
1087
1185
  * The exact v1 decoder for a normalized model request. Exported because the
1088
1186
  * request crosses the Bot isolate boundary inbound — a Bot-authored model
@@ -1108,6 +1206,7 @@ function requireNormalizedModelRequest(value: unknown, label: string): void {
1108
1206
  "system",
1109
1207
  "messages",
1110
1208
  "tools",
1209
+ ...(Object.hasOwn(request, "responseFormat") ? ["responseFormat"] : []),
1111
1210
  ...(Object.hasOwn(request, "modelBinding") ? ["modelBinding"] : []),
1112
1211
  ],
1113
1212
  label,
@@ -1125,6 +1224,12 @@ function requireNormalizedModelRequest(value: unknown, label: string): void {
1125
1224
  request.tools.forEach((tool, index) =>
1126
1225
  requireToolSchema(tool, `${label}.tools[${index}]`),
1127
1226
  );
1227
+ if (request.responseFormat !== undefined) {
1228
+ decodeModelResponseFormatV1(
1229
+ request.responseFormat,
1230
+ `${label}.responseFormat`,
1231
+ );
1232
+ }
1128
1233
  if (request.modelBinding !== undefined) {
1129
1234
  const binding = eventRecord(request.modelBinding, `${label}.modelBinding`);
1130
1235
  requireEventKeys(
@@ -1268,6 +1373,88 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
1268
1373
  step();
1269
1374
  requireNormalizedModelRequest(event.request, "session event.request");
1270
1375
  break;
1376
+ case "model/usage": {
1377
+ requireEventKeys(
1378
+ event,
1379
+ keys(
1380
+ "turn",
1381
+ "step",
1382
+ "requestId",
1383
+ "provider",
1384
+ "model",
1385
+ ...(Object.hasOwn(event, "modelBinding") ? ["modelBinding"] : []),
1386
+ "inputTokens",
1387
+ "outputTokens",
1388
+ ...(Object.hasOwn(event, "cachedInputTokens")
1389
+ ? ["cachedInputTokens"]
1390
+ : []),
1391
+ ...(Object.hasOwn(event, "reasoningTokens")
1392
+ ? ["reasoningTokens"]
1393
+ : []),
1394
+ "latencyMs",
1395
+ "estimated",
1396
+ ),
1397
+ "session event",
1398
+ );
1399
+ turn();
1400
+ step();
1401
+ requestId();
1402
+ eventString(event.provider, "session event.provider");
1403
+ eventString(event.model, "session event.model");
1404
+ if (event.modelBinding !== undefined) {
1405
+ requireNormalizedModelRequest(
1406
+ {
1407
+ requestId: event.requestId,
1408
+ provider: event.provider,
1409
+ model: event.model,
1410
+ system: "",
1411
+ messages: [],
1412
+ tools: [],
1413
+ modelBinding: event.modelBinding,
1414
+ },
1415
+ "session event usage binding",
1416
+ );
1417
+ }
1418
+ const inputTokens = eventInteger(
1419
+ event.inputTokens,
1420
+ "session event.inputTokens",
1421
+ 0,
1422
+ );
1423
+ const outputTokens = eventInteger(
1424
+ event.outputTokens,
1425
+ "session event.outputTokens",
1426
+ 0,
1427
+ );
1428
+ if (event.cachedInputTokens !== undefined) {
1429
+ const cached = eventInteger(
1430
+ event.cachedInputTokens,
1431
+ "session event.cachedInputTokens",
1432
+ 0,
1433
+ );
1434
+ if (cached > inputTokens) {
1435
+ throw new Error(
1436
+ "session event.cachedInputTokens cannot exceed inputTokens",
1437
+ );
1438
+ }
1439
+ }
1440
+ if (event.reasoningTokens !== undefined) {
1441
+ const reasoning = eventInteger(
1442
+ event.reasoningTokens,
1443
+ "session event.reasoningTokens",
1444
+ 0,
1445
+ );
1446
+ if (reasoning > outputTokens) {
1447
+ throw new Error(
1448
+ "session event.reasoningTokens cannot exceed outputTokens",
1449
+ );
1450
+ }
1451
+ }
1452
+ eventInteger(event.latencyMs, "session event.latencyMs", 0);
1453
+ if (typeof event.estimated !== "boolean") {
1454
+ throw new Error("session event.estimated must be a boolean");
1455
+ }
1456
+ break;
1457
+ }
1271
1458
  case "model/effect-not-started":
1272
1459
  case "model/reconciliation-required":
1273
1460
  requireEventKeys(
@@ -1280,6 +1467,45 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
1280
1467
  requestId();
1281
1468
  eventString(event.reason, "session event.reason");
1282
1469
  break;
1470
+ case "model/response-format-note": {
1471
+ requireEventKeys(
1472
+ event,
1473
+ keys("turn", "step", "requestId", "note"),
1474
+ "session event",
1475
+ );
1476
+ turn();
1477
+ step();
1478
+ requestId();
1479
+ const note = eventRecord(event.note, "session event.note");
1480
+ requireEventKeys(
1481
+ note,
1482
+ keys("code", "requested", "effective", "message"),
1483
+ "session event.note",
1484
+ );
1485
+ if (note.code !== "structured-output-downgraded") {
1486
+ throw new Error("session event.note.code is invalid");
1487
+ }
1488
+ if (note.requested !== "json_schema" && note.requested !== "json") {
1489
+ throw new Error("session event.note.requested is invalid");
1490
+ }
1491
+ if (note.effective !== "json" && note.effective !== "prompt") {
1492
+ throw new Error("session event.note.effective is invalid");
1493
+ }
1494
+ eventString(note.message, "session event.note.message");
1495
+ break;
1496
+ }
1497
+ case "model/response-failed": {
1498
+ requireEventKeys(
1499
+ event,
1500
+ keys("turn", "step", "requestId", "failure"),
1501
+ "session event",
1502
+ );
1503
+ turn();
1504
+ step();
1505
+ requestId();
1506
+ requireStructuredOutputFailureV1(event.failure, "session event.failure");
1507
+ break;
1508
+ }
1283
1509
  case "model/retry":
1284
1510
  requireEventKeys(
1285
1511
  event,
@@ -2035,6 +2261,8 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
2035
2261
  "restored",
2036
2262
  "removed",
2037
2263
  "adopted",
2264
+ ...(Object.hasOwn(event, "ignored") ? ["ignored"] : []),
2265
+ ...(Object.hasOwn(event, "omitted") ? ["omitted"] : []),
2038
2266
  "conflicts",
2039
2267
  "failures",
2040
2268
  ),
@@ -2049,7 +2277,7 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
2049
2277
  throw new Error("session event.reason is invalid");
2050
2278
  }
2051
2279
  if (
2052
- !["ok", "unavailable", "refused", "skipped"].includes(
2280
+ !["ok", "degraded", "unavailable", "refused", "skipped"].includes(
2053
2281
  event.status as string,
2054
2282
  )
2055
2283
  ) {
@@ -2067,6 +2295,12 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
2067
2295
  ] as const) {
2068
2296
  eventInteger(event[field], `session event.${field}`, 0);
2069
2297
  }
2298
+ if (event.ignored !== undefined) {
2299
+ eventInteger(event.ignored, "session event.ignored", 0);
2300
+ }
2301
+ if (event.omitted !== undefined) {
2302
+ eventInteger(event.omitted, "session event.omitted", 0);
2303
+ }
2070
2304
  break;
2071
2305
  }
2072
2306
  case "bot/renamed": {