narraeon 0.7.4 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }