@f5-sales-demo/xcsh 20.8.2 → 20.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.8.2",
4
+ "version": "20.8.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -61,13 +61,13 @@
61
61
  "dependencies": {
62
62
  "@agentclientprotocol/sdk": "1.3.0",
63
63
  "@mozilla/readability": "^0.6",
64
- "@f5-sales-demo/xcsh-stats": "20.8.2",
65
- "@f5-sales-demo/pi-agent-core": "20.8.2",
66
- "@f5-sales-demo/pi-ai": "20.8.2",
67
- "@f5-sales-demo/pi-natives": "20.8.2",
68
- "@f5-sales-demo/pi-resource-management": "20.8.2",
69
- "@f5-sales-demo/pi-tui": "20.8.2",
70
- "@f5-sales-demo/pi-utils": "20.8.2",
64
+ "@f5-sales-demo/xcsh-stats": "20.8.4",
65
+ "@f5-sales-demo/pi-agent-core": "20.8.4",
66
+ "@f5-sales-demo/pi-ai": "20.8.4",
67
+ "@f5-sales-demo/pi-natives": "20.8.4",
68
+ "@f5-sales-demo/pi-resource-management": "20.8.4",
69
+ "@f5-sales-demo/pi-tui": "20.8.4",
70
+ "@f5-sales-demo/pi-utils": "20.8.4",
71
71
  "@sinclair/typebox": "^0.34",
72
72
  "@xterm/headless": "^6.0",
73
73
  "ajv": "^8.20",
@@ -602,6 +602,21 @@ export const SETTINGS_SCHEMA = {
602
602
  },
603
603
  },
604
604
 
605
+ "routing.tierEffort": {
606
+ type: "record",
607
+ default: {
608
+ utility: "low",
609
+ balanced: "medium",
610
+ frontier: "high",
611
+ },
612
+ ui: {
613
+ tab: "model",
614
+ label: "Tier Effort Mapping",
615
+ description: "Map routing tiers to thinking effort levels",
616
+ submenu: true,
617
+ },
618
+ },
619
+
605
620
  "routing.pools": {
606
621
  type: "record",
607
622
  default: {},
package/src/index.ts CHANGED
@@ -38,6 +38,13 @@ export * from "./modes";
38
38
  export * from "./modes/components";
39
39
  // Theme utilities for custom tools
40
40
  export * from "./modes/theme/theme";
41
+ export * from "./routing/classifier";
42
+ export * from "./routing/coordinator";
43
+ export * from "./routing/delegation";
44
+ export * from "./routing/presets";
45
+ export * from "./routing/profiler";
46
+ export * from "./routing/state-machine";
47
+ export type * from "./routing/types";
41
48
  // SDK for programmatic usage
42
49
  export * from "./sdk";
43
50
  export * from "./session/agent-session";
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.8.2",
21
- "commit": "66c868524c9a033080f9783d9be773ecab7b6831",
22
- "shortCommit": "66c8685",
20
+ "version": "20.8.4",
21
+ "commit": "297e0432f40ac41a3926217230d2ef6b3864eb26",
22
+ "shortCommit": "297e043",
23
23
  "branch": "main",
24
- "tag": "v20.8.2",
25
- "commitDate": "2026-08-08T14:08:50Z",
26
- "buildDate": "2026-08-08T14:35:05.342Z",
24
+ "tag": "v20.8.4",
25
+ "commitDate": "2026-08-09T08:55:46Z",
26
+ "buildDate": "2026-08-09T09:20:02.412Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/66c868524c9a033080f9783d9be773ecab7b6831",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.2"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/297e0432f40ac41a3926217230d2ef6b3864eb26",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.4"
33
33
  };
@@ -2,10 +2,10 @@
2
2
 
3
3
  import type { ConsoleCatalogData } from "./console-catalog-types";
4
4
 
5
- export const CONSOLE_CATALOG_VERSION = "340b21c54e065c667ca82dc7d42e86d9a345e171";
5
+ export const CONSOLE_CATALOG_VERSION = "aa04ae2c206de5bfef275a7ea628d204f6f31d1c";
6
6
 
7
7
  export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
8
- version: "340b21c54e065c667ca82dc7d42e86d9a345e171",
8
+ version: "aa04ae2c206de5bfef275a7ea628d204f6f31d1c",
9
9
  workflows: {
10
10
  "address-allocator/create":
11
11
  '---\nschema: urn:xcsh:console:workflow:v1\nid: address-allocator-create\nlabel: Create IP Address Allocators\nresource: address-allocator\noperation: create\npreconditions:\n - user_logged_in\n - "role_minimum: admin"\nparams:\n name:\n required: true\n description: IP Address Allocators name (lowercase alphanumeric and hyphens)\n example: example-address-allocator\n address_allocator_mode:\n required: true\n description: Address Allocator Mode\n allocation_unit:\n required: false\n description: Allocation Unit\n default: 0\n address_pool:\n required: false\n description: Address Pool\n default: value\n address_allocation_scheme:\n required: false\n description: "Server-required: Field should be not nil"\n default: value\nsteps:\n - id: navigate-to-list\n action: navigate\n url: /web/workspaces/multi-cloud-network-connect/manage/networking/legacy_network_configuration/address_allocators\n wait_for: text(\'IP Address Allocators\')\n description: Navigate to IP Address Allocators list page\n - id: click-add-tab\n action: click\n selector: text(\'Add IP Address Allocator\')\n wait_for: textbox[name=\'Name\']\n description: Click Add IP Address Allocator to open the create form\n - id: fill-name\n action: fill\n selector: textbox[name=\'Name\']\n value: "{name}"\n description: Enter Name\n - id: select-address_allocator_mode\n action: select\n selector: listbox\n context: Address Allocator Mode section\n value: "{address_allocator_mode}"\n description: Select Address Allocator Mode\n - id: fill-allocation_unit\n action: fill\n selector: spinbutton[name=\'Allocation Unit\']\n value: "{allocation_unit}"\n description: Set Allocation Unit\n - id: fill-address_pool\n action: fill\n selector: ngx-datatable input.form-control\n context: Address Pool table\n value: "{address_pool}"\n description: Enter Address Pool in the existing table row (no Add Item needed — the table ships one empty row)\n - id: select-address_allocation_scheme\n action: select\n selector: listbox\n context: Address Allocation Scheme section\n value: "{address_allocation_scheme}"\n description: Select Address Allocation Scheme\n - id: save\n action: click\n selector: "[class*=\'save-bt\'],[class*=\'submit-button\']"\n context: footer\n wait_for: text(\'{name}\')\n wait_timeout_ms: 30000\n description: Save/submit the form (union selector matches save-bt OR submit-button)\npostconditions:\n - resource_list_page_visible\n - "resource_name_in_list: {name}"\nmetadata:\n confidence: inferred\n discovered_at: 2026-06-24\n console_version: "2025.06"\n notes: Auto-generated by scripts/generate-workflows.ts from api-specs-enriched field metadata.\n',
@@ -28,16 +28,31 @@ export async function classifyTaskHybrid(options: HybridClassifierOptions): Prom
28
28
  });
