@cr1ms0n/pi-subagent 0.9.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.
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
@@ -2,6 +2,8 @@ import { Buffer } from "node:buffer";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { Semaphore } from "./semaphore.js";
4
4
  import { isThinkingLevel } from "./thinking.js";
5
+ import { normalizeRoutingApiKey } from "./routing-policy.js";
6
+ import { choiceIsMaximal, orderRankedModels } from "./model-failover.js";
5
7
  import {
6
8
  DEFAULT_ROUTING_CONCURRENCY,
7
9
  MAX_ROUTING_MODEL_ID_LENGTH,
@@ -24,6 +26,7 @@ import {
24
26
  type RoutingSelectInput,
25
27
  type RoutingSelectOptions,
26
28
  type RoutingToolCandidate,
29
+ type RankedModelOption,
27
30
  } from "./routing-types.js";
28
31
 
29
32
  // Re-exported so integration can import the selector contract from the router module.
@@ -55,10 +58,18 @@ export type {
55
58
  * Guarantees:
56
59
  * - One model Choice first, then one binary include/exclude Choice per eligible tool, packed
57
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.
58
69
  * - A single logical deadline = min(config.timeoutMs, caller absolute deadline) spans every
59
70
  * request and all limiter waiting. Concurrent HTTP requests are bounded to two by default.
60
71
  * - Only `https://api.typesafe.ai/v1/systemone` with `redirect:"error"`; the Bearer key comes
61
- * from the injected environment accessor and never appears in config, results or messages.
72
+ * from the private config snapshot and is never copied into request bodies, results or messages.
62
73
  * - Responses are untrusted data: shape, answer type, question set, allowed options, finite
63
74
  * probabilities, probability sum tolerance, confidence, usage counts and selector version
64
75
  * are validated. Valid low-confidence choices are accepted (no threshold, no substitution).
@@ -72,7 +83,7 @@ export type {
72
83
  * - Failures return a discriminated result that includes every available receipt; there is no
73
84
  * automatic selector retry, fallback or emergency model.
74
85
  *
75
- * Collaborators (`fetchImpl`, `env`, `now`, `idFactory`, `limiter`, `onReceipt`) are all
86
+ * Collaborators (`fetchImpl`, `now`, `idFactory`, `limiter`, `onReceipt`) are all
76
87
  * injectable so the whole surface is testable offline with zero provider calls.
77
88
  */
78
89
 
@@ -82,12 +93,10 @@ export interface RoutingLimiter {
82
93
  }
83
94
 
84
95
  export interface JevRouterOptions {
85
- /** Frozen per-invocation config snapshot. */
96
+ /** Frozen per-invocation config snapshot containing a private credential. Never log it. */
86
97
  config: JevRoutingConfig;
87
98
  /** Injected transport; defaults to global `fetch`. */
88
99
  fetchImpl?: typeof fetch;
89
- /** Injected environment accessor; defaults to `process.env`. */
90
- env?: (name: string) => string | undefined;
91
100
  /** Injected clock; defaults to `Date.now`. */
92
101
  now?: () => number;
93
102
  /** Injected unique-ID factory; defaults to `randomUUID`. */
@@ -168,6 +177,8 @@ interface AnswerValidation {
168
177
  selectorVersion: string;
169
178
  choices: ReadonlyMap<string, string>;
170
179
  confidences: ReadonlyMap<string, number>;
180
+ /** Per-question option probabilities exactly as validated (full option coverage). */
181
+ probabilities: ReadonlyMap<string, ReadonlyMap<string, number>>;
171
182
  }
172
183
 
173
184
  interface AnswerInvalid {
@@ -189,8 +200,9 @@ const MODEL_INSTRUCTIONS =
189
200
  const TOOL_INSTRUCTIONS =
190
201
  "Decide whether this single tool should be enabled for the delegated task described in state. "
191
202
  + "Choose 'include' only when this tool is relevant to completing that task; otherwise choose "
192
- + "'exclude'. The tool name and description are in the criteria; option keys are correlation "
193
- + "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.";
194
206
 
195
207
  const ROUTING_PROFILES = new Set<RoutingProfile>(["explore", "review", "general"]);
196
208
  const ROUTING_PURPOSES = new Set<RoutingPurpose>(["plan", "dispatch", "synthesis"]);
@@ -358,6 +370,7 @@ function validateAnswers(body: unknown, questions: readonly QuestionSpec[]): Ans
358
370
 
359
371
  const choices = new Map<string, string>();
360
372
  const confidences = new Map<string, number>();
373
+ const probabilitySets = new Map<string, ReadonlyMap<string, number>>();
361
374
  for (const question of questions) {
362
375
  const answer = answers.get(question.id);
363
376
  if (answer === undefined) {
@@ -377,22 +390,24 @@ function validateAnswers(body: unknown, questions: readonly QuestionSpec[]): Ans
377
390
  return invalid("invalid_decision", "The TypeSafe routing response chose an option that was not offered for one of the questions.");
378
391
  }
379
392
 
380
- const probabilities = record.probabilities;
381
- if (!probabilities || typeof probabilities !== "object" || Array.isArray(probabilities)) {
393
+ const rawProbabilities = record.probabilities;
394
+ if (!rawProbabilities || typeof rawProbabilities !== "object" || Array.isArray(rawProbabilities)) {
382
395
  return invalid("malformed_response", "A TypeSafe routing answer did not include an option probability set.");
383
396
  }
384
- const probRecord = probabilities as Record<string, unknown>;
397
+ const probRecord = rawProbabilities as Record<string, unknown>;
385
398
  const keys = Object.keys(probRecord);
386
399
  if (keys.length !== question.options.length || question.options.some((option) => !keys.includes(option))) {
387
400
  return invalid("malformed_response", "A TypeSafe routing answer probability set did not match the offered options.");
388
401
  }
389
402
  let sum = 0;
403
+ const optionProbabilities = new Map<string, number>();
390
404
  for (const option of question.options) {
391
405
  const value = probRecord[option];
392
406
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
393
407
  return invalid("malformed_response", "A TypeSafe routing answer reported a probability outside the finite range 0..1.");
394
408
  }
395
409
  sum += value;
410
+ optionProbabilities.set(option, value);
396
411
  }
397
412
  if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) {
398
413
  return invalid("malformed_response", `A TypeSafe routing answer probability set did not sum to 1 within the documented tolerance (${PROBABILITY_SUM_TOLERANCE}).`);
@@ -405,13 +420,14 @@ function validateAnswers(body: unknown, questions: readonly QuestionSpec[]): Ans
405
420
 
406
421
  choices.set(question.id, choice);
407
422
  confidences.set(question.id, confidence);
423
+ probabilitySets.set(question.id, optionProbabilities);
408
424
  }
409
- return { ok: true, selectorVersion, choices, confidences };
425
+ return { ok: true, selectorVersion, choices, confidences, probabilities: probabilitySets };
410
426
  }
411
427
 
412
428
  function selectorStatusFailure(status: number): { code: RoutingFailureCode; message: string } {
413
429
  if (status === 401 || status === 403) {
414
- return { code: "unauthorized", message: "TypeSafe rejected the routing credential (HTTP 401/403). Check the configured apiKeyEnv environment variable." };
430
+ return { code: "unauthorized", message: "TypeSafe rejected the routing credential (HTTP 401/403). Check jevRouting.apiKey in your private ~/.pi/subagent.json configuration." };
415
431
  }
416
432
  if (status === 422) {
417
433
  return { code: "invalid_request", message: "TypeSafe rejected the routing request as invalid (HTTP 422). Check selectorModel and the configured candidate/tool descriptions." };
@@ -425,7 +441,7 @@ function selectorStatusFailure(status: number): { code: RoutingFailureCode; mess
425
441
  return { code: "http_error", message: `TypeSafe returned an unexpected HTTP status (${status}) for the routing request.` };
426
442
  }
427
443
 
428
- function buildState(input: RoutingSelectInput, selectedModel: string | undefined): Record<string, unknown> {
444
+ function buildState(input: RoutingSelectInput): Record<string, unknown> {
429
445
  const state: Record<string, unknown> = { task: input.task };
430
446
  const constraints = input.constraints;
431
447
  if (constraints) {
@@ -435,7 +451,8 @@ function buildState(input: RoutingSelectInput, selectedModel: string | undefined
435
451
  ...(constraints.structuredOutput === undefined ? {} : { structured_output: constraints.structuredOutput }),
436
452
  };
437
453
  }
438
- 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.
439
456
  return state;
440
457
  }
441
458
 
@@ -607,7 +624,6 @@ function freezeReceipt(draft: ReceiptDraft): RoutingReceipt {
607
624
  export class JevRouter {
608
625
  private readonly config: JevRoutingConfig;
609
626
  private readonly fetchImpl: typeof fetch | undefined;
610
- private readonly env: (name: string) => string | undefined;
611
627
  private readonly now: () => number;
612
628
  private readonly idFactory: () => string;
613
629
  private readonly limiter: RoutingLimiter;
@@ -616,7 +632,6 @@ export class JevRouter {
616
632
  constructor(options: JevRouterOptions) {
617
633
  this.config = options.config;
618
634
  this.fetchImpl = options.fetchImpl ?? (typeof globalThis.fetch === "function" ? globalThis.fetch : undefined);
619
- this.env = options.env ?? ((name) => process.env[name]);
620
635
  this.now = options.now ?? Date.now;
621
636
  this.idFactory = options.idFactory ?? randomUUID;
622
637
  this.limiter = options.limiter ?? sharedLimiter;
@@ -647,12 +662,11 @@ export class JevRouter {
647
662
  return this.fail("too_many_tools", `At most ${MAX_ROUTING_TOOL_QUESTIONS} eligible tools can be considered in one selection.`, call);
648
663
  }
649
664
 
650
- const rawKey = this.env(this.config.apiKeyEnv);
651
- const apiKey = typeof rawKey === "string" ? rawKey.trim() : "";
665
+ const apiKey = normalizeRoutingApiKey(this.config.apiKey);
652
666
  if (!apiKey) {
653
667
  return this.fail(
654
668
  "missing_api_key",
655
- `The TypeSafe routing credential is missing: set the ${this.config.apiKeyEnv} environment variable locally, then retry the dispatch.`,
669
+ "The TypeSafe routing credential is missing or invalid: set jevRouting.apiKey in your private ~/.pi/subagent.json to a non-blank key without embedded whitespace or control characters, then retry the dispatch.",
656
670
  call,
657
671
  );
658
672
  }
@@ -660,11 +674,11 @@ export class JevRouter {
660
674
  return this.fail("transport_error", "No fetch implementation is available for TypeSafe routing.", call);
661
675
  }
662
676
 
663
- // Preflight grossly oversized single tool questions before paying for the model request.
664
- // This uses the minimum state (no selected model yet); the residual case where adding the
665
- // 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.
666
680
  if (tools.length > 0) {
667
- const probe = packToolBatches(tools, buildState(input, undefined), this.config.selectorModel);
681
+ const probe = packToolBatches(tools, buildState(input), this.config.selectorModel);
668
682
  if ("error" in probe) return this.fail(probe.error.code, probe.error.message, call);
669
683
  }
670
684
 
@@ -683,7 +697,7 @@ export class JevRouter {
683
697
 
684
698
  // ---- 1. Model Choice ------------------------------------------------------------
685
699
  const modelQuestion = buildModelQuestion(models);
686
- const modelRequest = serializeRequest(this.config.selectorModel, buildState(input, undefined), [modelQuestion]);
700
+ const modelRequest = serializeRequest(this.config.selectorModel, buildState(input), [modelQuestion]);
687
701
  if (!withinRequestLimit(modelRequest)) {
688
702
  return this.fail(
689
703
  "request_too_large",
@@ -708,7 +722,7 @@ export class JevRouter {
708
722
  }
709
723
  const modelChoice = modelValidation.choices.get("model");
710
724
  const modelIndex = modelChoice === undefined ? -1 : modelQuestion.options.indexOf(modelChoice);
711
- if (modelIndex < 0) {
725
+ if (modelIndex < 0 || typeof modelChoice !== "string") {
712
726
  if (modelIssue.receipt) this.markReceiptFailed(modelIssue.receipt, "invalid_decision", call);
713
727
  return this.fail("invalid_decision", "The TypeSafe routing response did not select a valid candidate model.", call);
714
728
  }
@@ -717,10 +731,31 @@ export class JevRouter {
717
731
  const primaryVersion = modelValidation.selectorVersion;
718
732
  const versions: string[] = [primaryVersion];
719
733
 
720
- // ---- 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) ----
721
756
  const selectedTools: string[] = [];
722
757
  if (tools.length > 0) {
723
- const packed = packToolBatches(tools, buildState(input, selectedModel), this.config.selectorModel);
758
+ const packed = packToolBatches(tools, buildState(input), this.config.selectorModel);
724
759
  if ("error" in packed) return this.fail(packed.error.code, packed.error.message, call);
725
760
 
726
761
  const settled = await Promise.all(packed.batches.map(async (batch, index) => {
@@ -765,6 +800,7 @@ export class JevRouter {
765
800
  ...(options.taskIndex === undefined ? {} : { taskIndex: options.taskIndex }),
766
801
  selectedModel,
767
802
  selectedTools: Object.freeze(selectedTools),
803
+ rankedModels,
768
804
  ...(modelConfidence === undefined ? {} : { confidence: modelConfidence }),
769
805
  selectorModel: this.config.selectorModel,
770
806
  selectorVersion: primaryVersion,