@genesislcap/ai-assistant 15.19.0 → 15.19.1

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.
Files changed (288) hide show
  1. package/package.json +17 -18
  2. package/api-extractor.json +0 -11
  3. package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts +0 -2
  4. package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts.map +0 -1
  5. package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts +0 -2
  6. package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts.map +0 -1
  7. package/dist/dts/components/chat-driver/chat-driver.test.d.ts +0 -2
  8. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +0 -1
  9. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +0 -2
  10. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +0 -1
  11. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +0 -2
  12. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +0 -1
  13. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +0 -2
  14. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +0 -1
  15. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts +0 -2
  16. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts.map +0 -1
  17. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +0 -2
  18. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +0 -1
  19. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +0 -2
  20. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +0 -1
  21. package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts +0 -2
  22. package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts.map +0 -1
  23. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +0 -2
  24. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +0 -1
  25. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +0 -2
  26. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +0 -1
  27. package/dist/dts/config/define-stateful-agent.test.d.ts +0 -2
  28. package/dist/dts/config/define-stateful-agent.test.d.ts.map +0 -1
  29. package/dist/dts/config/validate-providers.test.d.ts +0 -2
  30. package/dist/dts/config/validate-providers.test.d.ts.map +0 -1
  31. package/dist/dts/main/blocked-state.test.d.ts +0 -2
  32. package/dist/dts/main/blocked-state.test.d.ts.map +0 -1
  33. package/dist/dts/main/budget-meter.test.d.ts +0 -2
  34. package/dist/dts/main/budget-meter.test.d.ts.map +0 -1
  35. package/dist/dts/main/cost-session-banking.test.d.ts +0 -2
  36. package/dist/dts/main/cost-session-banking.test.d.ts.map +0 -1
  37. package/dist/dts/main/file-attachments.test.d.ts +0 -2
  38. package/dist/dts/main/file-attachments.test.d.ts.map +0 -1
  39. package/dist/dts/main/interaction-cost.test.d.ts +0 -2
  40. package/dist/dts/main/interaction-cost.test.d.ts.map +0 -1
  41. package/dist/dts/main/main.styles.test.d.ts +0 -2
  42. package/dist/dts/main/main.styles.test.d.ts.map +0 -1
  43. package/dist/dts/main/persistence-broken-sources.test.d.ts +0 -2
  44. package/dist/dts/main/persistence-broken-sources.test.d.ts.map +0 -1
  45. package/dist/dts/main/popout-interaction-gate.test.d.ts +0 -2
  46. package/dist/dts/main/popout-interaction-gate.test.d.ts.map +0 -1
  47. package/dist/dts/state/ai-assistant-slice.test.d.ts +0 -2
  48. package/dist/dts/state/ai-assistant-slice.test.d.ts.map +0 -1
  49. package/dist/dts/state/debug-event-log.test.d.ts +0 -2
  50. package/dist/dts/state/debug-event-log.test.d.ts.map +0 -1
  51. package/dist/dts/state/driver-registry.test.d.ts +0 -2
  52. package/dist/dts/state/driver-registry.test.d.ts.map +0 -1
  53. package/dist/dts/state/interaction-context.test.d.ts +0 -2
  54. package/dist/dts/state/interaction-context.test.d.ts.map +0 -1
  55. package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts +0 -2
  56. package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts.map +0 -1
  57. package/dist/dts/state/persistence/diagnostics.test.d.ts +0 -2
  58. package/dist/dts/state/persistence/diagnostics.test.d.ts.map +0 -1
  59. package/dist/dts/state/persistence/session-persistence-provider.test.d.ts +0 -2
  60. package/dist/dts/state/persistence/session-persistence-provider.test.d.ts.map +0 -1
  61. package/dist/dts/state/persistence/session-persistence.integration.test.d.ts +0 -2
  62. package/dist/dts/state/persistence/session-persistence.integration.test.d.ts.map +0 -1
  63. package/dist/dts/state/persistence/session-persister.test.d.ts +0 -2
  64. package/dist/dts/state/persistence/session-persister.test.d.ts.map +0 -1
  65. package/dist/dts/state/persistence/session-snapshot.test.d.ts +0 -2
  66. package/dist/dts/state/persistence/session-snapshot.test.d.ts.map +0 -1
  67. package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts +0 -2
  68. package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts.map +0 -1
  69. package/dist/dts/utils/animation-exclusivity.test.d.ts +0 -2
  70. package/dist/dts/utils/animation-exclusivity.test.d.ts.map +0 -1
  71. package/dist/dts/utils/collect-session-models.test.d.ts +0 -2
  72. package/dist/dts/utils/collect-session-models.test.d.ts.map +0 -1
  73. package/dist/dts/utils/condense-history.test.d.ts +0 -2
  74. package/dist/dts/utils/condense-history.test.d.ts.map +0 -1
  75. package/dist/dts/utils/cost-session-history.test.d.ts +0 -2
  76. package/dist/dts/utils/cost-session-history.test.d.ts.map +0 -1
  77. package/dist/dts/utils/derive-cost-session-title.test.d.ts +0 -2
  78. package/dist/dts/utils/derive-cost-session-title.test.d.ts.map +0 -1
  79. package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts +0 -2
  80. package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts.map +0 -1
  81. package/dist/dts/utils/history-transform.test.d.ts +0 -2
  82. package/dist/dts/utils/history-transform.test.d.ts.map +0 -1
  83. package/dist/dts/utils/message-partition.test.d.ts +0 -2
  84. package/dist/dts/utils/message-partition.test.d.ts.map +0 -1
  85. package/dist/dts/utils/resolve-cost-history-config.test.d.ts +0 -2
  86. package/dist/dts/utils/resolve-cost-history-config.test.d.ts.map +0 -1
  87. package/dist/dts/utils/resolve-preference-baseline.test.d.ts +0 -2
  88. package/dist/dts/utils/resolve-preference-baseline.test.d.ts.map +0 -1
  89. package/dist/dts/utils/strip-agent-handlers.test.d.ts +0 -2
  90. package/dist/dts/utils/strip-agent-handlers.test.d.ts.map +0 -1
  91. package/dist/dts/utils/sum-costs.test.d.ts +0 -2
  92. package/dist/dts/utils/sum-costs.test.d.ts.map +0 -1
  93. package/dist/dts/utils/sum-tokens.test.d.ts +0 -2
  94. package/dist/dts/utils/sum-tokens.test.d.ts.map +0 -1
  95. package/dist/dts/utils/sum-usage.test.d.ts +0 -2
  96. package/dist/dts/utils/sum-usage.test.d.ts.map +0 -1
  97. package/dist/dts/utils/usage-rows.test.d.ts +0 -2
  98. package/dist/dts/utils/usage-rows.test.d.ts.map +0 -1
  99. package/dist/esm/components/chat-driver/chat-driver.compact.test.js +0 -93
  100. package/dist/esm/components/chat-driver/chat-driver.invocation-scope.test.js +0 -238
  101. package/dist/esm/components/chat-driver/chat-driver.test.js +0 -2770
  102. package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +0 -137
  103. package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +0 -200
  104. package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +0 -268
  105. package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +0 -104
  106. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +0 -312
  107. package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +0 -76
  108. package/dist/esm/components/orchestrating-driver/orchestrating-driver.pin.test.js +0 -61
  109. package/dist/esm/components/settings-modal/settings-modal.styles.test.js +0 -80
  110. package/dist/esm/components/settings-modal/settings-modal.template.test.js +0 -91
  111. package/dist/esm/config/define-stateful-agent.test.js +0 -53
  112. package/dist/esm/config/validate-providers.test.js +0 -100
  113. package/dist/esm/main/blocked-state.test.js +0 -1065
  114. package/dist/esm/main/budget-meter.test.js +0 -359
  115. package/dist/esm/main/cost-session-banking.test.js +0 -538
  116. package/dist/esm/main/file-attachments.test.js +0 -164
  117. package/dist/esm/main/interaction-cost.test.js +0 -107
  118. package/dist/esm/main/main.styles.test.js +0 -161
  119. package/dist/esm/main/persistence-broken-sources.test.js +0 -180
  120. package/dist/esm/main/popout-interaction-gate.test.js +0 -54
  121. package/dist/esm/state/ai-assistant-slice.test.js +0 -229
  122. package/dist/esm/state/debug-event-log.test.js +0 -117
  123. package/dist/esm/state/driver-registry.test.js +0 -55
  124. package/dist/esm/state/interaction-context.test.js +0 -78
  125. package/dist/esm/state/persistence/diagnostics-cursors.test.js +0 -30
  126. package/dist/esm/state/persistence/diagnostics.test.js +0 -280
  127. package/dist/esm/state/persistence/session-persistence-provider.test.js +0 -110
  128. package/dist/esm/state/persistence/session-persistence.integration.test.js +0 -113
  129. package/dist/esm/state/persistence/session-persister.test.js +0 -613
  130. package/dist/esm/state/persistence/session-snapshot.test.js +0 -256
  131. package/dist/esm/state/persistence/stateful-restore.e2e.test.js +0 -158
  132. package/dist/esm/utils/animation-exclusivity.test.js +0 -52
  133. package/dist/esm/utils/collect-session-models.test.js +0 -53
  134. package/dist/esm/utils/condense-history.test.js +0 -547
  135. package/dist/esm/utils/cost-session-history.test.js +0 -174
  136. package/dist/esm/utils/derive-cost-session-title.test.js +0 -37
  137. package/dist/esm/utils/flatten-sub-agent-messages.test.js +0 -139
  138. package/dist/esm/utils/history-transform.test.js +0 -158
  139. package/dist/esm/utils/message-partition.test.js +0 -90
  140. package/dist/esm/utils/resolve-cost-history-config.test.js +0 -32
  141. package/dist/esm/utils/resolve-preference-baseline.test.js +0 -69
  142. package/dist/esm/utils/strip-agent-handlers.test.js +0 -81
  143. package/dist/esm/utils/sum-costs.test.js +0 -114
  144. package/dist/esm/utils/sum-tokens.test.js +0 -51
  145. package/dist/esm/utils/sum-usage.test.js +0 -164
  146. package/dist/esm/utils/usage-rows.test.js +0 -189
  147. package/dist/tsconfig.tsbuildinfo +0 -1
  148. package/license.txt +0 -46
  149. package/sandbox/tsconfig.json +0 -8
  150. package/src/channel/ai-activity-bus.ts +0 -209
  151. package/src/channel/ai-activity-channel.ts +0 -146
  152. package/src/chat-driver-node.ts +0 -159
  153. package/src/components/activity-halo/activity-halo.ts +0 -106
  154. package/src/components/agent-picker/agent-picker.constants.ts +0 -2
  155. package/src/components/agent-picker/agent-picker.styles.ts +0 -24
  156. package/src/components/agent-picker/agent-picker.template.ts +0 -54
  157. package/src/components/agent-picker/agent-picker.ts +0 -99
  158. package/src/components/agent-picker/index.ts +0 -1
  159. package/src/components/ai-driver/ai-driver.ts +0 -166
  160. package/src/components/ai-driver/index.ts +0 -1
  161. package/src/components/chat-bubble/chat-bubble.styles.ts +0 -194
  162. package/src/components/chat-bubble/chat-bubble.template.ts +0 -67
  163. package/src/components/chat-bubble/chat-bubble.ts +0 -436
  164. package/src/components/chat-bubble/index.ts +0 -1
  165. package/src/components/chat-driver/align-event-globals.ts +0 -23
  166. package/src/components/chat-driver/chat-driver.compact.test.ts +0 -131
  167. package/src/components/chat-driver/chat-driver.invocation-scope.test.ts +0 -285
  168. package/src/components/chat-driver/chat-driver.test.ts +0 -3679
  169. package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +0 -185
  170. package/src/components/chat-driver/chat-driver.trace-capture.test.ts +0 -251
  171. package/src/components/chat-driver/chat-driver.ts +0 -3925
  172. package/src/components/chat-driver/chat-driver.turn-usage.test.ts +0 -362
  173. package/src/components/chat-driver/index.ts +0 -1
  174. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts +0 -7
  175. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts +0 -6
  176. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +0 -144
  177. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +0 -243
  178. package/src/components/chat-interaction-wrapper/index.ts +0 -3
  179. package/src/components/chat-markdown/chat-markdown.ts +0 -89
  180. package/src/components/chat-markdown/index.ts +0 -1
  181. package/src/components/flowing-waves-indicator.ts +0 -286
  182. package/src/components/halo-overlay.ts +0 -184
  183. package/src/components/orchestrating-driver/index.ts +0 -1
  184. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +0 -438
  185. package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +0 -107
  186. package/src/components/orchestrating-driver/orchestrating-driver.pin.test.ts +0 -96
  187. package/src/components/orchestrating-driver/orchestrating-driver.ts +0 -813
  188. package/src/components/plasma-orb-indicator.ts +0 -281
  189. package/src/components/popout-manager/index.ts +0 -1
  190. package/src/components/popout-manager/popout-manager.ts +0 -212
  191. package/src/components/settings-modal/index.ts +0 -2
  192. package/src/components/settings-modal/settings-modal.styles.test.ts +0 -94
  193. package/src/components/settings-modal/settings-modal.styles.ts +0 -853
  194. package/src/components/settings-modal/settings-modal.template.test.ts +0 -124
  195. package/src/components/settings-modal/settings-modal.template.ts +0 -943
  196. package/src/components/waves-indicator.ts +0 -241
  197. package/src/config/config.ts +0 -608
  198. package/src/config/define-stateful-agent.test.ts +0 -64
  199. package/src/config/define-stateful-agent.ts +0 -642
  200. package/src/config/fallback-agents.ts +0 -29
  201. package/src/config/index.ts +0 -2
  202. package/src/config/validate-providers.test.ts +0 -148
  203. package/src/config/validate-providers.ts +0 -58
  204. package/src/index.ts +0 -29
  205. package/src/main/blocked-state.test.ts +0 -1444
  206. package/src/main/budget-meter.test.ts +0 -477
  207. package/src/main/cost-session-banking.test.ts +0 -679
  208. package/src/main/file-attachments.test.ts +0 -215
  209. package/src/main/index.ts +0 -3
  210. package/src/main/interaction-cost.test.ts +0 -140
  211. package/src/main/main.styles.test.ts +0 -216
  212. package/src/main/main.styles.ts +0 -995
  213. package/src/main/main.template.ts +0 -1119
  214. package/src/main/main.ts +0 -5432
  215. package/src/main/main.types.ts +0 -225
  216. package/src/main/persistence-broken-sources.test.ts +0 -219
  217. package/src/main/popout-interaction-gate.test.ts +0 -87
  218. package/src/provider/ai-provider-switcher.ts +0 -40
  219. package/src/provider/assistant-app-settings.ts +0 -84
  220. package/src/state/ai-assistant-slice.test.ts +0 -336
  221. package/src/state/ai-assistant-slice.ts +0 -604
  222. package/src/state/debug-event-log.test.ts +0 -154
  223. package/src/state/debug-event-log.ts +0 -358
  224. package/src/state/driver-registry.test.ts +0 -70
  225. package/src/state/driver-registry.ts +0 -79
  226. package/src/state/interaction-context.test.ts +0 -90
  227. package/src/state/interaction-context.ts +0 -101
  228. package/src/state/persistence/build-timeline-entries.ts +0 -66
  229. package/src/state/persistence/diagnostics-cursors.test.ts +0 -53
  230. package/src/state/persistence/diagnostics-cursors.ts +0 -80
  231. package/src/state/persistence/diagnostics.test.ts +0 -346
  232. package/src/state/persistence/diagnostics.ts +0 -220
  233. package/src/state/persistence/index.ts +0 -10
  234. package/src/state/persistence/persister-registry.ts +0 -55
  235. package/src/state/persistence/session-persistence-provider.test.ts +0 -136
  236. package/src/state/persistence/session-persistence-provider.ts +0 -226
  237. package/src/state/persistence/session-persistence.integration.test.ts +0 -141
  238. package/src/state/persistence/session-persister.test.ts +0 -787
  239. package/src/state/persistence/session-persister.ts +0 -917
  240. package/src/state/persistence/session-snapshot.test.ts +0 -352
  241. package/src/state/persistence/session-snapshot.ts +0 -344
  242. package/src/state/persistence/stateful-restore.e2e.test.ts +0 -229
  243. package/src/state/session-store.ts +0 -56
  244. package/src/styles/ai-colours.ts +0 -30
  245. package/src/styles/menu.ts +0 -187
  246. package/src/styles/settings-section.ts +0 -40
  247. package/src/suggestions/chat-suggestions.ts +0 -186
  248. package/src/tags/index.ts +0 -1
  249. package/src/types/ai-chat-widget.ts +0 -67
  250. package/src/types/interaction-context.ts +0 -82
  251. package/src/utils/animated-panel-toggle.ts +0 -62
  252. package/src/utils/animation-exclusivity.test.ts +0 -72
  253. package/src/utils/animation-exclusivity.ts +0 -40
  254. package/src/utils/banked-usage-baselines.ts +0 -90
  255. package/src/utils/collect-session-models.test.ts +0 -82
  256. package/src/utils/collect-session-models.ts +0 -65
  257. package/src/utils/condense-history.test.ts +0 -746
  258. package/src/utils/condense-history.ts +0 -448
  259. package/src/utils/cost-session-history.test.ts +0 -226
  260. package/src/utils/cost-session-history.ts +0 -218
  261. package/src/utils/derive-cost-session-title.test.ts +0 -63
  262. package/src/utils/derive-cost-session-title.ts +0 -76
  263. package/src/utils/flatten-sub-agent-messages.test.ts +0 -163
  264. package/src/utils/flatten-sub-agent-messages.ts +0 -88
  265. package/src/utils/format-usd.ts +0 -26
  266. package/src/utils/history-transform.test.ts +0 -228
  267. package/src/utils/history-transform.ts +0 -163
  268. package/src/utils/index.ts +0 -3
  269. package/src/utils/logger.ts +0 -3
  270. package/src/utils/message-partition.test.ts +0 -129
  271. package/src/utils/message-partition.ts +0 -115
  272. package/src/utils/resolve-cost-history-config.test.ts +0 -38
  273. package/src/utils/resolve-cost-history-config.ts +0 -38
  274. package/src/utils/resolve-preference-baseline.test.ts +0 -84
  275. package/src/utils/resolve-preference-baseline.ts +0 -80
  276. package/src/utils/strip-agent-handlers.test.ts +0 -99
  277. package/src/utils/strip-agent-handlers.ts +0 -53
  278. package/src/utils/sum-costs.test.ts +0 -136
  279. package/src/utils/sum-costs.ts +0 -18
  280. package/src/utils/sum-tokens.test.ts +0 -93
  281. package/src/utils/sum-tokens.ts +0 -68
  282. package/src/utils/sum-usage.test.ts +0 -191
  283. package/src/utils/sum-usage.ts +0 -208
  284. package/src/utils/tool-fold.ts +0 -181
  285. package/src/utils/usage-rows.test.ts +0 -237
  286. package/src/utils/usage-rows.ts +0 -187
  287. package/src/utils/with-timeout.ts +0 -24
  288. package/tsconfig.json +0 -9
