@f5-sales-demo/xcsh 20.8.4 → 20.8.6

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.4",
4
+ "version": "20.8.6",
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.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",
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",
71
71
  "@sinclair/typebox": "^0.34",
72
72
  "@xterm/headless": "^6.0",
73
73
  "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.4",
21
- "commit": "297e0432f40ac41a3926217230d2ef6b3864eb26",
22
- "shortCommit": "297e043",
20
+ "version": "20.8.6",
21
+ "commit": "ffac88b3a59886c6e6e579bc266e0a62e3d354d5",
22
+ "shortCommit": "ffac88b",
23
23
  "branch": "main",
24
- "tag": "v20.8.4",
25
- "commitDate": "2026-08-09T08:55:46Z",
26
- "buildDate": "2026-08-09T09:20:02.412Z",
24
+ "tag": "v20.8.6",
25
+ "commitDate": "2026-08-10T05:40:49Z",
26
+ "buildDate": "2026-08-10T06:09:48.444Z",
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/297e0432f40ac41a3926217230d2ef6b3864eb26",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.4"
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"
33
33
  };
@@ -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
  }
@@ -1347,39 +1347,59 @@ export class AgentSession {
1347
1347
  return;
1348
1348
  }
1349
1349
 
1350
- if (outcome.safeToContinue) {
1351
- this.agent.abort();
1350
+ if (!outcome.safeToContinue) {
1351
+ this.#routingCoordinator.getStateMachine().setEscalationFloor(targetTier);
1352
+ return;
1352
1353
  }
1353
1354
 
1354
- this.#routingCoordinator.getStateMachine().setEscalationFloor(targetTier);
1355
- if (outcome.safeToContinue) {
1355
+ const previousTier = this.#routingCoordinator.getState().currentTier;
1356
+ const previousFloor = this.#routingCoordinator.getState().escalationFloor;
1357
+ const previousModel = this.model;
1358
+
1359
+ try {
1360
+ this.agent.abort();
1361
+
1362
+ this.#routingCoordinator.getStateMachine().setEscalationFloor(targetTier);
1356
1363
  this.#routingCoordinator.restoreState({ currentTier: targetTier });
1357
- }
1358
1364
 
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) {
1365
+ await this.setModelRoutingSwitch(targetModel);
1366
+ const effortMap = this.settings.get("routing.tierEffort") as Record<string, string> | undefined;
1367
+ const { mapTierToEffort } = await import("../routing/effort");
1368
+ const effort = mapTierToEffort(targetTier, effortMap);
1369
+ this.setThinkingLevel(effort as any);
1370
+
1371
+ this.#emitSessionEvent(
1372
+ sanitizeRoutingEvent({
1373
+ type: "routing_escalated",
1374
+ epochId: `route-${Date.now()}-esc`,
1375
+ reasons: reasons,
1376
+ mode: routingMode,
1377
+ effectiveTier: targetTier,
1378
+ state: this.#routingCoordinator.getState(),
1379
+ selectedModel: this.model ? `${this.model.provider}/${this.model.id}` : undefined,
1380
+ escalated: true,
1381
+ }),
1382
+ ).catch(() => {});
1383
+ } catch (err) {
1384
+ console.error("Escalation model swap failed", err);
1385
+ this.#routingCoordinator.restoreState({ currentTier: previousTier });
1386
+ if (previousFloor) {
1387
+ this.#routingCoordinator.getStateMachine().setEscalationFloor(previousFloor);
1388
+ } else {
1389
+ this.#routingCoordinator.getStateMachine().clearEscalationFloor();
1390
+ }
1391
+ if (previousModel) {
1392
+ this.#setModelWithProviderSessionReset(previousModel, "runtime-switch");
1393
+ this.sessionManager.appendModelChange(
1394
+ `${previousModel.provider}/${previousModel.id}`,
1395
+ "routing_switch_rollback",
1396
+ );
1397
+ }
1398
+ } finally {
1379
1399
  this.#scheduleAgentContinue({ delayMs: 10 });
1380
1400
  }
