narraeon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +164 -0
- package/README.zh-CN.md +164 -0
- package/dist/node/cli/NarraeonCli.js +163 -0
- package/dist/node/cli/main.js +38 -0
- package/dist/node/protocol/appPreferences.js +5 -0
- package/dist/node/protocol/contentTree.js +14 -0
- package/dist/node/protocol/modelConnections.js +110 -0
- package/dist/node/protocol/playerViews.js +1 -0
- package/dist/node/protocol/v1.js +278 -0
- package/dist/node/runtime/V1Runtime.js +625 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
- package/dist/node/runtime/config/appPaths.js +19 -0
- package/dist/node/runtime/content/ContentLimits.js +1 -0
- package/dist/node/runtime/content/ContentTreeFile.js +1 -0
- package/dist/node/runtime/content/ContentWorkspace.js +601 -0
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
- package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
- package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
- package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
- package/dist/node/runtime/model/AiFailureLog.js +332 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
- package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
- package/dist/node/runtime/model/ModelHost.js +89 -0
- package/dist/node/runtime/model/ModelHostStream.js +367 -0
- package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
- package/dist/node/runtime/model/ProviderStream.js +90 -0
- package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
- package/dist/node/runtime/play/PlayCallChain.js +1280 -0
- package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
- package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
- package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
- package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
- package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
- package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
- package/dist/node/runtime/world/WorldStateRevision.js +75 -0
- package/dist/node/server/createRuntime.js +6 -0
- package/dist/node/server/createServer.js +218 -0
- package/dist/node/server/main.js +25 -0
- package/dist/node/server/startWebServer.js +28 -0
- package/dist/node/shared/default-play-prompts.js +85 -0
- package/dist/node/shared/default-preset-host.js +538 -0
- package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
- package/dist/node/shared/first-party-action-choices.js +164 -0
- package/dist/node/shared/first-party-generic-panels.js +105 -0
- package/dist/node/shared/first-party-play-preset-templates.js +41 -0
- package/dist/node/shared/first-party-player-view.js +179 -0
- package/dist/node/shared/play-preset-prompt-roles.js +4 -0
- package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
- package/dist/web/assets/index-DDx1itzT.js +1192 -0
- package/dist/web/index.html +14 -0
- package/package.json +86 -0
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
|
|
3
|
+
import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
|
|
4
|
+
import { FileNativePlayDocuments, fingerprintControl, isPlayDocumentAuthorizationCheckpoint, } from "./PlayDocumentTools.js";
|
|
5
|
+
const callChainToolNames = new Set([
|
|
6
|
+
"context_list",
|
|
7
|
+
"context_search",
|
|
8
|
+
"context_read",
|
|
9
|
+
"world_patch",
|
|
10
|
+
"world_create",
|
|
11
|
+
]);
|
|
12
|
+
export class PlayCallChainError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "PlayCallChainError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 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.
|
|
22
|
+
*/
|
|
23
|
+
export class PlayCallChain {
|
|
24
|
+
#worlds;
|
|
25
|
+
#compiler;
|
|
26
|
+
#artifacts;
|
|
27
|
+
#failureLog;
|
|
28
|
+
#active = new Map();
|
|
29
|
+
#worldChains = new Map();
|
|
30
|
+
constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog) {
|
|
31
|
+
this.#worlds = worlds;
|
|
32
|
+
this.#compiler = compiler;
|
|
33
|
+
this.#artifacts = artifacts;
|
|
34
|
+
this.#failureLog = failureLog;
|
|
35
|
+
}
|
|
36
|
+
async start(input) {
|
|
37
|
+
validateIdentity(input.chainId, "call-chain ID");
|
|
38
|
+
validateIdentity(input.exchangeId, "exchange ID");
|
|
39
|
+
validatePlayerText(input.playerText);
|
|
40
|
+
const existing = await this.#readPersisted(input.worldId);
|
|
41
|
+
if (existing?.chainId === input.chainId) {
|
|
42
|
+
const duplicate = duplicatePlayerExchange(existing, input.exchangeId, input.playerText, "fresh");
|
|
43
|
+
if (duplicate)
|
|
44
|
+
return projectView(existing);
|
|
45
|
+
}
|
|
46
|
+
const activeId = this.#worldChains.get(input.worldId);
|
|
47
|
+
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
48
|
+
if (active?.status === "running")
|
|
49
|
+
throw new PlayCallChainError("A model request is still running for this world.");
|
|
50
|
+
const binding = await this.#worlds.bindPlayCallChain(input.worldId);
|
|
51
|
+
const documents = new FileNativePlayDocuments(binding.files);
|
|
52
|
+
const compilation = this.#compiler.compilePlayCallChain({
|
|
53
|
+
endpoint: {
|
|
54
|
+
id: `${input.worldId}:${binding.parentHead}`,
|
|
55
|
+
commit: binding.parentHead,
|
|
56
|
+
},
|
|
57
|
+
hostBinding: structuredClone(input.hostBinding),
|
|
58
|
+
world: {
|
|
59
|
+
controlFingerprint: fingerprintControl(binding.files),
|
|
60
|
+
documentSnapshot: documents.snapshot,
|
|
61
|
+
additionalMaterials: structuredClone(binding.additionalMaterials),
|
|
62
|
+
history: structuredClone(binding.history),
|
|
63
|
+
},
|
|
64
|
+
playerInputPlacement: "append",
|
|
65
|
+
playerInput: input.playerText,
|
|
66
|
+
modelBinding: structuredClone(input.modelBinding),
|
|
67
|
+
}, input.playPreset);
|
|
68
|
+
const tools = structuredClone(compilation.toolUniverse);
|
|
69
|
+
const bootstrap = structuredClone(compilation.bootstrap);
|
|
70
|
+
documents.bindBootstrap(bootstrap);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const session = {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
kind: "play_call_chain",
|
|
75
|
+
chainId: input.chainId,
|
|
76
|
+
worldId: input.worldId,
|
|
77
|
+
previousContexts: existing === null
|
|
78
|
+
? []
|
|
79
|
+
: [
|
|
80
|
+
...(existing.previousContexts ?? []).map((context) => structuredClone(context)),
|
|
81
|
+
snapshotCurrentContext(existing),
|
|
82
|
+
],
|
|
83
|
+
baselineHead: binding.parentHead,
|
|
84
|
+
baselineHistoryLength: Object.keys(binding.history).length,
|
|
85
|
+
parentHead: binding.parentHead,
|
|
86
|
+
playPreset: {
|
|
87
|
+
id: input.playPreset.id,
|
|
88
|
+
name: input.playPreset.name,
|
|
89
|
+
revision: input.playPreset.revision,
|
|
90
|
+
},
|
|
91
|
+
followups: structuredClone(compilation.followups ?? []),
|
|
92
|
+
playPresetScriptsEnabled: input.playPreset.scriptsEnabled,
|
|
93
|
+
status: "ready",
|
|
94
|
+
canRetry: false,
|
|
95
|
+
bootstrap,
|
|
96
|
+
tools: structuredClone(tools),
|
|
97
|
+
transcript: [],
|
|
98
|
+
events: [],
|
|
99
|
+
completedTools: [],
|
|
100
|
+
documentAuthorizationCheckpoints: [
|
|
101
|
+
{
|
|
102
|
+
afterEventId: 0,
|
|
103
|
+
authorization: documents.authorizationCheckpoint(),
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
changedDocuments: [],
|
|
107
|
+
nextMaterials: structuredClone(binding.additionalMaterials),
|
|
108
|
+
nextEventId: 1,
|
|
109
|
+
exchange: 0,
|
|
110
|
+
lastRequest: null,
|
|
111
|
+
lastRequestAttempt: 0,
|
|
112
|
+
lastFailure: null,
|
|
113
|
+
updatedAt: now,
|
|
114
|
+
documents,
|
|
115
|
+
history: historyEntries(binding.history),
|
|
116
|
+
completedToolMap: new Map(),
|
|
117
|
+
};
|
|
118
|
+
if (activeId !== undefined)
|
|
119
|
+
this.#active.delete(activeId);
|
|
120
|
+
this.#active.set(session.chainId, session);
|
|
121
|
+
this.#worldChains.set(session.worldId, session.chainId);
|
|
122
|
+
await this.#persist(session);
|
|
123
|
+
return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, input.observer);
|
|
124
|
+
}
|
|
125
|
+
async append(input) {
|
|
126
|
+
validateIdentity(input.exchangeId, "exchange ID");
|
|
127
|
+
const session = await this.#requireChain(input.worldId, input.chainId);
|
|
128
|
+
await this.#assertCurrentHead(session);
|
|
129
|
+
if (input.playerText.trim().length === 0) {
|
|
130
|
+
if (session.status === "running")
|
|
131
|
+
throw new PlayCallChainError("A model request is still running.");
|
|
132
|
+
if (session.status === "interrupted") {
|
|
133
|
+
if (!session.canRetry || session.lastRequest === null)
|
|
134
|
+
throw new PlayCallChainError("Call-chain processing failed, and there is no model context to continue.");
|
|
135
|
+
return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, input.observer);
|
|
136
|
+
}
|
|
137
|
+
session.exchange += 1;
|
|
138
|
+
return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, input.observer);
|
|
139
|
+
}
|
|
140
|
+
if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
|
|
141
|
+
return projectView(session);
|
|
142
|
+
if (session.status !== "ready")
|
|
143
|
+
throw new PlayCallChainError(session.canRetry
|
|
144
|
+
? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
|
|
145
|
+
: "The call chain was interrupted; use a fresh context.");
|
|
146
|
+
return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, input.observer);
|
|
147
|
+
}
|
|
148
|
+
async inspectWorld(worldId) {
|
|
149
|
+
const activeId = this.#worldChains.get(worldId);
|
|
150
|
+
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
151
|
+
if (active !== undefined)
|
|
152
|
+
return projectView(active);
|
|
153
|
+
const persisted = await this.#readPersisted(worldId);
|
|
154
|
+
if (persisted === null)
|
|
155
|
+
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);
|
|
176
|
+
}
|
|
177
|
+
async forkToDerivedWorld(input) {
|
|
178
|
+
const existing = await this.#readPersisted(input.targetWorldId);
|
|
179
|
+
if (existing !== null)
|
|
180
|
+
return projectView(existing);
|
|
181
|
+
const source = await this.#readPersisted(input.sourceWorldId);
|
|
182
|
+
if (source === null)
|
|
183
|
+
return null;
|
|
184
|
+
const sourceContexts = persistedContexts(source);
|
|
185
|
+
const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
|
|
186
|
+
if (selectedContextIndex < 0)
|
|
187
|
+
return null;
|
|
188
|
+
const sourceContext = sourceContexts[selectedContextIndex];
|
|
189
|
+
const sourceEvents = eventsThroughHead(sourceContext.events, input.sourceHead);
|
|
190
|
+
return this.#forkSelectionToDerivedWorld({
|
|
191
|
+
sourceContexts,
|
|
192
|
+
selectedContextIndex,
|
|
193
|
+
sourceEvents,
|
|
194
|
+
sourceHead: input.sourceHead,
|
|
195
|
+
targetWorldId: input.targetWorldId,
|
|
196
|
+
branchIdentity: input.sourceHead,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Revise one committed player message in place. Authority remains
|
|
201
|
+
* append-only: one timeline-revision commit restores the player's logical
|
|
202
|
+
* parent snapshot and appends the replacement text atomically.
|
|
203
|
+
*/
|
|
204
|
+
async revisePlayer(input) {
|
|
205
|
+
validateIdentity(input.operationId, "timeline-revision operation ID");
|
|
206
|
+
validateIdentity(input.worldId, "world ID");
|
|
207
|
+
validateIdentity(input.chainId, "call-chain ID");
|
|
208
|
+
validateIdentity(input.replacementExchangeId, "replacement exchange ID");
|
|
209
|
+
validatePlayerText(input.replacementText);
|
|
210
|
+
if (!Number.isSafeInteger(input.eventId) || input.eventId < 1)
|
|
211
|
+
throw new PlayCallChainError("The player-event ID is invalid.");
|
|
212
|
+
const activeId = this.#worldChains.get(input.worldId);
|
|
213
|
+
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
214
|
+
if (active?.status === "running")
|
|
215
|
+
throw new PlayCallChainError("A model request is still running; wait for it to return before revising committed history.");
|
|
216
|
+
const source = await this.#readPersisted(input.worldId);
|
|
217
|
+
if (source === null)
|
|
218
|
+
throw new PlayCallChainError("The current world has no model call chain to revise.");
|
|
219
|
+
const sourceContexts = persistedContexts(source);
|
|
220
|
+
const appliedEvent = sourceContexts
|
|
221
|
+
.flatMap(({ events }) => events)
|
|
222
|
+
.find((event) => event.kind === "player" &&
|
|
223
|
+
event.exchangeId === input.replacementExchangeId &&
|
|
224
|
+
event.committedHead !== undefined);
|
|
225
|
+
if (appliedEvent !== undefined) {
|
|
226
|
+
if (appliedEvent.text !== input.replacementText)
|
|
227
|
+
throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
|
|
228
|
+
const authority = await this.#worlds.readAuthorityHistory(input.worldId);
|
|
229
|
+
const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
|
|
230
|
+
if (commit?.operationId !== input.operationId ||
|
|
231
|
+
commit.mode !== "timeline_revision")
|
|
232
|
+
throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
|
|
233
|
+
return {
|
|
234
|
+
outcome: "revised",
|
|
235
|
+
worldId: input.worldId,
|
|
236
|
+
playCallChain: projectView(source),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const selectedContextIndex = sourceContexts.findIndex(({ chainId }) => chainId === input.chainId);
|
|
240
|
+
if (selectedContextIndex < 0)
|
|
241
|
+
throw new PlayCallChainError("The model context to revise does not exist.");
|
|
242
|
+
const sourceContext = sourceContexts[selectedContextIndex];
|
|
243
|
+
const selectedEventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
|
|
244
|
+
const selectedEvent = sourceContext.events[selectedEventIndex];
|
|
245
|
+
if (selectedEventIndex < 0 ||
|
|
246
|
+
selectedEvent?.kind !== "player" ||
|
|
247
|
+
selectedEvent.committedHead === undefined)
|
|
248
|
+
throw new PlayCallChainError("The selected event is not a committed player message.");
|
|
249
|
+
return this.#applyPlayerRevision({
|
|
250
|
+
request: input,
|
|
251
|
+
source,
|
|
252
|
+
sourceContexts,
|
|
253
|
+
selectedContextIndex,
|
|
254
|
+
selectedEventIndex,
|
|
255
|
+
selectedEvent: {
|
|
256
|
+
...selectedEvent,
|
|
257
|
+
committedHead: selectedEvent.committedHead,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async #applyPlayerRevision(input) {
|
|
262
|
+
const request = input.request;
|
|
263
|
+
const sourceContext = input.sourceContexts[input.selectedContextIndex];
|
|
264
|
+
let restoresHead = sourceContext.baselineHead;
|
|
265
|
+
for (const event of sourceContext.events.slice(0, input.selectedEventIndex))
|
|
266
|
+
if ((event.kind === "player" || event.kind === "assistant") &&
|
|
267
|
+
event.committedHead !== undefined)
|
|
268
|
+
restoresHead = event.committedHead;
|
|
269
|
+
const requestFingerprint = "sha256:" +
|
|
270
|
+
createHash("sha256")
|
|
271
|
+
.update(JSON.stringify({
|
|
272
|
+
schema: "narraeon.timeline-revision-request/v1",
|
|
273
|
+
chainId: request.chainId,
|
|
274
|
+
eventId: request.eventId,
|
|
275
|
+
selectedHead: input.selectedEvent.committedHead,
|
|
276
|
+
restoresHead,
|
|
277
|
+
replacementExchangeId: request.replacementExchangeId,
|
|
278
|
+
replacementText: request.replacementText,
|
|
279
|
+
}))
|
|
280
|
+
.digest("hex");
|
|
281
|
+
const outcome = await this.#worlds.reviseTimeline({
|
|
282
|
+
operationId: request.operationId,
|
|
283
|
+
worldId: request.worldId,
|
|
284
|
+
expectedCurrentHead: input.source.parentHead,
|
|
285
|
+
restoresHead,
|
|
286
|
+
replacesHead: input.selectedEvent.committedHead,
|
|
287
|
+
replacementText: request.replacementText,
|
|
288
|
+
requestFingerprint,
|
|
289
|
+
});
|
|
290
|
+
const prefixEvents = structuredClone(sourceContext.events.slice(0, input.selectedEventIndex));
|
|
291
|
+
const transcript = transcriptThroughEvents(sourceContext.transcript, prefixEvents);
|
|
292
|
+
transcript.push({ kind: "player", text: request.replacementText });
|
|
293
|
+
const events = [
|
|
294
|
+
...prefixEvents,
|
|
295
|
+
{
|
|
296
|
+
id: input.selectedEvent.id,
|
|
297
|
+
kind: "player",
|
|
298
|
+
exchangeId: request.replacementExchangeId,
|
|
299
|
+
text: request.replacementText,
|
|
300
|
+
context: input.selectedEvent.context,
|
|
301
|
+
committedHead: outcome.head,
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
const completedKeys = completedToolKeys(prefixEvents);
|
|
305
|
+
const [baseline, selected, binding] = await Promise.all([
|
|
306
|
+
this.#worlds.recoverEndpoint(request.worldId, sourceContext.baselineHead),
|
|
307
|
+
this.#worlds.recoverEndpoint(request.worldId, restoresHead),
|
|
308
|
+
this.#worlds.bindPlayCallChain(request.worldId),
|
|
309
|
+
]);
|
|
310
|
+
if (binding.parentHead !== outcome.head)
|
|
311
|
+
throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
|
|
312
|
+
const documents = restorePlayDocuments(binding.files, sourceContext, prefixEvents);
|
|
313
|
+
const revised = {
|
|
314
|
+
schemaVersion: 1,
|
|
315
|
+
kind: "play_call_chain",
|
|
316
|
+
chainId: derivedChainId(sourceContext.chainId, "timeline-revision:" + request.operationId, request.worldId),
|
|
317
|
+
worldId: request.worldId,
|
|
318
|
+
previousContexts: input.sourceContexts
|
|
319
|
+
.slice(0, input.selectedContextIndex)
|
|
320
|
+
.map((context) => independentContextCopy(context)),
|
|
321
|
+
baselineHead: sourceContext.baselineHead,
|
|
322
|
+
baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
|
|
323
|
+
parentHead: outcome.head,
|
|
324
|
+
playPreset: structuredClone(sourceContext.playPreset),
|
|
325
|
+
...(sourceContext.followups === undefined
|
|
326
|
+
? {}
|
|
327
|
+
: { followups: structuredClone(sourceContext.followups) }),
|
|
328
|
+
...(sourceContext.playPresetScriptsEnabled === undefined
|
|
329
|
+
? {}
|
|
330
|
+
: {
|
|
331
|
+
playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
|
|
332
|
+
}),
|
|
333
|
+
status: "ready",
|
|
334
|
+
canRetry: false,
|
|
335
|
+
bootstrap: structuredClone(sourceContext.bootstrap),
|
|
336
|
+
tools: structuredClone(sourceContext.tools),
|
|
337
|
+
transcript,
|
|
338
|
+
events,
|
|
339
|
+
completedTools: sourceContext.completedTools
|
|
340
|
+
.filter(({ key }) => completedKeys.has(key))
|
|
341
|
+
.map((item) => structuredClone(item)),
|
|
342
|
+
documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(sourceContext, events, documents.authorizationCheckpoint()),
|
|
343
|
+
changedDocuments: changedDocumentsAtHead(sourceContext.changedDocuments, baseline.state, selected.state),
|
|
344
|
+
nextMaterials: structuredClone(binding.additionalMaterials),
|
|
345
|
+
nextEventId: Math.max(0, ...events.map(({ id }) => id)) + 1,
|
|
346
|
+
exchange: Math.max(0, ...prefixEvents
|
|
347
|
+
.filter((event) => event.kind === "assistant" && event.status === "completed")
|
|
348
|
+
.map(({ exchange }) => exchange)),
|
|
349
|
+
lastRequest: null,
|
|
350
|
+
lastRequestAttempt: 0,
|
|
351
|
+
lastFailure: null,
|
|
352
|
+
updatedAt: Date.now(),
|
|
353
|
+
};
|
|
354
|
+
await this.#worlds.writePlayCallChain(request.worldId, structuredClone(revised));
|
|
355
|
+
const activeId = this.#worldChains.get(request.worldId);
|
|
356
|
+
if (activeId !== undefined)
|
|
357
|
+
this.#active.delete(activeId);
|
|
358
|
+
this.#active.delete(sourceContext.chainId);
|
|
359
|
+
this.#worldChains.delete(request.worldId);
|
|
360
|
+
return {
|
|
361
|
+
outcome: "revised",
|
|
362
|
+
worldId: request.worldId,
|
|
363
|
+
playCallChain: projectView(revised),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
async #forkSelectionToDerivedWorld(input) {
|
|
367
|
+
const existing = await this.#readPersisted(input.targetWorldId);
|
|
368
|
+
if (existing !== null)
|
|
369
|
+
return projectView(existing);
|
|
370
|
+
const sourceContext = input.sourceContexts[input.selectedContextIndex];
|
|
371
|
+
if ((await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
|
|
372
|
+
throw new PlayCallChainError("The forked world's Authority does not match the selected history endpoint, so the call chain cannot be written.");
|
|
373
|
+
const [baseline, selected] = await Promise.all([
|
|
374
|
+
this.#worlds.recoverEndpoint(input.targetWorldId, sourceContext.baselineHead),
|
|
375
|
+
this.#worlds.recoverEndpoint(input.targetWorldId, input.sourceHead),
|
|
376
|
+
]);
|
|
377
|
+
const transcript = transcriptThroughEvents(sourceContext.transcript, input.sourceEvents);
|
|
378
|
+
const completedKeys = completedToolKeys(input.sourceEvents);
|
|
379
|
+
const events = structuredClone(input.sourceEvents);
|
|
380
|
+
const derivedBinding = await this.#worlds.bindPlayCallChain(input.targetWorldId);
|
|
381
|
+
const derivedDocuments = restorePlayDocuments(derivedBinding.files, sourceContext, input.sourceEvents);
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
const derived = {
|
|
384
|
+
schemaVersion: 1,
|
|
385
|
+
kind: "play_call_chain",
|
|
386
|
+
chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
|
|
387
|
+
worldId: input.targetWorldId,
|
|
388
|
+
previousContexts: input.sourceContexts
|
|
389
|
+
.slice(0, input.selectedContextIndex)
|
|
390
|
+
.map((context) => independentContextCopy(context)),
|
|
391
|
+
baselineHead: sourceContext.baselineHead,
|
|
392
|
+
baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
|
|
393
|
+
parentHead: input.sourceHead,
|
|
394
|
+
playPreset: structuredClone(sourceContext.playPreset),
|
|
395
|
+
...(sourceContext.followups === undefined
|
|
396
|
+
? {}
|
|
397
|
+
: { followups: structuredClone(sourceContext.followups) }),
|
|
398
|
+
...(sourceContext.playPresetScriptsEnabled === undefined
|
|
399
|
+
? {}
|
|
400
|
+
: {
|
|
401
|
+
playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
|
|
402
|
+
}),
|
|
403
|
+
status: "ready",
|
|
404
|
+
canRetry: false,
|
|
405
|
+
bootstrap: structuredClone(sourceContext.bootstrap),
|
|
406
|
+
tools: structuredClone(sourceContext.tools),
|
|
407
|
+
transcript,
|
|
408
|
+
events,
|
|
409
|
+
completedTools: sourceContext.completedTools
|
|
410
|
+
.filter(({ key }) => completedKeys.has(key))
|
|
411
|
+
.map((item) => structuredClone(item)),
|
|
412
|
+
documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(sourceContext, events, derivedDocuments.authorizationCheckpoint()),
|
|
413
|
+
changedDocuments: changedDocumentsAtHead(sourceContext.changedDocuments, baseline.state, selected.state),
|
|
414
|
+
nextMaterials: structuredClone(derivedBinding.additionalMaterials),
|
|
415
|
+
nextEventId: Math.max(0, ...events.map(({ id }) => id)) + 1,
|
|
416
|
+
exchange: Math.max(0, ...events
|
|
417
|
+
.filter((event) => event.kind === "assistant" && event.status === "completed")
|
|
418
|
+
.map(({ exchange }) => exchange)),
|
|
419
|
+
lastRequest: null,
|
|
420
|
+
lastRequestAttempt: 0,
|
|
421
|
+
lastFailure: null,
|
|
422
|
+
updatedAt: now,
|
|
423
|
+
};
|
|
424
|
+
await this.#worlds.writePlayCallChain(input.targetWorldId, structuredClone(derived));
|
|
425
|
+
return projectView(derived);
|
|
426
|
+
}
|
|
427
|
+
forgetWorld(worldId) {
|
|
428
|
+
const activeId = this.#worldChains.get(worldId);
|
|
429
|
+
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
430
|
+
if (active?.status === "running")
|
|
431
|
+
throw new PlayCallChainError("A model request is still running; wait for it to return before deleting the world.");
|
|
432
|
+
if (activeId !== undefined)
|
|
433
|
+
this.#active.delete(activeId);
|
|
434
|
+
this.#worldChains.delete(worldId);
|
|
435
|
+
}
|
|
436
|
+
async #submitPlayer(session, exchangeId, playerText, context, modelHost, observer) {
|
|
437
|
+
if (duplicatePlayerExchange(session, exchangeId, playerText, context))
|
|
438
|
+
return projectView(session);
|
|
439
|
+
if (session.status === "running")
|
|
440
|
+
throw new PlayCallChainError("The call chain is waiting for the model to return.");
|
|
441
|
+
session.status = "running";
|
|
442
|
+
session.lastFailure = null;
|
|
443
|
+
let committed;
|
|
444
|
+
try {
|
|
445
|
+
committed = await this.#commitStep(session, {
|
|
446
|
+
operationKey: `player:${exchangeId}`,
|
|
447
|
+
historyAppend: [{ role: "player", exactText: playerText }],
|
|
448
|
+
stateChanges: [],
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
const message = error instanceof Error
|
|
453
|
+
? error.message
|
|
454
|
+
: "Failed to write the original player text.";
|
|
455
|
+
session.events.push({
|
|
456
|
+
id: session.nextEventId++,
|
|
457
|
+
kind: "failure",
|
|
458
|
+
message,
|
|
459
|
+
});
|
|
460
|
+
session.status = "interrupted";
|
|
461
|
+
session.canRetry = false;
|
|
462
|
+
session.lastFailure = message;
|
|
463
|
+
await this.#persist(session);
|
|
464
|
+
throw new PlayCallChainError(message);
|
|
465
|
+
}
|
|
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,
|
|
474
|
+
});
|
|
475
|
+
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);
|
|
480
|
+
}
|
|
481
|
+
async #dispatch(session, modelHost, firstRequest, firstAttempt, observer) {
|
|
482
|
+
let request = structuredClone(firstRequest);
|
|
483
|
+
let attempt = firstAttempt;
|
|
484
|
+
for (;;) {
|
|
485
|
+
session.status = "running";
|
|
486
|
+
session.canRetry = false;
|
|
487
|
+
session.lastFailure = null;
|
|
488
|
+
session.lastRequest = structuredClone(request);
|
|
489
|
+
session.lastRequestAttempt = attempt;
|
|
490
|
+
const responseExchange = request.exchange ?? session.exchange;
|
|
491
|
+
const event = {
|
|
492
|
+
id: session.nextEventId++,
|
|
493
|
+
kind: "assistant",
|
|
494
|
+
text: "",
|
|
495
|
+
status: "streaming",
|
|
496
|
+
exchange: responseExchange,
|
|
497
|
+
attempt,
|
|
498
|
+
};
|
|
499
|
+
session.events.push(event);
|
|
500
|
+
await this.#persist(session);
|
|
501
|
+
notifySnapshot(observer, session);
|
|
502
|
+
let response;
|
|
503
|
+
try {
|
|
504
|
+
response = await modelHost.exchange(request, {
|
|
505
|
+
onDelta: (delta) => {
|
|
506
|
+
if (delta.kind === "text")
|
|
507
|
+
event.text += delta.text;
|
|
508
|
+
else if (delta.kind === "reasoning")
|
|
509
|
+
event.reasoning = `${event.reasoning ?? ""}${delta.text}`;
|
|
510
|
+
else if (delta.kind === "tool")
|
|
511
|
+
event.toolFragment = `${event.toolFragment ?? ""}${delta.text}`;
|
|
512
|
+
touch(session);
|
|
513
|
+
notifyAssistantDelta(observer, {
|
|
514
|
+
eventId: event.id,
|
|
515
|
+
kind: delta.kind,
|
|
516
|
+
text: delta.text,
|
|
517
|
+
updatedAt: session.updatedAt,
|
|
518
|
+
});
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
if (response.diagnostics !== undefined)
|
|
522
|
+
await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
event.status = "interrupted";
|
|
526
|
+
const message = error instanceof Error
|
|
527
|
+
? error.message
|
|
528
|
+
: "The model request was interrupted.";
|
|
529
|
+
session.events.push({
|
|
530
|
+
id: session.nextEventId++,
|
|
531
|
+
kind: "failure",
|
|
532
|
+
message,
|
|
533
|
+
});
|
|
534
|
+
session.status = "interrupted";
|
|
535
|
+
session.canRetry = true;
|
|
536
|
+
session.lastFailure = message;
|
|
537
|
+
await this.#persist(session);
|
|
538
|
+
notifySnapshot(observer, session);
|
|
539
|
+
return projectView(session);
|
|
540
|
+
}
|
|
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
|
+
};
|
|
564
|
+
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);
|
|
582
|
+
if (failedTools.length > 0 && response.diagnostics !== undefined)
|
|
583
|
+
await this.#failureLog?.recordFailure({
|
|
584
|
+
exchange: response.diagnostics,
|
|
585
|
+
failures: [
|
|
586
|
+
{
|
|
587
|
+
kind: "tool_execution",
|
|
588
|
+
message: "A Runtime tool call from the model was rejected.",
|
|
589
|
+
details: {
|
|
590
|
+
calls: failedTools.map(({ call, result, replayed }) => ({
|
|
591
|
+
id: call.id,
|
|
592
|
+
name: call.name,
|
|
593
|
+
arguments: structuredClone(call.arguments),
|
|
594
|
+
ok: result.ok,
|
|
595
|
+
failureKind: result.failureKind,
|
|
596
|
+
markdown: result.markdown,
|
|
597
|
+
replayed,
|
|
598
|
+
})),
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
});
|
|
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) {
|
|
650
|
+
const message = "The model returned neither text nor a complete tool call.";
|
|
651
|
+
if (response.diagnostics !== undefined)
|
|
652
|
+
await this.#failureLog?.recordFailure({
|
|
653
|
+
exchange: response.diagnostics,
|
|
654
|
+
failures: [
|
|
655
|
+
{
|
|
656
|
+
kind: "format_validation",
|
|
657
|
+
message,
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
});
|
|
661
|
+
session.events.push({
|
|
662
|
+
id: session.nextEventId++,
|
|
663
|
+
kind: "failure",
|
|
664
|
+
message,
|
|
665
|
+
});
|
|
666
|
+
session.status = "interrupted";
|
|
667
|
+
session.canRetry = false;
|
|
668
|
+
session.lastFailure = message;
|
|
669
|
+
await this.#persist(session);
|
|
670
|
+
notifySnapshot(observer, session);
|
|
671
|
+
return projectView(session);
|
|
672
|
+
}
|
|
673
|
+
// The exchange has settled: no tool call is pending and the narrative
|
|
674
|
+
// is committed. Followups run here, while the chain still reads as
|
|
675
|
+
// running, so the player only regains the composer once the panels
|
|
676
|
+
// they are about to act on are in place.
|
|
677
|
+
if (response.diagnostics !== undefined)
|
|
678
|
+
await this.#failureLog?.resolve({
|
|
679
|
+
exchange: response.diagnostics,
|
|
680
|
+
message: "The play call chain recovered during a later model exchange and completed.",
|
|
681
|
+
details: { parentHead: session.parentHead },
|
|
682
|
+
});
|
|
683
|
+
await this.#runFollowups(session, modelHost, observer);
|
|
684
|
+
session.status = "ready";
|
|
685
|
+
await this.#persist(session);
|
|
686
|
+
notifySnapshot(observer, session);
|
|
687
|
+
return projectView(session);
|
|
688
|
+
}
|
|
689
|
+
await this.#persist(session);
|
|
690
|
+
notifySnapshot(observer, session);
|
|
691
|
+
session.exchange = Math.max(session.exchange, responseExchange) + 1;
|
|
692
|
+
request = createRequest(session, modelHost, session.exchange);
|
|
693
|
+
attempt = 1;
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
const message = error instanceof Error
|
|
697
|
+
? error.message
|
|
698
|
+
: "Call-chain processing failed.";
|
|
699
|
+
if (response.diagnostics !== undefined)
|
|
700
|
+
await this.#failureLog?.recordFailure({
|
|
701
|
+
exchange: response.diagnostics,
|
|
702
|
+
failures: [
|
|
703
|
+
{
|
|
704
|
+
kind: "runtime_post_processing",
|
|
705
|
+
message,
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
});
|
|
709
|
+
session.events.push({
|
|
710
|
+
id: session.nextEventId++,
|
|
711
|
+
kind: "failure",
|
|
712
|
+
message,
|
|
713
|
+
});
|
|
714
|
+
session.status = "interrupted";
|
|
715
|
+
session.canRetry = false;
|
|
716
|
+
session.lastFailure = message;
|
|
717
|
+
await this.#persist(session);
|
|
718
|
+
notifySnapshot(observer, session);
|
|
719
|
+
return projectView(session);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Dispatch the frozen follow-ups once against the settled exchange.
|
|
725
|
+
*
|
|
726
|
+
* Everything here is best-effort by construction: Authority already accepted
|
|
727
|
+
* the narrative and the world changes, so a failing panel must cost the
|
|
728
|
+
* player nothing more than a missing panel. Failures are recorded in the
|
|
729
|
+
* trace and the chain still returns to `ready`.
|
|
730
|
+
*/
|
|
731
|
+
async #runFollowups(session, modelHost, observer) {
|
|
732
|
+
const followups = session.followups ?? [];
|
|
733
|
+
if (this.#artifacts === undefined || followups.length === 0)
|
|
734
|
+
return;
|
|
735
|
+
const record = (outcome) => {
|
|
736
|
+
session.events.push({
|
|
737
|
+
id: session.nextEventId++,
|
|
738
|
+
kind: "followup",
|
|
739
|
+
followupId: outcome.id,
|
|
740
|
+
displayName: outcome.displayName,
|
|
741
|
+
text: outcome.text,
|
|
742
|
+
...(outcome.reasoning === undefined
|
|
743
|
+
? {}
|
|
744
|
+
: { reasoning: outcome.reasoning }),
|
|
745
|
+
...(outcome.usage === undefined ? {} : { usage: outcome.usage }),
|
|
746
|
+
toolCalls: structuredClone(outcome.toolCalls),
|
|
747
|
+
...(outcome.failure === undefined ? {} : { failure: outcome.failure }),
|
|
748
|
+
});
|
|
749
|
+
touch(session);
|
|
750
|
+
notifySnapshot(observer, session);
|
|
751
|
+
};
|
|
752
|
+
try {
|
|
753
|
+
await runPlayFollowupRequests({
|
|
754
|
+
artifacts: this.#artifacts,
|
|
755
|
+
modelHost,
|
|
756
|
+
followups,
|
|
757
|
+
bootstrap: session.bootstrap,
|
|
758
|
+
// Captured once: every followup is dispatched against this exact
|
|
759
|
+
// prefix, so none of them observes another one's prompt or output.
|
|
760
|
+
prefix: structuredClone(session.transcript),
|
|
761
|
+
toolStrategy: session.bootstrap.toolStrategy,
|
|
762
|
+
context: {
|
|
763
|
+
// A follow-up produces no world endpoint of its own, so it starts and
|
|
764
|
+
// ends on the settled exchange's head. Reconciliation keeps panels
|
|
765
|
+
// bound to this endpoint and retires ones bound to the previous head.
|
|
766
|
+
worldId: session.worldId,
|
|
767
|
+
parentHead: session.parentHead,
|
|
768
|
+
operationId: followupOperationId(session),
|
|
769
|
+
playPresetId: session.playPreset.id,
|
|
770
|
+
playPresetRevision: session.playPreset.revision,
|
|
771
|
+
playPresetScriptsEnabled: session.playPresetScriptsEnabled ?? true,
|
|
772
|
+
},
|
|
773
|
+
head: session.parentHead,
|
|
774
|
+
maxOutputTokens: modelHost.binding().maxOutputTokens,
|
|
775
|
+
...(this.#failureLog === undefined
|
|
776
|
+
? {}
|
|
777
|
+
: { failureLog: this.#failureLog }),
|
|
778
|
+
observer: { onOutcome: record },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
session.events.push({
|
|
783
|
+
id: session.nextEventId++,
|
|
784
|
+
kind: "failure",
|
|
785
|
+
message: error instanceof Error
|
|
786
|
+
? `Follow-up request failed: ${error.message}`
|
|
787
|
+
: "The follow-up request failed.",
|
|
788
|
+
});
|
|
789
|
+
touch(session);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
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
|
+
async #requireChain(worldId, chainId) {
|
|
812
|
+
validateIdentity(chainId, "call-chain ID");
|
|
813
|
+
const active = this.#active.get(chainId);
|
|
814
|
+
if (active !== undefined) {
|
|
815
|
+
if (active.worldId !== worldId)
|
|
816
|
+
throw new PlayCallChainError("The call chain does not belong to this world.");
|
|
817
|
+
return active;
|
|
818
|
+
}
|
|
819
|
+
const persisted = await this.#readPersisted(worldId);
|
|
820
|
+
if (persisted?.chainId !== chainId)
|
|
821
|
+
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)
|
|
839
|
+
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 = {
|
|
842
|
+
...structuredClone(persisted),
|
|
843
|
+
documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
|
|
844
|
+
nextMaterials: structuredClone(binding.additionalMaterials),
|
|
845
|
+
documents,
|
|
846
|
+
history: historyEntries(binding.history),
|
|
847
|
+
completedToolMap: new Map(persisted.completedTools.map((item) => [
|
|
848
|
+
item.key,
|
|
849
|
+
structuredClone(item),
|
|
850
|
+
])),
|
|
851
|
+
};
|
|
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;
|
|
857
|
+
}
|
|
858
|
+
async #assertCurrentHead(session) {
|
|
859
|
+
if ((await this.#worlds.currentHead(session.worldId)) !== session.parentHead)
|
|
860
|
+
throw new PlayCallChainError("Another operation has advanced the current world; use a fresh context.");
|
|
861
|
+
}
|
|
862
|
+
async #readPersisted(worldId) {
|
|
863
|
+
const value = await this.#worlds.readPlayCallChain(worldId);
|
|
864
|
+
if (value === null)
|
|
865
|
+
return null;
|
|
866
|
+
assertPersistedPlayCallChain(value, worldId);
|
|
867
|
+
return { ...structuredClone(value), schemaVersion: 1 };
|
|
868
|
+
}
|
|
869
|
+
async #persist(session) {
|
|
870
|
+
touch(session);
|
|
871
|
+
session.completedTools = [...session.completedToolMap.values()].map((item) => structuredClone(item));
|
|
872
|
+
const { documents: _documents, history: _history, completedToolMap: _completedToolMap, ...persisted } = session;
|
|
873
|
+
void _documents;
|
|
874
|
+
void _history;
|
|
875
|
+
void _completedToolMap;
|
|
876
|
+
await this.#worlds.writePlayCallChain(session.worldId, structuredClone(persisted));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* One operation per settled exchange. A fresh id makes the artifact store
|
|
881
|
+
* supersede the previous exchange's `new_operation` panels, so it advances with
|
|
882
|
+
* the committed endpoint rather than staying stable for the chain.
|
|
883
|
+
*/
|
|
884
|
+
function followupOperationId(session) {
|
|
885
|
+
return `${session.chainId}:followup:${session.parentHead}`;
|
|
886
|
+
}
|
|
887
|
+
function createRequest(session, modelHost, exchange) {
|
|
888
|
+
return {
|
|
889
|
+
bootstrap: structuredClone(session.bootstrap),
|
|
890
|
+
tools: structuredClone(session.tools),
|
|
891
|
+
toolUniverse: structuredClone(session.tools),
|
|
892
|
+
allowedTools: session.tools.map(({ name }) => name),
|
|
893
|
+
toolStrategy: session.bootstrap.toolStrategy,
|
|
894
|
+
appended: structuredClone(session.transcript),
|
|
895
|
+
requestId: "play_call_chain",
|
|
896
|
+
operationId: session.chainId,
|
|
897
|
+
requestAttempt: 1,
|
|
898
|
+
exchange,
|
|
899
|
+
maxOutputTokens: modelHost.binding().maxOutputTokens,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function prepareTool(session, working, exchange, call) {
|
|
903
|
+
const key = `${exchange}:${call.id}`;
|
|
904
|
+
const signature = JSON.stringify(call.arguments);
|
|
905
|
+
const completed = working.get(key);
|
|
906
|
+
let result;
|
|
907
|
+
let replayed = false;
|
|
908
|
+
if (completed !== undefined) {
|
|
909
|
+
replayed = true;
|
|
910
|
+
result =
|
|
911
|
+
completed.name === call.name && completed.signature === signature
|
|
912
|
+
? structuredClone(completed.result)
|
|
913
|
+
: {
|
|
914
|
+
ok: false,
|
|
915
|
+
failureKind: "protocol",
|
|
916
|
+
markdown: "# Runtime tool rejected\n\nThe same tool-call ID is already bound to another tool name or argument set.",
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
else if (!callChainToolNames.has(call.name)) {
|
|
920
|
+
result = {
|
|
921
|
+
ok: false,
|
|
922
|
+
failureKind: "protocol",
|
|
923
|
+
markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}.`,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
try {
|
|
928
|
+
result = session.documents.execute(call, session.history);
|
|
929
|
+
}
|
|
930
|
+
catch (error) {
|
|
931
|
+
result = {
|
|
932
|
+
ok: false,
|
|
933
|
+
failureKind: "protocol",
|
|
934
|
+
markdown: `# Runtime tool failed\n\n${error instanceof Error ? error.message : "Tool execution failed."}`,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
working.set(key, {
|
|
938
|
+
key,
|
|
939
|
+
name: call.name,
|
|
940
|
+
signature,
|
|
941
|
+
result: structuredClone(result),
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
const event = {
|
|
945
|
+
id: 0,
|
|
946
|
+
kind: "tool_result",
|
|
947
|
+
callId: call.id,
|
|
948
|
+
name: call.name,
|
|
949
|
+
ok: result.ok,
|
|
950
|
+
markdown: result.markdown,
|
|
951
|
+
replayed,
|
|
952
|
+
};
|
|
953
|
+
return {
|
|
954
|
+
call,
|
|
955
|
+
key,
|
|
956
|
+
result,
|
|
957
|
+
replayed,
|
|
958
|
+
event,
|
|
959
|
+
transcript: {
|
|
960
|
+
kind: "tool",
|
|
961
|
+
toolCallId: call.id,
|
|
962
|
+
markdown: result.markdown,
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
function duplicatePlayerExchange(session, exchangeId, playerText, context) {
|
|
967
|
+
const existing = session.events.find((event) => event.kind === "player" && event.exchangeId === exchangeId);
|
|
968
|
+
if (existing === undefined)
|
|
969
|
+
return false;
|
|
970
|
+
if (existing.text !== playerText || existing.context !== context)
|
|
971
|
+
throw new PlayCallChainError("The same exchange ID is already bound to different player text or a different context mode.");
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
function mergeChangedDocuments(session, changes) {
|
|
975
|
+
for (const change of changes) {
|
|
976
|
+
const projected = {
|
|
977
|
+
kind: change.kind,
|
|
978
|
+
ref: `@${change.stableShortRef}`,
|
|
979
|
+
path: change.relativePath,
|
|
980
|
+
};
|
|
981
|
+
const index = session.changedDocuments.findIndex(({ path }) => path === projected.path);
|
|
982
|
+
if (index < 0)
|
|
983
|
+
session.changedDocuments.push(projected);
|
|
984
|
+
else
|
|
985
|
+
session.changedDocuments[index] = projected;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function restorePlayDocuments(files, context, events) {
|
|
989
|
+
const documents = new FileNativePlayDocuments(files);
|
|
990
|
+
try {
|
|
991
|
+
const checkpoint = documentAuthorizationThroughEvents(context, events);
|
|
992
|
+
if (checkpoint === undefined)
|
|
993
|
+
documents.bindBootstrap(context.bootstrap);
|
|
994
|
+
else
|
|
995
|
+
documents.restoreAuthorizationCheckpoint(checkpoint.authorization);
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
|
|
999
|
+
}
|
|
1000
|
+
return documents;
|
|
1001
|
+
}
|
|
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
|
+
function documentAuthorizationThroughEvents(context, events) {
|
|
1016
|
+
const checkpoints = context.documentAuthorizationCheckpoints;
|
|
1017
|
+
if (checkpoints === undefined || checkpoints.length === 0)
|
|
1018
|
+
return undefined;
|
|
1019
|
+
const selectedEventIds = new Set(events.map(({ id }) => id));
|
|
1020
|
+
const selected = checkpoints.findLast(({ afterEventId }) => afterEventId === 0 || selectedEventIds.has(afterEventId));
|
|
1021
|
+
// Legacy V1 records had no durable dynamic authorization. If the selected
|
|
1022
|
+
// prefix predates their first lazily-written checkpoint, retain the exact
|
|
1023
|
+
// old recovery behavior and rebuild only bootstrap authorization.
|
|
1024
|
+
return selected === undefined ? undefined : structuredClone(selected);
|
|
1025
|
+
}
|
|
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
|
+
function independentContextCopy(source) {
|
|
1041
|
+
const context = structuredClone(source);
|
|
1042
|
+
delete context.derivedFrom;
|
|
1043
|
+
delete context.branchedBeforePlayer;
|
|
1044
|
+
return context;
|
|
1045
|
+
}
|
|
1046
|
+
function authorizationCheckpointsThroughEvents(context, events, fallback) {
|
|
1047
|
+
const eventIds = new Set(events.map(({ id }) => id));
|
|
1048
|
+
const selected = (context.documentAuthorizationCheckpoints ?? [])
|
|
1049
|
+
.filter(({ afterEventId }) => afterEventId === 0 || eventIds.has(afterEventId))
|
|
1050
|
+
.map((checkpoint) => structuredClone(checkpoint));
|
|
1051
|
+
if (selected.length > 0)
|
|
1052
|
+
return selected;
|
|
1053
|
+
return [
|
|
1054
|
+
{
|
|
1055
|
+
afterEventId: Math.max(0, ...events.map(({ id }) => id)),
|
|
1056
|
+
authorization: structuredClone(fallback),
|
|
1057
|
+
},
|
|
1058
|
+
];
|
|
1059
|
+
}
|
|
1060
|
+
function projectContext(context) {
|
|
1061
|
+
return {
|
|
1062
|
+
chainId: context.chainId,
|
|
1063
|
+
baselineHead: context.baselineHead,
|
|
1064
|
+
...(context.baselineHistoryLength === undefined
|
|
1065
|
+
? {}
|
|
1066
|
+
: { baselineHistoryLength: context.baselineHistoryLength }),
|
|
1067
|
+
parentHead: context.parentHead,
|
|
1068
|
+
playPreset: structuredClone(context.playPreset),
|
|
1069
|
+
status: context.status,
|
|
1070
|
+
canRetry: context.canRetry,
|
|
1071
|
+
events: structuredClone(context.events),
|
|
1072
|
+
changedDocuments: structuredClone(context.changedDocuments),
|
|
1073
|
+
lastFailure: context.lastFailure,
|
|
1074
|
+
updatedAt: context.updatedAt,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
function projectView(session) {
|
|
1078
|
+
return {
|
|
1079
|
+
...projectContext(session),
|
|
1080
|
+
worldId: session.worldId,
|
|
1081
|
+
previousContexts: (session.previousContexts ?? []).map((context) => projectContext(context)),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
function eventsThroughHead(events, head) {
|
|
1085
|
+
const selectedIndex = events.findIndex((event) => (event.kind === "player" || event.kind === "assistant") &&
|
|
1086
|
+
event.committedHead === head);
|
|
1087
|
+
if (selectedIndex < 0)
|
|
1088
|
+
return null;
|
|
1089
|
+
let end = selectedIndex + 1;
|
|
1090
|
+
if (events[selectedIndex]?.kind === "assistant")
|
|
1091
|
+
while (events[end]?.kind === "tool_call" ||
|
|
1092
|
+
events[end]?.kind === "tool_result")
|
|
1093
|
+
end += 1;
|
|
1094
|
+
return structuredClone(events.slice(0, end));
|
|
1095
|
+
}
|
|
1096
|
+
function transcriptThroughEvents(transcript, events) {
|
|
1097
|
+
const result = [];
|
|
1098
|
+
let cursor = 0;
|
|
1099
|
+
for (const [index, event] of events.entries()) {
|
|
1100
|
+
if (event.kind === "player") {
|
|
1101
|
+
const item = transcript[cursor];
|
|
1102
|
+
if (item?.kind !== "player" || item.text !== event.text)
|
|
1103
|
+
throw new PlayCallChainError("Source call-chain events do not match the model transcript, so a fork cannot be created safely.");
|
|
1104
|
+
result.push(structuredClone(item));
|
|
1105
|
+
cursor += 1;
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (event.kind === "assistant" && event.status === "completed") {
|
|
1109
|
+
const hasToolCall = events
|
|
1110
|
+
.slice(index + 1)
|
|
1111
|
+
.find((candidate) => candidate.kind === "player" ||
|
|
1112
|
+
candidate.kind === "assistant" ||
|
|
1113
|
+
candidate.kind === "tool_call");
|
|
1114
|
+
const recorded = event.text.trim().length > 0 || hasToolCall?.kind === "tool_call";
|
|
1115
|
+
if (!recorded)
|
|
1116
|
+
continue;
|
|
1117
|
+
const item = transcript[cursor];
|
|
1118
|
+
if (item?.kind !== "assistant" || item.text !== event.text)
|
|
1119
|
+
throw new PlayCallChainError("Source call-chain responses do not match the model transcript, so a fork cannot be created safely.");
|
|
1120
|
+
result.push(structuredClone(item));
|
|
1121
|
+
cursor += 1;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (event.kind === "tool_result") {
|
|
1125
|
+
const item = transcript[cursor];
|
|
1126
|
+
if (item?.kind !== "tool" || item.toolCallId !== event.callId)
|
|
1127
|
+
throw new PlayCallChainError("Source call-chain tool results do not match the model transcript, so a fork cannot be created safely.");
|
|
1128
|
+
result.push(structuredClone(item));
|
|
1129
|
+
cursor += 1;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return result;
|
|
1133
|
+
}
|
|
1134
|
+
function completedToolKeys(events) {
|
|
1135
|
+
const keys = new Set();
|
|
1136
|
+
let exchange = null;
|
|
1137
|
+
for (const event of events) {
|
|
1138
|
+
if (event.kind === "player")
|
|
1139
|
+
exchange = null;
|
|
1140
|
+
else if (event.kind === "assistant")
|
|
1141
|
+
exchange = event.exchange;
|
|
1142
|
+
else if (event.kind === "tool_call" && exchange !== null)
|
|
1143
|
+
keys.add(`${exchange}:${event.callId}`);
|
|
1144
|
+
}
|
|
1145
|
+
return keys;
|
|
1146
|
+
}
|
|
1147
|
+
function changedDocumentsAtHead(changes, baseline, selected) {
|
|
1148
|
+
const before = new Map(baseline.map((file) => [file.path, file.contents]));
|
|
1149
|
+
const after = new Map(selected.map((file) => [file.path, file.contents]));
|
|
1150
|
+
return changes
|
|
1151
|
+
.filter(({ path }) => after.has(path) && before.get(path) !== after.get(path))
|
|
1152
|
+
.map((change) => structuredClone(change));
|
|
1153
|
+
}
|
|
1154
|
+
function derivedChainId(sourceChainId, branchIdentity, targetWorldId) {
|
|
1155
|
+
return `play-branch-${createHash("sha256")
|
|
1156
|
+
.update(`${sourceChainId}\0${branchIdentity}\0${targetWorldId}`)
|
|
1157
|
+
.digest("hex")
|
|
1158
|
+
.slice(0, 40)}`;
|
|
1159
|
+
}
|
|
1160
|
+
function historyEntries(history) {
|
|
1161
|
+
return Object.entries(history).map(([path, contents]) => ({
|
|
1162
|
+
path,
|
|
1163
|
+
contents,
|
|
1164
|
+
}));
|
|
1165
|
+
}
|
|
1166
|
+
function operationId(chainId, key) {
|
|
1167
|
+
return `play-${createHash("sha256")
|
|
1168
|
+
.update(`${chainId}\0${key}`)
|
|
1169
|
+
.digest("hex")}`;
|
|
1170
|
+
}
|
|
1171
|
+
function assertPersistedPlayCallChain(value, worldId) {
|
|
1172
|
+
if (!isRecord(value) ||
|
|
1173
|
+
(value.schemaVersion !== 1 && value.schemaVersion !== 2) ||
|
|
1174
|
+
value.kind !== "play_call_chain" ||
|
|
1175
|
+
value.worldId !== worldId ||
|
|
1176
|
+
!isPersistedPlayCallChainContext(value) ||
|
|
1177
|
+
(value.previousContexts !== undefined &&
|
|
1178
|
+
(!Array.isArray(value.previousContexts) ||
|
|
1179
|
+
value.previousContexts.some((context) => !isPersistedPlayCallChainContext(context)))))
|
|
1180
|
+
throw new PlayCallChainError("The persisted call-chain record does not match the current format and cannot be read.");
|
|
1181
|
+
}
|
|
1182
|
+
function isPersistedPlayCallChainContext(value) {
|
|
1183
|
+
if (!isRecord(value))
|
|
1184
|
+
return false;
|
|
1185
|
+
return (typeof value.chainId === "string" &&
|
|
1186
|
+
typeof value.baselineHead === "string" &&
|
|
1187
|
+
(value.baselineHistoryLength === undefined ||
|
|
1188
|
+
(typeof value.baselineHistoryLength === "number" &&
|
|
1189
|
+
Number.isSafeInteger(value.baselineHistoryLength) &&
|
|
1190
|
+
value.baselineHistoryLength >= 0)) &&
|
|
1191
|
+
typeof value.parentHead === "string" &&
|
|
1192
|
+
(value.derivedFrom === undefined ||
|
|
1193
|
+
(isRecord(value.derivedFrom) &&
|
|
1194
|
+
typeof value.derivedFrom.worldId === "string" &&
|
|
1195
|
+
typeof value.derivedFrom.chainId === "string" &&
|
|
1196
|
+
typeof value.derivedFrom.head === "string")) &&
|
|
1197
|
+
(value.branchedBeforePlayer === undefined ||
|
|
1198
|
+
(isRecord(value.branchedBeforePlayer) &&
|
|
1199
|
+
typeof value.branchedBeforePlayer.worldId === "string" &&
|
|
1200
|
+
typeof value.branchedBeforePlayer.chainId === "string" &&
|
|
1201
|
+
Number.isSafeInteger(value.branchedBeforePlayer.eventId) &&
|
|
1202
|
+
value.branchedBeforePlayer.eventId > 0)) &&
|
|
1203
|
+
isRecord(value.playPreset) &&
|
|
1204
|
+
(value.followups === undefined || Array.isArray(value.followups)) &&
|
|
1205
|
+
(value.playPresetScriptsEnabled === undefined ||
|
|
1206
|
+
typeof value.playPresetScriptsEnabled === "boolean") &&
|
|
1207
|
+
(value.status === "ready" ||
|
|
1208
|
+
value.status === "running" ||
|
|
1209
|
+
value.status === "interrupted") &&
|
|
1210
|
+
typeof value.canRetry === "boolean" &&
|
|
1211
|
+
isRecord(value.bootstrap) &&
|
|
1212
|
+
Array.isArray(value.tools) &&
|
|
1213
|
+
Array.isArray(value.transcript) &&
|
|
1214
|
+
Array.isArray(value.events) &&
|
|
1215
|
+
Array.isArray(value.completedTools) &&
|
|
1216
|
+
(value.documentAuthorizationCheckpoints === undefined ||
|
|
1217
|
+
isPersistedDocumentAuthorizationHistory(value.documentAuthorizationCheckpoints, value.events)) &&
|
|
1218
|
+
Array.isArray(value.changedDocuments) &&
|
|
1219
|
+
Array.isArray(value.nextMaterials) &&
|
|
1220
|
+
Number.isSafeInteger(value.nextEventId) &&
|
|
1221
|
+
Number.isSafeInteger(value.exchange) &&
|
|
1222
|
+
(value.lastRequest === null || isRecord(value.lastRequest)) &&
|
|
1223
|
+
Number.isSafeInteger(value.lastRequestAttempt) &&
|
|
1224
|
+
(value.lastFailure === null || typeof value.lastFailure === "string") &&
|
|
1225
|
+
typeof value.updatedAt === "number");
|
|
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;
|
|
1249
|
+
}
|
|
1250
|
+
function validateIdentity(value, label) {
|
|
1251
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
|
|
1252
|
+
throw new PlayCallChainError(`${label} is invalid.`);
|
|
1253
|
+
}
|
|
1254
|
+
function validatePlayerText(value) {
|
|
1255
|
+
if (value.trim().length === 0)
|
|
1256
|
+
throw new PlayCallChainError("Player text must not be empty.");
|
|
1257
|
+
}
|
|
1258
|
+
function touch(session) {
|
|
1259
|
+
session.updatedAt = Date.now();
|
|
1260
|
+
}
|
|
1261
|
+
function notifySnapshot(observer, session) {
|
|
1262
|
+
try {
|
|
1263
|
+
observer?.onSnapshot?.(projectView(session));
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
// A browser stream may disconnect while the durable model request keeps
|
|
1267
|
+
// running. Presentation failure never cancels or changes play Authority.
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
function notifyAssistantDelta(observer, delta) {
|
|
1271
|
+
try {
|
|
1272
|
+
observer?.onAssistantDelta?.(structuredClone(delta));
|
|
1273
|
+
}
|
|
1274
|
+
catch {
|
|
1275
|
+
// See notifySnapshot: the Provider exchange outlives its browser stream.
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
function isRecord(value) {
|
|
1279
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1280
|
+
}
|