narraeon 0.7.4 → 0.7.5

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,5 +1,5 @@
1
1
  import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
2
- import { classifyPlayFailure } from "./PlayFailure.js";
2
+ import { dispatchFailureEnding, emptyResponseEnding, lastEndingCancelled, noContinuationEnding, PlayAdvanceLifecycle, settlementFailureEnding, } from "./PlayAdvanceLifecycle.js";
3
3
  import { isResolutionTool, resolutionMarkdown, resolutionToolNames, resolveTabletop, seededRandom, } from "./TabletopResolution.js";
4
4
  import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
5
5
  import { isDeepStrictEqual } from "node:util";
@@ -13,7 +13,7 @@ import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepN
13
13
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
14
14
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
15
15
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
16
- import { equalModelHostBinding, ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
16
+ import { equalModelHostBinding, ModelHostCancelledError, } from "../model/ModelHost.js";
17
17
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
18
18
  import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
19
19
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
@@ -82,6 +82,7 @@ export class PlayCallChain {
82
82
  #invocationSettled = new Map();
83
83
  /** Worlds whose release is queued in their WorldQueue slot. */
84
84
  #releasing = new Set();
85
+ #advances;
85
86
  constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog, changed = () => {
86
87
  /* No observers attached. */
87
88
  }) {
@@ -90,6 +91,10 @@ export class PlayCallChain {
90
91
  this.#compiler = compiler;
91
92
  this.#artifacts = artifacts;
92
93
  this.#failureLog = failureLog;
94
+ this.#advances = new PlayAdvanceLifecycle({
95
+ persist: (session) => this.#persist(session),
96
+ clearMarker: (advance) => this.#worlds.playAdvances.markSettled(advance),
97
+ });
93
98
  }
94
99
  async start(input) {
95
100
  return this.#runInvocation(input, (signal) => this.#start(input, signal));
@@ -115,32 +120,12 @@ export class PlayCallChain {
115
120
  if (active?.status === "running")
116
121
  throw new PlayCallChainError("A model request is still running for this world.");
117
122
  const binding = await this.#worlds.bindPlayCallChain(input.worldId);
118
- const documents = new FileNativePlayDocuments(binding.files);
119
- const documentMaintenance = await this.#bindDocumentMaintenance(documents, input.worldId, binding.parentHead);
120
- const compilation = this.#compiler.compilePlayCallChain({
121
- extensionControls: await this.#worlds.extensionControls(input.worldId, input.playPreset, this.#compiler.locale),
122
- endpoint: {
123
- id: `${input.worldId}:${binding.parentHead}`,
124
- commit: binding.parentHead,
125
- },
126
- hostBinding: structuredClone(input.hostBinding),
127
- world: {
128
- controlFingerprint: fingerprintControl(binding.files),
129
- documentSnapshot: documents.snapshot,
130
- additionalMaterials: structuredClone(binding.additionalMaterials),
131
- history: structuredClone(binding.history),
132
- historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
133
- narrativeCheckpoint: binding.narrativeCheckpoint,
134
- documentMaintenance,
135
- documentMaintenanceUnavailableReason: documents.maintenanceWarning,
136
- },
137
- playerInputPlacement: "append",
138
- playerInput: input.playerText,
139
- modelBinding: structuredClone(input.modelBinding),
140
- }, input.playPreset);
141
- const tools = structuredClone(compilation.toolUniverse);
142
- const bootstrap = structuredClone(compilation.bootstrap);
143
- documents.bindBootstrap(bootstrap);
123
+ const fresh = await this.#compileFreshContext(input.worldId, binding, {
124
+ hostBinding: input.hostBinding,
125
+ playPreset: input.playPreset,
126
+ modelBinding: input.modelBinding,
127
+ playerText: input.playerText,
128
+ });
144
129
  const now = Date.now();
