@f5-sales-demo/xcsh 20.8.6 → 20.10.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.10.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.10.0",
66
+ "@f5-sales-demo/pi-agent-core": "20.10.0",
67
+ "@f5-sales-demo/pi-ai": "20.10.0",
68
+ "@f5-sales-demo/pi-natives": "20.10.0",
69
+ "@f5-sales-demo/pi-resource-management": "20.10.0",
70
+ "@f5-sales-demo/pi-tui": "20.10.0",
71
+ "@f5-sales-demo/pi-utils": "20.10.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.10.0",
21
+ "commit": "f6a647696fb10fb3e6ecccb35c753821f015741e",
22
+ "shortCommit": "f6a6476",
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.10.0",
25
+ "commitDate": "2026-08-10T19:10:53Z",
26
+ "buildDate": "2026-08-10T19:39:51.013Z",
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/f6a647696fb10fb3e6ecccb35c753821f015741e",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.10.0"
33
33
  };
@@ -147,21 +147,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
147
147
  "workload_flavor",
148
148
  ],
149
149
  },
150
- {
151
- name: "DNS",
152
- slug: "dns",
153
- description: "DNS domains, zones, compliance checks, and DNS proxy configuration",
154
- resource_count: 7,
155
- resources: [
156
- "dns_compliance_checks",
157
- "dns_domain",
158
- "dns_lb_health_check",
159
- "dns_lb_pool",
160
- "dns_load_balancer",
161
- "dns_proxy",
162
- "dns_zone",
163
- ],
164
- },
165
150
  {
166
151
  name: "Uncategorized",
167
152
  slug: "uncategorized",
@@ -177,6 +162,20 @@ export const TERRAFORM_INDEX: TerraformIndex = {
177
162
  "registration_approval",
178
163
  ],
179
164
  },