1381
1401
  } catch (err) {
1382
- console.error("Escalation model swap failed", err);
1402
+ console.error("Escalation setup failed", err);
1383
1403
  }
1384
1404
  };
1385
1405
  this.#trackPostPromptTask(performEscalationModelSwap());
@@ -2838,13 +2858,30 @@ export class AgentSession {
2838
2858
  .getAll()
2839
2859
  .find(m => `${m.provider}/${m.id}` === utilityModel || m.id === utilityModel);
2840
2860
  if (!resolved) throw new Error("Classifier model not found");
2841
- const apiKey = await this.#modelRegistry.getApiKey(resolved, this.sessionId);
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);
2842
2879
  if (!apiKey) throw new Error("No API key for classifier model");
2843
2880
 
2844
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.`;
2845
2882
 
2846
2883
  const res = await completeSimple(
2847
- resolved,
2884
+ targetModel,
2848
2885
  {
2849
2886
  systemPrompt: systemInstruction,
2850
2887
  messages: [
@@ -3068,17 +3105,270 @@ export class AgentSession {
3068
3105
  return;
3069
3106
  }
3070
3107
 
3108
+ const clonedContent = Array.isArray(message.content)
3109
+ ? message.content.map((c: any) => ({ ...c }))
3110
+ : message.content;
3111
+
3071
3112
  const customMessage: CustomMessage<T> = {
3072
3113
  role: "custom",
3073
3114
  customType: message.customType,
3074
- content: message.content,
3115
+ content: clonedContent,
3075
3116
  display: message.display,
3076
3117
  details: message.details,
3077
3118
  attribution: message.attribution ?? "agent",
3078
3119
  timestamp: Date.now(),
3079
3120
  };
3080
3121
 
3081
- await this.#promptWithMessage(customMessage, textContent, options);
3122
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3123
+ textContent,
3124
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3125
+ );
3126
+ if (delegationOutput) {
3127
+ if (typeof customMessage.content === "string") {
3128
+ customMessage.content += delegationOutput;
3129
+ } else if (Array.isArray(customMessage.content)) {
3130
+ const textBlock = customMessage.content.find((c: any) => c.type === "text") as any;
3131
+ if (textBlock) {
3132
+ textBlock.text += delegationOutput;
3133
+ } else {
3134
+ customMessage.content.push({ type: "text", text: delegationOutput });
3135
+ }
3136
+ }
3137
+ }
3138
+
3139
+ await this.#promptWithMessage(customMessage, textContent + (delegationOutput || ""), options);
3140
+ }
3141
+
3142
+ async #evaluateAndApplyRouting(
3143
+ promptText: string,
3144
+ hasImages: boolean,
3145
+ options?: { signal?: AbortSignal } | any,
3146
+ ): Promise<string | undefined> {
3147
+ const routingMode = (this.settings.get("routing.mode") as import("../routing/types").RoutingMode) ?? "off";
3148
+ if (routingMode === "off" || !this.model) return undefined;
3149
+
3150
+ if (this.#activeRetryFallback) {
3151
+ this.#emitSessionEvent({
3152
+ type: "routing_skipped",
3153
+ epochId: `skip-${Date.now()}`,
3154
+ reasons: ["retry_fallback"],
3155
+ } as any).catch(() => {});
3156
+ return undefined;
3157
+ }
3158
+
3159
+ const anchorModel = `${this.model.provider}/${this.model.id}`;
3160
+ const availableModels =
3161
+ this.#scopedModels.length > 0
3162
+ ? this.#scopedModels.map(sm => `${sm.model.provider}/${sm.model.id}`)
3163
+ : this.#modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
3164
+ const startTime = Date.now();
3165
+ const systemTokens = Math.round((this.systemPrompt?.length ?? 0) / 4);
3166
+ const promptTokens = Math.round(promptText.length / 4);
3167
+ const toolsStr = JSON.stringify(Array.from(this.#toolRegistry.values()));
3168
+ const toolsTokens = Math.round(toolsStr.length / 4);
3169
+ const reserveTokens = (this.settings.get("compaction.reserveTokens") as number) ?? 0;
3170
+ const contextEstimate = {
3171
+ usedTokens: calculateUsedTokens(this.messages) + systemTokens + promptTokens + toolsTokens,
3172
+ reserveTokens,
3173
+ contextWindow: this.model.contextWindow ?? 128000,
3174
+ };
3175
+ const decision = await this.#routingCoordinator.evaluateTurn({
3176
+ anchorModel,
3177
+ mode: routingMode,
3178
+ prompt: promptText,
3179
+ hasImages,
3180
+ priorRejection: this.#routingCoordinator.getState().escalationFloor !== undefined,
3181
+ availableModels,
3182
+ customPools: validateCustomPools(this.settings.get("routing.pools")),
3183
+ disabledPresets: (this.settings.get("routing.disabledPresets") as readonly string[]) ?? [],
3184
+ familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
3185
+ profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
3186
+ contextEstimate,
3187
+ getModelContextWindow: (modelId: string) => {
3188
+ const m = this.#modelRegistry
3189
+ .getAvailable()
3190
+ .find(m => `${m.provider}/${m.id}` === modelId || m.id === modelId);
3191
+ return m?.contextWindow ?? 128000;
3192
+ },
3193
+ });
3194
+
3195
+ this.#emitSessionEvent({
3196
+ type: "routing_evaluated",
3197
+ epochId: decision.epochId,
3198
+ profiler: {
3199
+ latencyMs: Date.now() - startTime,
3200
+ ...(decision as any).profiler,
3201
+ },
3202
+ applied: decision.applied,
3203
+ decision: decision.selectedModel,
3204
+ delegation: decision.delegation,
3205
+ } as any).catch(() => {});
3206
+
3207
+ if (decision.selectedModel && decision.applied) {
3208
+ const targetModel = this.#modelRegistry
3209
+ .getAvailable()
3210
+ .find(m => `${m.provider}/${m.id}` === decision.selectedModel || m.id === decision.selectedModel);
3211
+ if (targetModel) {
3212
+ const currentModelId = `${this.model.provider}/${this.model.id}`;
3213
+ const targetModelId = `${targetModel.provider}/${targetModel.id}`;
3214
+
3215
+ let needsSwitch = currentModelId !== targetModelId;
3216
+
3217
+ // Force switch if the current model is the same but needs an internal URL override
3218
+ if (!needsSwitch) {
3219
+ if (
3220
+ targetModel.provider === "openai" ||
3221
+ (targetModel.provider === "litellm" && targetModel.id.includes("openai"))
3222
+ ) {
3223
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
3224
+ if ((internalUrl || undefined) !== this.model.baseUrl) {
3225
+ needsSwitch = true;
3226
+ }
3227
+ } else if (
3228
+ targetModel.provider === "anthropic" ||
3229
+ (targetModel.provider === "litellm" &&
3230
+ (targetModel.id.includes("anthropic") || targetModel.id.includes("claude")))
3231
+ ) {
3232
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
3233
+ if ((internalUrl || undefined) !== this.model.baseUrl) {
3234
+ needsSwitch = true;
3235
+ }
3236
+ }
3237
+ }
3238
+
3239
+ if (needsSwitch) {
3240
+ await this.setModelRoutingSwitch(targetModel);
3241
+ }
3242
+ }
3243
+ }
3244
+
3245
+ if (
3246
+ decision.applied &&
3247
+ decision.delegation &&
3248
+ decision.delegation.subtasks.length > 1 &&
3249
+ (this.settings.get("routing.delegation") as string) === "read-only"
3250
+ ) {
3251
+ let maxTasks = (this.settings.get("routing.delegationMaxTasks") as number) ?? 3;
3252
+ maxTasks = Math.min(Math.max(1, maxTasks), 3);
3253
+
3254
+ const { results, tokensUsed } = await executeReadOnlyDelegationPlan(
3255
+ decision.delegation,
3256
+ async (subtaskPrompt, signal) => {
3257
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3258
+
3259
+ const { resolveModelPool } = await import("../routing/presets");
3260
+ const pool = resolveModelPool(
3261
+ decision.poolId ?? (this.model ? `${this.model.provider}/${this.model.id}` : ""),
3262
+ this.settings.get("routing.pools") as any,
3263
+ this.settings.get("routing.disabledPresets") as readonly string[],
3264
+ this.settings.get("routing.familyPolicy") as any,
3265
+ );
3266
+
3267
+ let resolvedUtility = this.model;
3268
+ if (pool?.tiers?.utility) {
3269
+ let uId = pool.tiers.utility;
3270
+ if (!uId.includes("/") && pool.provider) {
3271
+ uId = `${pool.provider}/${uId}`;
3272
+ }
3273
+ const found = this.#modelRegistry
3274
+ .getAvailable()
3275
+ .find(m => `${m.provider}/${m.id}` === uId || m.id === uId);
3276
+ if (found) resolvedUtility = found;
3277
+ }
3278
+
3279
+ if (!resolvedUtility) return { result: "Delegation failed: Model not resolved", tokens: 0 };
3280
+
3281
+ if (
3282
+ resolvedUtility.provider === "openai" ||
3283
+ (resolvedUtility.provider === "litellm" && resolvedUtility.id.includes("openai"))
3284
+ ) {
3285
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
3286
+ if (internalUrl) resolvedUtility = { ...resolvedUtility, baseUrl: internalUrl } as any;
3287
+ } else if (
3288
+ resolvedUtility.provider === "anthropic" ||
3289
+ (resolvedUtility.provider === "litellm" &&
3290
+ (resolvedUtility.id.includes("anthropic") || resolvedUtility.id.includes("claude")))
3291
+ ) {
3292
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
3293
+ if (internalUrl) resolvedUtility = { ...resolvedUtility, baseUrl: internalUrl } as any;
3294
+ }
3295
+
3296
+ const { createAgentSession } = await import("../sdk");
3297
+ const { SessionManager: SDKSessionManager } = await import("./session-manager");
3298
+ const childSettings = await this.settings.cloneForCwd(process.cwd());
3299
+ childSettings.set("routing.delegation", "off");
3300
+
3301
+ if (options?.signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3302
+
3303
+ const { session: childSession } = await createAgentSession({
3304
+ model: resolvedUtility!,
3305
+ sessionManager: SDKSessionManager.inMemory(),
3306
+ settings: childSettings,
3307
+ authStorage: this.#modelRegistry.authStorage,
3308
+ modelRegistry: this.#modelRegistry,
3309
+ toolNames: Array.from(this.#toolRegistry.values())
3310
+ .map(t => t.name)
3311
+ .filter(isDelegationAllowedTool),
3312
+ enableLsp: false,
3313
+ enableMCP: false,
3314
+ });
3315
+
3316
+ try {
3317
+ const onAbort = () => childSession.agent.abort();
3318
+ signal?.addEventListener("abort", onAbort);
3319
+ if (signal?.aborted) {
3320
+ onAbort();
3321
+ return { result: "Delegation failed: Aborted", tokens: 0 };
3322
+ }
3323
+ try {
3324
+ await childSession.prompt(subtaskPrompt);
3325
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3326
+ const resultStr = childSession
3327
+ .buildDisplaySessionContext()
3328
+ .messages.filter((m: any) => m.role === "assistant")
3329
+ .map((m: any) =>
3330
+ m.content
3331
+ .filter((c: any) => c.type === "text")
3332
+ .map((c: any) => c.text)
3333
+ .join(""),
3334
+ )
3335
+ .join("\n");
3336
+ const tUsage = ((childSession as any).agent?.lastUsage?.totalTokens ?? 0) as number;
3337
+ return { result: resultStr || "No output", tokens: tUsage };
3338
+ } finally {
3339
+ signal?.removeEventListener("abort", onAbort);
3340
+ }
3341
+ } catch (err) {
3342
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3343
+ return { result: `Delegation failed: ${String(err)}`, tokens: 0 };
3344
+ } finally {
3345
+ await childSession.dispose();
3346
+ }
3347
+ },
3348
+ maxTasks,
3349
+ { signal: options?.signal },
3350
+ );
3351
+
3352
+ if (results.length > 0) {
3353
+ let resultsString = JSON.stringify(results, null, 2);
3354
+ if (resultsString.length > 8000) {
3355
+ resultsString = `${resultsString.substring(0, 8000)}\n...[truncated]`;
3356
+ }
3357
+ const delegationOutput = `\n\n<delegation_results>\n${resultsString}\n</delegation_results>`;
3358
+
3359
+ this.#emitSessionEvent({
3360
+ type: "routing_delegated",
3361
+ epochId: decision.epochId,
3362
+ tasks: decision.delegation.subtasks.length,
3363
+ completed: results.length,
3364
+ tokensUsed,
3365
+ } as any).catch(() => {});
3366
+
3367
+ return delegationOutput;
3368
+ }
3369
+ }
3370
+
3371
+ return undefined;
3082
3372
  }
3083
3373
 
3084
3374
  async #promptWithMessage(
@@ -3467,8 +3757,25 @@ export class AgentSession {
3467
3757
 
3468
3758
  const prependMessages = queuedMessages.slice(0, -1);
3469
3759
  const textContent = this.#getCustomMessageTextContent(message);
3760
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3761
+ textContent,
3762
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3763
+ );
3764
+ if (delegationOutput) {
3765
+ if (typeof message.content === "string") {
3766
+ message.content += delegationOutput;
3767
+ } else if (Array.isArray(message.content)) {
3768
+ const textBlock = message.content.find((c: any) => c.type === "text");
3769
+ if (textBlock) {
3770
+ (textBlock as any).text += delegationOutput;
3771
+ } else {
3772
+ message.content.push({ type: "text", text: delegationOutput });
3773
+ }
3774
+ }
3775
+ }
3776
+
3470
3777
  try {
3471
- await this.#promptWithMessage(message, textContent, {
3778
+ await this.#promptWithMessage(message, textContent + (delegationOutput || ""), {
3472
3779
  prependMessages,
3473
3780
  skipPostPromptRecoveryWait: true,
3474
3781
  });
@@ -3517,10 +3824,14 @@ export class AgentSession {
3517
3824
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
3518
3825
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
3519
3826
  ): Promise<void> {
3827
+ const clonedContent = Array.isArray(message.content)
3828
+ ? message.content.map((c: any) => ({ ...c }))
3829
+ : message.content;
3830
+
3520
3831
  const appMessage: CustomMessage<T> = {
3521
3832
  role: "custom",
3522
3833
  customType: message.customType,
3523
- content: message.content,
3834
+ content: clonedContent,
3524
3835
  display: message.display,
3525
3836
  details: message.details,
3526
3837
  attribution: message.attribution ?? "agent",
@@ -3542,7 +3853,33 @@ export class AgentSession {
3542
3853
 
3543
3854
  if (options?.deliverAs === "nextTurn") {
3544
3855
  if (options?.triggerTurn) {
3545
- await this.agent.prompt(appMessage);
3856
+ let promptText = "";
3857
+ if (typeof message.content === "string") promptText = message.content;
3858
+ else if (Array.isArray(message.content)) {
3859
+ promptText = message.content
3860
+ .filter(c => c.type === "text")
3861
+ .map((c: any) => c.text)
3862
+ .join("");
3863
+ }
3864
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3865
+ promptText,
3866
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3867
+ options,
3868
+ );
3869
+ if (delegationOutput) {
3870
+ promptText += delegationOutput;
3871
+ if (typeof appMessage.content === "string") {
3872
+ appMessage.content += delegationOutput;
3873
+ } else if (Array.isArray(appMessage.content)) {
3874
+ const textBlock = appMessage.content.find(c => c.type === "text") as any;
3875
+ if (textBlock) {
3876
+ textBlock.text += delegationOutput;
3877
+ } else {
3878
+ appMessage.content.push({ type: "text", text: delegationOutput });
3879
+ }
3880
+ }
3881
+ }
3882
+ await this.#promptWithMessage(appMessage, promptText, options as any);
3546
3883
  return;
3547
3884
  }
3548
3885
  this.agent.appendMessage(appMessage);
@@ -3557,7 +3894,33 @@ export class AgentSession {
3557
3894
  }
3558
3895
 
3559
3896
  if (options?.triggerTurn) {
3560
- await this.agent.prompt(appMessage);
3897
+ let promptText = "";
3898
+ if (typeof message.content === "string") promptText = message.content;
3899
+ else if (Array.isArray(message.content)) {
3900
+ promptText = message.content
3901
+ .filter(c => c.type === "text")
3902
+ .map((c: any) => c.text)
3903
+ .join("");
3904
+ }
3905
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3906
+ promptText,
3907
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3908
+ options,
3909
+ );
3910
+ if (delegationOutput) {
3911
+ promptText += delegationOutput;
3912
+ if (typeof appMessage.content === "string") {
3913
+ appMessage.content += delegationOutput;
3914
+ } else if (Array.isArray(appMessage.content)) {
3915
+ const textBlock = appMessage.content.find(c => c.type === "text") as any;
3916
+ if (textBlock) {
3917
+ textBlock.text += delegationOutput;
3918
+ } else {
3919
+ appMessage.content.push({ type: "text", text: delegationOutput });
3920
+ }
3921
+ }
3922
+ }
3923
+ await this.#promptWithMessage(appMessage, promptText, options as any);
3561
3924
  return;
3562
3925
  }
3563
3926
 
@@ -4002,15 +4365,28 @@ export class AgentSession {
4002
4365
  */
4003
4366
  async setModelRoutingSwitch(model: Model): Promise<void> {
4004
4367
  const previousEditMode = this.#resolveActiveEditMode();
4005
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
4368
+
4369
+ let targetModel = model;
4370
+ if (model.provider === "openai" || (model.provider === "litellm" && model.id.includes("openai"))) {
4371
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
4372
+ if (internalUrl) targetModel = { ...model, baseUrl: internalUrl };
4373
+ } else if (
4374
+ model.provider === "anthropic" ||
4375
+ (model.provider === "litellm" && (model.id.includes("anthropic") || model.id.includes("claude")))
4376
+ ) {
4377
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
4378
+ if (internalUrl) targetModel = { ...model, baseUrl: internalUrl };
4379
+ }
4380
+
4381
+ const apiKey = await this.#modelRegistry.getApiKey(targetModel, this.sessionId);
4006
4382
  if (!apiKey) {
4007
- throw new Error(`No API key for ${model.provider}/${model.id}`);
4383
+ throw new Error(`No API key for ${targetModel.provider}/${targetModel.id}`);
4008
4384
  }
4009
4385
 
4010
4386
  // DO NOT clear active retry fallback - routing is a transient optimization
4011
- this.#setModelWithProviderSessionReset(model, "runtime-switch");
4012
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "routing_switch");
4013
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
4387
+ this.#setModelWithProviderSessionReset(targetModel, "runtime-switch");
4388
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, "routing_switch");
4389
+ this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
4014
4390
 
4015
4391
  this.setThinkingLevel(this.thinkingLevel);
4016
4392
  await this.#syncEditToolModeAfterModelChange(previousEditMode);