narraeon 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { equalModelHostBinding, 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,7 @@ export class PlayCallChain {
27
34
  #failureLog;
28
35
  #active = new Map();
29
36
  #worldChains = new Map();
37
+ #loadedCursors = new Map();
30
38
  constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog) {
31
39
  this.#worlds = worlds;
32
40
  this.#compiler = compiler;
@@ -37,6 +45,7 @@ export class PlayCallChain {
37
45
  validateIdentity(input.chainId, "call-chain ID");
38
46
  validateIdentity(input.exchangeId, "exchange ID");
39
47
  validatePlayerText(input.playerText);
48
+ assertModelHostBinding(input.modelBinding, input.modelHost);
40
49
  const existing = await this.#readPersisted(input.worldId);
41
50
  if (existing?.chainId === input.chainId) {
42
51
  const duplicate = duplicatePlayerExchange(existing, input.exchangeId, input.playerText, "fresh");
@@ -70,16 +79,14 @@ export class PlayCallChain {
70
79
  documents.bindBootstrap(bootstrap);
71
80
  const now = Date.now();
72
81
  const session = {
73
- schemaVersion: 1,
82
+ schemaVersion: 3,
74
83
  kind: "play_call_chain",
75
84
  chainId: input.chainId,
76
85
  worldId: input.worldId,
77
- previousContexts: existing === null
78
- ? []
79
- : [
80
- ...(existing.previousContexts ?? []).map((context) => structuredClone(context)),
81
- snapshotCurrentContext(existing),
82
- ],
86
+ previousContexts: [],
87
+ previousChainId: existing?.chainId ?? null,
88
+ timelineGeneration: existing?.timelineGeneration ??
89
+ this.#worlds.playTimeline.newGeneration(),
83
90
  baselineHead: binding.parentHead,
84
91
  baselineHistoryLength: Object.keys(binding.history).length,
85
92
  parentHead: binding.parentHead,
@@ -90,6 +97,7 @@ export class PlayCallChain {
90
97
  },
91
98
  followups: structuredClone(compilation.followups ?? []),
92
99
  playPresetScriptsEnabled: input.playPreset.scriptsEnabled,
100
+ modelBinding: structuredClone(input.modelBinding),
93
101
  status: "ready",
94
102
  canRetry: false,
95
103
  bootstrap,
@@ -114,6 +122,12 @@ export class PlayCallChain {
114
122
  documents,
115
123
  history: historyEntries(binding.history),
116
124
  completedToolMap: new Map(),
125
+ persistenceCursor: {
126
+ eventCount: 0,
127
+ transcriptCount: 0,
128
+ completedToolCount: 0,
129
+ authorizationCheckpointCount: 0,
130
+ },
117
131
  };
118
132
  if (activeId !== undefined)
119
133
  this.#active.delete(activeId);
@@ -126,6 +140,7 @@ export class PlayCallChain {
126
140
  validateIdentity(input.exchangeId, "exchange ID");
127
141
  const session = await this.#requireChain(input.worldId, input.chainId);
128
142
  await this.#assertCurrentHead(session);
143
+ assertSessionModelHostBinding(session, input.modelHost);
129
144
  if (input.playerText.trim().length === 0) {
130
145
  if (session.status === "running")
131
146
  throw new PlayCallChainError("A model request is still running.");
@@ -148,31 +163,21 @@ export class PlayCallChain {
148
163
  async inspectWorld(worldId) {
149
164
  const activeId = this.#worldChains.get(worldId);
150
165
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
151
- if (active !== undefined)
166
+ if (active !== undefined) {
167
+ await this.#reconcileSessionAdvance(active);
152
168
  return projectView(active);
169
+ }
153
170
  const persisted = await this.#readPersisted(worldId);
154
171
  if (persisted === null)
155
172
  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,
168
- });
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);
173
+ const advance = await this.#worlds.playAdvances.readCurrent(worldId, persisted.chainId);
174
+ const session = await this.#hydrateSession(persisted, advance);
175
+ this.#active.set(session.chainId, session);
176
+ this.#worldChains.set(worldId, session.chainId);
177
+ await this.#reconcileSessionAdvance(session, advance);
178
+ if (session.status === "running")
179
+ await this.#interruptAbandonedDispatch(session);
180
+ return projectView(session);
176
181
  }
177
182
  async forkToDerivedWorld(input) {
178
183
  const existing = await this.#readPersisted(input.targetWorldId);
@@ -181,7 +186,7 @@ export class PlayCallChain {
181
186
  const source = await this.#readPersisted(input.sourceWorldId);
182
187
  if (source === null)
183
188
  return null;
184
- const sourceContexts = persistedContexts(source);
189
+ const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
185
190
  const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
186
191
  if (selectedContextIndex < 0)
187
192
  return null;
@@ -216,7 +221,7 @@ export class PlayCallChain {
216
221
  const source = await this.#readPersisted(input.worldId);
217
222
  if (source === null)
218
223
  throw new PlayCallChainError("The current world has no model call chain to revise.");
219
- const sourceContexts = persistedContexts(source);
224
+ const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
220
225
  const appliedEvent = sourceContexts
221
226
  .flatMap(({ events }) => events)
222
227
  .find((event) => event.kind === "player" &&
@@ -311,13 +316,15 @@ export class PlayCallChain {
311
316
  throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
312
317
  const documents = restorePlayDocuments(binding.files, sourceContext, prefixEvents);
313
318
  const revised = {
314
- schemaVersion: 1,
319
+ schemaVersion: 3,
315
320
  kind: "play_call_chain",
316
321
  chainId: derivedChainId(sourceContext.chainId, "timeline-revision:" + request.operationId, request.worldId),
317
322
  worldId: request.worldId,
318
- previousContexts: input.sourceContexts
319
- .slice(0, input.selectedContextIndex)
320
- .map((context) => independentContextCopy(context)),
323
+ previousContexts: [],
324
+ previousChainId: input.selectedContextIndex === 0
325
+ ? null
326
+ : input.sourceContexts[input.selectedContextIndex - 1].chainId,
327
+ timelineGeneration: this.#worlds.playTimeline.newGeneration(),
321
328
  baselineHead: sourceContext.baselineHead,
322
329
  baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
323
330
  parentHead: outcome.head,
@@ -330,6 +337,9 @@ export class PlayCallChain {
330
337
  : {
331
338
  playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
332
339
  }),
340
+ ...(sourceContext.modelBinding === undefined
341
+ ? {}
342
+ : { modelBinding: structuredClone(sourceContext.modelBinding) }),
333
343
  status: "ready",
334
344
  canRetry: false,
335
345
  bootstrap: structuredClone(sourceContext.bootstrap),
@@ -351,7 +361,7 @@ export class PlayCallChain {
351
361
  lastFailure: null,
352
362
  updatedAt: Date.now(),
353
363
  };
354
- await this.#worlds.writePlayCallChain(request.worldId, structuredClone(revised));
364
+ await this.#worlds.playTimeline.persist(structuredClone(revised));
355
365
  const activeId = this.#worldChains.get(request.worldId);
356
366
  if (activeId !== undefined)
357
367
  this.#active.delete(activeId);
@@ -380,14 +390,32 @@ export class PlayCallChain {
380
390
  const derivedBinding = await this.#worlds.bindPlayCallChain(input.targetWorldId);
381
391
  const derivedDocuments = restorePlayDocuments(derivedBinding.files, sourceContext, input.sourceEvents);
382
392
  const now = Date.now();
393
+ const timelineGeneration = this.#worlds.playTimeline.newGeneration();
394
+ let previousChainId = null;
395
+ for (const [index, context] of input.sourceContexts
396
+ .slice(0, input.selectedContextIndex)
397
+ .entries()) {
398
+ const chainId = derivedChainId(context.chainId, `prefix:${input.branchIdentity}:${index}`, input.targetWorldId);
399
+ await this.#worlds.playTimeline.persist({
400
+ ...independentContextCopy(context),
401
+ schemaVersion: 3,
402
+ kind: "play_call_chain",
403
+ worldId: input.targetWorldId,
404
+ chainId,
405
+ previousContexts: [],
406
+ previousChainId,
407
+ timelineGeneration,
408
+ });
409
+ previousChainId = chainId;
410
+ }
383
411
  const derived = {
384
- schemaVersion: 1,
412
+ schemaVersion: 3,
385
413
  kind: "play_call_chain",
386
414
  chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
387
415
  worldId: input.targetWorldId,
388
- previousContexts: input.sourceContexts
389
- .slice(0, input.selectedContextIndex)
390
- .map((context) => independentContextCopy(context)),
416
+ previousContexts: [],
417
+ previousChainId,
418
+ timelineGeneration,
391
419
  baselineHead: sourceContext.baselineHead,
392
420
  baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
393
421
  parentHead: input.sourceHead,
@@ -400,6 +428,9 @@ export class PlayCallChain {
400
428
  : {
401
429
  playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
402
430
  }),
431
+ ...(sourceContext.modelBinding === undefined
432
+ ? {}
433
+ : { modelBinding: structuredClone(sourceContext.modelBinding) }),
403
434
  status: "ready",
404
435
  canRetry: false,
405
436
  bootstrap: structuredClone(sourceContext.bootstrap),
@@ -421,7 +452,7 @@ export class PlayCallChain {
421
452
  lastFailure: null,
422
453
  updatedAt: now,
423
454
  };
424
- await this.#worlds.writePlayCallChain(input.targetWorldId, structuredClone(derived));
455
+ await this.#worlds.playTimeline.persist(structuredClone(derived));
425
456
  return projectView(derived);
426
457
  }
427
458
  forgetWorld(worldId) {
@@ -438,17 +469,44 @@ export class PlayCallChain {
438
469
  return projectView(session);
439
470
  if (session.status === "running")
440
471
  throw new PlayCallChainError("The call chain is waiting for the model to return.");
441
- session.status = "running";
442
- session.lastFailure = null;
443
- let committed;
472
+ const exchange = session.exchange + 1;
473
+ const nextRequest = createRequest(session, modelHost, exchange, [
474
+ ...session.transcript,
475
+ { kind: "player", text: playerText },
476
+ ]);
477
+ const operationKey = `player:${exchangeId}`;
478
+ const playerOperationId = operationId(session.chainId, operationKey);
479
+ const advance = {
480
+ schemaVersion: 1,
481
+ kind: "play_advance",
482
+ advanceKind: "player",
483
+ worldId: session.worldId,
484
+ chainId: session.chainId,
485
+ advanceId: playerOperationId,
486
+ operationId: playerOperationId,
487
+ parentHead: session.parentHead,
488
+ eventId: session.nextEventId,
489
+ exchangeId,
490
+ playerText,
491
+ context,
492
+ transcriptStart: session.transcript.length,
493
+ exchange,
494
+ nextRequest: structuredClone(nextRequest),
495
+ createdAt: Date.now(),
496
+ };
497
+ await this.#worlds.playAdvances.begin(advance);
444
498
  try {
445
- committed = await this.#commitStep(session, {
446
- operationKey: `player:${exchangeId}`,
447
- historyAppend: [{ role: "player", exactText: playerText }],
448
- stateChanges: [],
449
- });
499
+ await this.#settlePlayerAdvance(session, advance);
450
500
  }
451
501
  catch (error) {
502
+ const accepted = await this.#worlds
503
+ .getOperationOutcome(advance.operationId)
504
+ .catch(() => ({ outcome: "not_started" }));
505
+ if (accepted.outcome === "committed" ||
506
+ accepted.outcome === "committed_materialization_pending") {
507
+ await this.#settlePlayerAdvance(session, advance);
508
+ return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, observer);
509
+ }
452
510
  const message = error instanceof Error
453
511
  ? error.message
454
512
  : "Failed to write the original player text.";
@@ -461,22 +519,272 @@ export class PlayCallChain {
461
519
  session.canRetry = false;
462
520
  session.lastFailure = message;
463
521
  await this.#persist(session);
522
+ await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
464
523
  throw new PlayCallChainError(message);
465
524
  }
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,
525
+ return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, observer);
526
+ }
527
+ async #settlePlayerAdvance(session, advance) {
528
+ const outcome = await this.#worlds.commitPlayStep({
529
+ operationId: advance.operationId,
530
+ worldId: session.worldId,
531
+ parentHead: advance.parentHead,
532
+ historyAppend: [{ role: "player", exactText: advance.playerText }],
533
+ nextMaterials: structuredClone(session.nextMaterials),
534
+ stateChanges: [],
474
535
  });
536
+ if (outcome.worldId !== session.worldId ||
537
+ outcome.parentHead !== advance.parentHead)
538
+ throw new PlayCallChainError("The recovered player advance belongs to a different world endpoint.");
539
+ const playerEvent = {
540
+ id: advance.eventId,
541
+ kind: "player",
542
+ exchangeId: advance.exchangeId,
543
+ text: advance.playerText,
544
+ context: advance.context,
545
+ committedHead: outcome.head,
546
+ };
547
+ session.events = [
548
+ ...session.events.filter(({ id }) => id < advance.eventId),
549
+ playerEvent,
550
+ ];
551
+ if (session.transcript.length < advance.transcriptStart)
552
+ throw new PlayCallChainError("The recovered call-chain transcript is shorter than its player prefix.");
553
+ session.transcript = [
554
+ ...session.transcript.slice(0, advance.transcriptStart),
555
+ { kind: "player", text: advance.playerText },
556
+ ];
557
+ session.nextEventId = advance.eventId + 1;
558
+ session.parentHead = outcome.head;
559
+ appendCommittedHistory(session, outcome.head, outcome.historyAppend);
560
+ session.exchange = advance.exchange;
561
+ session.lastRequest = structuredClone(advance.nextRequest);
562
+ session.lastRequestAttempt = 0;
563
+ session.status = "interrupted";
564
+ session.canRetry = true;
565
+ session.lastFailure =
566
+ "The player message is committed; the frozen model request is ready to continue.";
475
567
  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);
568
+ crashAtPlayAdvanceEdge("after_timeline_settled");
569
+ await this.#worlds.playAdvances.markSettled(advance, outcome.head);
570
+ }
571
+ #prepareResponseSettlement(session, advance, response) {
572
+ const existing = session.events.find(({ id }) => id === advance.eventId);
573
+ if (existing?.kind !== "assistant")
574
+ throw new PlayCallChainError("The durable Provider result no longer matches its assistant event.");
575
+ const calls = response.toolCalls ?? [];
576
+ const text = response.text ?? existing.text;
577
+ const hasText = text.trim().length > 0;
578
+ const responseKind = calls.length > 0 ? "tool_step" : hasText ? "narrative" : "empty";
579
+ const assistantEvent = {
580
+ id: advance.eventId,
581
+ kind: "assistant",
582
+ text,
583
+ status: "completed",
584
+ responseKind,
585
+ exchange: advance.exchange,
586
+ attempt: advance.attempt,
587
+ ...(response.reasoningContent === undefined
588
+ ? existing.reasoning === undefined
589
+ ? {}
590
+ : { reasoning: existing.reasoning }
591
+ : { reasoning: response.reasoningContent }),
592
+ ...(response.usage === undefined
593
+ ? {}
594
+ : { usage: structuredClone(response.usage) }),
595
+ ...(response.stopReason === undefined
596
+ ? {}
597
+ : { stopReason: response.stopReason }),
598
+ continuation: response.providerState === undefined ? "unavailable" : "available",
599
+ };
600
+ const workingTools = new Map(session.completedToolMap);
601
+ const prepared = [];
602
+ const trailingEvents = [];
603
+ let nextEventId = advance.eventId + 1;
604
+ for (const call of calls) {
605
+ const item = prepareTool(session, workingTools, advance.exchange, call);
606
+ prepared.push(item);
607
+ trailingEvents.push({
608
+ id: nextEventId++,
609
+ kind: "tool_call",
610
+ callId: call.id,
611
+ name: call.name,
612
+ arguments: structuredClone(call.arguments),
613
+ replayed: item.replayed,
614
+ });
615
+ item.event.id = nextEventId++;
616
+ trailingEvents.push(structuredClone(item.event));
617
+ }
618
+ const stateChanges = session.documents.stateChanges();
619
+ const visibleText = responseKind === "narrative";
620
+ const assistantItem = {
621
+ kind: "assistant",
622
+ text,
623
+ ...(response.reasoningContent === undefined
624
+ ? {}
625
+ : { reasoningContent: response.reasoningContent }),
626
+ ...(response.providerState === undefined
627
+ ? {}
628
+ : { providerState: response.providerState }),
629
+ toolCalls: structuredClone(calls),
630
+ };
631
+ return {
632
+ assistantEvent,
633
+ trailingEvents,
634
+ transcriptStart: session.transcript.length,
635
+ transcriptAppend: [
636
+ ...(calls.length > 0 ||
637
+ visibleText ||
638
+ response.providerState !== undefined
639
+ ? [assistantItem]
640
+ : []),
641
+ ...prepared.map(({ transcript }) => structuredClone(transcript)),
642
+ ],
643
+ completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
644
+ authorizationCheckpoint: session.documents.authorizationCheckpoint(),
645
+ stateChanges: structuredClone(stateChanges),
646
+ visibleText,
647
+ };
648
+ }
649
+ async #settleResponseAdvance(session, advance, prepared) {
650
+ const settlement = structuredClone(prepared);
651
+ const hasToolCalls = settlement.trailingEvents.some(({ kind }) => kind === "tool_call");
652
+ // A legacy prepared fact has no responseKind. Its visibleText decision is
653
+ // already part of an immutable recovery operation and must be replayed as
654
+ // recorded; every newly prepared fact is checked against the current rule.
655
+ if (settlement.assistantEvent.responseKind !== undefined) {
656
+ const expectedResponseKind = hasToolCalls
657
+ ? "tool_step"
658
+ : settlement.assistantEvent.text.trim().length > 0
659
+ ? "narrative"
660
+ : "empty";
661
+ if (settlement.assistantEvent.responseKind !== expectedResponseKind ||
662
+ settlement.visibleText !== (expectedResponseKind === "narrative"))
663
+ throw new PlayCallChainError("The prepared response settlement has an inconsistent narrative classification.");
664
+ }
665
+ let committedHead;
666
+ if (settlement.visibleText || settlement.stateChanges.length > 0) {
667
+ const outcome = await this.#worlds.commitPlayStep({
668
+ operationId: advance.operationId,
669
+ worldId: session.worldId,
670
+ parentHead: advance.parentHead,
671
+ historyAppend: settlement.visibleText
672
+ ? [
673
+ {
674
+ role: "narrator",
675
+ exactText: settlement.assistantEvent.text,
676
+ },
677
+ ]
678
+ : [],
679
+ nextMaterials: structuredClone(session.nextMaterials),
680
+ stateChanges: structuredClone(settlement.stateChanges),
681
+ });
682
+ committedHead = outcome.head;
683
+ settlement.assistantEvent.committedHead = outcome.head;
684
+ crashAtPlayAdvanceEdge("after_authority_accepted");
685
+ appendCommittedHistory(session, outcome.head, outcome.historyAppend);
686
+ }
687
+ finalizePreparedReceipts(settlement);
688
+ session.events = [
689
+ ...session.events.filter(({ id }) => id < advance.eventId),
690
+ structuredClone(settlement.assistantEvent),
691
+ ...structuredClone(settlement.trailingEvents),
692
+ ];
693
+ session.nextEventId =
694
+ Math.max(advance.eventId, ...settlement.trailingEvents.map(({ id }) => id)) + 1;
695
+ if (session.transcript.length < settlement.transcriptStart)
696
+ throw new PlayCallChainError("The recovered call-chain transcript is shorter than its settlement prefix.");
697
+ session.transcript = [
698
+ ...session.transcript.slice(0, settlement.transcriptStart),
699
+ ...structuredClone(settlement.transcriptAppend),
700
+ ];
701
+ session.completedTools = structuredClone(settlement.completedTools);
702
+ session.completedToolMap = new Map(settlement.completedTools.map((item) => [
703
+ item.key,
704
+ structuredClone(item),
705
+ ]));
706
+ const lastToolEventId = settlement.trailingEvents.findLast(({ kind }) => kind === "tool_result")?.id;
707
+ if (lastToolEventId !== undefined) {
708
+ session.documentAuthorizationCheckpoints =
709
+ session.documentAuthorizationCheckpoints.filter(({ afterEventId }) => afterEventId < advance.eventId);
710
+ session.documentAuthorizationCheckpoints.push({
711
+ afterEventId: lastToolEventId,
712
+ authorization: structuredClone(settlement.authorizationCheckpoint),
713
+ });
714
+ }
715
+ if (committedHead !== undefined)
716
+ session.parentHead = committedHead;
717
+ if (settlement.stateChanges.length > 0) {
718
+ const currentAuthorization = session.documents.authorizationCheckpoint();
719
+ if (currentAuthorization.stateFingerprint ===
720
+ settlement.authorizationCheckpoint.stateFingerprint)
721
+ session.documents.acceptCommittedState();
722
+ else {
723
+ const binding = await this.#worlds.bindPlayCallChain(session.worldId);
724
+ session.documents = new FileNativePlayDocuments(binding.files);
725
+ session.documents.restoreAuthorizationCheckpoint(settlement.authorizationCheckpoint);
726
+ session.history = historyEntries(binding.history);
727
+ session.nextMaterials = structuredClone(binding.additionalMaterials);
728
+ }
729
+ mergeChangedDocuments(session, settlement.stateChanges);
730
+ }
731
+ session.exchange = Math.max(session.exchange, advance.exchange);
732
+ session.canRetry = false;
733
+ session.lastFailure = null;
734
+ let result;
735
+ if (settlement.assistantEvent.continuation !== "available") {
736
+ const message = "The complete model response has no provider-native continuation payload; use a fresh context.";
737
+ session.events.push({
738
+ id: session.nextEventId++,
739
+ kind: "failure",
740
+ message,
741
+ });
742
+ session.status = "interrupted";
743
+ session.lastFailure = message;
744
+ result = { kind: "terminal", status: "interrupted" };
745
+ }
746
+ else if (hasToolCalls) {
747
+ session.exchange += 1;
748
+ const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
749
+ session.lastRequest = structuredClone(nextRequest);
750
+ session.lastRequestAttempt = 0;
751
+ session.status = "interrupted";
752
+ session.canRetry = true;
753
+ session.lastFailure =
754
+ "The completed tool response is settled; its frozen continuation request is ready.";
755
+ result = { kind: "continue", nextRequest };
756
+ }
757
+ else {
758
+ session.status = settlement.visibleText ? "ready" : "interrupted";
759
+ if (!settlement.visibleText) {
760
+ const message = "The model returned neither text nor a complete tool call.";
761
+ session.events.push({
762
+ id: session.nextEventId++,
763
+ kind: "failure",
764
+ message,
765
+ });
766
+ session.lastFailure = message;
767
+ }
768
+ result = { kind: "terminal", status: session.status };
769
+ }
770
+ await this.#persist(session);
771
+ crashAtPlayAdvanceEdge("after_timeline_settled");
772
+ await this.#worlds.playAdvances.markSettled(advance, committedHead ?? session.parentHead);
773
+ return result;
774
+ }
775
+ async #recoverAcceptedResponseAdvance(session, advance) {
776
+ const outcome = await this.#worlds
777
+ .getOperationOutcome(advance.operationId)
778
+ .catch(() => ({ outcome: "not_started" }));
779
+ if (outcome.outcome !== "committed" &&
780
+ outcome.outcome !== "committed_materialization_pending")
781
+ return null;
782
+ const current = await this.#worlds.playAdvances.readCurrent(advance.worldId, advance.chainId);
783
+ if (current?.base.advanceKind !== "response" ||
784
+ current.base.advanceId !== advance.advanceId ||
785
+ current.settlementPrepared === null)
786
+ throw new PlayCallChainError("Authority accepted a response whose durable settlement is missing.");
787
+ return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
480
788
  }
