@kontextmind/kxm 0.7.150 → 0.7.151

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,504 @@
1
+ /**
2
+ * Opt-in mid-attempt route walk (role authority P4).
3
+ *
4
+ * A role walks only when `policy.fallback.onError` lists the failure class.
5
+ * An empty or absent list keeps today's single-route failure. Cancellation,
6
+ * policy refusal, authentication, an unhosted model, gate failure, tool
7
+ * policy, and admission errors never walk.
8
+ *
9
+ * The chain is the role roster (own entries, then `extends`), with each
10
+ * route's `fallbacks` selectors expanded one level. Selectors use the
11
+ * profile / tag / provider+model shapes from the model schema.
12
+ */
13
+
14
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { parse } from "yaml";
17
+ import { oneShotWriterArgs, validateHarnessModelPair } from "./harness.ts";
18
+ import { listRoleBindings, loadRoutePolicy } from "./routes.ts";
19
+ import { EFFORTS, WALKABLE_CLASSES, type WalkableClass } from "./route-switch.ts";
20
+ import { findYamlBasename } from "./workforce-names.mjs";
21
+
22
+ export { WALKABLE_CLASSES, type WalkableClass } from "./route-switch.ts";
23
+
24
+ export type FailureClass =
25
+ | WalkableClass
26
+ | "cancelled"
27
+ | "policy_refusal"
28
+ | "auth"
29
+ | "unhosted_model"
30
+ | "gate"
31
+ | "tool_policy"
32
+ | "admission"
33
+ | "unknown";
34
+
35
+ export interface ClassifiedFailure {
36
+ class: FailureClass;
37
+ walkable: boolean;
38
+ }
39
+
40
+ export interface FallbackSelector {
41
+ profile?: string;
42
+ tag?: string;
43
+ capabilities?: readonly string[];
44
+ provider?: string;
45
+ model?: string;
46
+ }
47
+
48
+ export interface FallbackCandidate {
49
+ routeId: string;
50
+ harness: string;
51
+ provider: string;
52
+ model: string;
53
+ selector: string;
54
+ vendor: string;
55
+ permissions: readonly string[];
56
+ priority: number;
57
+ effort?: string;
58
+ }
59
+
60
+ export interface FallbackPolicy {
61
+ onError: readonly WalkableClass[];
62
+ /** Switches allowed after the starting route. Defaults to 1 when the role opts in and omits the field. */
63
+ maxSwitches: number;
64
+ revert: "next_run" | "never";
65
+ }
66
+
67
+ export interface ChainRosterEntry {
68
+ route: string;
69
+ effort?: string;
70
+ }
71
+
72
+ export interface ChainModel {
73
+ routeId: string;
74
+ harness: string;
75
+ model: string;
76
+ vendor: string;
77
+ status: string;
78
+ permissions: readonly string[];
79
+ tags: readonly string[];
80
+ capabilities: readonly string[];
81
+ priority: number;
82
+ fallbacks: readonly FallbackSelector[];
83
+ hosted: boolean;
84
+ writerReady: boolean;
85
+ onWriterRoster: boolean;
86
+ }
87
+
88
+ export interface RouteSessionMessage {
89
+ role: "user" | "assistant" | "tool";
90
+ text: string;
91
+ }
92
+
93
+ export interface RouteSessionCarry {
94
+ transcript: RouteSessionMessage[];
95
+ }
96
+
97
+ const CARRY_ROLES = new Set(["user", "assistant", "tool"]);
98
+ const MAX_CARRY_MESSAGES = 8;
99
+ const MAX_CARRY_TEXT = 2000;
100
+ const MAX_CARRY_TOTAL = 8000;
101
+ const DEFAULT_MAX_SWITCHES = 1;
102
+
103
+ export function classifyRouteFailure(input: {
104
+ code?: string | undefined;
105
+ text?: string | undefined;
106
+ httpStatus?: number | undefined;
107
+ name?: string | undefined;
108
+ }): ClassifiedFailure {
109
+ const code = (input.code ?? "").toLowerCase();
110
+ const text = `${code} ${input.text ?? ""}`.toLowerCase();
111
+ const name = (input.name ?? "").toLowerCase();
112
+ const status = input.httpStatus;
113
+
114
+ const result = (failureClass: FailureClass): ClassifiedFailure => ({
115
+ class: failureClass,
116
+ walkable: (WALKABLE_CLASSES as readonly string[]).includes(failureClass),
117
+ });
118
+
119
+ if (name === "aborterror" || /\b(?:user_cancelled|cancelled|cancellation)\b/.test(text) || code === "process_aborted" || text.includes("abort")) {
120
+ return result("cancelled");
121
+ }
122
+ if (/policy_refusal|content_policy|tool_policy|usage policy|safety/.test(text) || text.includes("policy refusal")) {
123
+ return result("policy_refusal");
124
+ }
125
+ if (code.includes("not_authenticated") || /unauthori[sz]ed|authentication|invalid api key|auth_failed|\bauth\b/.test(text) || status === 401 || status === 403) {
126
+ return result("auth");
127
+ }
128
+ if (/permission_profile_unaudited|writer_profile_unaudited|tool_policy/.test(text)) {
129
+ return result("tool_policy");
130
+ }
131
+ if (/harness_unhosted_model|unhosted|does not host/.test(text)) {
132
+ return result("unhosted_model");
133
+ }
134
+ if (code.startsWith("gate_") || text.includes("gate_unsupported") || text.includes("gate_failed")) {
135
+ return result("gate");
136
+ }
137
+ if (code === "producer_route_not_admitted" || text.includes("not_admitted") || text.includes("not admitted")) {
138
+ return result("admission");
139
+ }
140
+ if (status === 413 || /context_overflow|context length|context window|maximum context|token limit|prompt too long|context overflow/.test(text)) {
141
+ return result("context_overflow");
142
+ }
143
+ if (status === 429 || /rate_limit|rate limit|ratelimit|too many requests/.test(text)) {
144
+ return result("rate_limit");
145
+ }
146
+ if (
147
+ status === 408 || status === 502 || status === 504
148
+ || code === "process_timeout"
149
+ || /etimedout|econnreset|econnrefused|enotfound|eai_again|socket hang up|\btimeout\b|timed out|\btransport\b/.test(text)
150
+ ) {
151
+ return result("transport");
152
+ }
153
+ if (
154
+ status === 500 || status === 503
155
+ || /provider_unavailable|model unavailable|service unavailable|overloaded|model_not_found|no available model/.test(text)
156
+ ) {
157
+ return result("provider_unavailable");
158
+ }
159
+ return result("unknown");
160
+ }
161
+
162
+ export function parseFallbackPolicy(value: unknown): FallbackPolicy | undefined {
163
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
164
+ const record = value as Record<string, unknown>;
165
+ const onErrorRaw = record.onError;
166
+ if (!Array.isArray(onErrorRaw) || onErrorRaw.length === 0) return undefined;
167
+ if (new Set(onErrorRaw).size !== onErrorRaw.length) return undefined;
168
+ const onError: WalkableClass[] = [];
169
+ for (const item of onErrorRaw) {
170
+ if (typeof item !== "string" || !(WALKABLE_CLASSES as readonly string[]).includes(item)) return undefined;
171
+ onError.push(item as WalkableClass);
172
+ }
173
+ let maxSwitches = DEFAULT_MAX_SWITCHES;
174
+ if (record.maxSwitches !== undefined) {
175
+ if (!Number.isInteger(record.maxSwitches) || (record.maxSwitches as number) < 0) return undefined;
176
+ maxSwitches = record.maxSwitches as number;
177
+ }
178
+ let revert: FallbackPolicy["revert"] = "next_run";
179
+ if (record.revert !== undefined) {
180
+ if (record.revert !== "next_run" && record.revert !== "never") return undefined;
181
+ revert = record.revert;
182
+ }
183
+ return { onError, maxSwitches, revert };
184
+ }
185
+
186
+ function admittedSelector(model: ChainModel, admitted: ReadonlySet<string>, disabled: ReadonlySet<string>): string | undefined {
187
+ const candidates = [model.model, `${model.vendor}/${model.model}`, `${model.harness}/${model.model}`];
188
+ const selector = candidates.find((candidate) => admitted.has(candidate));
189
+ if (!selector || disabled.has(selector)) return undefined;
190
+ return selector;
191
+ }
192
+
193
+ function splitSelector(selector: string): { provider: string; model: string } | undefined {
194
+ const slash = selector.indexOf("/");
195
+ if (slash <= 0 || slash === selector.length - 1) return undefined;
196
+ return { provider: selector.slice(0, slash), model: selector.slice(slash + 1) };
197
+ }
198
+
199
+ function eligible(
200
+ model: ChainModel,
201
+ admitted: ReadonlySet<string>,
202
+ disabled: ReadonlySet<string>,
203
+ liveWrite: boolean,
204
+ ): FallbackCandidate | undefined {
205
+ if (model.status !== "admitted" || !model.hosted) return undefined;
206
+ if (liveWrite && (!model.permissions.includes("edit") || !model.writerReady || !model.onWriterRoster)) return undefined;
207
+ const selector = admittedSelector(model, admitted, disabled);
208
+ if (!selector) return undefined;
209
+ const parts = splitSelector(selector);
210
+ if (!parts) return undefined;
211
+ return {
212
+ routeId: model.routeId,
213
+ harness: model.harness,
214
+ provider: parts.provider,
215
+ model: parts.model,
216
+ selector,
217
+ vendor: model.vendor,
218
+ permissions: model.permissions,
219
+ priority: model.priority,
220
+ };
221
+ }
222
+
223
+ function selectorsFor(fallback: FallbackSelector, models: readonly ChainModel[]): ChainModel[] {
224
+ if (fallback.profile) {
225
+ const model = models.find((item) => item.routeId === fallback.profile);
226
+ return model ? [model] : [];
227
+ }
228
+ if (fallback.tag) {
229
+ const required = new Set(fallback.capabilities ?? []);
230
+ return models
231
+ .filter((item) => item.tags.includes(fallback.tag!) && [...required].every((capability) => item.capabilities.includes(capability)))
232
+ .slice()
233
+ .sort((left, right) => right.priority - left.priority || left.routeId.localeCompare(right.routeId));
234
+ }
235
+ if (fallback.provider && fallback.model) {
236
+ return models.filter((item) => item.vendor === fallback.provider && item.model === fallback.model);
237
+ }
238
+ return [];
239
+ }
240
+
241
+ export function buildFallbackChain(input: {
242
+ roster: readonly ChainRosterEntry[];
243
+ extendsRoster?: readonly ChainRosterEntry[] | undefined;
244
+ models: readonly ChainModel[];
245
+ admitted: readonly string[];
246
+ disabled: readonly string[];
247
+ liveWrite: boolean;
248
+ criticVendors?: readonly string[] | undefined;
249
+ vendorIndependenceRequired?: boolean | undefined;
250
+ }): FallbackCandidate[] {
251
+ const admitted = new Set(input.admitted);
252
+ const disabled = new Set(input.disabled);
253
+ const critics = new Set(input.criticVendors ?? []);
254
+ const byId = new Map(input.models.map((model) => [model.routeId, model]));
255
+ const chain: FallbackCandidate[] = [];
256
+ const seen = new Set<string>();
257
+ const entries = [...input.roster, ...(input.extendsRoster ?? [])];
258
+
259
+ const push = (model: ChainModel | undefined, effort: string | undefined): void => {
260
+ if (!model || seen.has(model.routeId)) return;
261
+ const candidate = eligible(model, admitted, disabled, input.liveWrite);
262
+ if (!candidate) return;
263
+ if (input.vendorIndependenceRequired && chain.length > 0 && critics.has(candidate.vendor)) return;
264
+ seen.add(model.routeId);
265
+ const resolvedEffort = effort && EFFORTS.has(effort) ? effort : undefined;
266
+ chain.push(resolvedEffort ? { ...candidate, effort: resolvedEffort } : candidate);
267
+ };
268
+
269
+ for (const entry of entries) {
270
+ const model = byId.get(entry.route);
271
+ push(model, entry.effort);
272
+ if (!model) continue;
273
+ for (const fallback of model.fallbacks) {
274
+ for (const resolved of selectorsFor(fallback, input.models)) push(resolved, entry.effort);
275
+ }
276
+ }
277
+ return chain;
278
+ }
279
+
280
+ export function decideRouteSwitch(input: {
281
+ policy: FallbackPolicy | undefined;
282
+ chain: readonly FallbackCandidate[];
283
+ currentRouteId: string;
284
+ switchesUsed: number;
285
+ failure: ClassifiedFailure;
286
+ currentEffort?: string | undefined;
287
+ }): { action: "continue"; cause: "disabled" | "not_walkable" | "class_not_opted_in" | "budget" | "no_candidate" }
288
+ | { action: "switch"; to: FallbackCandidate; reason: WalkableClass; effort?: string } {
289
+ if (!input.policy || input.policy.onError.length === 0) return { action: "continue", cause: "disabled" };
290
+ if (!input.failure.walkable) return { action: "continue", cause: "not_walkable" };
291
+ if (!(input.policy.onError as readonly string[]).includes(input.failure.class)) {
292
+ return { action: "continue", cause: "class_not_opted_in" };
293
+ }
294
+ if (input.switchesUsed >= input.policy.maxSwitches) return { action: "continue", cause: "budget" };
295
+ const index = input.chain.findIndex((candidate) => candidate.routeId === input.currentRouteId);
296
+ const next = index >= 0 ? input.chain[index + 1] : undefined;
297
+ if (!next) return { action: "continue", cause: "no_candidate" };
298
+ const effort = next.effort ?? (input.currentEffort && EFFORTS.has(input.currentEffort) ? input.currentEffort : undefined);
299
+ return {
300
+ action: "switch",
301
+ to: next,
302
+ reason: input.failure.class as WalkableClass,
303
+ ...(effort ? { effort } : {}),
304
+ };
305
+ }
306
+
307
+ export function parseSessionCarry(value: unknown): RouteSessionCarry | undefined {
308
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
309
+ const transcript = (value as { transcript?: unknown }).transcript;
310
+ if (!Array.isArray(transcript) || transcript.length === 0) return undefined;
311
+ const messages: RouteSessionMessage[] = [];
312
+ let total = 0;
313
+ for (const item of transcript) {
314
+ if (messages.length >= MAX_CARRY_MESSAGES) break;
315
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
316
+ const role = (item as { role?: unknown }).role;
317
+ const text = (item as { text?: unknown }).text;
318
+ if (typeof role !== "string" || !CARRY_ROLES.has(role) || typeof text !== "string" || text.length === 0) continue;
319
+ const clipped = text.slice(0, MAX_CARRY_TEXT);
320
+ if (total + clipped.length > MAX_CARRY_TOTAL) break;
321
+ total += clipped.length;
322
+ messages.push({ role: role as RouteSessionMessage["role"], text: clipped });
323
+ }
324
+ return messages.length > 0 ? { transcript: messages } : undefined;
325
+ }
326
+
327
+ export function mergeSessionCarry(left?: RouteSessionCarry | undefined, right?: RouteSessionCarry | undefined): RouteSessionCarry | undefined {
328
+ return parseSessionCarry({ transcript: [...(left?.transcript ?? []), ...(right?.transcript ?? [])] });
329
+ }
330
+
331
+ export function carryForwardPrompt(prompt: string | undefined, carry: RouteSessionCarry | undefined): string | undefined {
332
+ if (!carry || carry.transcript.length === 0) return prompt;
333
+ const lines = carry.transcript.map((message) => `${message.role}: ${message.text}`);
334
+ const block = `Prior attempt transcript:\n${lines.join("\n")}`;
335
+ if (!prompt || prompt.length === 0) return block;
336
+ return `${prompt}\n\n${block}`;
337
+ }
338
+
339
+ function readYaml(file: string): Record<string, unknown> | undefined {
340
+ if (!existsSync(file)) return undefined;
341
+ try {
342
+ const parsed = parse(readFileSync(file, "utf8")) as unknown;
343
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
344
+ return parsed as Record<string, unknown>;
345
+ } catch {
346
+ return undefined;
347
+ }
348
+ }
349
+
350
+ function stringList(value: unknown): string[] {
351
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
352
+ }
353
+
354
+ function readSelectors(projectRoot: string, value: unknown): FallbackSelector[] {
355
+ if (!Array.isArray(value)) return [];
356
+ const modelsDir = join(projectRoot, ".kxm", "models");
357
+ const selectors: FallbackSelector[] = [];
358
+ for (const item of value) {
359
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
360
+ const record = item as Record<string, unknown>;
361
+ if (typeof record.profile === "string") {
362
+ selectors.push({ profile: findYamlBasename(modelsDir, record.profile, "route") ?? record.profile });
363
+ } else if (typeof record.tag === "string") {
364
+ selectors.push({
365
+ tag: record.tag,
366
+ ...(Array.isArray(record.capabilities) ? { capabilities: stringList(record.capabilities) } : {}),
367
+ });
368
+ } else if (typeof record.provider === "string" && typeof record.model === "string") {
369
+ selectors.push({ provider: record.provider, model: record.model });
370
+ }
371
+ }
372
+ return selectors;
373
+ }
374
+
375
+ function loadChainModel(projectRoot: string, routeId: string, writerIds: ReadonlySet<string>): ChainModel | undefined {
376
+ const modelsDir = join(projectRoot, ".kxm", "models");
377
+ const fileId = findYamlBasename(modelsDir, routeId, "route") ?? routeId;
378
+ const doc = readYaml(join(modelsDir, `${fileId}.yaml`));
379
+ if (!doc || doc.schema !== "kxm.model.v2") return undefined;
380
+ const harness = typeof doc.harness === "string" ? doc.harness : "";
381
+ const model = typeof doc.model === "string" ? doc.model : "";
382
+ const vendor = typeof doc.vendor === "string" ? doc.vendor : "";
383
+ if (!harness || !model || !vendor) return undefined;
384
+ const permissions = stringList(doc.permissions);
385
+ const selector = [model, `${vendor}/${model}`, `${harness}/${model}`].find((candidate) => candidate.includes("/")) ?? `${vendor}/${model}`;
386
+ const parts = splitSelector(selector);
387
+ const hosted = parts ? validateHarnessModelPair(harness, { provider: parts.provider, model: parts.model }).valid : false;
388
+ return {
389
+ routeId: fileId,
390
+ harness,
391
+ model,
392
+ vendor,
393
+ status: typeof doc.status === "string" ? doc.status : "",
394
+ permissions,
395
+ tags: stringList(doc.tags),
396
+ capabilities: stringList(doc.capabilities),
397
+ priority: typeof doc.priority === "number" ? doc.priority : 0,
398
+ fallbacks: readSelectors(projectRoot, doc.fallbacks),
399
+ hosted,
400
+ writerReady: oneShotWriterArgs(harness) !== undefined,
401
+ onWriterRoster: writerIds.has(fileId) || writerIds.has(routeId),
402
+ };
403
+ }
404
+
405
+ function canonicalRoster(projectRoot: string, entries: readonly ChainRosterEntry[]): ChainRosterEntry[] {
406
+ const modelsDir = join(projectRoot, ".kxm", "models");
407
+ return entries.map((entry) => {
408
+ const route = findYamlBasename(modelsDir, entry.route, "route") ?? entry.route;
409
+ return entry.effort ? { route, effort: entry.effort } : { route };
410
+ });
411
+ }
412
+
413
+ function rosterEntries(doc: Record<string, unknown> | undefined): ChainRosterEntry[] {
414
+ if (!Array.isArray(doc?.roster)) return [];
415
+ const entries: ChainRosterEntry[] = [];
416
+ for (const item of doc.roster) {
417
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
418
+ const route = (item as { route?: unknown }).route;
419
+ const effort = (item as { effort?: unknown }).effort;
420
+ if (typeof route !== "string" || route.length === 0) continue;
421
+ entries.push(typeof effort === "string" ? { route, effort } : { route });
422
+ }
423
+ return entries;
424
+ }
425
+
426
+ function loadModelsFor(projectRoot: string, roster: readonly ChainRosterEntry[], writerIds: ReadonlySet<string>): ChainModel[] {
427
+ const modelsDir = join(projectRoot, ".kxm", "models");
428
+ const wanted = new Set<string>();
429
+ for (const entry of roster) wanted.add(entry.route);
430
+ if (existsSync(modelsDir)) {
431
+ for (const name of readdirSync(modelsDir)) {
432
+ if (!name.endsWith(".yaml") || name === "inventory.yaml") continue;
433
+ wanted.add(name.slice(0, -5));
434
+ }
435
+ }
436
+ const models: ChainModel[] = [];
437
+ const seen = new Set<string>();
438
+ for (const routeId of wanted) {
439
+ const model = loadChainModel(projectRoot, routeId, writerIds);
440
+ if (!model || seen.has(model.routeId)) continue;
441
+ seen.add(model.routeId);
442
+ models.push(model);
443
+ }
444
+ return models;
445
+ }
446
+
447
+ function criticVendors(projectRoot: string, writerIds: ReadonlySet<string>): string[] {
448
+ const dir = join(projectRoot, ".kxm", "roles");
449
+ if (!existsSync(dir)) return [];
450
+ const vendors: string[] = [];
451
+ for (const name of readdirSync(dir)) {
452
+ if (!name.endsWith(".yaml")) continue;
453
+ const doc = readYaml(join(dir, name));
454
+ const purpose = doc?.purpose;
455
+ if (purpose !== "reviewer-arch" && purpose !== "reviewer-cli") continue;
456
+ const first = rosterEntries(doc)[0];
457
+ if (!first) continue;
458
+ const model = loadChainModel(projectRoot, first.route, writerIds);
459
+ if (model?.status === "admitted") vendors.push(model.vendor);
460
+ }
461
+ return vendors;
462
+ }
463
+
464
+ export function loadAgentFallback(
465
+ projectRoot: string,
466
+ agentId: string,
467
+ options: { liveWrite: boolean },
468
+ ): { policy: FallbackPolicy; chain: FallbackCandidate[]; roleId: string } | undefined {
469
+ const agentsDir = join(projectRoot, ".kxm", "agents");
470
+ const agentFile = findYamlBasename(agentsDir, agentId, "agent") ?? agentId;
471
+ const agent = readYaml(join(agentsDir, `${agentFile}.yaml`));
472
+ const roleId = typeof agent?.role === "string" ? agent.role : "";
473
+ if (!roleId) return undefined;
474
+ const role = readYaml(join(projectRoot, ".kxm", "roles", `${roleId}.yaml`));
475
+ if (!role) return undefined;
476
+ const policyRecord = role.policy && typeof role.policy === "object" ? (role.policy as Record<string, unknown>).fallback : undefined;
477
+ const policy = parseFallbackPolicy(policyRecord);
478
+ if (!policy) return undefined;
479
+ const own = canonicalRoster(projectRoot, rosterEntries(role));
480
+ const seenRoles = new Set<string>([roleId]);
481
+ let extendsRoster: ChainRosterEntry[] = [];
482
+ let cursor = typeof role.extends === "string" ? role.extends : "";
483
+ while (cursor && !seenRoles.has(cursor)) {
484
+ seenRoles.add(cursor);
485
+ const parent = readYaml(join(projectRoot, ".kxm", "roles", `${cursor}.yaml`));
486
+ extendsRoster = extendsRoster.concat(canonicalRoster(projectRoot, rosterEntries(parent)));
487
+ cursor = typeof parent?.extends === "string" ? parent.extends : "";
488
+ }
489
+ const writerIds = new Set(listRoleBindings(projectRoot).writer ?? []);
490
+ const models = loadModelsFor(projectRoot, [...own, ...extendsRoster], writerIds);
491
+ const routePolicy = loadRoutePolicy(projectRoot);
492
+ const independence = Boolean(role.policy && typeof role.policy === "object" && (role.policy as { vendorIndependenceRequired?: unknown }).vendorIndependenceRequired);
493
+ const chain = buildFallbackChain({
494
+ roster: own,
495
+ extendsRoster,
496
+ models,
497
+ admitted: routePolicy.admitted,
498
+ disabled: routePolicy.disabled,
499
+ liveWrite: options.liveWrite,
500
+ criticVendors: independence ? criticVendors(projectRoot, writerIds) : [],
501
+ vendorIndependenceRequired: independence,
502
+ });
503
+ return { policy, chain, roleId };
504
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Route-switch records for reports.
3
+ *
4
+ * This module stays free of harness, route, and YAML imports so the Pi
5
+ * extension load graph can read a switch without pulling the fallback walker.
6
+ */
7
+
8
+ export const WALKABLE_CLASSES = ["rate_limit", "transport", "provider_unavailable", "context_overflow"] as const;
9
+ export type WalkableClass = (typeof WALKABLE_CLASSES)[number];
10
+
11
+ export const EFFORTS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
12
+
13
+ export interface RouteSwitchRecord {
14
+ from: string;
15
+ to: string;
16
+ reason: WalkableClass;
17
+ effort?: string;
18
+ attemptId: string;
19
+ stepId: string;
20
+ runId?: string;
21
+ }
22
+
23
+ export function parseRouteSwitchRecord(
24
+ payload: unknown,
25
+ identity: { attemptId?: string | undefined; stepId?: string | undefined; runId?: string | undefined } = {},
26
+ ): RouteSwitchRecord | undefined {
27
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
28
+ const routeSwitch = (payload as { routeSwitch?: unknown }).routeSwitch;
29
+ if (!routeSwitch || typeof routeSwitch !== "object" || Array.isArray(routeSwitch)) return undefined;
30
+ const record = routeSwitch as Record<string, unknown>;
31
+ const from = record.from;
32
+ const to = record.to;
33
+ const reason = record.reason;
34
+ if (typeof from !== "string" || typeof to !== "string" || typeof reason !== "string") return undefined;
35
+ if (!(WALKABLE_CLASSES as readonly string[]).includes(reason)) return undefined;
36
+ const attemptId = typeof identity.attemptId === "string" ? identity.attemptId : typeof (payload as { attemptId?: unknown }).attemptId === "string" ? (payload as { attemptId: string }).attemptId : "";
37
+ const stepId = typeof identity.stepId === "string" ? identity.stepId : typeof (payload as { stepId?: unknown }).stepId === "string" ? (payload as { stepId: string }).stepId : "";
38
+ if (!attemptId || !stepId) return undefined;
39
+ const effort = typeof record.effort === "string" && EFFORTS.has(record.effort) ? record.effort : undefined;
40
+ return {
41
+ from,
42
+ to,
43
+ reason: reason as WalkableClass,
44
+ attemptId,
45
+ stepId,
46
+ ...(effort ? { effort } : {}),
47
+ ...(identity.runId ? { runId: identity.runId } : {}),
48
+ };
49
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { calculateModelCost, type PriceCatalog } from "./price-calc.ts";
3
+ import type { RouteSwitchRecord } from "./route-switch.ts";
3
4
 
4
5
  /**
5
6
  * Model/harness routing telemetry (v0.5, issue #40).
@@ -531,6 +532,8 @@ export interface RoutingReport {
531
532
  generatedAt: string;
532
533
  totalAttempts: number;
533
534
  rows: RoutingReportRow[];
535
+ /** Present when the caller supplied at least one route switch. */
536
+ routeSwitches?: RouteSwitchRecord[] | undefined;
534
537
  }
535
538
 
536
539
  export interface GenerateRoutingReportOptions {
@@ -538,6 +541,7 @@ export interface GenerateRoutingReportOptions {
538
541
  includeEquivalentListCost?: boolean | undefined;
539
542
  halfLifeDays?: number | undefined;
540
543
  now?: () => string;
544
+ routeSwitches?: readonly RouteSwitchRecord[] | undefined;
541
545
  }
542
546
 
543
547
  /**
@@ -641,12 +645,14 @@ export function generateRoutingReport(
641
645
  options: GenerateRoutingReportOptions = {},
642
646
  ): RoutingReport {
643
647
  const generatedAt = options.now ? options.now() : new Date().toISOString();
648
+ const routeSwitches = options.routeSwitches?.filter((item) => item.from && item.to) ?? [];
644
649
  if (records.length === 0) {
645
650
  return {
646
651
  schema: ROUTING_REPORT_SCHEMA,
647
652
  generatedAt,
648
653
  totalAttempts: 0,
649
654
  rows: [],
655
+ ...(routeSwitches.length > 0 ? { routeSwitches: [...routeSwitches] } : {}),
650
656
  };
651
657
  }
652
658
 
@@ -860,6 +866,7 @@ export function generateRoutingReport(
860
866
  generatedAt,
861
867
  totalAttempts: records.length,
862
868
  rows,
869
+ ...(routeSwitches.length > 0 ? { routeSwitches: [...routeSwitches] } : {}),
863
870
  };
864
871
  }
865
872
 
@@ -867,9 +874,17 @@ export function formatRoutingReport(
867
874
  report: RoutingReport,
868
875
  options: { equivalentListCost?: boolean } = {},
869
876
  ): string {
870
- if (report.rows.length === 0) {
877
+ if (report.rows.length === 0 && (!report.routeSwitches || report.routeSwitches.length === 0)) {
871
878
  return "no routing records to report";
872
879
  }
880
+ if (report.rows.length === 0) {
881
+ const lines = ["no routing records to report", "", `Route switches (${report.routeSwitches!.length})`];
882
+ for (const item of report.routeSwitches!) {
883
+ const effort = item.effort ? ` effort ${item.effort}` : "";
884
+ lines.push(` ${item.from} -> ${item.to} (${item.reason}${effort}) step ${item.stepId}`);
885
+ }
886
+ return lines.join("\n");
887
+ }
873
888
 
874
889
  const showListCost = Boolean(options.equivalentListCost);
875
890
  const headers = [
@@ -930,6 +945,14 @@ export function formatRoutingReport(
930
945
  if (report.rows.some((r) => r.flagged)) {
931
946
  lines.push("* = unknown-cost attempts present (never ranked cheapest)");
932
947
  }
948
+ if (report.routeSwitches && report.routeSwitches.length > 0) {
949
+ lines.push("");
950
+ lines.push(`Route switches (${report.routeSwitches.length})`);
951
+ for (const item of report.routeSwitches) {
952
+ const effort = item.effort ? ` effort ${item.effort}` : "";
953
+ lines.push(` ${item.from} -> ${item.to} (${item.reason}${effort}) step ${item.stepId}`);
954
+ }
955
+ }
933
956
 
934
957
  return lines.join("\n");
935
958
  }
@@ -197,7 +197,8 @@
197
197
  "lease.lost",
198
198
  "delivery.status_changed",
199
199
  "candidate.created",
200
- "routing.attempt.recorded"
200
+ "routing.attempt.recorded",
201
+ "routing.route_switched"
201
202
  ]
202
203
  },
203
204
  "effectClass": {
@@ -92,7 +92,7 @@
92
92
  "properties": {
93
93
  "onError": {
94
94
  "type": "array",
95
- "items": { "enum": ["rate_limit", "transport", "provider_unavailable"] },
95
+ "items": { "enum": ["rate_limit", "transport", "provider_unavailable", "context_overflow"] },
96
96
  "uniqueItems": true,
97
97
  "default": []
98
98
  },
@@ -297,6 +297,14 @@
297
297
  }
298
298
  }
299
299
  },
300
+ {
301
+ "if": { "properties": { "eventType": { "const": "routing.route_switched" } }, "required": ["eventType"] },
302
+ "then": {
303
+ "properties": {
304
+ "payload": { "type": "object", "required": ["routeSwitch", "attemptId", "stepId"] }
305
+ }
306
+ }
307
+ },
300
308
  {
301
309
  "if": { "properties": { "eventType": { "const": "run.drive_opened" } }, "required": ["eventType"] },
302
310
  "then": {
@@ -441,7 +449,18 @@
441
449
  "additionalProperties": { "type": "integer", "minimum": 0 },
442
450
  "maxProperties": 32
443
451
  },
444
- "routing": { "$ref": "#/$defs/routingRecordV2" }
452
+ "routing": { "$ref": "#/$defs/routingRecordV2" },
453
+ "routeSwitch": {
454
+ "type": "object",
455
+ "required": ["from", "to", "reason"],
456
+ "additionalProperties": false,
457
+ "properties": {
458
+ "from": { "type": "string", "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", "minLength": 1, "maxLength": 64 },
459
+ "to": { "type": "string", "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", "minLength": 1, "maxLength": 64 },
460
+ "reason": { "enum": ["rate_limit", "transport", "provider_unavailable", "context_overflow"] },
461
+ "effort": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"] }
462
+ }
463
+ }
445
464
  },
446
465
  "additionalProperties": false
447
466
  },