@@ -1,813 +0,0 @@
1
- import type {
2
- AIProviderRegistry,
3
- ChatAttachment,
4
- ChatDriverResult,
5
- ChatMessage,
6
- ChatRequestOptions,
7
- } from '@genesislcap/foundation-ai';
8
- import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
9
- import type { ActivityBus } from '../../channel/ai-activity-bus';
10
- import type {
11
- AgentConfig,
12
- FallbackAgentConfig,
13
- SpecialistAgentConfig,
14
- SystemPromptContext,
15
- SystemPromptInput,
16
- } from '../../config/config';
17
- import { validateStaticAgentProviders } from '../../config/validate-providers';
18
- import { recordMetaEvent } from '../../state/debug-event-log';
19
- import type { DiagnosticEntry } from '../../state/persistence/diagnostics';
20
- import type { InteractionContext } from '../../types/interaction-context';
21
- import { transformHistoryForAgent } from '../../utils/history-transform';
22
- import { logger } from '../../utils/logger';
23
- import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
24
- import {
25
- ChatDriver,
26
- REQUEST_CONTINUATION_TOOL,
27
- type TurnSnapshot,
28
- } from '../chat-driver/chat-driver';
29
-
30
- const DEFAULT_MAX_HANDOFFS = 3;
31
- const DEFAULT_CLASSIFIER_HISTORY_LENGTH = 4;
32
- const DEFAULT_CLASSIFIER_RETRIES = 2;
33
-
34
- const REQUEST_CONTINUATION_DEFINITION = {
35
- name: REQUEST_CONTINUATION_TOOL,
36
- description:
37
- "Call this when you have completed your part of the task but fulfilling the user's full request requires capabilities outside your domain. Do not call this if you can complete the request yourself. Pass a plain-language description of what still needs to be done — another specialist will be selected automatically.",
38
- parameters: {
39
- type: 'object',
40
- required: ['summary', 'remaining_task'],
41
- properties: {
42
- summary: {
43
- type: 'string',
44
- description: 'What you found or did — passed as context to the next specialist.',
45
- },
46
- remaining_task: {
47
- type: 'string',
48
- description:
49
- 'What still needs to be done, in plain language. Used to route to the right specialist.',
50
- },
51
- },
52
- },
53
- };
54
-
55
- function isSpecialist(agent: AgentConfig): agent is SpecialistAgentConfig {
56
- return !agent.fallback;
57
- }
58
-
59
- function isFallback(agent: AgentConfig): agent is FallbackAgentConfig {
60
- return agent.fallback === true;
61
- }
62
-
63
- function buildFallbackSystemPrompt(
64
- fallback: FallbackAgentConfig,
65
- specialists: SpecialistAgentConfig[],
66
- ): SystemPromptInput | undefined {
67
- const agentList = specialists.map((s) => `- ${s.name}: ${s.description}`).join('\n');
68
- if (typeof fallback.systemPrompt === 'string') {
69
- return fallback.systemPrompt.replace('{{agents}}', agentList);
70
- }
71
- if (typeof fallback.systemPrompt === 'function') {
72
- // Function-form fallback prompt — pass through unchanged. The `{{agents}}`
73
- // substitution is a string-template convenience; consumers using the
74
- // function form can compose the agent list themselves if they want it.
75
- return fallback.systemPrompt;
76
- }
77
- return `You are a helpful assistant. You cannot directly help with the user's request, but the following specialists are available:\n\n${agentList}\n\nPolitely let the user know what you can help with and invite them to rephrase their request.`;
78
- }
79
-
80
- /**
81
- * Orchestrates multiple specialist agents. Sits between `FoundationAiAssistant`
82
- * and `ChatDriver`, classifying each user message and routing it to the right
83
- * specialist — each with its own focused system prompt, tools, and primer.
84
- *
85
- * @beta
86
- */
87
- export class OrchestratingDriver extends EventTarget implements AiDriver {
88
- private readonly chatDriver: ChatDriver;
89
- private readonly specialists: SpecialistAgentConfig[];
90
- private readonly fallback?: FallbackAgentConfig;
91
- private readonly maxHandoffs: number;
92
- private readonly classifierHistoryLength: number;
93
- private readonly classifierRetries: number;
94
- private readonly sessionKey: string;
95
- /**
96
- * Aborted on driver disposal. Threaded into `AgentLifecycleContext.signal`
97
- * so long-running `onActivate` work can bail if the session disconnects.
98
- */
99
- private readonly lifecycleAbortController = new AbortController();
100
- /**
101
- * Set by `cancel()` for the duration of a `sendMessage` run. Breaks the
102
- * handoff loop so we don't classify or start another agent turn after the
103
- * user stops. Reset at the start of each `sendMessage`.
104
- */
105
- private cancelled = false;
106
- /**
107
- * Whether the CURRENT turn's user message has reached the inner driver's
108
- * history. False through the pre-first-turn classify (where the only echo of
109
- * the message is an optimistic `history-updated` dispatch), true from the
110
- * moment `chatDriver.sendMessage` is entered — including through every later
111
- * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
112
- * whether `reportBudgetExhausted` must append the message itself: appending
113
- * it when already appended duplicated it; not appending it when unappended
114
- * made it vanish. Reset at the top of each `runOrchestratedTurn`.
115
- */
116
- private userMessageAppended = false;
117
- /**
118
- * Sticky user pick from the picker (or the host's `setAgent` API). Only
119
- * changes on explicit user action. Survives flow completion: when a stateful
120
- * agent releases, the user-pin stays put and routing falls back to it on
121
- * the next turn.
122
- */
123
- private pinnedAgentName: string | null = null;
124
- /**
125
- * Transient flow lock — set when a stateful agent's `onActivate` fires,
126
- * cleared by `releaseActiveAgent`. While non-null, routing precedence goes
127
- * to this agent and the picker is locked. Independent of
128
- * `pinnedAgentName` so completing a flow doesn't wipe the user's sticky
129
- * choice.
130
- */
131
- private flowOwnerAgentName: string | null = null;
132
-
133
- /**
134
- * Per-agent restored state for a rehydrated session, keyed by agent name.
135
- * Primed by the host on restore; each entry is passed to that agent's
136
- * `onActivate` as `ctx.restored` on its next activation, then deleted so a
137
- * later re-activation starts fresh.
138
- */
139
- private restoredAgentStates: Record<string, unknown> = {};
140
-
141
- activeAgent?: AgentConfig;
142
-
143
- constructor(
144
- private readonly providerRegistry: AIProviderRegistry,
145
- private readonly agents: AgentConfig[],
146
- options: {
147
- sessionKey?: string;
148
- maxHandoffs?: number;
149
- classifierHistoryLength?: number;
150
- classifierRetries?: number;
151
- maxToolIterations?: number;
152
- maxFoldOperations?: number;
153
- maxTurnSnapshots?: number;
154
- /** Activity bus passed through to the inner ChatDriver (browser host injects the singleton). */
155
- activityBus?: ActivityBus;
156
- /** Budget-wall transcript copy, passed through to the inner ChatDriver (GENC-1464). */
157
- budgetExhaustedMessage?: string;
158
- } = {},
159
- ) {
160
- super();
161
- this.sessionKey = options.sessionKey ?? '';
162
- this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
163
- this.classifierHistoryLength =
164
- options.classifierHistoryLength ?? DEFAULT_CLASSIFIER_HISTORY_LENGTH;
165
- this.classifierRetries = options.classifierRetries ?? DEFAULT_CLASSIFIER_RETRIES;
166
-
167
- // Static-name validation: walk every agent (and nested sub-agents). Any
168
- // `provider: '<name>'` that isn't registered, or resolves to a provider
169
- // without `chat()`, throws now rather than at first turn.
170
- validateStaticAgentProviders(agents, providerRegistry);
171
-
172
- // Specialists drive the classifier. `excludeFromClassifier` agents are still
173
- // resolvable by name (so manual pinning works) but never auto-routed.
174
- this.specialists = agents.filter(isSpecialist).filter((a) => !a.excludeFromClassifier);
175
- const fallbacks = agents.filter(isFallback);
176
- if (fallbacks.length > 1) {
177
- logger.warn(
178
- 'OrchestratingDriver: multiple fallback agents found — only the first will be used.',
179
- );
180
- }
181
-
182
- const rawFallback = fallbacks[0];
183
- this.fallback = rawFallback
184
- ? { ...rawFallback, systemPrompt: buildFallbackSystemPrompt(rawFallback, this.specialists) }
185
- : undefined;
186
-
187
- this.chatDriver = new ChatDriver(providerRegistry, {
188
- maxToolIterations: options.maxToolIterations,
189
- maxFoldOperations: options.maxFoldOperations,
190
- maxTurnSnapshots: options.maxTurnSnapshots,
191
- sessionKey: this.sessionKey,
192
- activityBus: options.activityBus,
193
- budgetExhaustedMessage: options.budgetExhaustedMessage,
194
- });
195
-
196
- // Proxy events from the shared driver
197
- this.chatDriver.addEventListener('history-updated', (e: Event) => {
198
- this.dispatchEvent(new CustomEvent('history-updated', { detail: (e as CustomEvent).detail }));
199
- });
200
- this.chatDriver.addEventListener('sub-agent-history-updated', (e: Event) => {
201
- this.dispatchEvent(
202
- new CustomEvent('sub-agent-history-updated', { detail: (e as CustomEvent).detail }),
203
- );
204
- });
205
- this.chatDriver.addEventListener('sub-agent-start', (e: Event) => {
206
- this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: (e as CustomEvent).detail }));
207
- });
208
- this.chatDriver.addEventListener('sub-agent-stop', (e: Event) => {
209
- this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: (e as CustomEvent).detail }));
210
- });
211
- this.chatDriver.addEventListener('interaction-start', (e: Event) => {
212
- this.dispatchEvent(
213
- new CustomEvent('interaction-start', { detail: (e as CustomEvent).detail }),
214
- );
215
- });
216
- this.chatDriver.addEventListener('interaction-stop', (e: Event) => {
217
- this.dispatchEvent(
218
- new CustomEvent('interaction-stop', { detail: (e as CustomEvent).detail }),
219
- );
220
- });
221
- this.chatDriver.addEventListener('provider-changed', (e: Event) => {
222
- this.dispatchEvent(
223
- new CustomEvent('provider-changed', { detail: (e as CustomEvent).detail }),
224
- );
225
- });
226
- // Post-resolve external cost. The host only ever holds THIS wrapper (`createDriver` returns
227
- // one for any non-empty `agents`), so an event the inner driver raises and this list omits is
228
- // an event the host never sees: `recordExternalCost` still delegates and the money still lands
229
- // on the message, but the totals never refresh and the save is never scheduled — and in the
230
- // flow this exists for the interaction is the last thing in the session, so "stale until the
231
- // next history change" means permanently. Reviewer catch on #2474.
232
- this.chatDriver.addEventListener('external-cost-recorded', (e: Event) => {
233
- this.dispatchEvent(
234
- new CustomEvent('external-cost-recorded', { detail: (e as CustomEvent).detail }),
235
- );
236
- });
237
- }
238
-
239
- resolveInteraction(interactionId: string, result: unknown): void {
240
- this.chatDriver.resolveInteraction(interactionId, result);
241
- }
242
-
243
- /** Delegated for the same reason as `resolveInteraction`: the history that owns the
244
- * interaction — and therefore the cost — lives on the wrapped driver, not here. */
245
- recordExternalCost(interactionId: string, costUsd: number): boolean {
246
- return this.chatDriver.recordExternalCost(interactionId, costUsd);
247
- }
248
-
249
- getInteractionContext(interactionId: string): InteractionContext | undefined {
250
- return this.chatDriver.getInteractionContext(interactionId);
251
- }
252
-
253
- isBusy(): boolean {
254
- return this.chatDriver.isBusy();
255
- }
256
-
257
- /** Currently active provider name from the underlying ChatDriver. */
258
- getActiveProviderName(): string {
259
- return this.chatDriver.getActiveProviderName();
260
- }
261
-
262
- /**
263
- * Pins routing to a specific agent by name. While pinned, the classifier is
264
- * skipped and the continuation tool is hidden from the agent's tool list, so
265
- * the agent cannot quietly hand back to the orchestrator. Pass `null` to
266
- * return to automatic routing.
267
- */
268
- setPinnedAgent(name: string | null): void {
269
- this.pinnedAgentName = name;
270
- }
271
-
272
- /**
273
- * Set the flow-owner lock — used to restore state from Redux on driver
274
- * reconstruction (agents-array changes, pop-in/pop-out). Not intended for
275
- * external callers to manipulate routing; that's what `setPinnedAgent` is
276
- * for.
277
- */
278
- setFlowOwner(name: string | null): void {
279
- this.flowOwnerAgentName = name;
280
- }
281
-
282
- loadHistory(messages: ChatMessage[]): void {
283
- this.chatDriver.loadHistory(messages);
284
- }
285
-
286
- getRawHistory(): readonly ChatMessage[] {
287
- return this.chatDriver.getHistory();
288
- }
289
-
290
- /** {@inheritDoc AiDriver.compact} */
291
- compact(): Promise<ChatMessage | null> {
292
- return this.chatDriver.compact();
293
- }
294
-
295
- /** {@inheritDoc AiDriver.canCompact} */
296
- canCompact(): boolean {
297
- return this.chatDriver.canCompact();
298
- }
299
-
300
- /** Delegates to the inner {@link ChatDriver} — turns are captured there. */
301
- getTurnSnapshots(): ReadonlyArray<TurnSnapshot> {
302
- return this.chatDriver.getTurnSnapshots();
303
- }
304
-
305
- /** Delegates to the inner {@link ChatDriver} — interactions resolve there, so it holds the buffer. */
306
- getExternalDiagnostics(): ReadonlyArray<DiagnosticEntry> {
307
- return this.chatDriver.getExternalDiagnostics();
308
- }
309
-
310
- async getSuggestions(
311
- history: ChatMessage[],
312
- prompt: string,
313
- count: number,
314
- allAgentInfo?: AllAgentSummary[],
315
- ): Promise<string[]> {
316
- // When pinned to a specialist, scope suggestions to that agent so the
317
- // proposed prompts reflect the active routing instead of the full panel.
318
- const pinned = this.resolvePinnedAgent();
319
- const candidates = pinned && isSpecialist(pinned) ? [pinned] : this.specialists;
320
- const agentInfo = candidates.map((s) => ({
321
- name: s.name,
322
- description: s.description,
323
- // Suggestions use tool names for prompt hints. Dynamic agents resolve
324
- // their tools per-turn against a SystemPromptContext we don't have here
325
- // — pass an empty list rather than invoke the factory with a fake one.
326
- tools: Array.isArray(s.toolDefinitions) ? s.toolDefinitions : [],
327
- }));
328
- return this.chatDriver.getSuggestions(history, prompt, count, agentInfo);
329
- }
330
-
331
- async sendMessage(input: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult> {
332
- try {
333
- return await this.runOrchestratedTurn(input, attachments);
334
- } catch (e) {
335
- // The classifier calls the provider directly, outside any ChatDriver turn,
336
- // so a budget wall raised there would escape as an unhandled rejection —
337
- // `FoundationAiAssistant.send()` has a `finally` but no `catch`. Land it on
338
- // the driver that owns the transcript, which appends the user-facing copy
339
- // and returns the same terminal result an in-loop wall would.
340
- //
341
- // The user's message rides along ONLY while it is genuinely unappended.
342
- // `classify()` runs at two seams: before the first agent turn — where the
343
- // `history-updated` dispatch in `runOrchestratedTurn` is optimistic only
344
- // and the real append lives inside `chatDriver.sendMessage`, which the
345
- // wall prevents from ever running — and again at the END of every handoff
346
- // iteration, by which point `chatDriver.sendMessage` HAS appended it.
347
- // Passing the message unconditionally duplicated it on the handoff seam:
348
- // `['user','assistant','tool','user','assistant']` in the transcript, the
349
- // snapshot, and the history sent to the model once the budget is raised.
350
- if (e instanceof BudgetExhaustedError) {
351
- return this.chatDriver.reportBudgetExhausted(
352
- e,
353
- this.userMessageAppended
354
- ? undefined
355
- : {
356
- role: 'user',
357
- content: input,
358
- ...(attachments ? { attachments } : {}),
359
- },
360
- );
361
- }
362
- throw e;
363
- }
364
- }
365
-
366
- private async runOrchestratedTurn(
367
- input: string,
368
- attachments?: ChatAttachment[],
369
- ): Promise<ChatDriverResult> {
370
- this.cancelled = false;
371
- this.userMessageAppended = false;
372
- const history = this.chatDriver.getHistory() as ChatMessage[];
373
-
374
- // Emit the user message immediately so the UI reflects it during the classify
375
- // round-trip — without this the chat appears frozen until classify returns.
376
- this.dispatchEvent(
377
- new CustomEvent('history-updated', {
378
- detail: [...history, { role: 'user', content: input, attachments }],
379
- }),
380
- );
381
-
382
- const pinned = this.resolvePinnedAgent();
383
- if (!pinned) this.dispatchEvent(new CustomEvent('orchestrating-start'));
384
- let currentAgent = pinned ?? (await this.classify(input, history, this.activeAgent));
385
- let isHandoff = false;
386
- let handoffs = 0;
387
- let handoffSummary = '';
388
- let remainingTask = '';
389
- // The outcome of the agent turn that ENDS the loop — see the return below.
390
- let lastResult: ChatDriverResult | undefined;
391
-
392
- while (true) {
393
- // Cancelled before a (next) agent turn started — e.g. during classify.
394
- // The chat driver appends its own "Stopped." marker when a turn it was
395
- // running is cancelled, so we just stop here without a duplicate.
396
- if (this.cancelled) break;
397
-
398
- // oxlint-disable-next-line no-await-in-loop
399
- await this.applyAgent(currentAgent);
400
-
401
- let result: ChatDriverResult;
402
- if (isHandoff) {
403
- const contextPrimer: ChatMessage[] = handoffSummary
404
- ? [{ role: 'user', content: `[Context from previous agent]: ${handoffSummary}` }]
405
- : [];
406
- // oxlint-disable-next-line no-await-in-loop
407
- result = await this.chatDriver.continueFromHistory(contextPrimer);
408
- } else {
409
- // From here the inner driver owns the user message — it appends it as
410
- // its first act, before any provider call can throw. Set BEFORE the
411
- // await so a wall raised anywhere inside the turn (or on a later
412
- // handoff classify) sees the append as done.
413
- this.userMessageAppended = true;
414
- // oxlint-disable-next-line no-await-in-loop
415
- result = await this.chatDriver.sendMessage(input, attachments);
416
- }
417
- lastResult = result;
418
-
419
- // Release check: a stateful agent called `releaseAgent` from a terminal
420
- // tool handler. Fire onDeactivate, clear the pin, drop the user back to
421
- // classifier-mode. The LLM has already emitted its final wrap-up message
422
- // by the time we get here — release is purely a teardown.
423
- if (this.chatDriver.getAgentReleaseRequested()) {
424
- // oxlint-disable-next-line no-await-in-loop
425
- await this.releaseActiveAgent();
426
- break;
427
- }
428
-
429
- // Pinned agents never hand off — the continuation tool is filtered out in
430
- // applyAgent, but this guards against a model hallucinating a handoff result.
431
- if (result.reason !== 'agent-handoff' || isFallback(currentAgent) || pinned) {
432
- break;
433
- }
434
-
435
- handoffs += 1;
436
- if (handoffs > this.maxHandoffs) {
437
- this.appendInlineMessage(
438
- `I wasn't able to fully complete your request — the task required more hand-offs between specialists than allowed. Please try breaking your request into smaller steps.`,
439
- );
440
- break;
441
- }
442
-
443
- handoffSummary = result.summary;
444
- remainingTask = result.remainingTask;
445
- isHandoff = true;
446
-
447
- const updatedHistory = this.chatDriver.getHistory() as ChatMessage[];
448
- this.dispatchEvent(new CustomEvent('orchestrating-start'));
449
- // oxlint-disable-next-line no-await-in-loop
450
- currentAgent = await this.classify(remainingTask, updatedHistory);
451
- }
452
-
453
- // The orchestrated turn reports the outcome of the agent turn that ENDED it.
454
- // The hardcoded `{ reason: 'done' }` that used to live here made every
455
- // typed failure unreachable for an agents-configured host — including
456
- // `'budget-exhausted'`, which the element latches its blocked state off.
457
- //
458
- // "Last wins" cannot hide an earlier failure behind a later success: a
459
- // failing inner turn always ends the loop, because the loop only continues
460
- // on `reason === 'agent-handoff'`. The two breaks that hold a non-'done'
461
- // result (the handoff cap, and the pinned/fallback break) both carry
462
- // `'agent-handoff'`, which is an internal routing protocol that must never
463
- // escape to the host — the collapse below strips it. A cancel before the
464
- // first turn leaves `lastResult` undefined; the release break returns the
465
- // inner turn's real result, which is correct, because that turn ran.
466
- return lastResult?.reason === 'done' ? lastResult : { reason: 'done' };
467
- }
468
-
469
- async continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult> {
470
- return this.chatDriver.continueFromHistory(transientPrimer);
471
- }
472
-
473
- /** {@inheritDoc AiDriver.primeRestoredAgentState} */
474
- primeRestoredAgentState(snapshots: Record<string, unknown>): void {
475
- this.restoredAgentStates = { ...snapshots };
476
- }
477
-
478
- /** {@inheritDoc AiDriver.getActiveAgent} */
479
- getActiveAgent(): AgentConfig | undefined {
480
- return this.activeAgent;
481
- }
482
-
483
- private async applyAgent(agent: AgentConfig): Promise<void> {
484
- const previousAgent = this.activeAgent;
485
- const isSwitch = !previousAgent || previousAgent.name !== agent.name;
486
-
487
- // Record the agent transition on the debug-log timeline. A `null` `from`
488
- // marks the initial activation; a named `from` is a handoff between agents.
489
- if (isSwitch) {
490
- recordMetaEvent(this.sessionKey, 'agent.handoff', {
491
- from: previousAgent?.name ?? null,
492
- to: agent.name,
493
- // GENC-1351: was a restored snapshot waiting for this agent at activation?
494
- restored: this.restoredAgentStates[agent.name] !== undefined,
495
- });
496
- }
497
-
498
- // Fire lifecycle hooks around the swap — outgoing first, then incoming.
499
- // Both are awaited so a heavy `onActivate` (e.g. machine restore) completes
500
- // before the agent's first turn runs.
501
- if (isSwitch && previousAgent?.onDeactivate) {
502
- try {
503
- await previousAgent.onDeactivate({
504
- agentName: previousAgent.name,
505
- sessionKey: this.sessionKey,
506
- signal: this.lifecycleAbortController.signal,
507
- });
508
- } catch (e) {
509
- logger.warn(`OrchestratingDriver: onDeactivate("${previousAgent.name}") threw:`, e);
510
- }
511
- }
512
- if (isSwitch && agent.onActivate) {
513
- // On a session restore, hand this agent its persisted state exactly once
514
- // (consumed here) so a stateful machine resumes at the right step; a later
515
- // re-activation of the same agent then starts fresh.
516
- const restored = this.restoredAgentStates[agent.name];
517
- if (restored !== undefined) delete this.restoredAgentStates[agent.name];
518
- try {
519
- await agent.onActivate({
520
- agentName: agent.name,
521
- sessionKey: this.sessionKey,
522
- previousAgentName: previousAgent?.name,
523
- signal: this.lifecycleAbortController.signal,
524
- restored,
525
- });
526
- } catch (e) {
527
- logger.warn(`OrchestratingDriver: onActivate("${agent.name}") threw:`, e);
528
- }
529
- }
530
-
531
- const hasLifecycleHooks = !!(agent.onActivate || agent.onDeactivate);
532
-
533
- // Stateful agents auto-lock the flow on activation. The lock guarantees
534
- // the machine survives subsequent turns (the classifier would otherwise be
535
- // free to route away mid-flow, tearing the machine down). Release happens
536
- // when the agent calls `releaseAgent` from a terminal-state tool handler
537
- // — see the post-sendMessage check below. We write `flowOwnerAgentName`
538
- // rather than `pinnedAgentName` so completing a flow doesn't wipe the
539
- // user's sticky picker choice.
540
- if (isSwitch && hasLifecycleHooks && this.flowOwnerAgentName !== agent.name) {
541
- this.flowOwnerAgentName = agent.name;
542
- this.dispatchEvent(new CustomEvent('flow-owner-changed', { detail: agent.name }));
543
- }
544
-
545
- // Terminal agents do not get the cross-agent handoff tool. Four cases:
546
- // • fallback — already a leaf; handoff would loop
547
- // • user-pinned — user explicitly selected this agent; do not auto-route away
548
- // • flow-owner — a stateful flow is mid-stream; do not auto-route away
549
- // • stateful — agents with lifecycle hooks own state for the duration of
550
- // their flow. Initiating a handoff mid-flow would abandon
551
- // that state with no clean exit and dump the user into the
552
- // classifier mid-machine. Capture the tool loop until the
553
- // user (or the agent itself, via `releaseAgent`) releases.
554
- const isTerminal =
555
- isFallback(agent) ||
556
- this.pinnedAgentName !== null ||
557
- this.flowOwnerAgentName !== null ||
558
- hasLifecycleHooks;
559
-
560
- let agentToApply: AgentConfig = agent;
561
- if (!isTerminal) {
562
- const declaredTools = agent.toolDefinitions;
563
- agentToApply = {
564
- ...agent,
565
- toolDefinitions:
566
- typeof declaredTools === 'function'
567
- ? async (ctx: SystemPromptContext) => [
568
- ...(await declaredTools(ctx)),
569
- REQUEST_CONTINUATION_DEFINITION,
570
- ]
571
- : [...(declaredTools ?? []), REQUEST_CONTINUATION_DEFINITION],
572
- };
573
- }
574
-
575
- if (previousAgent && previousAgent.name !== agent.name) {
576
- const rawHistory = this.chatDriver.getHistory() as ChatMessage[];
577
- this.chatDriver.loadHistory([
578
- ...rawHistory,
579
- { role: 'system-event', content: agent.name, timestamp: new Date().toISOString() },
580
- ]);
581
- }
582
-
583
- this.chatDriver.setProviderHistoryTransform((h) => transformHistoryForAgent(h, agent.name));
584
- this.chatDriver.applyAgent(agentToApply);
585
- this.activeAgent = agent;
586
- this.dispatchEvent(new CustomEvent('orchestrating-stop'));
587
- this.dispatchEvent(new CustomEvent('agent-changed', { detail: agent }));
588
- }
589
-
590
- /**
591
- * Release the current stateful agent: fire `onDeactivate`, clear the
592
- * flow-owner lock, dispatch events so the host (and Redux) reflect the
593
- * unlocked state. Called automatically when a tool handler invokes
594
- * `context.releaseAgent`.
595
- *
596
- * The user-pin (`pinnedAgentName`) is intentionally *not* cleared — a user
597
- * who explicitly picked this agent should stay on it after the flow ends,
598
- * so the next message starts a fresh flow on the same agent rather than
599
- * routing through the classifier.
600
- */
601
- private async releaseActiveAgent(): Promise<void> {
602
- const agent = this.activeAgent;
603
- if (!agent) return;
604
- if (agent.onDeactivate) {
605
- try {
606
- await agent.onDeactivate({
607
- agentName: agent.name,
608
- sessionKey: this.sessionKey,
609
- signal: this.lifecycleAbortController.signal,
610
- });
611
- } catch (e) {
612
- logger.warn(`OrchestratingDriver: release onDeactivate("${agent.name}") threw:`, e);
613
- }
614
- }
615
- this.activeAgent = undefined;
616
- if (this.flowOwnerAgentName !== null) {
617
- this.flowOwnerAgentName = null;
618
- this.dispatchEvent(new CustomEvent('flow-owner-changed', { detail: null }));
619
- }
620
- this.dispatchEvent(new CustomEvent('agent-released', { detail: agent }));
621
- this.dispatchEvent(new CustomEvent('agent-changed', { detail: undefined }));
622
- }
623
-
624
- /**
625
- * Stop the current turn (user "stop" button). Cancels the inner chat driver's
626
- * in-flight request and breaks the handoff loop so no further agent turn or
627
- * classify runs. The driver stays usable for the next message.
628
- */
629
- cancel(): void {
630
- this.cancelled = true;
631
- this.chatDriver.cancel();
632
- }
633
-
634
- /**
635
- * Fire `onDeactivate` on the current active agent and abort any pending
636
- * lifecycle work. Called by the host on session teardown so machines can
637
- * release resources cleanly.
638
- */
639
- async dispose(): Promise<void> {
640
- const previousAgent = this.activeAgent;
641
- if (previousAgent?.onDeactivate) {
642
- try {
643
- await previousAgent.onDeactivate({
644
- agentName: previousAgent.name,
645
- sessionKey: this.sessionKey,
646
- signal: this.lifecycleAbortController.signal,
647
- });
648
- } catch (e) {
649
- logger.warn(`OrchestratingDriver: dispose onDeactivate("${previousAgent.name}") threw:`, e);
650
- }
651
- }
652
- this.lifecycleAbortController.abort();
653
- // Tear down the inner driver too, so any in-flight provider request aborts.
654
- this.chatDriver.dispose();
655
- this.activeAgent = undefined;
656
- }
657
-
658
- private async classify(
659
- input: string,
660
- history: ChatMessage[],
661
- contextAgent?: AgentConfig,
662
- ): Promise<AgentConfig> {
663
- // Single-candidate short-circuits. No point asking the LLM to route
664
- // when there's only one viable choice. Skipped if a fallback is
665
- // configured — that's an explicit "escape hatch" signal from the
666
- // consumer, and we preserve the LLM's ability to send unrelated
667
- // messages there by returning -1 from select_agent.
668
- if (this.specialists.length === 1 && !this.fallback) {
669
- return this.specialists[0];
670
- }
671
- if (this.specialists.length === 0) {
672
- // No classifier-eligible specialists. If exactly one non-fallback
673
- // agent exists (typically a stateful agent flagged
674
- // `excludeFromClassifier`) and there's no fallback to preserve as an
675
- // escape hatch, route to it — this is what fixes the previously-
676
- // silent single-stateful-agent case. Otherwise drop to the fallback;
677
- // excluded specialists remain reachable via manual pin.
678
- if (!this.fallback) {
679
- const routable = this.agents.filter((a) => !isFallback(a));
680
- if (routable.length === 1) {
681
- return routable[0];
682
- }
683
- }
684
- return this.fallback ?? { name: 'Assistant', fallback: true };
685
- }
686
-
687
- const agentList = this.specialists
688
- .map((a, i) => `${i}: ${a.name} — ${a.description}`)
689
- .join('\n');
690
-
691
- const recentMessages = history
692
- .filter((m) => m.role === 'user' || m.role === 'assistant')
693
- .slice(-this.classifierHistoryLength)
694
- .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
695
- .join('\n');
696
-
697
- const continuationHint =
698
- contextAgent && isSpecialist(contextAgent)
699
- ? `\n\nThe previous turn was handled by "${contextAgent.name}". If the new message is a refinement, correction, or continuation of that work, prefer "${contextAgent.name}" — unless the topic has clearly shifted to another specialist's domain.`
700
- : '';
701
-
702
- const classifierPrompt = `You are a routing classifier. Call select_agent with the index of the best matching agent for the user message. Use -1 only if the request is entirely unrelated to every agent listed — prefer the closest match when in doubt. If the request spans multiple agents, pick the one that should act first; I will re-classify and route again once that agent is done.\n\nAgents:\n${agentList}${continuationHint}${recentMessages ? `\n\nRecent conversation:\n${recentMessages}` : ''}`;
703
-
704
- const routingTool = {
705
- name: 'select_agent',
706
- description: 'Select the most appropriate agent for the user message.',
707
- parameters: {
708
- type: 'object',
709
- required: ['agent_index'],
710
- properties: {
711
- agent_index: {
712
- type: 'integer',
713
- description: 'Zero-based index of the matching agent, or -1 if no agent matches.',
714
- },
715
- },
716
- },
717
- };
718
-
719
- // True only when the classifier RAN and answered "no match" (index -1). The
720
- // no-fallback apology below is gated on it: an exhausted-retries exit never
721
- // earned that apology, and emitting it there put a routing excuse ahead of
722
- // the real error the routed turn is about to produce.
723
- let routedNoMatch = false;
724
-
725
- for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
726
- try {
727
- const options: ChatRequestOptions = {
728
- systemPrompt: classifierPrompt,
729
- tools: [routingTool],
730
- };
731
- // Classification is orchestrator-level, not bound to any single agent —
732
- // always run against the registry default.
733
- const classifierProvider = this.providerRegistry.default();
734
- if (!classifierProvider.chat) {
735
- logger.warn(
736
- 'OrchestratingDriver: default AI provider does not implement chat() — cannot classify.',
737
- );
738
- break;
739
- }
740
- // oxlint-disable-next-line no-await-in-loop
741
- const response = await classifierProvider.chat([], input, options);
742
- const tc = response.toolCalls?.[0];
743
- const index = tc?.name === 'select_agent' ? (tc.args.agent_index as number) : -1;
744
-
745
- if (index >= 0 && index < this.specialists.length) {
746
- return this.specialists[index];
747
- }
748
- // index === -1 — fall through to fallback
749
- routedNoMatch = true;
750
- break;
751
- } catch (e) {
752
- // Terminal: no ladder clears a budget wall. Short-circuit the
753
- // classifier's own retries the same way 402 short-circuits the
754
- // transport's — this loop is a SECOND retry ladder stacked on that one,
755
- // so without this a single wall costs `classifierRetries + 1` doomed
756
- // classifier calls and then a doomed fallback turn on top. Deliberately
757
- // narrow: a generic classifier failure must still retry and fall back.
758
- if (e instanceof BudgetExhaustedError) throw e;
759
- logger.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
760
- if (attempt === this.classifierRetries) {
761
- logger.error('OrchestratingDriver: classifier failed after all retries, using fallback');
762
- }
763
- }
764
- }
765
-
766
- if (this.fallback) return this.fallback;
767
-
768
- // No fallback configured. Route to the first specialist as a no-op — but
769
- // only APOLOGISE when the classifier actually said "none of these". On an
770
- // exhausted-retries exit the classifier never expressed an opinion, so the
771
- // apology would be a routing excuse the user reads immediately above the
772
- // real error that turn is about to produce.
773
- if (routedNoMatch) {
774
- const specialistNames = this.specialists.map((s) => s.name).join(', ');
775
- this.appendInlineMessage(
776
- `I'm not sure how to help with that. I can assist with: ${specialistNames}.`,
777
- );
778
- }
779
- return this.specialists[0];
780
- }
781
-
782
- /**
783
- * Returns the agent that the current turn is locked to, if any. Flow-owner
784
- * (transient, set by stateful-agent activation) wins over user-pin (sticky,
785
- * set by picker click). `undefined` falls back to the classifier.
786
- *
787
- * Logs and returns `undefined` if a name no longer matches the agents array
788
- * — protects against stale state after an agents-array reassignment.
789
- */
790
- private resolvePinnedAgent(): AgentConfig | undefined {
791
- const name = this.flowOwnerAgentName ?? this.pinnedAgentName;
792
- if (name === null) return undefined;
793
- const match = this.agents.find((a) => a.name === name);
794
- if (!match) {
795
- logger.warn(
796
- `OrchestratingDriver: pinned agent "${name}" not found in agents array — falling back to classifier.`,
797
- );
798
- return undefined;
799
- }
800
- return match;
801
- }
802
-
803
- private appendInlineMessage(content: string): void {
804
- const history = this.chatDriver.getHistory() as ChatMessage[];
805
- this.chatDriver.loadHistory([
806
- ...history,
807
- { role: 'assistant', content, timestamp: new Date().toISOString() },
808
- ]);
809
- this.dispatchEvent(
810
- new CustomEvent('history-updated', { detail: this.chatDriver.getHistory() }),
811
- );
812
- }
813
- }