@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.
- package/CHANGELOG.md +8 -1
- package/README.md +11 -101
- package/README.zh-CN.md +42 -132
- package/docs/ARCHITECTURE.md +12 -24
- package/docs/COST-ACCOUNTING.md +6 -7
- package/docs/REFERENCE.md +25 -7
- package/docs/UX.md +8 -12
- package/package.json +1 -1
- package/skills/subagent/SKILL.md +9 -8
- package/src/backend.ts +16 -1
- package/src/extension.ts +33 -15
- package/src/format.ts +98 -3
- package/src/jev-router.ts +56 -16
- 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 +1 -1
- package/src/routing-types.ts +22 -0
- package/src/runner.ts +101 -15
- package/src/schema.ts +3 -3
- package/src/types.ts +88 -1
package/skills/subagent/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)"}
|
|
470
|
-
`
|
|
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
|
|
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
|
-
|
|
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"
|
|
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)
|
|
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'.
|
|
192
|
-
+ "
|
|
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
|
|
380
|
-
if (!
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
660
|
-
//
|
|
661
|
-
//
|
|
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
|
|
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
|
|
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
|
-
// ----
|
|
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
|
|
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,
|