145
130
  const session = {
146
131
  schemaVersion: 3,
@@ -159,14 +144,14 @@ export class PlayCallChain {
159
144
  name: input.playPreset.name,
160
145
  revision: input.playPreset.revision,
161
146
  },
162
- followups: structuredClone(compilation.followups ?? []),
147
+ followups: fresh.followups,
163
148
  playPresetScriptsEnabled: input.playPreset.scriptsEnabled,
164
149
  presetFiles: structuredClone(input.playPreset.files),
165
150
  modelBinding: structuredClone(input.modelBinding),
166
151
  status: "ready",
167
152
  canRetry: false,
168
- bootstrap,
169
- tools: structuredClone(tools),
153
+ bootstrap: fresh.bootstrap,
154
+ tools: fresh.tools,
170
155
  transcript: [],
171
156
  events: [],
172
157
  completedTools: [],
@@ -178,7 +163,7 @@ export class PlayCallChain {
178
163
  lastRequestAttempt: 0,
179
164
  lastFailure: null,
180
165
  updatedAt: now,
181
- documents,
166
+ documents: fresh.documents,
182
167
  history: historyEntries(binding.history, binding.historyBackgrounds),
183
168
  narrativeCheckpoint: binding.narrativeCheckpoint,
184
169
  completedToolMap: new Map(),
@@ -195,6 +180,45 @@ export class PlayCallChain {
195
180
  await this.#worlds.completeArchiveFreshContext(session.worldId);
196
181
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
197
182
  }
183
+ /**
184
+ * Compiles a fresh context's input and binds its documents to the bootstrap.
185
+ * Starting a context and a player revision that begins one both come through
186
+ * here; they differ only in the world binding they read. A continuing context
187
+ * compiles against its frozen world context instead (`#refreshPrompt`).
188
+ */
189
+ async #compileFreshContext(worldId, binding, input) {
190
+ const documents = new FileNativePlayDocuments(binding.files);
191
+ const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
192
+ const compilation = this.#compiler.compilePlayCallChain({
193
+ extensionControls: await this.#worlds.extensionControls(worldId, input.playPreset, this.#compiler.locale),
194
+ endpoint: {
195
+ id: `${worldId}:${binding.parentHead}`,
196
+ commit: binding.parentHead,
197
+ },
198
+ hostBinding: structuredClone(input.hostBinding),
199
+ world: {
200
+ controlFingerprint: fingerprintControl(binding.files),
201
+ documentSnapshot: documents.snapshot,
202
+ additionalMaterials: structuredClone(binding.additionalMaterials),
203
+ history: structuredClone(binding.history),
204
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
205
+ narrativeCheckpoint: binding.narrativeCheckpoint,
206
+ documentMaintenance,
207
+ documentMaintenanceUnavailableReason: documents.maintenanceWarning,
208
+ },
209
+ playerInputPlacement: "append",
210
+ playerInput: input.playerText,
211
+ modelBinding: structuredClone(input.modelBinding),
212
+ }, input.playPreset);
213
+ const bootstrap = structuredClone(compilation.bootstrap);
214
+ documents.bindBootstrap(bootstrap);
215
+ return {
216
+ documents,
217
+ bootstrap,
218
+ tools: structuredClone(compilation.toolUniverse),
219
+ followups: structuredClone(compilation.followups ?? []),
220
+ };
221
+ }
198
222
  async append(input) {
199
223
  return this.#runInvocation(input, (signal) => this.#append(input, signal));
200
224
  }
@@ -896,35 +920,17 @@ export class PlayCallChain {
896
920
  }
897
921
  async #prepareFreshPlayerRevision(worldId, freshContext, restoresHead, replacementText) {
898
922
  const binding = await this.#worlds.bindPlayCallChainAt(worldId, restoresHead);
899
- const documents = new FileNativePlayDocuments(binding.files);
900
- const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
901
- const compilation = this.#compiler.compilePlayCallChain({
902
- extensionControls: await this.#worlds.extensionControls(worldId, freshContext.playPreset, this.#compiler.locale),
903
- endpoint: {
904
- id: `${worldId}:${binding.parentHead}`,
905
- commit: binding.parentHead,
906
- },
907
- hostBinding: structuredClone(freshContext.hostBinding),
908
- world: {
909
- controlFingerprint: fingerprintControl(binding.files),
910
- documentSnapshot: documents.snapshot,
911
- additionalMaterials: structuredClone(binding.additionalMaterials),
912
- history: structuredClone(binding.history),
913
- historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
914
- narrativeCheckpoint: binding.narrativeCheckpoint,
915
- documentMaintenance,
916
- documentMaintenanceUnavailableReason: documents.maintenanceWarning,
917
- },
918
- playerInputPlacement: "append",
919
- playerInput: replacementText,
920
- modelBinding: structuredClone(freshContext.modelBinding),
921
- }, freshContext.playPreset);
922
- documents.bindBootstrap(compilation.bootstrap);
923
+ const fresh = await this.#compileFreshContext(worldId, binding, {
924
+ hostBinding: freshContext.hostBinding,
925
+ playPreset: freshContext.playPreset,
926
+ modelBinding: freshContext.modelBinding,
927
+ playerText: replacementText,
928
+ });
923
929
  return {
924
930
  binding,
925
- bootstrap: structuredClone(compilation.bootstrap),
926
- tools: structuredClone(compilation.toolUniverse),
927
- followups: structuredClone(compilation.followups ?? []),
931
+ bootstrap: fresh.bootstrap,
932
+ tools: fresh.tools,
933
+ followups: fresh.followups,
928
934
  };
929
935
  }
930
936
  async #buildFreshPlayerRevision(input) {
@@ -1166,23 +1172,9 @@ export class PlayCallChain {
1166
1172
  await this.#settlePlayerAdvance(session, advance);
1167
1173
  }