481
789
  async #dispatch(session, modelHost, firstRequest, firstAttempt, observer) {
482
790
  let request = structuredClone(firstRequest);
@@ -493,11 +801,29 @@ export class PlayCallChain {
493
801
  kind: "assistant",
494
802
  text: "",
495
803
  status: "streaming",
804
+ responseKind: "pending",
496
805
  exchange: responseExchange,
497
806
  attempt,
498
807
  };
499
808
  session.events.push(event);
500
809
  await this.#persist(session);
810
+ const responseOperationId = operationId(session.chainId, `response:${responseExchange}:attempt:${attempt}`);
811
+ const advance = {
812
+ schemaVersion: 1,
813
+ kind: "play_advance",
814
+ advanceKind: "response",
815
+ worldId: session.worldId,
816
+ chainId: session.chainId,
817
+ advanceId: responseOperationId,
818
+ operationId: responseOperationId,
819
+ parentHead: session.parentHead,
820
+ eventId: event.id,
821
+ exchange: responseExchange,
822
+ attempt,
823
+ createdAt: Date.now(),
824
+ };
825
+ await this.#worlds.playAdvances.begin(advance);
826
+ crashAtPlayAdvanceEdge("after_response_advance_began");
501
827
  notifySnapshot(observer, session);
502
828
  let response;
