@f5-sales-demo/xcsh 20.8.6 → 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.6",
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.6",
65
- "@f5-sales-demo/pi-agent-core": "20.8.6",
66
- "@f5-sales-demo/pi-ai": "20.8.6",
67
- "@f5-sales-demo/pi-natives": "20.8.6",
68
- "@f5-sales-demo/pi-resource-management": "20.8.6",
69
- "@f5-sales-demo/pi-tui": "20.8.6",
70
- "@f5-sales-demo/pi-utils": "20.8.6",
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",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.8.6",
21
- "commit": "ffac88b3a59886c6e6e579bc266e0a62e3d354d5",
22
- "shortCommit": "ffac88b",
20
+ "version": "20.9.0",
21
+ "commit": "c120e6e1b6530d1ba068a89905dcbd1df72d7b2c",
22
+ "shortCommit": "c120e6e",
23
23
  "branch": "main",
24
- "tag": "v20.8.6",
25
- "commitDate": "2026-08-10T05:40:49Z",
26
- "buildDate": "2026-08-10T06:09:48.444Z",
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/ffac88b3a59886c6e6e579bc266e0a62e3d354d5",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.6"
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 };
@@ -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,7 +1349,6 @@ 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
 
@@ -1357,12 +1357,11 @@ export class AgentSession {
1357
1357
  const previousModel = this.model;
1358
1358
 
1359
1359
  try {
1360
+ await this.setModelRoutingSwitch(targetModel);
1360
1361
  this.agent.abort();
1361
1362
 
1362
1363
  this.#routingCoordinator.getStateMachine().setEscalationFloor(targetTier);
1363
1364
  this.#routingCoordinator.restoreState({ currentTier: targetTier });
1364
-
1365
- await this.setModelRoutingSwitch(targetModel);
1366
1365
  const effortMap = this.settings.get("routing.tierEffort") as Record<string, string> | undefined;
1367
1366
  const { mapTierToEffort } = await import("../routing/effort");
1368
1367
  const effort = mapTierToEffort(targetTier, effortMap);
@@ -2809,264 +2808,21 @@ export class AgentSession {
2809
2808
 
2810
2809
  await this.#maybeRestoreRetryFallbackPrimary();
2811
2810
 
2812
- // Evaluate dynamic model routing if enabled
2813
- const routingMode = (this.settings.get("routing.mode") as RoutingMode) ?? "off";
2814
- if (routingMode !== "off" && this.model) {
2815
- if (this.#activeRetryFallback) {
2816
- this.#emitSessionEvent({
2817
- type: "routing_skipped",
2818
- epochId: `skip-${Date.now()}`,
2819
- reasons: ["retry_fallback"],
2820
- } 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;
2821
2824
  } else {
2822
- const anchorModel = `${this.model.provider}/${this.model.id}`;
2823
- const availableModels =
2824
- this.#scopedModels.length > 0
2825
- ? this.#scopedModels.map(sm => `${sm.model.provider}/${sm.model.id}`)
2826
- : this.#modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
2827
- const startTime = Date.now();
2828
- const systemTokens = Math.round((this.systemPrompt?.length ?? 0) / 4);
2829
- const promptTokens = Math.round(expandedText.length / 4);
2830
- const toolsStr = JSON.stringify(Array.from(this.#toolRegistry.values()));
2831
- const toolsTokens = Math.round(toolsStr.length / 4);
2832
- const reserveTokens = (this.settings.get("compaction.reserveTokens") as number) ?? 0;
2833
- const contextEstimate = {
2834
- usedTokens: calculateUsedTokens(this.messages) + systemTokens + promptTokens + toolsTokens,
2835
- reserveTokens,
2836
- contextWindow: this.model.contextWindow ?? 128000,
2837
- };
2838
- const decision = await this.#routingCoordinator.evaluateTurn({
2839
- anchorModel,
2840
- mode: routingMode,
2841
- prompt: expandedText,
2842
- hasImages: options?.images && options.images.length > 0,
2843
- priorRejection: this.#routingCoordinator.getState().escalationFloor !== undefined,
2844
- availableModels,
2845
- customPools: validateCustomPools(this.settings.get("routing.pools")),
2846
- disabledPresets: (this.settings.get("routing.disabledPresets") as readonly string[]) ?? [],
2847
- familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
2848
- profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
2849
- contextEstimate,
2850
- getModelContextWindow: (modelId: string) => {
2851
- const found = this.#modelRegistry
2852
- .getAll()
2853
- .find(m => `${m.provider}/${m.id}` === modelId || m.id === modelId);
2854
- return found?.contextWindow ?? 128000;
2855
- },
2856
- runRoutingClassifier: async (utilityModel: string, promptText: string) => {
2857
- const resolved = this.#modelRegistry
2858
- .getAll()
2859
- .find(m => `${m.provider}/${m.id}` === utilityModel || m.id === utilityModel);
2860
- if (!resolved) throw new Error("Classifier model not found");
2861
-
2862
- let targetModel = resolved;
2863
- if (
2864
- resolved.provider === "openai" ||
2865
- (resolved.provider === "litellm" && resolved.id.includes("openai"))
2866
- ) {
2867
- const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
2868
- if (internalUrl) targetModel = { ...resolved, baseUrl: internalUrl };
2869
- } else if (
2870
- resolved.provider === "anthropic" ||
2871
- (resolved.provider === "litellm" &&
2872
- (resolved.id.includes("anthropic") || resolved.id.includes("claude")))
2873
- ) {
2874
- const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
2875
- if (internalUrl) targetModel = { ...resolved, baseUrl: internalUrl };
2876
- }
2877
-
2878
- const apiKey = await this.#modelRegistry.getApiKey(targetModel, this.sessionId);
2879
- if (!apiKey) throw new Error("No API key for classifier model");
2880
-
2881
- 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.`;
2882
-
2883
- const res = await completeSimple(
2884
- targetModel,
2885
- {
2886
- systemPrompt: systemInstruction,
2887
- messages: [
2888
- { role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() },
2889
- ],
2890
- },
2891
- { apiKey, signal: AbortSignal.timeout(15000) },
2892
- );
2893
- const rawText = res.content
2894
- .filter(c => c.type === "text")
2895
- .map(c => (c as TextContent).text)
2896
- .join("");
2897
-
2898
- const jsonMatch = rawText.match(/\{[\s\S]*\}/);
2899
- if (jsonMatch) {
2900
- const parsed = JSON.parse(jsonMatch[0]); // Check validity
2901
- if (res.usage) {
2902
- this.settings.getStorage()?.recordModelUsage(`${resolved.provider}/${resolved.id}`);
2903
- parsed.routingUsage = (res.usage as any).totalTokens ?? (res.usage as any).total ?? 0;
2904
- }
2905
- return JSON.stringify(parsed);
2906
- }
2907
- throw new Error("Classifier returned malformed JSON");
2908
- },
2909
- downshiftAfterTurns: (this.settings.get("routing.downshiftAfterTurns") as number) ?? 2,
2910
- });
2911
- const durationMs = Date.now() - startTime;
2912
-
2913
- this.#emitSessionEvent({
2914
- type: decision.applied ? "routing_applied" : "routing_decision",
2915
- epochId: decision.epochId,
2916
- mode: decision.mode,
2917
- provider: this.model.provider,
2918
- poolId: decision.poolId,
2919
- effectiveTier: decision.effectiveTier,
2920
- selectedModel: decision.selectedModel,
2921
- reasons: decision.reasons,
2922
- delegated: !!decision.delegation,
2923
- escalated: decision.reasons.some(
2924
- r => r === "context_capacity_promotion" || r === "escalation_floor_active",
2925
- ),
2926
- contextTokens: contextEstimate.usedTokens,
2927
- routingUsage: decision.routingUsage,
2928
- durationMs,
2929
- state: this.#routingCoordinator.getState(),
2930
- } as any).catch(() => {});
2931
-
2932
- if (decision.selectedModel && decision.applied) {
2933
- const targetModel = this.#modelRegistry
2934
- .getAvailable()
2935
- .find(m => `${m.provider}/${m.id}` === decision.selectedModel || m.id === decision.selectedModel);
2936
- if (targetModel) {
2937
- const currentModelId = this.model ? `${this.model.provider}/${this.model.id}` : undefined;
2938
- const targetModelId = `${targetModel.provider}/${targetModel.id}`;
2939
- if (currentModelId !== targetModelId) {
2940
- await this.setModelRoutingSwitch(targetModel);
2941
- }
2942
- if (decision.effectiveTier) {
2943
- const effortMap = this.settings.get("routing.tierEffort") as Record<string, string> | undefined;
2944
- const { mapTierToEffort } = await import("../routing/effort");
2945
- const effort = mapTierToEffort(decision.effectiveTier, effortMap);
2946
- this.setThinkingLevel(effort as any);
2947
- }
2948
- }
2949
- }
2950
-
2951
- if (
2952
- decision.applied &&
2953
- decision.delegation &&
2954
- decision.delegation.subtasks.length > 1 &&
2955
- (this.settings.get("routing.delegation") as string) === "read-only"
2956
- ) {
2957
- let maxTasks = (this.settings.get("routing.delegationMaxTasks") as number) ?? 3;
2958
- maxTasks = Math.min(Math.max(1, maxTasks), 3);
2959
-
2960
- const { results, tokensUsed } = await executeReadOnlyDelegationPlan(
2961
- decision.delegation,
2962
- async (subtaskPrompt, signal) => {
2963
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2964
-
2965
- const { resolveModelPool } = await import("../routing/presets");
2966
- const pool = resolveModelPool(
2967
- decision.poolId ?? (this.model ? `${this.model.provider}/${this.model.id}` : ""),
2968
- this.settings.get("routing.pools") as any,
2969
- this.settings.get("routing.disabledPresets") as readonly string[],
2970
- this.settings.get("routing.familyPolicy") as any,
2971
- );
2972
-
2973
- let resolvedUtility = this.model;
2974
- if (pool?.tiers?.utility) {
2975
- let uId = pool.tiers.utility;
2976
- if (!uId.includes("/") && pool.provider) {
2977
- uId = `${pool.provider}/${uId}`;
2978
- }
2979
- const found = this.#modelRegistry
2980
- .getAvailable()
2981
- .find(m => `${m.provider}/${m.id}` === uId || m.id === uId);
2982
- if (found) resolvedUtility = found;
2983
- }
2984
-
2985
- if (!resolvedUtility) return { result: "Delegation failed: no model.", tokens: 0 };
2986
-
2987
- const { createAgentSession } = await import("../sdk");
2988
- const { SessionManager: SDKSessionManager } = await import("./session-manager");
2989
- const childSettings = await this.settings.cloneForCwd(process.cwd());
2990
- childSettings.set("routing.delegation", "off");
2991
-
2992
- // Fix abort race by checking right before creation
2993
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
2994
-
2995
- const { session: childSession } = await createAgentSession({
2996
- model: resolvedUtility,
2997
- sessionManager: SDKSessionManager.inMemory(),
2998
- settings: childSettings,
2999
- authStorage: this.#modelRegistry.authStorage,
3000
- modelRegistry: this.#modelRegistry,
3001
- toolNames: Array.from(this.#toolRegistry.values())
3002
- .map(t => t.name)
3003
- .filter(isDelegationAllowedTool),
3004
- enableLsp: false,
3005
- enableMCP: false,
3006
- });
3007
-
3008
- try {
3009
- const onAbort = () => childSession.agent.abort();
3010
- signal?.addEventListener("abort", onAbort);
3011
- if (signal?.aborted) {
3012
- onAbort();
3013
- return { result: "Delegation failed: Aborted", tokens: 0 };
3014
- }
3015
- try {
3016
- await childSession.prompt(subtaskPrompt);
3017
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3018
- const resultStr = childSession
3019
- .buildDisplaySessionContext()
3020
- .messages.filter(m => m.role === "assistant")
3021
- .map(m =>
3022
- m.content
3023
- .filter((c: any) => c.type === "text")
3024
- .map((c: any) => c.text)
3025
- .join(""),
3026
- )
3027
- .join("\n");
3028
- const tokens = childSession.buildDisplaySessionContext().usedTokens || 0;
3029
- return { result: resultStr, tokens };
3030
- } finally {
3031
- signal?.removeEventListener("abort", onAbort);
3032
- }
3033
- } catch (err) {
3034
- if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3035
- return { result: `Delegation failed: ${String(err)}`, tokens: 0 };
3036
- } finally {
3037
- await childSession.dispose();
3038
- }
3039
- },
3040
- maxTasks,
3041
- { signal: options?.signal },
3042
- );
3043
-
3044
- if (results.length > 0) {
3045
- let resultsString = JSON.stringify(results, null, 2);
3046
- if (resultsString.length > 8000) {
3047
- resultsString = `${resultsString.substring(0, 8000)}\n...[truncated]`;
3048
- }
3049
- const delegationOutput = `\n\n<delegation_results>\n${resultsString}\n</delegation_results>`;
3050
- expandedText += delegationOutput;
3051
- const textBlock = userContent.find(c => c.type === "text") as Extract<
3052
- (typeof userContent)[0],
3053
- { type: "text" }
3054
- >;
3055
- if (textBlock) {
3056
- textBlock.text += delegationOutput;
3057
- } else {
3058
- userContent.push({ type: "text", text: delegationOutput });
3059
- }
3060
-
3061
- this.#emitSessionEvent({
3062
- type: "routing_delegated",
3063
- epochId: decision.epochId,
3064
- tasks: decision.delegation.subtasks.length,
3065
- completed: results.length,
3066
- tokensUsed,
3067
- } as any).catch(() => {});
3068
- }
3069
- }
2825
+ userContent.push({ type: "text", text: delegationOutput });
3070
2826
  }
3071
2827
  }
3072
2828
 
@@ -3087,7 +2843,7 @@ export class AgentSession {
3087
2843
 
3088
2844
  async promptCustomMessage<T = unknown>(
3089
2845
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
3090
- options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
2846
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice" | "signal">,
3091
2847
  ): Promise<void> {
3092
2848
  const textContent =
3093
2849
  typeof message.content === "string"
@@ -3122,6 +2878,7 @@ export class AgentSession {
3122
2878
  const delegationOutput = await this.#evaluateAndApplyRouting(
3123
2879
  textContent,
3124
2880
  Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
2881
+ { signal: options?.signal },
3125
2882
  );
3126
2883
  if (delegationOutput) {
3127
2884
  if (typeof customMessage.content === "string") {
@@ -3184,6 +2941,7 @@ export class AgentSession {
3184
2941
  familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
3185
2942
  profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
3186
2943
  contextEstimate,
2944
+ signal: options?.signal,
3187
2945
  getModelContextWindow: (modelId: string) => {
3188
2946
  const m = this.#modelRegistry
3189
2947
  .getAvailable()
@@ -3218,7 +2976,8 @@ export class AgentSession {
3218
2976
  if (!needsSwitch) {
3219
2977
  if (
3220
2978
  targetModel.provider === "openai" ||
3221
- (targetModel.provider === "litellm" && targetModel.id.includes("openai"))
2979
+ (targetModel.provider === "litellm" &&
2980
+ (targetModel.id.includes("openai") || /gpt-/.test(targetModel.id)))
3222
2981
  ) {
3223
2982
  const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
3224
2983
  if ((internalUrl || undefined) !== this.model.baseUrl) {
@@ -3253,7 +3012,9 @@ export class AgentSession {
3253
3012
 
3254
3013
  const { results, tokensUsed } = await executeReadOnlyDelegationPlan(
3255
3014
  decision.delegation,
3256
- async (subtaskPrompt, signal) => {
3015
+ async (subtaskPrompt, options) => {
3016
+ const signal = options?.signal;
3017
+ const allowedTools = options?.allowedTools ?? isDelegationAllowedTool;
3257
3018
  if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3258
3019
 
3259
3020
  const { resolveModelPool } = await import("../routing/presets");
@@ -3280,7 +3041,8 @@ export class AgentSession {
3280
3041
 
3281
3042
  if (
3282
3043
  resolvedUtility.provider === "openai" ||
3283
- (resolvedUtility.provider === "litellm" && resolvedUtility.id.includes("openai"))
3044
+ (resolvedUtility.provider === "litellm" &&
3045
+ (resolvedUtility.id.includes("openai") || /gpt-/.test(resolvedUtility.id)))
3284
3046
  ) {
3285
3047
  const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
3286
3048
  if (internalUrl) resolvedUtility = { ...resolvedUtility, baseUrl: internalUrl } as any;
@@ -3308,7 +3070,7 @@ export class AgentSession {
3308
3070
  modelRegistry: this.#modelRegistry,
3309
3071
  toolNames: Array.from(this.#toolRegistry.values())
3310
3072
  .map(t => t.name)
3311
- .filter(isDelegationAllowedTool),
3073
+ .filter(allowedTools),
3312
3074
  enableLsp: false,
3313
3075
  enableMCP: false,
3314
3076
  });
@@ -3333,7 +3095,7 @@ export class AgentSession {
3333
3095
  .join(""),
3334
3096
  )
3335
3097
  .join("\n");
3336
- const tUsage = ((childSession as any).agent?.lastUsage?.totalTokens ?? 0) as number;
3098
+ const tUsage = childSession.buildDisplaySessionContext().usedTokens || 0;
3337
3099
  return { result: resultStr || "No output", tokens: tUsage };
3338
3100
  } finally {
3339
3101
  signal?.removeEventListener("abort", onAbort);
@@ -3822,7 +3584,7 @@ export class AgentSession {
3822
3584
  */
3823
3585
  async sendCustomMessage<T = unknown>(
3824
3586
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
3825
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
3587
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn"; signal?: AbortSignal },
3826
3588
  ): Promise<void> {
3827
3589
  const clonedContent = Array.isArray(message.content)
3828
3590
  ? message.content.map((c: any) => ({ ...c }))
@@ -4367,7 +4129,10 @@ export class AgentSession {
4367
4129
  const previousEditMode = this.#resolveActiveEditMode();
4368
4130
 
4369
4131
  let targetModel = model;
4370
- if (model.provider === "openai" || (model.provider === "litellm" && model.id.includes("openai"))) {
4132
+ if (
4133
+ model.provider === "openai" ||
4134
+ (model.provider === "litellm" && (model.id.includes("openai") || /gpt-/.test(model.id)))
4135
+ ) {
4371
4136
  const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
4372
4137
  if (internalUrl) targetModel = { ...model, baseUrl: internalUrl };
4373
4138
  } else if (
@@ -6981,8 +6746,10 @@ export class AgentSession {
6981
6746
  #syncRoutingStateFromBranch() {
6982
6747
  this.#routingCoordinator.reset();
6983
6748
  const entries = this.sessionManager.getBranch();
6749
+ let foundRoutingState = false;
6984
6750
  for (const entry of entries) {
6985
6751
  if (entry.type === "custom" && entry.customType === "routing_event") {
6752
+ foundRoutingState = true;
6986
6753
  const event = entry.data as any;
6987
6754
  if (event.state) {
6988
6755
  this.#routingCoordinator.restoreState(event.state);
@@ -7001,6 +6768,16 @@ export class AgentSession {
7001
6768
  }
7002
6769
  }
7003
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
+ }
7004
6781
  }
7005
6782
 
7006
6783
  /**
@@ -7054,6 +6831,7 @@ export class AgentSession {
7054
6831
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
7055
6832
  }
7056
6833
  this.#syncTodoPhasesFromBranch();
6834
+ this.#syncRoutingStateFromBranch();
7057
6835
  this.agent.sessionId = this.sessionManager.getSessionId();
7058
6836
 
7059
6837
  // Reload messages from entries (works for both file and in-memory mode)
@@ -7600,6 +7378,9 @@ export class AgentSession {
7600
7378
  export function calculateUsedTokens(messages: any[]): number {
7601
7379
  const countTextLength = (content: any): number => {
7602
7380
  if (typeof content === "string") {
7381
+ if (content.startsWith("data:image/") || content.startsWith("base64,")) {
7382
+ return 0;
7383
+ }
7603
7384
  return content.length;
7604
7385
  }
7605
7386
  if (Array.isArray(content)) {