@sangyaahub/pi-marg 1.0.1 → 1.0.4

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.
@@ -1,4 +1,5 @@
1
1
  export type AutoStage = "A" | "B" | "C" | "D";
2
+ export type FallbackLevel = "high" | "low";
2
3
  export type CandidateTier = "frontier" | "execution" | "general";
3
4
  export type RuntimeName = "OMP" | "Pi";
4
5
 
@@ -36,16 +37,30 @@ export interface ModelSelection extends ModelCandidate {
36
37
  }
37
38
 
38
39
  export interface ModelRouteState {
39
- version: 1;
40
+ version: 2;
40
41
  workType?: number;
41
42
  selections: Partial<Record<AutoStage, ModelSelection>>;
43
+ fallbacks: Partial<Record<FallbackLevel, ModelSelection>>;
44
+ activeStage?: AutoStage;
45
+ activeFallback?: FallbackLevel;
46
+ exhaustedSelectors?: string[];
47
+ handledFailureKeys?: string[];
48
+ lastFailover?: {
49
+ stage?: AutoStage;
50
+ failedSelector: string;
51
+ fallbackLevel: FallbackLevel;
52
+ activatedSelector: string;
53
+ switchedAt: string;
54
+ };
42
55
  }
43
56
 
44
57
  export interface ModelRouteParams {
45
- action: "catalog" | "select" | "activate" | "status";
58
+ action: "catalog" | "select" | "select_fallback" | "activate" | "status";
46
59
  stage?: AutoStage;
60
+ fallback?: FallbackLevel;
47
61
  target?: string;
48
62
  workType?: number;
63
+ provider?: string;
49
64
  }
50
65
 
51
66
  export interface ModelRoutePort {
@@ -62,8 +77,66 @@ export interface ModelRouteExecution {
62
77
  response: ReturnType<typeof toolResult>;
63
78
  }
64
79
 
80
+ export interface AutomaticFailoverExecution {
81
+ state: ModelRouteState;
82
+ switched?: {
83
+ level: FallbackLevel;
84
+ selection: ModelSelection;
85
+ };
86
+ exhausted: boolean;
87
+ stateChanged: boolean;
88
+ reusedCurrent?: boolean;
89
+ ignoredDuplicate?: boolean;
90
+ persistenceError?: string;
91
+ }
92
+
93
+ export interface TerminalUsageFailure {
94
+ role: "assistant";
95
+ stopReason: "error";
96
+ errorMessage: string;
97
+ provider?: string;
98
+ model?: string;
99
+ responseId?: string;
100
+ timestamp?: number;
101
+ }
102
+
103
+ export interface ToolUsageFailure {
104
+ selector: string;
105
+ key: string;
106
+ }
107
+
65
108
  export const MODEL_STATE_TYPE = "co.sangyaa.pi-marg.model-routing.v1";
66
109
  export const STAGES: AutoStage[] = ["A", "B", "C", "D"];
110
+ export const FALLBACK_LEVELS: FallbackLevel[] = ["high", "low"];
111
+
112
+ const USAGE_LIMIT_PATTERNS = [
113
+ /usage[\s_-]*limit/i,
114
+ /quota[^\n]{0,40}(?:exceeded|exhausted|reached|depleted|limit)/i,
115
+ /insufficient[_ -]?quota/i,
116
+ /monthly\s+usage\s+limit/i,
117
+ /weekly\s+(?:usage\s+)?limit/i,
118
+ /available\s+balance[^\n]{0,40}(?:\b0\b|depleted|exhausted|insufficient)/i,
119
+ /out\s+of\s+(?:budget|credits?)/i,
120
+ /credits?\s+(?:are\s+)?exhausted/i,
121
+ /agent\s+compute\s+units?[^\n]{0,40}(?:exhausted|depleted|limit|remaining\s*:?\s*0)/i,
122
+ /\bACUs?\b[^\n]*(?:exhausted|limit|remaining\s*:?\s*0)/i,
123
+ ];
124
+
125
+ const ERROR_TEXT_KEYS = ["error", "errorMessage", "message", "stderr", "output", "content"];
126
+ const ERROR_CONTAINER_KEYS = ["details", "result"];
127
+ const MAX_ERROR_TEXT_LENGTH = 32_000;
128
+ const MAX_ERROR_NODES = 200;
129
+ const MAX_HANDLED_FAILURE_KEYS = 32;
130
+ const EXACT_MODEL_EXECUTION_TOOLS = new Set([
131
+ "task",
132
+ "subagent",
133
+ "spawn_agent",
134
+ "run_agent",
135
+ "agent",
136
+ "workflowz",
137
+ "orchestrate",
138
+ "advisor",
139
+ ]);
67
140
 
68
141
  const FRONTIER_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
69
142
  ["claude-frontier", /(?:claude[^\n]*(?:fable|opus)|(?:fable|opus)[^\n]*claude)/i],
@@ -78,7 +151,87 @@ const EXECUTION_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
78
151
  ];