503
829
  try {
@@ -532,53 +858,24 @@ export class PlayCallChain {
532
858
  message,
533
859
  });
534
860
  session.status = "interrupted";
535
- session.canRetry = true;
861
+ const requiresFreshContext = error instanceof ModelHostContinuationError ||
862
+ error instanceof ModelHostOutcomeUnknownError;
863
+ session.canRetry =
864
+ !requiresFreshContext && error instanceof ModelHostFailureError;
536
865
  session.lastFailure = message;
537
866
  await this.#persist(session);
867
+ await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
538
868
  notifySnapshot(observer, session);
539
869
  return projectView(session);
540
870
  }
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
- };
871
+ const durableResponse = durableModelResponse(response);
564
872
  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);
873
+ await this.#worlds.playAdvances.recordProviderCompleted(advance, durableResponse);
874
+ crashAtPlayAdvanceEdge("after_provider_completed");
875
+ const settlement = this.#prepareResponseSettlement(session, advance, durableResponse);
876
+ await this.#worlds.playAdvances.recordSettlementPrepared(advance, settlement);
877
+ crashAtPlayAdvanceEdge("after_settlement_prepared");
878
+ const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
582
879
  if (failedTools.length > 0 && response.diagnostics !== undefined)
583
880
  await this.#failureLog?.recordFailure({
