ytdb-slate 0.1.2 → 0.2.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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Model failover: shared helpers for the `modelFailover` config map
3
+ * ("provider/id" → "provider/id"), consumed by the three retry sites
4
+ * (worker dispatch, episode compression, orchestrator), plus the
5
+ * orchestrator-side registration itself (registerOrchestratorFailover).
6
+ *
7
+ * Rules baked in here:
8
+ * - SINGLE HOP: a mapping's target is never itself re-looked-up in the map,
9
+ * so chains and cycles in the config are inert — one failed model buys at
10
+ * most one retry on its mapped model.
11
+ * - Classification is STATE-based, not message-based (AF10): pi signals
12
+ * preflight auth failures as plain Errors with prose messages, so
13
+ * substring matching would silently break on a pi wording change (peer dep
14
+ * is "*"). Instead, isAuthFailure re-runs the registry auth check, which
15
+ * reads credentials fresh on every call.
16
+ * - Context-overflow failures never qualify (isContextOverflow): switching
17
+ * models cannot shrink an oversized prompt, and pi has its own overflow
18
+ * handling. "aborted" stops never qualify either (AF11): a user or
19
+ * orchestrator cancellation must not be answered with a retry.
20
+ */
21
+
22
+ import { isContextOverflow, isRetryableAssistantError, type AssistantMessage } from "@earendil-works/pi-ai";
23
+ import {
24
+ getAgentDir,
25
+ SettingsManager,
26
+ type ExtensionAPI,
27
+ type ExtensionContext,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import type { SlateConfig } from "./state.ts";
30
+
31
+ type RegistryModel = NonNullable<ReturnType<ExtensionContext["modelRegistry"]["find"]>>;
32
+
33
+ // Error messages reach ctx.ui.notify, and pi-tui renders control/ANSI codes
34
+ // verbatim — strip control characters and cap length before display (same
35
+ // pattern as handoff.ts's sanitizeForNotify).
36
+ function sanitizeForNotify(s: string, max = 120): string {
37
+ const clean = s.replace(/[\u0000-\u001f\u007f\u009b]/g, "");
38
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
39
+ }
40
+
41
+ /** "provider/id": slash at index > 0 with a non-empty id after it. */
42
+ function isModelSpec(value: unknown): value is string {
43
+ if (typeof value !== "string") return false;
44
+ const slash = value.indexOf("/");
45
+ return slash > 0 && slash < value.length - 1;
46
+ }
47
+
48
+ /**
49
+ * Validate the raw `modelFailover` config value. Keeps only entries where key
50
+ * and value are both "provider/id" strings and key !== value (a self-mapping
51
+ * could never help). Drops everything else with ONE aggregate warning;
52
+ * non-object input yields an empty map (absent = feature off, silently).
53
+ */
54
+ export function sanitizeModelFailover(
55
+ raw: unknown,
56
+ warn: (msg: string) => void,
57
+ ): Record<string, string> {
58
+ if (raw === undefined) return {};
59
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
60
+ warn('slate: ignoring modelFailover — expected an object mapping "provider/id" to "provider/id"');
61
+ return {};
62
+ }
63
+ const map: Record<string, string> = {};
64
+ const dropped: string[] = [];
65
+ for (const [key, value] of Object.entries(raw)) {
66
+ if (isModelSpec(key) && isModelSpec(value) && key !== value) {
67
+ map[key] = value;
68
+ } else {
69
+ // CQ1: keys/values come from user-edited slate.json and reach
70
+ // ctx.ui.notify — strip control/ANSI codes before display.
71
+ dropped.push(sanitizeForNotify(`"${key}": ${JSON.stringify(value)}`));
72
+ }
73
+ }
74
+ if (dropped.length > 0) {
75
+ warn(
76
+ `slate: dropped invalid modelFailover entries (need "provider/id" → "provider/id", key ≠ value):\n` +
77
+ dropped.join("\n"),
78
+ );
79
+ }
80
+ return map;
81
+ }
82
+
83
+ /**
84
+ * True iff a finished assistant message is a failure that a model switch
85
+ * could plausibly fix: stopReason "error" that is NOT a context overflow.
86
+ * Never true for "aborted" (or any other stopReason), or for a missing
87
+ * message.
88
+ */
89
+ export function isFailoverCandidate(
90
+ msg: { stopReason?: string; errorMessage?: string; usage?: unknown } | undefined,
91
+ contextWindow: number | undefined,
92
+ ): boolean {
93
+ if (!msg || msg.stopReason !== "error") return false;
94
+ return !isContextOverflow(msg as AssistantMessage, contextWindow);
95
+ }
96
+
97
+ /**
98
+ * Resolve the failover target for the current model: look up
99
+ * map["provider/id"], resolve it against the registry, and verify auth.
100
+ * Returns the Model when all three succeed, undefined otherwise. Single hop:
101
+ * the result is returned as-is, never fed back through the map.
102
+ */
103
+ export async function resolveMappedModel(
104
+ ctx: ExtensionContext,
105
+ map: Record<string, string>,
106
+ currentProvider: string,
107
+ currentId: string,
108
+ ): Promise<RegistryModel | undefined> {
109
+ const target = map[`${currentProvider}/${currentId}`];
110
+ if (!isModelSpec(target)) return undefined;
111
+ const slash = target.indexOf("/");
112
+ const model = ctx.modelRegistry.find(target.slice(0, slash), target.slice(slash + 1));
113
+ if (!model) return undefined;
114
+ try {
115
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
116
+ if (!auth.ok) return undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ return model;
121
+ }
122
+
123
+ /**
124
+ * State-based auth classification (AF10): after a caught preflight throw,
125
+ * re-run the registry auth check on the model that was in use. True when
126
+ * credentials are missing/bad NOW — no error-message parsing. A throwing
127
+ * check counts as failed auth: if credentials cannot even be resolved, a
128
+ * mapped-model retry is the right response.
129
+ */
130
+ export async function isAuthFailure(ctx: ExtensionContext, model: RegistryModel): Promise<boolean> {
131
+ try {
132
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
133
+ return !auth.ok;
134
+ } catch {
135
+ return true;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Orchestrator-side model failover.
141
+ *
142
+ * Trigger point is `agent_settled`: pi fires it only after its own retry,
143
+ * compaction, and queued-continuation logic is fully done — so a mapped
144
+ * retry never races or duplicates pi's built-in retries. The event carries
145
+ * no payload, so the final assistant message of the last turn is cached from
146
+ * `turn_end` (which fires even for errored/aborted runs).
147
+ *
148
+ * One-shot guard (AF1): armed synchronously BEFORE the handler's first await
149
+ * (CN3 — a second, independent run can settle and enter this handler while
150
+ * the first invocation is parked at an await; the pre-await arm makes the
151
+ * second invocation bail on the guard check). The guard deliberately STAYS
152
+ * armed on the no-map/no-auth/setModel-failed early-outs: failover cannot
153
+ * succeed right now anyway, and the guard re-arms on the next non-error
154
+ * settle or user prompt. Re-arm rules: (a) an agent run settling with a
155
+ * non-error final message, or (b) a genuine new user prompt via the `input`
156
+ * event. The `input` event fires for interactive/rpc prompts and
157
+ * sendUserMessage, but NOT for pi.sendMessage custom steers (verified:
158
+ * sendCustomMessage skips the input-event path), so slate's own
159
+ * failover/pause steers cannot re-arm the guard.
160
+ *
161
+ * BG2 (cancel during pi's auto-retry backoff): cancelling during the backoff
162
+ * sleep emits NO extension-visible event — `auto_retry_end` is a
163
+ * session-subscriber event only, no fresh turn_end fires, and agent.abort()
164
+ * no-ops because the agent loop already returned — so the settle after a
165
+ * cancel is indistinguishable by event shape from "retries exhausted". The
166
+ * guard below reconstructs the distinction: pi retries an error iff
167
+ * isRetryableAssistantError(msg) (agent-session's _isRetryableError minus
168
+ * the overflow case, which isFailoverCandidate already excluded), so for a
169
+ * retryable error "exhausted" means exactly maxRetries+1 consecutive errored
170
+ * turns, while a backoff cancel settles with ≤ maxRetries of them. The
171
+ * consecutive counter mirrors pi's own _retryAttempt reset-on-success rule.
172
+ * RESIDUAL GAP: the retry settings are re-read here at settle time, so a
173
+ * mid-flight settings change — or a pi release changing its retry semantics
174
+ * (peer dep "*") — can desync the threshold; the failure mode is a skipped
175
+ * failover (conservative), or a failover on a cancelled run only if
176
+ * maxRetries was LOWERED mid-cycle.
177
+ *
178
+ * Deliberately NOT gated on store.paused: failover must run while slate is
179
+ * paused for handoff, so the handoff brief is written by a working model.
180
+ */
181
+ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () => SlateConfig): void {
182
+ /** Final assistant message of the last turn (agent_settled has no payload). */
183
+ let lastAssistant: { stopReason?: string; errorMessage?: string; usage?: unknown } | undefined;
184
+ /** Consecutive errored assistant turns in the current settle cycle (BG2). */
185
+ let consecutiveErrors = 0;
186
+ /** One-shot guard (AF1). */
187
+ let failedOver = false;
188
+
189
+ pi.on("turn_end", async (event) => {
190
+ const msg = event.message as { role?: string; stopReason?: string; errorMessage?: string; usage?: unknown };
191
+ if (msg?.role !== "assistant") return;
192
+ lastAssistant = msg;
193
+ // Mirrors pi's auto-retry counter: any non-error assistant message resets
194
+ // it (agent-session resets _retryAttempt on success the same way).
195
+ if (msg.stopReason === "error") consecutiveErrors++;
196
+ else consecutiveErrors = 0;
197
+ });
198
+
199
+ // Genuine new user prompt → re-arm (rule (b) above) and drop stale caches.
200
+ pi.on("input", async () => {
201
+ failedOver = false;
202
+ lastAssistant = undefined;
203
+ consecutiveErrors = 0;
204
+ });
205
+
206
+ pi.on("agent_settled", async (_event, ctx) => {
207
+ const msg = lastAssistant;
208
+ const errors = consecutiveErrors;
209
+ // BG3/CN5: consume the caches — a later settle that produced no fresh
210
+ // turn_end (e.g. a run throwing before its first turn completes) must
211
+ // never re-classify this run's message.
212
+ lastAssistant = undefined;
213
+ consecutiveErrors = 0;
214
+ if (!msg || msg.stopReason !== "error") {
215
+ // Non-error settle (including aborts — never fail over on abort):
216
+ // the model works, re-arm the guard (rule (a) above).
217
+ failedOver = false;
218
+ return;
219
+ }
220
+ if (failedOver) return;
221
+ const current = ctx.model;
222
+ if (!current) return;
223
+ if (!isFailoverCandidate(msg, current.contextWindow)) return;
224
+
225
+ // BG2 guard (see header): for a retryable error, only pi exhausting its
226
+ // auto-retries reaches settle with maxRetries+1 consecutive errored
227
+ // turns; fewer means the user cancelled during a backoff sleep — a
228
+ // cancelled run must NEVER fail over. Non-retryable errors (quota,
229
+ // billing, auth…) fail fast with no backoff to cancel, so they pass.
230
+ // SettingsManager.create is a synchronous, lock-protected read of the
231
+ // same merged settings pi uses.
232
+ if (isRetryableAssistantError(msg as AssistantMessage)) {
233
+ try {
234
+ const retry = SettingsManager.create(ctx.cwd, getAgentDir(), {
235
+ projectTrusted: ctx.isProjectTrusted(),
236
+ }).getRetrySettings();
237
+ if (retry.enabled && errors <= retry.maxRetries) return;
238
+ } catch {
239
+ return; // cannot read retry settings ⇒ cannot rule out a cancel → stand down
240
+ }
241
+ }
242
+
243
+ // AF1/CN3: arm the guard BEFORE the first await — a concurrent settle
244
+ // entering this handler while we are parked below must bail above. Stays
245
+ // armed on every early-out (see header).
246
+ failedOver = true;
247
+ const mapped = await resolveMappedModel(ctx, getConfig().modelFailover ?? {}, current.provider, current.id);
248
+ if (!mapped) return; // no mapping / unknown / unauthed → the original failure stands
249
+ const from = `${current.provider}/${current.id}`;
250
+ const to = `${mapped.provider}/${mapped.id}`;
251
+ try {
252
+ // pi.setModel returns false when no API key is available, but can ALSO
253
+ // throw on a failed live auth check despite the Promise<boolean>
254
+ // contract — handle both. NOTE: on success it persists the mapped model
255
+ // as the user's GLOBAL default (pi semantics; documented caveat).
256
+ if (!(await pi.setModel(mapped))) {
257
+ if (ctx.hasUI) {
258
+ ctx.ui.notify(`slate: model failover to ${to} skipped — no API key. Keeping ${from}.`, "warning");
259
+ }
260
+ return;
261
+ }
262
+ } catch (error) {
263
+ if (ctx.hasUI) {
264
+ ctx.ui.notify(
265
+ `slate: model failover to ${to} failed — ${sanitizeForNotify(
266
+ error instanceof Error ? error.message : String(error),
267
+ )}. Keeping ${from}.`,
268
+ "warning",
269
+ );
270
+ }
271
+ return;
272
+ }
273
+ if (ctx.hasUI) {
274
+ ctx.ui.notify(`slate: model failover ${from} ⇒ ${to} — retrying the failed turn`, "warning");
275
+ }
276
+ pi.sendMessage(
277
+ {
278
+ customType: "slate-failover",
279
+ content:
280
+ `[slate] The previous turn failed due to a model API failure. The model has been switched to ${to}. ` +
281
+ "The conversation context is intact — re-issue the failed action and continue.",
282
+ display: true,
283
+ },
284
+ { deliverAs: "steer", triggerTurn: true },
285
+ );
286
+ });
287
+ }