1168
1174
  catch (error) {
1169
- const message = error instanceof Error
1170
- ? error.message
1171
- : "Failed to write the original player text.";
1172
- const failureKind = classifyPlayFailure(error, false);
1173
- session.events.push({
1174
- id: session.nextEventId++,
1175
- kind: "failure",
1176
- message,
1177
- failureKind,
1178
- });
1179
- session.status = "interrupted";
1180
- session.canRetry = false;
1181
- session.lastFailure = message;
1182
- session.lastFailureKind = failureKind;
1183
- await this.#persist(session);
1184
- await this.#worlds.playAdvances.markSettled(advance);
1185
- throw new PlayCallChainError(message);
1175
+ const ending = settlementFailureEnding(error, "Failed to write the original player text.");
1176
+ await this.#advances.end(session, ending, advance);
1177
+ throw new PlayCallChainError(ending.message);
1186
1178
  }
1187
1179
  return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
1188
1180
  }
@@ -1223,8 +1215,6 @@ export class PlayCallChain {
1223
1215
  ...session.events.filter(({ id }) => id < advance.eventId),
1224
1216
  playerEvent,
1225
1217
  ];
1226
- if (session.transcript.length < advance.transcriptStart)
1227
- throw new PlayCallChainError("The recovered call-chain transcript is shorter than its player prefix.");
1228
1218
  session.transcript = [
1229
1219
  ...session.transcript.slice(0, advance.transcriptStart),
1230
1220
  ...structuredClone(transcriptAppendOf(advance.nextRequest).slice(advance.transcriptStart)),
@@ -1237,24 +1227,28 @@ export class PlayCallChain {
1237
1227
  else
1238
1228
  session.narrativeOrigin = structuredClone(advance.narrativeOrigin);
1239
1229
  session.exchange = advance.exchange;
1240
- session.lastRequest = structuredClone(advance.nextRequest);
1241
- session.lastRequestAttempt = 0;
1242
- session.status = "interrupted";
1243
- session.canRetry = true;
1244
- session.lastFailure =
1245
- "The player message is committed; the frozen model request is ready to continue.";
1246
- session.lastFailureKind = "retryable";
1247
- await this.#persist(session);
1248
- await this.#worlds.playAdvances.markSettled(advance);
1230
+ await this.#advances.end(session, {
1231
+ kind: "resumable",
1232
+ reason: "player_committed",
1233
+ request: advance.nextRequest,
1234
+ }, advance);
1249
1235
  }
1250
- async #prepareResponseSettlement(session, advance, response) {
1236
+ /**
1237
+ * Settles one complete model response in this process. The tool batch, the
1238
+ * narrative and state commit, and the transcript and event tail advance
1239
+ * together. Nothing is kept for a replay: a process that stops before this
1240
+ * returns is recovered as an interrupted exchange (ADR-0044).
1241
+ */
1242
+ async #settleResponse(session, advance, response) {
1243
+ const durable = durableModelResponse(response);
1251
1244
  const existing = session.events.find(({ id }) => id === advance.eventId);
1252
1245
  if (existing?.kind !== "assistant")
1253
1246
  throw new PlayCallChainError("The durable Provider result no longer matches its assistant event.");
1254
- const calls = response.toolCalls ?? [];
1255
- const text = response.text ?? existing.text;
1247
+ const calls = durable.toolCalls ?? [];
1248
+ const text = durable.text ?? existing.text;
1256
1249
  const hasText = text.trim().length > 0;
1257
1250
  const responseKind = calls.length > 0 ? "tool_step" : hasText ? "narrative" : "empty";
