narraeon 0.1.1 → 0.3.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 (29) hide show
  1. package/dist/node/protocol/modelConnections.js +49 -0
  2. package/dist/node/protocol/modelUsage.js +39 -0
  3. package/dist/node/protocol/v1.js +35 -0
  4. package/dist/node/runtime/FileNativePhysicalClone.js +86 -0
  5. package/dist/node/runtime/V1Runtime.js +66 -29
  6. package/dist/node/runtime/model/FileNativeModelAdapters.js +730 -287
  7. package/dist/node/runtime/model/ModelConnectionStore.js +123 -2
  8. package/dist/node/runtime/model/ModelHost.js +81 -9
  9. package/dist/node/runtime/model/ModelHostStream.js +61 -1
  10. package/dist/node/runtime/model/SettingAuthorStream.js +16 -113
  11. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +269 -0
  12. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +1128 -0
  13. package/dist/node/runtime/play/PlayCallChain.js +904 -369
  14. package/dist/node/runtime/play/PlayDocumentTools.js +35 -0
  15. package/dist/node/runtime/play/PlayFollowupRequests.js +43 -26
  16. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +50 -35
  17. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +55 -29
  18. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +9 -4
  19. package/dist/node/runtime/world/FileNativeAuthorityV3.js +971 -0
  20. package/dist/node/runtime/world/FileNativeHistoryProjection.js +39 -0
  21. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +1067 -0
  22. package/dist/node/runtime/world/FileNativeWorldStore.js +615 -784
  23. package/dist/node/server/createServer.js +10 -2
  24. package/dist/web/assets/index-D5VqDvjY.js +1192 -0
  25. package/dist/web/assets/index-Xa0s35CJ.css +1 -0
  26. package/dist/web/index.html +2 -2
  27. package/package.json +1 -1
  28. package/dist/web/assets/index-CRGTZ9Ak.css +0 -1
  29. package/dist/web/assets/index-DDx1itzT.js +0 -1192