165
+ {
166
+ name: "DNS",
167
+ slug: "dns",
168
+ description: "DNS domains, zones, compliance checks, and DNS proxy configuration",
169
+ resource_count: 6,
170
+ resources: [
171
+ "dns_compliance_checks",
172
+ "dns_lb_health_check",
173
+ "dns_lb_pool",
174
+ "dns_load_balancer",
175
+ "dns_proxy",
176
+ "dns_zone",
177
+ ],
178
+ },
180
179
  {
181
180
  name: "API Security",
182
181
  slug: "api-security",
@@ -1523,201 +1522,187 @@ export const TERRAFORM_INDEX: TerraformIndex = {
1523
1522
  import_syntax: "terraform import xcsh_workload_flavor.example namespace/name",
1524
1523
  },
1525
1524
  {
1526
- name: "dns_compliance_checks",
1527
- category: "dns",
1528
- description:
1529
- "DNS Compliance Checks Specification in a given namespace. If one already exists it will give an error",
1525
+ name: "application_profiles",
1526
+ category: "uncategorized",
1527
+ description: "Application Profiles in a given namespace. If one already exists it will give an error",
1530
1528
  required: ["name", "namespace"],
1531
1529
  minimal_config: {
1532
1530
  format: "terraform",
1533
- source: "_llms-txt/resources/dns_compliance_checks.txt#minimal-valid-config",
1534
- },
1535
- dependencies: {
1536
- requires: [],
1537
- },
1538
- import_syntax: "terraform import xcsh_dns_compliance_checks.example namespace/name",
1539
- },
1540
- {
1541
- name: "dns_domain",
1542
- category: "dns",
1543
- description: "DNS Domain in a given namespace. If one already exist it will give a error",
1544
- required: ["name", "dnssec_mode"],
1545
- minimal_config: {
1546
- format: "terraform",
1547
- source: "_llms-txt/resources/dns_domain.txt#minimal-valid-config",
1531
+ source: "_llms-txt/resources/application_profiles.txt#minimal-valid-config",
1548
1532
  },
1549
1533
  dependencies: {
1550
1534
  requires: [],
1551
1535
  },
1552
- import_syntax: "terraform import xcsh_dns_domain.example namespace/name",
1536
+ import_syntax: "terraform import xcsh_application_profiles.example namespace/name",
1553
1537
  },
1554
1538
  {
1555
- name: "dns_lb_health_check",
1556
- category: "dns",
1557
- description: "DNS Load Balancer Health Check in a given namespace. If one already exist it will give a error",
1558
- required: ["name"],
1539
+ name: "authorization_server",
1540
+ category: "uncategorized",
1541
+ description: "Authorization_server creates a new object in the storage backend for metadata.namespace",
1542
+ required: ["name", "namespace", "jwks_uri"],
1559
1543
  minimal_config: {
1560
1544
  format: "terraform",
1561
- source: "_llms-txt/resources/dns_lb_health_check.txt#minimal-valid-config",
1545
+ source: "_llms-txt/resources/authorization_server.txt#minimal-valid-config",
1562
1546
  },
1563
1547
  dependencies: {
1564
1548
  requires: [],
1565
1549
  },
1566
- import_syntax: "terraform import xcsh_dns_lb_health_check.example namespace/name",
1550
+ import_syntax: "terraform import xcsh_authorization_server.example namespace/name",
1567
1551
  },
1568
1552
  {
1569
- name: "dns_lb_pool",
1570
- category: "dns",
1571
- description: "DNS Load Balancer Pool in a given namespace. If one already exist it will give a error",
1572
- required: ["name", "load_balancing_mode"],
1553
+ name: "bot_infrastructure",
1554
+ category: "uncategorized",
1555
+ description: "Bot Infrastructure",
1556
+ required: ["name", "namespace", "traffic_type"],
1573
1557
  minimal_config: {
1574
1558
  format: "terraform",
1575
- source: "_llms-txt/resources/dns_lb_pool.txt#minimal-valid-config",
1559
+ source: "_llms-txt/resources/bot_infrastructure.txt#minimal-valid-config",
1576
1560
  },
1577
1561
  dependencies: {
1578
1562
  requires: [],
1579
1563
  },
1580
- import_syntax: "terraform import xcsh_dns_lb_pool.example namespace/name",
1564
+ import_syntax: "terraform import xcsh_bot_infrastructure.example namespace/name",
1581
1565
  },
1582
1566
  {
1583
- name: "dns_load_balancer",
1584
- category: "dns",
1585
- description: "DNS Load Balancer in a given namespace. If one already exist it will give a error",
1586
- required: ["name"],
1567
+ name: "mitigated_domain",
1568
+ category: "uncategorized",
1569
+ description: "Mitigated Domain",
1570
+ required: ["name", "namespace", "mitigated_domain"],
1587
1571
  minimal_config: {
1588
1572
  format: "terraform",
1589
- source: "_llms-txt/resources/dns_load_balancer.txt#minimal-valid-config",
1573
+ source: "_llms-txt/resources/mitigated_domain.txt#minimal-valid-config",
1590
1574
  },
1591
1575
  dependencies: {
1592
1576
  requires: [],
1593
1577
  },
1594
- import_syntax: "terraform import xcsh_dns_load_balancer.example namespace/name",
1578
+ import_syntax: "terraform import xcsh_mitigated_domain.example namespace/name",
1595
1579
  },
1596
1580
  {
1597
- name: "dns_proxy",
1598
- category: "dns",
1599
- description: "DNS Proxy in a given namespace. If one already exists it will give an error",
1600
- required: ["name", "transport_type"],
1581
+ name: "protected_application",
1582
+ category: "uncategorized",
1583
+ description: "Applications protected by Bot Defense",
1584
+ required: ["name", "namespace", "region"],
1601
1585
  minimal_config: {
1602
1586
  format: "terraform",
1603
- source: "_llms-txt/resources/dns_proxy.txt#minimal-valid-config",
1587
+ source: "_llms-txt/resources/protected_application.txt#minimal-valid-config",
1604
1588
  },
1605
1589
  dependencies: {
1606
1590
  requires: [],
1607
1591
  },
1608
- import_syntax: "terraform import xcsh_dns_proxy.example namespace/name",
1592
+ import_syntax: "terraform import xcsh_protected_application.example namespace/name",
1609
1593
  },
1610
1594
  {
1611
- name: "dns_zone",
1612
- category: "dns",
1613
- description: "DNS Zone in a given namespace. If one already exist it will give a error",
1614
- required: ["name"],
1595
+ name: "protected_domain",
1596
+ category: "uncategorized",
1597
+ description: "Domain to protect",
1598
+ required: ["name", "namespace", "protected_domain"],
1615
1599
  minimal_config: {
1616
1600
  format: "terraform",
1617
- source: "_llms-txt/resources/dns_zone.txt#minimal-valid-config",
1601
+ source: "_llms-txt/resources/protected_domain.txt#minimal-valid-config",
1618
1602
  },
1619
1603
  dependencies: {
1620
1604
  requires: [],
1621
1605
  },
1622
- import_syntax: "terraform import xcsh_dns_zone.example namespace/name",
1606
+ import_syntax: "terraform import xcsh_protected_domain.example namespace/name",
1623
1607
  },
1624
1608
  {
1625
- name: "application_profiles",
1609
+ name: "registration_approval",
1626
1610
  category: "uncategorized",
1627
- description: "Application Profiles in a given namespace. If one already exists it will give an error",
1611
+ description: "Request for admission approval. configuration",
1628
1612
  required: ["name", "namespace"],
1629
1613
  minimal_config: {
1630
1614
  format: "terraform",
1631
- source: "_llms-txt/resources/application_profiles.txt#minimal-valid-config",
1615
+ source: "_llms-txt/resources/registration_approval.txt#minimal-valid-config",
1632
1616
  },
1633
1617
  dependencies: {
1634
1618
  requires: [],
1635
1619
  },
1636
- import_syntax: "terraform import xcsh_application_profiles.example namespace/name",
1620
+ import_syntax: "terraform import xcsh_registration_approval.example namespace/name",
1637
1621
  },
1638
1622
  {
1639
- name: "authorization_server",
1640
- category: "uncategorized",
1641
- description: "Authorization_server creates a new object in the storage backend for metadata.namespace",
1642
- required: ["name", "namespace", "jwks_uri"],
1623
+ name: "dns_compliance_checks",
1624
+ category: "dns",
1625
+ description:
1626
+ "DNS Compliance Checks Specification in a given namespace. If one already exists it will give an error",
1627
+ required: ["name", "namespace"],
1643
1628
  minimal_config: {
1644
1629
  format: "terraform",
1645
- source: "_llms-txt/resources/authorization_server.txt#minimal-valid-config",
1630
+ source: "_llms-txt/resources/dns_compliance_checks.txt#minimal-valid-config",
1646
1631
  },
1647
1632
  dependencies: {
1648
1633
  requires: [],
1649
1634
  },
1650
- import_syntax: "terraform import xcsh_authorization_server.example namespace/name",
1635
+ import_syntax: "terraform import xcsh_dns_compliance_checks.example namespace/name",
1651
1636
  },
1652
1637
  {
1653
- name: "bot_infrastructure",
1654
- category: "uncategorized",
1655
- description: "Bot Infrastructure",
1656
- required: ["name", "namespace", "traffic_type"],
1638
+ name: "dns_lb_health_check",
1639
+ category: "dns",
1640
+ description: "DNS Load Balancer Health Check in a given namespace. If one already exist it will give a error",
1641
+ required: ["name"],
1657
1642
  minimal_config: {
1658
1643
  format: "terraform",
1659
- source: "_llms-txt/resources/bot_infrastructure.txt#minimal-valid-config",
1644
+ source: "_llms-txt/resources/dns_lb_health_check.txt#minimal-valid-config",
1660
1645
  },
1661
1646
  dependencies: {
1662
1647
  requires: [],
1663
1648
  },
1664
- import_syntax: "terraform import xcsh_bot_infrastructure.example namespace/name",
1649
+ import_syntax: "terraform import xcsh_dns_lb_health_check.example namespace/name",
1665
1650
  },
1666
1651
  {
1667
- name: "mitigated_domain",
1668
- category: "uncategorized",
1669
- description: "Mitigated Domain",
1670
- required: ["name", "namespace", "mitigated_domain"],
1652
+ name: "dns_lb_pool",
1653
+ category: "dns",
1654
+ description: "DNS Load Balancer Pool in a given namespace. If one already exist it will give a error",
1655
+ required: ["name", "load_balancing_mode"],
1671
1656
  minimal_config: {
1672
1657
  format: "terraform",
1673
- source: "_llms-txt/resources/mitigated_domain.txt#minimal-valid-config",
1658
+ source: "_llms-txt/resources/dns_lb_pool.txt#minimal-valid-config",
1674
1659
  },
1675
1660
  dependencies: {
1676
1661
  requires: [],
1677
1662
  },
1678
- import_syntax: "terraform import xcsh_mitigated_domain.example namespace/name",
1663
+ import_syntax: "terraform import xcsh_dns_lb_pool.example namespace/name",
1679
1664
  },
1680
1665
  {
1681
- name: "protected_application",
1682
- category: "uncategorized",
1683
- description: "Applications protected by Bot Defense",
1684
- required: ["name", "namespace", "region"],
1666
+ name: "dns_load_balancer",
1667
+ category: "dns",
1668
+ description: "DNS Load Balancer in a given namespace. If one already exist it will give a error",
1669
+ required: ["name"],
1685
1670
  minimal_config: {
1686
1671
  format: "terraform",
1687
- source: "_llms-txt/resources/protected_application.txt#minimal-valid-config",
1672
+ source: "_llms-txt/resources/dns_load_balancer.txt#minimal-valid-config",
1688
1673
  },
1689
1674
  dependencies: {
1690
1675
  requires: [],
1691
1676
  },
1692
- import_syntax: "terraform import xcsh_protected_application.example namespace/name",
1677
+ import_syntax: "terraform import xcsh_dns_load_balancer.example namespace/name",
1693
1678
  },
1694
1679
  {
1695
- name: "protected_domain",
1696
- category: "uncategorized",
1697
- description: "Domain to protect",
1698
- required: ["name", "namespace", "protected_domain"],
1680
+ name: "dns_proxy",
1681
+ category: "dns",
1682
+ description: "DNS Proxy in a given namespace. If one already exists it will give an error",
1683
+ required: ["name", "transport_type"],
1699
1684
  minimal_config: {
1700
1685
  format: "terraform",
1701
- source: "_llms-txt/resources/protected_domain.txt#minimal-valid-config",
1686
+ source: "_llms-txt/resources/dns_proxy.txt#minimal-valid-config",
1702
1687
  },
1703
1688
  dependencies: {
1704
1689
  requires: [],
1705
1690
  },
1706
- import_syntax: "terraform import xcsh_protected_domain.example namespace/name",
1691
+ import_syntax: "terraform import xcsh_dns_proxy.example namespace/name",
1707
1692
  },
1708
1693
  {
1709
- name: "registration_approval",
1710
- category: "uncategorized",
1711
- description: "Request for admission approval. configuration",
1712
- required: ["name", "namespace"],
1694
+ name: "dns_zone",
1695
+ category: "dns",
1696
+ description: "DNS Zone in a given namespace. If one already exist it will give a error",
1697
+ required: ["name"],
1713
1698
  minimal_config: {
1714
1699
  format: "terraform",
1715
- source: "_llms-txt/resources/registration_approval.txt#minimal-valid-config",
1700
+ source: "_llms-txt/resources/dns_zone.txt#minimal-valid-config",
1716
1701
  },
1717
1702
  dependencies: {
1718
1703
  requires: [],
1719
1704
  },
1720
- import_syntax: "terraform import xcsh_registration_approval.example namespace/name",
1705
+ import_syntax: "terraform import xcsh_dns_zone.example namespace/name",
1721
1706
  },
1722
1707
  {
1723
1708
  name: "api_crawler",
@@ -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)) {