@frockbot/kernel-contracts 0.3.16 → 0.3.18
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 +1 -1
- package/src/applets.test.ts +7 -0
- package/src/applets.ts +8 -1
- package/src/index.ts +1 -0
- package/src/isolate.ts +96 -1
- package/src/model-invocation.ts +20 -0
- package/src/prompt-assembly.ts +8 -0
- package/src/session.test.ts +32 -0
- package/src/session.ts +3 -0
- package/src/structured-output.test.ts +117 -0
- package/src/structured-output.ts +415 -0
- package/src/turn-type.test.ts +6 -1
- package/src/types.ts +237 -3
package/package.json
CHANGED
package/src/applets.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/model-invocation.ts
CHANGED
|
@@ -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,
|
package/src/prompt-assembly.ts
CHANGED
|
@@ -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. */
|
package/src/session.test.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/src/turn-type.test.ts
CHANGED
|
@@ -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([
|
|
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": {
|