@@ -1,14 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { equalModelHostBinding, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "../model/ModelHost.js";
2
3
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
3
4
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
4
- import { FileNativePlayDocuments, fingerprintControl, isPlayDocumentAuthorizationCheckpoint, } from "./PlayDocumentTools.js";
5
+ import { FileNativePlayDocuments, fingerprintControl, } from "./PlayDocumentTools.js";
5
6
  const callChainToolNames = new Set([
7
+ "state_list",
8
+ "history_list",
9
+ // Kept executable only for contexts whose frozen tool universe contains it.
6
10
  "context_list",
7
11
  "context_search",
8
12
  "context_read",
9
13
  "world_patch",
10
14
  "world_create",
11
15
  ]);
16
+ const projectedEventTail = 40;
12
17
  export class PlayCallChainError extends Error {
13
18
  constructor(message) {
14
19
  super(message);
@@ -17,8 +22,10 @@ export class PlayCallChainError extends Error {
17
22
  }
18
23
  /**
19
24
  * The production play loop: one durable logical context, no Runtime-authored
20
- * adjudication/narration phases, and immediate Authority commits after every
21
- * completed model response that contains visible text or world changes.
25
+ * adjudication/narration phases. Responses with tool calls are intermediate
26
+ * tool steps: their Provider-native payload remains appendable, while only
27
+ * their world changes may advance Authority. A tool-free response is the sole
28
+ * response shape whose non-empty text can become committed narrative.
22
29
  */
23
30
  export class PlayCallChain {
24
31
  #worlds;
@@ -27,6 +34,8 @@ export class PlayCallChain {
27
34
  #failureLog;
28
35
  #active = new Map();
29
36
  #worldChains = new Map();
37
+ #loadedCursors = new Map();
38
+ #activeInvocations = new Map();
30
39
  constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog) {
31
40
  this.#worlds = worlds;
32
41
  this.#compiler = compiler;
@@ -34,9 +43,13 @@ export class PlayCallChain {
34
43
  this.#failureLog = failureLog;
35
44
  }
36
45
  async start(input) {
46
+ return this.#runInvocation(input, (signal) => this.#start(input, signal));
47
+ }
48
+ async #start(input, signal) {
37
49
  validateIdentity(input.chainId, "call-chain ID");
38
50
  validateIdentity(input.exchangeId, "exchange ID");
39
51
  validatePlayerText(input.playerText);
52
+ assertModelHostBinding(input.modelBinding, input.modelHost);
40
53
  const existing = await this.#readPersisted(input.worldId);
41
54
  if (existing?.chainId === input.chainId) {
42
55
  const duplicate = duplicatePlayerExchange(existing, input.exchangeId, input.playerText, "fresh");
@@ -70,16 +83,14 @@ export class PlayCallChain {
70
83
  documents.bindBootstrap(bootstrap);
71
84
  const now = Date.now();
72
85
  const session = {
73
- schemaVersion: 1,
86
+ schemaVersion: 3,
74
87
  kind: "play_call_chain",
75
88
  chainId: input.chainId,
76
89
  worldId: input.worldId,
77
- previousContexts: existing === null
78
- ? []
79
- : [
80
- ...(existing.previousContexts ?? []).map((context) => structuredClone(context)),
81
- snapshotCurrentContext(existing),
82
- ],
90
+ previousContexts: [],
91
+ previousChainId: existing?.chainId ?? null,
92
+ timelineGeneration: existing?.timelineGeneration ??
93
+ this.#worlds.playTimeline.newGeneration(),
83
94
  baselineHead: binding.parentHead,
84
95
  baselineHistoryLength: Object.keys(binding.history).length,
85
96
  parentHead: binding.parentHead,
@@ -90,6 +101,7 @@ export class PlayCallChain {
90
101
  },
91
102
  followups: structuredClone(compilation.followups ?? []),
92
103
  playPresetScriptsEnabled: input.playPreset.scriptsEnabled,
104
+ modelBinding: structuredClone(input.modelBinding),
93
105
  status: "ready",
94
106
  canRetry: false,
95
107
  bootstrap,
@@ -114,28 +126,38 @@ export class PlayCallChain {
114
126
  documents,
115
127
  history: historyEntries(binding.history),
116
128
  completedToolMap: new Map(),
129
+ persistenceCursor: {
130
+ eventCount: 0,
131
+ transcriptCount: 0,
132
+ completedToolCount: 0,
133
+ authorizationCheckpointCount: 0,
134
+ },
117
135
  };
118
136
  if (activeId !== undefined)
119
137
  this.#active.delete(activeId);
120
138
  this.#active.set(session.chainId, session);
121
139
  this.#worldChains.set(session.worldId, session.chainId);
122
140
  await this.#persist(session);
123
- return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, input.observer);
141
+ return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
124
142
  }
125
143
  async append(input) {
144
+ return this.#runInvocation(input, (signal) => this.#append(input, signal));
145
+ }
146
+ async #append(input, signal) {
126
147
  validateIdentity(input.exchangeId, "exchange ID");
127
148
  const session = await this.#requireChain(input.worldId, input.chainId);
128
149
  await this.#assertCurrentHead(session);
150
+ assertSessionModelHostBinding(session, input.modelHost);
129
151
  if (input.playerText.trim().length === 0) {
130
152
  if (session.status === "running")
131
153
  throw new PlayCallChainError("A model request is still running.");
132
154
  if (session.status === "interrupted") {
133
155
  if (!session.canRetry || session.lastRequest === null)
134
156
  throw new PlayCallChainError("Call-chain processing failed, and there is no model context to continue.");
135
- return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, input.observer);
157
+ return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
136
158
  }
137
159
  session.exchange += 1;
138
- return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, input.observer);
160
+ return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
139
161
  }
140
162
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
141
163
  return projectView(session);
@@ -143,36 +165,133 @@ export class PlayCallChain {
143
165
  throw new PlayCallChainError(session.canRetry
144
166
  ? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
145
167
  : "The call chain was interrupted; use a fresh context.");
146
- return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, input.observer);
168
+ return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, signal, input.observer);
169
+ }
170
+ cancel(input) {
171
+ validateIdentity(input.worldId, "world ID");
172
+ validateIdentity(input.chainId, "call-chain ID");
173
+ validateIdentity(input.exchangeId, "exchange ID");
174
+ const active = this.#activeInvocations.get(input.chainId);
175
+ if (active?.worldId !== input.worldId ||
176
+ active?.exchangeId !== input.exchangeId ||
177
+ active?.abortable !== true)
178
+ return { outcome: "not_running" };
179
+ if (!active.controller.signal.aborted) {
180
+ active.phase = "cancelling";
181
+ active.lastActivityAt = Date.now();
182
+ active.controller.abort();
183
+ }
184
+ return { outcome: "cancellation_requested" };
185
+ }
186
+ async #runInvocation(input, run) {
187
+ validateIdentity(input.worldId, "world ID");
188
+ validateIdentity(input.chainId, "call-chain ID");
189
+ validateIdentity(input.exchangeId, "exchange ID");
190
+ if (this.#activeInvocations.has(input.chainId) ||
191
+ [...this.#activeInvocations.values()].some(({ worldId }) => worldId === input.worldId))
192
+ throw new PlayCallChainError("A model request is already running for this world or call chain.");
193
+ const startedAt = Date.now();
194
+ const invocation = {
195
+ worldId: input.worldId,
196
+ chainId: input.chainId,
197
+ exchangeId: input.exchangeId,
198
+ phase: "preparing",
199
+ startedAt,
200
+ lastActivityAt: startedAt,
201
+ reasoningChars: 0,
202
+ textChars: 0,
203
+ toolChars: 0,
204
+ toolCalls: 0,
205
+ dispatches: 0,
206
+ controller: new AbortController(),
207
+ abortable: true,
208
+ };
209
+ this.#activeInvocations.set(input.chainId, invocation);
210
+ try {
211
+ return await run(invocation.controller.signal);
212
+ }
213
+ finally {
214
+ if (this.#activeInvocations.get(input.chainId) === invocation)
215
+ this.#activeInvocations.delete(input.chainId);
216
+ }
217
+ }
218
+ #observeInvocation(chainId, phase, increments = {}) {
219
+ const active = this.#activeInvocations.get(chainId);
220
+ if (active === undefined || active.phase === "cancelling")
221
+ return;
222
+ active.phase = phase;
223
+ active.lastActivityAt = Date.now();
224
+ active.reasoningChars += increments.reasoningChars ?? 0;
225
+ active.textChars += increments.textChars ?? 0;
226
+ active.toolChars += increments.toolChars ?? 0;
227
+ active.toolCalls += increments.toolCalls ?? 0;
228
+ active.dispatches += increments.dispatches ?? 0;
229
+ }
230
+ #setInvocationAbortable(chainId, abortable) {
231
+ const active = this.#activeInvocations.get(chainId);
232
+ if (active !== undefined)
233
+ active.abortable = abortable;
234
+ }
235
+ #projectActiveView(session) {
236
+ const view = projectView(session);
237
+ const active = this.#activeInvocations.get(session.chainId);
238
+ if (active?.worldId !== session.worldId)
239
+ return view;
240
+ const { controller: _controller, worldId: _worldId, abortable: _abortable, ...progress } = active;
241
+ void _controller;
242
+ void _worldId;
243
+ void _abortable;
244
+ return { ...view, activeInvocation: structuredClone(progress) };
245
+ }
246
+ #notifySnapshot(observer, session) {
247
+ try {
248
+ observer?.onSnapshot?.(this.#projectActiveView(session));
249
+ }
250
+ catch {
251
+ // A browser stream may disconnect while the durable model request keeps
252
+ // running. Presentation failure never cancels or changes play Authority.
253
+ }
147
254
  }
148
255
  async inspectWorld(worldId) {
149
256
  const activeId = this.#worldChains.get(worldId);
150
257
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
151
- if (active !== undefined)
152
- return projectView(active);
258
+ if (active !== undefined) {
259
+ if (this.#activeInvocations.get(active.chainId)?.worldId !== active.worldId)
260
+ await this.#reconcileSessionAdvance(active);
261
+ return this.#projectActiveView(active);
262
+ }
153
263
  const persisted = await this.#readPersisted(worldId);
154
264
  if (persisted === null)
155
265
  return null;
156
- if (persisted.status === "running") {
157
- const streaming = persisted.events.findLast((event) => event.kind === "assistant" && event.status === "streaming");
158
- if (streaming !== undefined)
159
- streaming.status = "interrupted";
160
- const canRetry = persisted.lastRequest !== null;
161
- const message = canRetry
162
- ? "The service stopped before the model request completed; the same request can be retried."
163
- : "The service stopped before dispatching the model request; use a fresh context.";
164
- persisted.events.push({
165
- id: persisted.nextEventId++,
166
- kind: "failure",
167
- message,
266
+ const advance = await this.#worlds.playAdvances.readCurrent(worldId, persisted.chainId);
267
+ const session = await this.#hydrateSession(persisted, advance);
268
+ this.#active.set(session.chainId, session);
269
+ this.#worldChains.set(worldId, session.chainId);
270
+ await this.#reconcileSessionAdvance(session, advance);
271
+ if (session.status === "running")
272
+ await this.#interruptAbandonedDispatch(session);
273
+ return this.#projectActiveView(session);
274
+ }
275
+ async deriveWorld(input) {
276
+ const result = await this.#worlds.deriveWorld({
277
+ ...input,
278
+ stageTarget: async ({ targetWorldRoot, targetWorldId, binding }) => {
279
+ await this.stageForkToDerivedWorld({
280
+ sourceWorldId: input.sourceWorldId,
281
+ sourceHead: input.sourceHead,
282
+ targetWorldId,
283
+ targetWorldRoot,
284
+ targetBinding: binding,
285
+ });
286
+ },
287
+ });
288
+ if ((await this.inspectWorld(result.world.worldId)) === null)
289
+ await this.forkToDerivedWorld({
290
+ sourceWorldId: input.sourceWorldId,
291
+ sourceHead: input.sourceHead,
292
+ targetWorldId: result.world.worldId,
168
293
  });
169
- persisted.status = "interrupted";
170
- persisted.canRetry = canRetry;
171
- persisted.lastFailure = message;
172
- persisted.updatedAt = Date.now();
173
- await this.#worlds.writePlayCallChain(worldId, persisted);
174
- }
175
- return projectView(persisted);
294
+ return result;
176
295
  }
177
296
  async forkToDerivedWorld(input) {
178
297
  const existing = await this.#readPersisted(input.targetWorldId);
@@ -181,13 +300,14 @@ export class PlayCallChain {
181
300
  const source = await this.#readPersisted(input.sourceWorldId);
182
301
  if (source === null)
183
302
  return null;
184
- const sourceContexts = persistedContexts(source);
303
+ const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
185
304
  const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
186
305
  if (selectedContextIndex < 0)
187
306
  return null;
188
307
  const sourceContext = sourceContexts[selectedContextIndex];
189
308
  const sourceEvents = eventsThroughHead(sourceContext.events, input.sourceHead);
190
309
  return this.#forkSelectionToDerivedWorld({
310
+ sourceWorldId: input.sourceWorldId,
191
311
  sourceContexts,
192
312
  selectedContextIndex,
193
313
  sourceEvents,
@@ -196,6 +316,27 @@ export class PlayCallChain {
196
316
  branchIdentity: input.sourceHead,
197
317
  });
198
318
  }
319
+ async stageForkToDerivedWorld(input) {
320
+ const source = await this.#readPersisted(input.sourceWorldId);
321
+ if (source === null)
322
+ return null;
323
+ const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
324
+ const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
325
+ if (selectedContextIndex < 0)
326
+ return null;
327
+ const sourceEvents = eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
328
+ return this.#forkSelectionToDerivedWorld({
329
+ sourceWorldId: input.sourceWorldId,
330
+ sourceContexts,
331
+ selectedContextIndex,
332
+ sourceEvents,
333
+ sourceHead: input.sourceHead,
334
+ targetWorldId: input.targetWorldId,
335
+ branchIdentity: input.sourceHead,
336
+ targetWorldRoot: input.targetWorldRoot,
337
+ targetBinding: input.targetBinding,
338
+ });
339
+ }
199
340
  /**
200
341
  * Revise one committed player message in place. Authority remains
201
342
  * append-only: one timeline-revision commit restores the player's logical
@@ -216,7 +357,7 @@ export class PlayCallChain {
216
357
  const source = await this.#readPersisted(input.worldId);
217
358
  if (source === null)
218
359
  throw new PlayCallChainError("The current world has no model call chain to revise.");
219
- const sourceContexts = persistedContexts(source);
360
+ const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
220
361
  const appliedEvent = sourceContexts
221
362
  .flatMap(({ events }) => events)
222
363
  .find((event) => event.kind === "player" &&
@@ -227,8 +368,12 @@ export class PlayCallChain {
227
368
  throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
228
369
  const authority = await this.#worlds.readAuthorityHistory(input.worldId);
229
370
  const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
230
- if (commit?.operationId !== input.operationId ||
231
- commit.mode !== "timeline_revision")
371
+ const operation = await this.#worlds.getOperationOutcome(input.operationId);
372
+ if (commit?.mode !== "timeline_revision" ||
373
+ (operation.outcome !== "committed" &&
374
+ operation.outcome !== "committed_materialization_pending") ||
375
+ operation.worldId !== input.worldId ||
376
+ operation.head !== appliedEvent.committedHead)
232
377
  throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
233
378
  return {
234
379
  outcome: "revised",
@@ -311,13 +456,15 @@ export class PlayCallChain {
311
456
  throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
312
457
  const documents = restorePlayDocuments(binding.files, sourceContext, prefixEvents);
313
458
  const revised = {
314
- schemaVersion: 1,
459
+ schemaVersion: 3,
315
460
  kind: "play_call_chain",
316
461
  chainId: derivedChainId(sourceContext.chainId, "timeline-revision:" + request.operationId, request.worldId),
317
462
  worldId: request.worldId,
318
- previousContexts: input.sourceContexts
319
- .slice(0, input.selectedContextIndex)
320
- .map((context) => independentContextCopy(context)),
463
+ previousContexts: [],
464
+ previousChainId: input.selectedContextIndex === 0
465
+ ? null
466
+ : input.sourceContexts[input.selectedContextIndex - 1].chainId,
467
+ timelineGeneration: this.#worlds.playTimeline.newGeneration(),
321
468
  baselineHead: sourceContext.baselineHead,
322
469
  baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
323
470
  parentHead: outcome.head,
@@ -330,6 +477,9 @@ export class PlayCallChain {
330
477
  : {
331
478
  playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
332
479
  }),
480
+ ...(sourceContext.modelBinding === undefined
481
+ ? {}
482
+ : { modelBinding: structuredClone(sourceContext.modelBinding) }),
333
483
  status: "ready",
334
484
  canRetry: false,
335
485
  bootstrap: structuredClone(sourceContext.bootstrap),
@@ -351,7 +501,7 @@ export class PlayCallChain {
351
501
  lastFailure: null,
352
502
  updatedAt: Date.now(),
353
503
  };
354
- await this.#worlds.writePlayCallChain(request.worldId, structuredClone(revised));
504
+ await this.#worlds.playTimeline.persist(structuredClone(revised));
355
505
  const activeId = this.#worldChains.get(request.worldId);
356
506
  if (activeId !== undefined)
357
507
  this.#active.delete(activeId);
@@ -364,30 +514,73 @@ export class PlayCallChain {
364
514
  };
365
515
  }
366
516
  async #forkSelectionToDerivedWorld(input) {
367
- const existing = await this.#readPersisted(input.targetWorldId);
368
- if (existing !== null)
369
- return projectView(existing);
517
+ if (input.targetWorldRoot === undefined) {
518
+ const existing = await this.#readPersisted(input.targetWorldId);
519
+ if (existing !== null)
520
+ return projectView(existing);
521
+ }
370
522
  const sourceContext = input.sourceContexts[input.selectedContextIndex];
371
- if ((await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
523
+ if (input.targetWorldRoot === undefined &&
524
+ (await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
372
525
  throw new PlayCallChainError("The forked world's Authority does not match the selected history endpoint, so the call chain cannot be written.");
373
526
  const [baseline, selected] = await Promise.all([
374
- this.#worlds.recoverEndpoint(input.targetWorldId, sourceContext.baselineHead),
375
- this.#worlds.recoverEndpoint(input.targetWorldId, input.sourceHead),
527
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
528
+ ? input.targetWorldId
529
+ : input.sourceWorldId, sourceContext.baselineHead),
530
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
531
+ ? input.targetWorldId
532
+ : input.sourceWorldId, input.sourceHead),
376
533
  ]);
377
- const transcript = transcriptThroughEvents(sourceContext.transcript, input.sourceEvents);
378
- const completedKeys = completedToolKeys(input.sourceEvents);
534
+ const selectsCompleteContext = input.sourceEvents.length === sourceContext.events.length;
535
+ // A complete context already defines the selected trace closure. Preserve
536
+ // its model transcript verbatim instead of reconstructing it from the
537
+ // independently persisted page-event projection.
538
+ const transcript = selectsCompleteContext
539
+ ? structuredClone(sourceContext.transcript)
540
+ : transcriptThroughEvents(sourceContext.transcript, input.sourceEvents);
541
+ const completedKeys = selectsCompleteContext
542
+ ? null
543
+ : completedToolKeys(input.sourceEvents);
379
544
  const events = structuredClone(input.sourceEvents);
380
- const derivedBinding = await this.#worlds.bindPlayCallChain(input.targetWorldId);
545
+ const derivedBinding = input.targetBinding ??
546
+ (await this.#worlds.bindPlayCallChain(input.targetWorldId));
381
547
  const derivedDocuments = restorePlayDocuments(derivedBinding.files, sourceContext, input.sourceEvents);
382
548
  const now = Date.now();
549
+ const timelineGeneration = this.#worlds.playTimeline.newGeneration();
550
+ let previousChainId = null;
551
+ for (const [index, context] of input.sourceContexts
552
+ .slice(0, input.selectedContextIndex)
553
+ .entries()) {
554
+ const chainId = derivedChainId(context.chainId, `prefix:${input.branchIdentity}:${index}`, input.targetWorldId);
555
+ const previousContext = {
556
+ ...independentContextCopy(context),
557
+ schemaVersion: 3,
558
+ kind: "play_call_chain",
559
+ worldId: input.targetWorldId,
560
+ chainId,
561
+ previousContexts: [],
562
+ previousChainId,
563
+ timelineGeneration,
564
+ };
565
+ if (input.targetWorldRoot === undefined)
566
+ await this.#worlds.playTimeline.persist(previousContext);
567
+ else
568
+ await this.#worlds.playTimeline.cloneContextPrefixToStaging({
569
+ sourceWorldId: input.sourceWorldId,
570
+ targetWorldRoot: input.targetWorldRoot,
571
+ source: context,
572
+ target: previousContext,
573
+ });
574
+ previousChainId = chainId;
575
+ }
383
576
  const derived = {
384
- schemaVersion: 1,
577
+ schemaVersion: 3,
385
578
  kind: "play_call_chain",
386
579
  chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
387
580
  worldId: input.targetWorldId,
388
- previousContexts: input.sourceContexts
389
- .slice(0, input.selectedContextIndex)
390
- .map((context) => independentContextCopy(context)),
581
+ previousContexts: [],
582
+ previousChainId,
583
+ timelineGeneration,
391
584
  baselineHead: sourceContext.baselineHead,
392
585
  baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
393
586
  parentHead: input.sourceHead,
@@ -400,6 +593,9 @@ export class PlayCallChain {
400
593
  : {
401
594
  playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
402
595
  }),
596
+ ...(sourceContext.modelBinding === undefined
597
+ ? {}
598
+ : { modelBinding: structuredClone(sourceContext.modelBinding) }),
403
599
  status: "ready",
404
600
  canRetry: false,
405
601
  bootstrap: structuredClone(sourceContext.bootstrap),
@@ -407,7 +603,7 @@ export class PlayCallChain {
407
603
  transcript,
408
604
  events,
409
605
  completedTools: sourceContext.completedTools
410
- .filter(({ key }) => completedKeys.has(key))
606
+ .filter(({ key }) => completedKeys === null || completedKeys.has(key))
411
607
  .map((item) => structuredClone(item)),
412
608
  documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(sourceContext, events, derivedDocuments.authorizationCheckpoint()),
413
609
  changedDocuments: changedDocumentsAtHead(sourceContext.changedDocuments, baseline.state, selected.state),
@@ -421,7 +617,15 @@ export class PlayCallChain {
421
617
  lastFailure: null,
422
618
  updatedAt: now,
423
619
  };
424
- await this.#worlds.writePlayCallChain(input.targetWorldId, structuredClone(derived));
620
+ if (input.targetWorldRoot === undefined)
621
+ await this.#worlds.playTimeline.persist(structuredClone(derived));
622
+ else
623
+ await this.#worlds.playTimeline.cloneContextPrefixToStaging({
624
+ sourceWorldId: input.sourceWorldId,
625
+ targetWorldRoot: input.targetWorldRoot,
626
+ source: sourceContext,
627
+ target: structuredClone(derived),
628
+ });
425
629
  return projectView(derived);
426
630
  }
427
631
  forgetWorld(worldId) {
@@ -433,22 +637,49 @@ export class PlayCallChain {
433
637
  this.#active.delete(activeId);
434
638
  this.#worldChains.delete(worldId);
435
639
  }
436
- async #submitPlayer(session, exchangeId, playerText, context, modelHost, observer) {
640
+ async #submitPlayer(session, exchangeId, playerText, context, modelHost, signal, observer) {
437
641
  if (duplicatePlayerExchange(session, exchangeId, playerText, context))
438
642
  return projectView(session);
439
643
  if (session.status === "running")
440
644
  throw new PlayCallChainError("The call chain is waiting for the model to return.");
441
- session.status = "running";
442
- session.lastFailure = null;
443
- let committed;
645
+ const exchange = session.exchange + 1;
646
+ const nextRequest = createRequest(session, modelHost, exchange, [
647
+ ...session.transcript,
648
+ { kind: "player", text: playerText },
649
+ ]);
650
+ const operationKey = `player:${exchangeId}`;
651
+ const playerOperationId = operationId(session.chainId, operationKey);
652
+ const advance = {
653
+ schemaVersion: 1,
654
+ kind: "play_advance",
655
+ advanceKind: "player",
656
+ worldId: session.worldId,
657
+ chainId: session.chainId,
658
+ advanceId: playerOperationId,
659
+ operationId: playerOperationId,
660
+ parentHead: session.parentHead,
661
+ eventId: session.nextEventId,
662
+ exchangeId,
663
+ playerText,
664
+ context,
665
+ transcriptStart: session.transcript.length,
666
+ exchange,
667
+ nextRequest: structuredClone(nextRequest),
668
+ createdAt: Date.now(),
669
+ };
670
+ await this.#worlds.playAdvances.begin(advance);
444
671
  try {
445
- committed = await this.#commitStep(session, {
446
- operationKey: `player:${exchangeId}`,
447
- historyAppend: [{ role: "player", exactText: playerText }],
448
- stateChanges: [],
449
- });
672
+ await this.#settlePlayerAdvance(session, advance);
450
673
  }
451
674
  catch (error) {
675
+ const accepted = await this.#worlds
676
+ .getOperationOutcome(advance.operationId)
677
+ .catch(() => ({ outcome: "not_started" }));
678
+ if (accepted.outcome === "committed" ||
679
+ accepted.outcome === "committed_materialization_pending") {
680
+ await this.#settlePlayerAdvance(session, advance);
681
+ return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
682
+ }
452
683
  const message = error instanceof Error
453
684
  ? error.message
454
685
  : "Failed to write the original player text.";
@@ -461,27 +692,281 @@ export class PlayCallChain {
461
692
  session.canRetry = false;
462
693
  session.lastFailure = message;
463
694
  await this.#persist(session);
695
+ await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
464
696
  throw new PlayCallChainError(message);
465
697
  }
466
- session.transcript.push({ kind: "player", text: playerText });
467
- session.events.push({
468
- id: session.nextEventId++,
469
- kind: "player",
470
- exchangeId,
471
- text: playerText,
472
- context,
473
- committedHead: committed.head,
698
+ return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
699
+ }
700
+ async #settlePlayerAdvance(session, advance) {
701
+ const outcome = await this.#worlds.commitPlayStep({
702
+ operationId: advance.operationId,
703
+ worldId: session.worldId,
704
+ parentHead: advance.parentHead,
705
+ historyAppend: [{ role: "player", exactText: advance.playerText }],
706
+ nextMaterials: structuredClone(session.nextMaterials),
707
+ stateChanges: [],
474
708
  });
709
+ if (outcome.worldId !== session.worldId ||
710
+ outcome.parentHead !== advance.parentHead)
711
+ throw new PlayCallChainError("The recovered player advance belongs to a different world endpoint.");
712
+ const playerEvent = {
713
+ id: advance.eventId,
714
+ kind: "player",
715
+ exchangeId: advance.exchangeId,
716
+ text: advance.playerText,
717
+ context: advance.context,
718
+ committedHead: outcome.head,
719
+ };
720
+ session.events = [
721
+ ...session.events.filter(({ id }) => id < advance.eventId),
722
+ playerEvent,
723
+ ];
724
+ if (session.transcript.length < advance.transcriptStart)
725
+ throw new PlayCallChainError("The recovered call-chain transcript is shorter than its player prefix.");
726
+ session.transcript = [
727
+ ...session.transcript.slice(0, advance.transcriptStart),
728
+ { kind: "player", text: advance.playerText },
729
+ ];
730
+ session.nextEventId = advance.eventId + 1;
731
+ session.parentHead = outcome.head;
732
+ appendCommittedHistory(session, outcome.head, outcome.historyAppend);
733
+ session.exchange = advance.exchange;
734
+ session.lastRequest = structuredClone(advance.nextRequest);
735
+ session.lastRequestAttempt = 0;
736
+ session.status = "interrupted";
737
+ session.canRetry = true;
738
+ session.lastFailure =
739
+ "The player message is committed; the frozen model request is ready to continue.";
475
740
  await this.#persist(session);
476
- notifySnapshot(observer, session);
477
- session.exchange += 1;
478
- const request = createRequest(session, modelHost, session.exchange);
479
- return this.#dispatch(session, modelHost, request, 1, observer);
741
+ crashAtPlayAdvanceEdge("after_timeline_settled");
742
+ await this.#worlds.playAdvances.markSettled(advance, outcome.head);
743
+ }
744
+ #prepareResponseSettlement(session, advance, response) {
745
+ const existing = session.events.find(({ id }) => id === advance.eventId);
746
+ if (existing?.kind !== "assistant")
747
+ throw new PlayCallChainError("The durable Provider result no longer matches its assistant event.");
748
+ const calls = response.toolCalls ?? [];
749
+ const text = response.text ?? existing.text;
750
+ const hasText = text.trim().length > 0;
751
+ const responseKind = calls.length > 0 ? "tool_step" : hasText ? "narrative" : "empty";
752
+ const assistantEvent = {
753
+ id: advance.eventId,
754
+ kind: "assistant",
755
+ text,
756
+ status: "completed",
757
+ responseKind,
758
+ exchange: advance.exchange,
759
+ attempt: advance.attempt,
760
+ ...(response.reasoningContent === undefined
761
+ ? existing.reasoning === undefined
762
+ ? {}
763
+ : { reasoning: existing.reasoning }
764
+ : { reasoning: response.reasoningContent }),
765
+ ...(response.usage === undefined
766
+ ? {}
767
+ : { usage: structuredClone(response.usage) }),
768
+ ...(response.stopReason === undefined
769
+ ? {}
770
+ : { stopReason: response.stopReason }),
771
+ continuation: response.providerState === undefined ? "unavailable" : "available",
772
+ };
773
+ const workingTools = new Map(session.completedToolMap);
774
+ const prepared = [];
775
+ const trailingEvents = [];
776
+ let nextEventId = advance.eventId + 1;
777
+ for (const call of calls) {
778
+ const item = prepareTool(session, workingTools, advance.exchange, call);
779
+ prepared.push(item);
780
+ trailingEvents.push({
781
+ id: nextEventId++,
782
+ kind: "tool_call",
783
+ callId: call.id,
784
+ name: call.name,
785
+ arguments: structuredClone(call.arguments),
786
+ replayed: item.replayed,
787
+ });
788
+ item.event.id = nextEventId++;
789
+ trailingEvents.push(structuredClone(item.event));
790
+ }
791
+ const stateChanges = session.documents.stateChanges();
792
+ const visibleText = responseKind === "narrative";
793
+ const assistantItem = {
794
+ kind: "assistant",
795
+ text,
796
+ ...(response.reasoningContent === undefined
797
+ ? {}
798
+ : { reasoningContent: response.reasoningContent }),
799
+ ...(response.providerState === undefined
800
+ ? {}
801
+ : { providerState: response.providerState }),
802
+ toolCalls: structuredClone(calls),
803
+ };
804
+ return {
805
+ assistantEvent,
806
+ trailingEvents,
807
+ transcriptStart: session.transcript.length,
808
+ transcriptAppend: [
809
+ ...(calls.length > 0 ||
810
+ visibleText ||
811
+ response.providerState !== undefined
812
+ ? [assistantItem]
813
+ : []),
814
+ ...prepared.map(({ transcript }) => structuredClone(transcript)),
815
+ ],
816
+ completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
817
+ authorizationCheckpoint: session.documents.authorizationCheckpoint(),
818
+ stateChanges: structuredClone(stateChanges),
819
+ visibleText,
820
+ };
480
821
  }
481
- async #dispatch(session, modelHost, firstRequest, firstAttempt, observer) {
822
+ async #settleResponseAdvance(session, advance, prepared) {
823
+ const settlement = structuredClone(prepared);
824
+ const hasToolCalls = settlement.trailingEvents.some(({ kind }) => kind === "tool_call");
825
+ // A legacy prepared fact has no responseKind. Its visibleText decision is
826
+ // already part of an immutable recovery operation and must be replayed as
827
+ // recorded; every newly prepared fact is checked against the current rule.
828
+ if (settlement.assistantEvent.responseKind !== undefined) {
829
+ const expectedResponseKind = hasToolCalls
830
+ ? "tool_step"
831
+ : settlement.assistantEvent.text.trim().length > 0
832
+ ? "narrative"
833
+ : "empty";
834
+ if (settlement.assistantEvent.responseKind !== expectedResponseKind ||
835
+ settlement.visibleText !== (expectedResponseKind === "narrative"))
836
+ throw new PlayCallChainError("The prepared response settlement has an inconsistent narrative classification.");
837
+ }
838
+ let committedHead;
839
+ if (settlement.visibleText || settlement.stateChanges.length > 0) {
840
+ const outcome = await this.#worlds.commitPlayStep({
841
+ operationId: advance.operationId,
842
+ worldId: session.worldId,
843
+ parentHead: advance.parentHead,
844
+ historyAppend: settlement.visibleText
845
+ ? [
846
+ {
847
+ role: "narrator",
848
+ exactText: settlement.assistantEvent.text,
849
+ },
850
+ ]
851
+ : [],
852
+ nextMaterials: structuredClone(session.nextMaterials),
853
+ stateChanges: structuredClone(settlement.stateChanges),
854
+ });
855
+ committedHead = outcome.head;
856
+ settlement.assistantEvent.committedHead = outcome.head;
857
+ crashAtPlayAdvanceEdge("after_authority_accepted");
858
+ appendCommittedHistory(session, outcome.head, outcome.historyAppend);
859
+ }
860
+ finalizePreparedReceipts(settlement);
861
+ session.events = [
862
+ ...session.events.filter(({ id }) => id < advance.eventId),
863
+ structuredClone(settlement.assistantEvent),
864
+ ...structuredClone(settlement.trailingEvents),
865
+ ];
866
+ session.nextEventId =
867
+ Math.max(advance.eventId, ...settlement.trailingEvents.map(({ id }) => id)) + 1;
868
+ if (session.transcript.length < settlement.transcriptStart)
869
+ throw new PlayCallChainError("The recovered call-chain transcript is shorter than its settlement prefix.");
870
+ session.transcript = [
871
+ ...session.transcript.slice(0, settlement.transcriptStart),
872
+ ...structuredClone(settlement.transcriptAppend),
873
+ ];
874
+ session.completedTools = structuredClone(settlement.completedTools);
875
+ session.completedToolMap = new Map(settlement.completedTools.map((item) => [
876
+ item.key,
877
+ structuredClone(item),
878
+ ]));
879
+ const lastToolEventId = settlement.trailingEvents.findLast(({ kind }) => kind === "tool_result")?.id;
880
+ if (lastToolEventId !== undefined) {
881
+ session.documentAuthorizationCheckpoints =
882
+ session.documentAuthorizationCheckpoints.filter(({ afterEventId }) => afterEventId < advance.eventId);
883
+ session.documentAuthorizationCheckpoints.push({
884
+ afterEventId: lastToolEventId,
885
+ authorization: structuredClone(settlement.authorizationCheckpoint),
886
+ });
887
+ }
888
+ if (committedHead !== undefined)
889
+ session.parentHead = committedHead;
890
+ if (settlement.stateChanges.length > 0) {
891
+ const currentAuthorization = session.documents.authorizationCheckpoint();
892
+ if (currentAuthorization.stateFingerprint ===
893
+ settlement.authorizationCheckpoint.stateFingerprint)
894
+ session.documents.acceptCommittedState();
895
+ else {
896
+ const binding = await this.#worlds.bindPlayCallChain(session.worldId);
897
+ session.documents = new FileNativePlayDocuments(binding.files);
898
+ session.documents.restoreAuthorizationCheckpoint(settlement.authorizationCheckpoint);
899
+ session.history = historyEntries(binding.history);
900
+ session.nextMaterials = structuredClone(binding.additionalMaterials);
901
+ }
902
+ mergeChangedDocuments(session, settlement.stateChanges);
903
+ }
904
+ session.exchange = Math.max(session.exchange, advance.exchange);
905
+ session.canRetry = false;
906
+ session.lastFailure = null;
907
+ let result;
908
+ if (settlement.assistantEvent.continuation !== "available") {
909
+ const message = "The complete model response has no provider-native continuation payload; use a fresh context.";
910
+ session.events.push({
911
+ id: session.nextEventId++,
912
+ kind: "failure",
913
+ message,
914
+ });
915
+ session.status = "interrupted";
916
+ session.lastFailure = message;
917
+ result = { kind: "terminal", status: "interrupted" };
918
+ }
919
+ else if (hasToolCalls) {
920
+ session.exchange += 1;
921
+ const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
922
+ session.lastRequest = structuredClone(nextRequest);
923
+ session.lastRequestAttempt = 0;
924
+ session.status = "interrupted";
925
+ session.canRetry = true;
926
+ session.lastFailure =
927
+ "The completed tool response is settled; its frozen continuation request is ready.";
928
+ result = { kind: "continue", nextRequest };
929
+ }
930
+ else {
931
+ session.status = settlement.visibleText ? "ready" : "interrupted";
932
+ if (!settlement.visibleText) {
933
+ const message = "The model returned neither text nor a complete tool call.";
934
+ session.events.push({
935
+ id: session.nextEventId++,
936
+ kind: "failure",
937
+ message,
938
+ });
939
+ session.lastFailure = message;
940
+ }
941
+ result = { kind: "terminal", status: session.status };
942
+ }
943
+ await this.#persist(session);
944
+ crashAtPlayAdvanceEdge("after_timeline_settled");
945
+ await this.#worlds.playAdvances.markSettled(advance, committedHead ?? session.parentHead);
946
+ return result;
947
+ }
948
+ async #recoverAcceptedResponseAdvance(session, advance) {
949
+ const outcome = await this.#worlds
950
+ .getOperationOutcome(advance.operationId)
951
+ .catch(() => ({ outcome: "not_started" }));
952
+ if (outcome.outcome !== "committed" &&
953
+ outcome.outcome !== "committed_materialization_pending")
954
+ return null;
955
+ const current = await this.#worlds.playAdvances.readCurrent(advance.worldId, advance.chainId);
956
+ if (current?.base.advanceKind !== "response" ||
957
+ current.base.advanceId !== advance.advanceId ||
958
+ current.settlementPrepared === null)
959
+ throw new PlayCallChainError("Authority accepted a response whose durable settlement is missing.");
960
+ return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
961
+ }
962
+ async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
482
963
  let request = structuredClone(firstRequest);
483
964
  let attempt = firstAttempt;
484
965
  for (;;) {
966
+ this.#setInvocationAbortable(session.chainId, true);
967
+ if (signal.aborted)
968
+ return this.#cancelBeforeProviderDispatch(session, request, attempt, observer);
969
+ this.#observeInvocation(session.chainId, "waiting");
485
970
  session.status = "running";
486
971
  session.canRetry = false;
487
972
  session.lastFailure = null;
@@ -493,16 +978,45 @@ export class PlayCallChain {
493
978
  kind: "assistant",
494
979
  text: "",
495
980
  status: "streaming",
981
+ responseKind: "pending",
496
982
  exchange: responseExchange,
497
983
  attempt,
498
984
  };
499
985
  session.events.push(event);
500
986
  await this.#persist(session);
501
- notifySnapshot(observer, session);
987
+ const responseOperationId = operationId(session.chainId, `response:${responseExchange}:attempt:${attempt}`);
988
+ const advance = {
989
+ schemaVersion: 1,
990
+ kind: "play_advance",
991
+ advanceKind: "response",
992
+ worldId: session.worldId,
993
+ chainId: session.chainId,
994
+ advanceId: responseOperationId,
995
+ operationId: responseOperationId,
996
+ parentHead: session.parentHead,
997
+ eventId: event.id,
998
+ exchange: responseExchange,
999
+ attempt,
1000
+ createdAt: Date.now(),
1001
+ };
1002
+ await this.#worlds.playAdvances.begin(advance);
1003
+ crashAtPlayAdvanceEdge("after_response_advance_began");
1004
+ this.#notifySnapshot(observer, session);
1005
+ if (signal.aborted)
1006
+ return this.#cancelBeforeProviderDispatch(session, request, attempt, observer, advance);
502
1007
  let response;
503
1008
  try {
1009
+ this.#observeInvocation(session.chainId, "waiting", { dispatches: 1 });
504
1010
  response = await modelHost.exchange(request, {
1011
+ signal,
505
1012
  onDelta: (delta) => {
1013
+ this.#observeInvocation(session.chainId, delta.kind, {
1014
+ ...(delta.kind === "reasoning"
1015
+ ? { reasoningChars: delta.text.length }
1016
+ : delta.kind === "text"
1017
+ ? { textChars: delta.text.length }
1018
+ : { toolChars: delta.text.length }),
1019
+ });
506
1020
  if (delta.kind === "text")
507
1021
  event.text += delta.text;
508
1022
  else if (delta.kind === "reasoning")
@@ -518,6 +1032,13 @@ export class PlayCallChain {
518
1032
  });
519
1033
  },
520
1034
  });
1035
+ if (signal.aborted)
1036
+ throw new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed and cannot be replayed.");
1037
+ this.#setInvocationAbortable(session.chainId, false);
1038
+ if ((response.toolCalls?.length ?? 0) > 0)
1039
+ this.#observeInvocation(session.chainId, "tool", {
1040
+ toolCalls: response.toolCalls?.length ?? 0,
1041
+ });
521
1042
  if (response.diagnostics !== undefined)
522
1043
  await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
523
1044
  }
@@ -528,57 +1049,30 @@ export class PlayCallChain {
528
1049
  : "The model request was interrupted.";
529
1050
  session.events.push({
530
1051
  id: session.nextEventId++,
531
- kind: "failure",
1052
+ kind: error instanceof ModelHostCancelledError
1053
+ ? "cancellation"
1054
+ : "failure",
532
1055
  message,
533
1056
  });
534
1057
  session.status = "interrupted";
535
- session.canRetry = true;
1058
+ const requiresFreshContext = error instanceof ModelHostContinuationError ||
1059
+ error instanceof ModelHostOutcomeUnknownError;
1060
+ session.canRetry =
1061
+ !requiresFreshContext && error instanceof ModelHostFailureError;
536
1062
  session.lastFailure = message;
537
1063
  await this.#persist(session);
538
- notifySnapshot(observer, session);
1064
+ await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1065
+ this.#notifySnapshot(observer, session);
539
1066
  return projectView(session);
540
1067
  }
541
- event.status = "completed";
542
- session.canRetry = false;
543
- event.text = response.text ?? event.text;
544
- if (response.reasoningContent !== undefined)
545
- event.reasoning = response.reasoningContent;
546
- delete event.toolFragment;
547
- if (response.usage !== undefined)
548
- event.usage = {
549
- inputTokens: response.usage.inputTokens,
550
- outputTokens: response.usage.outputTokens,
551
- };
552
- const calls = response.toolCalls ?? [];
553
- const assistantItem = {
554
- kind: "assistant",
555
- text: response.text ?? "",
556
- ...(response.reasoningContent === undefined
557
- ? {}
558
- : { reasoningContent: response.reasoningContent }),
559
- ...(response.providerState === undefined
560
- ? {}
561
- : { providerState: response.providerState }),
562
- toolCalls: structuredClone(calls),
563
- };
1068
+ const durableResponse = durableModelResponse(response);
564
1069
  try {
565
- const workingTools = new Map(session.completedToolMap);
566
- const prepared = [];
567
- for (const call of calls) {
568
- const item = prepareTool(session, workingTools, responseExchange, call);
569
- prepared.push(item);
570
- session.events.push({
571
- id: session.nextEventId++,
572
- kind: "tool_call",
573
- callId: call.id,
574
- name: call.name,
575
- arguments: structuredClone(call.arguments),
576
- replayed: item.replayed,
577
- });
578
- item.event.id = session.nextEventId++;
579
- session.events.push(item.event);
580
- }
581
- const failedTools = prepared.filter(({ result }) => !result.ok);
1070
+ await this.#worlds.playAdvances.recordProviderCompleted(advance, durableResponse);
1071
+ crashAtPlayAdvanceEdge("after_provider_completed");
1072
+ const settlement = this.#prepareResponseSettlement(session, advance, durableResponse);
1073
+ await this.#worlds.playAdvances.recordSettlementPrepared(advance, settlement);
1074
+ crashAtPlayAdvanceEdge("after_settlement_prepared");
1075
+ const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
582
1076
  if (failedTools.length > 0 && response.diagnostics !== undefined)
583
1077
  await this.#failureLog?.recordFailure({
584
1078
  exchange: response.diagnostics,
@@ -587,66 +1081,20 @@ export class PlayCallChain {
587
1081
  kind: "tool_execution",
588
1082
  message: "A Runtime tool call from the model was rejected.",
589
1083
  details: {
590
- calls: failedTools.map(({ call, result, replayed }) => ({
591
- id: call.id,
592
- name: call.name,
593
- arguments: structuredClone(call.arguments),
1084
+ calls: failedTools.map((result) => ({
1085
+ id: result.callId,
1086
+ name: result.name,
594
1087
  ok: result.ok,
595
- failureKind: result.failureKind,
596
1088
  markdown: result.markdown,
597
- replayed,
1089
+ replayed: result.replayed,
598
1090
  })),
599
1091
  },
600
1092
  },
601
1093
  ],
602
1094
  });
603
- const stateChanges = session.documents.stateChanges();
604
- const visibleText = (response.text ?? "").trim().length > 0;
605
- let committedHead;
606
- if (visibleText || stateChanges.length > 0) {
607
- const committed = await this.#commitStep(session, {
608
- operationKey: `response:${responseExchange}:attempt:${attempt}`,
609
- historyAppend: visibleText
610
- ? [{ role: "narrator", exactText: response.text ?? "" }]
611
- : [],
612
- stateChanges,
613
- });
614
- committedHead = committed.head;
615
- event.committedHead = committed.head;
616
- if (stateChanges.length > 0) {
617
- session.documents.acceptCommittedState();
618
- mergeChangedDocuments(session, stateChanges);
619
- }
620
- }
621
- const committedWriteRefs = new Set(stateChanges.map(({ stableShortRef }) => stableShortRef));
622
- for (const item of prepared) {
623
- const candidateWrite = item.result.candidateWrite;
624
- if (!item.result.ok || candidateWrite === undefined)
625
- continue;
626
- const markdown = candidateWrite.changed &&
627
- committedWriteRefs.has(candidateWrite.shortRef)
628
- ? `@${candidateWrite.shortRef} write succeeded`
629
- : `@${candidateWrite.shortRef} unchanged`;
630
- item.result.markdown = markdown;
631
- delete item.result.candidateWrite;
632
- item.event.markdown = markdown;
633
- item.transcript.markdown = markdown;
634
- const completed = workingTools.get(item.key);
635
- if (completed !== undefined)
636
- completed.result = structuredClone(item.result);
637
- }
638
- if (calls.length > 0 || visibleText)
639
- session.transcript.push(assistantItem);
640
- session.transcript.push(...prepared.map(({ transcript }) => transcript));
641
- session.completedToolMap = workingTools;
642
- session.completedTools = [...workingTools.values()].map((item) => structuredClone(item));
643
- const lastToolEventId = prepared.at(-1)?.event.id;
644
- if (lastToolEventId !== undefined)
645
- recordDocumentAuthorizationCheckpoint(session, lastToolEventId);
646
- if (committedHead !== undefined)
647
- session.parentHead = committedHead;
648
- if (calls.length === 0) {
649
- if (!visibleText) {
1095
+ const settled = await this.#settleResponseAdvance(session, advance, settlement);
1096
+ if (settled.kind === "terminal") {
1097
+ if (!settlement.visibleText) {
650
1098
  const message = "The model returned neither text nor a complete tool call.";
651
1099
  if (response.diagnostics !== undefined)
652
1100
  await this.#failureLog?.recordFailure({
@@ -658,16 +1106,11 @@ export class PlayCallChain {
658
1106
  },
659
1107
  ],
660
1108
  });
661
- session.events.push({
662
- id: session.nextEventId++,
663
- kind: "failure",
664
- message,
665
- });
666
- session.status = "interrupted";
667
- session.canRetry = false;
668
- session.lastFailure = message;
669
- await this.#persist(session);
670
- notifySnapshot(observer, session);
1109
+ this.#notifySnapshot(observer, session);
1110
+ return projectView(session);
1111
+ }
1112
+ if (settled.status !== "ready") {
1113
+ this.#notifySnapshot(observer, session);
671
1114
  return projectView(session);
672
1115
  }
673
1116
  // The exchange has settled: no tool call is pending and the narrative
@@ -680,19 +1123,46 @@ export class PlayCallChain {
680
1123
  message: "The play call chain recovered during a later model exchange and completed.",
681
1124
  details: { parentHead: session.parentHead },
682
1125
  });
683
- await this.#runFollowups(session, modelHost, observer);
1126
+ if ((session.followups?.length ?? 0) > 0) {
1127
+ // Follow-up artifacts are part of this player-visible invocation.
1128
+ // Keep `running` ephemeral: the settled main response remains the
1129
+ // durable recovery point if this Runtime process stops here.
1130
+ session.status = "running";
1131
+ this.#observeInvocation(session.chainId, "followup");
1132
+ this.#setInvocationAbortable(session.chainId, true);
1133
+ this.#notifySnapshot(observer, session);
1134
+ }
1135
+ await this.#runFollowups(session, modelHost, signal, observer);
1136
+ this.#setInvocationAbortable(session.chainId, false);
1137
+ if (signal.aborted) {
1138
+ session.events.push({
1139
+ id: session.nextEventId++,
1140
+ kind: "cancellation",
1141
+ message: "The player cancelled the remaining follow-up generation after the narrative was committed.",
1142
+ });
1143
+ touch(session);
1144
+ }
684
1145
  session.status = "ready";
685
1146
  await this.#persist(session);
686
- notifySnapshot(observer, session);
1147
+ this.#notifySnapshot(observer, session);
687
1148
  return projectView(session);
688
1149
  }
689
- await this.#persist(session);
690
- notifySnapshot(observer, session);
691
- session.exchange = Math.max(session.exchange, responseExchange) + 1;
692
- request = createRequest(session, modelHost, session.exchange);
1150
+ this.#notifySnapshot(observer, session);
1151
+ request = settled.nextRequest;
693
1152
  attempt = 1;
694
1153
  }
695
1154
  catch (error) {
1155
+ const recovered = await this.#recoverAcceptedResponseAdvance(session, advance);
1156
+ if (recovered !== null) {
1157
+ if (recovered.kind === "continue") {
1158
+ request = recovered.nextRequest;
1159
+ attempt = 1;
1160
+ this.#notifySnapshot(observer, session);
1161
+ continue;
1162
+ }
1163
+ this.#notifySnapshot(observer, session);
1164
+ return projectView(session);
1165
+ }
696
1166
  const message = error instanceof Error
697
1167
  ? error.message
698
1168
  : "Call-chain processing failed.";
@@ -715,11 +1185,34 @@ export class PlayCallChain {
715
1185
  session.canRetry = false;
716
1186
  session.lastFailure = message;
717
1187
  await this.#persist(session);
718
- notifySnapshot(observer, session);
1188
+ this.#notifySnapshot(observer, session);
719
1189
  return projectView(session);
720
1190
  }
721
1191
  }
722
1192
  }
1193
+ async #cancelBeforeProviderDispatch(session, request, attempt, observer, advance) {
1194
+ if (advance !== undefined) {
1195
+ const streaming = session.events.find((event) => event.kind === "assistant" && event.id === advance.eventId);
1196
+ if (streaming !== undefined)
1197
+ streaming.status = "interrupted";
1198
+ }
1199
+ const message = "The player cancelled before the next Provider dispatch; the frozen request can be resumed without replaying a completed model result.";
1200
+ session.events.push({
1201
+ id: session.nextEventId++,
1202
+ kind: "cancellation",
1203
+ message,
1204
+ });
1205
+ session.status = "interrupted";
1206
+ session.canRetry = true;
1207
+ session.lastRequest = structuredClone(request);
1208
+ session.lastRequestAttempt = Math.max(0, attempt - 1);
1209
+ session.lastFailure = message;
1210
+ await this.#persist(session);
1211
+ if (advance !== undefined)
1212
+ await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1213
+ this.#notifySnapshot(observer, session);
1214
+ return projectView(session);
1215
+ }
723
1216
  /**
724
1217
  * Dispatch the frozen follow-ups once against the settled exchange.
725
1218
  *
@@ -728,11 +1221,24 @@ export class PlayCallChain {
728
1221
  * player nothing more than a missing panel. Failures are recorded in the
729
1222
  * trace and the chain still returns to `ready`.
730
1223
  */
