tinker-agent 2.7.0 → 2.9.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +64 -10
  3. package/package.json +4 -3
  4. package/src/agent/runtime-context-capabilities.ts +19 -0
  5. package/src/agent/runtime-context-events.ts +127 -0
  6. package/src/agent/runtime-context-maintenance.ts +780 -0
  7. package/src/agent/runtime-interactions.ts +291 -0
  8. package/src/agent/runtime-prompt-scheduler.ts +182 -0
  9. package/src/agent/runtime-session-contracts.ts +317 -0
  10. package/src/agent/runtime-session.ts +253 -2117
  11. package/src/agent/runtime-skills.ts +544 -0
  12. package/src/cli/runner-dependencies.ts +6 -5
  13. package/src/context/context-automation-policy.ts +12 -118
  14. package/src/events/types.ts +13 -1
  15. package/src/memory/memory-get-tool.ts +1 -1
  16. package/src/observation/observation-builder.ts +41 -11
  17. package/src/session/resume-projection.ts +47 -21
  18. package/src/session/session-history-access.ts +238 -0
  19. package/src/session/session-store-context-readers.ts +183 -0
  20. package/src/session/session-store-ledger-writer.ts +315 -0
  21. package/src/session/session-store-record-writer.ts +318 -0
  22. package/src/session/session-store-recovery.ts +225 -0
  23. package/src/session/session-store-revisions.ts +1004 -0
  24. package/src/session/session-store-sql.ts +40 -0
  25. package/src/session/session-store-validation.ts +657 -0
  26. package/src/session/session-store.ts +756 -3186
  27. package/src/tools/bash-task.ts +20 -2
  28. package/src/tools/bash.ts +1 -1
  29. package/src/tools/context-maintenance.ts +1 -1
  30. package/src/tools/read.ts +1 -1
  31. package/src/tools/recall.ts +106 -50
  32. package/src/tools/registry.ts +4 -6
  33. package/src/tools/task-output-range.ts +146 -0
  34. package/src/tools/task-output-tool.ts +35 -5
  35. package/src/tools/task-output.ts +35 -0
  36. package/src/tools/task-tool-args.ts +34 -0
  37. package/src/tools/types.ts +9 -0
  38. package/src/tools/wait.ts +1 -3
  39. package/src/tui/event-store.ts +8 -3