584
881
  exchange: response.diagnostics,
@@ -587,66 +884,20 @@ export class PlayCallChain {
587
884
  kind: "tool_execution",
588
885
  message: "A Runtime tool call from the model was rejected.",
589
886
  details: {
590
- calls: failedTools.map(({ call, result, replayed }) => ({
591
- id: call.id,
592
- name: call.name,
593
- arguments: structuredClone(call.arguments),
887
+ calls: failedTools.map((result) => ({
888
+ id: result.callId,
889
+ name: result.name,
594
890
  ok: result.ok,
595
- failureKind: result.failureKind,
596
891
  markdown: result.markdown,
597
- replayed,
892
+ replayed: result.replayed,
598
893
  })),
599
894
  },
600
895
  },
601
896
  ],
602
897
  });
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) {
898
+ const settled = await this.#settleResponseAdvance(session, advance, settlement);
899
+ if (settled.kind === "terminal") {
900
+ if (!settlement.visibleText) {
650
901
  const message = "The model returned neither text nor a complete tool call.";
651
902
  if (response.diagnostics !== undefined)
652
903
  await this.#failureLog?.recordFailure({
@@ -658,15 +909,10 @@ export class PlayCallChain {
658
909
  },
659
910
  ],
660
911
  });
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);
912
+ notifySnapshot(observer, session);
913
+ return projectView(session);
914
+ }
915
+ if (settled.status !== "ready") {
670
916
  notifySnapshot(observer, session);
671
917
  return projectView(session);
672
918
  }
