@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/CHANGELOG.md +23 -2
- package/README.md +40 -672
- package/README.zh-CN.md +42 -0
- package/docs/ARCHITECTURE.md +12 -24
- package/docs/COST-ACCOUNTING.md +6 -7
- package/docs/DEVELOPMENT.md +124 -0
- package/docs/PLAN.md +2 -0
- package/docs/REFERENCE.md +454 -0
- package/docs/RELEASING.md +151 -32
- package/docs/ROADMAP.md +2 -0
- package/docs/SECURITY.md +17 -18
- package/docs/UX.md +8 -12
- package/package.json +9 -1
- package/skills/subagent/SKILL.md +17 -12
- package/src/backend.ts +16 -1
- package/src/config.ts +1 -1
- package/src/extension.ts +33 -15
- package/src/format.ts +98 -3
- package/src/jev-router.ts +63 -27
- package/src/model-failover.ts +445 -0
- package/src/notifications.ts +2 -0
- package/src/orchestrator.ts +522 -303
- package/src/output.ts +9 -4
- package/src/persistence.ts +127 -5
- package/src/policy.ts +26 -3
- package/src/process-lock.ts +16 -0
- package/src/protocol.ts +208 -11
- package/src/registry.ts +33 -7
- package/src/routing-policy.ts +27 -19
- package/src/routing-types.ts +26 -4
- package/src/runner.ts +101 -15
- package/src/schema.ts +3 -3
- package/src/types.ts +88 -1
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)
|
|
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
|
|
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`, `
|
|
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'.
|
|
193
|
-
+ "
|
|
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
|
|
381
|
-
if (!
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
664
|
-
//
|
|
665
|
-
//
|
|
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
|
|
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
|
|
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
|
-
// ----
|
|
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
|
|
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,
|