@cr1ms0n/pi-subagent 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -109,7 +109,7 @@ Pi path are **refused**, not silently degraded:
109
109
  - Prefer `max_turns`, `max_cost`, and/or `timeout_ms` on long or write-capable runs.
110
110
  `timeout_ms` is absolute: local preflight, Jev selection, setup, queue and
111
111
  runtime all count against it.
112
- - `output_schema` asks the child for a fenced `json:result` block (one repair round).
112
+ - `output_schema` asks the child for a fenced `json:result` block. An otherwise successful invalid answer gets one repair round; a failed provider attempt neither repairs nor publishes structured output.
113
113
  - `context: "fork"` continues from a fork of the parent session.
114
114
  - Do not poll `status` in a tight loop. Use `wait` / `subagent_wait`, or let the
115
115
  completion notification arrive for `async: true` runs.
@@ -121,16 +121,17 @@ Pi path are **refused**, not silently degraded:
121
121
 
122
122
  Omit `model` and `fallback_models` on every new call: both are legacy fields,
123
123
  and an explicit value is rejected rather than bypassing selection. Jev chooses
124
- one execution model from the user's dedicated candidate list plus an individual
125
- include/exclude decision per eligible tool. The local policy then re-validates
124
+ an initial execution model and probabilities for the user's eligible candidates, plus one task-based include/exclude decision per eligible tool shared by all attempts. The local policy then re-validates
126
125
  the answer: unknown or unsafe tools cannot launch, explore/review stay read-only,
127
126
  and management actions need no routing config or credential.
128
127
 
129
- There are no emergency or fallback models, and low confidence is accepted rather
130
- than treated as a threshold. A Jev timeout or API failure stops the affected new
131
- dispatch with an actionable error; existing runs stay queryable and cancellable.
132
- Transient child failures retry the already selected model and tool set within the
133
- original deadline, up to `max_retries`; a quality failure never reselects.
128
+ A Jev timeout or invalid decision still stops new dispatch; there is no emergency model. A valid route retains every candidate probability and tries higher values first. Tied maxima keep the returned choice first; other ties follow configured order. Low confidence and zero probability are accepted, not thresholds. Per-model probability is a selector preference, not uptime or a separate confidence score.
129
+
130
+ Recognized settled model-unavailable, temporary rate-limit/service and transport errors can advance to the next candidate only before any tool execution begins in the current invocation. Once a tool starts, or protocol evidence is uncertain, do not restart the child on another or the same model. Auth/configuration, quota/billing, context, invalid requests, task/schema quality, cancellation and exhausted budgets never trigger model switching. Historical resume/fork messages are not new tool execution.
131
+
132
+ `max_retries` limits all extension-level extra attempts: 0 means one initial attempt; 2 means at most three attempts. The built-in default remains 1. Availability failure advances directly to the next candidate; candidate exhaustion never wraps. Conclusively pre-work infrastructure failures may retry the same model within that budget. Every attempt shares tools, absolute deadline and cumulative reported cost/turn budgets, with fresh exact-model/tool startup verification. Switching makes no extra Jev call. Pi's internal provider retries are separate, unchanged and may delay fallback.
133
+
134
+ Plan/status distinguish original choice, ranked alternatives and actual attempts. Earlier failed output is retained as attributed previews/session pointers, not mixed into a later structured answer. All-failed tasks keep their final failure. Existing runs remain manageable without selector configuration or a credential.
134
135
 
135
136
  An optional candidate `thinking` value is an opaque Pi thinking-level string;