@@ -686,13 +932,22 @@ export class PlayCallChain {
686
932
  notifySnapshot(observer, session);
687
933
  return projectView(session);
688
934
  }
689
- await this.#persist(session);
690
935
  notifySnapshot(observer, session);
691
- session.exchange = Math.max(session.exchange, responseExchange) + 1;
692
- request = createRequest(session, modelHost, session.exchange);
936
+ request = settled.nextRequest;
693
937
  attempt = 1;
694
938
  }
695
939
  catch (error) {
940
+ const recovered = await this.#recoverAcceptedResponseAdvance(session, advance);
941
+ if (recovered !== null) {
942
+ if (recovered.kind === "continue") {
943
+ request = recovered.nextRequest;
944
+ attempt = 1;
945
+ notifySnapshot(observer, session);
946
+ continue;
947
+ }
948
+ notifySnapshot(observer, session);
949
+ return projectView(session);
950
+ }
696
951
  const message = error instanceof Error
697
952
  ? error.message
698
953
  : "Call-chain processing failed.";
@@ -789,56 +1044,44 @@ export class PlayCallChain {
789
1044
  touch(session);
790
1045
  }
791
1046
  }
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
1047
  async #requireChain(worldId, chainId) {
812
1048
  validateIdentity(chainId, "call-chain ID");