79
152
 
80
153
  export function emptyModelRouteState(): ModelRouteState {
81
- return { version: 1, selections: {} };
154
+ return { version: 2, selections: {}, fallbacks: {} };
155
+ }
156
+
157
+ export function isFallbackLevel(value: unknown): value is FallbackLevel {
158
+ return typeof value === "string" && FALLBACK_LEVELS.includes(value as FallbackLevel);
159
+ }
160
+
161
+ export function isUsageLimitErrorText(value: unknown): boolean {
162
+ if (typeof value !== "string" || value.trim().length === 0) return false;
163
+ return USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(value));
164
+ }
165
+
166
+ export function hasConfiguredFallback(state: ModelRouteState): boolean {
167
+ const high = state.fallbacks.high;
168
+ const low = state.fallbacks.low;
169
+ return Boolean(
170
+ high
171
+ && low
172
+ && high.access === "runtime-model"
173
+ && low.access === "runtime-model"
174
+ && high.identity !== low.identity,
175
+ );
176
+ }
177
+
178
+ export function findTerminalUsageLimitFailure(messages: readonly unknown[]): TerminalUsageFailure | undefined {
179
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
180
+ const message = asRecord(messages[index]);
181
+ if (message?.role !== "assistant") continue;
182
+ if (message.stopReason !== "error" || typeof message.errorMessage !== "string") return undefined;
183
+ if (!isUsageLimitErrorText(message.errorMessage)) return undefined;
184
+ return {
185
+ role: "assistant",
186
+ stopReason: "error",
187
+ errorMessage: message.errorMessage,
188
+ ...(typeof message.provider === "string" ? { provider: message.provider } : {}),
189
+ ...(typeof message.model === "string" ? { model: message.model } : {}),
190
+ ...(typeof message.responseId === "string" ? { responseId: message.responseId } : {}),
191
+ ...(typeof message.timestamp === "number" ? { timestamp: message.timestamp } : {}),
192
+ };
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ export function failedModelSelector(message: TerminalUsageFailure, current?: ModelLike): string {
198
+ if (message.provider && message.model) {
199
+ return `${message.provider}/${message.model}`;
200
+ }
201
+ return current?.provider && current?.id ? `${current.provider}/${current.id}` : "unknown-model";
202
+ }
203
+
204
+ export function modelFailureKey(message: TerminalUsageFailure): string {
205
+ if (message.responseId) return `model:${message.responseId}`;
206
+ return `model:${message.provider ?? "unknown"}/${message.model ?? "unknown"}:${message.timestamp ?? "no-time"}:${message.errorMessage}`;
207
+ }
208
+
209
+ export function modelToolUsageLimitFailure(event: unknown): ToolUsageFailure | undefined {
210
+ const record = asRecord(event);
211
+ const toolName = typeof record?.toolName === "string" ? record.toolName : "";
212
+ if (record?.isError !== true || !isModelExecutionTool(toolName)) return undefined;
213
+ if (!isUsageLimitErrorText(boundedErrorText(record.result))) return undefined;
214
+ return {
215
+ selector: isDevinExecutionTool(toolName) ? "devin/external-session" : `tool/${toolName}`,
216
+ key: `tool:${typeof record.toolCallId === "string" ? record.toolCallId : `${toolName}:${boundedErrorText(record.result)}`}`,
217
+ };
218
+ }
219
+
220
+ export function formatFailoverRecoveryMessage(
221
+ state: ModelRouteState,
222
+ failedSelector: string,
223
+ level: FallbackLevel,
224
+ target: string,
225
+ reusedCurrent = false,
226
+ ): string {
227
+ const stage = state.activeStage ? `Stage ${state.activeStage}` : "the active PiMarg stage";
228
+ return [
229
+ `[PiMarg auto-failover] ${failedSelector} reached its usage limit.`,
230
+ reusedCurrent
231
+ ? `Kept the active ${level} backup: ${target}.`
232
+ : `Switched to the ${level} backup: ${target}.`,
233
+ `Resume the current ${stage} work from the last safe point. Inspect existing files, tool results, and external state before repeating any side effect. Do not restart intake or the approved plan.`,
234
+ ].join("\n");
82
235
  }
83
236
 
84
237
  export function isStage(value: unknown): value is AutoStage {
@@ -89,7 +242,7 @@ export function requiredStagesForWorkType(workType: number): AutoStage[] {
89
242
  switch (workType) {
90
243
  case 2:
91
244
  case 3:
92
- return ["A", "C", "B", "D"];
245
+ return ["A", "B", "C", "D"];
93
246
  case 4:
94
247
  return ["A", "B", "D"];
95
248
  case 1:
@@ -102,6 +255,25 @@ export function requiredStagesForWorkType(workType: number): AutoStage[] {
102
255
  }
103
256
  }
104
257
 
258
+ export function ascendingStages(stages: readonly AutoStage[]): AutoStage[] {
259
+ return STAGES.filter((stage) => stages.includes(stage));
260
+ }
261
+
262
+ export function emptyCatalogSetupMessage(runtimeName: RuntimeName): string {
263
+ if (runtimeName === "OMP") {
264
+ return [
265
+ "No authenticated and enabled OMP models were found.",
266
+ "Set up any provider subscription with /login, verify with `omp models` or /model, then run /pi-marg again.",
267
+ "PiMarg picks up whatever models you configure in OMP; see MODEL-SETUP.md.",
268
+ ].join(" ");
269
+ }
270
+ return [
271
+ "No authenticated and available Pi models were found.",
272
+ "Configure any provider subscription or API credentials, verify with `pi --list-models`, then retry.",
273
+ "PiMarg picks up whatever models you configure in Pi; see MODEL-SETUP.md.",
274
+ ].join(" ");
275
+ }
276
+
105
277
  export function normalizeModelIdentity(value: string): string {
106
278
  let normalized = value
107
279
  .toLowerCase()
@@ -191,6 +363,115 @@ export function buildModelCatalog(models: ModelLike[], hasExternalDevin: boolean
191
363
  } satisfies Record<AutoStage, ModelCandidate[]>;
192
364
  }
193
365
 
366
+ export function buildFallbackCatalog(catalog: Record<AutoStage, ModelCandidate[]>) {
367
+ return {
368
+ high: catalog.A.filter((candidate) => candidate.access === "runtime-model"),
369
+ low: catalog.B.filter((candidate) => candidate.access === "runtime-model"),
370
+ } satisfies Record<FallbackLevel, ModelCandidate[]>;
371
+ }
372
+
373
+ export function numberedOptionLabel(index: number, text: string): string {
374
+ return `${index + 1}. ${text}`;
375
+ }
376
+
377
+ export function matchNumberedOption<T>(
378
+ answer: string | undefined,
379
+ items: readonly T[],
380
+ labelOf: (item: T) => string,
381
+ ): T | undefined {
382
+ if (!answer) return undefined;
383
+ return items.find((item, index) => numberedOptionLabel(index, labelOf(item)) === answer);
384
+ }
385
+
386
+ export function uniqueProviders(candidates: readonly ModelCandidate[]): string[] {
387
+ return [...new Set(candidates.map((candidate) => candidate.provider))].sort((left, right) => left.localeCompare(right));
388
+ }
389
+
390
+ export function candidatesForProvider(
391
+ candidates: readonly ModelCandidate[],
392
+ provider: string,
393
+ ): ModelCandidate[] {
394
+ return candidates.filter((candidate) => candidate.provider === provider);
395
+ }
396
+
397
+ export function formatCatalogPresentation(
398
+ runtimeName: RuntimeName,
399
+ catalog: Record<AutoStage, ModelCandidate[]>,
400
+ requiredStages: AutoStage[],
401
+ providerFilter?: string,
402
+ ) {
403
+ const notes = catalogNotes(runtimeName, catalog);
404
+ const orderedStages = ascendingStages(requiredStages);
405
+ const stages: Record<string, {
406
+ providers: Array<{ index: number; name: string; count: number }>;
407
+ providerFilter: string | null;
408
+ count: number;
409
+ options: Array<{
410
+ index: number;
411
+ selector: string;
412
+ label: string;
413
+ provider: string;
414
+ tier: CandidateTier;
415
+ choice: ModelCandidate["choice"];
416
+ }>;
417
+ }> = {};
418
+ const lines: string[] = [
419
+ `${runtimeName} model catalog`,
420
+ `Required stages: ${orderedStages.join(", ")}`,
421
+ "",
422
+ ];
423
+
424
+ for (const stage of orderedStages) {
425
+ const all = catalog[stage];
426
+ const providers = uniqueProviders(all).map((name, index) => ({
427
+ index: index + 1,
428
+ name,
429
+ count: candidatesForProvider(all, name).length,
430
+ }));
431
+ const shown = providerFilter ? candidatesForProvider(all, providerFilter) : [];
432
+ const options = shown.map((candidate, index) => ({
433
+ index: index + 1,
434
+ selector: candidate.selector,
435
+ label: candidate.label,
436
+ provider: candidate.provider,
437
+ tier: candidate.tier,
438
+ choice: candidate.choice,
439
+ }));
440
+ stages[stage] = {
441
+ providers,
442
+ providerFilter: providerFilter ?? null,
443
+ count: providerFilter ? shown.length : all.length,
444
+ options,
445
+ };
446
+
447
+ lines.push(`## Stage ${stage} (${all.length} models across ${providers.length} providers)`);
448
+ lines.push("Providers:");
449
+ for (const provider of providers) {
450
+ const marker = providerFilter === provider.name ? " ← selected" : "";
451
+ lines.push(` ${provider.index}. ${provider.name} (${provider.count})${marker}`);
452
+ }
453
+ if (!providerFilter) {
454
+ lines.push("Call catalog again with provider=<name> to list every model for that provider.");
455
+ } else {
456
+ lines.push(`Models for ${providerFilter} (${shown.length}):`);
457
+ for (const option of options) {
458
+ lines.push(` ${option.index}. ${option.label} — ${option.selector} [${option.tier}]`);
459
+ }
460
+ }
461
+ lines.push("");
462
+ }
463
+
464
+ for (const note of notes) lines.push(`- ${note}`);
465
+ lines.push("- Present every provider as 1,2,… then every model for the chosen provider as 1,2,… Never omit a logged-in provider.");
466
+ lines.push("- Select with action=select using the exact selector string.");
467
+ lines.push("- Also save two distinct runtime-only backups with action=select_fallback: high first, then low.");
468
+
469
+ return {
470
+ text: lines.join("\n"),
471
+ presentation: { runtime: runtimeName, requiredStages: orderedStages, stages, notes },
472
+ };
473
+ }
474
+
194
475
  export function validateDistinctSelection(
195
476
  state: ModelRouteState,
196
477
  stage: AutoStage,
@@ -208,29 +489,183 @@ export function validateDistinctSelection(
208
489
  export function restoreModelRouteState(entries: readonly any[]): ModelRouteState {
209
490
  let restored = emptyModelRouteState();
210
491
  for (const entry of entries) {
211
- if (entry?.type === "custom" && entry.customType === MODEL_STATE_TYPE && entry.data?.version === 1) {
212
- restored = entry.data as ModelRouteState;
492
+ if (entry?.type !== "custom" || entry.customType !== MODEL_STATE_TYPE) continue;
493
+ if (entry.data?.version === 2) {
494
+ restored = {
495
+ ...entry.data,
496
+ version: 2,
497
+ selections: entry.data.selections ?? {},
498
+ fallbacks: entry.data.fallbacks ?? {},
499
+ } as ModelRouteState;
500
+ continue;
501
+ }
502
+ if (entry.data?.version === 1) {
503
+ restored = {
504
+ version: 2,
505
+ workType: entry.data.workType,
506
+ selections: entry.data.selections ?? {},
507
+ fallbacks: {},
508
+ };
213
509
  }
214
510
  }
215
511
  return restored;
216
512
  }
217
513
 
514
+ export async function executeAutomaticFailover(
515
+ currentState: ModelRouteState,
516
+ failedSelector: string,
517
+ port: ModelRoutePort,
518
+ failureKey?: string,
519
+ ): Promise<AutomaticFailoverExecution> {
520
+ const failed = failedSelector.trim() || "unknown-model";
521
+ const handledFailureKeys = currentState.handledFailureKeys ?? [];
522
+ if (failureKey && handledFailureKeys.includes(failureKey)) {
523
+ return {
524
+ state: currentState,
525
+ exhausted: false,
526
+ stateChanged: false,
527
+ ignoredDuplicate: true,
528
+ };
529
+ }
530
+ const nextHandledFailureKeys = failureKey
531
+ ? [...handledFailureKeys, failureKey].slice(-MAX_HANDLED_FAILURE_KEYS)
532
+ : handledFailureKeys;
533
+ const exhausted = new Set(currentState.exhaustedSelectors ?? []);
534
+ exhausted.add(failed);
535
+ const activeLevel = currentState.activeFallback;
536
+ const activeSelection = activeLevel ? currentState.fallbacks[activeLevel] : undefined;
537
+
538
+ if (activeLevel && activeSelection && activeSelection.selector !== failed) {
539
+ const nextState: ModelRouteState = {
540
+ ...currentState,
541
+ handledFailureKeys: nextHandledFailureKeys,
542
+ exhaustedSelectors: [...exhausted],
543
+ };
544
+ const persistenceError = persistFailoverState(port, nextState);
545
+ return {
546
+ state: nextState,
547
+ switched: { level: activeLevel, selection: activeSelection },
548
+ exhausted: false,
549
+ stateChanged: true,
550
+ reusedCurrent: true,
551
+ ...(persistenceError ? { persistenceError } : {}),
552
+ };
553
+ }
554
+
555
+ const startingIndex = activeLevel ? FALLBACK_LEVELS.indexOf(activeLevel) + 1 : 0;
556
+
557
+ for (const level of FALLBACK_LEVELS.slice(startingIndex)) {
558
+ const selection = currentState.fallbacks[level];
559
+ if (!selection || selection.access !== "runtime-model" || exhausted.has(selection.selector)) continue;
560
+ const model = port.models.find((item) => `${item.provider}/${item.id}` === selection.selector);
561
+ if (!model) {
562
+ exhausted.add(selection.selector);
563
+ continue;
564
+ }
565
+ let activated: boolean | void;
566
+ try {
567
+ activated = await port.activate(model);
568
+ } catch {
569
+ exhausted.add(selection.selector);
570
+ continue;
571
+ }
572
+ if (activated === false) {
573
+ exhausted.add(selection.selector);
574
+ continue;
575
+ }
576
+
577
+ const nextState: ModelRouteState = {
578
+ ...currentState,
579
+ version: 2,
580
+ activeFallback: level,
581
+ exhaustedSelectors: [...exhausted],
582
+ handledFailureKeys: nextHandledFailureKeys,
583
+ lastFailover: {
584
+ stage: currentState.activeStage,
585
+ failedSelector: failed,
586
+ fallbackLevel: level,
587
+ activatedSelector: selection.selector,
588
+ switchedAt: port.now?.() ?? new Date().toISOString(),
589
+ },
590
+ };
591
+ const persistenceError = persistFailoverState(port, nextState);
592
+ return {
593
+ state: nextState,
594
+ switched: { level, selection },
595
+ exhausted: false,
596
+ stateChanged: true,
597
+ ...(persistenceError ? { persistenceError } : {}),
598
+ };
599
+ }
600
+
601
+ const exhaustedSelectors = [...exhausted];
602
+ const priorExhausted = new Set(currentState.exhaustedSelectors ?? []);
603
+ const stateChanged = exhaustedSelectors.length !== priorExhausted.size
604
+ || exhaustedSelectors.some((selector) => !priorExhausted.has(selector))
605
+ || nextHandledFailureKeys.length !== handledFailureKeys.length;
606
+ if (!stateChanged) {
607
+ return { state: currentState, exhausted: true, stateChanged: false };
608
+ }
609
+ const nextState: ModelRouteState = {
610
+ ...currentState,
611
+ version: 2,
612
+ exhaustedSelectors,
613
+ handledFailureKeys: nextHandledFailureKeys,
614
+ };
615
+ const persistenceError = persistFailoverState(port, nextState);
616
+ return {
617
+ state: nextState,
618
+ exhausted: true,
619
+ stateChanged: true,
620
+ ...(persistenceError ? { persistenceError } : {}),
621
+ };
622
+ }
623
+
218
624
  export async function executeModelRoute(
219
625
  currentState: ModelRouteState,
220
626
  params: ModelRouteParams,
221
627
  port: ModelRoutePort,
222
628
  ): Promise<ModelRouteExecution> {
223
- const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
224
-
225
629
  if (params.action === "catalog") {
630
+ const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
631
+ const fallbackCatalog = buildFallbackCatalog(catalog);
632
+ if (port.models.length === 0) {
633
+ return {
634
+ state: currentState,
635
+ response: toolResult(
636
+ emptyCatalogSetupMessage(port.runtimeName),
637
+ { runtime: port.runtimeName, catalog, fallbackCatalog, state: currentState },
638
+ true,
639
+ ),
640
+ };
641
+ }
226
642
  const workType = Number(params.workType);
227
643
  const requiredStages = Number.isInteger(workType) ? requiredStagesForWorkType(workType) : STAGES;
644
+ const providerFilter = typeof params.provider === "string" && params.provider.trim() ? params.provider.trim() : undefined;
645
+ if (providerFilter) {
646
+ const known = STAGES.some((stage) => catalog[stage].some((candidate) => candidate.provider === providerFilter));
647
+ if (!known) {
648
+ return {
649
+ state: currentState,
650
+ response: toolResult(
651
+ `Provider ${providerFilter} is not in the live authenticated catalog. Refresh catalog without a provider filter.`,
652
+ { runtime: port.runtimeName, requiredStages, catalog, state: currentState },
653
+ true,
654
+ ),
655
+ };
656
+ }
657
+ }
658
+ const formatted = formatCatalogPresentation(port.runtimeName, catalog, requiredStages, providerFilter);
228
659
  return {
229
660
  state: currentState,
230
- response: toolResult(
231
- JSON.stringify({ runtime: port.runtimeName, requiredStages, catalog, notes: catalogNotes(port.runtimeName, catalog) }, null, 2),
232
- { runtime: port.runtimeName, requiredStages, catalog, state: currentState },
233
- ),
661
+ response: toolResult(formatted.text, {
662
+ runtime: port.runtimeName,
663
+ requiredStages: formatted.presentation.requiredStages,
664
+ catalog,
665
+ fallbackCatalog,
666
+ presentation: formatted.presentation,
667
+ state: currentState,
668
+ }),
234
669
  };
235
670
  }
236
671
 
@@ -238,12 +673,59 @@ export async function executeModelRoute(
238
673
  return { state: currentState, response: toolResult(JSON.stringify(currentState, null, 2), { state: currentState }) };
239
674
  }
240
675
 
676
+ if (params.action === "select_fallback") {
677
+ const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
678
+ const fallbackCatalog = buildFallbackCatalog(catalog);
679
+ if (!isFallbackLevel(params.fallback)) {
680
+ return { state: currentState, response: toolResult("A fallback level of high or low is required.", { state: currentState }, true) };
681
+ }
682
+ if (typeof params.target !== "string") {
683
+ return { state: currentState, response: toolResult("Select an exact runtime selector from the current catalog.", { fallbackCatalog, state: currentState }, true) };
684
+ }
685
+ const level = params.fallback;
686
+ if (level === "low" && !currentState.fallbacks.high) {
687
+ return {
688
+ state: currentState,
689
+ response: toolResult("Select the high fallback before the low fallback so the recovery order is explicit.", { fallbackCatalog, state: currentState }, true),
690
+ };
691
+ }
692
+ const selected = fallbackCatalog[level].find((item) => item.selector === params.target);
693
+ if (!selected) {
694
+ return { state: currentState, response: toolResult(`Target ${params.target} is not an available ${level} fallback choice. Refresh the catalog.`, { fallbackCatalog, state: currentState }, true) };
695
+ }
696
+ const otherLevel: FallbackLevel = level === "high" ? "low" : "high";
697
+ const other = currentState.fallbacks[otherLevel];
698
+ if (other?.identity === selected.identity) {
699
+ return { state: currentState, response: toolResult(`The ${level} fallback must use a different model from the ${otherLevel} fallback.`, { fallbackCatalog, state: currentState }, true) };
700
+ }
701
+ const nextState: ModelRouteState = {
702
+ ...currentState,
703
+ version: 2,
704
+ fallbacks: {
705
+ ...currentState.fallbacks,
706
+ [level]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
707
+ },
708
+ activeFallback: undefined,
709
+ exhaustedSelectors: [],
710
+ handledFailureKeys: [],
711
+ };
712
+ port.persist(nextState);
713
+ return {
714
+ state: nextState,
715
+ response: toolResult(
716
+ `Saved ${selected.label} (${selected.selector}) as the ${level} fallback. It will activate only after a usage-limit failure.`,
717
+ { state: nextState, selected, fallback: level },
718
+ ),
719
+ };
720
+ }
721
+
241
722
  if (!isStage(params.stage)) {
242
723
  return { state: currentState, response: toolResult("A valid stage A, B, C, or D is required.", { state: currentState }, true) };
243
724
  }
244
725
 
245
726
  const stage = params.stage;
246
727
  if (params.action === "select") {
728
+ const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
247
729
  if (typeof params.target !== "string") {
248
730
  return { state: currentState, response: toolResult("Select an exact selector from the current catalog.", { catalog, state: currentState }, true) };
249
731
  }
@@ -263,12 +745,17 @@ export async function executeModelRoute(
263
745
 
264
746
  const workType = Number(params.workType);
265
747
  const nextState: ModelRouteState = {
266
- version: 1,
748
+ ...currentState,
749
+ version: 2,
267
750
  workType: Number.isInteger(workType) && workType >= 1 && workType <= 7 ? workType : currentState.workType,
268
751
  selections: {
269
752
  ...currentState.selections,
270
753
  [stage]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
271
754
  },
755
+ activeStage: stage,
756
+ activeFallback: undefined,
757
+ exhaustedSelectors: [],
758
+ handledFailureKeys: [],
272
759
  };
273
760
  port.persist(nextState);
274
761
  return {
@@ -288,13 +775,113 @@ export async function executeModelRoute(
288
775
  }
289
776
  if (selected.access === "external-agent") {
290
777
  if (!port.hasExternalDevin) return { state: currentState, response: toolResult("Devin was selected but no external Devin tool is available.", { state: currentState }, true) };
291
- return { state: currentState, response: toolResult(`Stage ${stage} uses an external Devin session. Delegate only after post-web approval.`, { state: currentState, selected }) };
778
+ const nextState: ModelRouteState = {
779
+ ...currentState,
780
+ activeStage: stage,
781
+ activeFallback: undefined,
782
+ exhaustedSelectors: [],
783
+ handledFailureKeys: [],
784
+ };
785
+ port.persist(nextState);
786
+ return { state: nextState, response: toolResult(`Stage ${stage} uses an external Devin session. Delegate only after post-web approval.`, { state: nextState, selected }) };
292
787
  }
293
788
  const model = port.models.find((item) => `${item.provider}/${item.id}` === selected.selector);
294
789
  if (!model) return { state: currentState, response: toolResult(`Saved model ${selected.selector} is unavailable. Select a replacement.`, { state: currentState }, true) };
295
790
  const activated = await port.activate(model);
296
791
  if (activated === false) return { state: currentState, response: toolResult(`Could not activate ${selected.selector}; verify provider authentication.`, { state: currentState }, true) };
297
- return { state: currentState, response: toolResult(`Activated ${selected.label} (${selected.selector}) for Stage ${stage}.`, { state: currentState, selected }) };
792
+ const nextState: ModelRouteState = {
793
+ ...currentState,
794
+ activeStage: stage,
795
+ activeFallback: undefined,
796
+ exhaustedSelectors: [],
797
+ handledFailureKeys: [],
798
+ };
799
+ port.persist(nextState);
800
+ return { state: nextState, response: toolResult(`Activated ${selected.label} (${selected.selector}) for Stage ${stage}.`, { state: nextState, selected }) };
801
+ }
802
+
803
+ function boundedErrorText(value: unknown): string {
804
+ const parts: string[] = [];
805
+ const seen = new Set<object>();
806
+ let remaining = MAX_ERROR_TEXT_LENGTH;
807
+ let nodes = 0;
808
+
809
+ const append = (text: string) => {
810
+ if (remaining <= 0 || text.length === 0) return;
811
+ const fragment = text.slice(0, remaining);
812
+ parts.push(fragment);
813
+ remaining -= fragment.length;
814
+ };
815
+
816
+ const visit = (item: unknown, includePlainText = true) => {
817
+ if (remaining <= 0 || nodes >= MAX_ERROR_NODES || item == null) return;
818
+ nodes += 1;
819
+ if (typeof item === "string") {
820
+ if (includePlainText) append(item);
821
+ return;
822
+ }
823
+ if (typeof item !== "object") {
824
+ if (includePlainText) append(String(item));
825
+ return;
826
+ }
827
+ if (seen.has(item)) return;
828
+ seen.add(item);
829
+
830
+ if (item instanceof Error) {
831
+ append(item.message);
832
+ return;
833
+ }
834
+ if (Array.isArray(item)) {
835
+ for (const child of item) visit(child, includePlainText);
836
+ return;
837
+ }
838
+
839
+ const record = item as Record<string, unknown>;
840
+ if (includePlainText && record.type === "text" && typeof record.text === "string") append(record.text);
841
+ for (const key of ERROR_TEXT_KEYS) {
842
+ if (!(key in record)) continue;
843
+ try {
844
+ visit(record[key], true);
845
+ } catch {
846
+ // Ignore getters that throw while inspecting an external tool error.
847
+ }
848
+ }
849
+ for (const key of ERROR_CONTAINER_KEYS) {
850
+ if (!(key in record)) continue;
851
+ try {
852
+ visit(record[key], false);
853
+ } catch {
854
+ // Ignore getters that throw while inspecting an external tool error.
855
+ }
856
+ }
857
+ };
858
+
859
+ visit(value);
860
+ return parts.join("\n");
861
+ }
862
+
863
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
864
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
865
+ }
866
+
867
+ function isDevinExecutionTool(toolName: string): boolean {
868
+ return /^(?:mcp__)?devin(?:__|_|$)/i.test(toolName);
869
+ }
870
+
871
+ function isModelExecutionTool(toolName: string): boolean {
872
+ const normalized = toolName.toLowerCase();
873
+ return EXACT_MODEL_EXECUTION_TOOLS.has(normalized)
874
+ || /^(?:spawn_agent|run_agent|subagent)(?:__|_|$)/.test(normalized)
875
+ || /^(?:mcp__)?(?:devin|workflowz)(?:__|_|$)/.test(normalized);
876
+ }
877
+
878
+ function persistFailoverState(port: ModelRoutePort, state: ModelRouteState): string | undefined {
879
+ try {
880
+ port.persist(state);
881
+ return undefined;
882
+ } catch (error) {
883
+ return error instanceof Error ? error.message : String(error);
884
+ }
298
885
  }
299
886
 
300
887
  function candidate(
@@ -354,10 +941,14 @@ function toolResult(text: string, details: unknown, isError = false) {
354
941
  };
355
942
  }
356
943
 
944
+ function catalogHasProvider(catalog: Record<AutoStage, ModelCandidate[]>, provider: string): boolean {
945
+ return STAGES.some((stage) => catalog[stage].some((candidate) => candidate.provider === provider));
946
+ }
947
+
357
948
  function catalogNotes(runtimeName: RuntimeName, catalog: Record<AutoStage, ModelCandidate[]>): string[] {
358
949
  const notes = [
359
- `The catalog shows every model in ${runtimeName}'s authenticated, enabled registry at call time; selectors are never guessed or pinned.`,
360
- "A and C rank frontier fits first. B and D rank execution fits first. Every live model remains selectable for every stage.",
950
+ `The catalog shows every model in ${runtimeName}'s authenticated, enabled registry at call time; selectors are never guessed, pinned, or limited to a maintainer's subscriptions.`,
951
+ "A and C rank recognizable frontier fits first when present. B and D rank recognizable execution fits first when present. Every live model remains selectable for every stage.",
361
952
  "Zero-metered describes the runtime's reported token price; it does not promise unlimited subscription usage.",
362
953
  ];
363
954
  if (!catalog.A.some((candidate) => candidate.tier === "frontier")) {
@@ -366,5 +957,14 @@ function catalogNotes(runtimeName: RuntimeName, catalog: Record<AutoStage, Model
366
957
  if (!catalog.B.some((candidate) => candidate.tier === "execution")) {
367
958
  notes.push("No preferred execution matches are authenticated and no external Devin tool was detected; choose another available model.");
368
959
  }
960
+ if (
961
+ runtimeName === "OMP"
962
+ && catalogHasProvider(catalog, "cursor")
963
+ && catalogHasProvider(catalog, "openai-codex")
964
+ ) {
965
+ notes.push(
966
+ "OMP compatibility warning (@oh-my-pi/pi-coding-agent 18.1.17): mid-session switches from cursor to openai-codex can reject tool call_id values longer than 64 characters when prior cursor-agent history is replayed. Start a fresh session that does not replay the affected tool history; branching alone is insufficient if that history is retained. This is a harness limitation, not a PiMarg model-routing failure.",
967
+ );
968
+ }
369
969
  return notes;
370
970
  }