731
- async #runFollowups(session, modelHost, observer) {
1224
+ async #runFollowups(session, modelHost, signal, observer) {
732
1225
  const followups = session.followups ?? [];
733
1226
  if (this.#artifacts === undefined || followups.length === 0)
734
1227
  return;
1228
+ const observeFollowupDelta = (delta) => {
1229
+ this.#observeInvocation(session.chainId, "followup", {
1230
+ ...(delta.kind === "reasoning"
1231
+ ? { reasoningChars: delta.text.length }
1232
+ : delta.kind === "text"
1233
+ ? { textChars: delta.text.length }
1234
+ : { toolChars: delta.text.length }),
1235
+ });
1236
+ this.#notifySnapshot(observer, session);
1237
+ };
735
1238
  const record = (outcome) => {
1239
+ this.#observeInvocation(session.chainId, "followup", {
1240
+ toolCalls: outcome.toolCalls.length,
1241
+ });
736
1242
  session.events.push({
737
1243
  id: session.nextEventId++,
738
1244
  kind: "followup",
@@ -747,7 +1253,7 @@ export class PlayCallChain {
747
1253
  ...(outcome.failure === undefined ? {} : { failure: outcome.failure }),
748
1254
  });
749
1255
  touch(session);
750
- notifySnapshot(observer, session);
1256
+ this.#notifySnapshot(observer, session);
751
1257
  };