813
1049
  const active = this.#active.get(chainId);
814
1050
  if (active !== undefined) {
815
1051
  if (active.worldId !== worldId)
816
1052
  throw new PlayCallChainError("The call chain does not belong to this world.");
1053
+ await this.#reconcileSessionAdvance(active);
817
1054
  return active;
818
1055
  }
819
1056
  const persisted = await this.#readPersisted(worldId);
820
1057
  if (persisted?.chainId !== chainId)
821
1058
  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)
1059
+ const advance = await this.#worlds.playAdvances.readCurrent(worldId, chainId);
1060
+ const session = await this.#hydrateSession(persisted, advance);
1061
+ this.#active.set(chainId, session);
1062
+ this.#worldChains.set(worldId, chainId);
1063
+ await this.#reconcileSessionAdvance(session, advance);
1064
+ if (session.status === "running")
1065
+ await this.#interruptAbandonedDispatch(session);
1066
+ const currentHead = await this.#worlds.currentHead(worldId);
1067
+ if (currentHead !== session.parentHead)
839
1068
  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 = {
1069
+ return session;
1070
+ }
1071
+ async #hydrateSession(persisted, advance) {
1072
+ const binding = await this.#worlds.bindPlayCallChain(persisted.worldId);
1073
+ let documents;
1074
+ try {
1075
+ documents = restorePlayDocuments(binding.files, persisted, persisted.events);
1076
+ }
1077
+ catch (error) {
1078
+ const prepared = advance?.settlementPrepared?.settlement;
1079
+ if (prepared === undefined)
1080
+ throw error;
1081
+ documents = new FileNativePlayDocuments(binding.files);
1082
+ documents.restoreAuthorizationCheckpoint(prepared.authorizationCheckpoint);
1083
+ }
1084
+ return {
842
1085
  ...structuredClone(persisted),
843
1086
  documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
844
1087
  nextMaterials: structuredClone(binding.additionalMaterials),
@@ -848,32 +1091,79 @@ export class PlayCallChain {
848
1091
  item.key,
849
1092
  structuredClone(item),
850
1093
  ])),
1094
+ persistenceCursor: this.#loadedCursors.get(persisted.chainId) ?? {
1095
+ eventCount: 0,
1096
+ transcriptCount: 0,
1097
+ completedToolCount: 0,
1098
+ authorizationCheckpointCount: 0,
1099
+ },
851
1100
  };
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;
1101
+ }
1102
+ async #reconcileSessionAdvance(session, loaded) {
1103
+ const current = loaded ??
1104
+ (await this.#worlds.playAdvances.readCurrent(session.worldId, session.chainId));
1105
+ if (current?.settled !== null)
1106
+ return;
1107
+ if (current.base.advanceKind === "player") {
1108
+ await this.#settlePlayerAdvance(session, current.base);
1109
+ return;
1110
+ }
1111
+ if (current.providerCompleted === null) {
1112
+ await this.#interruptAbandonedDispatch(session, current.base);
1113
+ await this.#worlds.playAdvances.markSettled(current.base, session.parentHead);
1114
+ return;
1115
+ }
1116
+ let settlement = current.settlementPrepared?.settlement;
1117
+ if (settlement === undefined) {
1118
+ settlement = this.#prepareResponseSettlement(session, current.base, current.providerCompleted.response);
1119
+ await this.#worlds.playAdvances.recordSettlementPrepared(current.base, settlement);
1120
+ }
1121
+ await this.#settleResponseAdvance(session, current.base, settlement);
1122
+ }
1123
+ async #interruptAbandonedDispatch(session, advance) {
1124
+ const streaming = session.events.findLast((event) => event.kind === "assistant" &&
1125
+ event.status === "streaming" &&
1126
+ (advance === undefined || event.id === advance.eventId));
1127
+ if (streaming !== undefined)
1128
+ streaming.status = "interrupted";
1129
+ const dispatchMayHaveStarted = advance !== undefined;
1130
+ const canRetry = !dispatchMayHaveStarted && session.lastRequest !== null;
1131
+ const message = dispatchMayHaveStarted
1132
+ ? "The service stopped after model dispatch may have started; the outcome is unknown and the request cannot be replayed. Use a fresh context."
1133
+ : canRetry
1134
+ ? "The service stopped before model dispatch; the frozen request can be retried."
1135
+ : "The service stopped before preparing a model request; use a fresh context.";
1136
+ session.events.push({
1137
+ id: session.nextEventId++,
1138
+ kind: "failure",
1139
+ message,
1140
+ });
1141
+ session.status = "interrupted";
1142
+ session.canRetry = canRetry;
1143
+ session.lastFailure = message;
1144
+ await this.#persist(session);
857
1145
  }
