@f5-sales-demo/xcsh 20.8.5 → 20.9.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.8.5",
4
+ "version": "20.9.0",
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",
@@ -40,6 +40,7 @@
40
40
  "check:bundle": "bun scripts/check-bundle.ts",
41
41
  "pretest": "bun ../../scripts/check-installed-dependencies.ts",
42
42
  "test": "bun run generate-build-info && bun scripts/bun-test-guarded.ts --max-concurrency 1",
43
+ "bench:routing-matrix": "bun scripts/bench-routing-matrix.ts",
43
44
  "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
44
45
  "fmt": "biome format --write . && bun run format-prompts",
45
46
  "format-prompts": "bun scripts/format-prompts.ts",
@@ -61,13 +62,13 @@
61
62
  "dependencies": {
62
63
  "@agentclientprotocol/sdk": "1.3.0",
63
64
  "@mozilla/readability": "^0.6",
64
- "@f5-sales-demo/xcsh-stats": "20.8.5",
65
- "@f5-sales-demo/pi-agent-core": "20.8.5",
66
- "@f5-sales-demo/pi-ai": "20.8.5",
67
- "@f5-sales-demo/pi-natives": "20.8.5",
68
- "@f5-sales-demo/pi-resource-management": "20.8.5",
69
- "@f5-sales-demo/pi-tui": "20.8.5",
70
- "@f5-sales-demo/pi-utils": "20.8.5",
65
+ "@f5-sales-demo/xcsh-stats": "20.9.0",
66
+ "@f5-sales-demo/pi-agent-core": "20.9.0",
67
+ "@f5-sales-demo/pi-ai": "20.9.0",
68
+ "@f5-sales-demo/pi-natives": "20.9.0",
69
+ "@f5-sales-demo/pi-resource-management": "20.9.0",
70
+ "@f5-sales-demo/pi-tui": "20.9.0",
71
+ "@f5-sales-demo/pi-utils": "20.9.0",
71
72
  "@sinclair/typebox": "^0.34",
72
73
  "@xterm/headless": "^6.0",
73
74
  "ajv": "^8.20",
@@ -627,6 +627,28 @@ export const SETTINGS_SCHEMA = {
627
627
  default: [],
628
628
  },
629
629
 
630
+ "routing.internalOpenAiUrl": {
631
+ type: "string",
632
+ default: "",
633
+ ui: {
634
+ tab: "model",
635
+ label: "Internal OpenAI URL",
636
+ description: "Internal FQDN for OpenAI routing lane",
637
+ submenu: true,
638
+ },
639
+ },
640
+
641
+ "routing.internalAnthropicUrl": {
642
+ type: "string",
643
+ default: "",
644
+ ui: {
645
+ tab: "model",
646
+ label: "Internal Anthropic URL",
647
+ description: "Internal FQDN for Anthropic routing lane",
648
+ submenu: true,
649
+ },
650
+ },
651
+
630
652
  // Sampling
631
653
  temperature: {
632
654
  type: "number",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.8.5",
21
- "commit": "ed354637dc7586f58ebedbea98dea65b304e7122",
22
- "shortCommit": "ed35463",
20
+ "version": "20.9.0",
21
+ "commit": "c120e6e1b6530d1ba068a89905dcbd1df72d7b2c",
22
+ "shortCommit": "c120e6e",
23
23
  "branch": "main",
24
- "tag": "v20.8.5",
25
- "commitDate": "2026-08-09T13:04:40Z",
26
- "buildDate": "2026-08-09T13:30:24.105Z",
24
+ "tag": "v20.9.0",
25
+ "commitDate": "2026-08-10T17:23:39Z",
26
+ "buildDate": "2026-08-10T17:52:55.552Z",
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/ed354637dc7586f58ebedbea98dea65b304e7122",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.5"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/c120e6e1b6530d1ba068a89905dcbd1df72d7b2c",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.9.0"
33
33
  };
@@ -86,7 +86,12 @@ export async function classifyTaskHybrid(options: HybridClassifierOptions): Prom
86
86
  if (err instanceof Error && err.name === "AbortError") {
87
87
  throw err;
88
88
  }
89
- const isSyntax = err instanceof SyntaxError || (err instanceof Error && err.message.includes("malformed"));
89
+ const isSyntax =
90
+ err instanceof SyntaxError ||
91
+ (err instanceof Error &&
92
+ (err.message.includes("malformed") ||
93
+ err.message.includes("JSON") ||
94
+ err.message.includes("Unexpected token")));
90
95
  return {
91
96
  ...baseProfile,
92
97
  reasons: [...baseProfile.reasons, isSyntax ? "classifier_fallback_malformed" : "classifier_fallback_timeout"],
@@ -28,6 +28,8 @@ export interface EvaluateTurnOptions {
28
28
  downshiftAfterTurns?: number;
29
29
  getModelContextWindow?: (modelId: string) => number;
30
30
  runRoutingClassifier?: (utilityModel: string, prompt: string) => Promise<string>;
31
+ signal?: AbortSignal;
32
+ ttftStartNs?: number;
31
33
  }
32
34
 
33
35
  export class RoutingCoordinator {
@@ -41,7 +41,10 @@ export function validateDelegationPlan(plan: ReadOnlyDelegationPlan, maxTasks =
41
41
 
42
42
  export async function executeReadOnlyDelegationPlan(
43
43
  plan: ReadOnlyDelegationPlan,
44
- executor: (subtaskPrompt: string, signal?: AbortSignal) => Promise<{ result: string; tokens: number }>,
44
+ executor: (
45
+ subtaskPrompt: string,
46
+ options?: { signal?: AbortSignal; allowedTools?: (toolName: string) => boolean },
47
+ ) => Promise<{ result: string; tokens: number }>,
45
48
  maxTasks = 3,
46
49
  options?: { signal?: AbortSignal },
47
50
  ): Promise<{ results: Array<{ id: string; result: string }>; tokensUsed: number }> {
@@ -65,7 +68,10 @@ export async function executeReadOnlyDelegationPlan(
65
68
  ]
66
69
  .filter(Boolean)
67
70
  .join("\n");
68
- const { result, tokens } = await executor(promptPayload, options?.signal);
71
+ const { result, tokens } = await executor(promptPayload, {
72
+ signal: options?.signal,
73
+ allowedTools: isDelegationAllowedTool,
74
+ });
69
75
  return { id: task.id, result, tokens };
70
76
  } catch (err) {
71
77
  return { id: task.id, result: `Failed: ${String(err)}`, tokens: 0 };
@@ -107,4 +107,7 @@ export interface RoutingSettings {
107
107
  pools: Record<string, RoutingPoolConfig>;
108
108
  disabledPresets: string[];
109
109
  tierEffort?: Record<string, string>;
110
+
111
+ internalOpenAiUrl?: string;
112
+ internalAnthropicUrl?: string;
110
113
  }
@@ -43,7 +43,6 @@ import type {
43
43
  } from "@f5-sales-demo/pi-ai";
44
44
  import {
45
45
  calculateRateLimitBackoffMs,
46
- completeSimple,
47
46
  getSupportedEfforts,
48
47
  isContextOverflow,
49
48
  isUsageLimitError,
@@ -445,6 +444,7 @@ export class AgentSession {
445
444
  * distinguishable from launch configuration (#2459). Defaults to "config": settings, a remembered
446
445
  * role, or a resumed session are all config, and only --model or a live switch is not.
447
446
  */
447
+ #initialModelResolutionSource: ModelResolutionSource = "config";
448
448
  #modelResolutionSource: ModelResolutionSource = "config";
449
449
  #promptTemplates: PromptTemplate[];
450
450
  #slashCommands: FileSlashCommand[];
@@ -596,7 +596,8 @@ export class AgentSession {
596
596
  this.#pythonKernelOwnerId = config.pythonKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
597
597
  this.#scopedModels = config.scopedModels ?? [];
598
598
  this.#thinkingLevel = config.thinkingLevel;
599
- this.#modelResolutionSource = config.modelResolutionSource ?? "config";
599
+ this.#initialModelResolutionSource = config.modelResolutionSource ?? "config";
600
+ this.#modelResolutionSource = this.#initialModelResolutionSource;
600
601
  this.#promptTemplates = config.promptTemplates ?? [];
601
602
  this.#slashCommands = config.slashCommands ?? [];
602
603
  this.#extensionRunner = config.extensionRunner;
@@ -1348,20 +1349,19 @@ export class AgentSession {
1348
1349
  }
1349
1350
 
1350
1351
  if (!outcome.safeToContinue) {
1351
- this.#routingCoordinator.getStateMachine().setEscalationFloor(targetTier);
1352
1352
  return;
1353
1353
  }
1354
1354
 
1355
1355
  const previousTier = this.#routingCoordinator.getState().currentTier;
1356
1356
  const previousFloor = this.#routingCoordinator.getState().escalationFloor;
1357
+ const previousModel = this.model;
1357
1358
 
1358
1359
  try {
1360
+ await this.setModelRoutingSwitch(targetModel);
1359
1361
  this.agent.abort();
1360
1362
 
1361
1363
  this.#routingCoordinator.getStateMachine().setEscalationFloor(targetTier);
1362
1364
  this.#routingCoordinator.restoreState({ currentTier: targetTier });
1363
-
1364
- await this.setModelRoutingSwitch(targetModel);
1365
1365
  const effortMap = this.settings.get("routing.tierEffort") as Record<string, string> | undefined;
1366
1366
  const { mapTierToEffort } = await import("../routing/effort");
1367
1367
  const effort = mapTierToEffort(targetTier, effortMap);
@@ -1387,6 +1387,13 @@ export class AgentSession {
1387
1387
  } else {
1388
1388
  this.#routingCoordinator.getStateMachine().clearEscalationFloor();
1389
1389
  }
1390
+ if (previousModel) {
1391
+ this.#setModelWithProviderSessionReset(previousModel, "runtime-switch");
1392
+ this.sessionManager.appendModelChange(
1393
+ `${previousModel.provider}/${previousModel.id}`,
1394
+ "routing_switch_rollback",
1395
+ );
1396
+ }
1390
1397
  } finally {
1391
1398
  this.#scheduleAgentContinue({ delayMs: 10 });
1392
1399
  }
@@ -2801,247 +2808,21 @@ export class AgentSession {
2801
2808
 
2802
2809
  await this.#maybeRestoreRetryFallbackPrimary();
2803
2810
 
2804
- // Evaluate dynamic model routing if enabled
2805
- const routingMode = (this.settings.get("routing.mode") as RoutingMode) ?? "off";
2806
- if (routingMode !== "off" && this.model) {
2807
- if (this.#activeRetryFallback) {
2808
- this.#emitSessionEvent({
2809
- type: "routing_skipped",
2810
- epochId: `skip-${Date.now()}`,
2811
- reasons: ["retry_fallback"],
2812
- } as any).catch(() => {});
2811
+ const delegationOutput = await this.#evaluateAndApplyRouting(
2812
+ expandedText,
2813
+ options?.images ? options.images.length > 0 : false,
2814
+ { signal: options?.signal },
2815
+ );
2816
+ if (delegationOutput) {
2817
+ expandedText += delegationOutput;
2818
+ const textBlock = userContent.find(c => c.type === "text") as Extract<
2819
+ (typeof userContent)[0],
2820
+ { type: "text" }
2821
+ >;
2822
+ if (textBlock) {
2823
+ textBlock.text += delegationOutput;
2813
2824
  } else {
2814
- const anchorModel = `${this.model.provider}/${this.model.id}`;
2815
- const availableModels =
2816
- this.#scopedModels.length > 0
2817
- ? this.#scopedModels.map(sm => `${sm.model.provider}/${sm.model.id}`)
2818
- : this.#modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
2819
- const startTime = Date.now();
2820
- const systemTokens = Math.round((this.systemPrompt?.length ?? 0) / 4);
2821
- const promptTokens = Math.round(expandedText.length / 4);
2822
- const toolsStr = JSON.stringify(Array.from(this.#toolRegistry.values()));
2823
- const toolsTokens = Math.round(toolsStr.length / 4);
2824
- const reserveTokens = (this.settings.get("compaction.reserveTokens") as number) ?? 0;
2825
- const contextEstimate = {
2826
- usedTokens: calculateUsedTokens(this.messages) + systemTokens + promptTokens + toolsTokens,
2827
- reserveTokens,
2828
- contextWindow: this.model.contextWindow ?? 128000,
2829
- };
2830
- const decision = await this.#routingCoordinator.evaluateTurn({
2831
- anchorModel,
2832
- mode: routingMode,
2833
- prompt: expandedText,
2834
- hasImages: options?.images && options.images.length > 0,
2835
- priorRejection: this.#routingCoordinator.getState().escalationFloor !== undefined,
2836
- availableModels,
2837
- customPools: validateCustomPools(this.settings.get("routing.pools")),
2838
- disabledPresets: (this.settings.get("routing.disabledPresets") as readonly string[]) ?? [],
2839
- familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
2840
- profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
2841
- contextEstimate,
2842
- getModelContextWindow: (modelId: string) => {
2843
- const found = this.#modelRegistry
2844
- .getAll()
2845
- .find(m => `${m.provider}/${m.id}` === modelId || m.id === modelId);
2846
- return found?.contextWindow ?? 128000;
2847
- },
2848
- runRoutingClassifier: async (utilityModel: string, promptText: string) => {
2849
- const resolved = this.#modelRegistry
2850
- .getAll()
2851
- .find(m => `${m.provider}/${m.id}` === utilityModel || m.id === utilityModel);
2852
- if (!resolved) throw new Error("Classifier model not found");
2853
- const apiKey = await this.#modelRegistry.getApiKey(resolved, this.sessionId);
2854
- if (!apiKey) throw new Error("No API key for classifier model");
2855
-
2856
- 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.`;
2857
-
2858
- const res = await completeSimple(
2859
- resolved,
2860
- {
2861
- systemPrompt: systemInstruction,
2862
- messages: [
2863
- { role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() },
2864
- ],
2865
- },
2866
- { apiKey, signal: AbortSignal.timeout(15000) },
2867
- );
2868
- const rawText = res.content
2869
- .filter(c => c.type === "text")
2870
- .map(c => (c as TextContent).text)
2871
- .join("");
2872
-
2873
- const jsonMatch = rawText.match(/\{[\s\S]*\}/);
2874
- if (jsonMatch) {
2875
- const parsed = JSON.parse(jsonMatch[0]); // Check validity
2876
- if (res.usage) {
2877
- this.settings.getStorage()?.recordModelUsage(`${resolved.provider}/${resolved.id}`);
2878
- parsed.routingUsage = (res.usage as any).totalTokens ?? (res.usage as any).total ?? 0;
2879
- }
2880
- return JSON.stringify(parsed);
2881
- }
2882
- throw new Error("Classifier returned malformed JSON");
2883
- },
2884
- downshiftAfterTurns: (this.settings.get("routing.downshiftAfterTurns") as number) ?? 2,
2885
- });
2886
- const durationMs = Date.now() - startTime;
2887
-
2888
- this.#emitSessionEvent({
2889
- type: decision.applied ? "routing_applied" : "routing_decision",
2890
- epochId: decision.epochId,
2891
- mode: decision.mode,
2892
- provider: this.model.provider,
2893
- poolId: decision.poolId,
2894
- effectiveTier: decision.effectiveTier,
2895
- selectedModel: decision.selectedModel,
2896
- reasons: decision.reasons,
2897
- delegated: !!decision.delegation,
2898
- escalated: decision.reasons.some(
2899
- r => r === "context_capacity_promotion" || r === "escalation_floor_active",
2900
- ),
2901
- contextTokens: contextEstimate.usedTokens,
2902
- routingUsage: decision.routingUsage,
2903
- durationMs,
2904
- state: this.#routingCoordinator.getState(),
2905
- } as any).catch(() => {});
2906
-
2907
- if (decision.selectedModel && decision.applied) {
2908
- const targetModel = this.#modelRegistry
2909
- .getAvailable()
2910
- .find(m => `${m.provider}/${m.id}` === decision.selectedModel || m.id === decision.selectedModel);
2911
- if (targetModel) {
2912
- const currentModelId = this.model ? `${this.model.provider}/${this.model.id}` : undefined;
2913
- const targetModelId = `${targetModel.provider}/${targetModel.id}`;
2914
- if (currentModelId !== targetModelId) {
2915
- await this.setModelRoutingSwitch(targetModel);
2916
- }
2917
- if (decision.effectiveTier) {
2918
- const effortMap = this.settings.get("routing.tierEffort") as Record<string, string> | undefined;
2919
- const { mapTierToEffort } = await import("../routing/effort");
2920
- const effort = mapTierToEffort(decision.effectiveTier, effortMap);
2921
- this.setThinkingLevel(effort as any);
2922
- }
2923
- }
2924
- }
2925
-
2926
- if (
2927
- decision.applied &&
2928
- decision.delegation &&
2929
- decision.delegation.subtasks.length > 1 &&
2930
- (this.settings.get("routing.delegation") as string) === "read-only"
2931
- ) {
2932
- let maxTasks = (this.settings.get("routing.delegationMaxTasks") as number) ?? 3;
2933
- maxTasks = Math.min(Math.max(1, maxTasks), 3);
2934
-
2935
- const { results, tokensUsed } = await executeReadOnlyDelegationPlan(
2936
- decision.delegation,
2937
- async (subtaskPrompt, signal) => {
2938
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2939
-
2940
- const { resolveModelPool } = await import("../routing/presets");
2941
- const pool = resolveModelPool(
2942
- decision.poolId ?? (this.model ? `${this.model.provider}/${this.model.id}` : ""),
2943
- this.settings.get("routing.pools") as any,
2944
- this.settings.get("routing.disabledPresets") as readonly string[],
2945
- this.settings.get("routing.familyPolicy") as any,
2946
- );
2947
-
2948
- let resolvedUtility = this.model;
2949
- if (pool?.tiers?.utility) {
2950
- let uId = pool.tiers.utility;
2951
- if (!uId.includes("/") && pool.provider) {
2952
- uId = `${pool.provider}/${uId}`;
2953
- }
2954
- const found = this.#modelRegistry
2955
- .getAvailable()
2956
- .find(m => `${m.provider}/${m.id}` === uId || m.id === uId);
2957
- if (found) resolvedUtility = found;
2958
- }
2959
-
2960
- if (!resolvedUtility) return { result: "Delegation failed: no model.", tokens: 0 };
2961
-
2962
- const { createAgentSession } = await import("../sdk");
2963
- const { SessionManager: SDKSessionManager } = await import("./session-manager");
2964
- const childSettings = await this.settings.cloneForCwd(process.cwd());
2965
- childSettings.set("routing.delegation", "off");
2966
-
2967
- // Fix abort race by checking right before creation
2968
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2969
-
2970
- const { session: childSession } = await createAgentSession({
2971
- model: resolvedUtility,
2972
- sessionManager: SDKSessionManager.inMemory(),
2973
- settings: childSettings,
2974
- authStorage: this.#modelRegistry.authStorage,
2975
- modelRegistry: this.#modelRegistry,
2976
- toolNames: Array.from(this.#toolRegistry.values())
2977
- .map(t => t.name)
2978
- .filter(isDelegationAllowedTool),
2979
- enableLsp: false,
2980
- enableMCP: false,
2981
- });
2982
-
2983
- try {
2984
- const onAbort = () => childSession.agent.abort();
2985
- signal?.addEventListener("abort", onAbort);
2986
- if (signal?.aborted) {
2987
- onAbort();
2988
- return { result: "Delegation failed: Aborted", tokens: 0 };
2989
- }
2990
- try {
2991
- await childSession.prompt(subtaskPrompt);
2992
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2993
- const resultStr = childSession
2994
- .buildDisplaySessionContext()
2995
- .messages.filter(m => m.role === "assistant")
2996
- .map(m =>
2997
- m.content
2998
- .filter((c: any) => c.type === "text")
2999
- .map((c: any) => c.text)
3000
- .join(""),
3001
- )
3002
- .join("\n");
3003
- const tokens = childSession.buildDisplaySessionContext().usedTokens || 0;
3004
- return { result: resultStr, tokens };
3005
- } finally {
3006
- signal?.removeEventListener("abort", onAbort);
3007
- }
3008
- } catch (err) {
3009
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3010
- return { result: `Delegation failed: ${String(err)}`, tokens: 0 };
3011
- } finally {
3012
- await childSession.dispose();
3013
- }
3014
- },
3015
- maxTasks,
3016
- { signal: options?.signal },
3017
- );
3018
-
3019
- if (results.length > 0) {
3020
- let resultsString = JSON.stringify(results, null, 2);
3021
- if (resultsString.length > 8000) {
3022
- resultsString = `${resultsString.substring(0, 8000)}\n...[truncated]`;
3023
- }
3024
- const delegationOutput = `\n\n<delegation_results>\n${resultsString}\n</delegation_results>`;
3025
- expandedText += delegationOutput;
3026
- const textBlock = userContent.find(c => c.type === "text") as Extract<
3027
- (typeof userContent)[0],
3028
- { type: "text" }
3029
- >;
3030
- if (textBlock) {
3031
- textBlock.text += delegationOutput;
3032
- } else {
3033
- userContent.push({ type: "text", text: delegationOutput });
3034
- }
3035
-
3036
- this.#emitSessionEvent({
3037
- type: "routing_delegated",
3038
- epochId: decision.epochId,
3039
- tasks: decision.delegation.subtasks.length,
3040
- completed: results.length,
3041
- tokensUsed,
3042
- } as any).catch(() => {});
3043
- }
3044
- }
2825
+ userContent.push({ type: "text", text: delegationOutput });
3045
2826
  }
3046
2827
  }
3047
2828
 
@@ -3062,7 +2843,7 @@ export class AgentSession {
3062
2843
 
3063
2844
  async promptCustomMessage<T = unknown>(
3064
2845
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
3065
- options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
2846
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice" | "signal">,
3066
2847
  ): Promise<void> {
3067
2848
  const textContent =
3068
2849
  typeof message.content === "string"
@@ -3080,17 +2861,276 @@ export class AgentSession {
3080
2861
  return;
3081
2862
  }
3082
2863
 
2864
+ const clonedContent = Array.isArray(message.content)
2865
+ ? message.content.map((c: any) => ({ ...c }))
2866
+ : message.content;
2867
+
3083
2868
  const customMessage: CustomMessage<T> = {
3084
2869
  role: "custom",
3085
2870
  customType: message.customType,
3086
- content: message.content,
2871
+ content: clonedContent,
3087
2872
  display: message.display,
3088
2873
  details: message.details,
3089
2874
  attribution: message.attribution ?? "agent",
3090
2875
  timestamp: Date.now(),
3091
2876
  };
3092
2877
 
3093
- await this.#promptWithMessage(customMessage, textContent, options);
2878
+ const delegationOutput = await this.#evaluateAndApplyRouting(
2879
+ textContent,
2880
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
2881
+ { signal: options?.signal },
2882
+ );
2883
+ if (delegationOutput) {
2884
+ if (typeof customMessage.content === "string") {
2885
+ customMessage.content += delegationOutput;
2886
+ } else if (Array.isArray(customMessage.content)) {
2887
+ const textBlock = customMessage.content.find((c: any) => c.type === "text") as any;
2888
+ if (textBlock) {
2889
+ textBlock.text += delegationOutput;
2890
+ } else {
2891
+ customMessage.content.push({ type: "text", text: delegationOutput });
2892
+ }
2893
+ }
2894
+ }
2895
+
2896
+ await this.#promptWithMessage(customMessage, textContent + (delegationOutput || ""), options);
2897
+ }
2898
+
2899
+ async #evaluateAndApplyRouting(
2900
+ promptText: string,
2901
+ hasImages: boolean,
2902
+ options?: { signal?: AbortSignal } | any,
2903
+ ): Promise<string | undefined> {
2904
+ const routingMode = (this.settings.get("routing.mode") as import("../routing/types").RoutingMode) ?? "off";
2905
+ if (routingMode === "off" || !this.model) return undefined;
2906
+
2907
+ if (this.#activeRetryFallback) {
2908
+ this.#emitSessionEvent({
2909
+ type: "routing_skipped",
2910
+ epochId: `skip-${Date.now()}`,
2911
+ reasons: ["retry_fallback"],
2912
+ } as any).catch(() => {});
2913
+ return undefined;
2914
+ }
2915
+
2916
+ const anchorModel = `${this.model.provider}/${this.model.id}`;
2917
+ const availableModels =
2918
+ this.#scopedModels.length > 0
2919
+ ? this.#scopedModels.map(sm => `${sm.model.provider}/${sm.model.id}`)
2920
+ : this.#modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
2921
+ const startTime = Date.now();
2922
+ const systemTokens = Math.round((this.systemPrompt?.length ?? 0) / 4);
2923
+ const promptTokens = Math.round(promptText.length / 4);
2924
+ const toolsStr = JSON.stringify(Array.from(this.#toolRegistry.values()));
2925
+ const toolsTokens = Math.round(toolsStr.length / 4);
2926
+ const reserveTokens = (this.settings.get("compaction.reserveTokens") as number) ?? 0;
2927
+ const contextEstimate = {
2928
+ usedTokens: calculateUsedTokens(this.messages) + systemTokens + promptTokens + toolsTokens,
2929
+ reserveTokens,
2930
+ contextWindow: this.model.contextWindow ?? 128000,
2931
+ };
2932
+ const decision = await this.#routingCoordinator.evaluateTurn({
2933
+ anchorModel,
2934
+ mode: routingMode,
2935
+ prompt: promptText,
2936
+ hasImages,
2937
+ priorRejection: this.#routingCoordinator.getState().escalationFloor !== undefined,
2938
+ availableModels,
2939
+ customPools: validateCustomPools(this.settings.get("routing.pools")),
2940
+ disabledPresets: (this.settings.get("routing.disabledPresets") as readonly string[]) ?? [],
2941
+ familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
2942
+ profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
2943
+ contextEstimate,
2944
+ signal: options?.signal,
2945
+ getModelContextWindow: (modelId: string) => {
2946
+ const m = this.#modelRegistry
2947
+ .getAvailable()
2948
+ .find(m => `${m.provider}/${m.id}` === modelId || m.id === modelId);
2949
+ return m?.contextWindow ?? 128000;
2950
+ },
2951
+ });
2952
+
2953
+ this.#emitSessionEvent({
2954
+ type: "routing_evaluated",
2955
+ epochId: decision.epochId,
2956
+ profiler: {
2957
+ latencyMs: Date.now() - startTime,
2958
+ ...(decision as any).profiler,
2959
+ },
2960
+ applied: decision.applied,
2961
+ decision: decision.selectedModel,
2962
+ delegation: decision.delegation,
2963
+ } as any).catch(() => {});
2964
+
2965
+ if (decision.selectedModel && decision.applied) {
2966
+ const targetModel = this.#modelRegistry
2967
+ .getAvailable()
2968
+ .find(m => `${m.provider}/${m.id}` === decision.selectedModel || m.id === decision.selectedModel);
2969
+ if (targetModel) {
2970
+ const currentModelId = `${this.model.provider}/${this.model.id}`;
2971
+ const targetModelId = `${targetModel.provider}/${targetModel.id}`;
2972
+
2973
+ let needsSwitch = currentModelId !== targetModelId;
2974
+
2975
+ // Force switch if the current model is the same but needs an internal URL override
2976
+ if (!needsSwitch) {
2977
+ if (
2978
+ targetModel.provider === "openai" ||
2979
+ (targetModel.provider === "litellm" &&
2980
+ (targetModel.id.includes("openai") || /gpt-/.test(targetModel.id)))
2981
+ ) {
2982
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
2983
+ if ((internalUrl || undefined) !== this.model.baseUrl) {
2984
+ needsSwitch = true;
2985
+ }
2986
+ } else if (
2987
+ targetModel.provider === "anthropic" ||
2988
+ (targetModel.provider === "litellm" &&
2989
+ (targetModel.id.includes("anthropic") || targetModel.id.includes("claude")))
2990
+ ) {
2991
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
2992
+ if ((internalUrl || undefined) !== this.model.baseUrl) {
2993
+ needsSwitch = true;
2994
+ }
2995
+ }
2996
+ }
2997
+
2998
+ if (needsSwitch) {
2999
+ await this.setModelRoutingSwitch(targetModel);
3000
+ }
3001
+ }
3002
+ }
3003
+
3004
+ if (
3005
+ decision.applied &&
3006
+ decision.delegation &&
3007
+ decision.delegation.subtasks.length > 1 &&
3008
+ (this.settings.get("routing.delegation") as string) === "read-only"
3009
+ ) {
3010
+ let maxTasks = (this.settings.get("routing.delegationMaxTasks") as number) ?? 3;
3011
+ maxTasks = Math.min(Math.max(1, maxTasks), 3);
3012
+
3013
+ const { results, tokensUsed } = await executeReadOnlyDelegationPlan(
3014
+ decision.delegation,
3015
+ async (subtaskPrompt, options) => {
3016
+ const signal = options?.signal;
3017
+ const allowedTools = options?.allowedTools ?? isDelegationAllowedTool;
3018
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3019
+
3020
+ const { resolveModelPool } = await import("../routing/presets");
3021
+ const pool = resolveModelPool(
3022
+ decision.poolId ?? (this.model ? `${this.model.provider}/${this.model.id}` : ""),
3023
+ this.settings.get("routing.pools") as any,
3024
+ this.settings.get("routing.disabledPresets") as readonly string[],
3025
+ this.settings.get("routing.familyPolicy") as any,
3026
+ );
3027
+
3028
+ let resolvedUtility = this.model;
3029
+ if (pool?.tiers?.utility) {
3030
+ let uId = pool.tiers.utility;
3031
+ if (!uId.includes("/") && pool.provider) {
3032
+ uId = `${pool.provider}/${uId}`;
3033
+ }
3034
+ const found = this.#modelRegistry
3035
+ .getAvailable()
3036
+ .find(m => `${m.provider}/${m.id}` === uId || m.id === uId);
3037
+ if (found) resolvedUtility = found;
3038
+ }
3039
+
3040
+ if (!resolvedUtility) return { result: "Delegation failed: Model not resolved", tokens: 0 };
3041
+
3042
+ if (
3043
+ resolvedUtility.provider === "openai" ||
3044
+ (resolvedUtility.provider === "litellm" &&
3045
+ (resolvedUtility.id.includes("openai") || /gpt-/.test(resolvedUtility.id)))
3046
+ ) {
3047
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
3048
+ if (internalUrl) resolvedUtility = { ...resolvedUtility, baseUrl: internalUrl } as any;
3049
+ } else if (
3050
+ resolvedUtility.provider === "anthropic" ||
3051
+ (resolvedUtility.provider === "litellm" &&
3052
+ (resolvedUtility.id.includes("anthropic") || resolvedUtility.id.includes("claude")))
3053
+ ) {
3054
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
3055
+ if (internalUrl) resolvedUtility = { ...resolvedUtility, baseUrl: internalUrl } as any;
3056
+ }
3057
+
3058
+ const { createAgentSession } = await import("../sdk");
3059
+ const { SessionManager: SDKSessionManager } = await import("./session-manager");
3060
+ const childSettings = await this.settings.cloneForCwd(process.cwd());
3061
+ childSettings.set("routing.delegation", "off");
3062
+
3063
+ if (options?.signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3064
+
3065
+ const { session: childSession } = await createAgentSession({
3066
+ model: resolvedUtility!,
3067
+ sessionManager: SDKSessionManager.inMemory(),
3068
+ settings: childSettings,
3069
+ authStorage: this.#modelRegistry.authStorage,
3070
+ modelRegistry: this.#modelRegistry,
3071
+ toolNames: Array.from(this.#toolRegistry.values())
3072
+ .map(t => t.name)
3073
+ .filter(allowedTools),
3074
+ enableLsp: false,
3075
+ enableMCP: false,
3076
+ });
3077
+
3078
+ try {
3079
+ const onAbort = () => childSession.agent.abort();
3080
+ signal?.addEventListener("abort", onAbort);
3081
+ if (signal?.aborted) {
3082
+ onAbort();
3083
+ return { result: "Delegation failed: Aborted", tokens: 0 };
3084
+ }
3085
+ try {
3086
+ await childSession.prompt(subtaskPrompt);
3087
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3088
+ const resultStr = childSession
3089
+ .buildDisplaySessionContext()
3090
+ .messages.filter((m: any) => m.role === "assistant")
3091
+ .map((m: any) =>
3092
+ m.content
3093
+ .filter((c: any) => c.type === "text")
3094
+ .map((c: any) => c.text)
3095
+ .join(""),
3096
+ )
3097
+ .join("\n");
3098
+ const tUsage = childSession.buildDisplaySessionContext().usedTokens || 0;
3099
+ return { result: resultStr || "No output", tokens: tUsage };
3100
+ } finally {
3101
+ signal?.removeEventListener("abort", onAbort);
3102
+ }
3103
+ } catch (err) {
3104
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3105
+ return { result: `Delegation failed: ${String(err)}`, tokens: 0 };
3106
+ } finally {
3107
+ await childSession.dispose();
3108
+ }
3109
+ },
3110
+ maxTasks,
3111
+ { signal: options?.signal },
3112
+ );
3113
+
3114
+ if (results.length > 0) {
3115
+ let resultsString = JSON.stringify(results, null, 2);
3116
+ if (resultsString.length > 8000) {
3117
+ resultsString = `${resultsString.substring(0, 8000)}\n...[truncated]`;
3118
+ }
3119
+ const delegationOutput = `\n\n<delegation_results>\n${resultsString}\n</delegation_results>`;
3120
+
3121
+ this.#emitSessionEvent({
3122
+ type: "routing_delegated",
3123
+ epochId: decision.epochId,
3124
+ tasks: decision.delegation.subtasks.length,
3125
+ completed: results.length,
3126
+ tokensUsed,
3127
+ } as any).catch(() => {});
3128
+
3129
+ return delegationOutput;
3130
+ }
3131
+ }
3132
+
3133
+ return undefined;
3094
3134
  }
3095
3135
 
3096
3136
  async #promptWithMessage(
@@ -3479,8 +3519,25 @@ export class AgentSession {
3479
3519
 
3480
3520
  const prependMessages = queuedMessages.slice(0, -1);
3481
3521
  const textContent = this.#getCustomMessageTextContent(message);
3522
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3523
+ textContent,
3524
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3525
+ );
3526
+ if (delegationOutput) {
3527
+ if (typeof message.content === "string") {
3528
+ message.content += delegationOutput;
3529
+ } else if (Array.isArray(message.content)) {
3530
+ const textBlock = message.content.find((c: any) => c.type === "text");
3531
+ if (textBlock) {
3532
+ (textBlock as any).text += delegationOutput;
3533
+ } else {
3534
+ message.content.push({ type: "text", text: delegationOutput });
3535
+ }
3536
+ }
3537
+ }
3538
+
3482
3539
  try {
3483
- await this.#promptWithMessage(message, textContent, {
3540
+ await this.#promptWithMessage(message, textContent + (delegationOutput || ""), {
3484
3541
  prependMessages,
3485
3542
  skipPostPromptRecoveryWait: true,
3486
3543
  });
@@ -3527,12 +3584,16 @@ export class AgentSession {
3527
3584
  */
3528
3585
  async sendCustomMessage<T = unknown>(
3529
3586
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
3530
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
3587
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn"; signal?: AbortSignal },
3531
3588
  ): Promise<void> {
3589
+ const clonedContent = Array.isArray(message.content)
3590
+ ? message.content.map((c: any) => ({ ...c }))
3591
+ : message.content;
3592
+
3532
3593
  const appMessage: CustomMessage<T> = {
3533
3594
  role: "custom",
3534
3595
  customType: message.customType,
3535
- content: message.content,
3596
+ content: clonedContent,
3536
3597
  display: message.display,
3537
3598
  details: message.details,
3538
3599
  attribution: message.attribution ?? "agent",
@@ -3554,7 +3615,33 @@ export class AgentSession {
3554
3615
 
3555
3616
  if (options?.deliverAs === "nextTurn") {
3556
3617
  if (options?.triggerTurn) {
3557
- await this.agent.prompt(appMessage);
3618
+ let promptText = "";
3619
+ if (typeof message.content === "string") promptText = message.content;
3620
+ else if (Array.isArray(message.content)) {
3621
+ promptText = message.content
3622
+ .filter(c => c.type === "text")
3623
+ .map((c: any) => c.text)
3624
+ .join("");
3625
+ }
3626
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3627
+ promptText,
3628
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3629
+ options,
3630
+ );
3631
+ if (delegationOutput) {
3632
+ promptText += delegationOutput;
3633
+ if (typeof appMessage.content === "string") {
3634
+ appMessage.content += delegationOutput;
3635
+ } else if (Array.isArray(appMessage.content)) {
3636
+ const textBlock = appMessage.content.find(c => c.type === "text") as any;
3637
+ if (textBlock) {
3638
+ textBlock.text += delegationOutput;
3639
+ } else {
3640
+ appMessage.content.push({ type: "text", text: delegationOutput });
3641
+ }
3642
+ }
3643
+ }
3644
+ await this.#promptWithMessage(appMessage, promptText, options as any);
3558
3645
  return;
3559
3646
  }
3560
3647
  this.agent.appendMessage(appMessage);
@@ -3569,7 +3656,33 @@ export class AgentSession {
3569
3656
  }
3570
3657
 
3571
3658
  if (options?.triggerTurn) {
3572
- await this.agent.prompt(appMessage);
3659
+ let promptText = "";
3660
+ if (typeof message.content === "string") promptText = message.content;
3661
+ else if (Array.isArray(message.content)) {
3662
+ promptText = message.content
3663
+ .filter(c => c.type === "text")
3664
+ .map((c: any) => c.text)
3665
+ .join("");
3666
+ }
3667
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3668
+ promptText,
3669
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3670
+ options,
3671
+ );
3672
+ if (delegationOutput) {
3673
+ promptText += delegationOutput;
3674
+ if (typeof appMessage.content === "string") {
3675
+ appMessage.content += delegationOutput;
3676
+ } else if (Array.isArray(appMessage.content)) {
3677
+ const textBlock = appMessage.content.find(c => c.type === "text") as any;
3678
+ if (textBlock) {
3679
+ textBlock.text += delegationOutput;
3680
+ } else {
3681
+ appMessage.content.push({ type: "text", text: delegationOutput });
3682
+ }
3683
+ }
3684
+ }
3685
+ await this.#promptWithMessage(appMessage, promptText, options as any);
3573
3686
  return;
3574
3687
  }
3575
3688
 
@@ -4014,15 +4127,31 @@ export class AgentSession {
4014
4127
  */
4015
4128
  async setModelRoutingSwitch(model: Model): Promise<void> {
4016
4129
  const previousEditMode = this.#resolveActiveEditMode();
4017
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
4130
+
4131
+ let targetModel = model;
4132
+ if (
4133
+ model.provider === "openai" ||
4134
+ (model.provider === "litellm" && (model.id.includes("openai") || /gpt-/.test(model.id)))
4135
+ ) {
4136
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
4137
+ if (internalUrl) targetModel = { ...model, baseUrl: internalUrl };
4138
+ } else if (
4139
+ model.provider === "anthropic" ||
4140
+ (model.provider === "litellm" && (model.id.includes("anthropic") || model.id.includes("claude")))
4141
+ ) {
4142
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
4143
+ if (internalUrl) targetModel = { ...model, baseUrl: internalUrl };
4144
+ }
4145
+
4146
+ const apiKey = await this.#modelRegistry.getApiKey(targetModel, this.sessionId);
4018
4147
  if (!apiKey) {
4019
- throw new Error(`No API key for ${model.provider}/${model.id}`);
4148
+ throw new Error(`No API key for ${targetModel.provider}/${targetModel.id}`);
4020
4149
  }
4021
4150
 
4022
4151
  // DO NOT clear active retry fallback - routing is a transient optimization
4023
- this.#setModelWithProviderSessionReset(model, "runtime-switch");
4024
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "routing_switch");
4025
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
4152
+ this.#setModelWithProviderSessionReset(targetModel, "runtime-switch");
4153
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, "routing_switch");
4154
+ this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
4026
4155
 
4027
4156
  this.setThinkingLevel(this.thinkingLevel);
4028
4157
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
@@ -6617,8 +6746,10 @@ export class AgentSession {
6617
6746
  #syncRoutingStateFromBranch() {
6618
6747
  this.#routingCoordinator.reset();
6619
6748
  const entries = this.sessionManager.getBranch();
6749
+ let foundRoutingState = false;
6620
6750
  for (const entry of entries) {
6621
6751
  if (entry.type === "custom" && entry.customType === "routing_event") {
6752
+ foundRoutingState = true;
6622
6753
  const event = entry.data as any;
6623
6754
  if (event.state) {
6624
6755
  this.#routingCoordinator.restoreState(event.state);
@@ -6637,6 +6768,16 @@ export class AgentSession {
6637
6768
  }
6638
6769
  }
6639
6770
  }
6771
+ if (!foundRoutingState) {
6772
+ this.#modelResolutionSource = this.#initialModelResolutionSource;
6773
+ const hasServiceTierEntry = this.sessionManager
6774
+ .getBranch()
6775
+ .some(entry => entry.type === "service_tier_change");
6776
+ if (!hasServiceTierEntry) {
6777
+ const configuredServiceTier = this.settings?.get("serviceTier");
6778
+ this.agent.serviceTier = configuredServiceTier === "none" ? undefined : configuredServiceTier;
6779
+ }
6780
+ }
6640
6781
  }
6641
6782
 
6642
6783
  /**
@@ -6690,6 +6831,7 @@ export class AgentSession {
6690
6831
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
6691
6832
  }
6692
6833
  this.#syncTodoPhasesFromBranch();
6834
+ this.#syncRoutingStateFromBranch();
6693
6835
  this.agent.sessionId = this.sessionManager.getSessionId();
6694
6836
 
6695
6837
  // Reload messages from entries (works for both file and in-memory mode)
@@ -7236,6 +7378,9 @@ export class AgentSession {
7236
7378
  export function calculateUsedTokens(messages: any[]): number {
7237
7379
  const countTextLength = (content: any): number => {
7238
7380
  if (typeof content === "string") {
7381
+ if (content.startsWith("data:image/") || content.startsWith("base64,")) {
7382
+ return 0;
7383
+ }
7239
7384
  return content.length;
7240
7385
  }
7241
7386
  if (Array.isArray(content)) {