@@ -0,0 +1,40 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export function runTransaction<T>(database: Database, operation: () => T): T {
4
+ database.exec("BEGIN IMMEDIATE");
5
+ try {
6
+ const result = operation();
7
+ database.exec("COMMIT");
8
+ return result;
9
+ } catch (error) {
10
+ try {
11
+ database.exec("ROLLBACK");
12
+ } catch {
13
+ // Preserve the mutation error; the session will fault and close the database.
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ export function requireSingleChange(
20
+ database: Database,
21
+ reportedChanges: number | bigint,
22
+ operation: string,
23
+ ): void {
24
+ const row = database.query("SELECT changes() AS changes").get() as {
25
+ changes: number | bigint;
26
+ };
27
+ if (Number(row.changes) !== 1) {
28
+ throw new Error(
29
+ `${operation} must change exactly one row; changed ${row.changes} (driver reported ${reportedChanges}).`,
30
+ );
31
+ }
32
+ }
33
+
34
+ export function requireItem<T>(items: readonly T[], index: number, name: string): T {
35
+ const item = items[index];
36
+ if (item === undefined) {
37
+ throw new Error(`Missing ${name} at index ${index}.`);
38
+ }
39
+ return item;
40
+ }
@@ -0,0 +1,657 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { activeOverrideManifestHash } from "../context/compiled-context-hash";
3
+ import type {
4
+ StoredContextOverrideV8,
5
+ StoredContextRevisionV8,
6
+ StoredContextSnapshotV8,
7
+ SwapOverride,
8
+ } from "../context/context-revision";
9
+ import { ContextRevisionCompiler } from "../context/context-revision-compiler";
10
+ import {
11
+ contextSurfaceChangeManifestHash,
12
+ contextSurfaceChanges,
13
+ validateStoredContextSurface,
14
+ type StoredContextSurfaceV8,
15
+ } from "../context/context-surface";
16
+ import {
17
+ ContextSwapRenderer,
18
+ SWAP_OBSERVATION_FORMAT,
19
+ SWAP_TOOL_IMAGE_FORMAT,
20
+ } from "../context/context-swap-renderer";
21
+ import {
22
+ type CanonicalMessageRecord,
23
+ type ProtocolContextView,
24
+ type ToolResultRecord,
25
+ } from "../context/protocol-frame";
26
+ import type {
27
+ ContextRevisionId,
28
+ ContextSurfaceId,
29
+ MessageId,
30
+ SessionId,
31
+ } from "../ids/runtime-id";
32
+ import { stableJsonStringify } from "../model/model-request-preflight";
33
+ import {
34
+ renderSkillActivationReceipt,
35
+ SKILL_ACTIVATION_RECEIPT_FORMAT,
36
+ } from "../skills/skill-context";
37
+ import type { SessionStore } from "./session-store";
38
+ import {
39
+ loadMeasuredContextState,
40
+ previousRevision,
41
+ requireActiveRevisionId,
42
+ } from "./session-store-context-readers";
43
+ import {
44
+ skillActivationManifestSha256,
45
+ type StoredSessionMetaV10,
46
+ } from "./session-store-contracts";
47
+ import {
48
+ decodeContextRevision,
49
+ decodeContextSurface,
50
+ decodeStoredSwapOverride,
51
+ protocolPrefixView,
52
+ stripStoredOverride,
53
+ } from "./session-store-record-codecs";
54
+ import { requireItem } from "./session-store-sql";
55
+ import {
56
+ enumFromSql,
57
+ nullableStringFromSql,
58
+ numberFromSql,
59
+ stringFromSql,
60
+ } from "./session-store-value-codecs";
61
+
62
+ /** Validates persisted state without mutating canonical history. */
63
+ export class SessionStoreValidation {
64
+ private readonly revisionCompiler = new ContextRevisionCompiler();
65
+ private readonly swapRenderer = new ContextSwapRenderer();
66
+ constructor(
67
+ private readonly database: Database,
68
+ private readonly sessionId: SessionId,
69
+ private readonly loadSkillActivations: SessionStore["loadSkillActivations"],
70
+ ) {}
71
+
72
+ loadValidatedContextSnapshot(
73
+ meta: StoredSessionMetaV10,
74
+ canonical: ProtocolContextView,
75
+ ): StoredContextSnapshotV8 {
76
+ const activeRevisionId = requireActiveRevisionId(meta);
77
+ const surfaces = this.database
78
+ .query("SELECT * FROM context_surfaces ORDER BY rowid")
79
+ .all()
80
+ .map(decodeContextSurface);
81
+ const surfacesById = new Map<ContextSurfaceId, StoredContextSurfaceV8>();
82
+ for (const surface of surfaces) {
83
+ validateStoredContextSurface(surface);
84
+ if (surface.sessionId !== this.sessionId || surfacesById.has(surface.surfaceId)) {
85
+ throw new Error("Context surface identity is invalid or duplicated.");
86
+ }
87
+ surfacesById.set(surface.surfaceId, surface);
88
+ }
89
+
90
+ const revisions = this.database
91
+ .query("SELECT * FROM context_revisions ORDER BY revision_number")
92
+ .all()
93
+ .map(decodeContextRevision);
94
+ if (revisions.length === 0) {
95
+ throw new Error("Session has no context revision.");
96
+ }
97
+
98
+ const revisionNumberById = new Map<ContextRevisionId, number>();
99
+ for (let index = 0; index < revisions.length; index += 1) {
100
+ const revision = requireItem(revisions, index, "context revision");
101
+ const previous = revisions[index - 1];
102
+ const surface = surfacesById.get(revision.surfaceId);
103
+ if (
104
+ revision.sessionId !== this.sessionId ||
105
+ revision.revisionNumber !== index + 1 ||
106
+ surface === undefined ||
107
+ surface.surfaceSha256 !== revision.surfaceSha256 ||
108
+ (index === 0
109
+ ? revision.kind !== "initial_full" || revision.parentRevisionId !== null
110
+ : revision.kind === "initial_full" ||
111
+ revision.parentRevisionId !== previous?.revisionId) ||
112
+ ((revision.kind === "swap_only" || revision.kind === "prefix_retirement") &&
113
+ revision.surfaceId !== previous?.surfaceId) ||
114
+ (revision.kind === "surface_refresh" &&
115
+ revision.surfaceId === previous?.surfaceId) ||
116
+ (previous !== undefined &&
117
+ (revision.keepFromOrdinal < previous.keepFromOrdinal ||
118
+ ((revision.kind === "swap_only" ||
119
+ revision.kind === "surface_refresh" ||
120
+ revision.kind === "skills_update") &&
121
+ revision.keepFromOrdinal !== previous.keepFromOrdinal) ||
122
+ (revision.kind === "prefix_retirement" &&
123
+ revision.keepFromOrdinal <= previous.keepFromOrdinal)))
124
+ ) {
125
+ throw new Error("Context revision chain is not linear and contiguous.");
126
+ }
127
+ const boundary = canonical.frames.find(
128
+ (frame) => frame.lastOrdinal === revision.sourceThroughOrdinal,
129
+ );
130
+ if (
131
+ revision.sourceThroughOrdinal > canonical.messages.length ||
132
+ boundary?.state !== "closed"
133
+ ) {
134
+ throw new Error(
135
+ `Context revision ${revision.revisionId} has an invalid source boundary.`,
136
+ );
137
+ }
138
+ revisionNumberById.set(revision.revisionId, revision.revisionNumber);
139
+ }
140
+ const introducedSurfaceIds = new Set(
141
+ revisions.flatMap((revision, index) => {
142
+ const previous = revisions[index - 1];
143
+ return revision.kind === "initial_full" ||
144
+ revision.kind === "surface_refresh" ||
145
+ (revision.kind === "skills_update" &&
146
+ revision.surfaceId !== previous?.surfaceId)
147
+ ? [revision.surfaceId]
148
+ : [];
149
+ }),
150
+ );
151
+ if (
152
+ introducedSurfaceIds.size !== surfaces.length ||
153
+ surfaces.some((surface) => !introducedSurfaceIds.has(surface.surfaceId))
154
+ ) {
155
+ throw new Error("Context surface chain contains an orphan or duplicate surface.");
156
+ }
157
+
158
+ const activeRevision = requireItem(
159
+ revisions,
160
+ revisions.length - 1,
161
+ "active context revision",
162
+ );
163
+ if (activeRevision.revisionId !== activeRevisionId) {
164
+ throw new Error("Active context revision is not the latest committed revision.");
165
+ }
166
+ const activeSurface = surfacesById.get(activeRevision.surfaceId);
167
+ if (activeSurface === undefined) {
168
+ throw new Error("Active context revision surface is missing.");
169
+ }
170
+
171
+ const overrides = this.database
172
+ .query(
173
+ `SELECT co.* FROM context_overrides co
174
+ JOIN context_revisions cr
175
+ ON cr.revision_id = co.introduced_revision_id
176
+ ORDER BY cr.revision_number, co.ordinal`,
177
+ )
178
+ .all()
179
+ .map(decodeStoredSwapOverride);
180
+ this.validateStoredOverrides(overrides, canonical, revisions, revisionNumberById);
181
+ this.validateSkillActivationRows(canonical, revisions, overrides, surfaces);
182
+
183
+ for (const revision of revisions) {
184
+ const surface = surfacesById.get(revision.surfaceId);
185
+ if (surface === undefined) {
186
+ throw new Error(`Context revision ${revision.revisionId} has no surface.`);
187
+ }
188
+ const activeOverrides = overrides.filter(
189
+ (override) =>
190
+ (revisionNumberById.get(override.introducedRevisionId) ??
191
+ Number.POSITIVE_INFINITY) <= revision.revisionNumber &&
192
+ override.ordinal >= revision.keepFromOrdinal,
193
+ );
194
+ const introducedCount = overrides.filter(
195
+ (override) => override.introducedRevisionId === revision.revisionId,
196
+ ).length;
197
+ if (
198
+ introducedCount !== revision.addedOverrideCount ||
199
+ activeOverrides.length !== revision.activeOverrideCount ||
200
+ activeOverrideManifestHash(activeOverrides) !==
201
+ revision.activeOverrideManifestSha256
202
+ ) {
203
+ throw new Error(
204
+ `Context revision ${revision.revisionId} override manifest is invalid.`,
205
+ );
206
+ }
207
+ if (
208
+ revision.kind === "surface_refresh" &&
209
+ previousRevision(revisions, revision)?.activeOverrideManifestSha256 !==
210
+ revision.activeOverrideManifestSha256
211
+ ) {
212
+ throw new Error(
213
+ `Context surface revision ${revision.revisionId} changed overrides.`,
214
+ );
215
+ }
216
+ if (revision.kind === "skills_update") {
217
+ const parent = previousRevision(revisions, revision);
218
+ const parentSurface =
219
+ parent === undefined ? undefined : surfacesById.get(parent.surfaceId);
220
+ const settlements = this.loadSkillActivations().filter(
221
+ (activation) => activation.settledRevisionId === revision.revisionId,
222
+ );
223
+ if (
224
+ parentSurface === undefined ||
225
+ contextSurfaceChangeManifestHash(
226
+ contextSurfaceChanges(parentSurface, surface),
227
+ ) !== revision.changeManifestSha256 ||
228
+ settlements.length !== revision.addedOverrideCount ||
229
+ skillActivationManifestSha256(
230
+ settlements.map((activation) => ({
231
+ activationMessageId: activation.activationMessageId,
232
+ name: activation.name,
233
+ state: activation.state === "promoted" ? "promoted" : "rejected",
234
+ ...(activation.rejectionReason === undefined
235
+ ? {}
236
+ : { rejectionReason: activation.rejectionReason }),
237
+ })),
238
+ ) !== revision.activationManifestSha256
239
+ ) {
240
+ throw new Error(
241
+ `Agent Skills revision ${revision.revisionId} manifest is invalid.`,
242
+ );
243
+ }
244
+ }
245
+ if (revision.kind === "prefix_retirement") {
246
+ const parent = previousRevision(revisions, revision);
247
+ if (parent === undefined) {
248
+ throw new Error("Prefix retirement revision has no parent.");
249
+ }
250
+ const retiredStart = Math.max(parent.keepFromOrdinal, 2);
251
+ const retiredMessages = canonical.messages.filter(
252
+ (message) =>
253
+ message.ordinal >= retiredStart &&
254
+ message.ordinal < revision.keepFromOrdinal,
255
+ );
256
+ const retiredFrames = canonical.frames.filter(
257
+ (frame) =>
258
+ frame.firstOrdinal >= retiredStart &&
259
+ (frame.lastOrdinal ?? Number.POSITIVE_INFINITY) < revision.keepFromOrdinal,
260
+ );
261
+ const retiredTurns = new Set(
262
+ retiredMessages.flatMap((message) =>
263
+ message.role === "system" ? [] : [message.turnId],
264
+ ),
265
+ );
266
+ if (
267
+ revision.retiredThroughOrdinal !== revision.keepFromOrdinal - 1 ||
268
+ revision.retiredMessageCount !== retiredMessages.length ||
269
+ revision.retiredFrameCount !== retiredFrames.length ||
270
+ revision.retiredTurnCount !== retiredTurns.size
271
+ ) {
272
+ throw new Error(
273
+ `Context retirement revision ${revision.revisionId} counts are invalid.`,
274
+ );
275
+ }
276
+ }
277
+ if (revision.kind === "surface_refresh") {
278
+ const parent = previousRevision(revisions, revision);
279
+ const parentSurface =
280
+ parent === undefined ? undefined : surfacesById.get(parent.surfaceId);
281
+ if (
282
+ parentSurface === undefined ||
283
+ contextSurfaceChangeManifestHash(
284
+ contextSurfaceChanges(parentSurface, surface),
285
+ ) !== revision.changeManifestSha256
286
+ ) {
287
+ throw new Error(
288
+ `Context surface revision ${revision.revisionId} change manifest is invalid.`,
289
+ );
290
+ }
291
+ }
292
+ const prefix = protocolPrefixView(canonical, revision.sourceThroughOrdinal);
293
+ this.revisionCompiler.compileActive({
294
+ meta: Object.freeze({
295
+ sessionId: this.sessionId,
296
+ activeRevisionId: revision.revisionId,
297
+ }),
298
+ revision,
299
+ surface,
300
+ activeOverrides,
301
+ canonical: prefix,
302
+ });
303
+ }
304
+
305
+ const measurement = loadMeasuredContextState(this.database, this.sessionId);
306
+ if (
307
+ measurement !== undefined &&
308
+ measurement.revisionId !== activeRevision.revisionId
309
+ ) {
310
+ throw new Error("Context measurement is not bound to the active revision.");
311
+ }
312
+
313
+ return Object.freeze({
314
+ meta: Object.freeze({
315
+ sessionId: meta.sessionId,
316
+ activeRevisionId,
317
+ }),
318
+ revision: activeRevision,
319
+ surface: activeSurface,
320
+ activeOverrides: Object.freeze(
321
+ overrides.filter(
322
+ (override) =>
323
+ (revisionNumberById.get(override.introducedRevisionId) ??
324
+ Number.POSITIVE_INFINITY) <= activeRevision.revisionNumber &&
325
+ override.ordinal >= activeRevision.keepFromOrdinal,
326
+ ),
327
+ ),
328
+ canonical,
329
+ });
330
+ }
331
+
332
+ private validateStoredOverrides(
333
+ overrides: readonly StoredContextOverrideV8[],
334
+ canonical: ProtocolContextView,
335
+ revisions: readonly StoredContextRevisionV8[],
336
+ revisionNumberById: ReadonlyMap<ContextRevisionId, number>,
337
+ ): void {
338
+ const messages = new Map(
339
+ canonical.messages.map((message) => [message.messageId, message] as const),
340
+ );
341
+ const frames = new Map(
342
+ canonical.frames.map((frame) => [frame.frameId, frame] as const),
343
+ );
344
+ const results = new Map(
345
+ canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
346
+ );
347
+ const revisionsById = new Map(
348
+ revisions.map((revision) => [revision.revisionId, revision] as const),
349
+ );
350
+ const seenMessages = new Set<MessageId>();
351
+ for (const override of overrides) {
352
+ const revision = revisionsById.get(override.introducedRevisionId);
353
+ const message = messages.get(override.messageId);
354
+ const frame = frames.get(override.frameId);
355
+ const result = results.get(override.messageId);
356
+ if (
357
+ (revision?.kind !== "swap_only" && revision?.kind !== "skills_update") ||
358
+ (revision.kind === "swap_only" &&
359
+ override.rendererFormat !== SWAP_OBSERVATION_FORMAT &&
360
+ override.rendererFormat !== SWAP_TOOL_IMAGE_FORMAT) ||
361
+ (revision.kind === "skills_update" &&
362
+ override.rendererFormat !== SKILL_ACTIVATION_RECEIPT_FORMAT) ||
363
+ revisionNumberById.get(revision.revisionId) === undefined ||
364
+ override.ordinal < revision.keepFromOrdinal ||
365
+ override.ordinal > revision.sourceThroughOrdinal ||
366
+ seenMessages.has(override.messageId) ||
367
+ message?.role !== "tool" ||
368
+ message.frameId !== override.frameId ||
369
+ message.ordinal !== override.ordinal ||
370
+ frame?.kind !== "tool_exchange" ||
371
+ frame.state !== "closed" ||
372
+ result === undefined
373
+ ) {
374
+ throw new Error("Stored context override canonical identity is invalid.");
375
+ }
376
+ const rendered =
377
+ override.rendererFormat === SWAP_OBSERVATION_FORMAT ||
378
+ override.rendererFormat === SWAP_TOOL_IMAGE_FORMAT
379
+ ? this.swapRenderer.render({ message, result })
380
+ : this.renderStoredSkillReceipt(message, result, revision.revisionId);
381
+ if (
382
+ stableJsonStringify(rendered) !==
383
+ stableJsonStringify(stripStoredOverride(override))
384
+ ) {
385
+ throw new Error(
386
+ `Stored context override ${override.messageId} does not match deterministic rendering.`,
387
+ );
388
+ }
389
+ seenMessages.add(override.messageId);
390
+ }
391
+ }
392
+
393
+ private validateSkillActivationRows(
394
+ canonical: ProtocolContextView,
395
+ revisions: readonly StoredContextRevisionV8[],
396
+ overrides: readonly StoredContextOverrideV8[],
397
+ surfaces: readonly StoredContextSurfaceV8[],
398
+ ): void {
399
+ const activations = this.loadSkillActivations();
400
+ const activationByMessage = new Map(
401
+ activations.map((activation) => [activation.activationMessageId, activation]),
402
+ );
403
+ const messages = new Map(
404
+ canonical.messages.map((message) => [message.messageId, message]),
405
+ );
406
+ const results = new Map(
407
+ canonical.toolResults.map((result) => [result.toolMessageId, result]),
408
+ );
409
+ const loadedResults = canonical.toolResults.filter(
410
+ (result) =>
411
+ result.completion.kind === "returned" &&
412
+ result.completion.raw.kind === "skill" &&
413
+ result.completion.raw.ok &&
414
+ result.completion.raw.status === "loaded",
415
+ );
416
+ if (loadedResults.length !== activations.length) {
417
+ throw new Error("Loaded Agent Skill results and activation rows differ.");
418
+ }
419
+ const revisionsById = new Map(
420
+ revisions.map((revision) => [revision.revisionId, revision]),
421
+ );
422
+ const overridesByMessage = new Map(
423
+ overrides.map((override) => [override.messageId, override]),
424
+ );
425
+ for (const activation of activations) {
426
+ const message = messages.get(activation.activationMessageId);
427
+ const result = results.get(activation.activationMessageId);
428
+ const raw =
429
+ result?.completion.kind === "returned" ? result.completion.raw : undefined;
430
+ if (
431
+ activation.sessionId !== this.sessionId ||
432
+ message?.role !== "tool" ||
433
+ message.name !== "Skill" ||
434
+ message.toolCallId !== activation.toolCallId ||
435
+ raw?.kind !== "skill" ||
436
+ !raw.ok ||
437
+ raw.status !== "loaded" ||
438
+ raw.name !== activation.name ||
439
+ raw.scope !== activation.scope ||
440
+ raw.sha256 !== activation.skillFileSha256
441
+ ) {
442
+ throw new Error("Agent Skill activation canonical identity is invalid.");
443
+ }
444
+ if (activation.settledRevisionId !== undefined) {
445
+ const revision = revisionsById.get(activation.settledRevisionId);
446
+ const override = overridesByMessage.get(activation.activationMessageId);
447
+ if (
448
+ revision?.kind !== "skills_update" ||
449
+ override?.introducedRevisionId !== revision.revisionId ||
450
+ override.rendererFormat !== SKILL_ACTIVATION_RECEIPT_FORMAT
451
+ ) {
452
+ throw new Error("Settled Agent Skill activation receipt is invalid.");
453
+ }
454
+ }
455
+ }
456
+ for (const surface of surfaces) {
457
+ for (const active of surface.activeSkills) {
458
+ const activation = activationByMessage.get(active.activationMessageId);
459
+ if (activation?.state !== "promoted" || activation.name !== active.name) {
460
+ throw new Error(
461
+ "Context surface references an invalid Agent Skill activation.",
462
+ );
463
+ }
464
+ }
465
+ }
466
+ }
467
+
468
+ private renderStoredSkillReceipt(
469
+ message: Extract<CanonicalMessageRecord, { role: "tool" }>,
470
+ result: ToolResultRecord,
471
+ revisionId: ContextRevisionId,
472
+ ): SwapOverride {
473
+ if (
474
+ message.name !== "Skill" ||
475
+ result.completion.kind !== "returned" ||
476
+ result.completion.raw.kind !== "skill" ||
477
+ !result.completion.raw.ok ||
478
+ result.completion.raw.status !== "loaded"
479
+ ) {
480
+ throw new Error("Agent Skill receipt does not target a loaded Skill result.");
481
+ }
482
+ const activation = this.loadSkillActivations().find(
483
+ (entry) => entry.activationMessageId === message.messageId,
484
+ );
485
+ if (
486
+ activation === undefined ||
487
+ activation.settledRevisionId !== revisionId ||
488
+ (activation.state !== "promoted" && activation.state !== "rejected")
489
+ ) {
490
+ throw new Error("Agent Skill receipt has no matching settled activation.");
491
+ }
492
+ return renderSkillActivationReceipt({
493
+ message: {
494
+ messageId: message.messageId,
495
+ frameId: message.frameId,
496
+ ordinal: message.ordinal,
497
+ content: message.displayText,
498
+ contentSha256: message.contentSha256,
499
+ },
500
+ name: activation.name,
501
+ outcome:
502
+ activation.state === "promoted"
503
+ ? "promoted"
504
+ : activation.rejectionReason === "unavailable"
505
+ ? "unavailable"
506
+ : "rejected",
507
+ });
508
+ }
509
+
510
+ validateCounters(meta: StoredSessionMetaV10, view: ProtocolContextView): void {
511
+ const turns = this.database
512
+ .query("SELECT * FROM turns ORDER BY turn_number")
513
+ .all() as Array<Record<string, unknown>>;
514
+ for (let index = 0; index < turns.length; index += 1) {
515
+ const turn = turns[index];
516
+ if (numberFromSql(turn.turn_number, "turn_number") !== index + 1) {
517
+ throw new Error("Turn number sequence has a gap.");
518
+ }
519
+ const turnId = stringFromSql(turn.turn_id, "turn_id");
520
+ const turnStatus = enumFromSql(
521
+ turn.status,
522
+ ["open", "completed", "failed", "cancelled", "interrupted"] as const,
523
+ "turn status",
524
+ );
525
+ const iterations = this.database
526
+ .query("SELECT * FROM iterations WHERE turn_id = ? ORDER BY iteration_number")
527
+ .all(turnId) as Array<Record<string, unknown>>;
528
+ const storedLastIterationId = nullableStringFromSql(
529
+ turn.last_iteration_id,
530
+ "last_iteration_id",
531
+ );
532
+ const actualLastIterationId =
533
+ iterations.length === 0
534
+ ? null
535
+ : stringFromSql(iterations.at(-1)!.iteration_id, "iteration_id");
536
+ if (storedLastIterationId !== actualLastIterationId) {
537
+ throw new Error(`Last iteration identity is invalid in turn ${turnId}.`);
538
+ }
539
+ for (
540
+ let iterationIndex = 0;
541
+ iterationIndex < iterations.length;
542
+ iterationIndex += 1
543
+ ) {
544
+ const iteration = iterations[iterationIndex];
545
+ if (
546
+ numberFromSql(iteration.iteration_number, "iteration_number") !==
547
+ iterationIndex + 1
548
+ ) {
549
+ throw new Error(`Iteration number sequence has a gap in turn ${turnId}.`);
550
+ }
551
+ const iterationId = stringFromSql(iteration.iteration_id, "iteration_id");
552
+ const outcome = enumFromSql(
553
+ iteration.outcome,
554
+ [
555
+ "open",
556
+ "continue",
557
+ "completed",
558
+ "failed",
559
+ "cancelled",
560
+ "interrupted",
561
+ ] as const,
562
+ "iteration outcome",
563
+ );
564
+ if (iterationIndex < iterations.length - 1 && outcome !== "continue") {
565
+ throw new Error(
566
+ `Non-final iteration ${iterationId} must have continue outcome.`,
567
+ );
568
+ }
569
+ const toolCalls = view.messages.flatMap((message) =>
570
+ message.role === "assistant" && message.iterationId === iterationId
571
+ ? (message.toolCalls ?? [])
572
+ : [],
573
+ );
574
+ if (
575
+ numberFromSql(iteration.next_tool_call_number, "next_tool_call_number") !==
576
+ toolCalls.length + 1
577
+ ) {
578
+ throw new Error(`Tool call counter is invalid in iteration ${iterationId}.`);
579
+ }
580
+ }
581
+ if (
582
+ numberFromSql(turn.next_iteration_number, "next_iteration_number") !==
583
+ iterations.length + 1
584
+ ) {
585
+ throw new Error(`Iteration counter is invalid in turn ${turnId}.`);
586
+ }
587
+ const openIterationCount = iterations.filter(
588
+ (iteration) => iteration.outcome === "open",
589
+ ).length;
590
+ if (turnStatus === "open" && openIterationCount > 1) {
591
+ throw new Error(`Open turn ${turnId} has multiple open iterations.`);
592
+ }
593
+ if (turnStatus !== "open" && openIterationCount !== 0) {
594
+ throw new Error(`Terminal turn ${turnId} still has an open iteration.`);
595
+ }
596
+ const lastOutcome = iterations.at(-1)?.outcome;
597
+ if (
598
+ turnStatus !== "open" &&
599
+ iterations.length > 0 &&
600
+ lastOutcome !== turnStatus &&
601
+ !(turnStatus === "interrupted" && lastOutcome === "continue")
602
+ ) {
603
+ throw new Error(
604
+ `Terminal turn ${turnId} does not match its last iteration outcome.`,
605
+ );
606
+ }
607
+ const finalMessageId = nullableStringFromSql(
608
+ turn.final_message_id,
609
+ "final_message_id",
610
+ );
611
+ if (turnStatus === "completed") {
612
+ const finalMessage = view.messages.find(
613
+ (message) => message.messageId === finalMessageId,
614
+ );
615
+ const lastTurnMessage = [...view.messages]
616
+ .reverse()
617
+ .find((message) => "turnId" in message && message.turnId === turnId);
618
+ if (
619
+ finalMessage?.role !== "assistant" ||
620
+ finalMessage.turnId !== turnId ||
621
+ (finalMessage.toolCalls?.length ?? 0) !== 0 ||
622
+ lastTurnMessage?.messageId !== finalMessage.messageId
623
+ ) {
624
+ throw new Error(`Final message identity is invalid in turn ${turnId}.`);
625
+ }
626
+ } else if (finalMessageId !== null) {
627
+ throw new Error(`Non-completed turn ${turnId} has a final message.`);
628
+ }
629
+ }
630
+ if (meta.nextTurnNumber !== turns.length + 1) {
631
+ throw new Error("Session turn counter is invalid.");
632
+ }
633
+ }
634
+
635
+ validateAddedOverrides(
636
+ overrides: readonly SwapOverride[],
637
+ canonical: ProtocolContextView,
638
+ ): void {
639
+ const messages = new Map(
640
+ canonical.messages.map((message) => [message.messageId, message] as const),
641
+ );
642
+ const results = new Map(
643
+ canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
644
+ );
645
+ for (const override of overrides) {
646
+ const message = messages.get(override.messageId);
647
+ const result = results.get(override.messageId);
648
+ if (message?.role !== "tool" || result === undefined) {
649
+ throw new Error("Added context override does not target a tool result.");
650
+ }
651
+ const expected = this.swapRenderer.render({ message, result });
652
+ if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
653
+ throw new Error("Added context override is not deterministic.");
654
+ }
655
+ }
656
+ }
657
+ }