@kontextmind/kxm 0.7.150 → 0.7.152
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +14 -0
- package/docs/architecture/agent-path.md +5 -0
- package/docs/operations/troubleshooting.md +1 -0
- package/docs/reference/cli-reference.md +4 -4
- package/docs/reference/config-reference.md +17 -4
- package/docs/reference/harness-routing.md +7 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +153 -60
- package/plugins/kxm/dist/core.js +22 -3
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +676 -130
- package/plugins/kxm/dist/runtime.js +720 -164
- package/plugins/kxm/dist/server.js +1 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/system.ts +4 -1
- package/plugins/kxm/src/engine-fold.ts +1 -0
- package/plugins/kxm/src/engine.ts +264 -22
- package/plugins/kxm/src/improve-sources.ts +17 -3
- package/plugins/kxm/src/improve.ts +14 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/policy-draft.mjs +2 -2
- package/plugins/kxm/src/role.ts +1 -1
- package/plugins/kxm/src/route-fallback.ts +504 -0
- package/plugins/kxm/src/route-switch.ts +49 -0
- package/plugins/kxm/src/routing.ts +24 -1
- package/schemas/common.schema.json +2 -1
- package/schemas/role.schema.json +1 -1
- package/schemas/run-event.schema.json +20 -1
- package/scripts/ci-unit-shard.mjs +29 -13
|
@@ -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
|
}
|
package/schemas/role.schema.json
CHANGED
|
@@ -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
|
},
|