752
1258
  try {
753
1259
  await runPlayFollowupRequests({
@@ -772,10 +1278,22 @@ export class PlayCallChain {
772
1278
  },
773
1279
  head: session.parentHead,
774
1280
  maxOutputTokens: modelHost.binding().maxOutputTokens,
1281
+ signal,
775
1282
  ...(this.#failureLog === undefined
776
1283
  ? {}
777
1284
  : { failureLog: this.#failureLog }),
778
- observer: { onOutcome: record },
1285
+ observer: {
1286
+ onProviderDispatch: () => {
1287
+ this.#setInvocationAbortable(session.chainId, true);
1288
+ this.#observeInvocation(session.chainId, "followup", {
1289
+ dispatches: 1,
1290
+ });
1291
+ this.#notifySnapshot(observer, session);
1292
+ },
1293
+ onProviderDelta: observeFollowupDelta,
1294
+ onProviderSettled: () => this.#setInvocationAbortable(session.chainId, false),
1295
+ onOutcome: record,
1296
+ },
779
1297
  });
780
1298
  }
781
1299
  catch (error) {
@@ -789,56 +1307,44 @@ export class PlayCallChain {
789
1307
  touch(session);
790
1308
  }
791
1309
  }
792
- async #commitStep(session, input) {
793
- const parentHead = session.parentHead;
794
- const outcome = await this.#worlds.commitPlayStep({
795
- operationId: operationId(session.chainId, input.operationKey),
796
- worldId: session.worldId,
797
- parentHead,
798
- historyAppend: structuredClone(input.historyAppend),
799
- nextMaterials: structuredClone(session.nextMaterials),
800
- stateChanges: structuredClone(input.stateChanges),
801
- });
802
- session.parentHead = outcome.head;
803
- const sequence = Number(outcome.head.slice("commit:".length));
804
- for (const [index, message] of input.historyAppend.entries())
805
- session.history.push({
806
- path: `${session.worldId}.message.${sequence}.${index + 1}.${message.role}`,
807
- contents: message.exactText,
808
- });
809
- return outcome;
810
- }
811
1310
  async #requireChain(worldId, chainId) {
