switchback-core 2026.10.14 → 2026.10.16
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/package.json +2 -1
- package/src/availability.ts +5 -4
- package/src/classify.ts +14 -11
- package/src/config-editor.ts +101 -107
- package/src/config.ts +202 -183
- package/src/context-fit.ts +3 -3
- package/src/dispatch.ts +1 -1
- package/src/failure.ts +6 -6
- package/src/idle.ts +3 -3
- package/src/index.ts +28 -5
- package/src/routing.ts +56 -18
- package/src/simulate.ts +10 -2
- package/src/state.ts +63 -1
- package/src/systemone.ts +52 -46
- package/src/thinking.ts +2 -2
- package/src/types.ts +56 -31
package/src/config.ts
CHANGED
|
@@ -47,9 +47,9 @@ import { join, resolve } from "node:path";
|
|
|
47
47
|
import { parse as parseYaml } from "yaml";
|
|
48
48
|
import { IDLE_RESET_OPTIONS } from "./idle.ts";
|
|
49
49
|
import type {
|
|
50
|
+
DecisionModelConfig,
|
|
50
51
|
DecisionModelEntry,
|
|
51
|
-
|
|
52
|
-
JevRef,
|
|
52
|
+
DecisionModelId,
|
|
53
53
|
ModelId,
|
|
54
54
|
ResolvedSwitchbackConfig,
|
|
55
55
|
ResolvedSwitchbackFileConfig,
|
|
@@ -62,9 +62,10 @@ const PROVIDER = "switchback";
|
|
|
62
62
|
const VIRTUAL_ID = "auto";
|
|
63
63
|
const CONFIG_BASENAME = "switchback";
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
const
|
|
65
|
+
/** Recognised keys, per level. Anything else is reported as a warning, not an error. */
|
|
66
|
+
const FILE_KEYS = ["models", "decisionModels", "debug"] as const;
|
|
67
|
+
const MODEL_KEYS = ["id", "name", "fallbacks", "decisionModel", "idleReset"] as const;
|
|
68
|
+
const DECISION_MODEL_KEYS = ["baseUrl", "apiKey"] as const;
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
71
|
* Built-in default used when no user config is present.
|
|
@@ -111,9 +112,15 @@ export function piConfigDir(): string {
|
|
|
111
112
|
* `~/.config/opencode` or `~/.pi/agent`) are superposed on top of.
|
|
112
113
|
*/
|
|
113
114
|
export function switchbackConfigDir(): string {
|
|
114
|
-
return join(homedir(), ".config", "switchback");
|
|
115
|
+
return process.env[SWITCHBACK_CONFIG_DIR_ENV] ?? join(homedir(), ".config", "switchback");
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Env override naming the neutral config directory outright. Lets a host or a
|
|
120
|
+
* test pin the basis layer without touching the user's real `~/.config/switchback`.
|
|
121
|
+
*/
|
|
122
|
+
export const SWITCHBACK_CONFIG_DIR_ENV = "SWITCHBACK_CONFIG_DIR";
|
|
123
|
+
|
|
117
124
|
/** Env override naming the shared switchback state directory outright. */
|
|
118
125
|
export const SWITCHBACK_DIR_ENV = "SWITCHBACK_DIR";
|
|
119
126
|
|
|
@@ -178,101 +185,158 @@ function parseModelId(value: unknown, where: string): ModelId {
|
|
|
178
185
|
return value;
|
|
179
186
|
}
|
|
180
187
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
188
|
+
/** Keys the previous schema used. They warn with the replacement instead of a generic message. */
|
|
189
|
+
const RETIRED_KEYS: Record<string, string> = {
|
|
190
|
+
jev: "use decisionModel: <provider>/<model-id> instead",
|
|
191
|
+
api: "drop it - a decisionModels entry always speaks the SystemOne wire API",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Report keys this schema does not know. Stale or misspelled keys are warnings,
|
|
196
|
+
* not errors: the rest of the file still loads, and the message names the key so
|
|
197
|
+
* it can be fixed without reading the source.
|
|
198
|
+
*/
|
|
199
|
+
function noteUnknownKeys(
|
|
200
|
+
value: Record<string, unknown>,
|
|
201
|
+
allowed: readonly string[],
|
|
202
|
+
where: string,
|
|
203
|
+
warnings: string[],
|
|
204
|
+
): void {
|
|
205
|
+
for (const key of Object.keys(value)) {
|
|
206
|
+
if (allowed.includes(key)) continue;
|
|
207
|
+
const retired = RETIRED_KEYS[key];
|
|
208
|
+
warnings.push(
|
|
209
|
+
retired === undefined
|
|
210
|
+
? `${where}.${key} is not a switchback config key and was ignored`
|
|
211
|
+
: `${where}.${key} is no longer supported; ${retired}`,
|
|
212
|
+
);
|
|
199
213
|
}
|
|
200
|
-
return parseJevInline(value, where);
|
|
201
214
|
}
|
|
202
215
|
|
|
203
|
-
/**
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
/** A decision-model address is "provider/model-id", the same shape as a fallback. */
|
|
217
|
+
function parseDecisionModelId(value: unknown, where: string): DecisionModelId {
|
|
218
|
+
return parseModelId(value, `${where}.decisionModel`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Split an address on the first "/" - model ids may themselves contain ":" (e.g. tev1:0.8b). */
|
|
222
|
+
function splitAddress(address: DecisionModelId, where: string): { provider: string; id: string } {
|
|
223
|
+
const slash = address.indexOf("/");
|
|
224
|
+
if (slash <= 0 || slash === address.length - 1) {
|
|
225
|
+
throw new ConfigError(`${where} must be of the form "provider/model-id"`, where);
|
|
212
226
|
}
|
|
227
|
+
return { provider: address.slice(0, slash), id: address.slice(slash + 1) };
|
|
228
|
+
}
|
|
213
229
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
230
|
-
throw new ConfigError(`${where}.jev.baseUrl must be http(s), got ${parsed.protocol}`, where);
|
|
231
|
-
}
|
|
232
|
-
baseUrl = rawBaseUrl;
|
|
230
|
+
/**
|
|
231
|
+
* Parse the `decisionModels:` map. Each key is a decision-model address; the
|
|
232
|
+
* value carries the connection details for a direct SystemOne endpoint.
|
|
233
|
+
*/
|
|
234
|
+
function parseDecisionModels(
|
|
235
|
+
value: unknown,
|
|
236
|
+
path: string,
|
|
237
|
+
warnings: string[],
|
|
238
|
+
): Record<DecisionModelId, DecisionModelEntry> | undefined {
|
|
239
|
+
if (value === undefined) return undefined;
|
|
240
|
+
if (Array.isArray(value)) {
|
|
241
|
+
warnings.push(
|
|
242
|
+
`decisionModels is now a map keyed by "provider/model-id" (e.g. typesafe/jev-latest: { baseUrl, apiKey }); the list form was ignored (${path})`,
|
|
243
|
+
);
|
|
244
|
+
return undefined;
|
|
233
245
|
}
|
|
246
|
+
if (!isRecord(value)) throw new ConfigError('decisionModels must be an object keyed by "provider/model-id"', path);
|
|
247
|
+
const entries: Record<DecisionModelId, DecisionModelEntry> = {};
|
|
248
|
+
for (const [address, rawEntry] of Object.entries(value)) {
|
|
249
|
+
const where = `decisionModels.${address}`;
|
|
250
|
+
parseModelId(address, where);
|
|
251
|
+
if (!isRecord(rawEntry)) throw new ConfigError(`${where} must be an object with baseUrl and apiKey`, path);
|
|
252
|
+
noteUnknownKeys(rawEntry, DECISION_MODEL_KEYS, where, warnings);
|
|
253
|
+
|
|
254
|
+
const rawBaseUrl = rawEntry["baseUrl"];
|
|
255
|
+
let baseUrl: string | undefined;
|
|
256
|
+
if (rawBaseUrl !== undefined) {
|
|
257
|
+
if (typeof rawBaseUrl !== "string" || rawBaseUrl.length === 0) {
|
|
258
|
+
throw new ConfigError(`${where}.baseUrl must be a non-empty URL string`, path);
|
|
259
|
+
}
|
|
260
|
+
let parsed: URL;
|
|
261
|
+
try {
|
|
262
|
+
parsed = new URL(rawBaseUrl);
|
|
263
|
+
} catch {
|
|
264
|
+
throw new ConfigError(`${where}.baseUrl is not a valid URL: ${rawBaseUrl}`, path);
|
|
265
|
+
}
|
|
266
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
267
|
+
throw new ConfigError(`${where}.baseUrl must be http(s), got ${parsed.protocol}`, path);
|
|
268
|
+
}
|
|
269
|
+
baseUrl = rawBaseUrl;
|
|
270
|
+
}
|
|
234
271
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
272
|
+
const rawApiKey = rawEntry["apiKey"];
|
|
273
|
+
let apiKey: string | undefined;
|
|
274
|
+
if (rawApiKey !== undefined) {
|
|
275
|
+
if (typeof rawApiKey !== "string" || rawApiKey.length === 0) {
|
|
276
|
+
throw new ConfigError(`${where}.apiKey must be a non-empty string`, path);
|
|
277
|
+
}
|
|
278
|
+
apiKey = rawApiKey;
|
|
279
|
+
}
|
|
280
|
+
if (baseUrl !== undefined && apiKey === undefined) {
|
|
281
|
+
// A direct endpoint needs a bearer token to send. Local servers ignore
|
|
282
|
+
// it, but the token is still the thing that makes "direct endpoint"
|
|
283
|
+
// explicit rather than a silent default.
|
|
239
284
|
throw new ConfigError(
|
|
240
|
-
`${where}.
|
|
241
|
-
|
|
285
|
+
`${where}.apiKey is required with baseUrl - use "local" for endpoints that ignore the bearer token`,
|
|
286
|
+
path,
|
|
242
287
|
);
|
|
243
288
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (api !== undefined && baseUrl === undefined) {
|
|
247
|
-
throw new ConfigError(`${where}.jev.api requires ${where}.jev.baseUrl`, where);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const rawApiKey = value["apiKey"];
|
|
251
|
-
let apiKey: string | undefined;
|
|
252
|
-
if (rawApiKey !== undefined) {
|
|
253
|
-
if (typeof rawApiKey !== "string" || rawApiKey.length === 0) {
|
|
254
|
-
throw new ConfigError(`${where}.jev.apiKey must be a non-empty string`, where);
|
|
289
|
+
if (apiKey !== undefined && baseUrl === undefined) {
|
|
290
|
+
throw new ConfigError(`${where}.apiKey requires ${where}.baseUrl`, path);
|
|
255
291
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
292
|
+
if (baseUrl === undefined) {
|
|
293
|
+
warnings.push(
|
|
294
|
+
`${where} has no connection details; it can only resolve through a classifier catalog (pi has one, OpenCode does not)`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
entries[address] = {
|
|
298
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
299
|
+
...(apiKey !== undefined ? { apiKey } : {}),
|
|
300
|
+
};
|
|
260
301
|
}
|
|
302
|
+
return entries;
|
|
303
|
+
}
|
|
261
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Resolve a chain's `decisionModel:` address against the `decisionModels:` map.
|
|
307
|
+
*
|
|
308
|
+
* A map entry with a `baseUrl` pins the endpoint. Without one the address stays
|
|
309
|
+
* catalog-resolved, which pi can do and OpenCode cannot - that stays a visible
|
|
310
|
+
* "no classifier" cycle there instead of a load error, because the same file is
|
|
311
|
+
* valid on pi.
|
|
312
|
+
*/
|
|
313
|
+
export function resolveDecisionModel(
|
|
314
|
+
decisionModels: Record<DecisionModelId, DecisionModelConfig> | undefined,
|
|
315
|
+
address: DecisionModelId | undefined,
|
|
316
|
+
): DecisionModelConfig | undefined {
|
|
317
|
+
if (address === undefined) return undefined;
|
|
318
|
+
const { provider, id } = splitAddress(address, "decisionModel");
|
|
319
|
+
const entry = decisionModels?.[address];
|
|
262
320
|
return {
|
|
263
321
|
provider,
|
|
264
322
|
id,
|
|
265
|
-
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
266
|
-
...(
|
|
267
|
-
...(apiKey !== undefined ? { apiKey } : {}),
|
|
323
|
+
...(entry?.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}),
|
|
324
|
+
...(entry?.apiKey !== undefined ? { apiKey: entry.apiKey } : {}),
|
|
268
325
|
};
|
|
269
326
|
}
|
|
270
327
|
|
|
271
|
-
export type {
|
|
328
|
+
export type {
|
|
329
|
+
SwitchbackConfig,
|
|
330
|
+
SwitchbackFileConfig,
|
|
331
|
+
DecisionModelConfig,
|
|
332
|
+
DecisionModelEntry,
|
|
333
|
+
DecisionModelId,
|
|
334
|
+
} from "./types.ts";
|
|
272
335
|
|
|
273
|
-
function parseSwitchbackModel(value: unknown, index: number): SwitchbackConfig {
|
|
336
|
+
function parseSwitchbackModel(value: unknown, index: number, warnings: string[]): SwitchbackConfig {
|
|
274
337
|
const where = `models[${index}]`;
|
|
275
338
|
if (!isRecord(value)) throw new ConfigError(`${where} must be an object`, where);
|
|
339
|
+
noteUnknownKeys(value, MODEL_KEYS, where, warnings);
|
|
276
340
|
const id = value["id"];
|
|
277
341
|
const name = value["name"];
|
|
278
342
|
const fallbacks = value["fallbacks"];
|
|
@@ -287,13 +351,14 @@ function parseSwitchbackModel(value: unknown, index: number): SwitchbackConfig {
|
|
|
287
351
|
throw new ConfigError(`${where}.fallbacks must be a non-empty array of "provider/id" strings`, where);
|
|
288
352
|
}
|
|
289
353
|
const parsedFallbacks = fallbacks.map((entry, i) => parseModelId(entry, `${where}.fallbacks[${i}]`));
|
|
290
|
-
const
|
|
354
|
+
const rawDecisionModel = value["decisionModel"];
|
|
355
|
+
const decisionModel = rawDecisionModel === undefined ? undefined : parseDecisionModelId(rawDecisionModel, where);
|
|
291
356
|
const idleReset = parseIdleReset(value["idleReset"], where);
|
|
292
357
|
return {
|
|
293
358
|
id: fullId,
|
|
294
359
|
name,
|
|
295
360
|
fallbacks: parsedFallbacks,
|
|
296
|
-
...(
|
|
361
|
+
...(decisionModel !== undefined ? { decisionModel } : {}),
|
|
297
362
|
...(idleReset !== undefined ? { idleReset } : {}),
|
|
298
363
|
};
|
|
299
364
|
}
|
|
@@ -307,50 +372,6 @@ function parseIdleReset(value: unknown, where: string): IdleResetConfig | undefi
|
|
|
307
372
|
return value as IdleResetConfig;
|
|
308
373
|
}
|
|
309
374
|
|
|
310
|
-
/** Parse the top-level `decisionModels:` list. Names must be unique. */
|
|
311
|
-
function parseDecisionModels(value: unknown, path: string): DecisionModelEntry[] {
|
|
312
|
-
if (value === undefined) return [];
|
|
313
|
-
if (!Array.isArray(value)) throw new ConfigError("decisionModels must be an array", path);
|
|
314
|
-
const entries: DecisionModelEntry[] = [];
|
|
315
|
-
const seen = new Set<string>();
|
|
316
|
-
value.forEach((entry, i) => {
|
|
317
|
-
const where = `decisionModels[${i}]`;
|
|
318
|
-
if (!isRecord(entry)) throw new ConfigError(`${where} must be an object`, path);
|
|
319
|
-
const name = entry["name"];
|
|
320
|
-
if (typeof name !== "string" || name.length === 0) {
|
|
321
|
-
throw new ConfigError(`${where}.name must be a non-empty string`, path);
|
|
322
|
-
}
|
|
323
|
-
if (seen.has(name)) throw new ConfigError(`decisionModels contains the name "${name}" more than once`, path);
|
|
324
|
-
seen.add(name);
|
|
325
|
-
const inline = parseJevInline(entry, where);
|
|
326
|
-
entries.push({ name, ...inline });
|
|
327
|
-
});
|
|
328
|
-
return entries;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Resolve a model's `jev` to an inline config. A `decisionModel:` reference is
|
|
333
|
-
* looked up in the given decision-model list; an unknown name is an actionable
|
|
334
|
-
* load-time error, not a silent fall-through to no classifier.
|
|
335
|
-
*/
|
|
336
|
-
export function resolveJevConfig(
|
|
337
|
-
decisionModels: readonly DecisionModelEntry[] | undefined,
|
|
338
|
-
jev: JevRef | undefined,
|
|
339
|
-
): JevConfig | undefined {
|
|
340
|
-
if (jev === undefined) return undefined;
|
|
341
|
-
if (!("decisionModel" in jev)) return jev;
|
|
342
|
-
const name = jev.decisionModel;
|
|
343
|
-
const found = decisionModels?.find((d) => d.name === name);
|
|
344
|
-
if (found === undefined) {
|
|
345
|
-
throw new ConfigError(
|
|
346
|
-
`jev.decisionModel "${name}" is not defined in decisionModels (known: ${(decisionModels ?? []).map((d) => d.name).join(", ") || "none"})`,
|
|
347
|
-
"<config>",
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
const { name: _ignored, ...inline } = found;
|
|
351
|
-
return inline;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
375
|
const SECRETS_PREFIX = "secret:";
|
|
355
376
|
|
|
356
377
|
/**
|
|
@@ -382,48 +403,48 @@ export function resolveSecretApiKey(
|
|
|
382
403
|
return value;
|
|
383
404
|
}
|
|
384
405
|
|
|
385
|
-
/** Resolve every
|
|
386
|
-
function
|
|
406
|
+
/** Resolve every chain's `decisionModel:` address against the map, and the map's secret refs. */
|
|
407
|
+
function resolveFile(
|
|
387
408
|
file: SwitchbackFileConfig,
|
|
388
409
|
secrets: { get(name: string): string | undefined } | undefined = undefined,
|
|
389
410
|
): ResolvedSwitchbackFileConfig {
|
|
390
|
-
const resolvedDecisionModels =
|
|
411
|
+
const resolvedDecisionModels: Record<DecisionModelId, DecisionModelConfig> | undefined =
|
|
391
412
|
file.decisionModels === undefined
|
|
392
413
|
? undefined
|
|
393
|
-
:
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
414
|
+
: Object.fromEntries(
|
|
415
|
+
Object.entries(file.decisionModels).map(([address, entry]) => {
|
|
416
|
+
const { provider, id } = splitAddress(address, `decisionModels.${address}`);
|
|
417
|
+
const apiKey = resolveSecretApiKey(entry.apiKey, secrets);
|
|
418
|
+
const descriptor: DecisionModelConfig = {
|
|
419
|
+
provider,
|
|
420
|
+
id,
|
|
421
|
+
...(entry.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}),
|
|
422
|
+
...(apiKey !== undefined ? { apiKey } : {}),
|
|
423
|
+
};
|
|
424
|
+
return [address, descriptor];
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
398
427
|
return {
|
|
399
428
|
models: file.models.map((model) => {
|
|
400
|
-
const {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
jev,
|
|
404
|
-
);
|
|
405
|
-
if (inline === undefined) return rest;
|
|
406
|
-
const resolvedInline: JevConfig = (() => {
|
|
407
|
-
const apiKey = inline.apiKey;
|
|
408
|
-
const resolvedApiKey = resolveSecretApiKey(apiKey, secrets);
|
|
409
|
-
return resolvedApiKey === undefined ? inline : { ...inline, apiKey: resolvedApiKey };
|
|
410
|
-
})();
|
|
411
|
-
return { ...rest, jev: resolvedInline };
|
|
429
|
+
const { decisionModel, ...rest } = model;
|
|
430
|
+
const resolved = resolveDecisionModel(resolvedDecisionModels, decisionModel);
|
|
431
|
+
return resolved === undefined ? rest : { ...rest, decisionModel: resolved };
|
|
412
432
|
}),
|
|
413
|
-
...(
|
|
433
|
+
...(resolvedDecisionModels !== undefined && Object.keys(resolvedDecisionModels).length > 0
|
|
414
434
|
? { decisionModels: resolvedDecisionModels }
|
|
415
435
|
: {}),
|
|
416
436
|
...(file.debug !== undefined ? { debug: file.debug } : {}),
|
|
417
437
|
};
|
|
418
438
|
}
|
|
419
439
|
|
|
420
|
-
function parseFileConfig(value: unknown, path: string): SwitchbackFileConfig {
|
|
440
|
+
function parseFileConfig(value: unknown, path: string, warnings: string[]): SwitchbackFileConfig {
|
|
421
441
|
if (!isRecord(value)) throw new ConfigError("root must be an object", path);
|
|
442
|
+
noteUnknownKeys(value, FILE_KEYS, "config", warnings);
|
|
422
443
|
const models = value["models"];
|
|
423
444
|
if (!Array.isArray(models) || models.length === 0) {
|
|
424
445
|
throw new ConfigError("models must be a non-empty array", path);
|
|
425
446
|
}
|
|
426
|
-
const parsed = models.map((entry, i) => parseSwitchbackModel(entry, i));
|
|
447
|
+
const parsed = models.map((entry, i) => parseSwitchbackModel(entry, i, warnings));
|
|
427
448
|
// Any set of virtual model ids is valid - `switchback/auto` is the conventional
|
|
428
449
|
// first entry, not a requirement. An unknown virtual id is already surfaced by
|
|
429
450
|
// findModelConfig() at extension load, and index.ts registers one virtual model
|
|
@@ -440,39 +461,27 @@ function parseFileConfig(value: unknown, path: string): SwitchbackFileConfig {
|
|
|
440
461
|
if (rawDebug !== undefined && typeof rawDebug !== "boolean") {
|
|
441
462
|
throw new ConfigError("debug must be a boolean", path);
|
|
442
463
|
}
|
|
443
|
-
const decisionModels = parseDecisionModels(value["decisionModels"], path);
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (model.jev !== undefined && "decisionModel" in model.jev) {
|
|
454
|
-
referenced.add(model.jev.decisionModel);
|
|
464
|
+
const decisionModels = parseDecisionModels(value["decisionModels"], path, warnings);
|
|
465
|
+
// A `decisionModels` entry no chain references is harmless but almost always a
|
|
466
|
+
// leftover: warn (do not fail) so the block does not quietly rot.
|
|
467
|
+
if (decisionModels !== undefined) {
|
|
468
|
+
const referenced = new Set(parsed.map((model) => model.decisionModel).filter((id) => id !== undefined));
|
|
469
|
+
for (const address of Object.keys(decisionModels)) {
|
|
470
|
+
if (!referenced.has(address)) {
|
|
471
|
+
warnings.push(
|
|
472
|
+
`decisionModels.${address} is not referenced by any models[].decisionModel and was ignored`,
|
|
473
|
+
);
|
|
455
474
|
}
|
|
456
475
|
}
|
|
457
|
-
const orphans = decisionModels.filter((d) => !referenced.has(d.name));
|
|
458
|
-
if (orphans.length === decisionModels.length) {
|
|
459
|
-
throw new ConfigError(
|
|
460
|
-
"decisionModels entries are not referenced by any model.jev.decisionModel: " +
|
|
461
|
-
`${orphans.map((d) => `"${d.name}"`).join(", ")}. ` +
|
|
462
|
-
'A decisionModels entry must be referenced by at least one model.jev.decisionModel, otherwise the router sees no classifier and reports it as "not configured" - ' +
|
|
463
|
-
`reference one from a model ("${parsed[0]?.id ?? "<first model>"}".jev.decisionModel: <name>) or remove the decisionModels block.`,
|
|
464
|
-
path,
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
476
|
}
|
|
468
477
|
return {
|
|
469
478
|
models: parsed,
|
|
470
|
-
...(decisionModels
|
|
479
|
+
...(decisionModels !== undefined ? { decisionModels } : {}),
|
|
471
480
|
...(rawDebug !== undefined ? { debug: rawDebug } : {}),
|
|
472
481
|
};
|
|
473
482
|
}
|
|
474
483
|
|
|
475
|
-
function readAndParse(path: string): SwitchbackFileConfig {
|
|
484
|
+
function readAndParse(path: string, warnings: string[]): SwitchbackFileConfig {
|
|
476
485
|
// The only .json file in this project's surface is the simulate fixture;
|
|
477
486
|
// anything else ending in .json is a user config mistake.
|
|
478
487
|
if (path.endsWith(".json")) {
|
|
@@ -490,7 +499,10 @@ function readAndParse(path: string): SwitchbackFileConfig {
|
|
|
490
499
|
path,
|
|
491
500
|
);
|
|
492
501
|
}
|
|
493
|
-
|
|
502
|
+
const layerWarnings: string[] = [];
|
|
503
|
+
const parsed = parseFileConfig(raw, path, layerWarnings);
|
|
504
|
+
for (const warning of layerWarnings) warnings.push(`${warning} (${path})`);
|
|
505
|
+
return parsed;
|
|
494
506
|
}
|
|
495
507
|
|
|
496
508
|
/**
|
|
@@ -512,8 +524,8 @@ function aggregateConfigs(global: SwitchbackFileConfig, project: SwitchbackFileC
|
|
|
512
524
|
}
|
|
513
525
|
return {
|
|
514
526
|
models: merged,
|
|
515
|
-
// Decision models follow the same layering rule as debug: the project
|
|
516
|
-
// replaces the global
|
|
527
|
+
// Decision models follow the same layering rule as debug: the project map
|
|
528
|
+
// replaces the global map when present, so addresses resolve against one
|
|
517
529
|
// predictable set. (A project layer without decisionModels keeps the global set.)
|
|
518
530
|
...(project.decisionModels !== undefined
|
|
519
531
|
? { decisionModels: project.decisionModels }
|
|
@@ -554,11 +566,15 @@ export interface SecretResolver {
|
|
|
554
566
|
*
|
|
555
567
|
* `dirs` overrides the layer locations; a host other than pi passes its own
|
|
556
568
|
* agent config directory so its overlay participates.
|
|
569
|
+
*
|
|
570
|
+
* `warnings` carries recognised-but-suspicious findings (unknown keys, retired
|
|
571
|
+
* keys, unreferenced decision models). They never fail the load: a host shows
|
|
572
|
+
* them through its own notify channel.
|
|
557
573
|
*/
|
|
558
574
|
export function loadConfig(
|
|
559
575
|
secrets?: SecretResolver,
|
|
560
576
|
dirs?: ConfigLayerDirs,
|
|
561
|
-
): { config: ResolvedSwitchbackFileConfig; source: string } {
|
|
577
|
+
): { config: ResolvedSwitchbackFileConfig; source: string; warnings: string[] } {
|
|
562
578
|
const [neutralPath, agentPath, projectPath] = candidatePaths(dirs) as [string, string, string];
|
|
563
579
|
const neutralExists = existsSync(neutralPath);
|
|
564
580
|
const agentExists = existsSync(agentPath);
|
|
@@ -567,26 +583,27 @@ export function loadConfig(
|
|
|
567
583
|
// Aggregate all existing layers, lowest precedence first.
|
|
568
584
|
let merged: SwitchbackFileConfig | undefined;
|
|
569
585
|
const sources: string[] = [];
|
|
586
|
+
const warnings: string[] = [];
|
|
570
587
|
|
|
571
588
|
if (neutralExists) {
|
|
572
|
-
merged = readAndParse(neutralPath);
|
|
589
|
+
merged = readAndParse(neutralPath, warnings);
|
|
573
590
|
sources.push(resolve(neutralPath));
|
|
574
591
|
}
|
|
575
592
|
if (agentExists) {
|
|
576
|
-
const agent = readAndParse(agentPath);
|
|
593
|
+
const agent = readAndParse(agentPath, warnings);
|
|
577
594
|
sources.push(resolve(agentPath));
|
|
578
595
|
merged = merged === undefined ? agent : aggregateConfigs(merged, agent);
|
|
579
596
|
}
|
|
580
597
|
if (projectExists) {
|
|
581
|
-
const project = readAndParse(projectPath);
|
|
598
|
+
const project = readAndParse(projectPath, warnings);
|
|
582
599
|
sources.push(resolve(projectPath));
|
|
583
600
|
merged = merged === undefined ? project : aggregateConfigs(merged, project);
|
|
584
601
|
}
|
|
585
602
|
|
|
586
603
|
if (merged === undefined) {
|
|
587
|
-
return { config: DEFAULT_CONFIG, source: "<default>" };
|
|
604
|
+
return { config: DEFAULT_CONFIG, source: "<default>", warnings };
|
|
588
605
|
}
|
|
589
|
-
return { config:
|
|
606
|
+
return { config: resolveFile(merged, secrets), source: sources.join(" + "), warnings };
|
|
590
607
|
}
|
|
591
608
|
|
|
592
609
|
/**
|
|
@@ -594,8 +611,10 @@ export function loadConfig(
|
|
|
594
611
|
* loader applies; exposed so the interactive config editor validates a mutated
|
|
595
612
|
* document at save time instead of maintaining a second rule set.
|
|
596
613
|
*/
|
|
597
|
-
export function validateFileConfig(raw: unknown, path: string): SwitchbackFileConfig {
|
|
598
|
-
|
|
614
|
+
export function validateFileConfig(raw: unknown, path: string): { config: SwitchbackFileConfig; warnings: string[] } {
|
|
615
|
+
const warnings: string[] = [];
|
|
616
|
+
const config = parseFileConfig(raw, path, warnings);
|
|
617
|
+
return { config, warnings };
|
|
599
618
|
}
|
|
600
619
|
|
|
601
620
|
/** Look up the configuration entry for a given virtual model id. */
|
package/src/context-fit.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import type { ClassifierContext, ClassifierQuestion } from "@earendil-works/pi-ai";
|
|
34
34
|
import { callClassifier, readChoice, readConfidence, type NoClassifierReason } from "./classify.ts";
|
|
35
35
|
import type { ClassifierPort } from "./ports.ts";
|
|
36
|
-
import type {
|
|
36
|
+
import type { DecisionModelConfig, ModelId } from "./types.ts";
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Headroom kept free when judging whether a model can hold the current context.
|
|
@@ -71,7 +71,7 @@ export interface CandidateWindow {
|
|
|
71
71
|
|
|
72
72
|
export interface ContextCandidateInput {
|
|
73
73
|
registry: ClassifierPort;
|
|
74
|
-
|
|
74
|
+
decisionModel: DecisionModelConfig | undefined;
|
|
75
75
|
/** The deterministic pick: the floor the classifier may only reorder. */
|
|
76
76
|
preferred: ModelId;
|
|
77
77
|
/** Current context size in tokens. */
|
|
@@ -145,7 +145,7 @@ export async function chooseContextCandidate(input: ContextCandidateInput): Prom
|
|
|
145
145
|
.map((candidate) => `${candidate.id}: ${compactTokens(candidate.contextWindow)} window`)
|
|
146
146
|
.join("; "),
|
|
147
147
|
};
|
|
148
|
-
const call = await callClassifier(input.registry, input.
|
|
148
|
+
const call = await callClassifier(input.registry, input.decisionModel, {
|
|
149
149
|
state,
|
|
150
150
|
questions: { candidate: criteria },
|
|
151
151
|
});
|
package/src/dispatch.ts
CHANGED
package/src/failure.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { classifyError, PROMPT_VERSION, type NoClassifierReason } from "./classi
|
|
|
29
29
|
import { recordCrash, type CrashAction } from "./crashes.ts";
|
|
30
30
|
import type { AvailabilityPort, ClassifierPort, ModelRegistryPort, NotifyPort, RouterRegistryPort } from "./ports.ts";
|
|
31
31
|
import { blockModel, isBlocked, unblockModel } from "./state.ts";
|
|
32
|
-
import type { BlockedMap, ClassifiedError,
|
|
32
|
+
import type { BlockedMap, ClassifiedError, DecisionModelConfig, ModelId } from "./types.ts";
|
|
33
33
|
|
|
34
34
|
/** Maximum number of transient retries on the same model before the router moves on. */
|
|
35
35
|
export const MAX_TRANSIENT_RETRIES = 1;
|
|
@@ -112,14 +112,14 @@ export async function assessFailure(opts: {
|
|
|
112
112
|
failedId: string | undefined;
|
|
113
113
|
errorMessage: string;
|
|
114
114
|
stopReason: StopReason | undefined;
|
|
115
|
-
|
|
115
|
+
decisionModel: DecisionModelConfig | undefined;
|
|
116
116
|
registry: ClassifierPort;
|
|
117
117
|
now: number;
|
|
118
118
|
blocked: BlockedMap;
|
|
119
119
|
transientRetries: number;
|
|
120
120
|
}): Promise<AssessedFailure> {
|
|
121
|
-
const { failedId, errorMessage, stopReason,
|
|
122
|
-
const result = await classifyError(errorMessage, registry,
|
|
121
|
+
const { failedId, errorMessage, stopReason, decisionModel, registry, now, blocked } = opts;
|
|
122
|
+
const result = await classifyError(errorMessage, registry, decisionModel, now, stopReason);
|
|
123
123
|
if (result.kind === "no-classifier") {
|
|
124
124
|
// The universal cycle: advance, but never fabricate a class and never write a
|
|
125
125
|
// block - there is nothing to justify one with.
|
|
@@ -186,7 +186,7 @@ export async function observeUnretriedFailure(opts: {
|
|
|
186
186
|
failedId: string | undefined;
|
|
187
187
|
errorMessage: string;
|
|
188
188
|
stopReason: StopReason | undefined;
|
|
189
|
-
|
|
189
|
+
decisionModel: DecisionModelConfig | undefined;
|
|
190
190
|
registry: RouterRegistryPort;
|
|
191
191
|
now: number;
|
|
192
192
|
blocked: BlockedMap;
|
|
@@ -197,7 +197,7 @@ export async function observeUnretriedFailure(opts: {
|
|
|
197
197
|
failedId,
|
|
198
198
|
errorMessage,
|
|
199
199
|
stopReason: opts.stopReason,
|
|
200
|
-
|
|
200
|
+
decisionModel: opts.decisionModel,
|
|
201
201
|
registry: opts.registry,
|
|
202
202
|
now,
|
|
203
203
|
blocked: opts.blocked,
|
package/src/idle.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import type { ClassifierContext, ClassifierQuestion, Message } from "@earendil-works/pi-ai";
|
|
32
32
|
import { callClassifier, readChoice, readConfidence, type NoClassifierReason } from "./classify.ts";
|
|
33
33
|
import type { ClassifierPort } from "./ports.ts";
|
|
34
|
-
import type { IdleResetConfig,
|
|
34
|
+
import type { IdleResetConfig, DecisionModelConfig, ModelId } from "./types.ts";
|
|
35
35
|
|
|
36
36
|
/** Every accepted `idleReset:` value, in menu order. */
|
|
37
37
|
export const IDLE_RESET_OPTIONS: readonly IdleResetConfig[] = [
|
|
@@ -136,7 +136,7 @@ export interface IdleResetDecision {
|
|
|
136
136
|
|
|
137
137
|
export interface IdleResetInput {
|
|
138
138
|
registry: ClassifierPort;
|
|
139
|
-
|
|
139
|
+
decisionModel: DecisionModelConfig | undefined;
|
|
140
140
|
/** The virtual model's `idleReset:` setting. */
|
|
141
141
|
option: IdleResetConfig | undefined;
|
|
142
142
|
messages: readonly Message[];
|
|
@@ -210,7 +210,7 @@ async function askClassifier(input: IdleResetInput, idle: number): Promise<IdleR
|
|
|
210
210
|
preference_order: input.candidates.length > 0 ? input.candidates.join(", ") : "(none)",
|
|
211
211
|
blocked_models: input.blockedNotes.length > 0 ? input.blockedNotes.join(", ") : "(none)",
|
|
212
212
|
};
|
|
213
|
-
const call = await callClassifier(input.registry, input.
|
|
213
|
+
const call = await callClassifier(input.registry, input.decisionModel, {
|
|
214
214
|
state,
|
|
215
215
|
questions: { idle_reset: criteria },
|
|
216
216
|
});
|