narraeon 0.7.2 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/node/protocol/v1.js +8 -2
- package/dist/node/runtime/V1Runtime.js +12 -3
- package/dist/node/runtime/archive/WorldArchives.js +17 -5
- package/dist/node/runtime/content/ContentWorkspace.js +2 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +98 -165
- package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
- package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +104 -21
- package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
- package/dist/node/runtime/play/PlayCallChain.js +200 -374
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +312 -167
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +151 -108
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
- package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
- package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
- package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
- package/dist/node/server/createServer.js +44 -20
- package/dist/web/assets/index-9N9kO8kY.js +1408 -0
- package/dist/web/assets/index-BjxBYYQi.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BUCslLEK.css +0 -1
- package/dist/web/assets/index-DkamBH6c.js +0 -1408
|
@@ -8,6 +8,8 @@ import { renderPromptDeltaMessage, } from "../prompt/FileNativePromptCompiler.js
|
|
|
8
8
|
import { aggregateAnthropicModelStream, aggregateChatModelStream, aggregateResponsesModelStream, } from "./ModelHostStream.js";
|
|
9
9
|
import { defaultRuntimeToolDefinitionStrategy, portableRuntimeToolInputSchema, } from "../prompt/FileNativeToolRegistry.js";
|
|
10
10
|
import { AiExchangeCapture, errorDescription, } from "./AiFailureLog.js";
|
|
11
|
+
/** Anthropic allows four cache_control breakpoints per request. */
|
|
12
|
+
const explicitCacheBreakpointLimit = 4;
|
|
11
13
|
const providerContinuationCodecVersion = 1;
|
|
12
14
|
function resolveConnection(connection) {
|
|
13
15
|
const resolved = structuredClone({
|
|
@@ -17,6 +19,7 @@ function resolveConnection(connection) {
|
|
|
17
19
|
reasoningSummary: connection.reasoningSummary ?? "provider_default",
|
|
18
20
|
thinkingMode: connection.thinkingMode ?? "provider_default",
|
|
19
21
|
thinkingBudgetTokens: connection.thinkingBudgetTokens ?? null,
|
|
22
|
+
cacheSuffixBreakpoint: connection.cacheSuffixBreakpoint ?? true,
|
|
20
23
|
});
|
|
21
24
|
const issue = modelReasoningPolicyIssue({
|
|
22
25
|
provider: resolved.provider,
|
|
@@ -95,6 +98,8 @@ export class FileNativeModelHost {
|
|
|
95
98
|
throw new ModelHostBindingMismatchError("The request protocol does not match the selected model host");
|
|
96
99
|
return new _a({
|
|
97
100
|
...request.modelConfig,
|
|
101
|
+
// A frozen request without this choice predates the transcript breakpoint.
|
|
102
|
+
cacheSuffixBreakpoint: request.modelConfig.cacheSuffixBreakpoint === true,
|
|
98
103
|
baseUrl: this.#connection.baseUrl,
|
|
99
104
|
apiKey: this.#connection.apiKey,
|
|
100
105
|
}, this.#fetch, this.#failureLog);
|
|
@@ -458,19 +463,16 @@ async function trace(kind, value) {
|
|
|
458
463
|
// Explicit acceptance diagnostics must never change provider semantics.
|
|
459
464
|
}
|
|
460
465
|
}
|
|
461
|
-
function items(request) {
|
|
462
|
-
return request.appended;
|
|
463
|
-
}
|
|
464
466
|
function chatRequestBody(connection, request) {
|
|
465
467
|
const tools = providerToolDefinitions(request);
|
|
466
468
|
const allowedTools = providerAllowedTools(request);
|
|
467
469
|
const hasPolicy = hasFrozenToolPolicy(request);
|
|
468
|
-
return {
|
|
470
|
+
return checkedCacheBreakpoints({
|
|
469
471
|
model: connection.modelId,
|
|
470
472
|
...chatReasoningRequest(connection),
|
|
471
473
|
messages: [
|
|
472
474
|
...chatBootstrapMessages(connection, request),
|
|
473
|
-
...chatAppend(request),
|
|
475
|
+
...chatAppend(connection, request),
|
|
474
476
|
],
|
|
475
477
|
...(connection.dialect === "cliproxyapi"
|
|
476
478
|
? { prompt_cache_key: promptCacheKey(connection, request) }
|
|
@@ -495,17 +497,17 @@ function chatRequestBody(connection, request) {
|
|
|
495
497
|
max_tokens: request.maxOutputTokens,
|
|
496
498
|
stream: true,
|
|
497
499
|
stream_options: { include_usage: true },
|
|
498
|
-
};
|
|
500
|
+
});
|
|
499
501
|
}
|
|
500
502
|
function anthropicRequestBody(connection, request) {
|
|
501
503
|
const tools = providerToolDefinitions(request);
|
|
502
|
-
return {
|
|
504
|
+
return checkedCacheBreakpoints({
|
|
503
505
|
model: connection.modelId,
|
|
504
506
|
...anthropicReasoningRequest(connection),
|
|
505
507
|
system: request.bootstrap.provider.system,
|
|
506
508
|
messages: [
|
|
507
509
|
...anthropicBootstrapMessages(request),
|
|
508
|
-
...anthropicAppend(request),
|
|
510
|
+
...anthropicAppend(connection, request),
|
|
509
511
|
],
|
|
510
512
|
...(tools.length === 0
|
|
511
513
|
? {}
|
|
@@ -522,14 +524,14 @@ function anthropicRequestBody(connection, request) {
|
|
|
522
524
|
: {}),
|
|
523
525
|
max_tokens: request.maxOutputTokens,
|
|
524
526
|
stream: true,
|
|
525
|
-
};
|
|
527
|
+
});
|
|
526
528
|
}
|
|
527
529
|
function responsesRequestInput(connection, request) {
|
|
528
530
|
return {
|
|
529
531
|
connection,
|
|
530
532
|
input: [
|
|
531
533
|
...responsesBootstrapMessages(connection, request),
|
|
532
|
-
...responsesAppend(request),
|
|
534
|
+
...responsesAppend(connection, request),
|
|
533
535
|
],
|
|
534
536
|
tools: providerToolDefinitions(request).map((tool) => ({
|
|
535
537
|
name: tool.name,
|
|
@@ -555,7 +557,7 @@ function responsesRequestBody(input) {
|
|
|
555
557
|
tools: allowedTools.map((name) => ({ type: "function", name })),
|
|
556
558
|
}
|
|
557
559
|
: "auto";
|
|
558
|
-
return {
|
|
560
|
+
return checkedCacheBreakpoints({
|
|
559
561
|
model: input.connection.modelId,
|
|
560
562
|
...responsesReasoningRequest(resolveConnection(input.connection)),
|
|
561
563
|
input: input.input,
|
|
@@ -582,7 +584,7 @@ function responsesRequestBody(input) {
|
|
|
582
584
|
store: false,
|
|
583
585
|
include: ["reasoning.encrypted_content"],
|
|
584
586
|
stream: true,
|
|
585
|
-
};
|
|
587
|
+
});
|
|
586
588
|
}
|
|
587
589
|
/**
|
|
588
590
|
* A frozen policy is present when the caller supplies the logical operation's
|
|
@@ -758,8 +760,88 @@ function promptCacheKey(connection, request) {
|
|
|
758
760
|
.update(`${connection.provider}\0${connection.dialect}\0${connection.modelId}\0${scope}`)
|
|
759
761
|
.digest("hex");
|
|
760
762
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
+
/**
|
|
764
|
+
* Explicit-breakpoint encodings mark the last transcript item before the
|
|
765
|
+
* request-level suffix, so the next dispatch reads back the transcript it sent.
|
|
766
|
+
* A request without a suffix keeps its request end as the transcript's entry,
|
|
767
|
+
* so it is encoded exactly as before.
|
|
768
|
+
*/
|
|
769
|
+
function appendWithSuffixBreakpoint(connection, request, encode, markLast) {
|
|
770
|
+
const { transcript, suffix } = splitRequestSuffix(request.appended);
|
|
771
|
+
const encoded = encode(transcript);
|
|
772
|
+
const breakpoint = suffix.length > 0 &&
|
|
773
|
+
encoded.length > 0 &&
|
|
774
|
+
connection.cacheSuffixBreakpoint &&
|
|
775
|
+
modelCacheStrategy(connection) !== "provider_managed";
|
|
776
|
+
return [...(breakpoint ? markLast(encoded) : encoded), ...encode(suffix)];
|
|
777
|
+
}
|
|
778
|
+
/** Request-level suffix items are recompiled for every dispatch and never stored. */
|
|
779
|
+
function splitRequestSuffix(appended) {
|
|
780
|
+
let end = appended.length;
|
|
781
|
+
while (end > 0 && isRequestSuffix(appended[end - 1]))
|
|
782
|
+
end -= 1;
|
|
783
|
+
return { transcript: appended.slice(0, end), suffix: appended.slice(end) };
|
|
784
|
+
}
|
|
785
|
+
function isRequestSuffix(item) {
|
|
786
|
+
return item.kind === "runtime_notice" && item.notice === "turn_guide";
|
|
787
|
+
}
|
|
788
|
+
/** Message-level cache_control, the form the CLIProxyAPI prefix breakpoints use. */
|
|
789
|
+
function markLastMessage(messages) {
|
|
790
|
+
const last = messages.at(-1);
|
|
791
|
+
if (!isRecord(last))
|
|
792
|
+
throw new Error("The transcript tail cannot carry a cache breakpoint.");
|
|
793
|
+
return [
|
|
794
|
+
...messages.slice(0, -1),
|
|
795
|
+
{ ...last, cache_control: { type: "ephemeral" } },
|
|
796
|
+
];
|
|
797
|
+
}
|
|
798
|
+
/** Thinking blocks reject cache_control, so the breakpoint lands on the last block that accepts it. */
|
|
799
|
+
function markLastAnthropicBlock(messages) {
|
|
800
|
+
const last = messages.at(-1);
|
|
801
|
+
if (!isRecord(last))
|
|
802
|
+
throw new Error("The transcript tail cannot carry a cache breakpoint.");
|
|
803
|
+
const content = typeof last.content === "string"
|
|
804
|
+
? [{ type: "text", text: last.content }]
|
|
805
|
+
: Array.isArray(last.content)
|
|
806
|
+
? last.content
|
|
807
|
+
: [];
|
|
808
|
+
const index = content.findLastIndex((block) => isRecord(block) &&
|
|
809
|
+
block.type !== "thinking" &&
|
|
810
|
+
block.type !== "redacted_thinking");
|
|
811
|
+
if (index < 0)
|
|
812
|
+
throw new Error("The Anthropic transcript tail has no block that can carry a cache breakpoint.");
|
|
813
|
+
return [
|
|
814
|
+
...messages.slice(0, -1),
|
|
815
|
+
{
|
|
816
|
+
...last,
|
|
817
|
+
content: content.map((block, position) => position === index
|
|
818
|
+
? {
|
|
819
|
+
...block,
|
|
820
|
+
cache_control: { type: "ephemeral" },
|
|
821
|
+
}
|
|
822
|
+
: block),
|
|
823
|
+
},
|
|
824
|
+
];
|
|
825
|
+
}
|
|
826
|
+
/** Refuses a request over the breakpoint limit instead of silently dropping one. */
|
|
827
|
+
function checkedCacheBreakpoints(body) {
|
|
828
|
+
const count = countCacheBreakpoints(body);
|
|
829
|
+
if (count > explicitCacheBreakpointLimit)
|
|
830
|
+
throw new Error(`The request has ${count} cache breakpoints; the Anthropic limit is ${explicitCacheBreakpointLimit}.`);
|
|
831
|
+
return body;
|
|
832
|
+
}
|
|
833
|
+
function countCacheBreakpoints(value) {
|
|
834
|
+
if (Array.isArray(value))
|
|
835
|
+
return value.reduce((total, item) => total + countCacheBreakpoints(item), 0);
|
|
836
|
+
if (!isRecord(value))
|
|
837
|
+
return 0;
|
|
838
|
+
return Object.entries(value).reduce((total, [key, item]) => total + (key === "cache_control" ? 1 : 0) + countCacheBreakpoints(item), 0);
|
|
839
|
+
}
|
|
840
|
+
function chatAppend(connection, request) {
|
|
841
|
+
return appendWithSuffixBreakpoint(connection, request, chatItems, markLastMessage);
|
|
842
|
+
}
|
|
843
|
+
function chatItems(items) {
|
|
844
|
+
return items.flatMap((item) => {
|
|
763
845
|
if (item.kind === "player" ||
|
|
764
846
|
item.kind === "user" ||
|
|
765
847
|
item.kind === "runtime_notice")
|
|
@@ -782,7 +864,10 @@ function chatAppend(request) {
|
|
|
782
864
|
throw continuationError("Chat Completions");
|
|
783
865
|
});
|
|
784
866
|
}
|
|
785
|
-
function anthropicAppend(request) {
|
|
867
|
+
function anthropicAppend(connection, request) {
|
|
868
|
+
return appendWithSuffixBreakpoint(connection, request, anthropicItems, markLastAnthropicBlock);
|
|
869
|
+
}
|
|
870
|
+
function anthropicItems(items) {
|
|
786
871
|
const messages = [];
|
|
787
872
|
let toolResults = [];
|
|
788
873
|
const flushToolResults = () => {
|
|
@@ -791,7 +876,7 @@ function anthropicAppend(request) {
|
|
|
791
876
|
messages.push({ role: "user", content: toolResults });
|
|
792
877
|
toolResults = [];
|
|
793
878
|
};
|
|
794
|
-
for (const item of items
|
|
879
|
+
for (const item of items) {
|
|
795
880
|
if (item.kind === "tool") {
|
|
796
881
|
toolResults.push({
|
|
797
882
|
type: "tool_result",
|
|
@@ -825,8 +910,11 @@ function anthropicAppend(request) {
|
|
|
825
910
|
flushToolResults();
|
|
826
911
|
return messages;
|
|
827
912
|
}
|
|
828
|
-
function responsesAppend(request) {
|
|
829
|
-
return
|
|
913
|
+
function responsesAppend(connection, request) {
|
|
914
|
+
return appendWithSuffixBreakpoint(connection, request, responsesItems, markLastMessage);
|
|
915
|
+
}
|
|
916
|
+
function responsesItems(items) {
|
|
917
|
+
return items.flatMap((item) => {
|
|
830
918
|
if (item.kind === "player" ||
|
|
831
919
|
item.kind === "user" ||
|
|
832
920
|
item.kind === "runtime_notice")
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
|
|
5
|
+
/** Written last by the released-record cleanup (ADR-0034); its absence means "not yet run". */
|
|
6
|
+
const LAYOUT_MARKER = "play-advance-layout.json";
|
|
6
7
|
/**
|
|
7
8
|
* The single in-progress play advance of a call chain, if any.
|
|
8
9
|
*
|
|
9
10
|
* It exists only between `begin` and `markSettled`. A marker that survives a
|
|
10
11
|
* restart means the advance was interrupted; the runtime marks the chain
|
|
11
|
-
* interrupted and the player retries.
|
|
12
|
-
*
|
|
12
|
+
* interrupted and the player retries. Released records from earlier layouts
|
|
13
|
+
* never block a world from opening, and the storage upgrade removes them
|
|
14
|
+
* (see `migrateLayout`).
|
|
13
15
|
*/
|
|
14
16
|
export class FileNativePlayAdvanceStore {
|
|
15
17
|
#worldsRoot;
|
|
16
18
|
constructor(dataRoot) {
|
|
17
19
|
this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
|
|
18
20
|
}
|
|
19
|
-
async begin(
|
|
20
|
-
assertBase(
|
|
21
|
-
const
|
|
21
|
+
async begin(advance) {
|
|
22
|
+
assertBase(advance);
|
|
23
|
+
const marker = persistedAdvance(advance);
|
|
24
|
+
const current = await this.readCurrent(marker.worldId, marker.chainId);
|
|
22
25
|
if (current !== null) {
|
|
23
|
-
if (!
|
|
26
|
+
if (!sameAdvance(current, marker))
|
|
24
27
|
throw new Error("Another play advance is still awaiting settlement");
|
|
25
28
|
return;
|
|
26
29
|
}
|
|
27
|
-
await publishJson(this.#markerPath(
|
|
30
|
+
await publishJson(this.#markerPath(marker.worldId, marker.chainId), marker);
|
|
28
31
|
}
|
|
29
32
|
async readCurrent(worldId, chainId) {
|
|
30
33
|
assertIdentity(worldId, "World ID");
|
|
@@ -37,13 +40,15 @@ export class FileNativePlayAdvanceStore {
|
|
|
37
40
|
throw new Error("Play advance marker belongs to another context");
|
|
38
41
|
return value;
|
|
39
42
|
}
|
|
40
|
-
async markSettled(
|
|
41
|
-
const current = await this.readCurrent(
|
|
43
|
+
async markSettled(advance) {
|
|
44
|
+
const current = await this.readCurrent(advance.worldId, advance.chainId);
|
|
42
45
|
if (current === null)
|
|
43
46
|
return;
|
|
44
|
-
if (!
|
|
47
|
+
if (!sameAdvance(current, advance))
|
|
45
48
|
throw new Error("Play advance is no longer current");
|
|
46
|
-
await rm(this.#markerPath(
|
|
49
|
+
await rm(this.#markerPath(advance.worldId, advance.chainId), {
|
|
50
|
+
force: true,
|
|
51
|
+
});
|
|
47
52
|
}
|
|
48
53
|
/** Remove the in-progress marker and all advance records of a discarded chain. */
|
|
49
54
|
async discardChain(worldId, chainId) {
|
|
@@ -52,16 +57,73 @@ export class FileNativePlayAdvanceStore {
|
|
|
52
57
|
force: true,
|
|
53
58
|
});
|
|
54
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether the world's advance records are free of the pre-ADR-0044 layout
|
|
62
|
+
* (ADR-0034). Reads never write: a world with no released records is current
|
|
63
|
+
* without a marker.
|
|
64
|
+
*/
|
|
65
|
+
async layoutCurrent(worldId) {
|
|
66
|
+
assertIdentity(worldId, "World ID");
|
|
67
|
+
if ((await readOptionalJson(this.#layoutMarkerPath(worldId))) !==
|
|
68
|
+
null)
|
|
69
|
+
return true;
|
|
70
|
+
return (await this.#releasedChainRoots(worldId)).length === 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Remove the released `records/` and `current.json` of every chain. Each
|
|
74
|
+
* chain's `in-progress.json` stays as it is, and a chain left empty is removed.
|
|
75
|
+
* The marker is written last, so an interrupted run simply runs again.
|
|
76
|
+
*/
|
|
77
|
+
async migrateLayout(worldId) {
|
|
78
|
+
assertIdentity(worldId, "World ID");
|
|
79
|
+
if (await this.layoutCurrent(worldId))
|
|
80
|
+
return;
|
|
81
|
+
for (const chainRoot of await this.#releasedChainRoots(worldId)) {
|
|
82
|
+
await rm(join(chainRoot, "records"), { recursive: true, force: true });
|
|
83
|
+
await rm(join(chainRoot, "current.json"), { force: true });
|
|
84
|
+
if ((await readdir(chainRoot)).length === 0)
|
|
85
|
+
await rm(chainRoot, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
await publishJson(this.#layoutMarkerPath(worldId), layoutMarker());
|
|
88
|
+
}
|
|
89
|
+
/** Chain directories that still hold a released record or pointer. */
|
|
90
|
+
async #releasedChainRoots(worldId) {
|
|
91
|
+
const advancesRoot = join(this.#worldsRoot, worldId, "runtime", "play-advances");
|
|
92
|
+
const roots = [];
|
|
93
|
+
for (const name of await readDirectoryNames(advancesRoot)) {
|
|
94
|
+
const chainRoot = join(advancesRoot, name);
|
|
95
|
+
if ((await pathExists(join(chainRoot, "records"))) ||
|
|
96
|
+
(await pathExists(join(chainRoot, "current.json"))))
|
|
97
|
+
roots.push(chainRoot);
|
|
98
|
+
}
|
|
99
|
+
return roots;
|
|
100
|
+
}
|
|
101
|
+
#layoutMarkerPath(worldId) {
|
|
102
|
+
return join(this.#worldsRoot, worldId, "runtime", LAYOUT_MARKER);
|
|
103
|
+
}
|
|
55
104
|
#markerPath(worldId, chainId) {
|
|
56
105
|
return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "in-progress.json");
|
|
57
106
|
}
|
|
58
107
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return
|
|
108
|
+
/**
|
|
109
|
+
* Whether two advances are the same one. The marker is confirmed by these
|
|
110
|
+
* identity fields alone; the frozen request is never compared (ADR-0044).
|
|
111
|
+
*/
|
|
112
|
+
function sameAdvance(left, right) {
|
|
113
|
+
return (left.advanceKind === right.advanceKind &&
|
|
114
|
+
left.worldId === right.worldId &&
|
|
115
|
+
left.chainId === right.chainId &&
|
|
116
|
+
left.advanceId === right.advanceId &&
|
|
117
|
+
left.operationId === right.operationId &&
|
|
118
|
+
left.eventId === right.eventId);
|
|
119
|
+
}
|
|
120
|
+
/** The marker form of an advance: a player advance's frozen request stays in memory. */
|
|
121
|
+
function persistedAdvance(advance) {
|
|
122
|
+
if (!("nextRequest" in advance))
|
|
123
|
+
return advance;
|
|
124
|
+
const { nextRequest: _nextRequest, ...marker } = advance;
|
|
125
|
+
void _nextRequest;
|
|
126
|
+
return marker;
|
|
65
127
|
}
|
|
66
128
|
function assertBase(value) {
|
|
67
129
|
if (!isRecord(value) ||
|
|
@@ -90,9 +152,10 @@ function assertBase(value) {
|
|
|
90
152
|
!Number.isSafeInteger(value.transcriptStart) ||
|
|
91
153
|
Number(value.transcriptStart) < 0 ||
|
|
92
154
|
!Number.isSafeInteger(value.exchange) ||
|
|
93
|
-
Number(value.exchange) < 1
|
|
94
|
-
!isRecord(value.nextRequest))
|
|
155
|
+
Number(value.exchange) < 1)
|
|
95
156
|
throw new Error("Player advance has an invalid shape");
|
|
157
|
+
// A marker released before ADR-0044 may still carry `nextRequest`. Recovery
|
|
158
|
+
// never reads it, so the field is tolerated and not validated.
|
|
96
159
|
}
|
|
97
160
|
else if (!Number.isSafeInteger(value.exchange) ||
|
|
98
161
|
Number(value.exchange) < 0 ||
|
|
@@ -120,6 +183,26 @@ async function readOptionalJson(path) {
|
|
|
120
183
|
throw error;
|
|
121
184
|
}
|
|
122
185
|
}
|
|
186
|
+
async function readDirectoryNames(directory) {
|
|
187
|
+
try {
|
|
188
|
+
return await readdir(directory);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
192
|
+
return [];
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function pathExists(path) {
|
|
197
|
+
return stat(path).then(() => true, (error) => {
|
|
198
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
199
|
+
return false;
|
|
200
|
+
throw error;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function layoutMarker() {
|
|
204
|
+
return { schemaVersion: 1, kind: "play_advance_layout" };
|
|
205
|
+
}
|
|
123
206
|
/** Atomic publish: write a temporary file, then rename over the target. */
|
|
124
207
|
async function publishJson(path, value) {
|
|
125
208
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
|
|
2
|
+
import { classifyPlayFailure } from "./PlayFailure.js";
|
|
3
|
+
const messages = {
|
|
4
|
+
playerCommitted: "The player message is committed; the frozen model request is ready to continue.",
|
|
5
|
+
toolStepSettled: "The completed tool response is settled; its frozen continuation request is ready.",
|
|
6
|
+
cancelledBeforeDispatch: "The player cancelled before the next Provider dispatch; the frozen request can be resumed without replaying a completed model result.",
|
|
7
|
+
followupsCancelled: "The player cancelled the remaining follow-up generation after the narrative was committed.",
|
|
8
|
+
noContinuation: "The complete model response has no provider-native continuation payload; use a fresh context.",
|
|
9
|
+
noOutput: "The model returned neither text nor a complete tool call.",
|
|
10
|
+
serviceStoppedAfterHead: "The service stopped after the world was updated; use a fresh context.",
|
|
11
|
+
serviceStoppedSavingPlayer: "The service stopped while saving the player message; use a fresh context.",
|
|
12
|
+
serviceStoppedRetryable: "The service stopped while the model request was in progress; retry to call the model again.",
|
|
13
|
+
serviceStoppedNoRequest: "The service stopped before preparing a model request; use a fresh context.",
|
|
14
|
+
dispatchInterrupted: "The model request was interrupted.",
|
|
15
|
+
};
|
|
16
|
+
export class PlayAdvanceLifecycle {
|
|
17
|
+
#ports;
|
|
18
|
+
constructor(ports) {
|
|
19
|
+
this.#ports = ports;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Marks a Provider dispatch as in progress. `running` is not an ending: it is
|
|
23
|
+
* the state that every dispatch replaces with an ending before it returns.
|
|
24
|
+
*/
|
|
25
|
+
beginDispatch(session, request, attempt) {
|
|
26
|
+
session.status = "running";
|
|
27
|
+
session.canRetry = false;
|
|
28
|
+
session.lastFailure = null;
|
|
29
|
+
delete session.lastFailureKind;
|
|
30
|
+
session.lastRequest = structuredClone(request);
|
|
31
|
+
session.lastRequestAttempt = attempt;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Applies an ending, persists the session, then clears the advance marker
|
|
35
|
+
* and notifies. Every ending of an advance goes through here, so no path
|
|
36
|
+
* can persist without clearing the marker it settles.
|
|
37
|
+
*/
|
|
38
|
+
async end(session, ending, advance, notify) {
|
|
39
|
+
applyEnding(session, ending);
|
|
40
|
+
await this.#ports.persist(session);
|
|
41
|
+
if (advance !== null)
|
|
42
|
+
await this.#ports.clearMarker(advance);
|
|
43
|
+
notify?.();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ends an advance that a process exit cut off (ADR-0044). Nothing is
|
|
47
|
+
* replayed: the exchange is interrupted, and only the chain's own frozen
|
|
48
|
+
* request can be retried, and only while the world has not moved on.
|
|
49
|
+
*/
|
|
50
|
+
async recoverAbandoned(session, advance, headMoved) {
|
|
51
|
+
const playerPhase = advance?.advanceKind === "player";
|
|
52
|
+
const streaming = playerPhase
|
|
53
|
+
? undefined
|
|
54
|
+
: session.events.findLast((event) => event.kind === "assistant" &&
|
|
55
|
+
event.status === "streaming" &&
|
|
56
|
+
(advance === null || event.id === advance.eventId));
|
|
57
|
+
const canRetry = !headMoved && !playerPhase && session.lastRequest !== null;
|
|
58
|
+
const message = headMoved
|
|
59
|
+
? messages.serviceStoppedAfterHead
|
|
60
|
+
: playerPhase
|
|
61
|
+
? messages.serviceStoppedSavingPlayer
|
|
62
|
+
: canRetry
|
|
63
|
+
? messages.serviceStoppedRetryable
|
|
64
|
+
: messages.serviceStoppedNoRequest;
|
|
65
|
+
await this.end(session, {
|
|
66
|
+
kind: "failed",
|
|
67
|
+
message,
|
|
68
|
+
failureKind: canRetry ? "retryable" : "other",
|
|
69
|
+
canRetry,
|
|
70
|
+
streamingEventId: streaming?.id,
|
|
71
|
+
}, advance);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The ending of an exchange that raised an error before its response was
|
|
76
|
+
* settled. Cancellation after dispatch is named explicitly here and is never
|
|
77
|
+
* a retry, even though `ModelHostCancelledError` is also an outcome-unknown
|
|
78
|
+
* error (ADR-0030).
|
|
79
|
+
*/
|
|
80
|
+
export function dispatchFailureEnding(error, streamingEventId) {
|
|
81
|
+
const message = errorMessage(error, messages.dispatchInterrupted);
|
|
82
|
+
if (error instanceof ModelHostCancelledError)
|
|
83
|
+
return { kind: "cancelled_after_dispatch", message, streamingEventId };
|
|
84
|
+
const requiresFreshContext = error instanceof ModelHostBindingMismatchError ||
|
|
85
|
+
error instanceof ModelHostContinuationError ||
|
|
86
|
+
error instanceof ModelHostOutcomeUnknownError ||
|
|
87
|
+
modelHostFailureRequiresFreshContext(error);
|
|
88
|
+
const canRetry = !requiresFreshContext && error instanceof ModelHostFailureError;
|
|
89
|
+
return {
|
|
90
|
+
kind: "failed",
|
|
91
|
+
message,
|
|
92
|
+
failureKind: classifyPlayFailure(error, canRetry),
|
|
93
|
+
canRetry,
|
|
94
|
+
streamingEventId,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The ending of an error raised after a response was read or a player message
|
|
99
|
+
* was written. These failures never grant a retry: Authority may already have
|
|
100
|
+
* moved past the frozen request.
|
|
101
|
+
*/
|
|
102
|
+
export function settlementFailureEnding(error, fallbackMessage) {
|
|
103
|
+
return {
|
|
104
|
+
kind: "failed",
|
|
105
|
+
message: errorMessage(error, fallbackMessage),
|
|
106
|
+
failureKind: classifyPlayFailure(error, false),
|
|
107
|
+
canRetry: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** A settled response with no provider-native continuation payload (ADR-0035). */
|
|
111
|
+
export function noContinuationEnding() {
|
|
112
|
+
return {
|
|
113
|
+
kind: "failed",
|
|
114
|
+
message: messages.noContinuation,
|
|
115
|
+
failureKind: "context_unavailable",
|
|
116
|
+
canRetry: false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** A settled response with neither text nor a complete tool call. */
|
|
120
|
+
export function emptyResponseEnding() {
|
|
121
|
+
return {
|
|
122
|
+
kind: "failed",
|
|
123
|
+
message: messages.noOutput,
|
|
124
|
+
failureKind: "other",
|
|
125
|
+
canRetry: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Whether a chain's last ending was the player's cancellation. Every
|
|
130
|
+
* cancellation ending writes its event last, so the tail identifies it; callers
|
|
131
|
+
* read the projected flag instead of the event list.
|
|
132
|
+
*/
|
|
133
|
+
export function lastEndingCancelled(session) {
|
|
134
|
+
return session.events.at(-1)?.kind === "cancellation";
|
|
135
|
+
}
|
|
136
|
+
function applyEnding(session, ending) {
|
|
137
|
+
switch (ending.kind) {
|
|
138
|
+
case "ready":
|
|
139
|
+
if (ending.followupsCancelled === true)
|
|
140
|
+
session.events.push({
|
|
141
|
+
id: session.nextEventId++,
|
|
142
|
+
kind: "cancellation",
|
|
143
|
+
message: messages.followupsCancelled,
|
|
144
|
+
});
|
|
145
|
+
session.status = "ready";
|
|
146
|
+
session.canRetry = false;
|
|
147
|
+
session.lastFailure = null;
|
|
148
|
+
delete session.lastFailureKind;
|
|
149
|
+
return;
|
|
150
|
+
case "resumable":
|
|
151
|
+
session.lastRequest = structuredClone(ending.request);
|
|
152
|
+
session.lastRequestAttempt = 0;
|
|
153
|
+
session.status = "interrupted";
|
|
154
|
+
session.canRetry = true;
|
|
155
|
+
session.lastFailure =
|
|
156
|
+
ending.reason === "player_committed"
|
|
157
|
+
? messages.playerCommitted
|
|
158
|
+
: messages.toolStepSettled;
|
|
159
|
+
session.lastFailureKind = "retryable";
|
|
160
|
+
return;
|
|
161
|
+
case "cancelled_before_dispatch":
|
|
162
|
+
interruptStreamingEvent(session, ending.streamingEventId);
|
|
163
|
+
session.events.push({
|
|
164
|
+
id: session.nextEventId++,
|
|
165
|
+
kind: "cancellation",
|
|
166
|
+
message: messages.cancelledBeforeDispatch,
|
|
167
|
+
});
|
|
168
|
+
session.status = "interrupted";
|
|
169
|
+
session.canRetry = true;
|
|
170
|
+
session.lastRequest = structuredClone(ending.request);
|
|
171
|
+
session.lastRequestAttempt = Math.max(0, ending.attempt - 1);
|
|
172
|
+
session.lastFailure = messages.cancelledBeforeDispatch;
|
|
173
|
+
delete session.lastFailureKind;
|
|
174
|
+
return;
|
|
175
|
+
case "cancelled_after_dispatch":
|
|
176
|
+
interruptStreamingEvent(session, ending.streamingEventId);
|
|
177
|
+
session.status = "interrupted";
|
|
178
|
+
session.canRetry = false;
|
|
179
|
+
session.lastFailure = ending.message;
|
|
180
|
+
delete session.lastFailureKind;
|
|
181
|
+
session.events.push({
|
|
182
|
+
id: session.nextEventId++,
|
|
183
|
+
kind: "cancellation",
|
|
184
|
+
message: ending.message,
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
case "failed":
|
|
188
|
+
interruptStreamingEvent(session, ending.streamingEventId);
|
|
189
|
+
session.status = "interrupted";
|
|
190
|
+
session.canRetry = ending.canRetry;
|
|
191
|
+
session.lastFailure = ending.message;
|
|
192
|
+
session.lastFailureKind = ending.failureKind;
|
|
193
|
+
session.events.push({
|
|
194
|
+
id: session.nextEventId++,
|
|
195
|
+
kind: "failure",
|
|
196
|
+
message: ending.message,
|
|
197
|
+
failureKind: ending.failureKind,
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function interruptStreamingEvent(session, eventId) {
|
|
203
|
+
if (eventId === undefined)
|
|
204
|
+
return;
|
|
205
|
+
const event = session.events.find(({ id }) => id === eventId);
|
|
206
|
+
if (event?.kind === "assistant")
|
|
207
|
+
event.status = "interrupted";
|
|
208
|
+
}
|
|
209
|
+
function errorMessage(error, fallback) {
|
|
210
|
+
return error instanceof Error ? error.message : fallback;
|
|
211
|
+
}
|