812
1311
  validateIdentity(chainId, "call-chain ID");
813
1312
  const active = this.#active.get(chainId);
814
1313
  if (active !== undefined) {
815
1314
  if (active.worldId !== worldId)
816
1315
  throw new PlayCallChainError("The call chain does not belong to this world.");
1316
+ await this.#reconcileSessionAdvance(active);
817
1317
  return active;
818
1318
  }
819
1319
  const persisted = await this.#readPersisted(worldId);
820
1320
  if (persisted?.chainId !== chainId)
821
1321
  throw new PlayCallChainError("The call chain does not exist; start from the latest world state.");
822
- if (persisted.status === "running") {
823
- persisted.status = "interrupted";
824
- persisted.canRetry = persisted.lastRequest !== null;
825
- persisted.lastFailure = persisted.canRetry
826
- ? "The service stopped before the model request completed; the same request can be retried."
827
- : "The service stopped before dispatching the model request; use a fresh context.";
828
- const streaming = persisted.events.findLast((event) => event.kind === "assistant" && event.status === "streaming");
829
- if (streaming !== undefined)
830
- streaming.status = "interrupted";
831
- persisted.events.push({
832
- id: persisted.nextEventId++,
833
- kind: "failure",
834
- message: persisted.lastFailure,
835
- });
836
- }
837
- const binding = await this.#worlds.bindPlayCallChain(worldId);
838
- if (binding.parentHead !== persisted.parentHead)
1322
+ const advance = await this.#worlds.playAdvances.readCurrent(worldId, chainId);
1323
+ const session = await this.#hydrateSession(persisted, advance);
1324
+ this.#active.set(chainId, session);
1325
+ this.#worldChains.set(worldId, chainId);
1326
+ await this.#reconcileSessionAdvance(session, advance);
1327
+ if (session.status === "running")
1328
+ await this.#interruptAbandonedDispatch(session);
1329
+ const currentHead = await this.#worlds.currentHead(worldId);
1330
+ if (currentHead !== session.parentHead)
839
1331
  throw new PlayCallChainError("The call-chain record differs from the current world endpoint; use a fresh context.");
