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.
- package/README.md +7 -4
- package/docs/context-budget.md +176 -0
- package/docs/model-failover.md +153 -0
- package/extension/episodes.ts +107 -37
- package/extension/failover.ts +287 -0
- package/extension/handoff.ts +367 -31
- package/extension/index.ts +27 -4
- package/extension/state.ts +14 -1
- package/extension/threads.ts +155 -9
- package/extension/tools.ts +13 -1
- package/extension/worker.ts +40 -3
- package/package.json +7 -2
|
@@ -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
|
+
}
|