136
137
  common values include `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and
package/src/backend.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import type { ProtocolUpdate } from "./protocol.js";
27
- import type { TaskResult, TaskSpec } from "./types.js";
27
+ import type { TaskResult, TaskSpec, ToolActivity } from "./types.js";
28
28
 
29
29
  /** Normalized event-stream parser contract, implemented per backend. */
30
30
  export interface BackendParser {
@@ -40,6 +40,21 @@ export interface BackendParser {
40
40
  getLiveText(): string;
41
41
  /** Completed messages so far. */
42
42
  getMessages(): import("@earendil-works/pi-ai").Message[];
43
+ /**
44
+ * Optional sticky current-invocation tool-activity observation used by the
45
+ * ranked failover gate. Parsers that do not implement it provide no
46
+ * conclusive evidence (callers must treat activity as unknown on the ranked
47
+ * path); the legacy unranked path is unaffected.
48
+ */
49
+ getToolActivity?(): ToolActivity | undefined;
50
+ /**
51
+ * Optional stop reason of the latest completed assistant message, used to
52
+ * suppress the ranked structured-output repair prompt and final publication
53
+ * after a settled provider error/abort. Absent means "not observable".
54
+ */
55
+ getAssistantStopReason?(): string | undefined;
56
+ /** Latest completed assistant text; empty must not fall back to earlier turns. */
57
+ getAssistantText?(): string | undefined;
43
58
  }
44
59
 
45
60
  export interface BackendInvocation {
package/src/extension.ts CHANGED
@@ -8,10 +8,13 @@ import { Value } from "typebox/value";
8
8
  import { defaultConfig, loadConfig, readConfigFile, type SubagentConfig } from "./config.js";
9
9
  import {
10
10
  formatDuration,
11
+ formatRankedPreview,
11
12
  formatStatusPreview,
12
13
  formatTokens,
13
14
  isActiveState,
14
15
  oneLine,
16
+ projectRoutingForDisplay,
17
+ projectAttemptsForDisplay,
15
18
  renderCallLine,
16
19
  renderRunLines,
17
20
  SPINNERS,
@@ -25,7 +28,7 @@ import { runTasks } from "./orchestrator.js";
25
28
  import { OutputManager } from "./output.js";
26
29
  import { parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type PreparedTask, type ParentContext, type PreparationOptions, type ResolvedTask } from "./policy.js";
27
30
  import type { ChildRunner } from "./runner.js";
28
- import { ProcessLockManager } from "./process-lock.js";
31
+ import { ProcessLockManager, runRecordSessionIds } from "./process-lock.js";
29
32
  import { SessionScopedRunRegistry, snapshotFromLiveRun } from "./registry.js";
30
33
  import {
31
34
  ProviderSubagentParamsSchema,
@@ -49,6 +52,7 @@ import { eligibleModelCandidates, formatJevRoutingPrompt, toToolCandidates } fro
49
52
  import { JevRouter } from "./jev-router.js";
50
53
  import { routePreparedTasks, type RoutingCatalog } from "./dispatch-routing.js";
51
54
  import { runLocalPreflights } from "./dispatch-preflight.js";
55
+ import { rankedMaxAttempts } from "./model-failover.js";
52
56
  import type { RoutingReceipt } from "./routing-types.js";
53
57
  import { buildRoutingEvent, foldRoutingReceipts, MAX_ROUTING_DELIVERY_IDS, ROUTING_ENTRY_TYPE, type PersistedRoutingEvent } from "./persistence.js";
54
58
 
@@ -318,7 +322,7 @@ function compactDetails(
318
322
  errorMessage: result.errorMessage?.slice(0, 1_000),
319
323
  usage: result.usage ?? emptyUsage(),
320
324
  model: result.model,
321
- routing: result.routing,
325
+ routing: projectRoutingForDisplay(result.routing),
322
326
  thinking: result.thinking,
323
327
  profile: result.profile,
324
328
  canWrite: result.canWrite,
@@ -332,7 +336,8 @@ function compactDetails(
332
336
  wrappedUp: result.wrappedUp,
333
337
  stalledSince: result.stalledSince,
334
338
  attempts: result.attempts,
335
- attemptedModels: result.attemptedModels,
339
+ ...projectAttemptsForDisplay(result, Math.min(4_096, perResultText)),
340
+ toolActivity: result.toolActivity,
336
341
  structuredOutput: result.structuredOutput,
337
342
  structuredError: result.structuredError,
338
343
  })),
@@ -430,7 +435,7 @@ async function runPlanPreflights(
430
435
  });
431
436
  }
432
437
 
433
- function formatPlanEntry(task: ResolvedTask, index: number) {
438
+ function formatPlanEntry(task: ResolvedTask, index: number, maxRetriesDefault: number) {
434
439
  const agentNote = task.resolutionNotes.find((note) => note.startsWith("agent="));
435
440
  const agent = agentNote?.slice("agent=".length);
436
441
  return {
@@ -438,12 +443,16 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
438
443
  label: task.label,
439
444
  agent,
440
445
  model: task.model,
441
- fallbackModels: task.fallbackModels,
446
+ // The ranked route replaces legacy fallback semantics for this path: show
447
+ // the bounded ranked preview and the effective extension attempt budget.
448
+ rankedPreview: formatRankedPreview(task.routing?.rankedModels),
449
+ rankedTotal: task.routing?.rankedModels?.length,
450
+ maxAttempts: rankedMaxAttempts(task.maxRetries ?? maxRetriesDefault),
442
451
  thinking: task.thinking,
443
452
  profile: task.profile,
444
453
  access: task.canWrite ? "RW" : "RO" as const,
445
454
  tools: task.effectiveTools,
446
- routing: task.routing,
455
+ routing: projectRoutingForDisplay(task.routing),
447
456
  budgets: {
448
457
  timeoutMs: task.timeoutMs,
449
458
  maxTurns: task.maxTurns,
@@ -466,8 +475,10 @@ function formatPlanText(mode: "single" | "parallel", plan: ReturnType<typeof for
466
475
  ].filter(Boolean).join(" ");
467
476
  return [
468
477
  `${entry.index + 1}. ${entry.label}${entry.agent ? ` [agent:${entry.agent}]` : ""} (${entry.profile}/${entry.access})`,
469
- ` model=${entry.model ?? "(none)"} fallback_models=[${(entry.fallbackModels ?? []).join(", ")}] thinking=${entry.thinking ?? "(default)"} isolation=${entry.isolation}`,
470
- ` tools=[${entry.tools.join(",")}]`,
478
+ ` model=${entry.model ?? "(none)"} thinking=${entry.thinking ?? "(default)"} isolation=${entry.isolation}`,
479
+ ` ranked_models=[${entry.rankedPreview ?? entry.model ?? "(none)"}]${entry.rankedTotal && entry.rankedTotal > 5 ? ` (total ${entry.rankedTotal})` : ""}`,
480
+ ` attempt_budget=${entry.maxAttempts} (max_retries limits EXTRA extension-level attempts; pre-tool availability failure advances the ranking, never wraps)`,
481
+ ` shared_tools=[${entry.tools.join(",")}]`,
471
482
  ` ${budgets}`,
472
483
  ` notes: ${entry.resolutionNotes.join(", ")}`,
473
484
  ].join("\n");
@@ -502,7 +513,7 @@ function guidelines(catalog?: Map<string, AgentDefinition>): string[] {
502
513
  "Profiles: explore/review are strictly read-only (safe for fanout); general offers the full available locally permitted catalog to Jev and may write. Explicit tools are a ceiling; agent tool defaults do not narrow candidates. Single tasks default to general, parallel tasks to explore.",
503
514
  "Parallel writers need isolation:'worktree' (each gets an isolated checkout; changed work lands on a branch). After a worktree run finishes, use action:'diff' to inspect, then 'apply' to bring changes into the main checkout or 'discard' to drop them.",
504
515
  "Set budgets: at max_turns/max_cost the child is steered to wrap up and given grace turns for a final answer (grace_turns tunes this); results end as 'partial' with wrappedUp:true when the child concluded. timeout_ms includes Jev selection, setup, queue and retries; max_cost excludes unreported TypeSafe currency; timeout results report the phase.",
505
- "Transient child failures may retry on the already selected model/tools within the original deadline; no selector retries or fallback models. Task-quality failures never retry.",
516
+ "Transient child failures may retry within the same invocation: ranked Jev routes advance to the next probability-ranked candidate only for a recognized model-availability failure that settles before any tool execution, sharing one task-based tool set and the total max_retries attempt budget (0 = first attempt only; never wraps back). A tool that started, uncertain evidence, or auth/quota/context/schema failures stop without switching. No selector retries or emergency models are used. Task-quality failures never retry.",
506
517
  "context:'fork' starts a single child from a branched copy of this conversation — use it when the task depends on discussion context instead of re-explaining. Single-task only.",
507
518
  "Use async:true only when you have independent work meanwhile; then use action:'wait' with the run id (interruptible, does not cancel). action:'steer' injects mid-run guidance into a running child instead of cancel + retry.",
508
519
  "For parallel research, add synthesis:'<instruction>' to have one read-only child fold all outputs into a single brief, delivered first.",
@@ -614,7 +625,8 @@ function buildCompletionDetails(runtime: SessionRuntime, runIds: string[]): Comp
614
625
  tokens: (result.usage?.input ?? 0) + (result.usage?.output ?? 0),
615
626
  cost: result.usage?.cost ?? 0,
616
627
  model: result.model,
617
- attemptedModels: result.attemptedModels,
628
+ attempts: result.attempts,
629
+ attemptedModels: projectAttemptsForDisplay({ attemptedModels: result.attemptedModels }).attemptedModels,
618
630
  pointers: taskPointers,
619
631
  };
620
632
  });
@@ -632,6 +644,7 @@ function buildCompletionDetails(runtime: SessionRuntime, runIds: string[]): Comp
632
644
  // Preserve the old top-level fields only for single-task consumers. The
633
645
  // complete per-task model/attempt data lives in tasks[].
634
646
  model: tasks.length === 1 ? first?.model : undefined,
647
+ attempts: tasks.length === 1 ? first?.attempts : undefined,
635
648
  attemptedModels: tasks.length === 1 ? first?.attemptedModels : undefined,
636
649
  pointers,
637
650
  tasks,
@@ -665,7 +678,7 @@ function scheduleMaintenance(runtime: SessionRuntime): void {
665
678
  const keep = new Set(runtime.registry.planSessionRetention().keep);
666
679
  const busy = new Set<string>();
667
680
  for (const record of runtime.locks.listRunRecords()) {
668
- if (record.state === "running" && record.childSessionId) busy.add(record.childSessionId);
681
+ if (record.state === "running") for (const id of runRecordSessionIds(record)) busy.add(id);
669
682
  }
670
683
  for (const run of runtime.registry.getLiveRuns(runtime.key)) {
671
684
  for (const id of run.childSessionIds) busy.add(id);
@@ -912,6 +925,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
912
925
  state: run.state,
913
926
  preview: run.preview,
914
927
  model: run.model,
928
+ attempts: run.attempts,
915
929
  attemptedModels: run.attemptedModels,
916
930
  pointers: run.pointers,
917
931
  turns: run.turns,
@@ -920,7 +934,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
920
934
  }];
921
935
  return tasks.map((task) => {
922
936
  const attemptText = task.attemptedModels && task.attemptedModels.length > 1
923
- ? `; attempts: ${task.attemptedModels.join(" → ")}`
937
+ ? `; attempts${task.attempts ? ` (${task.attempts} total)` : ""}: ${task.attemptedModels.join(" → ")}`
924
938
  : "";
925
939
  const label = tasks.length > 1 ? `${run.label}/${task.label}` : task.label;
926
940
  return `- [${run.id.slice(0, 8)}] ${label}: ${task.state}${task.model ? ` on ${task.model}` : ""}${attemptText}${task.preview ? ` — ${task.preview}` : ""}${task.pointers.length ? ` (${task.pointers.join(", ")})` : ""}`;
@@ -1025,6 +1039,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1025
1039
  tokens: run.tokens,
1026
1040
  cost: run.cost,
1027
1041
  model: run.model,
1042
+ attempts: run.attempts,
1028
1043
  attemptedModels: run.attemptedModels,
1029
1044
  pointers: run.pointers,
1030
1045
  }];
@@ -1041,7 +1056,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1041
1056
  lines.push(truncateToWidth(`${glyph} ${theme.fg("dim", task.model ?? "model unknown")} · ${theme.bold(theme.fg("toolTitle", label))} ${theme.fg("dim", `[${run.id.slice(0, 8)}] ${stats}`)}`, width));
1042
1057
  if (task.preview) lines.push(truncateToWidth(` ${theme.fg("dim", "⎿")} ${theme.fg("toolOutput", task.preview)}`, width));
1043
1058
  if (task.attemptedModels && task.attemptedModels.length > 1) {
1044
- lines.push(truncateToWidth(` ${theme.fg("warning", `models: ${task.attemptedModels.join(" → ")}`)}`, width));
1059
+ lines.push(truncateToWidth(` ${theme.fg("warning", `models: ${task.attemptedModels.join(" → ")}${task.attempts && task.attempts > task.attemptedModels.length ? ` (last ${task.attemptedModels.length} of ${task.attempts})` : ""}`)}`, width));
1045
1060
  }
1046
1061
  if ((expanded || tasks.length === 1) && task.pointers.length) {
1047
1062
  lines.push(truncateToWidth(theme.fg("dim", ` ${task.pointers.join(" · ")}`), width));
@@ -1339,7 +1354,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1339
1354
  const planned = await routePreparedTasks(synthetic, catalog, router, {
1340
1355
  purpose: "plan", signal: routingScope.controller.signal, assertOwner: routingScope.assertOwner,
1341
1356
  });
1342
- synthesis = { state: "resolved", plan: formatPlanEntry(planned[0]!, 0) };
1357
+ synthesis = { state: "resolved", plan: formatPlanEntry(planned[0]!, 0, runtime.config.maxRetries) };
1343
1358
  } catch (error) {
1344
1359
  routingScope.assertOwner();
1345
1360
  synthesis = { state: "blocked", error: error instanceof Error ? error.message : "Optional synthesis routing failed." };
@@ -1348,7 +1363,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1348
1363
  routingScope.assertOwner();
1349
1364
  await requireRoutingPersistence(runtime);
1350
1365
  routingScope.assertOwner();
1351
- const plan = resolved.map((task, index) => formatPlanEntry(task, index));
1366
+ const plan = resolved.map((task, index) => formatPlanEntry(task, index, runtime.config.maxRetries));
1352
1367
  const mode = validated.mode as "single" | "parallel";
1353
1368
  const receipts = [...routingScope.receipts.values()];
1354
1369
  const selectorUsage = await claimRoutingUsage(runtime, { ids: new Set(receipts.map((receipt) => receipt.requestId)) });
@@ -1612,6 +1627,9 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1612
1627
  wrappedUp: task.wrappedUp,
1613
1628
  stalledSince: task.stalledSince,
1614
1629
  attempts: task.attempts,
1630
+ attemptedModels: task.attemptedModels,
1631
+ toolActivity: task.toolActivity,
1632
+ modelAttempts: task.modelAttempts,
1615
1633
  structuredOutput: task.structuredOutput,
1616
1634
  structuredError: task.structuredError,
1617
1635
  })),
package/src/format.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { UsageStats, RunSnapshot, RunState, RunMode, TimeoutPhase } from './types.js';
1
+ import type { UsageStats, RunSnapshot, RunState, RunMode, TimeoutPhase, ToolActivity, ModelAttemptRecord } from './types.js';
2
+ import type { RankedModelOption } from './routing-types.js';
3
+ import { utf8SafePrefix } from './model-failover.js';
4
+ import { Buffer } from 'node:buffer';
2
5
  import type { Theme } from '@earendil-works/pi-coding-agent';
3
6
  import * as os from 'node:os';
4
7
  import { truncateToWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
@@ -67,7 +70,8 @@ export function formatUsage(usage: UsageStats, model?: string, compact = true):
67
70
  /**
68
71
  * Structural subset of the persisted Jev route metadata that the TUI can render.
69
72
  * `TaskRouting` structurally satisfies this; legacy/empty input yields `undefined` so
70
- * old runs simply render no route line.
73
+ * old runs simply render no route line. A truncated/ranked display preview is
74
+ * presentation only — it is never a valid routing decision or execution plan.
71
75
  */
72
76
  export interface RoutingLineInput {
73
77
  selectedModel?: string;
@@ -79,10 +83,86 @@ export interface RoutingLineInput {
79
83
  latencyMs?: number;
80
84
  outcome?: string;
81
85
  code?: string;
86
+ /** Display window of the probability ranking (bounded; never an execution plan). */
87
+ rankedModels?: readonly RankedModelOption[];
88
+ /** Total ranked candidates when the display window truncates the ranking. */
89
+ rankedTotal?: number;
82
90
  }
83
91
 
84
92
  const ROUTE_MAX_TOOLS = 8;
85
93
  const ROUTE_MAX_TOOL_NAME = 24;
94
+ /** Ranked candidates named in compact/model-facing projections before counting. */
95
+ export const RANKED_DISPLAY_LIMIT = 5;
96
+
97
+ /**
98
+ * One shared bounded display projection for plan and compact details: keeps a
99
+ * small ranked window plus the total instead of echoing a maximum-size catalog
100
+ * into model-facing output. The truncated window is presentation only and is
101
+ * never reused as a routing decision or execution plan. Persisted/internal
102
+ * routing keeps the full bounded ranking.
103
+ */
104
+ export function projectRoutingForDisplay<TRouting extends RoutingLineInput | undefined>(routing: TRouting): RoutingLineInput | undefined {
105
+ if (!routing || typeof routing !== 'object') return undefined;
106
+ const { rankedModels, ...rest } = routing;
107
+ if (!Array.isArray(rankedModels) || rankedModels.length === 0) return { ...rest };
108
+ return {
109
+ ...rest,
110
+ rankedModels: rankedModels.slice(0, RANKED_DISPLAY_LIMIT),
111
+ rankedTotal: rankedModels.length,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * One bounded ranked preview: `a=.65>b=.25>c=.10` plus the total when more
117
+ * candidates exist. Model IDs are shortened to their last path segment so a
118
+ * large configured catalog stays inside display bounds; full bounded ranking
119
+ * remains available internally on the routing object.
120
+ */
121
+ export function formatRankedPreview(
122
+ ranked: readonly RankedModelOption[] | undefined,
123
+ limit = RANKED_DISPLAY_LIMIT,
124
+ ): string | undefined {
125
+ if (!Array.isArray(ranked) || ranked.length === 0) return undefined;
126
+ const short = (model: unknown): string | undefined => {
127
+ if (typeof model !== 'string' || !model.trim()) return undefined;
128
+ const parts = model.split('/');
129
+ return parts[parts.length - 1] ?? model;
130
+ };
131
+ const shown: string[] = [];
132
+ for (const entry of ranked.slice(0, Math.max(1, limit))) {
133
+ if (!entry || typeof entry !== 'object') return undefined; // malformed shape: display-skip whole preview, never execute
134
+ const name = short(entry.model);
135
+ if (!name) return undefined;
136
+ const probability = typeof entry.probability === 'number' && Number.isFinite(entry.probability)
137
+ ? `=${entry.probability.toFixed(2)}`
138
+ : '';
139
+ shown.push(`${name}${probability}`);
140
+ }
141
+ const remaining = ranked.length - shown.length;
142
+ return remaining > 0 ? `${shown.join('>')} +${remaining}` : shown.join('>');
143
+ }
144
+
145
+ /** Descriptive tail preview only; full histories remain in the run store. */
146
+ export function projectAttemptsForDisplay(
147
+ result: { modelAttempts?: readonly ModelAttemptRecord[]; attemptedModels?: readonly string[] },
148
+ maxBytes = 4_096,
149
+ ) {
150
+ const modelAttempts = result.modelAttempts?.slice(-RANKED_DISPLAY_LIMIT).map((record) => ({
151
+ ...record,
152
+ outputPreview: record.outputPreview ? utf8SafePrefix(record.outputPreview, 128) : undefined,
153
+ }));
154
+ const attemptedModels = result.attemptedModels?.slice(-RANKED_DISPLAY_LIMIT);
155
+ const projected = {
156
+ modelAttempts, modelAttemptsTotal: result.modelAttempts?.length,
157
+ attemptedModels, attemptedModelsTotal: result.attemptedModels?.length,
158
+ };
159
+ while (Buffer.byteLength(JSON.stringify(projected), 'utf8') > Math.max(256, maxBytes)
160
+ && (modelAttempts?.length || attemptedModels?.length)) {
161
+ modelAttempts?.shift();
162
+ attemptedModels?.shift();
163
+ }
164
+ return projected;
165
+ }
86
166
 
87
167
  function summarizeRoutingTools(tools: readonly string[]): string {
88
168
  const shown = tools.slice(0, ROUTE_MAX_TOOLS).map((name) => (name.length > ROUTE_MAX_TOOL_NAME ? `${name.slice(0, ROUTE_MAX_TOOL_NAME - 1)}…` : name));
@@ -121,6 +201,11 @@ export function formatRouteLine(routing?: RoutingLineInput, max = 160): string |
121
201
  if (selectedModel) parts.push(selectedModel);
122
202
  const selector = selectorModel ? (selectorVersion ? `${selectorModel}@${selectorVersion}` : selectorModel) : selectorVersion;
123
203
  if (selector) parts.push(`sel ${selector}`);
204
+ const rankedPreview = formatRankedPreview(routing.rankedModels, ROUTE_MAX_TOOLS);
205
+ if (rankedPreview) {
206
+ const total = typeof routing.rankedTotal === 'number' && routing.rankedTotal > 0 ? routing.rankedTotal : (Array.isArray(routing.rankedModels) ? routing.rankedModels.length : 0);
207
+ parts.push(`rank ${rankedPreview}${total > RANKED_DISPLAY_LIMIT ? ` (of ${total})` : ''}`);
208
+ }
124
209
  if (confidence !== undefined) parts.push(`conf ${confidence.toFixed(2)}`);
125
210
  parts.push(`tools ${selectedTools && selectedTools.length ? summarizeRoutingTools(selectedTools) : 'none'}`);
126
211
  if (mandatoryTools && mandatoryTools.length) parts.push(`+${summarizeRoutingTools(mandatoryTools)}`);
@@ -210,6 +295,11 @@ export interface InlineTaskView {
210
295
  wrappedUp?: boolean;
211
296
  stalledSince?: number;
212
297
  attempts?: number;
298
+ attemptedModels?: string[];
299
+ /** Sticky pre-tool boundary state across this task's attempts. */
300
+ toolActivity?: ToolActivity;
301
+ /** Bounded ranked attempt history (reasons for switches; previews capped). */
302
+ modelAttempts?: ModelAttemptRecord[];
213
303
  structuredOutput?: unknown;
214
304
  structuredError?: string;
215
305
  /** Bounded Jev route metadata; rendered only on expanded surfaces. */
@@ -256,7 +346,12 @@ function statsText(agg: AggregateStats, durationMs?: number): string {
256
346
 
257
347
  function taskAnnotations(task: InlineTaskView, now: number): string[] {
258
348
  const notes: string[] = [];
259
- if (task.attempts && task.attempts > 1) notes.push(`attempt ${task.attempts}`);
349
+ if (task.attempts && task.attempts > 1) {
350
+ const chain = Array.isArray(task.attemptedModels) && task.attemptedModels.length > 1
351
+ ? ` (${task.attemptedModels.slice(0, 3).map((m) => m.split('/').pop() ?? m).join('>')}${task.attemptedModels.length > 3 ? '…' : ''})`
352
+ : '';
353
+ notes.push(`attempt ${task.attempts}${chain}`);
354
+ }
260
355
  if (task.stalledSince && isActiveState(task.state)) notes.push(`stalled ${formatDuration(now - task.stalledSince)}`);
261
356
  if (!isActiveState(task.state)) {
262
357
  if (task.structuredOutput !== undefined) notes.push('✓ schema');
package/src/jev-router.ts CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { Semaphore } from "./semaphore.js";
4
4
  import { isThinkingLevel } from "./thinking.js";
5
5
  import { normalizeRoutingApiKey } from "./routing-policy.js";
6
+ import { choiceIsMaximal, orderRankedModels } from "./model-failover.js";
6
7
  import {
7
8
  DEFAULT_ROUTING_CONCURRENCY,
8
9
  MAX_ROUTING_MODEL_ID_LENGTH,
@@ -25,6 +26,7 @@ import {
25
26
  type RoutingSelectInput,
26
27
  type RoutingSelectOptions,
27
28
  type RoutingToolCandidate,
29
+ type RankedModelOption,
28
30
  } from "./routing-types.js";
29
31
 
30
32
  // Re-exported so integration can import the selector contract from the router module.
@@ -56,6 +58,14 @@ export type {
56
58
  * Guarantees:
57
59
  * - One model Choice first, then one binary include/exclude Choice per eligible tool, packed
58
60
  * into bounded requests. Every eligible tool is asked; nothing is truncated or ranked.
61
+ * - The model Choice's full validated probability distribution is retained as the
62
+ * deterministic `rankedModels` ordering (descending probability, returned choice first
63
+ * among a tied maximum, then configured order). The returned `choice` must be a
64
+ * maximum-probability option; a contradictory answer is an invalid decision, never a
65
+ * silently substituted model. Zero/low probabilities remain valid candidates.
66
+ * - Tool questions are task-based and model-independent: the selection state never
67
+ * conditions on the chosen execution model, so one shared subset serves every ranked
68
+ * attempt and fallback issues no further selector requests.
59
69
  * - A single logical deadline = min(config.timeoutMs, caller absolute deadline) spans every
60
70
  * request and all limiter waiting. Concurrent HTTP requests are bounded to two by default.
61
71
  * - Only `https://api.typesafe.ai/v1/systemone` with `redirect:"error"`; the Bearer key comes