1251
+ const narrative = responseKind === "narrative";
1258
1252
  const assistantEvent = {
1259
1253
  id: advance.eventId,
1260
1254
  kind: "assistant",
@@ -1263,18 +1257,18 @@ export class PlayCallChain {
1263
1257
  responseKind,
1264
1258
  exchange: advance.exchange,
1265
1259
  attempt: advance.attempt,
1266
- ...(response.reasoningContent === undefined
1260
+ ...(durable.reasoningContent === undefined
1267
1261
  ? existing.reasoning === undefined
1268
1262
  ? {}
1269
1263
  : { reasoning: existing.reasoning }
1270
- : { reasoning: response.reasoningContent }),
1271
- ...(response.usage === undefined
1264
+ : { reasoning: durable.reasoningContent }),
1265
+ ...(durable.usage === undefined
1272
1266
  ? {}
1273
- : { usage: structuredClone(response.usage) }),
1274
- ...(response.stopReason === undefined
1267
+ : { usage: structuredClone(durable.usage) }),
1268
+ ...(durable.stopReason === undefined
1275
1269
  ? {}
1276
- : { stopReason: response.stopReason }),
1277
- continuation: response.providerState === undefined ? "unavailable" : "available",
1270
+ : { stopReason: durable.stopReason }),
1271
+ continuation: durable.providerState === undefined ? "unavailable" : "available",
1278
1272
  };
1279
1273
  const workingTools = new Map(session.completedToolMap);
1280
1274
  const prepared = [];
@@ -1300,8 +1294,8 @@ export class PlayCallChain {
1300
1294
  trailingEvents.push(structuredClone(item.event));
1301
1295
  }
1302
1296
  const stateChanges = session.documents.stateChanges();
1303
- // Resolve once after the entire tool batch. Store the prediction with the
1304
- // prepared fact so crash recovery publishes exactly the same receipt.
1297
+ // Resolve once after the entire tool batch, so every write result reports
1298
+ // the prediction that the commit below makes true.
1305
1299
  const writes = [...workingTools.values()].filter(({ result }) => result.candidateWrite !== undefined);
1306
1300
  let worldClock;
1307
1301
  if (writes.length > 0) {
@@ -1346,12 +1340,11 @@ export class PlayCallChain {
1346
1340
  result.candidateWrite.freshContextCoverage = `${this.#compiler.locale === "zh-CN" ? "暂时无法判定下次注入;写入结果不受影响。原因" : "Fresh-context coverage unavailable; the write result is unaffected. Reason"}: ${reason}`;
1347
1341
  }
1348
1342
  }
1349
- const visibleText = responseKind === "narrative";
1350
1343
  const previousNarrative = session.events.findLast((event) => event.kind === "assistant" &&
1351
1344
  event.committedHead !== undefined &&
1352
1345
  event.responseKind === "narrative");
1353
1346
  const previousPlayer = session.events.findLast((event) => event.kind === "player");
1354
- const checkpointRequested = visibleText &&
1347
+ const checkpointRequested = narrative &&
1355
1348
  session.events.some((event) => event.id >
1356
1349
  Math.max(previousNarrative?.id ?? 0, previousPlayer?.id ?? 0) &&
1357
1350
  event.kind === "tool_result" &&
@@ -1369,177 +1362,135 @@ export class PlayCallChain {
1369
1362
  const assistantItem = {
1370
1363
  kind: "assistant",
1371
1364
  text,
1372
- ...(response.reasoningContent === undefined
1365
+ ...(durable.reasoningContent === undefined
1373
1366
  ? {}
1374
- : { reasoningContent: response.reasoningContent }),
1375
- ...(response.providerState === undefined
1367
+ : { reasoningContent: durable.reasoningContent }),
1368
+ ...(durable.providerState === undefined
1376
1369
  ? {}
1377
- : { providerState: response.providerState }),
1370
+ : { providerState: durable.providerState }),
1378
1371
  toolCalls: structuredClone(calls),
1379
1372
  };
1380
- const background = visibleText
1373
+ const background = narrative
1381
1374
  ? narrativeBackground(advance.narrativeOrigin, session.documents.snapshot)
1382
1375
  : undefined;
1383
- return {
1384
- ...(background === undefined ? {} : { background }),
1385
- assistantEvent,
1386
- playContext: session.continuityContextId ?? session.chainId,
1387
- ...(worldClock === undefined || stateChanges.length === 0
1388
- ? {}
1389
- : { worldClock }),
1390
- trailingEvents,
1391
- transcriptStart: session.transcript.length,
1392
- transcriptAppend: [
1393
- ...(calls.length > 0 ||
1394
- visibleText ||
1395
- response.providerState !== undefined
1396
- ? [assistantItem]
1397
- : []),
1398
- ...prepared.map(({ transcript }) => structuredClone(transcript)),
1399
- ...(calls.length > 0 && hasText
1400
- ? [toolStepNotice(this.#compiler.locale)]
1401
- : []),
1402
- ],
1403
- completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
1404
- stateChanges: structuredClone(stateChanges),
1405
- visibleText,
1406
- ...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
1407
- };
1408
- }
1409
- async #settleResponseAdvance(session, advance, prepared) {
1410
- const settlement = structuredClone(prepared);
1411
- const hasToolCalls = settlement.trailingEvents.some(({ kind }) => kind === "tool_call");
1412
- // A legacy prepared fact has no responseKind. Its visibleText decision is
1413
- // already part of an immutable recovery operation and must be replayed as
1414
- // recorded; every newly prepared fact is checked against the current rule.
1415
- if (settlement.assistantEvent.responseKind !== undefined) {
1416
- const expectedResponseKind = hasToolCalls
1417
- ? "tool_step"
1418
- : settlement.assistantEvent.text.trim().length > 0
1419
- ? "narrative"
1420
- : "empty";
1421
- if (settlement.assistantEvent.responseKind !== expectedResponseKind ||
1422
- settlement.visibleText !== (expectedResponseKind === "narrative"))
1423
- throw new PlayCallChainError("The prepared response settlement has an inconsistent narrative classification.");
1424
- }
1376
+ const transcriptStart = session.transcript.length;
1377
+ const transcriptAppend = [
1378
+ ...(calls.length > 0 || narrative || durable.providerState !== undefined
1379
+ ? [assistantItem]
1380
+ : []),
1381
+ ...prepared.map(({ transcript }) => structuredClone(transcript)),
1382
+ ...(calls.length > 0 && hasText
1383
+ ? [toolStepNotice(this.#compiler.locale)]
1384
+ : []),
1385
+ ];
1386
+ const completedTools = [...workingTools.values()].map((item) => structuredClone(item));
1387
+ const failedTools = trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
1388
+ if (failedTools.length > 0 && response.diagnostics !== undefined)
1389
+ await this.#failureLog?.recordFailure({
1390
+ exchange: response.diagnostics,
1391
+ failures: [
1392
+ {
1393
+ kind: "tool_execution",
1394
+ message: "A Runtime tool call from the model was rejected.",
1395
+ details: {
1396
+ calls: failedTools.map((result) => ({
1397
+ id: result.callId,
1398
+ name: result.name,
1399
+ ok: result.ok,
1400
+ markdown: result.markdown,
1401
+ replayed: result.replayed,
1402
+ })),
1403
+ },
1404
+ },
1405
+ ],
1406
+ });
1425
1407
  let committedHead;
1426
- if (settlement.visibleText || settlement.stateChanges.length > 0) {
1408
+ if (narrative || stateChanges.length > 0) {
1427
1409
  const outcome = await this.#worlds.commitPlayStep({
1428
- ...(settlement.playContext === undefined
1410
+ playContext: session.continuityContextId ?? session.chainId,
1411
+ ...(worldClock === undefined || stateChanges.length === 0
1429
1412
  ? {}
1430
- : { playContext: settlement.playContext }),
1431
- ...(settlement.worldClock === undefined
1432
- ? {}
1433
- : { worldClock: settlement.worldClock }),
1413
+ : { worldClock }),
1434
1414
  operationId: advance.operationId,
1435
1415
  worldId: session.worldId,
1436
1416
  parentHead: advance.parentHead,
1437
- historyAppend: settlement.visibleText
1417
+ historyAppend: narrative
1438
1418
  ? [
1439
1419
  {
1440
1420
  role: "narrator",
1441
- exactText: settlement.assistantEvent.text,
1442
- ...(settlement.background === undefined
1443
- ? {}
1444
- : { background: settlement.background }),
1421
+ exactText: text,
1422
+ ...(background === undefined ? {} : { background }),
1445
1423
  },
1446
1424
  ]
1447
1425
  : [],
1448
1426
  nextMaterials: structuredClone(session.nextMaterials),
1449
- stateChanges: structuredClone(settlement.stateChanges),
1450
- ...(settlement.narrativeCheckpoint === undefined
1451
- ? {}
1452
- : { narrativeCheckpoint: settlement.narrativeCheckpoint }),
1427
+ stateChanges: structuredClone(stateChanges),
1428
+ ...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
1453
1429
  });
1454
1430
  committedHead = outcome.head;
1455
- settlement.assistantEvent.committedHead = outcome.head;
1456
- if (settlement.narrativeCheckpoint !== undefined) {
1457
- settlement.assistantEvent.checkpoint = true;
1431
+ assistantEvent.committedHead = outcome.head;
1432
+ if (narrativeCheckpoint !== undefined) {
1433
+ assistantEvent.checkpoint = true;
1458
1434
  session.narrativeCheckpoint = {
1459
- ...settlement.narrativeCheckpoint,
1435
+ ...narrativeCheckpoint,
1460
1436
  head: outcome.head,
1461
1437
  historyMessageId: `message.${outcome.head.slice("commit:".length)}.1.narrator`,
1462
1438
  };
1463
1439
  }
1464
1440
  appendCommittedHistory(session, outcome.head, outcome.historyAppend);
1465
1441
  }
1466
- finalizePreparedReceipts(settlement);
1442
+ finalizeWriteReceipts({
1443
+ stateChanges,
1444
+ completedTools,
1445
+ trailingEvents,
1446
+ transcriptAppend,
1447
+ });
1467
1448
  session.events = [
1468
1449
  ...session.events.filter(({ id }) => id < advance.eventId),
1469
- structuredClone(settlement.assistantEvent),
1470
- ...structuredClone(settlement.trailingEvents),
1450
+ structuredClone(assistantEvent),
1451
+ ...structuredClone(trailingEvents),
1471
1452
  ];
1472
1453
  session.nextEventId =
1473
- Math.max(advance.eventId, ...settlement.trailingEvents.map(({ id }) => id)) + 1;
1474
- if (session.transcript.length < settlement.transcriptStart)
1475
- throw new PlayCallChainError("The recovered call-chain transcript is shorter than its settlement prefix.");
1454
+ Math.max(advance.eventId, ...trailingEvents.map(({ id }) => id)) + 1;
1476
1455
  session.transcript = [
1477
- ...session.transcript.slice(0, settlement.transcriptStart),
1478
- ...structuredClone(settlement.transcriptAppend),
1456
+ ...session.transcript.slice(0, transcriptStart),
1457
+ ...structuredClone(transcriptAppend),
1479
1458
  ];
1480
- session.completedTools = structuredClone(settlement.completedTools);
1481
- session.completedToolMap = new Map(settlement.completedTools.map((item) => [
1482
- item.key,
1483
- structuredClone(item),
1484
- ]));
1459
+ session.completedTools = structuredClone(completedTools);
1460
+ session.completedToolMap = new Map(completedTools.map((item) => [item.key, structuredClone(item)]));
1485
1461
  if (committedHead !== undefined)
1486
1462
  session.parentHead = committedHead;
1487
- if (settlement.stateChanges.length > 0) {
1463
+ if (stateChanges.length > 0) {
1488
1464
  session.documents.acceptCommittedState();
1489
- mergeChangedDocuments(session, settlement.stateChanges);
1490
- }
1491
- if (settlement.stateChanges.length > 0)
1465
+ mergeChangedDocuments(session, stateChanges);
1492
1466
  await this.#bindDocumentMaintenance(session.documents, session.worldId, session.parentHead);
1467
+ }
1468
+ const hasToolCalls = calls.length > 0;
1493
1469
  session.exchange = Math.max(session.exchange, advance.exchange);
1494
- session.canRetry = false;
1495
- session.lastFailure = null;
1496
- delete session.lastFailureKind;
1497
- let result;
1498
- if (settlement.assistantEvent.continuation !== "available") {
1499
- const message = "The complete model response has no provider-native continuation payload; use a fresh context.";
1500
- session.events.push({
1501
- id: session.nextEventId++,
1502
- kind: "failure",
1503
- message,
1504
- failureKind: "context_unavailable",
1505
- });
1506
- session.status = "interrupted";
1507
- session.lastFailure = message;
1508
- session.lastFailureKind = "context_unavailable";
1509
- result = { kind: "terminal", status: "interrupted" };
1470
+ // The chain has been `running` since its dispatch began, so its failure
1471
+ // fields are already clear; the ending below replaces them.
1472
+ if (assistantEvent.continuation !== "available") {
1473
+ await this.#advances.end(session, noContinuationEnding(), advance);
1474
+ return { kind: "terminal", status: "interrupted", narrative };
1510
1475
  }
1511
- else if (hasToolCalls) {
1476
+ if (hasToolCalls) {
1512
1477
  session.exchange += 1;
1513
1478
  const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
1514
1479
  if (session.lastRequest?.modelConfig !== undefined)
1515
1480
  nextRequest.modelConfig = structuredClone(session.lastRequest.modelConfig);
1516
- session.lastRequest = structuredClone(nextRequest);
1517
- session.lastRequestAttempt = 0;
1518
- session.status = "interrupted";
1519
- session.canRetry = true;
1520
- session.lastFailure =
1521
- "The completed tool response is settled; its frozen continuation request is ready.";
1522
- session.lastFailureKind = "retryable";
1523
- result = { kind: "continue", nextRequest };
1481
+ await this.#advances.end(session, {
1482
+ kind: "resumable",
1483
+ reason: "tool_step_settled",
1484
+ request: nextRequest,
1485
+ }, advance);
1486
+ return { kind: "continue", nextRequest };
1524
1487
  }
1525
- else {
1526
- session.status = settlement.visibleText ? "ready" : "interrupted";
1527
- if (!settlement.visibleText) {
1528
- const message = "The model returned neither text nor a complete tool call.";
1529
- session.events.push({
1530
- id: session.nextEventId++,
1531
- kind: "failure",
1532
- message,
1533
- failureKind: "other",
1534
- });
1535
- session.lastFailure = message;
1536
- session.lastFailureKind = "other";
1537
- }
1538
- result = { kind: "terminal", status: session.status };
1488
+ if (!narrative) {
1489
+ await this.#advances.end(session, emptyResponseEnding(), advance);
1490
+ return { kind: "terminal", status: "interrupted", narrative };
1539
1491
  }
1540
- await this.#persist(session);
1541
- await this.#worlds.playAdvances.markSettled(advance);
1542
- return result;
1492
+ await this.#advances.end(session, { kind: "ready" }, advance);
1493
+ return { kind: "terminal", status: "ready", narrative };
1543
1494
  }
1544
1495
  async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
1545
1496
  let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
@@ -1549,12 +1500,7 @@ export class PlayCallChain {
1549
1500
  if (signal.aborted)
1550
1501
  return this.#cancelBeforeProviderDispatch(session, request, attempt, observer);
1551
1502
  this.#observeInvocation(session.chainId, "waiting");
1552
- session.status = "running";
1553
- session.canRetry = false;
1554
- session.lastFailure = null;
1555
- delete session.lastFailureKind;
1556
- session.lastRequest = structuredClone(request);
1557
- session.lastRequestAttempt = attempt;
1503
+ this.#advances.beginDispatch(session, request, attempt);
1558
1504
  const responseExchange = request.exchange ?? session.exchange;
1559
1505
  const event = {
1560
1506
  id: session.nextEventId++,
@@ -1639,66 +1585,13 @@ export class PlayCallChain {
1639
1585
  await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
1640
1586
  }
1641
1587
  catch (error) {
1642
- event.status = "interrupted";
1643
- const message = error instanceof Error
1644
- ? error.message
1645
- : "The model request was interrupted.";
1646
- session.status = "interrupted";
1647
- const requiresFreshContext = error instanceof ModelHostBindingMismatchError ||
1648
- error instanceof ModelHostContinuationError ||
1649
- error instanceof ModelHostOutcomeUnknownError ||
1650
- modelHostFailureRequiresFreshContext(error);
1651
- session.canRetry =
1652
- !requiresFreshContext && error instanceof ModelHostFailureError;
1653
- session.lastFailure = message;
1654
- if (error instanceof ModelHostCancelledError) {
1655
- delete session.lastFailureKind;
1656
- session.events.push({
1657
- id: session.nextEventId++,
1658
- kind: "cancellation",
1659
- message,
1660
- });
1661
- }
1662
- else {
1663
- session.lastFailureKind = classifyPlayFailure(error, session.canRetry);
1664
- session.events.push({
1665
- id: session.nextEventId++,
1666
- kind: "failure",
1667
- message,
1668
- failureKind: session.lastFailureKind,
1669
- });
1670
- }
1671
- await this.#persist(session);
1672
- await this.#worlds.playAdvances.markSettled(advance);
1673
- this.#notifySnapshot(observer, session);
1588
+ await this.#advances.end(session, dispatchFailureEnding(error, event.id), advance, () => this.#notifySnapshot(observer, session));
1674
1589
  return projectView(session);
1675
1590
  }
1676
- const durableResponse = durableModelResponse(response);
1677
1591
  try {
1678
- const settlement = await this.#prepareResponseSettlement(session, advance, durableResponse);
1679
- const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
1680
- if (failedTools.length > 0 && response.diagnostics !== undefined)
1681
- await this.#failureLog?.recordFailure({
1682
- exchange: response.diagnostics,
1683
- failures: [
1684
- {
1685
- kind: "tool_execution",
1686
- message: "A Runtime tool call from the model was rejected.",
1687
- details: {
1688
- calls: failedTools.map((result) => ({
1689
- id: result.callId,
1690
- name: result.name,
1691
- ok: result.ok,
1692
- markdown: result.markdown,
1693
- replayed: result.replayed,
1694
- })),
1695
- },
1696
- },
1697
- ],
1698
- });
1699
- const settled = await this.#settleResponseAdvance(session, advance, settlement);
1592
+ const settled = await this.#settleResponse(session, advance, response);
1700
1593
  if (settled.kind === "terminal") {
1701
- if (!settlement.visibleText) {
1594
+ if (!settled.narrative) {
1702
1595
  const message = "The model returned neither text nor a complete tool call.";
1703
1596
  if (response.diagnostics !== undefined)
1704
1597
  await this.#failureLog?.recordFailure({
@@ -1738,18 +1631,7 @@ export class PlayCallChain {
1738
1631
  }
1739
1632
  await this.#runFollowups(session, modelHost, signal, observer);
1740
1633
  this.#setInvocationAbortable(session.chainId, false);
1741
- if (signal.aborted) {
1742
- session.events.push({
1743
- id: session.nextEventId++,
1744
- kind: "cancellation",
1745
- message: "The player cancelled the remaining follow-up generation after the narrative was committed.",
1746
- });
1747
- touch(session);
1748
- this.changed(session.worldId);
1749
- }
1750
- session.status = "ready";
1751
- await this.#persist(session);
1752
- this.#notifySnapshot(observer, session);
1634
+ await this.#advances.end(session, { kind: "ready", followupsCancelled: signal.aborted }, null, () => this.#notifySnapshot(observer, session));
1753
1635
  return projectView(session);
1754
1636
  }
1755
1637
  this.#notifySnapshot(observer, session);
@@ -1757,58 +1639,29 @@ export class PlayCallChain {
1757
1639
  attempt = 1;
1758
1640
  }
1759
1641
  catch (error) {
1760
- const message = error instanceof Error
1761
- ? error.message
1762
- : "Call-chain processing failed.";
1642
+ const ending = settlementFailureEnding(error, "Call-chain processing failed.");
1763
1643
  if (response.diagnostics !== undefined)
1764
1644
  await this.#failureLog?.recordFailure({
1765
1645
  exchange: response.diagnostics,
1766
1646
  failures: [
1767
1647
  {
1768
1648
  kind: "runtime_post_processing",
1769
- message,
1649
+ message: ending.message,
1770
1650
  },
1771
1651
  ],
1772
1652
  });
1773
- const failureKind = classifyPlayFailure(error, false);
1774
- session.events.push({
1775
- id: session.nextEventId++,
1776
- kind: "failure",
1777
- message,
1778
- failureKind,
1779
- });
1780
- session.status = "interrupted";
1781
- session.canRetry = false;
1782
- session.lastFailure = message;
1783
- session.lastFailureKind = failureKind;
1784
- await this.#persist(session);
1785
- this.#notifySnapshot(observer, session);
1653
+ await this.#advances.end(session, ending, advance, () => this.#notifySnapshot(observer, session));
1786
1654
  return projectView(session);
1787
1655
  }
1788
1656
  }
1789
1657
  }
1790
1658
  async #cancelBeforeProviderDispatch(session, request, attempt, observer, advance) {
1791
- if (advance !== undefined) {
1792
- const streaming = session.events.find((event) => event.kind === "assistant" && event.id === advance.eventId);
1793
- if (streaming !== undefined)
1794
- streaming.status = "interrupted";
1795
- }
1796
- const message = "The player cancelled before the next Provider dispatch; the frozen request can be resumed without replaying a completed model result.";
1797
- session.events.push({
1798
- id: session.nextEventId++,
1799
- kind: "cancellation",
1800
- message,
1801
- });
1802
- session.status = "interrupted";
1803
- session.canRetry = true;
1804
- session.lastRequest = structuredClone(request);
1805
- session.lastRequestAttempt = Math.max(0, attempt - 1);
1806
- session.lastFailure = message;
1807
- delete session.lastFailureKind;
1808
- await this.#persist(session);
1809
- if (advance !== undefined)
1810
- await this.#worlds.playAdvances.markSettled(advance);
1811
- this.#notifySnapshot(observer, session);
1659
+ await this.#advances.end(session, {
1660
+ kind: "cancelled_before_dispatch",
1661
+ request,
1662
+ attempt,
1663
+ ...(advance === undefined ? {} : { streamingEventId: advance.eventId }),
1664
+ }, advance ?? null, () => this.#notifySnapshot(observer, session));
1812
1665
  return projectView(session);
1813
1666
  }
1814
1667
  /**
@@ -1982,42 +1835,10 @@ export class PlayCallChain {
1982
1835
  : loaded;
1983
1836
  if (advance === null && session.status !== "running")
1984
1837
  return;
1985
- await this.#interruptAbandonedDispatch(session, advance);
1986
- if (advance !== null)
1987
- await this.#worlds.playAdvances.markSettled(advance);
1988
- }
1989
- async #interruptAbandonedDispatch(session, advance) {
1838
+ // Authority already moved past the persisted parent head: the world cannot
1839
+ // be replayed onto that head, so the recovery requires a fresh context.
1990
1840
  const headMoved = (await this.#worlds.currentHead(session.worldId)) !== session.parentHead;
1991
- const playerPhase = advance?.advanceKind === "player";
1992
- const streaming = playerPhase
1993
- ? undefined
1994
- : session.events.findLast((event) => event.kind === "assistant" &&
1995
- event.status === "streaming" &&
1996
- (advance === null || event.id === advance.eventId));
1997
- if (streaming !== undefined)
1998
- streaming.status = "interrupted";
1999
- // Authority already moved past the persisted parent head: the world
2000
- // cannot be replayed onto that head, so a fresh context is required.
2001
- const canRetry = !headMoved && !playerPhase && session.lastRequest !== null;
2002
- const message = headMoved
2003
- ? "The service stopped after the world was updated; use a fresh context."
2004
- : playerPhase
2005
- ? "The service stopped while saving the player message; use a fresh context."
2006
- : canRetry
2007
- ? "The service stopped while the model request was in progress; retry to call the model again."
2008
- : "The service stopped before preparing a model request; use a fresh context.";
2009
- const failureKind = canRetry ? "retryable" : "other";
2010
- session.events.push({
2011
- id: session.nextEventId++,
2012
- kind: "failure",
2013
- message,
2014
- failureKind,
2015
- });
2016
- session.status = "interrupted";
2017
- session.canRetry = canRetry;
2018
- session.lastFailure = message;
2019
- session.lastFailureKind = failureKind;
2020
- await this.#persist(session);
1841
+ await this.#advances.recoverAbandoned(session, advance, headMoved);
2021
1842
  }
2022
1843
  async #assertCurrentHead(session) {
2023
1844
  if ((await this.#worlds.currentHead(session.worldId)) !== session.parentHead)
@@ -2162,7 +1983,11 @@ function durableModelResponse(response) {
2162
1983
  void _diagnostics;
2163
1984
  return structuredClone(durable);
2164
1985
  }
2165
- function finalizePreparedReceipts(settlement) {
1986
+ /**
1987
+ * Copies each committed write's receipt into the tool results that report it,
1988
+ * then drops the candidate write. Receipts state what the commit made true.
1989
+ */
1990
+ function finalizeWriteReceipts(settlement) {
2166
1991
  const committedWriteRefs = new Set(settlement.stateChanges.map(({ stableShortRef }) => stableShortRef));
2167
1992
  const markdownByCall = new Map();
2168
1993
  for (const completed of settlement.completedTools) {
@@ -2402,6 +2227,7 @@ function projectView(session) {
2402
2227
  ...projectContext(session),
2403
2228
  worldId: session.worldId,
2404
2229
  previousContexts: (session.previousContexts ?? []).map((context) => projectContext(context)),
2230
+ ...(lastEndingCancelled(session) ? { lastCancelled: true } : {}),
2405
2231
  };
2406
2232
  }
2407
2233
  function projectContextReads(session) {