858
1146
  async #assertCurrentHead(session) {
859
1147
  if ((await this.#worlds.currentHead(session.worldId)) !== session.parentHead)
860
1148
  throw new PlayCallChainError("Another operation has advanced the current world; use a fresh context.");
861
1149
  }
862
1150
  async #readPersisted(worldId) {
863
- const value = await this.#worlds.readPlayCallChain(worldId);
864
- if (value === null)
1151
+ await this.#worlds.ensureCurrentStorage(worldId);
1152
+ const loaded = await this.#worlds.playTimeline.readCurrent(worldId);
1153
+ if (loaded === null)
865
1154
  return null;
866
- assertPersistedPlayCallChain(value, worldId);
867
- return { ...structuredClone(value), schemaVersion: 1 };
1155
+ this.#loadedCursors.set(loaded.value.chainId, loaded.cursor);
1156
+ return structuredClone(loaded.value);
868
1157
  }
869
1158
  async #persist(session) {
870
1159
  touch(session);
871
1160
  session.completedTools = [...session.completedToolMap.values()].map((item) => structuredClone(item));
872
- const { documents: _documents, history: _history, completedToolMap: _completedToolMap, ...persisted } = session;
1161
+ const { documents: _documents, history: _history, completedToolMap: _completedToolMap, persistenceCursor: _persistenceCursor, ...persisted } = session;
873
1162
  void _documents;
874
1163
  void _history;
875
1164
  void _completedToolMap;
876
- await this.#worlds.writePlayCallChain(session.worldId, structuredClone(persisted));
1165
+ void _persistenceCursor;
1166
+ session.persistenceCursor = await this.#worlds.playTimeline.persist(structuredClone(persisted), session.persistenceCursor);
877
1167
  }
878
1168
  }