@@ -167,6 +177,8 @@ interface AnswerValidation {
167
177
  selectorVersion: string;
168
178
  choices: ReadonlyMap<string, string>;
169
179
  confidences: ReadonlyMap<string, number>;
180
+ /** Per-question option probabilities exactly as validated (full option coverage). */
181
+ probabilities: ReadonlyMap<string, ReadonlyMap<string, number>>;
170
182
  }
171
183
 
172
184
  interface AnswerInvalid {
@@ -188,8 +200,9 @@ const MODEL_INSTRUCTIONS =
188
200
  const TOOL_INSTRUCTIONS =
189
201
  "Decide whether this single tool should be enabled for the delegated task described in state. "
190
202
  + "Choose 'include' only when this tool is relevant to completing that task; otherwise choose "
191
- + "'exclude'. The tool name and description are in the criteria; option keys are correlation "
192
- + "IDs. Each question is independent.";
203
+ + "'exclude'. This decision is about the task alone and must not depend on which model "
204
+ + "executes it. The tool name and description are in the criteria; option keys are "
205
+ + "correlation IDs. Each question is independent.";
193
206
 
194
207
  const ROUTING_PROFILES = new Set<RoutingProfile>(["explore", "review", "general"]);
195
208
  const ROUTING_PURPOSES = new Set<RoutingPurpose>(["plan", "dispatch", "synthesis"]);
@@ -357,6 +370,7 @@ function validateAnswers(body: unknown, questions: readonly QuestionSpec[]): Ans
357
370
 
358
371
  const choices = new Map<string, string>();
359
372
  const confidences = new Map<string, number>();
373
+ const probabilitySets = new Map<string, ReadonlyMap<string, number>>();
360
374
  for (const question of questions) {
361
375
  const answer = answers.get(question.id);
362
376
  if (answer === undefined) {
@@ -376,22 +390,24 @@ function validateAnswers(body: unknown, questions: readonly QuestionSpec[]): Ans
376
390
  return invalid("invalid_decision", "The TypeSafe routing response chose an option that was not offered for one of the questions.");
377
391
  }
378
392
 
379
- const probabilities = record.probabilities;
380
- if (!probabilities || typeof probabilities !== "object" || Array.isArray(probabilities)) {
393
+ const rawProbabilities = record.probabilities;
394
+ if (!rawProbabilities || typeof rawProbabilities !== "object" || Array.isArray(rawProbabilities)) {
381
395
  return invalid("malformed_response", "A TypeSafe routing answer did not include an option probability set.");
382
396
  }
383
- const probRecord = probabilities as Record<string, unknown>;
397
+ const probRecord = rawProbabilities as Record<string, unknown>;
384
398
  const keys = Object.keys(probRecord);
385
399
  if (keys.length !== question.options.length || question.options.some((option) => !keys.includes(option))) {
386
400
  return invalid("malformed_response", "A TypeSafe routing answer probability set did not match the offered options.");
387
401
  }
388
402
  let sum = 0;
403
+ const optionProbabilities = new Map<string, number>();
389
404
  for (const option of question.options) {
390
405
  const value = probRecord[option];
391
406
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
392
407
  return invalid("malformed_response", "A TypeSafe routing answer reported a probability outside the finite range 0..1.");
393
408
  }
394
409
  sum += value;
410
+ optionProbabilities.set(option, value);
395
411
  }
396
412
  if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) {
397
413
  return invalid("malformed_response", `A TypeSafe routing answer probability set did not sum to 1 within the documented tolerance (${PROBABILITY_SUM_TOLERANCE}).`);
@@ -404,8 +420,9 @@ function validateAnswers(body: unknown, questions: readonly QuestionSpec[]): Ans
404
420
 
405
421
  choices.set(question.id, choice);
406
422
  confidences.set(question.id, confidence);
423
+ probabilitySets.set(question.id, optionProbabilities);
407
424
  }
408
- return { ok: true, selectorVersion, choices, confidences };
425
+ return { ok: true, selectorVersion, choices, confidences, probabilities: probabilitySets };
409
426
  }