840
- const documents = restorePlayDocuments(binding.files, persisted, persisted.events);
841
- const session = {
1332
+ return session;
1333
+ }
1334
+ async #hydrateSession(persisted, advance) {
1335
+ const binding = await this.#worlds.bindPlayCallChain(persisted.worldId);
1336
+ let documents;
1337
+ try {
1338
+ documents = restorePlayDocuments(binding.files, persisted, persisted.events);
1339
+ }
1340
+ catch (error) {
1341
+ const prepared = advance?.settlementPrepared?.settlement;
1342
+ if (prepared === undefined)
1343
+ throw error;
1344
+ documents = new FileNativePlayDocuments(binding.files);
1345
+ documents.restoreAuthorizationCheckpoint(prepared.authorizationCheckpoint);
1346
+ }
1347
+ return {
842
1348
  ...structuredClone(persisted),
843
1349
  documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
844
1350
  nextMaterials: structuredClone(binding.additionalMaterials),
@@ -848,32 +1354,79 @@ export class PlayCallChain {
848
1354
  item.key,
849
1355
  structuredClone(item),
850
1356
  ])),
1357
+ persistenceCursor: this.#loadedCursors.get(persisted.chainId) ?? {
1358
+ eventCount: 0,
1359
+ transcriptCount: 0,
1360
+ completedToolCount: 0,
1361
+ authorizationCheckpointCount: 0,
1362
+ },
851
1363
  };
