@frockbot/kernel-do 0.1.3 → 0.2.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.
- package/package.json +3 -3
- package/src/authority.ts +7 -0
- package/src/composition-store.test.ts +154 -0
- package/src/composition-store.ts +44 -1
- package/src/direct-tool-command.test.ts +81 -0
- package/src/run-records.ts +63 -7
- package/src/run-recovery.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/kernel-composition": "0.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.
|
|
15
|
+
"@frockbot/kernel-composition": "0.2.0",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.2.0",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/authority.ts
CHANGED
|
@@ -322,6 +322,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
322
322
|
? { subagentRole: storedRunSubagentRoleV1(run) }
|
|
323
323
|
: {}),
|
|
324
324
|
...(run.admission?.origin ? { origin: run.admission.origin } : {}),
|
|
325
|
+
...(run.directTool ? { directTool: run.directTool } : {}),
|
|
325
326
|
},
|
|
326
327
|
previousEvents: latest,
|
|
327
328
|
configurationSnapshot: settings,
|
|
@@ -717,6 +718,9 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
717
718
|
command.origin,
|
|
718
719
|
command.subagentRole,
|
|
719
720
|
),
|
|
721
|
+
...(command.directTool
|
|
722
|
+
? { directTool: structuredClone(command.directTool) }
|
|
723
|
+
: {}),
|
|
720
724
|
} satisfies StoredRunV1<Snapshot>);
|
|
721
725
|
await transaction.put({
|
|
722
726
|
[key]: admittedRun,
|
|
@@ -980,6 +984,9 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
980
984
|
...(recovery.run.admission?.origin
|
|
981
985
|
? { origin: recovery.run.admission.origin }
|
|
982
986
|
: {}),
|
|
987
|
+
...(recovery.run.directTool
|
|
988
|
+
? { directTool: recovery.run.directTool }
|
|
989
|
+
: {}),
|
|
983
990
|
},
|
|
984
991
|
recovery.previous,
|
|
985
992
|
recovery.settings,
|
|
@@ -161,6 +161,125 @@ describe("Bot Durable Object Composition records", () => {
|
|
|
161
161
|
await expect(store.propose(next)).rejects.toThrow("already exists");
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
test("refuses proposals that remove or replace the first-party bootstrap core", async () => {
|
|
165
|
+
const storage = new MemoryStorage();
|
|
166
|
+
const store = createStore(storage);
|
|
167
|
+
const parent = await store.current();
|
|
168
|
+
const createdAt = "2026-09-01T00:00:00.000Z";
|
|
169
|
+
|
|
170
|
+
const omittedHash = await compositionArtifactSetHashV1([]);
|
|
171
|
+
await expect(
|
|
172
|
+
store.propose({
|
|
173
|
+
schemaVersion: 1,
|
|
174
|
+
generationId: compositionGenerationIdV1(createdAt, omittedHash),
|
|
175
|
+
artifactSetHash: omittedHash,
|
|
176
|
+
parentGenerationId: parent.generationId,
|
|
177
|
+
createdAt,
|
|
178
|
+
origin: {
|
|
179
|
+
kind: "bot-authored",
|
|
180
|
+
runId: "run-1",
|
|
181
|
+
sessionId: "s",
|
|
182
|
+
turnId: "t",
|
|
183
|
+
},
|
|
184
|
+
members: [],
|
|
185
|
+
status: "pending",
|
|
186
|
+
}),
|
|
187
|
+
).rejects.toThrow(/omits required first-party Package "shell"/);
|
|
188
|
+
|
|
189
|
+
const shadow: CompositionMemberV1 = {
|
|
190
|
+
...authoredMember,
|
|
191
|
+
packageId: "shell",
|
|
192
|
+
specifier: "bot-authored:shell",
|
|
193
|
+
provenance: {
|
|
194
|
+
...authoredMember.provenance,
|
|
195
|
+
packageId: "shell",
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
const replacedHash = await compositionArtifactSetHashV1([shadow]);
|
|
199
|
+
await expect(
|
|
200
|
+
store.propose({
|
|
201
|
+
schemaVersion: 1,
|
|
202
|
+
generationId: compositionGenerationIdV1(createdAt, replacedHash),
|
|
203
|
+
artifactSetHash: replacedHash,
|
|
204
|
+
parentGenerationId: parent.generationId,
|
|
205
|
+
createdAt,
|
|
206
|
+
origin: {
|
|
207
|
+
kind: "bot-authored",
|
|
208
|
+
runId: "run-1",
|
|
209
|
+
sessionId: "s",
|
|
210
|
+
turnId: "t",
|
|
211
|
+
},
|
|
212
|
+
members: [shadow],
|
|
213
|
+
status: "pending",
|
|
214
|
+
}),
|
|
215
|
+
).rejects.toThrow(
|
|
216
|
+
/replaces required first-party Package "shell" with bot provenance/,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const catalogShadow: CompositionMemberV1 = {
|
|
220
|
+
packageId: "shell",
|
|
221
|
+
specifier: "catalog:shell",
|
|
222
|
+
version: "0.0.1",
|
|
223
|
+
manifestHash: authoredMember.manifestHash,
|
|
224
|
+
provenance: {
|
|
225
|
+
kind: "catalog",
|
|
226
|
+
packageId: "shell",
|
|
227
|
+
version: "0.0.1",
|
|
228
|
+
catalogId: "shell",
|
|
229
|
+
catalogGeneration: "catalog-1",
|
|
230
|
+
contentHash: authoredMember.artifact!.contentHash,
|
|
231
|
+
},
|
|
232
|
+
artifact: authoredMember.artifact,
|
|
233
|
+
};
|
|
234
|
+
const catalogHash = await compositionArtifactSetHashV1([catalogShadow]);
|
|
235
|
+
await expect(
|
|
236
|
+
store.propose({
|
|
237
|
+
schemaVersion: 1,
|
|
238
|
+
generationId: compositionGenerationIdV1(createdAt, catalogHash),
|
|
239
|
+
artifactSetHash: catalogHash,
|
|
240
|
+
parentGenerationId: parent.generationId,
|
|
241
|
+
createdAt,
|
|
242
|
+
origin: {
|
|
243
|
+
kind: "bot-catalog",
|
|
244
|
+
action: "install",
|
|
245
|
+
packageId: "shell",
|
|
246
|
+
catalogId: "shell",
|
|
247
|
+
botId: "bot-1",
|
|
248
|
+
runId: "run-1",
|
|
249
|
+
sessionId: "s",
|
|
250
|
+
turnId: "t",
|
|
251
|
+
},
|
|
252
|
+
summary: "Added shell",
|
|
253
|
+
members: [catalogShadow],
|
|
254
|
+
status: "pending",
|
|
255
|
+
}),
|
|
256
|
+
).rejects.toThrow(
|
|
257
|
+
/replaces required first-party Package "shell" with catalog provenance/,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const changedCore = {
|
|
261
|
+
...parent.members[0]!,
|
|
262
|
+
version: "9.9.9",
|
|
263
|
+
provenance: {
|
|
264
|
+
...parent.members[0]!.provenance,
|
|
265
|
+
version: "9.9.9",
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const changedHash = await compositionArtifactSetHashV1([changedCore]);
|
|
269
|
+
await expect(
|
|
270
|
+
store.propose({
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
generationId: compositionGenerationIdV1(createdAt, changedHash),
|
|
273
|
+
artifactSetHash: changedHash,
|
|
274
|
+
parentGenerationId: parent.generationId,
|
|
275
|
+
createdAt,
|
|
276
|
+
origin: { kind: "user-install", userId: "user-1" },
|
|
277
|
+
members: [changedCore],
|
|
278
|
+
status: "pending",
|
|
279
|
+
}),
|
|
280
|
+
).resolves.toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
164
283
|
test("reverting records a new pending generation with the target's members", async () => {
|
|
165
284
|
const storage = new MemoryStorage();
|
|
166
285
|
const store = createStore(
|
|
@@ -244,6 +363,41 @@ describe("Bot Durable Object Composition records", () => {
|
|
|
244
363
|
]);
|
|
245
364
|
});
|
|
246
365
|
|
|
366
|
+
test("a Bot-origin revert is idempotent and cannot mark its generation last-known-good", async () => {
|
|
367
|
+
const storage = new MemoryStorage();
|
|
368
|
+
const store = createStore(storage);
|
|
369
|
+
const bootstrapped = await store.current();
|
|
370
|
+
const authored = await grownSuccessor(
|
|
371
|
+
bootstrapped,
|
|
372
|
+
"2026-09-01T00:00:00.000Z",
|
|
373
|
+
);
|
|
374
|
+
await store.propose(authored);
|
|
375
|
+
await store.commit(authored.generationId);
|
|
376
|
+
const origin = {
|
|
377
|
+
kind: "revert" as const,
|
|
378
|
+
revertsTo: bootstrapped.generationId,
|
|
379
|
+
botId: "bot-1",
|
|
380
|
+
runId: "run-undo",
|
|
381
|
+
turnId: "turn-undo",
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const reverted = await store.revert(bootstrapped.generationId, origin, {
|
|
385
|
+
createdAt: "2026-09-02T00:00:00.000Z",
|
|
386
|
+
});
|
|
387
|
+
const replay = await store.revert(bootstrapped.generationId, origin, {
|
|
388
|
+
createdAt: "2026-09-02T00:00:00.000Z",
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(replay).toEqual(reverted);
|
|
392
|
+
expect((await store.lastKnownGood()).generationId).toBe(
|
|
393
|
+
authored.generationId,
|
|
394
|
+
);
|
|
395
|
+
await store.fail(reverted.generationId, { quarantined: false });
|
|
396
|
+
expect((await store.lastKnownGood()).generationId).toBe(
|
|
397
|
+
authored.generationId,
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
247
401
|
test("refuses an unknown target and the generation that is already current", async () => {
|
|
248
402
|
const storage = new MemoryStorage();
|
|
249
403
|
const store = createStore(
|
package/src/composition-store.ts
CHANGED
|
@@ -162,6 +162,8 @@ export class DurableCompositionStore implements CompositionStore {
|
|
|
162
162
|
await assertCompositionArtifactSetHashV1(proposed);
|
|
163
163
|
await this.materialize();
|
|
164
164
|
await this.ctx.storage.transaction(async (transaction) => {
|
|
165
|
+
const bootstrap = await this.bootstrapGeneration(transaction);
|
|
166
|
+
this.assertRequiredCoreSet(bootstrap, proposed);
|
|
165
167
|
const key = compositionGenerationKey(proposed.generationId);
|
|
166
168
|
if ((await transaction.get<unknown>(key)) !== undefined) {
|
|
167
169
|
throw new Error(
|
|
@@ -342,6 +344,7 @@ export class DurableCompositionStore implements CompositionStore {
|
|
|
342
344
|
async revert(
|
|
343
345
|
toGenerationId: string,
|
|
344
346
|
origin: Extract<CompositionOriginV1, { kind: "revert" }>,
|
|
347
|
+
options: { createdAt?: string } = {},
|
|
345
348
|
): Promise<CompositionGenerationV1> {
|
|
346
349
|
if (origin.kind !== "revert" || origin.revertsTo !== toGenerationId) {
|
|
347
350
|
throw new Error("composition revert origin does not name its target");
|
|
@@ -356,7 +359,7 @@ export class DurableCompositionStore implements CompositionStore {
|
|
|
356
359
|
if (!target) {
|
|
357
360
|
throw new Error(`composition generation "${toGenerationId}" is unknown`);
|
|
358
361
|
}
|
|
359
|
-
const createdAt = this.now().toISOString();
|
|
362
|
+
const createdAt = options.createdAt ?? this.now().toISOString();
|
|
360
363
|
const generation = decodeCompositionGenerationV1({
|
|
361
364
|
schemaVersion: 1,
|
|
362
365
|
generationId: compositionGenerationIdV1(
|
|
@@ -370,6 +373,18 @@ export class DurableCompositionStore implements CompositionStore {
|
|
|
370
373
|
members: target.members,
|
|
371
374
|
status: "pending",
|
|
372
375
|
});
|
|
376
|
+
const existing = await this.read(generation.generationId);
|
|
377
|
+
if (existing) {
|
|
378
|
+
if (
|
|
379
|
+
existing.artifactSetHash === generation.artifactSetHash &&
|
|
380
|
+
JSON.stringify(existing.origin) === JSON.stringify(origin)
|
|
381
|
+
) {
|
|
382
|
+
return existing;
|
|
383
|
+
}
|
|
384
|
+
throw new Error(
|
|
385
|
+
`composition generation "${generation.generationId}" already exists for another operation`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
373
388
|
await this.propose(generation, { pin: true });
|
|
374
389
|
return generation;
|
|
375
390
|
}
|
|
@@ -431,6 +446,34 @@ export class DurableCompositionStore implements CompositionStore {
|
|
|
431
446
|
return decodeCompositionGenerationV1(stored);
|
|
432
447
|
}
|
|
433
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Every first-party bootstrap member is required core. No proposal path may
|
|
451
|
+
* remove it or replace its provenance: callers can update reviewed
|
|
452
|
+
* first-party members, but cannot turn them into User- or Bot-authored code.
|
|
453
|
+
*/
|
|
454
|
+
private assertRequiredCoreSet(
|
|
455
|
+
bootstrap: CompositionGenerationV1,
|
|
456
|
+
proposed: CompositionGenerationV1,
|
|
457
|
+
): void {
|
|
458
|
+
for (const required of bootstrap.members.filter(
|
|
459
|
+
(member) => member.provenance.kind === "first-party",
|
|
460
|
+
)) {
|
|
461
|
+
const candidate = proposed.members.find(
|
|
462
|
+
(member) => member.packageId === required.packageId,
|
|
463
|
+
);
|
|
464
|
+
if (!candidate) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`composition generation "${proposed.generationId}" omits required first-party Package "${required.packageId}"`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (candidate.provenance.kind !== "first-party") {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`composition generation "${proposed.generationId}" replaces required first-party Package "${required.packageId}" with ${candidate.provenance.kind} provenance`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
434
477
|
/**
|
|
435
478
|
* The durable, visible record that quarantine had no last known good to fail
|
|
436
479
|
* into. Written against the generation that was named last known good, whose
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
botTurnCommandFingerprintV1,
|
|
4
|
+
createStoredRunCodecV1,
|
|
5
|
+
} from "./run-records.js";
|
|
6
|
+
import { planBotRunRecovery } from "./run-recovery.js";
|
|
7
|
+
|
|
8
|
+
describe("durable direct tool commands", () => {
|
|
9
|
+
const directTool = {
|
|
10
|
+
generationId: "generation-1",
|
|
11
|
+
packageId: "weather-page",
|
|
12
|
+
name: "weather_lookup",
|
|
13
|
+
input: { city: "Sydney" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("survives the exact stored-run codec for eviction recovery", () => {
|
|
17
|
+
const codec = createStoredRunCodecV1({
|
|
18
|
+
decodeRunId: (value) => {
|
|
19
|
+
if (typeof value !== "string") throw new Error("invalid");
|
|
20
|
+
return value;
|
|
21
|
+
},
|
|
22
|
+
decodeConfigurationSnapshot: (value) => value,
|
|
23
|
+
});
|
|
24
|
+
const run = codec.require({
|
|
25
|
+
runId: "command-1",
|
|
26
|
+
commandFingerprint: "fingerprint",
|
|
27
|
+
sessionId: "user:bot",
|
|
28
|
+
acceptedAt: "2026-09-02T00:00:00.000Z",
|
|
29
|
+
input: "Weather page · weather_lookup",
|
|
30
|
+
events: [],
|
|
31
|
+
effectAdmissions: [],
|
|
32
|
+
status: "running",
|
|
33
|
+
phase: "admitted",
|
|
34
|
+
compositionGenerationId: "generation-1",
|
|
35
|
+
configurationSnapshot: {},
|
|
36
|
+
previousEventCount: 0,
|
|
37
|
+
directTool,
|
|
38
|
+
});
|
|
39
|
+
expect(run.directTool).toEqual(directTool);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("is part of the admitted command idempotency fingerprint", () => {
|
|
43
|
+
const base = {
|
|
44
|
+
userId: "user",
|
|
45
|
+
botId: "bot",
|
|
46
|
+
runId: "command-1",
|
|
47
|
+
sessionId: "user:bot",
|
|
48
|
+
acceptedAt: "2026-09-02T00:00:00.000Z",
|
|
49
|
+
text: "Weather page · weather_lookup",
|
|
50
|
+
};
|
|
51
|
+
expect(botTurnCommandFingerprintV1({ ...base, directTool })).not.toBe(
|
|
52
|
+
botTurnCommandFingerprintV1(base),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("resumes after eviction without requiring a synthetic model request", () => {
|
|
57
|
+
const codec = createStoredRunCodecV1({
|
|
58
|
+
decodeRunId: (value) => {
|
|
59
|
+
if (typeof value !== "string") throw new Error("invalid");
|
|
60
|
+
return value;
|
|
61
|
+
},
|
|
62
|
+
decodeConfigurationSnapshot: (value) => value,
|
|
63
|
+
});
|
|
64
|
+
const run = codec.require({
|
|
65
|
+
runId: "command-1",
|
|
66
|
+
commandFingerprint: "fingerprint",
|
|
67
|
+
sessionId: "user:bot",
|
|
68
|
+
acceptedAt: "2026-09-02T00:00:00.000Z",
|
|
69
|
+
input: "Weather page · weather_lookup",
|
|
70
|
+
events: [],
|
|
71
|
+
effectAdmissions: [],
|
|
72
|
+
status: "running",
|
|
73
|
+
phase: "executing",
|
|
74
|
+
compositionGenerationId: "generation-1",
|
|
75
|
+
configurationSnapshot: {},
|
|
76
|
+
previousEventCount: 0,
|
|
77
|
+
directTool,
|
|
78
|
+
});
|
|
79
|
+
expect(planBotRunRecovery(run, [], codec)).toEqual({ kind: "resume" });
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/run-records.ts
CHANGED
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
* The kernel records the Composition/configuration snapshot a Turn was admitted
|
|
12
12
|
* under, but never interprets it: the owning Package supplies the decoder.
|
|
13
13
|
*/
|
|
14
|
-
export interface StoredRunCodecOptionsV1 {
|
|
14
|
+
export interface StoredRunCodecOptionsV1<Snapshot> {
|
|
15
15
|
decodeRunId(value: unknown): string;
|
|
16
|
-
decodeConfigurationSnapshot(value: unknown):
|
|
16
|
+
decodeConfigurationSnapshot(value: unknown): Snapshot;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export interface StoredRunCodecV1<Snapshot> {
|
|
@@ -112,6 +112,15 @@ export interface StoredRunV1<Snapshot = unknown> {
|
|
|
112
112
|
previousEventCount: number;
|
|
113
113
|
/** Absent ⇒ the run was admitted as a `chat` Turn. */
|
|
114
114
|
admission?: StoredRunAdmissionV1;
|
|
115
|
+
/** An ordinary admitted Turn whose only action is one caller-selected tool. */
|
|
116
|
+
directTool?: DirectToolCommandV1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface DirectToolCommandV1 {
|
|
120
|
+
generationId: string;
|
|
121
|
+
packageId: string;
|
|
122
|
+
name: string;
|
|
123
|
+
input: unknown;
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
/** The subagent role a stored run re-mounts under, if any. */
|
|
@@ -185,6 +194,7 @@ const STORED_RUN_OPTIONAL_KEYS = [
|
|
|
185
194
|
"failure",
|
|
186
195
|
"stopRequestedAt",
|
|
187
196
|
"admission",
|
|
197
|
+
"directTool",
|
|
188
198
|
] as const;
|
|
189
199
|
const UTF8_ENCODER = new TextEncoder();
|
|
190
200
|
|
|
@@ -200,6 +210,44 @@ function boundedString(
|
|
|
200
210
|
);
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
function decodeDirectToolCommandV1(value: unknown): DirectToolCommandV1 {
|
|
214
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
215
|
+
throw new Error("stored run has invalid direct tool command");
|
|
216
|
+
}
|
|
217
|
+
const candidate = value as Record<PropertyKey, unknown>;
|
|
218
|
+
const fields = ["generationId", "packageId", "name", "input"];
|
|
219
|
+
if (
|
|
220
|
+
Reflect.ownKeys(candidate).length !== fields.length ||
|
|
221
|
+
Object.keys(candidate).length !== fields.length ||
|
|
222
|
+
!fields.every((field) => Object.hasOwn(candidate, field)) ||
|
|
223
|
+
!boundedString(candidate.generationId, 256) ||
|
|
224
|
+
!boundedString(candidate.packageId, 64) ||
|
|
225
|
+
!boundedString(candidate.name, 64) ||
|
|
226
|
+
!/^[a-z][a-z0-9_]{0,63}$/.test(candidate.name)
|
|
227
|
+
) {
|
|
228
|
+
throw new Error("stored run has invalid direct tool command fields");
|
|
229
|
+
}
|
|
230
|
+
let input: unknown;
|
|
231
|
+
try {
|
|
232
|
+
const encoded = JSON.stringify(candidate.input);
|
|
233
|
+
if (
|
|
234
|
+
encoded === undefined ||
|
|
235
|
+
UTF8_ENCODER.encode(encoded).byteLength > 64_000
|
|
236
|
+
) {
|
|
237
|
+
throw new Error("invalid input");
|
|
238
|
+
}
|
|
239
|
+
input = JSON.parse(encoded) as unknown;
|
|
240
|
+
} catch {
|
|
241
|
+
throw new Error("stored run has invalid direct tool input");
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
generationId: candidate.generationId,
|
|
245
|
+
packageId: candidate.packageId,
|
|
246
|
+
name: candidate.name,
|
|
247
|
+
input,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
203
251
|
/**
|
|
204
252
|
* Exact fields, per origin kind. Each kind gets its own branch rather than a
|
|
205
253
|
* union of optional fields: a `routine` origin carrying a `taskId` is not a
|
|
@@ -374,7 +422,7 @@ function decodeStoredEffectAdmissions(value: unknown): StoredEffectAdmission[] {
|
|
|
374
422
|
}
|
|
375
423
|
|
|
376
424
|
export function createStoredRunCodecV1<Snapshot>(
|
|
377
|
-
options: StoredRunCodecOptionsV1
|
|
425
|
+
options: StoredRunCodecOptionsV1<Snapshot>,
|
|
378
426
|
): StoredRunCodecV1<Snapshot> {
|
|
379
427
|
const require = (input: unknown): StoredRunV1<Snapshot> =>
|
|
380
428
|
requireStoredRunRecordV1(input, options);
|
|
@@ -386,7 +434,7 @@ export function createStoredRunCodecV1<Snapshot>(
|
|
|
386
434
|
|
|
387
435
|
function requireStoredRunRecordV1<Snapshot>(
|
|
388
436
|
input: unknown,
|
|
389
|
-
options: StoredRunCodecOptionsV1
|
|
437
|
+
options: StoredRunCodecOptionsV1<Snapshot>,
|
|
390
438
|
): StoredRunV1<Snapshot> {
|
|
391
439
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
392
440
|
throw new Error("stored run is invalid");
|
|
@@ -451,7 +499,9 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
451
499
|
) {
|
|
452
500
|
throw new Error(`run "${runId}" has no valid previous event count`);
|
|
453
501
|
}
|
|
454
|
-
options.decodeConfigurationSnapshot(
|
|
502
|
+
const configurationSnapshot = options.decodeConfigurationSnapshot(
|
|
503
|
+
candidate.configurationSnapshot,
|
|
504
|
+
);
|
|
455
505
|
if (
|
|
456
506
|
candidate.responseText !== undefined &&
|
|
457
507
|
!boundedString(candidate.responseText, 64_000, true)
|
|
@@ -505,7 +555,7 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
505
555
|
status,
|
|
506
556
|
phase,
|
|
507
557
|
compositionGenerationId: candidate.compositionGenerationId,
|
|
508
|
-
configurationSnapshot
|
|
558
|
+
configurationSnapshot,
|
|
509
559
|
previousEventCount: candidate.previousEventCount as number,
|
|
510
560
|
...(candidate.responseText === undefined
|
|
511
561
|
? {}
|
|
@@ -519,6 +569,9 @@ function requireStoredRunRecordV1<Snapshot>(
|
|
|
519
569
|
...(candidate.admission === undefined
|
|
520
570
|
? {}
|
|
521
571
|
: { admission: decodeStoredRunAdmission(candidate.admission, runId) }),
|
|
572
|
+
...(candidate.directTool === undefined
|
|
573
|
+
? {}
|
|
574
|
+
: { directTool: decodeDirectToolCommandV1(candidate.directTool) }),
|
|
522
575
|
};
|
|
523
576
|
}
|
|
524
577
|
|
|
@@ -548,6 +601,7 @@ export interface BotTurnCommand {
|
|
|
548
601
|
* command, so it must not collide on an idempotency record.
|
|
549
602
|
*/
|
|
550
603
|
skills?: SkillRefV1[];
|
|
604
|
+
directTool?: DirectToolCommandV1;
|
|
551
605
|
}
|
|
552
606
|
|
|
553
607
|
/**
|
|
@@ -566,7 +620,8 @@ export function botTurnCommandFingerprintV1(
|
|
|
566
620
|
turnType !== "chat" ||
|
|
567
621
|
command.origin !== undefined ||
|
|
568
622
|
command.subagentRole !== undefined ||
|
|
569
|
-
skills.length > 0
|
|
623
|
+
skills.length > 0 ||
|
|
624
|
+
command.directTool !== undefined
|
|
570
625
|
) {
|
|
571
626
|
return `bot-turn-command-v2:${JSON.stringify({
|
|
572
627
|
userId: command.userId,
|
|
@@ -577,6 +632,7 @@ export function botTurnCommandFingerprintV1(
|
|
|
577
632
|
...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
|
|
578
633
|
...(command.origin ? { origin: command.origin } : {}),
|
|
579
634
|
...(skills.length > 0 ? { skills: skills.map(formatSkillRefV1) } : {}),
|
|
635
|
+
...(command.directTool ? { directTool: command.directTool } : {}),
|
|
580
636
|
})}`;
|
|
581
637
|
}
|
|
582
638
|
return `bot-turn-command-v1:${JSON.stringify({
|
package/src/run-recovery.ts
CHANGED
|
@@ -97,6 +97,11 @@ export function planBotRunRecovery<Snapshot>(
|
|
|
97
97
|
lastAssistant?.type === "assistant/message" ? lastAssistant.text : "",
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
+
// A direct tool Turn has no model request by construction. Its single
|
|
101
|
+
// synthetic assistant/tool occurrence is nevertheless resumable at every
|
|
102
|
+
// durable boundary: before intent it can start, after intent it reconciles,
|
|
103
|
+
// and after result it only needs its terminal events appended.
|
|
104
|
+
if (run.directTool) return { kind: "resume" };
|
|
100
105
|
const modelState = latestModelRequestJournalState(run.events);
|
|
101
106
|
if (modelState.status === "no-effect") {
|
|
102
107
|
try {
|