narraeon 0.8.0 → 0.8.1
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/dist/node/protocol/v1.js +22 -0
- package/dist/node/runtime/V1Runtime.js +34 -2
- package/dist/node/runtime/authoring/AuthoringEditPlan.js +79 -0
- package/dist/node/runtime/authoring/AuthoringRetry.js +59 -0
- package/dist/node/runtime/authoring/ModelToolConversation.js +22 -6
- package/dist/node/runtime/content/ContentWorkspace.js +7 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +14 -8
- package/dist/node/runtime/model/DispatchFailure.js +8 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +17 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +49 -0
- package/dist/node/runtime/play/PlayAdvanceLifecycle.js +18 -14
- package/dist/node/runtime/play/PlayCallChain.js +16 -4
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +210 -8
- package/dist/node/runtime/setting/SettingImprovementSession.js +247 -24
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +154 -9
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +199 -33
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +47 -1
- package/dist/web/assets/index-BthiDIQu.js +1432 -0
- package/dist/web/assets/{index-BKA1D0Rz.css → index-wO9Y4HT1.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-C_Qta8UL.js +0 -1432
package/dist/node/protocol/v1.js
CHANGED
|
@@ -73,6 +73,14 @@ const requiredFields = {
|
|
|
73
73
|
message: "string",
|
|
74
74
|
continuation: "object",
|
|
75
75
|
},
|
|
76
|
+
"setting-improvement.message.edit": {
|
|
77
|
+
packageId: "string",
|
|
78
|
+
sessionId: "string",
|
|
79
|
+
requestId: "string",
|
|
80
|
+
messageId: "string",
|
|
81
|
+
message: "string",
|
|
82
|
+
filePolicy: "string",
|
|
83
|
+
},
|
|
76
84
|
"setting-improvement.cancel": { sessionId: "string" },
|
|
77
85
|
"setting-improvement.resume": {
|
|
78
86
|
packageId: "string",
|
|
@@ -152,6 +160,14 @@ const requiredFields = {
|
|
|
152
160
|
message: "string",
|
|
153
161
|
continuation: "object",
|
|
154
162
|
},
|
|
163
|
+
"world.revision.message.edit": {
|
|
164
|
+
worldId: "string",
|
|
165
|
+
sessionId: "string",
|
|
166
|
+
requestId: "string",
|
|
167
|
+
messageId: "string",
|
|
168
|
+
message: "string",
|
|
169
|
+
filePolicy: "string",
|
|
170
|
+
},
|
|
155
171
|
"world.revision.cancel": { sessionId: "string" },
|
|
156
172
|
"world.revision.resume": {
|
|
157
173
|
worldId: "string",
|
|
@@ -251,6 +267,10 @@ function validateRequestFields(request) {
|
|
|
251
267
|
if (!valid || (kind === "string" && value === "" && !allowsEmptyString))
|
|
252
268
|
throw new V1ProtocolError("invalid_request", `${String(request.type)}.${field} is invalid`);
|
|
253
269
|
}
|
|
270
|
+
if (request.type === "setting-improvement.message.edit" &&
|
|
271
|
+
request.filePolicy !== "keep" &&
|
|
272
|
+
request.filePolicy !== "revert")
|
|
273
|
+
throw new V1ProtocolError("invalid_request", "setting-improvement.message.edit.filePolicy is invalid");
|
|
254
274
|
if (request.type === "world.story.export" &&
|
|
255
275
|
request.format !== "markdown" &&
|
|
256
276
|
request.format !== "text")
|
|
@@ -510,6 +530,7 @@ const requestTypes = new Set([
|
|
|
510
530
|
"setting-improvement.session.delete",
|
|
511
531
|
"setting-improvement.rollback",
|
|
512
532
|
"setting-improvement.message",
|
|
533
|
+
"setting-improvement.message.edit",
|
|
513
534
|
"setting-improvement.cancel",
|
|
514
535
|
"setting-improvement.resume",
|
|
515
536
|
"play.read",
|
|
@@ -551,6 +572,7 @@ const requestTypes = new Set([
|
|
|
551
572
|
"world.revision.session.read",
|
|
552
573
|
"world.revision.session.delete",
|
|
553
574
|
"world.revision.message",
|
|
575
|
+
"world.revision.message.edit",
|
|
554
576
|
"world.revision.cancel",
|
|
555
577
|
"world.revision.resume",
|
|
556
578
|
"world.revision.files.replace",
|
|
@@ -25,7 +25,7 @@ import { projectArtifactForFrontend, projectDebugArtifactForFrontend, } from "./
|
|
|
25
25
|
import { projectPlayerViewPanels, } from "./extension/PlayerViewPanelProjector.js";
|
|
26
26
|
import { FileNativePromptCompiler } from "./prompt/FileNativePromptCompiler.js";
|
|
27
27
|
import { FileNativeSettingImprovementStore } from "./setting/FileNativeSettingImprovementStore.js";
|
|
28
|
-
import { SettingImprovementRollbackError, SettingImprovementSession, } from "./setting/SettingImprovementSession.js";
|
|
28
|
+
import { SettingImprovementRollbackError, SettingImprovementSession, SettingMessageEditError, } from "./setting/SettingImprovementSession.js";
|
|
29
29
|
import { FileNativeWorldStore } from "./world/FileNativeWorldStore.js";
|
|
30
30
|
import { WorldDocumentStore } from "./world/WorldDocumentStore.js";
|
|
31
31
|
import { FileNativeAiFailureLog } from "./model/AiFailureLog.js";
|
|
@@ -33,7 +33,7 @@ import { FileNativeRuntimeErrorLog } from "./RuntimeErrorLog.js";
|
|
|
33
33
|
import { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
|
|
34
34
|
import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
|
|
35
35
|
import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
|
|
36
|
-
import { WorldRevisionSession } from "./world-revision/WorldRevisionSession.js";
|
|
36
|
+
import { WorldRevisionSession, WorldRevisionMessageEditError, } from "./world-revision/WorldRevisionSession.js";
|
|
37
37
|
import { WorldRevisionWorkspace, WorldRevisionWorkspaceError, } from "./world-revision/WorldRevisionWorkspace.js";
|
|
38
38
|
import { ConversationChanges } from "./ConversationChanges.js";
|
|
39
39
|
// 先取得当前端点再按它读取的请求,在端点被回退丢弃时最多重做这么多次(#94)。
|
|
@@ -424,6 +424,22 @@ export class V1Runtime {
|
|
|
424
424
|
}
|
|
425
425
|
case "setting-improvement.message":
|
|
426
426
|
return this.#settingImprovements.send(request);
|
|
427
|
+
case "setting-improvement.message.edit":
|
|
428
|
+
try {
|
|
429
|
+
return await this.#settingImprovements.editMessage(request);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (error instanceof SettingMessageEditError)
|
|
433
|
+
throw new V1ProtocolError("invalid_request", error.message, {
|
|
434
|
+
cause: error,
|
|
435
|
+
details: {
|
|
436
|
+
kind: "setting_message_edit",
|
|
437
|
+
reason: error.reason,
|
|
438
|
+
paths: error.paths,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
427
443
|
case "setting-improvement.cancel":
|
|
428
444
|
return this.#settingImprovements.cancel(request.sessionId);
|
|
429
445
|
case "setting-improvement.resume":
|
|
@@ -708,6 +724,22 @@ export class V1Runtime {
|
|
|
708
724
|
return this.#worldRevisionCall(() => this.#worldRevisions.deleteSession(request.worldId, request.sessionId));
|
|
709
725
|
case "world.revision.message":
|
|
710
726
|
return this.#worldRevisionCall(() => this.#worldRevisions.send(request));
|
|
727
|
+
case "world.revision.message.edit":
|
|
728
|
+
try {
|
|
729
|
+
return await this.#worldRevisionCall(() => this.#worldRevisions.editMessage(request));
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
if (error instanceof WorldRevisionMessageEditError)
|
|
733
|
+
throw new V1ProtocolError("invalid_request", error.message, {
|
|
734
|
+
cause: error,
|
|
735
|
+
details: {
|
|
736
|
+
kind: "world_revision_message_edit",
|
|
737
|
+
reason: error.reason,
|
|
738
|
+
paths: error.paths,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
711
743
|
case "world.revision.cancel":
|
|
712
744
|
return this.#worldRevisionCall(() => this.#worldRevisions.cancel(request.sessionId));
|
|
713
745
|
case "world.revision.resume":
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { validAuthoringNativeContinuation } from "../model/FileNativeModelAdapters.js";
|
|
2
|
+
/** Checks idempotency before a target claims its own publication lease. */
|
|
3
|
+
export function completedAuthoringEdit(session, edit) {
|
|
4
|
+
const prior = session.editReceipts?.find(({ requestId }) => requestId === edit.requestId);
|
|
5
|
+
if (prior !== undefined &&
|
|
6
|
+
(prior.messageId !== edit.messageId ||
|
|
7
|
+
prior.message !== edit.message ||
|
|
8
|
+
prior.filePolicy !== edit.filePolicy))
|
|
9
|
+
throw new Error("This edit request ID was already used for different content");
|
|
10
|
+
if (!session.completedRequestIds.includes(edit.requestId))
|
|
11
|
+
return false;
|
|
12
|
+
if (prior === undefined)
|
|
13
|
+
throw new Error("This request ID was already used for another authoring operation");
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
/** Locates and validates the native prefix and prepares the replacement transcript. */
|
|
17
|
+
export function planAuthoringEdit(session, edit) {
|
|
18
|
+
const messageIndex = session.messages.findIndex((entry) => entry.role === "user" && entry.id === edit.messageId);
|
|
19
|
+
if (messageIndex < 0)
|
|
20
|
+
throw new Error("The selected authoring message no longer exists");
|
|
21
|
+
const userIndex = session.messages
|
|
22
|
+
.slice(0, messageIndex)
|
|
23
|
+
.filter((entry) => entry.role === "user").length;
|
|
24
|
+
const itemIndex = session.modelItems
|
|
25
|
+
.map((item, index) => (item.kind === "user" ? index : -1))
|
|
26
|
+
.filter((index) => index >= 0)[userIndex];
|
|
27
|
+
if (itemIndex === undefined ||
|
|
28
|
+
session.modelItems[itemIndex]?.kind !== "user" ||
|
|
29
|
+
session.modelItems[itemIndex].text !== session.messages[messageIndex].text)
|
|
30
|
+
throw new Error("The selected authoring message cannot be matched to the native transcript");
|
|
31
|
+
const prefix = session.modelItems.slice(0, itemIndex);
|
|
32
|
+
if (!validAuthoringNativeContinuation(prefix, session.modelBinding.provider))
|
|
33
|
+
throw new Error("The native continuation before this message is damaged; start a fresh context");
|
|
34
|
+
const next = structuredClone(session);
|
|
35
|
+
next.editReceipts = [...(next.editReceipts ?? []), edit];
|
|
36
|
+
delete next.retryDispatch;
|
|
37
|
+
next.modelItems = prefix;
|
|
38
|
+
next.messages = next.messages.slice(0, messageIndex);
|
|
39
|
+
next.requests = (next.requests ?? []).filter((entry) => entry.modelItemStart < itemIndex);
|
|
40
|
+
return {
|
|
41
|
+
itemIndex,
|
|
42
|
+
tail: session.modelItems.slice(itemIndex),
|
|
43
|
+
next: { ...next, requests: next.requests },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Returns a candidate tree and conflicts; the target validates and publishes it. */
|
|
47
|
+
export function revertAuthoringChanges(files, items) {
|
|
48
|
+
const result = new Map(files.map((file) => [file.path, structuredClone(file)]));
|
|
49
|
+
const conflicts = new Set();
|
|
50
|
+
const changes = items.flatMap((item) => item.kind === "tool" && !item.isError ? item.changes : []);
|
|
51
|
+
for (const change of changes.reverse()) {
|
|
52
|
+
if (conflicts.has(change.path))
|
|
53
|
+
continue;
|
|
54
|
+
const malformed = (change.kind === "create" &&
|
|
55
|
+
(change.before !== null || change.after === null)) ||
|
|
56
|
+
(change.kind === "delete" &&
|
|
57
|
+
(change.before === null || change.after !== null)) ||
|
|
58
|
+
(change.kind === "modify" &&
|
|
59
|
+
(change.before === null ||
|
|
60
|
+
change.after === null ||
|
|
61
|
+
change.before === change.after));
|
|
62
|
+
const current = result.get(change.path);
|
|
63
|
+
if (malformed ||
|
|
64
|
+
(change.after === null
|
|
65
|
+
? current !== undefined
|
|
66
|
+
: current?.contents !== change.after || current.encoding !== undefined)) {
|
|
67
|
+
conflicts.add(change.path);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (change.before === null)
|
|
71
|
+
result.delete(change.path);
|
|
72
|
+
else
|
|
73
|
+
result.set(change.path, { path: change.path, contents: change.before });
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
files: [...result.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
|
77
|
+
conflicts: [...conflicts].sort(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { validAuthoringNativeContinuation } from "../model/FileNativeModelAdapters.js";
|
|
2
|
+
import { dispatchRequiresFreshContext } from "../model/DispatchFailure.js";
|
|
3
|
+
export function prepareAuthoringDispatch(session, request) {
|
|
4
|
+
const retry = session.retryDispatch;
|
|
5
|
+
if (retry?.state === "retryable") {
|
|
6
|
+
session.retryDispatch = { ...retry, state: "dispatching" };
|
|
7
|
+
return { ...request, requestId: retry.requestId, exchange: retry.exchange };
|
|
8
|
+
}
|
|
9
|
+
session.retryDispatch = {
|
|
10
|
+
requestId: request.requestId,
|
|
11
|
+
exchange: request.exchange,
|
|
12
|
+
modelItemCount: session.modelItems.length,
|
|
13
|
+
state: "dispatching",
|
|
14
|
+
};
|
|
15
|
+
return request;
|
|
16
|
+
}
|
|
17
|
+
export function checkpointNextAuthoringDispatch(session, state) {
|
|
18
|
+
const request = session.requests?.at(-1);
|
|
19
|
+
if (session.modelBinding.provider !== "google_interactions" &&
|
|
20
|
+
request !== undefined)
|
|
21
|
+
session.retryDispatch = {
|
|
22
|
+
requestId: request.requestId,
|
|
23
|
+
exchange: session.exchange + 1,
|
|
24
|
+
modelItemCount: session.modelItems.length,
|
|
25
|
+
state,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function failAuthoringDispatch(session, error, phase) {
|
|
29
|
+
const checkpoint = session.retryDispatch;
|
|
30
|
+
if (checkpoint === undefined)
|
|
31
|
+
return;
|
|
32
|
+
// Acceptance clears the previous checkpoint. During settlement only a
|
|
33
|
+
// completed tool receipt can create the next one, before its final save.
|
|
34
|
+
const settledNextRequest = phase === "settling" &&
|
|
35
|
+
session.pendingSettlement === null &&
|
|
36
|
+
checkpoint.state === "dispatching" &&
|
|
37
|
+
checkpoint.modelItemCount === session.modelItems.length;
|
|
38
|
+
checkpoint.state =
|
|
39
|
+
session.modelBinding.provider !== "google_interactions" &&
|
|
40
|
+
(phase === "preparing" || phase === "dispatched" || settledNextRequest) &&
|
|
41
|
+
!dispatchRequiresFreshContext(error, false)
|
|
42
|
+
? "retryable"
|
|
43
|
+
: "blocked";
|
|
44
|
+
}
|
|
45
|
+
export function recoverAuthoringDispatch(session) {
|
|
46
|
+
if (session.retryDispatch?.state === "dispatching")
|
|
47
|
+
session.retryDispatch.state =
|
|
48
|
+
session.modelBinding.provider === "google_interactions"
|
|
49
|
+
? "blocked"
|
|
50
|
+
: "retryable";
|
|
51
|
+
}
|
|
52
|
+
export function canRetryAuthoringDispatch(session) {
|
|
53
|
+
return (session.runStatus === "interrupted" &&
|
|
54
|
+
session.modelBinding.provider !== "google_interactions" &&
|
|
55
|
+
session.googleOutcomeUnknown !== true &&
|
|
56
|
+
session.pendingSettlement === null &&
|
|
57
|
+
session.retryDispatch?.state === "retryable" &&
|
|
58
|
+
validAuthoringNativeContinuation(session.modelItems, session.modelBinding.provider));
|
|
59
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prepareAuthoringDispatch, failAuthoringDispatch, } from "./AuthoringRetry.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { aggregateModelUsage, } from "../../protocol/modelUsage.js";
|
|
3
4
|
import { ModelHostCancelledError } from "../model/ModelHost.js";
|
|
@@ -76,38 +77,51 @@ export class ModelToolConversation {
|
|
|
76
77
|
}
|
|
77
78
|
async #execute(input, live) {
|
|
78
79
|
const { session } = input;
|
|
80
|
+
let phase = "settling";
|
|
79
81
|
try {
|
|
80
82
|
for (let round = 0; round < maximumExchangesPerMessage; round += 1) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
let request = authoringExchange(session, input.exchangeRequestId ?? live.requestId, input.worldRevisionBoundaries);
|
|
84
|
+
if (input.retrySupported)
|
|
85
|
+
request = prepareAuthoringDispatch(session, request);
|
|
86
|
+
session.exchange = Math.max(session.exchange, request.exchange ?? session.exchange + 1);
|
|
83
87
|
if (session.modelBinding.provider === "google_interactions")
|
|
84
88
|
session.googleOutcomeUnknown = true;
|
|
85
89
|
live.streaming = emptyStreaming();
|
|
86
90
|
session.updatedAt = Date.now();
|
|
91
|
+
phase = "preparing";
|
|
87
92
|
await input.save(session);
|
|
88
93
|
input.changed?.(true);
|
|
94
|
+
phase = "dispatched";
|
|
89
95
|
const response = await input.host.exchange(request, {
|
|
90
96
|
signal: live.controller.signal,
|
|
91
97
|
onGoogleHop: (hop) => {
|
|
92
|
-
const
|
|
98
|
+
const requestId = input.exchangeRequestId ?? live.requestId;
|
|
99
|
+
const snapshot = session.requests?.findLast((entry) => entry.requestId === requestId);
|
|
93
100
|
if (snapshot?.wireRequest === undefined)
|
|
94
101
|
return;
|
|
95
102
|
snapshot.wireRequest = withGoogleHop(snapshot.wireRequest, hop);
|
|
96
|
-
return input.recordGoogleHop?.(
|
|
103
|
+
return input.recordGoogleHop?.(requestId, hop);
|
|
97
104
|
},
|
|
98
105
|
onDelta: (delta) => {
|
|
99
106
|
recordDelta(live, delta);
|
|
100
107
|
input.changed?.(false);
|
|
101
108
|
},
|
|
102
109
|
});
|
|
110
|
+
if (live.controller.signal.aborted)
|
|
111
|
+
throw new ModelHostCancelledError("The authoring request was cancelled");
|
|
112
|
+
phase = "validating";
|
|
103
113
|
assertCompleteResponse(response);
|
|
104
114
|
const toolCalls = response.toolCalls ?? [];
|
|
105
115
|
assertUniqueToolCallIds(toolCalls);
|
|
106
116
|
const visibleText = response.text ?? "";
|
|
107
|
-
|
|
117
|
+
session.usage = aggregateModelUsage(session.usage, response.usage);
|
|
118
|
+
if (toolCalls.length === 0 && visibleText.trim().length === 0) {
|
|
119
|
+
phase = "dispatched";
|
|
108
120
|
throw new Error(input.failure?.emptyResponse ??
|
|
109
121
|
"The model returned an empty tool-free authoring response");
|
|
110
|
-
|
|
122
|
+
}
|
|
123
|
+
delete session.retryDispatch;
|
|
124
|
+
phase = "settling";
|
|
111
125
|
session.modelItems.push({
|
|
112
126
|
kind: "assistant",
|
|
113
127
|
text: visibleText,
|
|
@@ -140,6 +154,7 @@ export class ModelToolConversation {
|
|
|
140
154
|
}
|
|
141
155
|
// The exchange budget only bounds a runaway loop. Every tool response is
|
|
142
156
|
// already settled, so the conversation pauses and can resume unchanged.
|
|
157
|
+
delete session.retryDispatch;
|
|
143
158
|
session.runStatus = "paused";
|
|
144
159
|
session.activeRequestId = null;
|
|
145
160
|
session.completedRequestIds = rememberRequest(session.completedRequestIds, live.requestId);
|
|
@@ -149,6 +164,7 @@ export class ModelToolConversation {
|
|
|
149
164
|
return input.view(session);
|
|
150
165
|
}
|
|
151
166
|
catch (error) {
|
|
167
|
+
failAuthoringDispatch(session, error, phase);
|
|
152
168
|
session.runStatus = "interrupted";
|
|
153
169
|
session.activeRequestId = null;
|
|
154
170
|
session.completedRequestIds = rememberRequest(session.completedRequestIds, live.requestId);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isAbsolute, win32 } from "node:path";
|
|
2
2
|
import { inflateRawSync } from "node:zlib";
|
|
3
|
+
import { FileNativeSettingImprovementStore } from "../setting/FileNativeSettingImprovementStore.js";
|
|
3
4
|
import { portableContentTreePathKey } from "../../protocol/contentTree.js";
|
|
4
5
|
import { fictionGameplay, gameplayFile, parseGameplay, } from "../../protocol/gameplay.js";
|
|
5
6
|
import { defaultAppLocale, } from "../../protocol/appPreferences.js";
|
|
@@ -36,6 +37,10 @@ export class ContentWorkspace {
|
|
|
36
37
|
limits: this.#limits,
|
|
37
38
|
...options.currentTree,
|
|
38
39
|
});
|
|
40
|
+
const recovery = new FileNativeSettingImprovementStore(dataRoot, {
|
|
41
|
+
content: this,
|
|
42
|
+
});
|
|
43
|
+
this.#currentTreeLibrary.setBeforeLease((id, lease) => recovery.recoverPendingEdits(id, lease));
|
|
39
44
|
}
|
|
40
45
|
createCurrentTreeContentPackage(gameplay = fictionGameplay) {
|
|
41
46
|
const locale = this.#locale();
|
|
@@ -64,8 +69,8 @@ export class ContentWorkspace {
|
|
|
64
69
|
readCurrentTreeContentPackage(localId) {
|
|
65
70
|
return this.#currentTreeLibrary.readPackage(localId);
|
|
66
71
|
}
|
|
67
|
-
beginCurrentTreeContentPackageOperation(localId) {
|
|
68
|
-
return this.#currentTreeLibrary.acquireOperationLease(localId);
|
|
72
|
+
beginCurrentTreeContentPackageOperation(localId, options = {}) {
|
|
73
|
+
return this.#currentTreeLibrary.acquireOperationLease(localId, options);
|
|
69
74
|
}
|
|
70
75
|
inspectCurrentTreeContentPackage(files) {
|
|
71
76
|
return inspectContentPackageCurrentTree(files);
|
|
@@ -61,6 +61,10 @@ export class CurrentTreeContentLibrary {
|
|
|
61
61
|
#beforeCurrentTreeReplace;
|
|
62
62
|
#mutationTail = Promise.resolve();
|
|
63
63
|
#legacyLocksRemoval;
|
|
64
|
+
#beforeLease;
|
|
65
|
+
setBeforeLease(hook) {
|
|
66
|
+
this.#beforeLease = hook;
|
|
67
|
+
}
|
|
64
68
|
constructor(dataRoot, options = {}) {
|
|
65
69
|
this.#legacyLocksRoot = join(dataRoot, "content", ".package-locks");
|
|
66
70
|
this.#packagesRoot = join(dataRoot, "content", "packages");
|
|
@@ -162,9 +166,6 @@ export class CurrentTreeContentLibrary {
|
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
168
|
async readPackage(localId) {
|
|
165
|
-
const snapshot = await this.#readPackageSnapshot(localId);
|
|
166
|
-
if (snapshot.metadata.schemaVersion === 2)
|
|
167
|
-
return snapshot.package;
|
|
168
169
|
const lease = await this.acquireOperationLease(localId, { wait: true });
|
|
169
170
|
try {
|
|
170
171
|
await ensureStoredPackageTitle(this.#packageRoot(localId), localId, lease.package.title);
|
|
@@ -221,7 +222,7 @@ export class CurrentTreeContentLibrary {
|
|
|
221
222
|
localId,
|
|
222
223
|
title: normalizedTitle,
|
|
223
224
|
});
|
|
224
|
-
return packageSummary(
|
|
225
|
+
return packageSummary({ ...lease.package, title: normalizedTitle });
|
|
225
226
|
}
|
|
226
227
|
finally {
|
|
227
228
|
lease.release();
|
|
@@ -283,19 +284,24 @@ export class CurrentTreeContentLibrary {
|
|
|
283
284
|
releaseInProcessPackageLease(packageRoot);
|
|
284
285
|
};
|
|
285
286
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
package
|
|
287
|
+
let package_ = (await this.#readPackageSnapshot(localId)).package;
|
|
288
|
+
const lease = {
|
|
289
|
+
get package() {
|
|
290
|
+
return structuredClone(package_);
|
|
291
|
+
},
|
|
289
292
|
replace: async (files, options = {}) => {
|
|
290
293
|
if (released)
|
|
291
294
|
throw new CurrentTreeContentPackageBusyError();
|
|
292
295
|
options.signal?.throwIfAborted();
|
|
293
296
|
const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
|
|
294
297
|
assertGameplayModeUnchanged(package_.files, normalized);
|
|
295
|
-
|
|
298
|
+
package_ = await this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
|
|
299
|
+
return structuredClone(package_);
|
|
296
300
|
},
|
|
297
301
|
release,
|
|
298
302
|
};
|
|
303
|
+
await this.#beforeLease?.(localId, lease);
|
|
304
|
+
return lease;
|
|
299
305
|
}
|
|
300
306
|
catch (error) {
|
|
301
307
|
release();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ModelHostBindingMismatchError, ModelHostContinuationError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "./ModelHost.js";
|
|
2
|
+
/** Protocol facts only; each lifecycle decides which failures permit replay. */
|
|
3
|
+
export function dispatchRequiresFreshContext(error, googleDispatched) {
|
|
4
|
+
return (error instanceof ModelHostBindingMismatchError ||
|
|
5
|
+
error instanceof ModelHostContinuationError ||
|
|
6
|
+
(googleDispatched && error instanceof ModelHostOutcomeUnknownError) ||
|
|
7
|
+
modelHostFailureRequiresFreshContext(error));
|
|
8
|
+
}
|
|
@@ -1652,6 +1652,23 @@ function providerText(content) {
|
|
|
1652
1652
|
}
|
|
1653
1653
|
return JSON.stringify(content) ?? "";
|
|
1654
1654
|
}
|
|
1655
|
+
/** Uses the same native payload checks as the production request encoders. */
|
|
1656
|
+
export function validAuthoringNativeContinuation(items, provider) {
|
|
1657
|
+
return items.every((item) => {
|
|
1658
|
+
if (item.kind !== "assistant")
|
|
1659
|
+
return true;
|
|
1660
|
+
const state = item.providerState;
|
|
1661
|
+
if (state?.protocol !== provider)
|
|
1662
|
+
return false;
|
|
1663
|
+
if (state.protocol === "chat_completions")
|
|
1664
|
+
return validChatAssistantMessage(state.assistantMessage);
|
|
1665
|
+
if (state.protocol === "openai_responses")
|
|
1666
|
+
return validResponsesOutput(state.output);
|
|
1667
|
+
if (state.protocol === "anthropic_messages")
|
|
1668
|
+
return validAnthropicContent(state.content);
|
|
1669
|
+
return validGoogleInteractionsContinuation(state.steps);
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1655
1672
|
function validChatAssistantMessage(value) {
|
|
1656
1673
|
if (!isRecord(value) || value.role !== "assistant")
|
|
1657
1674
|
return false;
|
|
@@ -749,6 +749,55 @@ export class FileNativePlayTimelineStore {
|
|
|
749
749
|
indexValue.chainId !== chainId ||
|
|
750
750
|
stateValue.chainId !== chainId)
|
|
751
751
|
throw new Error("Play context durable identity is inconsistent");
|
|
752
|
+
// Older releases disallowed retry after a player cancellation. The tail
|
|
753
|
+
// cancellation and interrupted assistant prove this was a dispatch ending,
|
|
754
|
+
// not a settlement failure. Validate the frozen request before exposing it.
|
|
755
|
+
if (!indexValue.importedFromArchive &&
|
|
756
|
+
stateValue.status === "interrupted" &&
|
|
757
|
+
!stateValue.canRetry &&
|
|
758
|
+
stateValue.eventCount >= 2) {
|
|
759
|
+
try {
|
|
760
|
+
const [tail, previous] = await Promise.all([
|
|
761
|
+
readJson(join(root, "events", numbered(stateValue.eventCount))),
|
|
762
|
+
readJson(join(root, "events", numbered(stateValue.eventCount - 1))),
|
|
763
|
+
]);
|
|
764
|
+
assertPlayEvent(tail);
|
|
765
|
+
assertPlayEvent(previous);
|
|
766
|
+
if (tail.kind === "cancellation" &&
|
|
767
|
+
previous.kind === "assistant" &&
|
|
768
|
+
previous.status === "interrupted") {
|
|
769
|
+
const [base, continuation] = await Promise.all([
|
|
770
|
+
readJson(join(root, "base.json")),
|
|
771
|
+
readJson(join(root, "continuations", `${stateValue.continuationDigest}.json`)),
|
|
772
|
+
]);
|
|
773
|
+
assertContextBase(base);
|
|
774
|
+
assertContextContinuation(continuation);
|
|
775
|
+
if (base.worldId === worldId &&
|
|
776
|
+
base.chainId === chainId &&
|
|
777
|
+
continuation.chainId === chainId &&
|
|
778
|
+
continuation.lastRequestDigest !== null &&
|
|
779
|
+
createHash("sha256")
|
|
780
|
+
.update(JSON.stringify(continuation))
|
|
781
|
+
.digest("hex") === stateValue.continuationDigest) {
|
|
782
|
+
const request = await readJson(join(root, "requests", `${continuation.lastRequestDigest}.json`));
|
|
783
|
+
const provider = request.modelConfig?.provider ?? base.modelBinding?.provider;
|
|
784
|
+
if (provider !== undefined &&
|
|
785
|
+
provider !== "google_interactions" &&
|
|
786
|
+
Array.isArray(request.appended) &&
|
|
787
|
+
// The legacy record carries no codec-validation receipt. Only
|
|
788
|
+
// requests without native assistant fragments can be proven safe.
|
|
789
|
+
request.appended.every((item) => item.kind !== "assistant") &&
|
|
790
|
+
createHash("sha256")
|
|
791
|
+
.update(JSON.stringify(request))
|
|
792
|
+
.digest("hex") === continuation.lastRequestDigest)
|
|
793
|
+
stateValue.canRetry = true;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
// Missing or damaged legacy evidence never grants retry or breaks metadata reads.
|
|
799
|
+
}
|
|
800
|
+
}
|
|
752
801
|
// Old versions persisted a configuration preflight failure as a terminal
|
|
753
802
|
// context failure. Recover only the disposition proven by its event prefix.
|
|
754
803
|
// Archive contexts and genuine provider failures retain their restrictions.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { dispatchRequiresFreshContext } from "../model/DispatchFailure.js";
|
|
2
|
+
import { ModelHostCancelledError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "../model/ModelHost.js";
|
|
2
3
|
import { classifyPlayFailure } from "./PlayFailure.js";
|
|
3
4
|
const messages = {
|
|
4
5
|
playerCommitted: "The player message is committed; the frozen model request is ready to continue.",
|
|
@@ -97,19 +98,21 @@ export class PlayAdvanceLifecycle {
|
|
|
97
98
|
}
|
|
98
99
|
/**
|
|
99
100
|
* The ending of an exchange that raised an error before its response was
|
|
100
|
-
* settled.
|
|
101
|
-
* a retry, even though `ModelHostCancelledError` is also an outcome-unknown
|
|
102
|
-
* error (ADR-0030).
|
|
101
|
+
* settled. Google dispatched outcomes remain unknown and cannot be replayed.
|
|
103
102
|
*/
|
|
104
|
-
export function dispatchFailureEnding(error, streamingEventId) {
|
|
103
|
+
export function dispatchFailureEnding(error, streamingEventId, googleDispatched = false) {
|
|
105
104
|
const message = errorMessage(error, messages.dispatchInterrupted);
|
|
106
105
|
if (error instanceof ModelHostCancelledError)
|
|
107
|
-
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
return {
|
|
107
|
+
kind: "cancelled_after_dispatch",
|
|
108
|
+
message,
|
|
109
|
+
canRetry: !googleDispatched,
|
|
110
|
+
streamingEventId,
|
|
111
|
+
};
|
|
112
|
+
const requiresFreshContext = dispatchRequiresFreshContext(error, googleDispatched);
|
|
113
|
+
const canRetry = !requiresFreshContext &&
|
|
114
|
+
(error instanceof ModelHostFailureError ||
|
|
115
|
+
error instanceof ModelHostOutcomeUnknownError);
|
|
113
116
|
return {
|
|
114
117
|
kind: "failed",
|
|
115
118
|
message,
|
|
@@ -141,12 +144,13 @@ export function noContinuationEnding() {
|
|
|
141
144
|
};
|
|
142
145
|
}
|
|
143
146
|
/** A settled response with neither text nor a complete tool call. */
|
|
144
|
-
export function emptyResponseEnding() {
|
|
147
|
+
export function emptyResponseEnding(canRetry = false, streamingEventId) {
|
|
145
148
|
return {
|
|
146
149
|
kind: "failed",
|
|
147
150
|
message: messages.noOutput,
|
|
148
151
|
failureKind: "other",
|
|
149
|
-
canRetry
|
|
152
|
+
canRetry,
|
|
153
|
+
streamingEventId,
|
|
150
154
|
};
|
|
151
155
|
}
|
|
152
156
|
/**
|
|
@@ -199,7 +203,7 @@ function applyEnding(session, ending) {
|
|
|
199
203
|
case "cancelled_after_dispatch":
|
|
200
204
|
interruptStreamingEvent(session, ending.streamingEventId);
|
|
201
205
|
session.status = "interrupted";
|
|
202
|
-
session.canRetry =
|
|
206
|
+
session.canRetry = ending.canRetry;
|
|
203
207
|
session.lastFailure = ending.message;
|
|
204
208
|
delete session.lastFailureKind;
|
|
205
209
|
session.events.push({
|
|
@@ -1281,14 +1281,14 @@ export class PlayCallChain {
|
|
|
1281
1281
|
calls.some((call) => call.id === event.callId)))
|
|
1282
1282
|
throw new ModelHostOutcomeUnknownError("Google Interactions repeated a tool-call ID during the same send; its outcome cannot be safely settled. Use a fresh context.");
|
|
1283
1283
|
}
|
|
1284
|
-
const text = durable.text ??
|
|
1284
|
+
const text = durable.text ?? "";
|
|
1285
1285
|
const hasText = text.trim().length > 0;
|
|
1286
1286
|
const responseKind = calls.length > 0 ? "tool_step" : hasText ? "narrative" : "empty";
|
|
1287
1287
|
const narrative = responseKind === "narrative";
|
|
1288
1288
|
const assistantEvent = {
|
|
1289
1289
|
id: advance.eventId,
|
|
1290
1290
|
kind: "assistant",
|
|
1291
|
-
text,
|
|
1291
|
+
text: responseKind === "empty" ? existing.text : text,
|
|
1292
1292
|
status: "completed",
|
|
1293
1293
|
responseKind,
|
|
1294
1294
|
exchange: advance.exchange,
|
|
@@ -1306,6 +1306,15 @@ export class PlayCallChain {
|
|
|
1306
1306
|
: { stopReason: durable.stopReason }),
|
|
1307
1307
|
continuation: durable.providerState === undefined ? "unavailable" : "available",
|
|
1308
1308
|
};
|
|
1309
|
+
// No response has been settled or added to the transcript. Non-Google
|
|
1310
|
+
// providers may explicitly replay the frozen request without duplicating work.
|
|
1311
|
+
if (responseKind === "empty" &&
|
|
1312
|
+
(session.lastRequest?.modelConfig?.provider ??
|
|
1313
|
+
session.modelBinding?.provider) !== "google_interactions") {
|
|
1314
|
+
session.events = session.events.map((event) => event.id === advance.eventId ? assistantEvent : event);
|
|
1315
|
+
await this.#advances.end(session, emptyResponseEnding(true, advance.eventId), advance);
|
|
1316
|
+
return { kind: "terminal", status: "interrupted", narrative: false };
|
|
1317
|
+
}
|
|
1309
1318
|
const workingTools = new Map(session.completedToolMap);
|
|
1310
1319
|
const prepared = [];
|
|
1311
1320
|
const trailingEvents = [];
|
|
@@ -1615,7 +1624,7 @@ export class PlayCallChain {
|
|
|
1615
1624
|
},
|
|
1616
1625
|
});
|
|
1617
1626
|
if (signal.aborted)
|
|
1618
|
-
throw new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed
|
|
1627
|
+
throw new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed.");
|
|
1619
1628
|
this.#setInvocationAbortable(session.chainId, false);
|
|
1620
1629
|
if ((response.toolCalls?.length ?? 0) > 0)
|
|
1621
1630
|
this.#observeInvocation(session.chainId, "tool", {
|
|
@@ -1625,7 +1634,10 @@ export class PlayCallChain {
|
|
|
1625
1634
|
await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
|
|
1626
1635
|
}
|
|
1627
1636
|
catch (error) {
|
|
1628
|
-
await this.#advances.end(session, dispatchFailureEnding(
|
|
1637
|
+
await this.#advances.end(session, dispatchFailureEnding(signal.aborted && !(error instanceof ModelHostCancelledError)
|
|
1638
|
+
? new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed.")
|
|
1639
|
+
: error, event.id, (request.modelConfig?.provider ??
|
|
1640
|
+
session.modelBinding?.provider) === "google_interactions"), advance, () => this.#notifySnapshot(observer, session));
|
|
1629
1641
|
return projectView(session);
|
|
1630
1642
|
}
|
|
1631
1643
|
try {
|