852
- this.#active.set(chainId, session);
853
- this.#worldChains.set(worldId, chainId);
854
- if (persisted.status === "interrupted")
855
- await this.#persist(session);
856
- return session;
1364
+ }
1365
+ async #reconcileSessionAdvance(session, loaded) {
1366
+ const current = loaded ??
1367
+ (await this.#worlds.playAdvances.readCurrent(session.worldId, session.chainId));
1368
+ if (current?.settled !== null)
1369
+ return;
1370
+ if (current.base.advanceKind === "player") {
1371
+ await this.#settlePlayerAdvance(session, current.base);
1372
+ return;
1373
+ }
1374
+ if (current.providerCompleted === null) {
1375
+ await this.#interruptAbandonedDispatch(session, current.base);
1376
+ await this.#worlds.playAdvances.markSettled(current.base, session.parentHead);
1377
+ return;
1378
+ }
1379
+ let settlement = current.settlementPrepared?.settlement;
1380
+ if (settlement === undefined) {
1381
+ settlement = this.#prepareResponseSettlement(session, current.base, current.providerCompleted.response);
1382
+ await this.#worlds.playAdvances.recordSettlementPrepared(current.base, settlement);
1383
+ }
1384
+ await this.#settleResponseAdvance(session, current.base, settlement);
1385
+ }
1386
+ async #interruptAbandonedDispatch(session, advance) {
1387
+ const streaming = session.events.findLast((event) => event.kind === "assistant" &&
1388
+ event.status === "streaming" &&
1389
+ (advance === undefined || event.id === advance.eventId));
1390
+ if (streaming !== undefined)
1391
+ streaming.status = "interrupted";
1392
+ const dispatchMayHaveStarted = advance !== undefined;
1393
+ const canRetry = !dispatchMayHaveStarted && session.lastRequest !== null;
1394
+ const message = dispatchMayHaveStarted
1395
+ ? "The service stopped after model dispatch may have started; the outcome is unknown and the request cannot be replayed. Use a fresh context."
1396
+ : canRetry
1397
+ ? "The service stopped before model dispatch; the frozen request can be retried."
1398
+ : "The service stopped before preparing a model request; use a fresh context.";
1399
+ session.events.push({
1400
+ id: session.nextEventId++,
1401
+ kind: "failure",
1402
+ message,
1403
+ });
1404
+ session.status = "interrupted";
1405
+ session.canRetry = canRetry;
1406
+ session.lastFailure = message;
1407
+ await this.#persist(session);
857
1408
  }
858
1409
  async #assertCurrentHead(session) {
859
1410
  if ((await this.#worlds.currentHead(session.worldId)) !== session.parentHead)
860
1411
  throw new PlayCallChainError("Another operation has advanced the current world; use a fresh context.");
861
1412
  }
862
1413
  async #readPersisted(worldId) {
863
- const value = await this.#worlds.readPlayCallChain(worldId);
864
- if (value === null)
1414
+ await this.#worlds.ensureCurrentStorage(worldId);
1415
+ const loaded = await this.#worlds.playTimeline.readCurrent(worldId);
1416
+ if (loaded === null)
865
1417
  return null;
866
- assertPersistedPlayCallChain(value, worldId);
867
- return { ...structuredClone(value), schemaVersion: 1 };
1418
+ this.#loadedCursors.set(loaded.value.chainId, loaded.cursor);
1419
+ return structuredClone(loaded.value);
868
1420
  }
869
1421
  async #persist(session) {
870
1422
  touch(session);
871
1423
  session.completedTools = [...session.completedToolMap.values()].map((item) => structuredClone(item));
872
- const { documents: _documents, history: _history, completedToolMap: _completedToolMap, ...persisted } = session;
1424
+ const { documents: _documents, history: _history, completedToolMap: _completedToolMap, persistenceCursor: _persistenceCursor, ...persisted } = session;
873
1425
  void _documents;
874
1426
  void _history;
875
1427
  void _completedToolMap;
876
- await this.#worlds.writePlayCallChain(session.worldId, structuredClone(persisted));
1428
+ void _persistenceCursor;
1429
+ session.persistenceCursor = await this.#worlds.playTimeline.persist(structuredClone(persisted), session.persistenceCursor);
877
1430
  }
878
1431
  }