879
1169
  /**
@@ -884,21 +1174,72 @@ export class PlayCallChain {
884
1174
  function followupOperationId(session) {
885
1175
  return `${session.chainId}:followup:${session.parentHead}`;
886
1176
  }
887
- function createRequest(session, modelHost, exchange) {
1177
+ function createRequest(session, modelHost, exchange, appended = session.transcript) {
1178
+ return createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1179
+ }
1180
+ function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
888
1181
  return {
889
1182
  bootstrap: structuredClone(session.bootstrap),
890
1183
  tools: structuredClone(session.tools),
891
1184
  toolUniverse: structuredClone(session.tools),
892
1185
  allowedTools: session.tools.map(({ name }) => name),
893
1186
  toolStrategy: session.bootstrap.toolStrategy,
894
- appended: structuredClone(session.transcript),
1187
+ appended: structuredClone([...appended]),
895
1188
  requestId: "play_call_chain",
896
1189
  operationId: session.chainId,
897
1190
  requestAttempt: 1,
898
1191
  exchange,
899
- maxOutputTokens: modelHost.binding().maxOutputTokens,
1192
+ maxOutputTokens,
900
1193
  };
901
1194
  }
1195
+ function durableModelResponse(response) {
1196
+ const { diagnostics: _diagnostics, ...durable } = response;
1197
+ void _diagnostics;
1198
+ return structuredClone(durable);
1199
+ }
1200
+ function finalizePreparedReceipts(settlement) {
1201
+ const committedWriteRefs = new Set(settlement.stateChanges.map(({ stableShortRef }) => stableShortRef));
1202
+ const markdownByCall = new Map();
1203
+ for (const completed of settlement.completedTools) {
1204
+ const candidateWrite = completed.result.candidateWrite;
1205
+ if (!completed.result.ok || candidateWrite === undefined)
1206
+ continue;
1207
+ const markdown = candidateWrite.changed && committedWriteRefs.has(candidateWrite.shortRef)
1208
+ ? `@${candidateWrite.shortRef} write succeeded`
1209
+ : `@${candidateWrite.shortRef} unchanged`;
1210
+ completed.result.markdown = markdown;
1211
+ delete completed.result.candidateWrite;
1212
+ const callId = completed.key.slice(completed.key.indexOf(":") + 1);
1213
+ markdownByCall.set(callId, markdown);
1214
+ }
1215
+ for (const event of settlement.trailingEvents) {
1216
+ if (event.kind !== "tool_result")
1217
+ continue;
1218
+ const markdown = markdownByCall.get(event.callId);
1219
+ if (markdown !== undefined)
1220
+ event.markdown = markdown;
1221
+ }
1222
+ for (const item of settlement.transcriptAppend) {
1223
+ if (item.kind !== "tool")
1224
+ continue;
1225
+ const markdown = markdownByCall.get(item.toolCallId);
1226
+ if (markdown !== undefined)
1227
+ item.markdown = markdown;
1228
+ }
1229
+ }
1230
+ function appendCommittedHistory(session, head, messages) {
1231
+ const sequence = Number(head.slice("commit:".length));
1232
+ for (const [index, message] of messages.entries()) {
1233
+ const path = `${session.worldId}.message.${sequence}.${index + 1}.${message.role}`;
1234
+ const existing = session.history.find((item) => item.path === path);
1235
+ if (existing !== undefined) {
1236
+ if (existing.contents !== message.exactText)
1237
+ throw new PlayCallChainError("Recovered Authority history conflicts with the call-chain history.");
1238
+ continue;
1239
+ }
1240
+ session.history.push({ path, contents: message.exactText });
1241
+ }
1242
+ }
902
1243
  function prepareTool(session, working, exchange, call) {
903
1244
  const key = `${exchange}:${call.id}`;
904
1245
  const signature = JSON.stringify(call.arguments);
@@ -916,7 +1257,8 @@ function prepareTool(session, working, exchange, call) {
916
1257
  markdown: "# Runtime tool rejected\n\nThe same tool-call ID is already bound to another tool name or argument set.",
917
1258
  };
918
1259
  }
919
- else if (!callChainToolNames.has(call.name)) {
1260
+ else if (!callChainToolNames.has(call.name) ||
1261
+ !session.tools.some(({ name }) => name === call.name)) {
920
1262
  result = {
921
1263
  ok: false,
922
1264
  failureKind: "protocol",
@@ -960,6 +1302,7 @@ function prepareTool(session, working, exchange, call) {
960
1302
  kind: "tool",
961
1303
  toolCallId: call.id,
962
1304
  markdown: result.markdown,
1305
+ ...(result.ok ? {} : { isError: true }),
963
1306
  },
964
1307
  };
965
1308
  }
@@ -999,19 +1342,6 @@ function restorePlayDocuments(files, context, events) {
999
1342
  }
1000
1343
  return documents;
1001
1344
  }
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
1345
  function documentAuthorizationThroughEvents(context, events) {
1016
1346
  const checkpoints = context.documentAuthorizationCheckpoints;
1017
1347
  if (checkpoints === undefined || checkpoints.length === 0)
@@ -1023,20 +1353,6 @@ function documentAuthorizationThroughEvents(context, events) {
1023
1353
  // old recovery behavior and rebuild only bootstrap authorization.
1024
1354
  return selected === undefined ? undefined : structuredClone(selected);
1025
1355
  }
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
1356
  function independentContextCopy(source) {
1041
1357
  const context = structuredClone(source);
1042
1358
  delete context.derivedFrom;
@@ -1068,12 +1384,41 @@ function projectContext(context) {
1068
1384
  playPreset: structuredClone(context.playPreset),
1069
1385
  status: context.status,
1070
1386
  canRetry: context.canRetry,
1071
- events: structuredClone(context.events),
1387
+ events: context.events
1388
+ .slice(-projectedEventTail)
1389
+ .map((event) => projectActivityEvent(event)),
1072
1390
  changedDocuments: structuredClone(context.changedDocuments),
1073
1391
  lastFailure: context.lastFailure,
1074
1392
  updatedAt: context.updatedAt,
1075
1393
  };
1076
1394
  }
1395
+ function projectActivityEvent(event) {
1396
+ if (event.kind === "player" || event.kind === "failure")
1397
+ return structuredClone(event);
1398
+ if (event.kind === "assistant") {
1399
+ const { reasoning, toolFragment, usage, ...summary } = event;
1400
+ void reasoning;
1401
+ void toolFragment;
1402
+ void usage;
1403
+ return structuredClone(summary);
1404
+ }
1405
+ if (event.kind === "tool_call")
1406
+ return { ...structuredClone(event), arguments: null };
1407
+ if (event.kind === "tool_result")
1408
+ return { ...structuredClone(event), markdown: "" };
1409
+ const { reasoning, usage, ...summary } = event;
1410
+ void reasoning;
1411
+ void usage;
1412
+ return {
1413
+ ...structuredClone(summary),
1414
+ text: "",
1415
+ toolCalls: event.toolCalls.map((call) => ({
1416
+ ...structuredClone(call),
1417
+ arguments: null,
1418
+ markdown: "",
1419
+ })),
1420
+ };
1421
+ }
1077
1422
  function projectView(session) {
1078
1423
  return {
1079
1424
  ...projectContext(session),
@@ -1111,7 +1456,9 @@ function transcriptThroughEvents(transcript, events) {
1111
1456
  .find((candidate) => candidate.kind === "player" ||
1112
1457
  candidate.kind === "assistant" ||
1113
1458
  candidate.kind === "tool_call");
1114
- const recorded = event.text.trim().length > 0 || hasToolCall?.kind === "tool_call";
1459
+ const recorded = event.text.trim().length > 0 ||
1460
+ hasToolCall?.kind === "tool_call" ||
1461
+ transcript[cursor]?.kind === "assistant";
1115
1462
  if (!recorded)
1116
1463
  continue;
1117
1464
  const item = transcript[cursor];
@@ -1168,84 +1515,14 @@ function operationId(chainId, key) {
1168
1515
  .update(`${chainId}\0${key}`)
1169
1516
  .digest("hex")}`;
1170
1517
  }
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.");
1518
+ function assertModelHostBinding(frozen, modelHost) {
1519
+ if (!equalModelHostBinding(frozen, modelHost.binding()))
1520
+ throw new PlayCallChainError("The selected model connection does not match the frozen model binding; start a fresh context.");
1181
1521
  }
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");
1226
- }
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;
1522
+ function assertSessionModelHostBinding(session, modelHost) {
1523
+ if (session.modelBinding === undefined)
1524
+ throw new PlayCallChainError("This model context predates durable provider continuation bindings; start a fresh context.");
1525
+ assertModelHostBinding(session.modelBinding, modelHost);
1249
1526
  }
1250
1527
  function validateIdentity(value, label) {
1251
1528
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
@@ -1275,6 +1552,8 @@ function notifyAssistantDelta(observer, delta) {
1275
1552
  // See notifySnapshot: the Provider exchange outlives its browser stream.
1276
1553
  }
1277
1554
  }
1278
- function isRecord(value) {
1279
- return typeof value === "object" && value !== null && !Array.isArray(value);
1555
+ function crashAtPlayAdvanceEdge(edge) {
1556
+ if (process.env.NODE_ENV === "test" &&
1557
+ process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PLAY_ADVANCE_EDGE === edge)
1558
+ throw new Error(`Simulated process exit at play advance edge: ${edge}`);
1280
1559
  }