29
29
 
30
30
  try {
31
- const utilityModel = options.pool.tiers.utility;
31
+ let utilityModel = options.pool.tiers.utility;
32
+ if (!utilityModel.includes("/") && options.pool.provider) {
33
+ utilityModel = `${options.pool.provider}/${utilityModel}`;
34
+ }
32
35
  const rawOutput = await runner(utilityModel, options.prompt);
33
36
 
34
37
  const parsed = JSON.parse(rawOutput);
35
- const score = typeof parsed.complexityScore === "number" ? parsed.complexityScore : 40;
38
+ let score = typeof parsed.complexityScore === "number" ? parsed.complexityScore : 40;
39
+ // Enforce hard capability and validated outcome floors computed by deterministic rules
40
+ score = Math.max(score, baseProfile.complexityScore);
36
41
  const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 0;
37
- const delegation =
38
- typeof parsed.delegation === "object" && parsed.delegation !== null && !Array.isArray(parsed.delegation)
39
- ? parsed.delegation
40
- : undefined;
42
+ let delegation = parsed.delegation;
43
+ if (Array.isArray(delegation)) {
44
+ delegation = { subtasks: delegation };
45
+ }
46
+ if (
47
+ delegation &&
48
+ typeof delegation === "object" &&
49
+ Array.isArray(delegation.subtasks) &&
50
+ delegation.subtasks.length > 0
51
+ ) {
52
+ // keep delegation
53
+ } else {
54
+ delegation = undefined;
55
+ }
41
56
  const routingUsage = typeof parsed.routingUsage === "number" ? parsed.routingUsage : undefined;
42
57
 
43
58
  if (confidence < 0.75) {
@@ -15,6 +15,7 @@ export interface EvaluateTurnOptions {
15
15
  contextEstimate?: {
16
16
  usedTokens: number;
17
17
  contextWindow: number;
18
+ reserveTokens?: number;
18
19
  };
19
20
  hasImages?: boolean;
20
21
  priorRejection?: boolean;
@@ -22,8 +22,8 @@ export function validateDelegationPlan(plan: ReadOnlyDelegationPlan, maxTasks =
22
22
  return { valid: false, error: "Invalid plan: missing subtasks array" };
23
23
  }
24
24
 
25
- if (plan.subtasks.length === 0) {
26
- return { valid: false, error: "Plan has 0 subtasks" };
25
+ if (plan.subtasks.length <= 1) {
26
+ return { valid: false, error: "Plan requires >1 subtasks for delegation" };
27
27
  }
28
28
 
29
29
  if (plan.subtasks.length > maxTasks) {
@@ -41,16 +41,19 @@ export function validateDelegationPlan(plan: ReadOnlyDelegationPlan, maxTasks =
41
41
 
42
42
  export async function executeReadOnlyDelegationPlan(
43
43
  plan: ReadOnlyDelegationPlan,
44
- executor: (subtaskPrompt: string) => Promise<string>,
44
+ executor: (subtaskPrompt: string, signal?: AbortSignal) => Promise<{ result: string; tokens: number }>,
45
45
  maxTasks = 3,
46
- ): Promise<Array<{ id: string; result: string }>> {
46
+ options?: { signal?: AbortSignal },
47
+ ): Promise<{ results: Array<{ id: string; result: string }>; tokensUsed: number }> {
47
48
  const validation = validateDelegationPlan(plan, maxTasks);
48
49
  if (!validation.valid) {
49
- return [];
50
+ return { results: [], tokensUsed: 0 };
50
51
  }
51
52
 
52
- const results: Array<{ id: string; result: string }> = [];
53
- for (const task of plan.subtasks.slice(0, maxTasks)) {
53
+ const tasks = plan.subtasks.slice(0, maxTasks).map(async task => {
54
+ if (options?.signal?.aborted) {
55
+ return { id: task.id, result: "Failed: Aborted", tokens: 0 };
56
+ }
54
57
  try {
55
58
  const promptPayload = [
56
59
  `Task: ${task.title}`,
@@ -62,11 +65,19 @@ export async function executeReadOnlyDelegationPlan(
62
65
  ]
63
66
  .filter(Boolean)
64
67
  .join("\n");
65
- const res = await executor(promptPayload);
66
- results.push({ id: task.id, result: res });
68
+ const { result, tokens } = await executor(promptPayload, options?.signal);
69
+ return { id: task.id, result, tokens };
67
70
  } catch (err) {
68
- results.push({ id: task.id, result: `Failed: ${String(err)}` });
71
+ return { id: task.id, result: `Failed: ${String(err)}`, tokens: 0 };
69
72
  }
70
- }
71
- return results;
73
+ });
74
+
75
+ const settled = await Promise.all(tasks);
76
+ let tokensUsed = 0;
77
+ const results = settled.map(s => {
78
+ tokensUsed += s.tokens;
79
+ return { id: s.id, result: s.result };
80
+ });
81
+
82
+ return { results, tokensUsed };
72
83
  }
@@ -15,6 +15,7 @@ export interface RoutingEvent {
15
15
  escalated?: boolean;
16
16
  contextTokens?: number;
17
17
  durationMs?: number;
18
+ state?: Partial<import("./state-machine").RoutingState>;
18
19
  }
19
20
 
20
21
  export function sanitizeRoutingEvent(event: Record<string, unknown>): RoutingEvent {
@@ -33,6 +34,7 @@ export function sanitizeRoutingEvent(event: Record<string, unknown>): RoutingEve
33
34
  "escalated",
34
35
  "contextTokens",
35
36
  "durationMs",
37
+ "state",
36
38
  ]);
37
39
 
38
40
  const sanitized: Record<string, unknown> = {};
@@ -57,6 +59,7 @@ export function sanitizeRoutingEvent(event: Record<string, unknown>): RoutingEve
57
59
  escalated: sanitized.escalated as boolean | undefined,
58
60
  contextTokens: sanitized.contextTokens as number | undefined,
59
61
  durationMs: sanitized.durationMs as number | undefined,
62
+ state: sanitized.state as Partial<import("./state-machine").RoutingState> | undefined,
60
63
  };
61
64
  }
62
65
 
@@ -5,36 +5,36 @@ export const BUILTIN_ROUTING_PRESETS: Record<string, RoutingPoolConfig> = {
5
5
  id: "openai/gpt-5.6",
6
6
  provider: "openai",
7
7
  tiers: {
8
- utility: "gpt-4.1-mini",
9
- balanced: "gpt-4.1",
10
- frontier: "gpt-5-pro",
8
+ utility: "gpt-5.4-mini",
9
+ balanced: "gpt-5.4",
10
+ frontier: "gpt-5.6-sol",
11
11
  },
12
12
  },
13
13
  "anthropic/claude": {
14
14
  id: "anthropic/claude",
15
15
  provider: "anthropic",
16
16
  tiers: {
17
- utility: "claude-3.5-haiku",
18
- balanced: "claude-3.5-sonnet",
19
- frontier: "claude-3.7-sonnet",
17
+ utility: "claude-3-haiku-20240307",
18
+ balanced: "claude-3-5-sonnet-20241022",
19
+ frontier: "claude-opus-4-0",
20
20
  },
21
21
  },
22
22
  "litellm/openai": {
23
23
  id: "litellm/openai",
24
24
  provider: "litellm",
25
25
  tiers: {
26
- utility: "luna",
27
- balanced: "terra",
28
- frontier: "sol",
26
+ utility: "gpt-5.4-mini",
27
+ balanced: "gpt-5.4",
28
+ frontier: "gpt-5.6-sol",
29
29
  },
30
30
  },
31
31
  "litellm/anthropic": {
32
32
  id: "litellm/anthropic",
33
33
  provider: "litellm",
34
34
  tiers: {
35
- utility: "claude-3-5-haiku",
36
- balanced: "claude-3-7-sonnet",
37
- frontier: "claude-opus-4-5",
35
+ utility: "claude-3-5-haiku-20241022",
36
+ balanced: "claude-3-5-sonnet-20241022",
37
+ frontier: "claude-opus-4-0",
38
38
  },
39
39
  },
40
40
  };
@@ -68,7 +68,8 @@ export function validateCustomPools(pools: any): Record<string, RoutingPoolConfi
68
68
 
69
69
  const allowMixed = typeof p.allowMixed === "boolean" ? p.allowMixed : false;
70
70
  if (!allowMixed) {
71
- const getPrefix = (model: string) => (model.includes("/") ? model.split("/")[0] : "");
71
+ const getPrefix = (model: string) =>
72
+ model.includes("/") ? model.split("/")[0] : typeof p.provider === "string" ? p.provider : "";
72
73
  const uPrefix = getPrefix(p.tiers.utility);
73
74
  const bPrefix = getPrefix(p.tiers.balanced);
74
75
  const fPrefix = getPrefix(p.tiers.frontier);
@@ -118,11 +119,24 @@ export function resolveModelPool(
118
119
  for (const [poolId, pool] of Object.entries(customPools)) {
119
120
  if (!pool?.tiers) continue;
120
121
  if (disabledPresets.includes(poolId) || disabledPresets.includes(pool.id)) continue;
121
- if (familyPolicy === "sticky" && pool.allowMixed) continue;
122
+ if (familyPolicy === "sticky") {
123
+ if (pool.allowMixed) continue;
124
+ if (provider) {
125
+ const pProv = pool.provider ?? provider;
126
+ const uProv = pool.tiers.utility.includes("/") ? pool.tiers.utility.split("/")[0] : pProv;
127
+ const bProv = pool.tiers.balanced.includes("/") ? pool.tiers.balanced.split("/")[0] : pProv;
128
+ const fProv = pool.tiers.frontier.includes("/") ? pool.tiers.frontier.split("/")[0] : pProv;
129
+ if (uProv !== provider || bProv !== provider || fProv !== provider) {
130
+ continue;
131
+ }
132
+ }
133
+ }
122
134
  if (provider && pool.provider && pool.provider !== provider && pool.id !== anchorModel) {
123
135
  continue;
124
136
  }
125
137
 
138
+ const qualify = (tier: string) => (tier.includes("/") ? tier : `${pool.provider ?? provider}/${tier}`);
139
+
126
140
  if (
127
141
  pool.id === anchorModel ||
128
142
  pool.tiers.utility === anchorModel ||
@@ -131,9 +145,9 @@ export function resolveModelPool(
131
145
  pool.tiers.utility === modelName ||
132
146
  pool.tiers.balanced === modelName ||
133
147
  pool.tiers.frontier === modelName ||
134
- `${pool.provider ?? provider}/${pool.tiers.utility}` === anchorModel ||
135
- `${pool.provider ?? provider}/${pool.tiers.balanced}` === anchorModel ||
136
- `${pool.provider ?? provider}/${pool.tiers.frontier}` === anchorModel
148
+ qualify(pool.tiers.utility) === anchorModel ||
149
+ qualify(pool.tiers.balanced) === anchorModel ||
150
+ qualify(pool.tiers.frontier) === anchorModel
137
151
  ) {
138
152
  return pool;
139
153
  }
@@ -146,14 +160,16 @@ export function resolveModelPool(
146
160
  continue; // Provider mismatch!
147
161
  }
148
162
 
163
+ const qualify = (tier: string) => (tier.includes("/") ? tier : `${pool.provider}/${tier}`);
164
+
149
165
  if (
150
166
  presetId === anchorModel ||
151
167
  pool.tiers.utility === modelName ||
152
168
  pool.tiers.balanced === modelName ||
153
169
  pool.tiers.frontier === modelName ||
154
- `${pool.provider}/${pool.tiers.utility}` === anchorModel ||
155
- `${pool.provider}/${pool.tiers.balanced}` === anchorModel ||
156
- `${pool.provider}/${pool.tiers.frontier}` === anchorModel
170
+ qualify(pool.tiers.utility) === anchorModel ||
171
+ qualify(pool.tiers.balanced) === anchorModel ||
172
+ qualify(pool.tiers.frontier) === anchorModel
157
173
  ) {
158
174
  return pool;
159
175
  }
@@ -19,13 +19,15 @@ const COMPLEX_KEYWORDS = [
19
19
  "refactor the session",
20
20
  "multi-region",
21
21
  "system design",
22
+ "ambiguity",
23
+ "target",
22
24
  ];
23
25
 
24
26
  const SIMPLE_READ_PATTERNS = [/^(summarize|read|cat|view|explain)\b/i, /\bfix typo\b/i, /\btypo in line\b/i];
25
27
 
26
28
  export function profileTaskDeterministic(input: ProfilerInput): TaskProfile {
27
29
  const reasons: RoutingReasonCode[] = [];
28
- let score = 40; // base score (default balanced)
30
+ let score = 30; // base score (default balanced)
29
31
 
30
32
  const promptLower = input.prompt.toLowerCase().trim();
31
33
 
@@ -9,7 +9,7 @@ export interface ResolveTierResult {
9
9
  }
10
10
 
11
11
  export interface ResolveTierOptions {
12
- contextEstimate?: { usedTokens: number; contextWindow: number };
12
+ contextEstimate?: { usedTokens: number; contextWindow: number; reserveTokens?: number };
13
13
  getModelContextWindow?: (modelId: string) => number;
14
14
  }
15
15
 
@@ -43,6 +43,7 @@ export function resolveTierModel(
43
43
  !checkCandidateContextEligible({
44
44
  estimatedInputTokens: options.contextEstimate.usedTokens,
45
45
  candidateContextWindow: candidateWin,
46
+ reserveTokens: options.contextEstimate.reserveTokens,
46
47
  })
47
48
  ) {
48
49
  return false;
@@ -64,6 +65,7 @@ export function resolveTierModel(
64
65
  !checkCandidateContextEligible({
65
66
  estimatedInputTokens: options.contextEstimate.usedTokens,
66
67
  candidateContextWindow: candidateWin,
68
+ reserveTokens: options.contextEstimate.reserveTokens,
67
69
  })
68
70
  ) {
69
71
  return false;
@@ -26,7 +26,8 @@ export type RoutingReasonCode =
26
26
  | "lint_failure"
27
27
  | "context_capacity_promotion"
28
28
  | "downshift_hysteresis_pending"
29
- | "escalation_floor_active";
29
+ | "escalation_floor_active"
30
+ | "retry_fallback";
30
31
 
31
32
  export interface ReadOnlyDelegationSubtask {
32
33
  id: string;
@@ -84,7 +85,7 @@ export interface RoutingDecision {
84
85
  }
85
86
 
86
87
  export interface RoutingOutcomeEvidence {
87
- kind: "test_failure" | "build_failure" | "lint_failure" | "user_feedback";
88
+ kind: "test_failure" | "build_failure" | "lint_failure" | "user_feedback" | "retry_fallback";
88
89
  summary: string;
89
90
  }
90
91
 
@@ -105,4 +106,5 @@ export interface RoutingSettings {
105
106
 
106
107
  pools: Record<string, RoutingPoolConfig>;
107
108
  disabledPresets: string[];
109
+ tierEffort?: Record<string, string>;
108
110
  }
@@ -294,6 +294,8 @@ export interface PromptOptions {
294
294
  * the provider `onPayload` seam (per-turn, no client-side tool registration).
295
295
  */
296
296
  serverTools?: Record<string, unknown>[];
297
+ /** Optional abort signal for early cancellation */
298
+ signal?: AbortSignal;
297
299
  }
298
300
 
299
301
  /** Result from cycleModel() */
@@ -756,6 +758,17 @@ export class AgentSession {
756
758
  void this.#queueExtensionEvent(event);
757
759
  return;
758
760
  }
761
+
762
+ if (
763
+ event.type === "routing_decision" ||
764
+ event.type === "routing_applied" ||
765
+ event.type === "routing_escalated" ||
766
+ event.type === "routing_skipped" ||
767
+ (event.type as string) === "routing_floor_cleared"
768
+ ) {
769
+ this.sessionManager.appendCustomEntry("routing_event", event);
770
+ }
771
+
759
772
  await this.#emitExtensionEvent(event);
760
773
  this.#emit(event);
761
774
  }
@@ -1284,34 +1297,98 @@ export class AgentSession {
1284
1297
  this.#routingCoordinator.restoreState(state);
1285
1298
  }
1286
1299
  public recordRoutingOutcome(outcome: RoutingOutcome): void {
1300
+ const routingMode = this.settings.get("routing.mode") as import("../routing/types").RoutingMode;
1287
1301
  if (outcome.status === "rejected") {
1288
1302
  const hasUserFeedback = outcome.evidence.some(e => e.kind === "user_feedback");
1289
1303
  const hasTestFailure = outcome.evidence.some(e => e.kind === "test_failure");
1290
1304
  const hasBuildFailure = outcome.evidence.some(e => e.kind === "build_failure");
1291
1305
  const hasLintFailure = outcome.evidence.some(e => e.kind === "lint_failure");
1306
+ const hasRetryFallback = outcome.evidence.some(e => e.kind === "retry_fallback");
1292
1307
 
1293
1308
  const reasons: import("../routing/types").RoutingReasonCode[] = [];
1294
1309
  if (hasUserFeedback) reasons.push("prior_rejection");
1295
1310
  if (hasTestFailure) reasons.push("test_failure");
1296
1311
  if (hasBuildFailure) reasons.push("build_failure");
1297
1312
  if (hasLintFailure) reasons.push("lint_failure");
1313
+ if (hasRetryFallback) reasons.push("retry_fallback");
1298
1314
 
1299
- this.#routingCoordinator.getStateMachine().setEscalationFloor("frontier");
1315
+ if (reasons.length === 0) return;
1300
1316
 
1301
1317
  const routingState = this.#routingCoordinator.getState();
1302
- this.#emitSessionEvent(
1303
- sanitizeRoutingEvent({
1304
- type: "routing_escalated",
1305
- epochId: outcome.epochId,
1306
- reasons: reasons,
1307
- mode: this.settings.get("routing.mode") as import("../routing/types").RoutingMode,
1308
- effectiveTier: routingState.currentTier,
1309
- selectedModel: this.model ? `${this.model.provider}/${this.model.id}` : undefined,
1310
- escalated: true,
1311
- }),
1312
- ).catch(() => {});
1318
+ let targetTier: import("../routing/types").RoutingTier = "frontier";
1319
+ if (outcome.safeToContinue) {
1320
+ if (routingState.currentTier === "utility") targetTier = "balanced";
1321
+ else if (routingState.currentTier === "balanced") targetTier = "frontier";
1322
+ }
1323
+
1324
+ if (routingMode === "auto") {
1325
+ const performEscalationModelSwap = async () => {
1326
+ try {
1327
+ const { resolveModelPool } = await import("../routing/presets");
1328
+ const poolId = this.settings.get("routing.pools") as any;
1329
+ const disabledPresets = this.settings.get("routing.disabledPresets") as readonly string[];
1330
+ const familyPolicy = this.settings.get("routing.familyPolicy") as any;
1331
+ const pool = resolveModelPool(
1332
+ this.model ? `${this.model.provider}/${this.model.id}` : "",
1333
+ poolId,
1334
+ disabledPresets,
1335
+ familyPolicy,
1336
+ );
1337
+ if (!pool) return;
1338
+ const modelId = pool.tiers[targetTier];
1339
+ if (!modelId) return;
1340
+ const uId = modelId.includes("/") ? modelId : `${pool.provider}/${modelId}`;
1341
+ const targetModel = this.#modelRegistry
1342
+ .getAvailable()
1343
+ .find(m => `${m.provider}/${m.id}` === uId || m.id === uId);
1344
+
1345
+ if (!targetModel) {
1346
+ console.warn(`Escalation aborted: target model ${uId} not available in registry.`);
1347
+ return;
1348
+ }
1349
+
1350
+ if (outcome.safeToContinue) {
1351
+ this.agent.abort();
1352
+ }
1353
+
1354
+ this.#routingCoordinator.getStateMachine().setEscalationFloor(targetTier);
1355
+ if (outcome.safeToContinue) {
1356
+ this.#routingCoordinator.restoreState({ currentTier: targetTier });
1357
+ }
1358
+
1359
+ await this.setModelRoutingSwitch(targetModel);
1360
+ const effortMap = this.settings.get("routing.tierEffort") as Record<string, string> | undefined;
1361
+ const { mapTierToEffort } = await import("../routing/effort");
1362
+ const effort = mapTierToEffort(targetTier, effortMap);
1363
+ this.setThinkingLevel(effort as any);
1364
+
1365
+ this.#emitSessionEvent(
1366
+ sanitizeRoutingEvent({
1367
+ type: "routing_escalated",
1368
+ epochId: `route-${Date.now()}-esc`,
1369
+ reasons: reasons,
1370
+ mode: routingMode,
1371
+ effectiveTier: targetTier,
1372
+ state: this.#routingCoordinator.getState(),
1373
+ selectedModel: this.model ? `${this.model.provider}/${this.model.id}` : undefined,
1374
+ escalated: true,
1375
+ }),
1376
+ ).catch(() => {});
1377
+
1378
+ if (outcome.safeToContinue) {
1379
+ this.#scheduleAgentContinue({ delayMs: 10 });
1380
+ }
1381
+ } catch (err) {
1382
+ console.error("Escalation model swap failed", err);
1383
+ }
1384
+ };
1385
+ this.#trackPostPromptTask(performEscalationModelSwap());
1386
+ }
1313
1387
  } else {
1314
- this.#routingCoordinator.getStateMachine().clearEscalationFloor();
1388
+ if (routingMode === "auto") {
1389
+ this.#routingCoordinator.getStateMachine().clearEscalationFloor();
1390
+ this.#emitSessionEvent({ type: "routing_floor_cleared" } as any).catch(() => {});
1391
+ }
1315
1392
  }
1316
1393
  }
1317
1394
  /**
@@ -2405,7 +2482,9 @@ export class AgentSession {
2405
2482
  }
2406
2483
 
2407
2484
  buildDisplaySessionContext(): SessionContext {
2408
- return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
2485
+ const context = deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
2486
+ context.usedTokens = calculateUsedTokens(context.messages);
2487
+ return context;
2409
2488
  }
2410
2489
 
2411
2490
  /** Convert session messages using the same pre-LLM pipeline as the active session. */
@@ -2713,172 +2792,242 @@ export class AgentSession {
2713
2792
  // Evaluate dynamic model routing if enabled
2714
2793
  const routingMode = (this.settings.get("routing.mode") as RoutingMode) ?? "off";
2715
2794
  if (routingMode !== "off" && this.model) {
2716
- const anchorModel = `${this.model.provider}/${this.model.id}`;
2717
- const availableModels = this.#modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
2718
- const startTime = Date.now();
2719
- const systemTokens = Math.round((this.systemPrompt?.length ?? 0) / 4);
2720
- const promptTokens = Math.round(expandedText.length / 4);
2721
- const toolsStr = JSON.stringify(Array.from(this.#toolRegistry.values()));
2722
- const toolsTokens = Math.round(toolsStr.length / 4);
2723
- const reserveTokens = (this.settings.get("compaction.reserveTokens") as number) ?? 0;
2724
- const contextEstimate = {
2725
- usedTokens: calculateUsedTokens(this.messages) + systemTokens + promptTokens + toolsTokens,
2726
- reserveTokens,
2727
- contextWindow: this.model.contextWindow ?? 128000,
2728
- };
2729
- const decision = await this.#routingCoordinator.evaluateTurn({
2730
- anchorModel,
2731
- mode: routingMode,
2732
- prompt: expandedText,
2733
- hasImages: options?.images && options.images.length > 0,
2734
- priorRejection: this.#routingCoordinator.getState().escalationFloor !== undefined,
2735
- availableModels,
2736
- customPools: validateCustomPools(this.settings.get("routing.pools")),
2737
- disabledPresets: (this.settings.get("routing.disabledPresets") as readonly string[]) ?? [],
2738
- familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
2739
- profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
2740
- contextEstimate,
2741
- getModelContextWindow: (modelId: string) => {
2742
- const found = this.#modelRegistry
2743
- .getAll()
2744
- .find(m => `${m.provider}/${m.id}` === modelId || m.id === modelId);
2745
- return found?.contextWindow ?? 128000;
2746
- },
2747
- runRoutingClassifier: async (utilityModel: string, promptText: string) => {
2748
- const resolved = this.#modelRegistry
2749
- .getAll()
2750
- .find(m => `${m.provider}/${m.id}` === utilityModel || m.id === utilityModel);
2751
- if (!resolved) throw new Error("Classifier model not found");
2752
- const apiKey = await this.#modelRegistry.getApiKey(resolved, this.sessionId);
2753
- if (!apiKey) throw new Error("No API key for classifier model");
2754
-
2755
- const systemInstruction = `You are a routing classifier. Analyze the user's prompt and output a JSON object: { "complexityScore": number, "confidence": number, "delegation"?: { "reason": string, "subtasks": { "id": string, "title": string, "description": string, "targetFilesOrPaths": string[], "desiredTier"?: string }[] } }. Score complexity from 0 to 100 based on required reasoning, context width, and coding effort. Return ONLY valid JSON without markdown blocks.`;
2756
-
2757
- const res = await completeSimple(
2758
- resolved,
2759
- {
2760
- systemPrompt: systemInstruction,
2761
- messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
2762
- },
2763
- { apiKey },
2764
- );
2765
- const rawText = res.content
2766
- .filter(c => c.type === "text")
2767
- .map(c => (c as TextContent).text)
2768
- .join("");
2795
+ if (this.#activeRetryFallback) {
2796
+ this.#emitSessionEvent({
2797
+ type: "routing_skipped",
2798
+ epochId: `skip-${Date.now()}`,
2799
+ reasons: ["retry_fallback"],
2800
+ } as any).catch(() => {});
2801
+ } else {
2802
+ const anchorModel = `${this.model.provider}/${this.model.id}`;
2803
+ const availableModels =
2804
+ this.#scopedModels.length > 0
2805
+ ? this.#scopedModels.map(sm => `${sm.model.provider}/${sm.model.id}`)
2806
+ : this.#modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
2807
+ const startTime = Date.now();
2808
+ const systemTokens = Math.round((this.systemPrompt?.length ?? 0) / 4);
2809
+ const promptTokens = Math.round(expandedText.length / 4);
2810
+ const toolsStr = JSON.stringify(Array.from(this.#toolRegistry.values()));
2811
+ const toolsTokens = Math.round(toolsStr.length / 4);
2812
+ const reserveTokens = (this.settings.get("compaction.reserveTokens") as number) ?? 0;
2813
+ const contextEstimate = {
2814
+ usedTokens: calculateUsedTokens(this.messages) + systemTokens + promptTokens + toolsTokens,
2815
+ reserveTokens,
2816
+ contextWindow: this.model.contextWindow ?? 128000,
2817
+ };
2818
+ const decision = await this.#routingCoordinator.evaluateTurn({
2819
+ anchorModel,
2820
+ mode: routingMode,
2821
+ prompt: expandedText,
2822
+ hasImages: options?.images && options.images.length > 0,
2823
+ priorRejection: this.#routingCoordinator.getState().escalationFloor !== undefined,
2824
+ availableModels,
2825
+ customPools: validateCustomPools(this.settings.get("routing.pools")),
2826
+ disabledPresets: (this.settings.get("routing.disabledPresets") as readonly string[]) ?? [],
2827
+ familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
2828
+ profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
2829
+ contextEstimate,
2830
+ getModelContextWindow: (modelId: string) => {
2831
+ const found = this.#modelRegistry
2832
+ .getAll()
2833
+ .find(m => `${m.provider}/${m.id}` === modelId || m.id === modelId);
2834
+ return found?.contextWindow ?? 128000;
2835
+ },
2836
+ runRoutingClassifier: async (utilityModel: string, promptText: string) => {
2837
+ const resolved = this.#modelRegistry
2838
+ .getAll()
2839
+ .find(m => `${m.provider}/${m.id}` === utilityModel || m.id === utilityModel);
2840
+ if (!resolved) throw new Error("Classifier model not found");
2841
+ const apiKey = await this.#modelRegistry.getApiKey(resolved, this.sessionId);
2842
+ if (!apiKey) throw new Error("No API key for classifier model");
2843
+
2844
+ const systemInstruction = `You are a routing classifier. Analyze the user's prompt and output a JSON object: { "complexityScore": number, "confidence": number, "delegation"?: { "reason": string, "subtasks": { "id": string, "title": string, "description": string, "targetFilesOrPaths": string[], "desiredTier"?: string }[] } }. Score complexity from 0 to 100 based on required reasoning, context width, and coding effort. Return ONLY valid JSON without markdown blocks.`;
2845
+
2846
+ const res = await completeSimple(
2847
+ resolved,
2848
+ {
2849
+ systemPrompt: systemInstruction,
2850
+ messages: [
2851
+ { role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() },
2852
+ ],
2853
+ },
2854
+ { apiKey, signal: AbortSignal.timeout(15000) },
2855
+ );
2856
+ const rawText = res.content
2857
+ .filter(c => c.type === "text")
2858
+ .map(c => (c as TextContent).text)
2859
+ .join("");
2769
2860
 
2770
- const jsonMatch = rawText.match(/\{[\s\S]*\}/);
2771
- if (jsonMatch) {
2772
- const parsed = JSON.parse(jsonMatch[0]); // Check validity
2773
- if (res.usage) {
2774
- this.settings.getStorage()?.recordModelUsage(`${resolved.provider}/${resolved.id}`);
2775
- parsed.routingUsage = (res.usage as any).totalTokens ?? (res.usage as any).total ?? 0;
2861
+ const jsonMatch = rawText.match(/\{[\s\S]*\}/);
2862
+ if (jsonMatch) {
2863
+ const parsed = JSON.parse(jsonMatch[0]); // Check validity
2864
+ if (res.usage) {
2865
+ this.settings.getStorage()?.recordModelUsage(`${resolved.provider}/${resolved.id}`);
2866
+ parsed.routingUsage = (res.usage as any).totalTokens ?? (res.usage as any).total ?? 0;
2867
+ }
2868
+ return JSON.stringify(parsed);
2869
+ }
2870
+ throw new Error("Classifier returned malformed JSON");
2871
+ },
2872
+ downshiftAfterTurns: (this.settings.get("routing.downshiftAfterTurns") as number) ?? 2,
2873
+ });
2874
+ const durationMs = Date.now() - startTime;
2875
+
2876
+ this.#emitSessionEvent({
2877
+ type: decision.applied ? "routing_applied" : "routing_decision",
2878
+ epochId: decision.epochId,
2879
+ mode: decision.mode,
2880
+ provider: this.model.provider,
2881
+ poolId: decision.poolId,
2882
+ effectiveTier: decision.effectiveTier,
2883
+ selectedModel: decision.selectedModel,
2884
+ reasons: decision.reasons,
2885
+ delegated: !!decision.delegation,
2886
+ escalated: decision.reasons.some(
2887
+ r => r === "context_capacity_promotion" || r === "escalation_floor_active",
2888
+ ),
2889
+ contextTokens: contextEstimate.usedTokens,
2890
+ routingUsage: decision.routingUsage,
2891
+ durationMs,
2892
+ state: this.#routingCoordinator.getState(),
2893
+ } as any).catch(() => {});
2894
+
2895
+ if (decision.selectedModel && decision.applied) {
2896
+ const targetModel = this.#modelRegistry
2897
+ .getAvailable()
2898
+ .find(m => `${m.provider}/${m.id}` === decision.selectedModel || m.id === decision.selectedModel);
2899
+ if (targetModel) {
2900
+ const currentModelId = this.model ? `${this.model.provider}/${this.model.id}` : undefined;
2901
+ const targetModelId = `${targetModel.provider}/${targetModel.id}`;
2902
+ if (currentModelId !== targetModelId) {
2903
+ await this.setModelRoutingSwitch(targetModel);
2904
+ }
2905
+ if (decision.effectiveTier) {
2906
+ const effortMap = this.settings.get("routing.tierEffort") as Record<string, string> | undefined;
2907
+ const { mapTierToEffort } = await import("../routing/effort");
2908
+ const effort = mapTierToEffort(decision.effectiveTier, effortMap);
2909
+ this.setThinkingLevel(effort as any);
2776
2910
  }
2777
- return JSON.stringify(parsed);
2778
2911
  }
2779
- throw new Error("Classifier returned malformed JSON");
2780
- },
2781
- downshiftAfterTurns: (this.settings.get("routing.downshiftAfterTurns") as number) ?? 2,
2782
- });
2783
- const durationMs = Date.now() - startTime;
2784
-
2785
- this.sessionManager.appendCustomEntry("routing_state", this.#routingCoordinator.getState());
2786
-
2787
- this.#emitSessionEvent({
2788
- type: decision.applied ? "routing_applied" : "routing_decision",
2789
- epochId: decision.epochId,
2790
- mode: decision.mode,
2791
- provider: this.model.provider,
2792
- poolId: decision.poolId,
2793
- effectiveTier: decision.effectiveTier,
2794
- selectedModel: decision.selectedModel,
2795
- reasons: decision.reasons,
2796
- delegated: !!decision.delegation,
2797
- escalated: decision.reasons.some(
2798
- r => r === "context_capacity_promotion" || r === "escalation_floor_active",
2799
- ),
2800
- contextTokens: contextEstimate.usedTokens,
2801
- routingUsage: decision.routingUsage,
2802
- durationMs,
2803
- } as any).catch(() => {});
2804
-
2805
- if (decision.applied && decision.selectedModel && decision.selectedModel !== anchorModel) {
2806
- const targetModel = this.#modelRegistry
2807
- .getAvailable()
2808
- .find(m => `${m.provider}/${m.id}` === decision.selectedModel || m.id === decision.selectedModel);
2809
- if (targetModel) {
2810
- await this.setModelRoutingSwitch(targetModel);
2811
2912
  }
2812
- }
2813
2913
 
2814
- if (
2815
- decision.applied &&
2816
- decision.delegation &&
2817
- (this.settings.get("routing.delegation") as string) === "read-only"
2818
- ) {
2819
- const maxTasks = (this.settings.get("routing.delegationMaxTasks") as number) ?? 3;
2820
- const results = await executeReadOnlyDelegationPlan(
2821
- decision.delegation,
2822
- async subtaskPrompt => {
2823
- const utilityModelId = decision.poolId
2824
- ? (this.settings.get("routing.pools") as any)?.[decision.poolId]?.tiers?.utility
2825
- : undefined;
2826
- const resolvedUtility = utilityModelId
2827
- ? this.#modelRegistry
2914
+ if (
2915
+ decision.applied &&
2916
+ decision.delegation &&
2917
+ decision.delegation.subtasks.length > 1 &&
2918
+ (this.settings.get("routing.delegation") as string) === "read-only"
2919
+ ) {
2920
+ let maxTasks = (this.settings.get("routing.delegationMaxTasks") as number) ?? 3;
2921
+ maxTasks = Math.min(Math.max(1, maxTasks), 3);
2922
+
2923
+ const { results, tokensUsed } = await executeReadOnlyDelegationPlan(
2924
+ decision.delegation,
2925
+ async (subtaskPrompt, signal) => {
2926
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2927
+
2928
+ const { resolveModelPool } = await import("../routing/presets");
2929
+ const pool = resolveModelPool(
2930
+ decision.poolId ?? (this.model ? `${this.model.provider}/${this.model.id}` : ""),
2931
+ this.settings.get("routing.pools") as any,
2932
+ this.settings.get("routing.disabledPresets") as readonly string[],
2933
+ this.settings.get("routing.familyPolicy") as any,
2934
+ );
2935
+
2936
+ let resolvedUtility = this.model;
2937
+ if (pool?.tiers?.utility) {
2938
+ let uId = pool.tiers.utility;
2939
+ if (!uId.includes("/") && pool.provider) {
2940
+ uId = `${pool.provider}/${uId}`;
2941
+ }
2942
+ const found = this.#modelRegistry
2828
2943
  .getAvailable()
2829
- .find(m => `${m.provider}/${m.id}` === utilityModelId || m.id === utilityModelId)
2830
- : this.model;
2831
- if (!resolvedUtility) return "Delegation failed: no model.";
2832
-
2833
- const { createAgentSession } = await import("../sdk");
2834
- const { SessionManager: SDKSessionManager } = await import("./session-manager");
2835
- const childSettings = await this.settings.cloneForCwd(process.cwd());
2836
- childSettings.set("routing.delegation", "off");
2837
- const { session: childSession } = await createAgentSession({
2838
- model: resolvedUtility,
2839
- sessionManager: SDKSessionManager.inMemory(),
2840
- settings: childSettings,
2841
- authStorage: this.#modelRegistry.authStorage,
2842
- modelRegistry: this.#modelRegistry,
2843
- toolNames: Array.from(this.#toolRegistry.values())
2844
- .map(t => t.name)
2845
- .filter(isDelegationAllowedTool),
2846
- enableLsp: false,
2847
- enableMCP: false,
2848
- });
2944
+ .find(m => `${m.provider}/${m.id}` === uId || m.id === uId);
2945
+ if (found) resolvedUtility = found;
2946
+ }
2849
2947
 
2850
- try {
2851
- await childSession.prompt(subtaskPrompt);
2852
- return childSession
2853
- .buildDisplaySessionContext()
2854
- .messages.filter(m => m.role === "assistant")
2855
- .map(m =>
2856
- m.content
2857
- .filter((c: any) => c.type === "text")
2858
- .map((c: any) => c.text)
2859
- .join(""),
2860
- )
2861
- .join("\n");
2862
- } catch (err) {
2863
- return `Delegation failed: ${String(err)}`;
2864
- } finally {
2865
- await childSession.dispose();
2948
+ if (!resolvedUtility) return { result: "Delegation failed: no model.", tokens: 0 };
2949
+
2950
+ const { createAgentSession } = await import("../sdk");
2951
+ const { SessionManager: SDKSessionManager } = await import("./session-manager");
2952
+ const childSettings = await this.settings.cloneForCwd(process.cwd());
2953
+ childSettings.set("routing.delegation", "off");
2954
+
2955
+ // Fix abort race by checking right before creation
2956
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2957
+
2958
+ const { session: childSession } = await createAgentSession({
2959
+ model: resolvedUtility,
2960
+ sessionManager: SDKSessionManager.inMemory(),
2961
+ settings: childSettings,
2962
+ authStorage: this.#modelRegistry.authStorage,
2963
+ modelRegistry: this.#modelRegistry,
2964
+ toolNames: Array.from(this.#toolRegistry.values())
2965
+ .map(t => t.name)
2966
+ .filter(isDelegationAllowedTool),
2967
+ enableLsp: false,
2968
+ enableMCP: false,
2969
+ });
2970
+
2971
+ try {
2972
+ const onAbort = () => childSession.agent.abort();
2973
+ signal?.addEventListener("abort", onAbort);
2974
+ if (signal?.aborted) {
2975
+ onAbort();
2976
+ return { result: "Delegation failed: Aborted", tokens: 0 };
2977
+ }
2978
+ try {
2979
+ await childSession.prompt(subtaskPrompt);
2980
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2981
+ const resultStr = childSession
2982
+ .buildDisplaySessionContext()
2983
+ .messages.filter(m => m.role === "assistant")
2984
+ .map(m =>
2985
+ m.content
2986
+ .filter((c: any) => c.type === "text")
2987
+ .map((c: any) => c.text)
2988
+ .join(""),
2989
+ )
2990
+ .join("\n");
2991
+ const tokens = childSession.buildDisplaySessionContext().usedTokens || 0;
2992
+ return { result: resultStr, tokens };
2993
+ } finally {
2994
+ signal?.removeEventListener("abort", onAbort);
2995
+ }
2996
+ } catch (err) {
2997
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2998
+ return { result: `Delegation failed: ${String(err)}`, tokens: 0 };
2999
+ } finally {
3000
+ await childSession.dispose();
3001
+ }
3002
+ },
3003
+ maxTasks,
3004
+ { signal: options?.signal },
3005
+ );
3006
+
3007
+ if (results.length > 0) {
3008
+ let resultsString = JSON.stringify(results, null, 2);
3009
+ if (resultsString.length > 8000) {
3010
+ resultsString = `${resultsString.substring(0, 8000)}\n...[truncated]`;
3011
+ }
3012
+ const delegationOutput = `\n\n<delegation_results>\n${resultsString}\n</delegation_results>`;
3013
+ expandedText += delegationOutput;
3014
+ const textBlock = userContent.find(c => c.type === "text") as Extract<
3015
+ (typeof userContent)[0],
3016
+ { type: "text" }
3017
+ >;
3018
+ if (textBlock) {
3019
+ textBlock.text += delegationOutput;
3020
+ } else {
3021
+ userContent.push({ type: "text", text: delegationOutput });
2866
3022
  }
2867
- },
2868
- maxTasks,
2869
- );
2870
3023
 
2871
- if (results.length > 0) {
2872
- const delegationOutput = `\n\n<delegation_results>\n${JSON.stringify(results, null, 2)}\n</delegation_results>`;
2873
- expandedText += delegationOutput;
2874
- const textBlock = userContent.find(c => c.type === "text") as Extract<
2875
- (typeof userContent)[0],
2876
- { type: "text" }
2877
- >;
2878
- if (textBlock) {
2879
- textBlock.text += delegationOutput;
2880
- } else {
2881
- userContent.push({ type: "text", text: delegationOutput });
3024
+ this.#emitSessionEvent({
3025
+ type: "routing_delegated",
3026
+ epochId: decision.epochId,
3027
+ tasks: decision.delegation.subtasks.length,
3028
+ completed: results.length,
3029
+ tokensUsed,
3030
+ } as any).catch(() => {});
2882
3031
  }
2883
3032
  }
2884
3033
  }
@@ -5707,11 +5856,6 @@ export class AgentSession {
5707
5856
  this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
5708
5857
  }
5709
5858
 
5710
- this.recordRoutingOutcome({
5711
- status: "rejected",
5712
- evidence: [{ kind: "test_failure", summary: `Fallback applied from ${currentSelector} to ${selector.raw}` }],
5713
- });
5714
-
5715
5859
  await this.#emitSessionEvent({
5716
5860
  type: "retry_fallback_applied",
5717
5861
  from: currentSelector,
@@ -6459,13 +6603,27 @@ export class AgentSession {
6459
6603
  }
6460
6604
 
6461
6605
  #syncRoutingStateFromBranch() {
6462
- const lastRoutingEntry = (this.sessionManager.getBranch() as any[]).findLast(
6463
- e => e.type === "custom" && e.customType === "routing_state",
6464
- );
6465
- if (lastRoutingEntry?.data) {
6466
- this.#routingCoordinator.restoreState(lastRoutingEntry.data);
6467
- } else {
6468
- this.#routingCoordinator.reset();
6606
+ this.#routingCoordinator.reset();
6607
+ const entries = this.sessionManager.getBranch();
6608
+ for (const entry of entries) {
6609
+ if (entry.type === "custom" && entry.customType === "routing_event") {
6610
+ const event = entry.data as any;
6611
+ if (event.state) {
6612
+ this.#routingCoordinator.restoreState(event.state);
6613
+ } else {
6614
+ if (event.type === "routing_escalated") {
6615
+ if (event.effectiveTier) {
6616
+ this.#routingCoordinator.getStateMachine().setEscalationFloor(event.effectiveTier);
6617
+ }
6618
+ } else if (event.type === "routing_floor_cleared") {
6619
+ this.#routingCoordinator.getStateMachine().clearEscalationFloor();
6620
+ } else if (event.type === "routing_applied" || event.type === "routing_decision") {
6621
+ if (event.effectiveTier) {
6622
+ this.#routingCoordinator.restoreState({ currentTier: event.effectiveTier });
6623
+ }
6624
+ }
6625
+ }
6626
+ }
6469
6627
  }
6470
6628
  }
6471
6629
 
@@ -261,6 +261,8 @@ export interface SessionContext {
261
261
  mode: string;
262
262
  /** Mode-specific data from the last mode_change entry */
263
263
  modeData?: Record<string, unknown>;
264
+ /** Number of tokens used across all provider requests on this branch */
265
+ usedTokens: number;
264
266
  }
265
267
 
266
268
  export interface SessionInfo {
@@ -532,6 +534,7 @@ export function buildSessionContext(
532
534
  selectedMCPToolNames: [],
533
535
  hasPersistedMCPToolSelection: false,
534
536
  mode: "none",
537
+ usedTokens: 0,
535
538
  };
536
539
  }
537
540
  if (leafId) {
@@ -552,6 +555,7 @@ export function buildSessionContext(
552
555
  selectedMCPToolNames: [],
553
556
  hasPersistedMCPToolSelection: false,
554
557
  mode: "none",
558
+ usedTokens: 0,
555
559
  };
556
560
  }
557
561
 
@@ -704,6 +708,7 @@ export function buildSessionContext(
704
708
  hasPersistedMCPToolSelection,
705
709
  mode,
706
710
  modeData,
711
+ usedTokens: 0, // Will be computed by the session object later
707
712
  };
708
713
  }
709
714