879
1432
  /**
@@ -884,21 +1437,72 @@ export class PlayCallChain {
884
1437
  function followupOperationId(session) {
885
1438
  return `${session.chainId}:followup:${session.parentHead}`;
886
1439
  }
887
- function createRequest(session, modelHost, exchange) {
1440
+ function createRequest(session, modelHost, exchange, appended = session.transcript) {
1441
+ return createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1442
+ }
1443
+ function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
888
1444
  return {
889
1445
  bootstrap: structuredClone(session.bootstrap),
890
1446
  tools: structuredClone(session.tools),
891
1447
  toolUniverse: structuredClone(session.tools),
892
1448
  allowedTools: session.tools.map(({ name }) => name),
893
1449
  toolStrategy: session.bootstrap.toolStrategy,
894
- appended: structuredClone(session.transcript),
1450
+ appended: structuredClone([...appended]),
895
1451
  requestId: "play_call_chain",
896
1452
  operationId: session.chainId,
897
1453
  requestAttempt: 1,
898
1454
  exchange,
899
- maxOutputTokens: modelHost.binding().maxOutputTokens,
1455
+ maxOutputTokens,
900
1456
  };
901
1457
  }
1458
+ function durableModelResponse(response) {
1459
+ const { diagnostics: _diagnostics, ...durable } = response;
1460
+ void _diagnostics;
1461
+ return structuredClone(durable);
1462
+ }
1463
+ function finalizePreparedReceipts(settlement) {
1464
+ const committedWriteRefs = new Set(settlement.stateChanges.map(({ stableShortRef }) => stableShortRef));
1465
+ const markdownByCall = new Map();
1466
+ for (const completed of settlement.completedTools) {
1467
+ const candidateWrite = completed.result.candidateWrite;
1468
+ if (!completed.result.ok || candidateWrite === undefined)
1469
+ continue;
1470
+ const markdown = candidateWrite.changed && committedWriteRefs.has(candidateWrite.shortRef)
1471
+ ? `@${candidateWrite.shortRef} write succeeded`
1472
+ : `@${candidateWrite.shortRef} unchanged`;
1473
+ completed.result.markdown = markdown;
1474
+ delete completed.result.candidateWrite;
1475
+ const callId = completed.key.slice(completed.key.indexOf(":") + 1);
1476
+ markdownByCall.set(callId, markdown);
1477
+ }
1478
+ for (const event of settlement.trailingEvents) {
1479
+ if (event.kind !== "tool_result")
1480
+ continue;
1481
+ const markdown = markdownByCall.get(event.callId);
1482
+ if (markdown !== undefined)
1483
+ event.markdown = markdown;
1484
+ }
1485
+ for (const item of settlement.transcriptAppend) {
1486
+ if (item.kind !== "tool")
1487
+ continue;
1488
+ const markdown = markdownByCall.get(item.toolCallId);
1489
+ if (markdown !== undefined)
1490
+ item.markdown = markdown;
1491
+ }
1492
+ }
1493
+ function appendCommittedHistory(session, head, messages) {
1494
+ const sequence = Number(head.slice("commit:".length));
1495
+ for (const [index, message] of messages.entries()) {
1496
+ const path = `message.${sequence}.${index + 1}.${message.role}`;
1497
+ const existing = session.history.find((item) => item.path === path);
1498
+ if (existing !== undefined) {
1499
+ if (existing.contents !== message.exactText)
1500
+ throw new PlayCallChainError("Recovered Authority history conflicts with the call-chain history.");
1501
+ continue;
1502
+ }
1503
+ session.history.push({ path, contents: message.exactText });
1504
+ }
1505
+ }
902
1506
  function prepareTool(session, working, exchange, call) {
903
1507
  const key = `${exchange}:${call.id}`;
904
1508
  const signature = JSON.stringify(call.arguments);
@@ -916,7 +1520,8 @@ function prepareTool(session, working, exchange, call) {
916
1520
  markdown: "# Runtime tool rejected\n\nThe same tool-call ID is already bound to another tool name or argument set.",
917
1521
  };
918
1522
  }
919
- else if (!callChainToolNames.has(call.name)) {
1523
+ else if (!callChainToolNames.has(call.name) ||
1524
+ !session.tools.some(({ name }) => name === call.name)) {
920
1525
  result = {
921
1526
  ok: false,
922
1527
  failureKind: "protocol",
@@ -960,6 +1565,7 @@ function prepareTool(session, working, exchange, call) {
960
1565
  kind: "tool",
961
1566
  toolCallId: call.id,
962
1567
  markdown: result.markdown,
1568
+ ...(result.ok ? {} : { isError: true }),
963
1569
  },
964
1570
  };
965
1571
  }
@@ -999,19 +1605,6 @@ function restorePlayDocuments(files, context, events) {
999
1605
  }
1000
1606
  return documents;
1001
1607
  }
1002
- function recordDocumentAuthorizationCheckpoint(session, afterEventId) {
1003
- const authorization = session.documents.authorizationCheckpoint();
1004
- const previous = session.documentAuthorizationCheckpoints.at(-1);
1005
- if (previous !== undefined &&
1006
- JSON.stringify(previous.authorization) === JSON.stringify(authorization))
1007
- return;
1008
- if (previous !== undefined && afterEventId <= previous.afterEventId)
1009
- throw new PlayCallChainError("Call-chain document authorization checkpoints are out of order.");
1010
- session.documentAuthorizationCheckpoints.push({
1011
- afterEventId,
1012
- authorization,
1013
- });
1014
- }
1015
1608
  function documentAuthorizationThroughEvents(context, events) {
1016
1609
  const checkpoints = context.documentAuthorizationCheckpoints;
1017
1610
  if (checkpoints === undefined || checkpoints.length === 0)
@@ -1023,20 +1616,6 @@ function documentAuthorizationThroughEvents(context, events) {
1023
1616
  // old recovery behavior and rebuild only bootstrap authorization.
1024
1617
  return selected === undefined ? undefined : structuredClone(selected);
1025
1618
  }
1026
- function snapshotCurrentContext(session) {
1027
- const { schemaVersion: _schemaVersion, kind: _kind, worldId: _worldId, previousContexts: _previousContexts, ...context } = session;
1028
- void _schemaVersion;
1029
- void _kind;
1030
- void _worldId;
1031
- void _previousContexts;
1032
- return structuredClone(context);
1033
- }
1034
- function persistedContexts(session) {
1035
- return [
1036
- ...(session.previousContexts ?? []).map((context) => structuredClone(context)),
1037
- snapshotCurrentContext(session),
1038
- ];
1039
- }
1040
1619
  function independentContextCopy(source) {
1041
1620
  const context = structuredClone(source);
1042
1621
  delete context.derivedFrom;
@@ -1068,12 +1647,43 @@ function projectContext(context) {
1068
1647
  playPreset: structuredClone(context.playPreset),
1069
1648
  status: context.status,
1070
1649
  canRetry: context.canRetry,
1071
- events: structuredClone(context.events),
1650
+ events: context.events
1651
+ .slice(-projectedEventTail)
1652
+ .map((event) => projectActivityEvent(event)),
1072
1653
  changedDocuments: structuredClone(context.changedDocuments),
1073
1654
  lastFailure: context.lastFailure,
1074
1655
  updatedAt: context.updatedAt,
1075
1656
  };
1076
1657
  }
1658
+ function projectActivityEvent(event) {
1659
+ if (event.kind === "player" ||
1660
+ event.kind === "failure" ||
1661
+ event.kind === "cancellation")
1662
+ return structuredClone(event);
1663
+ if (event.kind === "assistant") {
1664
+ const { reasoning, toolFragment, usage, ...summary } = event;
1665
+ void reasoning;
1666
+ void toolFragment;
1667
+ void usage;
1668
+ return structuredClone(summary);
1669
+ }
1670
+ if (event.kind === "tool_call")
1671
+ return { ...structuredClone(event), arguments: null };
1672
+ if (event.kind === "tool_result")
1673
+ return { ...structuredClone(event), markdown: "" };
1674
+ const { reasoning, usage, ...summary } = event;
1675
+ void reasoning;
1676
+ void usage;
1677
+ return {
1678
+ ...structuredClone(summary),
1679
+ text: "",
1680
+ toolCalls: event.toolCalls.map((call) => ({
1681
+ ...structuredClone(call),
1682
+ arguments: null,
1683
+ markdown: "",
1684
+ })),
1685
+ };
1686
+ }
1077
1687
  function projectView(session) {
1078
1688
  return {
1079
1689
  ...projectContext(session),
@@ -1111,7 +1721,9 @@ function transcriptThroughEvents(transcript, events) {
1111
1721
  .find((candidate) => candidate.kind === "player" ||
1112
1722
  candidate.kind === "assistant" ||
1113
1723
  candidate.kind === "tool_call");
1114
- const recorded = event.text.trim().length > 0 || hasToolCall?.kind === "tool_call";
1724
+ const recorded = event.text.trim().length > 0 ||
1725
+ hasToolCall?.kind === "tool_call" ||
1726
+ transcript[cursor]?.kind === "assistant";
1115
1727
  if (!recorded)
1116
1728
  continue;
1117
1729
  const item = transcript[cursor];
@@ -1168,84 +1780,14 @@ function operationId(chainId, key) {
1168
1780
  .update(`${chainId}\0${key}`)
1169
1781
  .digest("hex")}`;
1170
1782
  }
1171
- function assertPersistedPlayCallChain(value, worldId) {
1172
- if (!isRecord(value) ||
1173
- (value.schemaVersion !== 1 && value.schemaVersion !== 2) ||
1174
- value.kind !== "play_call_chain" ||
1175
- value.worldId !== worldId ||
1176
- !isPersistedPlayCallChainContext(value) ||
1177
- (value.previousContexts !== undefined &&
1178
- (!Array.isArray(value.previousContexts) ||
1179
- value.previousContexts.some((context) => !isPersistedPlayCallChainContext(context)))))
1180
- throw new PlayCallChainError("The persisted call-chain record does not match the current format and cannot be read.");
1181
- }
1182
- function isPersistedPlayCallChainContext(value) {
1183
- if (!isRecord(value))
1184
- return false;
1185
- return (typeof value.chainId === "string" &&
1186
- typeof value.baselineHead === "string" &&
1187
- (value.baselineHistoryLength === undefined ||
1188
- (typeof value.baselineHistoryLength === "number" &&
1189
- Number.isSafeInteger(value.baselineHistoryLength) &&
1190
- value.baselineHistoryLength >= 0)) &&
1191
- typeof value.parentHead === "string" &&
1192
- (value.derivedFrom === undefined ||
1193
- (isRecord(value.derivedFrom) &&
1194
- typeof value.derivedFrom.worldId === "string" &&
1195
- typeof value.derivedFrom.chainId === "string" &&
1196
- typeof value.derivedFrom.head === "string")) &&
1197
- (value.branchedBeforePlayer === undefined ||
1198
- (isRecord(value.branchedBeforePlayer) &&
1199
- typeof value.branchedBeforePlayer.worldId === "string" &&
1200
- typeof value.branchedBeforePlayer.chainId === "string" &&
1201
- Number.isSafeInteger(value.branchedBeforePlayer.eventId) &&
1202
- value.branchedBeforePlayer.eventId > 0)) &&
1203
- isRecord(value.playPreset) &&
1204
- (value.followups === undefined || Array.isArray(value.followups)) &&
1205
- (value.playPresetScriptsEnabled === undefined ||
1206
- typeof value.playPresetScriptsEnabled === "boolean") &&
1207
- (value.status === "ready" ||
1208
- value.status === "running" ||
1209
- value.status === "interrupted") &&
1210
- typeof value.canRetry === "boolean" &&
1211
- isRecord(value.bootstrap) &&
1212
- Array.isArray(value.tools) &&
1213
- Array.isArray(value.transcript) &&
1214
- Array.isArray(value.events) &&
1215
- Array.isArray(value.completedTools) &&
1216
- (value.documentAuthorizationCheckpoints === undefined ||
1217
- isPersistedDocumentAuthorizationHistory(value.documentAuthorizationCheckpoints, value.events)) &&
1218
- Array.isArray(value.changedDocuments) &&
1219
- Array.isArray(value.nextMaterials) &&
1220
- Number.isSafeInteger(value.nextEventId) &&
1221
- Number.isSafeInteger(value.exchange) &&
1222
- (value.lastRequest === null || isRecord(value.lastRequest)) &&
1223
- Number.isSafeInteger(value.lastRequestAttempt) &&
1224
- (value.lastFailure === null || typeof value.lastFailure === "string") &&
1225
- typeof value.updatedAt === "number");
1783
+ function assertModelHostBinding(frozen, modelHost) {
1784
+ if (!equalModelHostBinding(frozen, modelHost.binding()))
1785
+ throw new PlayCallChainError("The selected model connection does not match the frozen model binding; start a fresh context.");
1226
1786
  }
1227
- function isPersistedDocumentAuthorizationHistory(value, events) {
1228
- if (!Array.isArray(value) || value.length > 100_000)
1229
- return false;
1230
- const eventIds = new Set(events.flatMap((event) => isRecord(event) && Number.isSafeInteger(event.id) && Number(event.id) > 0
1231
- ? [Number(event.id)]
1232
- : []));
1233
- let previousEventId = -1;
1234
- for (const checkpoint of value) {
1235
- if (!isRecord(checkpoint) ||
1236
- Object.keys(checkpoint).length !== 2 ||
1237
- !Object.hasOwn(checkpoint, "afterEventId") ||
1238
- !Object.hasOwn(checkpoint, "authorization") ||
1239
- !Number.isSafeInteger(checkpoint.afterEventId) ||
1240
- Number(checkpoint.afterEventId) < 0 ||
1241
- Number(checkpoint.afterEventId) <= previousEventId ||
1242
- (Number(checkpoint.afterEventId) !== 0 &&
1243
- !eventIds.has(Number(checkpoint.afterEventId))) ||
1244
- !isPlayDocumentAuthorizationCheckpoint(checkpoint.authorization))
1245
- return false;
1246
- previousEventId = Number(checkpoint.afterEventId);
1247
- }
1248
- return true;
1787
+ function assertSessionModelHostBinding(session, modelHost) {
1788
+ if (session.modelBinding === undefined)
1789
+ throw new PlayCallChainError("This model context predates durable provider continuation bindings; start a fresh context.");
1790
+ assertModelHostBinding(session.modelBinding, modelHost);
1249
1791
  }
1250
1792
  function validateIdentity(value, label) {
1251
1793
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
@@ -1258,23 +1800,16 @@ function validatePlayerText(value) {
1258
1800
  function touch(session) {
1259
1801
  session.updatedAt = Date.now();
1260
1802
  }
1261
- function notifySnapshot(observer, session) {
1262
- try {
1263
- observer?.onSnapshot?.(projectView(session));
1264
- }
1265
- catch {
1266
- // A browser stream may disconnect while the durable model request keeps
1267
- // running. Presentation failure never cancels or changes play Authority.
1268
- }
1269
- }
1270
1803
  function notifyAssistantDelta(observer, delta) {
1271
1804
  try {
1272
1805
  observer?.onAssistantDelta?.(structuredClone(delta));
1273
1806
  }
1274
1807
  catch {
1275
- // See notifySnapshot: the Provider exchange outlives its browser stream.
1808
+ // Like snapshot projection, the Provider exchange outlives its browser stream.
1276
1809
  }
1277
1810
  }
1278
- function isRecord(value) {
1279
- return typeof value === "object" && value !== null && !Array.isArray(value);
1811
+ function crashAtPlayAdvanceEdge(edge) {
1812
+ if (process.env.NODE_ENV === "test" &&
1813
+ process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PLAY_ADVANCE_EDGE === edge)
1814
+ throw new Error(`Simulated process exit at play advance edge: ${edge}`);
1280
1815
  }