410
427
 
411
428
  function selectorStatusFailure(status: number): { code: RoutingFailureCode; message: string } {
@@ -424,7 +441,7 @@ function selectorStatusFailure(status: number): { code: RoutingFailureCode; mess
424
441
  return { code: "http_error", message: `TypeSafe returned an unexpected HTTP status (${status}) for the routing request.` };
425
442
  }
426
443
 
427
- function buildState(input: RoutingSelectInput, selectedModel: string | undefined): Record<string, unknown> {
444
+ function buildState(input: RoutingSelectInput): Record<string, unknown> {
428
445
  const state: Record<string, unknown> = { task: input.task };
429
446
  const constraints = input.constraints;
430
447
  if (constraints) {
@@ -434,7 +451,8 @@ function buildState(input: RoutingSelectInput, selectedModel: string | undefined
434
451
  ...(constraints.structuredOutput === undefined ? {} : { structured_output: constraints.structuredOutput }),
435
452
  };
436
453
  }
437
- if (selectedModel !== undefined) state.selected_model = selectedModel;
454
+ // Tool selection is deliberately model-independent: no selected_model is ever
455
+ // added, so one task-based tool subset is shared by every ranked execution attempt.
438
456
  return state;
439
457
  }
440
458
 
@@ -656,11 +674,11 @@ export class JevRouter {
656
674
  return this.fail("transport_error", "No fetch implementation is available for TypeSafe routing.", call);
657
675
  }
658
676
 
659
- // Preflight grossly oversized single tool questions before paying for the model request.
660
- // This uses the minimum state (no selected model yet); the residual case where adding the
661
- // chosen model ID pushes a question over the bound is still rejected before tool HTTP.
677
+ // Preflight grossly oversized single tool questions before paying for the model
678
+ // request. Tool state is task-only and model-independent, so the probe state equals
679
+ // the real request state and the residual size case is fully preflighted here.
662
680
  if (tools.length > 0) {
663
- const probe = packToolBatches(tools, buildState(input, undefined), this.config.selectorModel);
681
+ const probe = packToolBatches(tools, buildState(input), this.config.selectorModel);
664
682
  if ("error" in probe) return this.fail(probe.error.code, probe.error.message, call);
665
683
  }
666
684
 
@@ -679,7 +697,7 @@ export class JevRouter {
679
697
 
680
698
  // ---- 1. Model Choice ------------------------------------------------------------
681
699
  const modelQuestion = buildModelQuestion(models);
682
- const modelRequest = serializeRequest(this.config.selectorModel, buildState(input, undefined), [modelQuestion]);
700
+ const modelRequest = serializeRequest(this.config.selectorModel, buildState(input), [modelQuestion]);
683
701
  if (!withinRequestLimit(modelRequest)) {
684
702
  return this.fail(
685
703
  "request_too_large",
@@ -704,7 +722,7 @@ export class JevRouter {
704
722
  }
705
723
  const modelChoice = modelValidation.choices.get("model");
706
724
  const modelIndex = modelChoice === undefined ? -1 : modelQuestion.options.indexOf(modelChoice);
707
- if (modelIndex < 0) {
725
+ if (modelIndex < 0 || typeof modelChoice !== "string") {
708
726
  if (modelIssue.receipt) this.markReceiptFailed(modelIssue.receipt, "invalid_decision", call);
709
727
  return this.fail("invalid_decision", "The TypeSafe routing response did not select a valid candidate model.", call);
710
728
  }
@@ -713,10 +731,31 @@ export class JevRouter {
713
731
  const primaryVersion = modelValidation.selectorVersion;
714
732
  const versions: string[] = [primaryVersion];
715
733
 
716
- // ---- 2. One binary Choice per eligible tool ------------------------------------
734
+ // ---- 1b. Probability ranking (retained distribution) ---------------------------
735
+ // Official Choice contract: `choice` is a highest-probability option. A response
736
+ // that contradicts its own distribution is rejected, never substituted.
737
+ const modelProbabilities = modelValidation.probabilities.get("model");
738
+ if (!modelProbabilities) {
739
+ if (modelIssue.receipt) this.markReceiptFailed(modelIssue.receipt, "malformed_response", call);
740
+ return this.fail("malformed_response", "The TypeSafe routing answer did not include the validated option probability set.", call);
741
+ }
742
+ const choiceProblem = choiceIsMaximal(modelProbabilities, modelChoice, selectedModel);
743
+ if (choiceProblem) {
744
+ if (modelIssue.receipt) this.markReceiptFailed(modelIssue.receipt, "invalid_decision", call);
745
+ return this.fail("invalid_decision", `The TypeSafe routing answer contradicts its own probability distribution: ${choiceProblem}.`, call);
746
+ }
747
+ const rankedModels: readonly RankedModelOption[] = Object.freeze(orderRankedModels(
748
+ models.map((candidate, index) => ({
749
+ model: candidate.model,
750
+ probability: modelProbabilities.get(modelQuestion.options[index]!) ?? 0,
751
+ })),
752
+ selectedModel,
753
+ ));
754
+
755
+ // ---- 2. One binary Choice per eligible tool (task-based, model-independent) ----
717
756
  const selectedTools: string[] = [];
718
757
  if (tools.length > 0) {
719
- const packed = packToolBatches(tools, buildState(input, selectedModel), this.config.selectorModel);
758
+ const packed = packToolBatches(tools, buildState(input), this.config.selectorModel);
720
759
  if ("error" in packed) return this.fail(packed.error.code, packed.error.message, call);
721
760
 
722
761
  const settled = await Promise.all(packed.batches.map(async (batch, index) => {
@@ -761,6 +800,7 @@ export class JevRouter {
761
800
  ...(options.taskIndex === undefined ? {} : { taskIndex: options.taskIndex }),
762
801
  selectedModel,
763
802
  selectedTools: Object.freeze(selectedTools),
803
+ rankedModels,
764
804
  ...(modelConfidence === undefined ? {} : { confidence: modelConfidence }),
765
805
  selectorModel: this.config.selectorModel,
766
806
  selectorVersion: primaryVersion,