@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,3925 +0,0 @@
1
- import type {
2
- AggregateUsage,
3
- AIProvider,
4
- AIProviderRegistry,
5
- AIProviderType,
6
- CachePolicy,
7
- ChatAttachment,
8
- ChatDriverResult,
9
- ChatFallback,
10
- ChatImageAttachment,
11
- ChatMessage,
12
- ChatRequestOptions,
13
- ChatThinkingPolicy,
14
- ChatToolCall,
15
- ChatToolChoice,
16
- ChatToolDefinition,
17
- ChatToolHandlers,
18
- CondensePolicy,
19
- CondenseTrigger,
20
- InteractionRequestOptions,
21
- InteractionResult,
22
- SubAgentFailureReason,
23
- SubAgentRequestOptions,
24
- TurnFailureReason,
25
- } from '@genesislcap/foundation-ai';
26
- import {
27
- BudgetExhaustedError,
28
- DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
29
- DEFAULT_PROVIDER_REFUSED_MESSAGE,
30
- ProviderRefusedError,
31
- isObservableAIProviderRegistry,
32
- MalformedFunctionCallError,
33
- ResponseTruncatedError,
34
- vendorTypeOfLabel,
35
- } from '@genesislcap/foundation-ai';
36
- import { type ActivityBus, NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
37
- import type {
38
- AgentConfig,
39
- CachePolicyInput,
40
- ProviderInput,
41
- ResponseSchemaInput,
42
- SystemPromptContext,
43
- SystemPromptInput,
44
- TailContextInput,
45
- TemperatureInput,
46
- ThinkingPolicyInput,
47
- ToolChoiceInput,
48
- ToolDefinitionsInput,
49
- ToolHandlersInput,
50
- UnresolvedToolInput,
51
- } from '../../config/config';
52
- import { resolveChatProvider } from '../../config/validate-providers';
53
- import {
54
- clearSession,
55
- getMetaEvents,
56
- type MetaEvent,
57
- mergeMetaEvents,
58
- type MetaEventType,
59
- recordMetaEvent,
60
- recordTurnError,
61
- recordTurnRetry,
62
- } from '../../state/debug-event-log';
63
- import {
64
- createInteractionContext,
65
- type InteractionContextHandle,
66
- } from '../../state/interaction-context';
67
- import type { DiagnosticEntry } from '../../state/persistence/diagnostics';
68
- import type { InteractionContext } from '../../types/interaction-context';
69
- import { applyCondensation, type RegisteredCondensePolicy } from '../../utils/condense-history';
70
- import {
71
- applyHistoryCap,
72
- buildCompactionSummaryPrompt,
73
- findCompactionCut,
74
- normalizeForProvider,
75
- } from '../../utils/history-transform';
76
- import { logger } from '../../utils/logger';
77
- import { messageUsage, sumUsage } from '../../utils/sum-usage';
78
- import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
79
- import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
80
-
81
- /**
82
- * The `budget` payload carried on a `'budget-exhausted'` {@link ChatDriverResult}.
83
- * Derived from the type rather than restated so the two cannot drift.
84
- */
85
- type BudgetDetail = NonNullable<Extract<ChatDriverResult, { reason: 'done' }>['budget']>;
86
- /** The `providerRefused` payload on a `'provider-refused'` {@link ChatDriverResult} (GENC-1506). */
87
- type ProviderRefusedDetail = NonNullable<
88
- Extract<ChatDriverResult, { reason: 'done' }>['providerRefused']
89
- >;
90
-
91
- /**
92
- * Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
93
- * when it carries none.
94
- *
95
- * "None" means **no figures AND no attributable vendor** — not merely no
96
- * figures. Dropping the whole object on a figure-less 402 also dropped
97
- * `vendorLabel`, which is the authoritative attribution source, and left the
98
- * element's latch falling back to the driver's last-resolved provider, i.e. the
99
- * PREVIOUS turn's vendor. A turn-1 Gemini call followed by a turn-2 classifier
100
- * refused by Anthropic with a figure-less 402 then walled *Gemini* and advised
101
- * switching to the vendor that had actually run out. Three live shapes reach
102
- * here with no figures — a bare gateway 402, a code-only body, and the framed
103
- * err frame whose figures are `null` (which the proxy now prefers over a
104
- * misleading `0`, so this path is getting more common, not less).
105
- *
106
- * The figures alone still decide whether banner copy is composed — see
107
- * `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
108
- * host-set explanation survives the latch.
109
- */
110
- /**
111
- * The images on a tool handler's return, or `undefined` if it did not return any.
112
- *
113
- * Deliberately strict: `attachments` must be a non-empty array in which EVERY entry carries
114
- * `kind: 'image'`. A handler returning an ordinary object that happens to have an
115
- * `attachments` key therefore still JSON-stringifies exactly as it did before, and a
116
- * half-populated array fails the check rather than silently sending some of the images.
117
- */
118
- const toolResultImages = (result: unknown): ChatImageAttachment[] | undefined => {
119
- if (!result || typeof result !== 'object') return undefined;
120
- const candidate = (result as { attachments?: unknown }).attachments;
121
- if (!Array.isArray(candidate) || candidate.length === 0) return undefined;
122
- const allImages = candidate.every(
123
- (att) => !!att && typeof att === 'object' && (att as { kind?: unknown }).kind === 'image',
124
- );
125
- return allImages ? (candidate as ChatImageAttachment[]) : undefined;
126
- };
127
-
128
- /**
129
- * Lift the reportable facts off a {@link ProviderRefusedError} (GENC-1506).
130
- *
131
- * Unlike `budgetDetailOf` this is total — it always returns a detail. The budget version can answer
132
- * `undefined` because its payload is figures the proxy may not have sent; here every field either
133
- * comes from the transport that was refused (`vendorLabel`, `kind`) or is plainly optional, so there
134
- * is no "nothing worth reporting" case to model.
135
- */
136
- const providerRefusedDetailOf = (e: ProviderRefusedError): ProviderRefusedDetail => ({
137
- vendorLabel: e.vendorLabel,
138
- kind: e.kind,
139
- ...(e.upstreamStatus != null ? { upstreamStatus: e.upstreamStatus } : {}),
140
- ...(e.upstreamType ? { upstreamType: e.upstreamType } : {}),
141
- });
142
-
143
- const budgetDetailOf = (e: BudgetExhaustedError): BudgetDetail | undefined => {
144
- // The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
145
- // the label comes from the transport that was actually refused, whereas the
146
- // last-resolved provider is stale on the classification seam (an orchestrated
147
- // turn classifies against the registry default, which this driver may never
148
- // have resolved). The proxy's own `vendor` is the fallback for a transport
149
- // whose static label no vendor claims (a multiplexing or white-labelled
150
- // gateway). Omitted rather than set to `undefined` when neither resolves, so
151
- // "we know the vendor" stays testable by presence alone.
152
- const vendor = vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor);
153
- if (e.budgetUsd == null && e.spentUsd == null && !vendor) return undefined;
154
- return {
155
- budgetUsd: e.budgetUsd,
156
- spentUsd: e.spentUsd,
157
- vendorLabel: e.vendorLabel,
158
- ...(vendor ? { vendor } : {}),
159
- // Server-known truth the client cannot re-derive, so it rides all the way to
160
- // the banner rather than being re-guessed there from registry membership.
161
- ...(e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}),
162
- };
163
- };
164
-
165
- const DEFAULT_MAX_TOOL_ITERATIONS = 50;
166
- const DEFAULT_MAX_FOLD_OPERATIONS = 5;
167
- // TODO: dedup system prompts in-memory to allow raising this cap much higher.
168
- // Each TurnSnapshot stores the full resolved `systemPrompt`, so the buffer's
169
- // memory cost scales with cap × prompt size — a stable agent repeats a multi-KB
170
- // prompt every turn. `getDebugLog` already dedups identical consecutive prompts
171
- // at export time (`<repeated — identical to turn N>`); applying the same dedup
172
- // at capture time (store the prompt only when it changes, back-reference
173
- // otherwise) would make snapshots nearly as cheap as meta events and let this
174
- // cap reach thousands for full-session capture without the memory blowup.
175
- const DEFAULT_MAX_TURN_SNAPSHOTS = 400;
176
- const DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5;
177
- // Stale tools (advertised in an earlier state, retired now) and fold-hidden tools are
178
- // self-correcting — the model drops them once guided — so they get a higher loop-protection
179
- // ceiling than hallucinated names: a few legitimate stale calls across state transitions must
180
- // not prematurely end the turn. Still bounded so a genuinely stuck loop terminates.
181
- const MAX_STALE_TOOL_CALLS = DEFAULT_MAX_UNKNOWN_TOOL_CALLS * 2;
182
- // Gemini in particular emits short bursts of MALFORMED_FUNCTION_CALL; allow more CONSECUTIVE
183
- // retries. These counters reset on any productive response, so this is a consecutive-failure
184
- // ceiling, not a per-turn total.
185
- const MAX_MALFORMED_RETRIES = 5;
186
- const MAX_EMPTY_RESPONSE_RETRIES = 3;
187
- // Transient throws while building the per-turn tool surface or calling the provider retry the
188
- // SAME iteration up to this many times before propagating, rather than tearing down the turn.
189
- const MAX_SETUP_TRANSPORT_RETRIES = 3;
190
- const SUGGESTIONS_HISTORY_WINDOW = 8;
191
-
192
- /**
193
- * Sub-agent meta events worth folding into the parent's debug timeline: the
194
- * per-attempt and per-failure signals that do NOT otherwise surface in the
195
- * sub-agent's (now hoisted) messages — a retried-away malformed/empty attempt
196
- * produces no message, and the stale-vs-hallucinated/streak diagnostics live only
197
- * on the event. High-volume, message-derivable events (turn.start/turn.end,
198
- * provider.selected, context.updated) are intentionally excluded: read the
199
- * sub-agent's hoisted messages for model/tokens/cost and turn-by-turn activity.
200
- * See `ChatDriver.invokeSubAgent`.
201
- */
202
- const HARVESTED_SUBAGENT_EVENTS: ReadonlySet<MetaEventType> = new Set([
203
- 'turn.retry',
204
- 'turn.error',
205
- 'tool.failed',
206
- 'tool.unresolved',
207
- ]);
208
-
209
- /** Name reserved for the cross-agent handoff tool — injected by OrchestratingDriver. */
210
- export const REQUEST_CONTINUATION_TOOL = 'request_continuation';
211
-
212
- /** Paired in history for each `request_continuation` so tool_calls stay balanced for the provider. */
213
- const HANDOFF_TOOL_RESULT_PLACEHOLDER =
214
- 'Handoff to another specialist — routing continues on the next turn.';
215
-
216
- /**
217
- * Event emitted when the chat history is updated (new message appended).
218
- *
219
- * @beta
220
- */
221
- export type ChatHistoryUpdatedEvent = CustomEvent<ReadonlyArray<ChatMessage>>;
222
-
223
- /**
224
- * One captured frame of what the LLM saw on a single tool-loop iteration.
225
- * The driver records these as a ring buffer (cap: configurable via
226
- * `chatConfig.agent.maxTurnSnapshots`, default 400) so the export log can show,
227
- * per turn: which agent was active, the resolved system prompt, the tool names
228
- * visible to the LLM, and any agent-supplied debug snapshot (e.g. machine
229
- * state for stateful agents).
230
- *
231
- * @beta
232
- */
233
- export interface TurnSnapshot {
234
- /**
235
- * Turn identifier, always a string. A driver's own turns are the bare counter
236
- * (`"0"`, `"1"`, … — monotonic, does not reset on agent swap). Turns forwarded
237
- * up from a sub-agent are re-labelled under the parent turn that activated them
238
- * — a sub-agent invoked on parent turn 5 contributes `"5-1"`, `"5-2"`, …, and a
239
- * nested sub-agent on `"5-2"` contributes `"5-2-1"`, …. See
240
- * `forwardSubAgentSnapshots`.
241
- */
242
- turnIndex: string;
243
- /** ISO timestamp captured just before the LLM call. */
244
- timestamp: string;
245
- /** Name of the agent active when this LLM call ran. */
246
- agentName?: string;
247
- /**
248
- * The agent's resolved system prompt sent to the LLM, verbatim. NOTE: this is
249
- * now the *bare* prompt — the fold-surface suffix and the malformed/empty retry
250
- * nudge no longer mutate it (they kept it byte-unstable and busted prompt
251
- * caching), so they moved to {@link TurnSnapshot.tailContext}. To reconstruct
252
- * exactly what the model saw, read both fields.
253
- */
254
- systemPrompt?: string;
255
- /**
256
- * The framed `<system-reminder>` tail appended to the final user turn for this
257
- * call, verbatim — never part of stored history, so this snapshot is the only
258
- * record of it. Carries the framework's volatile additions (fold suffix, retry
259
- * nudge) and the agent's per-turn `tailContext` (e.g. current file spec, live
260
- * diagnostics). Undefined when the tail was empty (the normal case).
261
- */
262
- tailContext?: string;
263
- /** Tool names sent to the LLM, in order — definitions are static per name so names alone suffice. */
264
- toolNames: string[];
265
- /**
266
- * Normalized `0`–`1` sampling temperature in effect for this call, if the
267
- * agent (or its current state) configured one. Undefined → provider/model
268
- * default. Mirrors the value resolved from `BaseAgentConfig.temperature`.
269
- */
270
- temperature?: number;
271
- /**
272
- * Tool-call mode actually sent to the provider this call — the effective
273
- * value, including the sub-agent `'required'` default. Undefined → `'auto'`.
274
- * Mirrors the value resolved from `BaseAgentConfig.toolChoice`.
275
- */
276
- toolChoice?: ChatToolChoice;
277
- /**
278
- * Per-turn display label resolved from the agent's `displayName`, e.g.
279
- * "Guided Booking (Counterparties)". `agentName` stays as the canonical
280
- * identity used for routing/filtering.
281
- */
282
- agentLabel?: string;
283
- /** Agent-supplied snapshot — machine state/context for stateful agents, undefined otherwise. */
284
- agentSnapshot?: unknown;
285
- /**
286
- * Concrete model that ran this call (e.g. `'claude-sonnet-4-6'`) — the serving model
287
- * where the provider reports one, else the model the resolved tier was configured
288
- * with. Undefined when the provider exposes no `getStatus` and the transport stamped
289
- * nothing.
290
- *
291
- * Recorded per call, so an agent whose `provider` selector varies by state (a tier
292
- * switch between steps of a flow) has each step attributed to the model that actually
293
- * ran it — without the reader having to join the turn to the message after it, which
294
- * is impossible for a call that produced no message.
295
- */
296
- model?: string;
297
- /**
298
- * Registry slot the provider resolved under for this call — a tier name like
299
- * `'high'`/`'low'`, or the registry default's name. Kept alongside {@link
300
- * TurnSnapshot.model} because they answer different questions: the slot is what the
301
- * agent asked for, the model is what served it, and repointing a slot at a new model
302
- * mid-session is only visible when both are recorded.
303
- */
304
- providerName?: string;
305
- /** Vendor behind the resolved slot (`'anthropic'`, `'gemini'`, …), when the provider reports it. */
306
- provider?: AIProviderType;
307
- /**
308
- * What this one LLM call cost — the four disjoint token buckets plus USD, derived
309
- * from the response's usage by `messageUsage`. Back-filled when the response lands
310
- * (the rest of the snapshot is captured *before* the call), so it is `undefined`
311
- * while the call is in flight, on providers that report no usage, and on a call that
312
- * threw rather than returning — a malformed-call/truncation/402 error carries no usage
313
- * block, so any tokens the provider billed for it are not recoverable here.
314
- *
315
- * A snapshot is one **model call**, not one user turn: every tool-loop iteration and
316
- * every retried attempt records its own. That makes this the only record of spend on
317
- * an attempt that produced no message — a blank or refused response is billed and
318
- * then discarded (see the empty-response retries), so summing the transcript alone
319
- * under-reports the turn.
320
- *
321
- * For a call that DID produce a message, this is the same money as that message's
322
- * `cost`/token fields, not additional money — never add turn usage to message usage.
323
- */
324
- usage?: AggregateUsage;
325
- }
326
-
327
- interface FoldStackFrame {
328
- foldName: string;
329
- previousDefinitions: ChatToolDefinition[];
330
- previousHandlers: ChatToolHandlers;
331
- }
332
-
333
- /**
334
- * Construction-time configuration for {@link ChatDriver}. Everything except the provider
335
- * registry is optional — most fields are also settable per-agent via `applyAgent`, so a
336
- * bare `new ChatDriver(registry)` is valid. Mirrors the `(registry, options)` shape of
337
- * `OrchestratingDriver`.
338
- *
339
- * @beta
340
- */
341
- export interface ChatDriverConfig {
342
- /** Initial tool handlers (static map or per-turn factory). Default `{}`. */
343
- toolHandlers?: ToolHandlersInput;
344
- /** Initial tool definitions (static array or per-turn factory). Default `[]`. */
345
- toolDefinitions?: ToolDefinitionsInput;
346
- /** Initial system prompt (string or per-turn resolver). */
347
- systemPrompt?: SystemPromptInput;
348
- /** Primer history prepended to the conversation. */
349
- primerHistory?: ChatMessage[];
350
- /** Hard cap on tool-loop iterations. Default `50`. */
351
- maxToolIterations?: number;
352
- /** Hard cap on fold operations. Default `5`. */
353
- maxFoldOperations?: number;
354
- /**
355
- * Collapse `condenseWhen` payloads in batches of this many model-calls rather than as
356
- * soon as each trigger fires. A positive integer; decimals are floored and anything
357
- * below `1` becomes `1`. Default `1` — collapse immediately, the historical behaviour.
358
- *
359
- * Condensation rewrites history in place, so collapsing continuously breaks the
360
- * provider's prompt cache on nearly every call of a re-read loop. Raising this trades
361
- * up to that many calls' worth of extra context for one cache break per batch.
362
- */
363
- condenseBatchCalls?: number;
364
- /** Ring-buffer size for per-turn snapshots. Default `400`. */
365
- maxTurnSnapshots?: number;
366
- /** Session identity used to file meta events onto the shared debug-log timeline. */
367
- sessionKey?: string;
368
- /**
369
- * Activity bus for lifecycle/halo/tool-loop events. Injected by the browser host
370
- * (the shared cross-tab singleton); omitted off-browser (Node, tests, headless), where
371
- * it defaults to {@link NOOP_ACTIVITY_BUS} so no `BroadcastChannel` is ever opened.
372
- */
373
- activityBus?: ActivityBus;
374
- /**
375
- * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464),
376
- * used VERBATIM — set it only to own the copy entirely (white-labelling).
377
- * When unset, the driver composes the bubble per wall: the neutral
378
- * `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, upgraded with "switch to another AI
379
- * provider" advice only when the 402's `otherVendorAvailable` positively says
380
- * another metered vendor still has headroom — the same rule the blocked
381
- * banner applies, so the two surfaces can no longer contradict each other.
382
- *
383
- * The assistant element already lets a host override the blocked **banner**
384
- * via `setBlocked(true, reason)`; without this the transcript **bubble** stayed
385
- * on the default, so a white-labelled host got its own copy in the banner and
386
- * the shipped default directly below it. Passing the same effective copy here
387
- * keeps the two surfaces saying one thing.
388
- *
389
- * Deliberately a driver-config field rather than something read off a chat
390
- * config: `ChatDriver` has no `chatConfig` and is used standalone (see
391
- * `chat-driver-node`), so threading one in would be a much larger and less
392
- * reversible change.
393
- */
394
- budgetExhaustedMessage?: string;
395
-
396
- /**
397
- * Overrides the transcript sentence shown when the upstream PROVIDER refuses the account
398
- * (GENC-1506) — see `DEFAULT_PROVIDER_REFUSED_MESSAGE`.
399
- *
400
- * Separate from `budgetExhaustedMessage` because the two conditions are separate: ours is a spend
401
- * cap a host administrator can raise, this is the vendor declining to serve us at all. A host that
402
- * white-labels one will usually want to white-label both, but conflating them into one field would
403
- * force identical copy on two situations with different remedies.
404
- *
405
- * Rarely needed: unlike the budget default, the shipped sentence names no vendor and no Genesis, so
406
- * it is already safe for a white-labelled deployment.
407
- */
408
- providerRefusedMessage?: string;
409
- }
410
-
411
- /**
412
- * Plain TS class that drives a multi-turn chat conversation, including the tool-call loop.
413
- * Owned by `FoundationAiAssistant` — created in `connectedCallback`, torn down in `disconnectedCallback`.
414
- *
415
- * Dispatches `'history-updated'` events on itself so the owning element can observe changes.
416
- *
417
- * @fires history-updated - Fired whenever the in-memory chat history changes (append, tool loop, interaction resolution, post-resolve external cost) with the full history snapshot. detail: `ReadonlyArray<ChatMessage>`
418
- *
419
- * @beta
420
- */
421
- export class ChatDriver extends EventTarget implements AiDriver {
422
- private history: ChatMessage[] = [];
423
- private busy = false;
424
- /** Epoch ms when the current turn loop began — drives the `turn.end` duration. */
425
- private turnStartedAt = 0;
426
- private pendingInteractions = new Map<
427
- string,
428
- {
429
- resolve: (value: any) => void;
430
- reject: (reason?: any) => void;
431
- /** Present when the call requested a chat-input override. */
432
- overrideId?: string;
433
- /** Present when the call set `timeoutMs`; cleared on resolution. */
434
- timeoutHandle?: ReturnType<typeof setTimeout>;
435
- }
436
- >();
437
- /**
438
- * Live per-interaction contexts (GENC-1390), keyed by interactionId. Created in
439
- * `requestInteraction` alongside the pending entry, disposed + removed in
440
- * `resolveInteraction`. Non-serialisable (sockets, timers, subscriber sets), so
441
- * they live here on the driver — not on the DOM, not in the persisted store — and
442
- * therefore survive the element remounts (layout reflow/drag, transcript
443
- * re-render) that this feature exists to make harmless. At most one is live at a
444
- * time (see the one-interaction-at-a-time guard in `requestInteraction`).
445
- */
446
- private interactionContexts = new Map<string, InteractionContextHandle>();
447
-
448
- private systemPrompt?: SystemPromptInput;
449
- /**
450
- * Resolved tool definitions visible to the LLM. Folds mutate this in place
451
- * (push/pop on open/close). When `toolDefinitionsFactory` is set, this is
452
- * overwritten each tool-loop iteration with the factory's output.
453
- */
454
- private toolDefinitions: ChatToolDefinition[];
455
- /**
456
- * Optional dynamic-tools source. When set, called each tool-loop iteration
457
- * to recompute `toolDefinitions` before the LLM call. `defineStatefulAgent`
458
- * forbids folds when this is set, so the fold-mutation path is unreachable
459
- * in that case.
460
- */
461
- private toolDefinitionsFactory?: (
462
- ctx: SystemPromptContext,
463
- ) => ChatToolDefinition[] | Promise<ChatToolDefinition[]>;
464
- /**
465
- * Resolved tool handler map used for dispatch. When `toolHandlersFactory` is
466
- * set, this is overwritten each tool-loop iteration with the factory's output
467
- * — keeping it in lockstep with `toolDefinitions` so handlers don't have to
468
- * defend themselves against being dispatched in states where their tool
469
- * isn't advertised. Folds mutate this in place; `defineStatefulAgent`
470
- * forbids folds when a factory is set, so the fold-mutation path is
471
- * unreachable in that case.
472
- */
473
- private toolHandlers: ChatToolHandlers;
474
- /**
475
- * Optional per-turn handler-map source. Mirrors `toolDefinitionsFactory` so
476
- * the LLM-visible tools and the dispatchable handlers can be narrowed in
477
- * lockstep. Resolved each tool-loop iteration before the LLM call.
478
- */
479
- private toolHandlersFactory?: (
480
- ctx: SystemPromptContext,
481
- ) => ChatToolHandlers | Promise<ChatToolHandlers>;
482
- private primerHistory?: ChatMessage[];
483
- private activeAgentName?: string;
484
- /**
485
- * Per-turn display label resolved from the agent's `displayName`. Stamped
486
- * onto outgoing messages and turn snapshots for UX; `activeAgentName` stays
487
- * stable for routing/history-transform identity matching.
488
- */
489
- private activeAgentLabel?: string;
490
- private displayName?: SystemPromptInput;
491
- /**
492
- * When set, `requestInteraction` delegates to this callback instead of using
493
- * this driver's own pending map. Wired by `invokeSubAgent` so a sub-agent's
494
- * widget renders in — and resolves through — the parent (ultimately the
495
- * root) driver, where the main UI is listening.
496
- */
497
- private hostInteractionRequester?: <T>(
498
- componentName: string,
499
- data: any,
500
- options?: InteractionRequestOptions,
501
- ) => Promise<T>;
502
- /**
503
- * When set (e.g. by OrchestratingDriver), applied only to the conversation slice
504
- * sent to the model — stored `history` stays unchanged for UI and logging.
505
- */
506
- private providerHistoryTransform?: (history: ChatMessage[]) => ChatMessage[];
507
- /**
508
- * Tool-declared condensation policies, keyed by tool-call id. Populated by
509
- * `condenseWhen` (first-wins per call); read by `applyCondensation` before each
510
- * provider call to collapse stale payloads from the model-bound history only.
511
- * Accumulates across agents on a shared driver (a superseded read collapses no
512
- * matter which agent made it) and is never cleared — it dies with the driver.
513
- */
514
- private readonly condensePolicies = new Map<string, RegisteredCondensePolicy>();
515
- /**
516
- * Monotonic model-call counter for the driver's whole lifetime — the age clock
517
- * for `condenseWhen({ on: { kind: 'age' } })`. The per-`sendMessage` `iterations`
518
- * loop counter resets to 0 every turn, so it can only measure age WITHIN a
519
- * single turn; this never resets, so `age` counts model-calls since the result
520
- * appeared across turn boundaries (each short turn still advances it ≥ 1).
521
- * Bumped once per tool-loop iteration (provider call).
522
- */
523
- private modelCallSeq = 0;
524
- /**
525
- * Monotonic turn counter — the `turnEnd` clock. Bumped once per `sendMessage`
526
- * (a user turn; NOT per handoff continuation, which is the same request), never
527
- * reset. `turnEnd` collapses a payload once `turnSeq` exceeds the turn it was
528
- * created in.
529
- */
530
- private turnSeq = 0;
531
- /**
532
- * Monotonic agent-activation counter — the `agentEnd` clock. Advances when the
533
- * active flow ends: a swap to a different-named agent (`applyAgent`) OR an
534
- * explicit `releaseAgent` / `completeSubAgent`. A stateful agent re-resolving
535
- * the same name across turns keeps one activation. `agentEnd` fires for a
536
- * payload once a LATER activation is current (`callActivation < currentActivation`)
537
- * — so a re-run of a released agent gets a fresh activation and is NOT
538
- * collapsed until IT ends.
539
- */
540
- private currentActivation = 0;
541
- /**
542
- * Monotonic phase-epoch counter — the `phaseEnd` clock. Advanced by the
543
- * handler-context `endPhase()` (an app-declared phase boundary within one
544
- * activation), never reset. Unlike `currentActivation` it is NOT tied to agent
545
- * swaps or releases — it ticks only when the agent itself declares a phase
546
- * done. `phaseEnd` fires for a payload once a LATER epoch is current
547
- * (`callPhaseEpoch < currentPhaseEpoch`), with an `agentEnd` backstop.
548
- */
549
- private currentPhaseEpoch = 0;
550
-
551
- /** Stack of fold frames — grows when a fold opens, shrinks when it closes. */
552
- private foldStack: FoldStackFrame[] = [];
553
- /** Consecutive fold open/close ops without a real tool call. Reset on real tool execution. */
554
- private consecutiveFoldOps = 0;
555
- /** Consecutive unknown-tool calls without a real tool call. Reset on real tool execution. */
556
- private consecutiveUnknownToolCalls = 0;
557
- /**
558
- * Distinct unknown-tool names seen in the current consecutive streak — stamped
559
- * onto the `unknown-tool-limit` turn.error so a triager knows which tools were
560
- * hallucinated. Reset alongside `consecutiveUnknownToolCalls`.
561
- */
562
- private readonly recentUnknownToolNames = new Set<string>();
563
- /**
564
- * Union of every tool name advertised at any point during the current agent
565
- * activation. Lets the unknown-tool path tell a *stale* call (a real tool from
566
- * an earlier state, now retired — or one an open exclusive fold is hiding)
567
- * apart from a *hallucinated* one. Reset on agent swap in `applyAgent`.
568
- */
569
- private readonly everSeenToolNames = new Set<string>();
570
- /**
571
- * Subset of the current unknown-tool streak that was stale (previously
572
- * available) rather than hallucinated — surfaced separately on the
573
- * `unknown-tool-limit` turn.error so triage can tell a state/prompt-design
574
- * problem from a model that's inventing tools. Reset alongside
575
- * `recentUnknownToolNames`.
576
- */
577
- private readonly recentStaleToolNames = new Set<string>();
578
- private readonly maxFoldOperations: number;
579
-
580
- /** Sub-agents declared on the active agent config, keyed by name. */
581
- private subAgentsMap: Map<string, AgentConfig> = new Map();
582
- /**
583
- * Set by `completeSubAgent` inside a sub-agent tool handler. Checked at the
584
- * same point in the loop as `REQUEST_CONTINUATION_TOOL` — after tool results
585
- * are appended — so the exit path mirrors the system-call pattern.
586
- * `undefined` means the loop has not been stopped early.
587
- */
588
- private subAgentCompletion: { result: unknown } | undefined;
589
- /**
590
- * True when this driver runs as a child sub-agent (created by a parent
591
- * driver's `invokeSubAgent`). Sub-agents force tool use every turn so a turn
592
- * can only end via their completion tool, and on any non-completion exit they
593
- * record a typed `SubAgentFailureReason` instead of appending a
594
- * user-facing message — the parent decides how to surface the failure.
595
- */
596
- private isSubAgent = false;
597
- /**
598
- * Set when a sub-agent's tool loop ends without `completeSubAgent` being
599
- * called. Read by the parent's `invokeSubAgent` to build the `{ ok: false }`
600
- * branch of `requestSubAgent`. Only ever set when `isSubAgent` is true.
601
- *
602
- * `budget` rides along on a `'budget_exhausted'` failure so the parent inherits
603
- * the child's ATTRIBUTION, not just the fact of a wall — see
604
- * `budgetWallDetail`.
605
- */
606
- private subAgentFailure:
607
- | {
608
- reason: SubAgentFailureReason;
609
- budget?: BudgetDetail;
610
- /**
611
- * The refusing vendor and kind, when a child hit the PROVIDER wall (GENC-1506).
612
- *
613
- * Carried for the same reason `budget` is: the child is a **separate driver instance**, so
614
- * nothing it sets on itself is visible to the parent. Without this the parent could report
615
- * `provider-refused` but not say which vendor or which kind — losing the only signal that
616
- * distinguishes "top up the account" from "rotate the key", since the user copy is cause-free.
617
- */
618
- providerRefused?: ProviderRefusedDetail;
619
- }
620
- | undefined;
621
- /**
622
- * Set by `releaseAgent` inside a top-level tool handler — typically a stateful
623
- * agent's terminal-state handler signalling that its flow is complete and the
624
- * auto-pin lock can release. Checked by the orchestrator after `sendMessage`
625
- * returns; the orchestrator fires `onDeactivate` and clears the pin.
626
- *
627
- * Reset at the start of each `sendMessage` so a release from a previous turn
628
- * doesn't leak forward.
629
- */
630
- private agentReleaseRequested = false;
631
- /**
632
- * Ring buffer of per-LLM-call snapshots. Cap is configurable via
633
- * `chatConfig.agent.maxTurnSnapshots`; older entries drop off as new ones
634
- * arrive. See {@link TurnSnapshot} for the captured shape.
635
- */
636
- private turnSnapshots: TurnSnapshot[] = [];
637
- /**
638
- * `turn` + `message` diagnostic-log entries harvested from an out-of-band source (a resolved
639
- * interaction whose widget returned `InteractionResult.diagnostics` — e.g. a server-side
640
- * ChatDriver's debug log). `event` entries are NOT kept here — they're folded into the session's
641
- * meta-event registry via `mergeMetaEvents` (see `resolveInteraction`) so they inherit fresh host
642
- * indices, and reach the log through `getMetaEvents`. Append-only; the host folds these into its
643
- * debug log alongside this driver's own timeline. Not cleared here — it lives with the driver
644
- * instance, like `turnSnapshots`, so a new chat (fresh driver) starts empty while a compaction
645
- * (same instance) keeps it. (GENC-1461.)
646
- */
647
- private readonly externalDiagnostics: DiagnosticEntry[] = [];
648
- /**
649
- * Monotonic count of external-diagnostics batches harvested this driver-lifetime. Namespaces each
650
- * batch's re-keyed `turn` entries so distinct server generations in one chat session can't collide
651
- * on `turnIndex` in the forward-capture persister (which dedups turns by that key). (GENC-1461.)
652
- */
653
- private externalDiagnosticsBatches = 0;
654
- /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
655
- private globalTurnIndex = 0;
656
- /** Captured from `applyAgent` so we don't store the whole `AgentConfig`. */
657
- private debugSnapshotter?: () => unknown;
658
- private readonly maxTurnSnapshots: number;
659
-
660
- /**
661
- * Aborted by `dispose()` on driver teardown (e.g. an agent-config swap).
662
- * Threaded into every provider call as `ChatRequestOptions.signal`, so a
663
- * disposed driver's in-flight LLM request is cancelled instead of running on
664
- * to completion or the transport timeout. Also passed to prompt/tool
665
- * factories via `SystemPromptContext.signal`.
666
- */
667
- private readonly lifecycleController = new AbortController();
668
-
669
- /**
670
- * Per-turn abort controller, reset at the start of every turn by `beginTurn`.
671
- * Aborted by `cancel()` (user stop) and chained to `lifecycleController` so a
672
- * driver dispose also ends the current turn. Its signal — not the lifecycle
673
- * one — is what reaches the provider call, so a turn can be cancelled without
674
- * bricking the driver for the next message.
675
- */
676
- private turnController = new AbortController();
677
- /** True when the current turn was stopped via `cancel()` (vs a dispose). Drives the "Stopped." marker. */
678
- private turnCancelled = false;
679
- /** Detaches the lifecycle→turn abort link at turn end; set by `beginTurn`. */
680
- private unlinkLifecycleFromTurn?: () => void;
681
-
682
- /**
683
- * Active agent's provider selector (static name or per-turn resolver).
684
- * `undefined` means "use the registry default".
685
- */
686
- private activeProviderInput?: ProviderInput;
687
- /**
688
- * Active agent's temperature selector (static number or per-turn resolver),
689
- * normalized to `0`–`1`. `undefined` means "use the provider/model default".
690
- */
691
- private activeTemperatureInput?: TemperatureInput;
692
- /**
693
- * Active agent's tool-call mode selector (static value or per-turn resolver).
694
- * `undefined` falls back to the per-turn default (sub-agents force a tool
695
- * call; top-level turns are `'auto'`).
696
- */
697
- private activeToolChoiceInput?: ToolChoiceInput;
698
- /**
699
- * Active agent's prompt-cache policy selector (static value or per-turn resolver).
700
- * `undefined` requests no caching (equivalent to `{ scope: 'default' }`).
701
- */
702
- private activeCachePolicyInput?: CachePolicyInput;
703
- /**
704
- * Active agent's extended-thinking selector (static value or per-turn resolver). `undefined` —
705
- * unset, or returned by the resolver — leaves the model on its own default posture, which is
706
- * neither uniformly on nor off, so an agent that never sets this is unaffected by the option.
707
- */
708
- private activeThinkingPolicyInput?: ThinkingPolicyInput;
709
- /**
710
- * Active agent's tail-context selector (static value or per-turn resolver). The driver frames
711
- * the resolved string in a `<system-reminder>` marker and injects it at the message tail.
712
- */
713
- private activeTailContextInput?: TailContextInput;
714
- /**
715
- * Active agent's structured-output schema selector (static value or per-turn resolver).
716
- * When it resolves to a schema, the model's final answer is constrained to it this turn.
717
- */
718
- private activeResponseSchemaInput?: ResponseSchemaInput;
719
- /**
720
- * Active agent's refusal-fallback chain (static). Passed through to the provider so a refused
721
- * turn (e.g. Fable 5) is re-run on the next model server-side.
722
- */
723
- private activeFallbacks?: ChatFallback[];
724
- /**
725
- * Active agent's unresolved-tool hook, captured from `applyAgent`. Consulted
726
- * only when a tool call cannot be dispatched (a stale or hallucinated name);
727
- * `undefined` keeps the framework's default messages. See
728
- * `resolveUnresolvedToolContent`.
729
- */
730
- private activeOnUnresolvedTool?: UnresolvedToolInput;
731
- /**
732
- * Caches validated provider lookups per name within the current agent. Cleared
733
- * by `applyAgent` so each new agent's static/function-resolved names are
734
- * validated fresh.
735
- */
736
- private resolvedProviderCache = new Map<string, AIProvider>();
737
- /** Last successfully resolved provider name — drives `getActiveProviderName`. */
738
- private lastResolvedProviderName?: string;
739
- /** Last dispatched `provider-changed` name; avoids duplicate events on stable turns. */
740
- private lastDispatchedProviderName?: string;
741
- /**
742
- * Concrete model id of the provider resolved for the current turn, read from
743
- * its `getStatus()` and stamped onto the resulting assistant message so the
744
- * debug log carries per-message model lineage. Re-resolved each turn in
745
- * `resolveProviderForTurn`.
746
- */
747
- private lastResolvedModel?: string;
748
- /**
749
- * Vendor id from the provider's `getStatus()` for the current turn (e.g.
750
- * `'anthropic'`, `'gemini'`). Stamped onto assistant messages alongside
751
- * `lastResolvedModel`.
752
- */
753
- private lastResolvedProvider?: AIProviderType;
754
- /**
755
- * Memoised `name → getStatus()` lookups, so per-turn resolution doesn't
756
- * re-await `getStatus()` every turn. Cleared (with `resolvedProviderCache`) on
757
- * an observable-registry change, so a model swapped behind a stable name is
758
- * picked up on the next turn.
759
- */
760
- private resolvedStatusCache = new Map<string, { model?: string; provider?: AIProviderType }>();
761
- /**
762
- * Unsubscribe handle for the provider-registry change listener (only set when
763
- * the injected registry is observable). Called in `dispose` so the long-lived
764
- * registry doesn't retain this driver — see the constructor and the sub-agent
765
- * teardown in `runSubAgent`.
766
- */
767
- private unsubscribeRegistry?: () => void;
768
-
769
- /** Hard cap on tool-loop iterations. */
770
- private readonly maxToolIterations: number;
771
- /** Model-calls per condensation batch; `1` collapses as soon as a trigger fires. */
772
- private readonly condenseBatchCalls: number;
773
- /** Session identity used to file meta events onto the shared debug-log timeline. */
774
- private readonly sessionKey: string;
775
- /** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
776
- private readonly activityBus: ActivityBus;
777
- /**
778
- * Host override for the budget-wall transcript copy, used verbatim — see
779
- * `ChatDriverConfig.budgetExhaustedMessage`. `undefined` means no override,
780
- * and the bubble is composed per wall by {@link ChatDriver.budgetExhaustedBubble}.
781
- */
782
- private readonly budgetExhaustedMessageOverride?: string;
783
-
784
- /**
785
- * Host override for the provider-refusal sentence, from
786
- * `ChatDriverConfig.providerRefusedMessage`. `undefined` means no override, so the shipped default
787
- * is used verbatim.
788
- */
789
- private readonly providerRefusedMessageOverride?: string;
790
-
791
- /**
792
- * A provider refusal was observed this turn, so the tool loop must not call the model again
793
- * (GENC-1506).
794
- *
795
- * Exists for the SUB-AGENT path only, exactly like `budgetExhaustedThisTurn`: this driver's own
796
- * refusal returns straight out of the catch, whereas a child's refusal reaches the parent as a tool
797
- * result, and without this flag the loop would issue another call into the same wall — N batched
798
- * children costing N doomed calls plus a doomed parent one.
799
- */
800
- private providerRefusedThisTurn = false;
801
-
802
- /** The refusal detail latched off a sub-agent's failure, for the parent's own result. */
803
- private providerRefusedDetail?: ProviderRefusedDetail;
804
- /**
805
- * Set the moment a budget wall is observed anywhere in this turn — this
806
- * driver's own 402, or a sub-agent's (which surfaces here only as a
807
- * `'budget_exhausted'` tool result). Read at the top of the tool loop to end
808
- * the turn before issuing another model call that would hit the same wall.
809
- * Reset per turn alongside the other per-turn counters.
810
- */
811
- private budgetExhaustedThisTurn = false;
812
- /**
813
- * The refusing vendor's own attribution for the wall `budgetExhaustedThisTurn`
814
- * records, when it was knowable. Kept SEPARATE from the flag rather than
815
- * replacing it: a figure-less 402 from a transport no vendor claims yields no
816
- * detail at all (`budgetDetailOf` returns `undefined`), and folding the two
817
- * would make that case stop ending the turn.
818
- *
819
- * It matters most for a sub-agent's wall. The child can sit on a different
820
- * vendor from its parent — `applyAgent` reads `config.provider` — so without
821
- * this the parent's short-circuit reports `lastResolvedProvider`, i.e. the one
822
- * vendor that did NOT refuse. Under a mixed registry that walls Gemini because
823
- * an Anthropic child 402'd, and if those are the only two reachable vendors the
824
- * host then derives `blocked` and locks a composer that still had headroom.
825
- */
826
- private budgetWallDetail?: BudgetDetail;
827
- /**
828
- * Whether this turn's budget wall came from a SUB-AGENT rather than this
829
- * driver's own request. Decides whether `lastResolvedProvider` is a valid
830
- * attribution fallback: for an own wall it is the refusing vendor, for a
831
- * child's wall it is the parent's vendor — the one known NOT to have refused.
832
- * Reset per turn alongside `budgetWallDetail`.
833
- */
834
- private budgetWallViaSubAgent = false;
835
-
836
- constructor(
837
- private readonly providerRegistry: AIProviderRegistry,
838
- config: ChatDriverConfig = {},
839
- ) {
840
- super();
841
- const {
842
- toolHandlers = {},
843
- toolDefinitions = [],
844
- systemPrompt,
845
- primerHistory,
846
- maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS,
847
- maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS,
848
- condenseBatchCalls = 1,
849
- maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
850
- sessionKey = '',
851
- activityBus = NOOP_ACTIVITY_BUS,
852
- budgetExhaustedMessage,
853
- providerRefusedMessage,
854
- } = config;
855
- this.maxToolIterations = maxToolIterations;
856
- this.condenseBatchCalls = condenseBatchCalls;
857
- this.sessionKey = sessionKey;
858
- this.activityBus = activityBus;
859
- this.budgetExhaustedMessageOverride = budgetExhaustedMessage;
860
- this.providerRefusedMessageOverride = providerRefusedMessage;
861
- if (typeof toolHandlers === 'function') {
862
- this.toolHandlersFactory = toolHandlers;
863
- this.toolHandlers = {};
864
- } else {
865
- this.toolHandlersFactory = undefined;
866
- this.toolHandlers = toolHandlers;
867
- }
868
- if (typeof toolDefinitions === 'function') {
869
- this.toolDefinitionsFactory = toolDefinitions;
870
- this.toolDefinitions = [];
871
- } else {
872
- this.toolDefinitionsFactory = undefined;
873
- this.toolDefinitions = toolDefinitions;
874
- }
875
- this.systemPrompt = systemPrompt;
876
- this.primerHistory = primerHistory;
877
- this.maxFoldOperations = maxFoldOperations;
878
- this.maxTurnSnapshots = maxTurnSnapshots;
879
- // Runtime provider switching: when the host registered an observable
880
- // registry, drop our memoised provider lookups whenever its mapping/default
881
- // changes so the next turn re-resolves against the new providers. Resetting
882
- // `lastDispatchedProviderName` forces the next `resolveProviderForTurn` to
883
- // re-emit `provider-changed` even when the resolved *name* is unchanged
884
- // (e.g. a tier name like 'high' kept, but the vendor underneath swapped) —
885
- // that's what lets status UI refresh on a same-name switch. Feature-detect
886
- // means immutable/empty registries are a no-op. Child sub-agent drivers get
887
- // the same registry and so subscribe here too; each cleans up in `dispose`.
888
- if (isObservableAIProviderRegistry(this.providerRegistry)) {
889
- this.unsubscribeRegistry = this.providerRegistry.subscribe(() => {
890
- this.resolvedProviderCache.clear();
891
- this.resolvedStatusCache.clear();
892
- this.lastResolvedProviderName = undefined;
893
- this.lastResolvedModel = undefined;
894
- this.lastResolvedProvider = undefined;
895
- this.lastDispatchedProviderName = undefined;
896
- });
897
- }
898
- }
899
-
900
- /**
901
- * Tear down the driver: aborts the lifecycle signal so any in-flight provider
902
- * request (and prompt/tool factories awaiting it) cancels instead of running
903
- * on to completion or the transport timeout. Called by the host on driver
904
- * swap and by `OrchestratingDriver.dispose()`. Idempotent.
905
- *
906
- * Deliberately does NOT settle `pendingInteractions` here: dispose is never
907
- * reached while one is pending. The only host-level dispose is the agent-swap
908
- * rebuild, which is guarded by `isBusy()` — a pending interaction keeps the
909
- * tool loop suspended (`busy === true`), so the swap is deferred until the
910
- * widget resolves. Sub-agent child disposes always have an empty pending map
911
- * (widgets park on the parent via `setHostInteractionRequester`). A pending
912
- * interaction is instead settled by `resolveInteraction` (user response or
913
- * timeout), which also stamps history and reverts the chat-input override.
914
- */
915
- dispose(): void {
916
- this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
917
- // Drop any live interaction contexts (GENC-1390). Normally empty at teardown
918
- // (a context is created and destroyed with its pending interaction, and dispose
919
- // is not reached mid-interaction); disposed defensively so a stray context can
920
- // never leak an open socket/timer.
921
- for (const handle of this.interactionContexts.values()) handle.dispose();
922
- this.interactionContexts.clear();
923
- // Detach from the provider registry so the long-lived registry doesn't pin
924
- // this driver (and its closure) after teardown. Guard-cleared so a second
925
- // dispose is a no-op, matching this method's idempotent contract.
926
- this.unsubscribeRegistry?.();
927
- this.unsubscribeRegistry = undefined;
928
- }
929
-
930
- /**
931
- * Stop the current turn (user "stop" button). Aborts the in-flight provider
932
- * request immediately; if a tool is mid-execution it runs to completion and
933
- * the loop bails at the next boundary (tools are atomic). No-op when idle.
934
- * The driver stays usable for the next message.
935
- */
936
- cancel(): void {
937
- if (!this.busy) return;
938
- this.turnCancelled = true;
939
- this.turnController.abort(new DOMException('Cancelled by user', 'AbortError'));
940
- }
941
-
942
- /**
943
- * Start a fresh per-turn abort scope. Chains `lifecycleController` into the
944
- * new `turnController` so a dispose mid-turn also aborts the request.
945
- */
946
- private beginTurn(): void {
947
- this.turnCancelled = false;
948
- this.turnController = new AbortController();
949
- const lifecycle = this.lifecycleController.signal;
950
- if (lifecycle.aborted) {
951
- this.turnController.abort(lifecycle.reason);
952
- this.unlinkLifecycleFromTurn = undefined;
953
- return;
954
- }
955
- const onDispose = () => this.turnController.abort(lifecycle.reason);
956
- lifecycle.addEventListener('abort', onDispose, { once: true });
957
- this.unlinkLifecycleFromTurn = () => lifecycle.removeEventListener('abort', onDispose);
958
- }
959
-
960
- /** Detach the lifecycle→turn link so a long-lived lifecycle signal doesn't accumulate listeners. */
961
- private endTurn(): void {
962
- this.unlinkLifecycleFromTurn?.();
963
- this.unlinkLifecycleFromTurn = undefined;
964
- }
965
-
966
- /**
967
- * Finish a turn whose signal aborted. A user cancel adds a subtle "Stopped."
968
- * marker; a dispose-driven abort stops quietly (the widget is gone and the
969
- * cached history would otherwise gain a stray marker on remount).
970
- */
971
- private completeAbortedTurn(): ChatDriverResult {
972
- if (this.turnCancelled) {
973
- logger.warn('ChatDriver: turn cancelled by user');
974
- this.appendToHistory({ role: 'system-event', content: 'Stopped.' });
975
- } else {
976
- logger.warn('ChatDriver: turn aborted (driver disposed)');
977
- }
978
- return { reason: 'done' };
979
- }
980
-
981
- /**
982
- * Build the `done` loop result, carrying the typed failure reason when the turn
983
- * bailed (PTC-0). The discriminant stays `'done'` either way — the same value a
984
- * clean turn returns — so consumers matching on `reason === 'done'` are unchanged;
985
- * `failureReason` is simply present on a failure and absent on success. Omitted
986
- * (rather than set to `undefined`) so a happy-path result stays byte-identical to
987
- * the historical `{ reason: 'done' }`.
988
- */
989
- private turnDone(
990
- failureReason?: TurnFailureReason,
991
- budget?: BudgetDetail,
992
- providerRefused?: ProviderRefusedDetail,
993
- ): ChatDriverResult {
994
- if (!failureReason) return { reason: 'done' };
995
- // Both payloads are omitted rather than set to `undefined`, so a failure that carries neither has
996
- // a shape unchanged for a consumer that structurally compares it.
997
- return {
998
- reason: 'done',
999
- failureReason,
1000
- ...(budget ? { budget } : {}),
1001
- ...(providerRefused ? { providerRefused } : {}),
1002
- };
1003
- }
1004
-
1005
- /**
1006
- * Terminal budget outcome for a wall hit **outside** the tool loop — today,
1007
- * `OrchestratingDriver`'s classification phase, which calls the provider
1008
- * directly and so never enters `runToolLoop`.
1009
- *
1010
- * Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
1011
- * the classify phase, and an unbalanced end would break start/end pairing for
1012
- * subscribers that rely on it. The driver **return value** is what reports this
1013
- * case — see `FoundationAiAssistant`'s latch, which reads both seams for
1014
- * exactly this reason.
1015
- *
1016
- * The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
1017
- * here so there is one copy of the log line, the debug-log entry, the
1018
- * transcript bubble and the result shape rather than two that can drift.
1019
- *
1020
- * @param pendingUserMessage - a user message that has NOT yet been appended,
1021
- * appended first so the answer does not end up replying to nothing. Only the
1022
- * classification seam passes it: `OrchestratingDriver` dispatches the user's
1023
- * text as an optimistic `history-updated` detail and leaves the real append
1024
- * to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
1025
- * the bubble below would re-dispatch a history the user's own message was
1026
- * never in, and it would vanish from the transcript on the next render. The
1027
- * in-loop caller has already appended it and passes nothing.
1028
- *
1029
- * @internal
1030
- */
1031
- private budgetExhaustedBubble(wall?: {
1032
- vendorLabel?: string;
1033
- otherVendorAvailable?: boolean;
1034
- }): string {
1035
- // A host override wins verbatim — the white-label contract on
1036
- // `ChatDriverConfig.budgetExhaustedMessage` (its copy may not even be
1037
- // English; composing onto it would mangle it).
1038
- if (this.budgetExhaustedMessageOverride != null) return this.budgetExhaustedMessageOverride;
1039
- // Only a POSITIVE `otherVendorAvailable` earns the switch advice. `false`
1040
- // means every other metered vendor is exhausted too, and `undefined` means
1041
- // an older proxy did not say — in both cases advertising a switch to a
1042
- // vendor that may itself be walled is worse than the neutral default. This
1043
- // mirrors the blocked banner's rule, and closes the gap where the banner
1044
- // said "switch" while the transcript bubble directly under it said only
1045
- // "contact your administrator" (GENC-1464 tester finding).
1046
- if (wall?.otherVendorAvailable !== true) return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1047
- // The label is display copy, nothing more — attribution (which vendor gets
1048
- // WALLED) stays `budgetDetailOf`'s job. The parameter is the shared shape of
1049
- // both call sites: the thrown `BudgetExhaustedError` on the direct path, and
1050
- // the latched `budgetWallDetail` on the sub-agent path.
1051
- const subject = wall?.vendorLabel
1052
- ? `${wall.vendorLabel}'s AI usage limit`
1053
- : 'your AI usage limit';
1054
- return `You've reached ${subject}. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.`;
1055
- }
1056
-
1057
- reportBudgetExhausted(
1058
- e: BudgetExhaustedError,
1059
- pendingUserMessage?: ChatMessage,
1060
- ): ChatDriverResult {
1061
- if (pendingUserMessage) this.appendToHistory(pendingUserMessage);
1062
- this.budgetExhaustedThisTurn = true;
1063
- logger.error('ChatDriver: AI budget exhausted', e);
1064
- recordTurnError(this.sessionKey, 'budget-exhausted', {
1065
- agent: this.activeAgentName,
1066
- provider: this.lastResolvedProviderName,
1067
- // The registry ALIAS (e.g. 'high') is what `provider` records; the vendor
1068
- // is the thing a per-vendor budget is actually scoped to, and it was known
1069
- // at both ends and discarded in the middle until now. Taken from the
1070
- // refusing transport's label first — this method also serves the
1071
- // classification seam, where `lastResolvedProvider` is the PREVIOUS turn's
1072
- // vendor (or nothing), because classify runs against the registry default.
1073
- vendor:
1074
- vendorTypeOfLabel(e.vendorLabel) ??
1075
- vendorTypeOfLabel(e.serverVendor) ??
1076
- this.lastResolvedProvider,
1077
- budgetUsd: e.budgetUsd,
1078
- spentUsd: e.spentUsd,
1079
- isSubAgent: this.isSubAgent,
1080
- });
1081
- this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedBubble(e) });
1082
- return this.turnDone('budget-exhausted', budgetDetailOf(e));
1083
- }
1084
-
1085
- /**
1086
- * The sentence shown when the upstream provider refuses (GENC-1506).
1087
- *
1088
- * Deliberately far simpler than `budgetExhaustedBubble`: no vendor name, no figures, no
1089
- * switch-provider advice, and no branching on `kind`. The copy is cause-free by design — a user who
1090
- * can see their own remaining spend must not be told about a limit, and we must not imply the bill
1091
- * has gone unpaid — so there is nothing here to compose. A host override wins verbatim.
1092
- */
1093
- private providerRefusedBubble(): string {
1094
- return this.providerRefusedMessageOverride ?? DEFAULT_PROVIDER_REFUSED_MESSAGE;
1095
- }
1096
-
1097
- /**
1098
- * Terminal provider-refusal outcome. Mirrors `reportBudgetExhausted` so the two walls behave
1099
- * identically from the caller's side, while keeping their diagnostics distinct.
1100
- *
1101
- * `kind` reaches the debug log and the result but never the transcript: it is what lets an operator
1102
- * tell "top up the account" from "rotate the key", and with cause-free user copy this is the only
1103
- * place that distinction survives.
1104
- */
1105
- private reportProviderRefused(e: ProviderRefusedError): ChatDriverResult {
1106
- const detail = providerRefusedDetailOf(e);
1107
- this.providerRefusedThisTurn = true;
1108
- this.providerRefusedDetail = detail;
1109
- logger.error('ChatDriver: provider refused the request', e);
1110
- recordTurnError(this.sessionKey, 'provider-refused', {
1111
- agent: this.activeAgentName,
1112
- provider: this.lastResolvedProviderName,
1113
- vendor: vendorTypeOfLabel(e.vendorLabel) ?? this.lastResolvedProvider,
1114
- kind: e.kind,
1115
- upstreamStatus: e.upstreamStatus,
1116
- upstreamType: e.upstreamType,
1117
- isSubAgent: this.isSubAgent,
1118
- });
1119
- this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
1120
- return this.turnDone('provider-refused', undefined, detail);
1121
- }
1122
-
1123
- /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
1124
- private static failureReasonOf(result: ChatDriverResult): TurnFailureReason | undefined {
1125
- return result.reason === 'done' ? result.failureReason : undefined;
1126
- }
1127
-
1128
- /**
1129
- * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
1130
- * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
1131
- * kept byte-identical so subscribers see exactly what they always have.
1132
- *
1133
- * A budget failure additionally carries `vendor` — the concrete vendor
1134
- * (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
1135
- * and used to discard. Optional and additive: a subscriber reading only
1136
- * `failureReason` is unaffected, a non-budget failure still emits the historical
1137
- * `{ failureReason }` with no `vendor` key, and the value is a plain string so
1138
- * the detail stays structured-cloneable for the cross-tab hop. It is the field a
1139
- * per-vendor budget model needs and the one that would be awkward to retrofit.
1140
- */
1141
- private loopEndDetail(result: ChatDriverResult):
1142
- | {
1143
- failureReason: TurnFailureReason;
1144
- vendor?: AIProviderType;
1145
- budget?: BudgetDetail;
1146
- }
1147
- | undefined {
1148
- const failureReason = ChatDriver.failureReasonOf(result);
1149
- if (!failureReason) return undefined;
1150
- if (failureReason !== 'budget-exhausted') {
1151
- return { failureReason };
1152
- }
1153
- // The figures ride the event, not just the return value: this publish happens in
1154
- // sendMessage's `finally`, so for an in-loop wall it reaches the host's latch FIRST
1155
- // and the return-value seam is then a no-op (the latch is idempotent). Omitted
1156
- // entirely — never set to undefined — when the proxy sent no figures.
1157
- const budget = result.reason === 'done' ? result.budget : undefined;
1158
- // Prefer the refusing transport's own attribution over the driver's
1159
- // last-resolved provider, for the same staleness reason as `budgetDetailOf`.
1160
- //
1161
- // The fallback is legitimate ONLY for this driver's own wall — its resolved
1162
- // provider IS the refuser then. When the wall came from a SUB-AGENT
1163
- // (`budgetWallViaSubAgent`), the refuser is the child's vendor, and on a
1164
- // mixed registry `lastResolvedProvider` is the one vendor known NOT to have
1165
- // refused; an unattributable child wall degrades to the vendor-agnostic
1166
- // event instead, which the host's latch handles fail-safe.
1167
- const vendor =
1168
- budget?.vendor ?? (this.budgetWallViaSubAgent ? undefined : this.lastResolvedProvider);
1169
- return {
1170
- failureReason,
1171
- ...(vendor ? { vendor } : {}),
1172
- ...(budget ? { budget } : {}),
1173
- };
1174
- }
1175
-
1176
- /**
1177
- * Swap in a new agent's configuration. Called by OrchestratingDriver before
1178
- * each specialist turn so the shared driver runs with the right tools and prompt.
1179
- */
1180
- applyAgent(config: AgentConfig): void {
1181
- // A real swap to a different agent begins a NEW activation — the prior agent's
1182
- // flow is over, so its `agentEnd`-tagged payloads become collapsible. Guarded
1183
- // on a name change so re-applying the same agent (e.g. a per-turn re-resolve)
1184
- // does not spuriously advance the clock and prematurely drop its payloads.
1185
- if (config.name !== this.activeAgentName) this.currentActivation += 1;
1186
- this.systemPrompt = config.systemPrompt;
1187
- if (typeof config.toolDefinitions === 'function') {
1188
- this.toolDefinitionsFactory = config.toolDefinitions;
1189
- // Cleared each turn by the factory in runToolLoop; empty is safe in the
1190
- // meantime (no LLM call happens before resolution).
1191
- this.toolDefinitions = [];
1192
- } else {
1193
- this.toolDefinitionsFactory = undefined;
1194
- this.toolDefinitions = config.toolDefinitions ?? [];
1195
- }
1196
- if (typeof config.toolHandlers === 'function') {
1197
- this.toolHandlersFactory = config.toolHandlers;
1198
- // Cleared each turn by the factory in runToolLoop; empty is safe in the
1199
- // meantime (no LLM call happens before resolution).
1200
- this.toolHandlers = {};
1201
- } else {
1202
- this.toolHandlersFactory = undefined;
1203
- this.toolHandlers = config.toolHandlers ?? {};
1204
- }
1205
- this.primerHistory = config.primerHistory;
1206
- this.activeAgentName = config.name;
1207
- this.displayName = config.displayName;
1208
- // Static string form resolves to a stable label up-front; the function
1209
- // form gets re-resolved each tool-loop iteration. Falls back to the
1210
- // canonical name when displayName is unset.
1211
- this.activeAgentLabel =
1212
- typeof config.displayName === 'string' ? config.displayName : config.name;
1213
- this.debugSnapshotter = config.getDebugSnapshot;
1214
- this.subAgentsMap = new Map((config.subAgents ?? []).map((s) => [s.name, s]));
1215
- this.activeProviderInput = config.provider;
1216
- this.activeTemperatureInput = config.temperature;
1217
- this.activeToolChoiceInput = config.toolChoice;
1218
- this.activeCachePolicyInput = config.cachePolicy;
1219
- this.activeThinkingPolicyInput = config.thinkingPolicy;
1220
- this.activeTailContextInput = config.tailContext;
1221
- this.activeResponseSchemaInput = config.responseSchema;
1222
- this.activeFallbacks = config.fallbacks;
1223
- this.activeOnUnresolvedTool = config.onUnresolvedTool;
1224
- this.resolvedProviderCache.clear();
1225
- this.lastResolvedProviderName = undefined;
1226
- // Static validation: resolve the name now so unknown-provider and missing-
1227
- // capability errors fire at agent swap rather than on the first LLM call.
1228
- // Function-form `provider` is validated lazily inside `resolveProviderForTurn`.
1229
- if (typeof config.provider === 'string') {
1230
- this.resolveProviderByName(config.provider, config.name);
1231
- }
1232
- // Reset fold state when agent changes — each specialist starts fresh
1233
- this.foldStack = [];
1234
- this.consecutiveFoldOps = 0;
1235
- // Forget the previous agent's tools — "previously available" is scoped to
1236
- // the current activation, so a stateful agent accumulates its tools across
1237
- // states while a swap to a different specialist starts clean.
1238
- this.everSeenToolNames.clear();
1239
- }
1240
-
1241
- /**
1242
- * Resolve the tool-result content for an unresolved tool call. Consults the
1243
- * active agent's `onUnresolvedTool` hook (if any) with the attempted tool
1244
- * name, the failure `kind`, and the currently dispatchable tools, and returns
1245
- * the hook's non-empty string. Falls back to `fallback` when no hook is set,
1246
- * the hook returns nothing/empty, or the hook throws — so a misbehaving hook
1247
- * can never break tool dispatch.
1248
- */
1249
- private async resolveUnresolvedToolContent(
1250
- toolName: string,
1251
- kind: 'stale' | 'unknown',
1252
- fallback: string,
1253
- ): Promise<string> {
1254
- if (typeof this.activeOnUnresolvedTool !== 'function') {
1255
- return fallback;
1256
- }
1257
- try {
1258
- const custom = await this.activeOnUnresolvedTool({
1259
- toolName,
1260
- kind,
1261
- availableTools: Object.keys(this.toolHandlers),
1262
- });
1263
- return typeof custom === 'string' && custom.trim().length > 0 ? custom : fallback;
1264
- } catch (e) {
1265
- logger.warn(
1266
- `ChatDriver: onUnresolvedTool threw for "${toolName}" — using default message`,
1267
- e,
1268
- );
1269
- return fallback;
1270
- }
1271
- }
1272
-
1273
- /**
1274
- * Returns the most recently resolved provider name. Falls back to the
1275
- * registry's default when no per-turn resolution has happened yet.
1276
- */
1277
- getActiveProviderName(): string {
1278
- return this.lastResolvedProviderName ?? this.providerRegistry.defaultName();
1279
- }
1280
-
1281
- /**
1282
- * Resolve a named provider against the registry. Cached per-agent so
1283
- * repeated lookups during one agent's lifetime don't re-validate.
1284
- * Validation lives in `resolveChatProvider`; this wrapper just adds the
1285
- * cache.
1286
- */
1287
- private resolveProviderByName(name: string, agentName: string): AIProvider {
1288
- const cached = this.resolvedProviderCache.get(name);
1289
- if (cached) return cached;
1290
- const provider = resolveChatProvider(this.providerRegistry, name, agentName);
1291
- this.resolvedProviderCache.set(name, provider);
1292
- return provider;
1293
- }
1294
-
1295
- /**
1296
- * Resolve the provider to use for the current turn. Walks the agent's
1297
- * `provider` selector (static or function form) or falls back to the
1298
- * registry default. Dispatches `provider-changed` when the resolved name
1299
- * differs from the last dispatched value.
1300
- */
1301
- private async resolveProviderForTurn(ctx: SystemPromptContext): Promise<AIProvider> {
1302
- let provider: AIProvider;
1303
- let resolvedName: string;
1304
- if (this.activeProviderInput === undefined) {
1305
- provider = this.providerRegistry.default();
1306
- resolvedName = this.providerRegistry.defaultName();
1307
- } else {
1308
- const name =
1309
- typeof this.activeProviderInput === 'function'
1310
- ? await this.activeProviderInput(ctx)
1311
- : this.activeProviderInput;
1312
- provider = this.resolveProviderByName(name, this.activeAgentName ?? '<unknown>');
1313
- resolvedName = name;
1314
- }
1315
- this.lastResolvedProviderName = resolvedName;
1316
- const status = await this.resolveStatusForProvider(resolvedName, provider);
1317
- this.lastResolvedModel = status.model;
1318
- this.lastResolvedProvider = status.provider;
1319
- if (resolvedName !== this.lastDispatchedProviderName) {
1320
- this.lastDispatchedProviderName = resolvedName;
1321
- recordMetaEvent(this.sessionKey, 'provider.selected', {
1322
- // `provider` is the registry SLOT (a tier name like 'high'), kept under that key
1323
- // for compatibility; `model` and `vendor` are what it resolved to. Recording all
1324
- // three is the difference between "the agent switched to its high tier" and
1325
- // knowing which model that actually was — a tier can be repointed mid-session,
1326
- // and a slot name alone cannot distinguish anthropic from gemini.
1327
- provider: resolvedName,
1328
- model: status.model,
1329
- vendor: status.provider,
1330
- agent: this.activeAgentName,
1331
- });
1332
- this.dispatchEvent(
1333
- new CustomEvent<{ name: string }>('provider-changed', { detail: { name: resolvedName } }),
1334
- );
1335
- }
1336
- return provider;
1337
- }
1338
-
1339
- /**
1340
- * Resolve model id and vendor from a provider via its optional `getStatus()`,
1341
- * memoised by registry name. Used to stamp `model` and `provider` onto outgoing
1342
- * messages. Best-effort: a provider without `getStatus`, a null status, or a
1343
- * throw all yield empty fields — attribution is diagnostic, never load-bearing.
1344
- */
1345
- private async resolveStatusForProvider(
1346
- name: string,
1347
- provider: AIProvider,
1348
- ): Promise<{ model?: string; provider?: AIProviderType }> {
1349
- if (this.resolvedStatusCache.has(name)) return this.resolvedStatusCache.get(name)!;
1350
- let status: { model?: string; provider?: AIProviderType } = {};
1351
- try {
1352
- const resolved = await provider.getStatus?.();
1353
- if (resolved) {
1354
- status = { model: resolved.model, provider: resolved.provider };
1355
- }
1356
- } catch {
1357
- status = {};
1358
- }
1359
- this.resolvedStatusCache.set(name, status);
1360
- return status;
1361
- }
1362
-
1363
- /**
1364
- * Resolve a per-turn config input that is either a static value or a function
1365
- * of the turn context — the value-or-resolver shape shared by `provider`,
1366
- * `temperature`, and `toolChoice`. Returns undefined when the input is unset.
1367
- */
1368
- private async resolveTurnInput<T>(
1369
- input: T | ((ctx: SystemPromptContext) => T | Promise<T>) | undefined,
1370
- ctx: SystemPromptContext,
1371
- ): Promise<T | undefined> {
1372
- if (input === undefined) return undefined;
1373
- return typeof input === 'function'
1374
- ? (input as (ctx: SystemPromptContext) => T | Promise<T>)(ctx)
1375
- : input;
1376
- }
1377
-
1378
- /**
1379
- * Returns the early-stop result set by `completeSubAgent`, if any.
1380
- * Called by a parent `ChatDriver` after running this instance as a sub-agent.
1381
- */
1382
- getSubAgentCompletion(): { result: unknown } | undefined {
1383
- return this.subAgentCompletion;
1384
- }
1385
-
1386
- /**
1387
- * Mark this driver as running as a sub-agent. Called by a parent driver's
1388
- * `invokeSubAgent` immediately after construction, before the first turn.
1389
- * Enables forced tool use and typed failure reporting (see `isSubAgent`).
1390
- */
1391
- markAsSubAgent(): void {
1392
- this.isSubAgent = true;
1393
- }
1394
-
1395
- /**
1396
- * Returns the typed failure recorded when a sub-agent run ended without
1397
- * `completeSubAgent`, if any. Called by a parent `ChatDriver` after running
1398
- * this instance as a sub-agent.
1399
- */
1400
- getSubAgentFailure():
1401
- | {
1402
- reason: SubAgentFailureReason;
1403
- budget?: BudgetDetail;
1404
- providerRefused?: ProviderRefusedDetail;
1405
- }
1406
- | undefined {
1407
- return this.subAgentFailure;
1408
- }
1409
-
1410
- /**
1411
- * Record a sub-agent failure reason (first one wins). No-op for top-level
1412
- * agents, so loop-exit sites can call it unconditionally. The parent reads
1413
- * this via `getSubAgentFailure()` and emits the `subagent.failed` meta event
1414
- * under its *own* session — see `invokeSubAgent`. (A child sub-agent runs
1415
- * under a separate session key, so recording here would orphan the event off
1416
- * the user-visible debug-log timeline.)
1417
- */
1418
- private failSubAgent(
1419
- reason: SubAgentFailureReason,
1420
- budget?: BudgetDetail,
1421
- providerRefused?: ProviderRefusedDetail,
1422
- ): void {
1423
- if (!this.isSubAgent || this.subAgentFailure) return;
1424
- // Each payload is omitted rather than set to `undefined` so a failure carrying neither has a shape
1425
- // unchanged for a structural comparison, matching `turnDone`.
1426
- this.subAgentFailure = {
1427
- reason,
1428
- ...(budget ? { budget } : {}),
1429
- ...(providerRefused ? { providerRefused } : {}),
1430
- };
1431
- }
1432
-
1433
- /**
1434
- * Returns true if `releaseAgent` was called during the most recent turn.
1435
- * Consumed by the orchestrator to trigger the auto-pin release path.
1436
- */
1437
- getAgentReleaseRequested(): boolean {
1438
- return this.agentReleaseRequested;
1439
- }
1440
-
1441
- /**
1442
- * Return the per-turn snapshots captured so far. Used by the host's debug
1443
- * log exporter to show what the LLM saw on each turn — system prompt, tool
1444
- * surface, and agent-supplied state (e.g. a machine snapshot).
1445
- *
1446
- * Ring-buffered at `MAX_TURN_SNAPSHOTS`; older entries are dropped.
1447
- */
1448
- getTurnSnapshots(): ReadonlyArray<TurnSnapshot> {
1449
- return this.turnSnapshots;
1450
- }
1451
-
1452
- /**
1453
- * The `turn` + `message` diagnostic-log entries harvested from resolved interactions'
1454
- * `InteractionResult.diagnostics` (e.g. a server-side ChatDriver's collated debug log). The host
1455
- * concatenates these into its own debug log so `downloadDebugLog`/the persisted diagnostics stream
1456
- * include external-engine logs; `event` entries are excluded here because they're merged into the
1457
- * session's meta-event registry instead (see `resolveInteraction`) and reach the log via
1458
- * `getMetaEvents`. Opaque, chronologically sorted by the host at assemble time. (GENC-1461.)
1459
- */
1460
- getExternalDiagnostics(): ReadonlyArray<DiagnosticEntry> {
1461
- return this.externalDiagnostics;
1462
- }
1463
-
1464
- /**
1465
- * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
1466
- * as `kind:'turn'` entries in the exported debug log. The child runs as a
1467
- * separate, discarded driver, so its snapshots would otherwise be lost. Each is
1468
- * re-labelled under the parent turn that activated the sub-agent: the child's
1469
- * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
1470
- * any already-forwarded grand-child labels (strings) have their leading segment
1471
- * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
1472
- *
1473
- * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
1474
- * their labels can repeat — `agentName` on each snapshot disambiguates them.
1475
- */
1476
- private forwardSubAgentSnapshots(childSnapshots: ReadonlyArray<TurnSnapshot>): void {
1477
- if (childSnapshots.length === 0) return;
1478
- // The activating parent turn = the most recent snapshot this driver recorded
1479
- // before entering the tool handler that invoked the sub-agent.
1480
- const parentTurn = Math.max(0, this.globalTurnIndex - 1);
1481
- const ownTurnLabel = new Map<string, string>();
1482
- let ownPos = 0;
1483
- for (const snap of childSnapshots) {
1484
- let turnIndex: string;
1485
- if (!snap.turnIndex.includes('-')) {
1486
- // The child's own turn (a bare counter) → number it under the parent turn.
1487
- ownPos += 1;
1488
- turnIndex = `${parentTurn}-${ownPos}`;
1489
- ownTurnLabel.set(snap.turnIndex, turnIndex);
1490
- } else {
1491
- // An already-forwarded grand-child label — remap its leading segment.
1492
- const [lead, ...rest] = snap.turnIndex.split('-');
1493
- const leadLabel = ownTurnLabel.get(lead) ?? `${parentTurn}-${lead}`;
1494
- turnIndex = [leadLabel, ...rest].join('-');
1495
- }
1496
- this.turnSnapshots.push({ ...snap, turnIndex });
1497
- }
1498
- while (this.turnSnapshots.length > this.maxTurnSnapshots) {
1499
- this.turnSnapshots.shift();
1500
- }
1501
- }
1502
-
1503
- /**
1504
- * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
1505
- * before each LLM call — that's the latest point where the prompt, tool
1506
- * surface, and agent state line up with what the model is about to see.
1507
- *
1508
- * Returns the pushed object so the caller can back-fill what only the response
1509
- * knows (`usage`). Mutating it after the fact is safe whether or not the ring
1510
- * buffer has since evicted it — an evicted snapshot is simply no longer exported.
1511
- */
1512
- private recordTurnSnapshot(
1513
- resolvedSystemPrompt: string | undefined,
1514
- temperature: number | undefined,
1515
- toolChoice: ChatToolChoice | undefined,
1516
- tailContext: string | undefined,
1517
- ): TurnSnapshot {
1518
- let agentSnapshot: unknown;
1519
- if (this.debugSnapshotter) {
1520
- try {
1521
- agentSnapshot = this.debugSnapshotter();
1522
- } catch (e) {
1523
- // A snapshotter throwing must not derail the LLM call — capture the
1524
- // error string in place of the snapshot so the export still shows
1525
- // *something* happened.
1526
- agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
1527
- }
1528
- }
1529
- const turnIndex = String(this.globalTurnIndex);
1530
- this.globalTurnIndex += 1;
1531
- const snapshot: TurnSnapshot = {
1532
- turnIndex,
1533
- timestamp: new Date().toISOString(),
1534
- agentName: this.activeAgentName,
1535
- agentLabel: this.activeAgentLabel,
1536
- systemPrompt: resolvedSystemPrompt,
1537
- tailContext,
1538
- toolNames: this.toolDefinitions.map((t) => t.name),
1539
- temperature,
1540
- toolChoice,
1541
- agentSnapshot,
1542
- };
1543
- this.turnSnapshots.push(snapshot);
1544
- if (this.turnSnapshots.length > this.maxTurnSnapshots) {
1545
- this.turnSnapshots.shift();
1546
- }
1547
- return snapshot;
1548
- }
1549
-
1550
- /**
1551
- * Optional transform applied to conversation history immediately before each LLM request.
1552
- * Cleared when `undefined`. Does not alter stored history.
1553
- */
1554
- setProviderHistoryTransform(transform?: (history: ChatMessage[]) => ChatMessage[]): void {
1555
- this.providerHistoryTransform = transform;
1556
- }
1557
-
1558
- getHistory(): ReadonlyArray<ChatMessage> {
1559
- return this.history;
1560
- }
1561
-
1562
- getRawHistory(): readonly ChatMessage[] {
1563
- return this.history;
1564
- }
1565
-
1566
- /** Returns the current fold stack names for debugging. */
1567
- getActiveFoldNames(): string[] {
1568
- return this.foldStack.map((f) => f.foldName);
1569
- }
1570
-
1571
- async getSuggestions(
1572
- history: ChatMessage[],
1573
- prompt: string,
1574
- count: number,
1575
- allAgentInfo?: AllAgentSummary[],
1576
- ): Promise<string[]> {
1577
- // Suggestions are an out-of-turn UI helper, not bound to any single agent —
1578
- // always run against the registry default. Best-effort: a default with no
1579
- // `prompt()` just means no suggestions, not a hard error.
1580
- const defaultProvider = this.providerRegistry.default();
1581
- if (!defaultProvider.prompt) {
1582
- logger.warn('ChatDriver: default AI provider does not implement prompt()');
1583
- return [];
1584
- }
1585
-
1586
- let agentContext = '';
1587
- let toolContext = '';
1588
-
1589
- if (allAgentInfo?.length) {
1590
- const agentDescriptions = allAgentInfo
1591
- .map((agent) => {
1592
- const tools = agent.tools.map((t) => t.name).join(', ');
1593
- return `- ${agent.name} (${agent.description}): ${tools ? `(Tools: ${tools})` : 'No tools'}`;
1594
- })
1595
- .join('\n');
1596
- agentContext = `The assistant has the following capabilities:\n${agentDescriptions}`;
1597
-
1598
- const allToolNames = allAgentInfo
1599
- .flatMap((agent) => agent.tools.map((t) => t.name))
1600
- .filter((value, index, self) => self.indexOf(value) === index)
1601
- .join(', ');
1602
- toolContext = allToolNames
1603
- ? `You have access to the following tools across all agents: ${allToolNames}.`
1604
- : '';
1605
- } else if (this.activeAgentName) {
1606
- const toolNames = this.toolDefinitions.map((tool) => tool.name).join(', ');
1607
- agentContext = `You are currently acting as the "${this.activeAgentName}" agent.`;
1608
- toolContext = toolNames ? `You have access to the following tools: ${toolNames}.` : '';
1609
- }
1610
-
1611
- // System prompt is intentionally short: it sets the role only. Concrete
1612
- // instructions live in the user message *after* the transcript so the
1613
- // immediately-preceding context for the model is "here is data, now do X"
1614
- // rather than "here is a dialogue, continue it". This matters for Anthropic
1615
- // — without it, Claude reads the transcript as an in-progress conversation
1616
- // and produces a normal assistant turn instead of a list of suggestions.
1617
- const systemPrompt =
1618
- 'You generate suggested prompts that a user could send to an AI assistant. ' +
1619
- 'You never continue the conversation or roleplay as the assistant.';
1620
-
1621
- const capabilitiesBlock =
1622
- agentContext || toolContext
1623
- ? `\n\n<capabilities>\n${[agentContext, toolContext].filter(Boolean).join('\n')}\n</capabilities>\nSuggestions must only cover what the agent is capable of. Do not suggest anything outside these capabilities.`
1624
- : '';
1625
-
1626
- const guidanceBlock = prompt ? `\n\nAdditional guidance: "${prompt}"` : '';
1627
-
1628
- let userMessage: string;
1629
- if (history.length === 0) {
1630
- userMessage =
1631
- `Generate exactly ${count} brief starter prompts to show a user what an AI assistant can do. ` +
1632
- `Phrase each one as the user would write it. Keep them short and generic — do not invent specific names, IDs, or data (e.g. prefer "Search for a trade" over "Find all trades with Client A").` +
1633
- capabilitiesBlock +
1634
- guidanceBlock +
1635
- `\n\nOutput format (strict):\n` +
1636
- `- Exactly ${count} lines.\n` +
1637
- `- One suggestion per line.\n` +
1638
- `- No numbering, bullets, markdown, emojis, code blocks, or quotes around the suggestion.\n` +
1639
- `- No preamble, headings, summary, or commentary before or after the list.`;
1640
- } else {
1641
- const conversationContext = history
1642
- .filter(
1643
- (m) =>
1644
- (m.role === 'user' || m.role === 'assistant') &&
1645
- !m.toolCalls?.length &&
1646
- !m.thinking &&
1647
- !!m.content?.trim(),
1648
- )
1649
- .slice(-SUGGESTIONS_HISTORY_WINDOW)
1650
- .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
1651
- .join('\n');
1652
-
1653
- userMessage =
1654
- `<conversation_history>\n${conversationContext}\n</conversation_history>\n\n` +
1655
- `The conversation above has ended. Do not continue it or respond as the assistant. ` +
1656
- `Generate exactly ${count} follow-up prompts the *user* might send next, phrased in first person as the user would write them. ` +
1657
- `The first ${Math.max(0, count - 1)} should be natural follow-ups to the last turn. ` +
1658
- `Do not invent specific names, IDs, or data values that do not appear in <conversation_history>.` +
1659
- capabilitiesBlock +
1660
- guidanceBlock +
1661
- `\n\nOutput format (strict):\n` +
1662
- `- Exactly ${count} lines.\n` +
1663
- `- One suggestion per line.\n` +
1664
- `- No numbering, bullets, markdown, emojis, code blocks, or quotes around the suggestion.\n` +
1665
- `- No preamble, headings, summary, or commentary before or after the list.`;
1666
- }
1667
-
1668
- const text = await defaultProvider.prompt!(userMessage, { systemPrompt });
1669
-
1670
- // Lenient parsing as a defensive backstop: even with the strict prompt,
1671
- // models occasionally slip in numbering, bullets, or surrounding markdown.
1672
- return (
1673
- text
1674
- .split('\n')
1675
- .map((s) => s.trim())
1676
- // strip leading bullets/numbers ("- ", "* ", "• ", "1. ", "1) ")
1677
- .map((s) => s.replace(/^([-*•]|\d+[.)])\s+/, ''))
1678
- // strip surrounding bold/italic markers
1679
- .map((s) => s.replace(/^[*_]{1,3}|[*_]{1,3}$/g, '').trim())
1680
- // drop empty lines, separators, and obvious preamble lines
1681
- .filter((s) => s.length > 0)
1682
- .filter((s) => !/^[-=]{2,}$/.test(s))
1683
- .filter((s) => !/^here (are|is) /i.test(s))
1684
- .filter((s) => !s.endsWith(':'))
1685
- .slice(0, count)
1686
- );
1687
- }
1688
-
1689
- /**
1690
- * Whether `compact()` would do anything right now — a summarizable run of older
1691
- * turns exists behind a clean boundary. Uses the same `history` `compact()`
1692
- * acts on, so the UI's gate can't disagree with the action (GENC-1351 follow-up).
1693
- */
1694
- canCompact(): boolean {
1695
- return findCompactionCut(this.history) != null;
1696
- }
1697
-
1698
- /**
1699
- * Destructively compact older turns into a single `compacted-summary` message
1700
- * (GENC-1351 §5.7). Summarizes everything before a clean recent-tail boundary
1701
- * via the registry-default provider's one-shot `prompt()`, then **atomically**
1702
- * replaces the covered turns with one summary message at the head — history is
1703
- * mutated only on success. Rolling: any existing head summary is folded into
1704
- * the new one. Returns the created summary message, or `null` when there is
1705
- * nothing worth compacting or the default provider cannot summarize.
1706
- */
1707
- async compact(): Promise<ChatMessage | null> {
1708
- if (this.busy) return null;
1709
- const defaultProvider = this.providerRegistry.default();
1710
- if (!defaultProvider.prompt) {
1711
- logger.warn('ChatDriver: default AI provider has no prompt(); cannot compact');
1712
- return null;
1713
- }
1714
-
1715
- const history = this.history;
1716
- const cut = findCompactionCut(history);
1717
- if (cut == null) return null;
1718
-
1719
- const toCompact = history.slice(0, cut);
1720
- const tail = history.slice(cut);
1721
-
1722
- // Summarize BEFORE mutating history — a failed/empty call leaves the
1723
- // transcript untouched (atomic generate-then-replace).
1724
- const { systemPrompt, userMessage } = buildCompactionSummaryPrompt(toCompact);
1725
- // Surface a failure the way turn errors are surfaced — a brief "sorry" agent
1726
- // message — while leaving the transcript itself untouched (atomic). Retrying
1727
- // is safe (nothing was spliced).
1728
- const failMessage =
1729
- "Sorry, I couldn't compact the conversation just now. Nothing was changed — please try again.";
1730
- let summaryText: string;
1731
- try {
1732
- summaryText = (await defaultProvider.prompt!(userMessage, { systemPrompt }))?.trim() ?? '';
1733
- } catch (e) {
1734
- logger.warn(`ChatDriver: compaction summary failed: ${e instanceof Error ? e.message : e}`);
1735
- this.appendToHistory({ role: 'assistant', content: failMessage });
1736
- return null;
1737
- }
1738
- if (!summaryText) {
1739
- logger.warn('ChatDriver: compaction summary was empty; leaving history untouched');
1740
- this.appendToHistory({ role: 'assistant', content: failMessage });
1741
- return null;
1742
- }
1743
-
1744
- let tokensBefore: number | undefined;
1745
- for (let i = history.length - 1; i >= 0; i -= 1) {
1746
- if (history[i].inputTokens != null) {
1747
- tokensBefore = history[i].inputTokens;
1748
- break;
1749
- }
1750
- }
1751
- const status = await defaultProvider.getStatus?.();
1752
- const createdAt = new Date().toISOString();
1753
- const summaryMessage: ChatMessage = {
1754
- role: 'compacted-summary',
1755
- content: summaryText,
1756
- // Stamp with the covered region's start, not "now": the summary stands in
1757
- // for the oldest messages and sits at the head of history, so this keeps it
1758
- // sorted to the head in any timestamp-ordered view (the debug timeline) and
1759
- // history time-monotonic. Its true creation time is in compaction.createdAt.
1760
- timestamp: toCompact[0]?.timestamp ?? createdAt,
1761
- compaction: {
1762
- compactedCount: toCompact.length,
1763
- coveredThroughTimestamp: toCompact[toCompact.length - 1]?.timestamp,
1764
- tokensBefore,
1765
- model: status?.model,
1766
- createdAt,
1767
- // Bank what the replaced turns spent. `toCompact` is dropped from history
1768
- // below, taking its per-message cost/token fields with it — so a host total
1769
- // re-derived from the transcript would otherwise fall by exactly this much
1770
- // on every compaction. Summarising a turn does not refund what it cost.
1771
- rolledUpUsage: sumUsage(toCompact),
1772
- },
1773
- };
1774
-
1775
- this.loadHistory([summaryMessage, ...tail]);
1776
- return summaryMessage;
1777
- }
1778
-
1779
- isBusy(): boolean {
1780
- return this.busy;
1781
- }
1782
-
1783
- /**
1784
- * Wire a parent driver as the host for this driver's interactions. When set,
1785
- * `requestInteraction` delegates upward so the widget renders in (and
1786
- * resolves through) the parent's history and pending map. Calls chain
1787
- * naturally: a grandchild → child → root.
1788
- */
1789
- public setHostInteractionRequester(
1790
- fn: <T>(componentName: string, data: any, options?: InteractionRequestOptions) => Promise<T>,
1791
- ): void {
1792
- this.hostInteractionRequester = fn;
1793
- }
1794
-
1795
- /**
1796
- * Request a custom UI interaction. Emits a new message with the interaction.
1797
- * Tool handlers can call this to pause execution until the user completes the UI interaction.
1798
- *
1799
- * If a host requester is wired (sub-agent case), the call delegates upward
1800
- * so the interaction lives on the parent — the main UI is only listening to
1801
- * the root driver. Only one interaction may be in flight at any time on a
1802
- * given root: concurrent calls (e.g. two parallel sub-agents both spawning a
1803
- * widget) throw. Parallel sub-agents are for parallel work, not for user
1804
- * interaction, which is inherently sequential.
1805
- *
1806
- * @param componentName - The custom element name to render.
1807
- * @param data - Data to pass to the component.
1808
- * @param options - Optional per-call overrides, including
1809
- * `chatInputDuringExecution` to hide or disable the main chat input while
1810
- * the widget is awaiting user input (reverts when the interaction resolves),
1811
- * and `presentation` to control whether the host wraps the widget in a chat
1812
- * bubble and/or shows the "Assistant" label.
1813
- */
1814
- public async requestInteraction<T>(
1815
- componentName: string,
1816
- data: any,
1817
- options?: InteractionRequestOptions,
1818
- ): Promise<T> {
1819
- if (this.hostInteractionRequester) {
1820
- return this.hostInteractionRequester<T>(componentName, data, options);
1821
- }
1822
- if (this.pendingInteractions.size > 0) {
1823
- throw new Error(
1824
- 'requestInteraction: another user interaction is already in flight. ' +
1825
- 'Only one interaction may be active at a time — sequence them in a single tool handler ' +
1826
- 'rather than spawning widgets from parallel sub-agents or parallel tool calls.',
1827
- );
1828
- }
1829
- const interactionId = crypto.randomUUID();
1830
- const chatInputDuringExecution = options?.chatInputDuringExecution;
1831
- const timeoutMs = options?.timeoutMs;
1832
- const presentation = options?.presentation;
1833
- return new Promise((resolve, reject) => {
1834
- this.pendingInteractions.set(interactionId, {
1835
- resolve,
1836
- reject,
1837
- overrideId: chatInputDuringExecution ? interactionId : undefined,
1838
- // Resolve the interaction with a timeout result if the user does not
1839
- // respond in time. `resolveInteraction` runs the same teardown a normal
1840
- // resolution does (marks the widget read-only, reverts any input
1841
- // override) and clears any still-pending handle.
1842
- timeoutHandle:
1843
- timeoutMs != null
1844
- ? setTimeout(() => {
1845
- this.resolveInteraction(interactionId, {
1846
- status: 'timeout',
1847
- message: `Interaction timed out after ${timeoutMs}ms.`,
1848
- } satisfies InteractionResult);
1849
- }, timeoutMs)
1850
- : undefined,
1851
- });
1852
- recordMetaEvent(this.sessionKey, 'interaction.requested', {
1853
- interactionId,
1854
- component: componentName,
1855
- agent: this.activeAgentName,
1856
- });
1857
- // Signal the park boundary on the activity bus so turn-aware UI can tell
1858
- // "actively computing" from "parked awaiting the user" — the latter is a
1859
- // safe window for actions disallowed mid-request (e.g. switching provider
1860
- // during a long journey step). Paired with `interaction-resolved`.
1861
- this.activityBus.publish('interaction-requested', undefined);
1862
- if (chatInputDuringExecution) {
1863
- this.dispatchEvent(
1864
- new CustomEvent('interaction-start', {
1865
- detail: { interactionId, chatInputDuringExecution },
1866
- }),
1867
- );
1868
- }
1869
- // Create the live context (GENC-1390) BEFORE the widget can mount, so the
1870
- // wrapper finds it when it builds the pending `interaction` descriptor.
1871
- this.interactionContexts.set(interactionId, createInteractionContext(interactionId));
1872
- this.appendToHistory({
1873
- role: 'assistant',
1874
- content: '',
1875
- interaction: {
1876
- interactionId,
1877
- componentName,
1878
- data,
1879
- ...(presentation ? { presentation } : {}),
1880
- },
1881
- });
1882
- });
1883
- }
1884
-
1885
- /**
1886
- * Add external (non-LLM) spend to an interaction's message AFTER it has resolved.
1887
- *
1888
- * **Why this exists separately from `resolveInteraction`.** A widget's spend reaches the session
1889
- * total only through `InteractionResult.costUsd`, which is read exactly once — `resolveInteraction`
1890
- * does its whole job inside `if (interaction)` and then deletes the entry from
1891
- * `pendingInteractions`. That is correct for the resolution itself, but it assumes a widget stops
1892
- * costing money when it resolves, and several deliberately do not: a widget that resolves EARLY so
1893
- * the assistant stops waiting (options shown, a preview rendered) may stay interactive for minutes
1894
- * afterwards and do more paid work on the user's behalf. Re-emitting `interaction-completed` does
1895
- * not help — with the interaction gone from the map the call is a silent no-op — so that spend had
1896
- * nowhere to go and was simply missing from the figure shown to the user.
1897
- *
1898
- * Additive by design: the caller reports a DELTA, not a running total, so repeated calls sum. It
1899
- * touches only `externalCostUsd`; the recorded `interaction.resolved` payload is left exactly as
1900
- * the resolution wrote it, because this is not a second resolution and must not read like one.
1901
- *
1902
- * Deliberately does NOT require the interaction to be pending, does not resurrect it, and has no
1903
- * loading/turn side effects — this is bookkeeping about a turn that already happened.
1904
- *
1905
- * @param interactionId the interaction whose message owns the spend.
1906
- * @param costUsd a positive delta in USD. Non-finite, zero and negative values are ignored.
1907
- * @returns `true` when the spend was recorded; `false` when it was not — either the value was
1908
- * not a positive finite amount, or the message is no longer in history (trimmed or condensed
1909
- * away). A real outcome a caller may want to log rather than a failure to throw on; every
1910
- * `false` also records an `interaction.external-cost-dropped` meta event here, so money can
1911
- * never disappear without a trace.
1912
- * @beta
1913
- */
1914
- public recordExternalCost(interactionId: string, costUsd: number): boolean {
1915
- const drop = (reason: string): false => {
1916
- recordMetaEvent(this.sessionKey, 'interaction.external-cost-dropped', {
1917
- interactionId,
1918
- costUsd,
1919
- reason,
1920
- });
1921
- return false;
1922
- };
1923
- if (!interactionId) return drop('no interactionId');
1924
- if (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd <= 0) {
1925
- return drop('not a positive finite amount');
1926
- }
1927
- const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
1928
- // The message is gone (trimmed or condensed): the spend is real and already billed, so it is
1929
- // recorded as dropped rather than silently discarded — an invisible gap in the session total
1930
- // is exactly what this whole channel exists to close.
1931
- if (idx === -1) return drop('interaction message no longer in history');
1932
- this.history[idx] = {
1933
- ...this.history[idx],
1934
- externalCostUsd: (this.history[idx].externalCostUsd ?? 0) + costUsd,
1935
- };
1936
- recordMetaEvent(this.sessionKey, 'interaction.external-cost', { interactionId, costUsd });
1937
- // A NARROW event, deliberately not `history-updated`.
1938
- //
1939
- // The host answers `history-updated` by reassigning `messages`, which re-evaluates
1940
- // `activeInteractionRow` — and the trailing interaction slot renders through a
1941
- // `repeat(..., { recycle: false })`, so every view in it is disposed and rebuilt regardless of
1942
- // item identity. That is correct for a PENDING interaction (GENC-1429: a recycled wrapper
1943
- // carries the previous interaction's truthy `resolved` and swallows the next completion), and
1944
- // survivable because `buildInteraction` hands a remounted pending widget the same driver-owned
1945
- // `interactionContext`.
1946
- //
1947
- // A RESOLVED one has no such context — `resolveInteraction` disposed and deleted it — so
1948
- // announcing this on `history-updated` would tear down and blank the very widget that just
1949
- // reported, in exactly the flow this channel exists for (a user tweaking an option after the
1950
- // interaction resolved). Bookkeeping about a finished turn must stay invisible, so the host
1951
- // recomputes its totals from this driver's history and saves, without touching what is
1952
- // rendered. Reviewer catch on #2474 — confirmed against the layout widget, whose tweak result
1953
- // went off screen because the rebuild raced the session write that would have restored it.
1954
- this.dispatchEvent(
1955
- new CustomEvent<{ interactionId: string; costUsd: number }>('external-cost-recorded', {
1956
- detail: { interactionId, costUsd },
1957
- }),
1958
- );
1959
- return true;
1960
- }
1961
-
1962
- /**
1963
- * Resolve a pending interaction. The wrapper component calls this on completion.
1964
- * Marks the interaction message as resolved so it renders read-only on re-render.
1965
- */
1966
- public resolveInteraction(interactionId: string, result: any): void {
1967
- const interaction = this.pendingInteractions.get(interactionId);
1968
- if (interaction) {
1969
- // Clear the timeout so a user resolution doesn't later fire a stale
1970
- // timeout (and a timeout firing here clears its own already-fired handle).
1971
- if (interaction.timeoutHandle) clearTimeout(interaction.timeoutHandle);
1972
- // Harvest any out-of-band diagnostic entries the widget collated (e.g. from a server-side
1973
- // ChatDriver) so the host's debug log + persisted diagnostics include them. Independent of the
1974
- // message `idx` below — they describe the widget's own work, not this message.
1975
- //
1976
- // The harvested entries carry the SOURCE engine's own index space (0-based turns/events), which
1977
- // would collide with this host's cursors in the forward-capture persister — turns dedup by
1978
- // `turnIndex`, events by an `index` high-water mark — so foreign entries get silently dropped
1979
- // from the persisted stream. Route each kind through the host's own collision-safe machinery,
1980
- // mirroring how a sub-agent's log is folded in (see `invokeSubAgent`):
1981
- // • `event` → `mergeMetaEvents`, which re-indexes them onto this session's monotonic event
1982
- // counter (clearing the high-water) while preserving their timestamps; they then
1983
- // reach the log via `getMetaEvents` in `buildTimelineEntries`, not this buffer.
1984
- // • `turn` → re-key `turnIndex` to the turn's local position within a per-batch namespace,
1985
- // so turns stay unique even when one interaction folds in several independent
1986
- // server runs that each number their own turns from 0 (a consolidator carries a
1987
- // consolidator-code run AND a table-code run), and never collide with the host's
1988
- // own bare-integer turn keys.
1989
- // • `message` → kept as-is; the persister already keys messages by content, so they survive.
1990
- // TODO(GENC-1461, option 2 / streaming): expose an imperative `appendDiagnostics(entries)` on
1991
- // the handler context (see buildHandlerContext) that runs this same decomposition as SSE ticks
1992
- // arrive, so a streaming widget can surface its external engine's log live rather than only at
1993
- // resolve time. Batch-at-resolve (here) is the current, non-streaming path.
1994
- const harvestedDiagnostics = (result as { diagnostics?: readonly unknown[] } | undefined)
1995
- ?.diagnostics;
1996
- if (Array.isArray(harvestedDiagnostics) && harvestedDiagnostics.length) {
1997
- const batch = (this.externalDiagnosticsBatches += 1);
1998
- const externalEvents: MetaEvent[] = [];
1999
- let turnCount = 0;
2000
- let messageCount = 0;
2001
- for (const raw of harvestedDiagnostics) {
2002
- // The harvest is external and typed `unknown[]`, so an element could be a primitive, null,
2003
- // or an object without a string `kind`. Guard before reading `.kind` so a malformed entry
2004
- // can't throw here (and gets skipped rather than derailing the whole fold).
2005
- if (
2006
- !raw ||
2007
- typeof raw !== 'object' ||
2008
- typeof (raw as { kind?: unknown }).kind !== 'string'
2009
- ) {
2010
- continue;
2011
- }
2012
- const entry = raw as DiagnosticEntry;
2013
- if (entry.kind === 'event') {
2014
- const { kind: _kind, ...event } = entry;
2015
- externalEvents.push(event as unknown as MetaEvent);
2016
- } else if (entry.kind === 'turn') {
2017
- // Local position within THIS harvest, not the source engine's own `turnIndex`: one
2018
- // interaction can carry several independent server runs (a consolidator folds in its
2019
- // consolidator-code run and its table-code run), each numbering its turns from 0, so
2020
- // keying by the source index would collide them. The local position keeps every turn key
2021
- // distinct within the batch, so the persister's per-`turnIndex` dedup keeps them all.
2022
- this.externalDiagnostics.push({
2023
- ...entry,
2024
- turnIndex: `server-generation.${batch}:${turnCount}`,
2025
- });
2026
- turnCount += 1;
2027
- } else if (entry.kind === 'message') {
2028
- messageCount += 1;
2029
- this.externalDiagnostics.push(entry);
2030
- }
2031
- // Any other kind is intentionally dropped, not folded. A sibling's `buildTimelineEntries`
2032
- // only ever emits message/turn/event; `meta-snapshot` is host/DOM-owned (latest-wins on
2033
- // reassembly, plus its own persister dedup), so folding a foreign one would pollute this
2034
- // host's timeline and meta cursor. Restricting to the three real kinds also keeps the
2035
- // manifest counts accurate (nothing miscounted as a `message`).
2036
- }
2037
- if (externalEvents.length) mergeMetaEvents(this.sessionKey, externalEvents);
2038
- // Manifest: one provenance marker for the fold itself — records that an out-of-band engine's
2039
- // log crossed into this session and how much of each kind, so a regression that silently
2040
- // drops a generation's diagnostics (e.g. a new path that forgets to thread them across the
2041
- // boundary) surfaces as a zero/absent manifest rather than only by diffing a download. Also
2042
- // the natural seed for the streaming PR's open/close bracket. Stamped at resolve, so it
2043
- // closes the block on the sorted timeline. (GENC-1461.)
2044
- recordMetaEvent(this.sessionKey, 'external-diagnostics.folded', {
2045
- interactionId,
2046
- batch,
2047
- counts: { turn: turnCount, event: externalEvents.length, message: messageCount },
2048
- });
2049
- }
2050
- const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
2051
- if (idx !== -1) {
2052
- // Fold any widget-reported external (non-LLM) cost onto the message, so
2053
- // the host's `sumCosts` recompute includes it in the session total.
2054
- // Accumulates defensively in case a widget reports more than once.
2055
- const costUsd =
2056
- typeof result?.costUsd === 'number' &&
2057
- Number.isFinite(result.costUsd) &&
2058
- result.costUsd > 0
2059
- ? result.costUsd
2060
- : undefined;
2061
- this.history[idx] = {
2062
- ...this.history[idx],
2063
- interaction: { ...this.history[idx].interaction!, resolved: result },
2064
- ...(costUsd != null
2065
- ? { externalCostUsd: (this.history[idx].externalCostUsd ?? 0) + costUsd }
2066
- : {}),
2067
- };
2068
- this.dispatchEvent(
2069
- new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
2070
- detail: this.history,
2071
- }),
2072
- );
2073
- }
2074
- // A widget may opt into echoing its outcome on the user's side of the
2075
- // chat by returning `userMessage`. Appended as a display-only
2076
- // `'synthetic-user'` message: it is tracked in history (and the debug log)
2077
- // and rendered like a user message, but transports never serialize it —
2078
- // the model already learns the outcome from this handler's return value.
2079
- // Appended before `interaction.resolve` so it precedes the tool result and
2080
- // any follow-up turn the resumed handler produces.
2081
- if (typeof result?.userMessage === 'string' && result.userMessage.trim()) {
2082
- this.appendToHistory({ role: 'synthetic-user', content: result.userMessage });
2083
- }
2084
- if (interaction.overrideId) {
2085
- this.dispatchEvent(new CustomEvent('interaction-stop', { detail: { interactionId } }));
2086
- }
2087
- recordMetaEvent(this.sessionKey, 'interaction.resolved', { interactionId });
2088
- // The park is ending and the loop is about to resume computing — paired
2089
- // with `interaction-requested`. Fires for every resolution path (user
2090
- // completion, timeout, cancellation), since all route through here.
2091
- this.activityBus.publish('interaction-resolved', undefined);
2092
- interaction.resolve(result);
2093
- this.pendingInteractions.delete(interactionId);
2094
- // Tear down the live context on RESOLVE (not on element unmount) — this closes
2095
- // any sockets/timers the widget parked on it via `onDispose`. (GENC-1390)
2096
- this.interactionContexts.get(interactionId)?.dispose();
2097
- this.interactionContexts.delete(interactionId);
2098
- } else {
2099
- logger.warn(`Interaction with ID ${interactionId} not found.`);
2100
- }
2101
- }
2102
-
2103
- /**
2104
- * The live {@link InteractionContext} for a pending interaction (GENC-1390), or
2105
- * `undefined` once resolved/evicted. Read by `AiChatInteractionWrapper` (via the
2106
- * host) to build the widget's pending `interaction` descriptor.
2107
- */
2108
- public getInteractionContext(interactionId: string): InteractionContext | undefined {
2109
- return this.interactionContexts.get(interactionId)?.context;
2110
- }
2111
-
2112
- /**
2113
- * Resolve every in-flight interaction with a `'timeout'` result, closing its
2114
- * widget read-only. Used when a sub-agent that owns the open widget times out:
2115
- * the widget is parked in *this* driver's map (interactions resolve on the
2116
- * host, not the child), so cancelling it here unblocks the sub-agent handler
2117
- * awaiting it. The "one interaction at a time" invariant means there is at
2118
- * most one.
2119
- */
2120
- private cancelPendingInteractions(message: string): void {
2121
- for (const interactionId of [...this.pendingInteractions.keys()]) {
2122
- this.resolveInteraction(interactionId, {
2123
- status: 'timeout',
2124
- message,
2125
- } satisfies InteractionResult);
2126
- }
2127
- }
2128
-
2129
- /**
2130
- * Seeds the driver with a prior conversation history so a new component instance
2131
- * can continue an existing conversation. Call this before the first user message.
2132
- */
2133
- public loadHistory(messages: ChatMessage[]): void {
2134
- this.history = [...messages];
2135
- this.dispatchEvent(
2136
- new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
2137
- detail: this.history,
2138
- }),
2139
- );
2140
- }
2141
-
2142
- async sendMessage(userInput: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult> {
2143
- if (this.busy || (!userInput.trim() && !attachments?.length)) return { reason: 'done' };
2144
-
2145
- this.busy = true;
2146
- this.beginTurn();
2147
- // A new user turn — advances the `turnEnd` clock (continuations after a
2148
- // handoff stay on the same turn; they go through `continueFromHistory`).
2149
- this.turnSeq += 1;
2150
- this.subAgentCompletion = undefined;
2151
- this.subAgentFailure = undefined;
2152
- this.agentReleaseRequested = false;
2153
- this.budgetExhaustedThisTurn = false;
2154
- this.budgetWallDetail = undefined;
2155
- this.budgetWallViaSubAgent = false;
2156
- this.providerRefusedThisTurn = false;
2157
- this.providerRefusedDetail = undefined;
2158
- this.appendToHistory({ role: 'user', content: userInput, attachments });
2159
- this.turnStartedAt = Date.now();
2160
- recordMetaEvent(this.sessionKey, 'turn.start', {
2161
- phase: 'sendMessage',
2162
- agent: this.activeAgentName,
2163
- });
2164
- this.activityBus.publish('tool-loop-start', undefined);
2165
-
2166
- // Captured so the `finally` can carry the turn's outcome onto `tool-loop-end`.
2167
- let result: ChatDriverResult = { reason: 'done' };
2168
- try {
2169
- result = await this.runToolLoop(userInput, attachments);
2170
- return result;
2171
- } catch (e) {
2172
- logger.error('ChatDriver error:', e);
2173
- recordTurnError(this.sessionKey, 'exception', {
2174
- phase: 'sendMessage',
2175
- agent: this.activeAgentName,
2176
- provider: this.lastResolvedProviderName,
2177
- name: e instanceof Error ? e.name : undefined,
2178
- message: e instanceof Error ? e.message : String(e),
2179
- });
2180
- this.appendToHistory({
2181
- role: 'assistant',
2182
- content: 'Sorry, something went wrong on my end. Please try again in a moment.',
2183
- });
2184
- result = this.turnDone('exception');
2185
- return result;
2186
- } finally {
2187
- recordMetaEvent(this.sessionKey, 'turn.end', {
2188
- phase: 'sendMessage',
2189
- agent: this.activeAgentName,
2190
- durationMs: Date.now() - this.turnStartedAt,
2191
- });
2192
- this.busy = false;
2193
- this.endTurn();
2194
- this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
2195
- }
2196
- }
2197
-
2198
- /**
2199
- * Builds the context object passed to every tool handler call.
2200
- * Centralised here so fold shortcut dispatch and the main tool loop use the
2201
- * same context without duplication.
2202
- *
2203
- * @param activeToolCallId - The id of the tool call this context belongs to, so
2204
- * `condenseWhen` can register against the right call (it stamps the clocks
2205
- * straight off the driver). Absent for dispatch paths with no addressable tool
2206
- * call (e.g. the fold-close handler), where `condenseWhen` is a no-op.
2207
- * @param traceCapture - Optional per-tool-call accumulator. When provided, every
2208
- * sub-agent call's trace is **appended** here rather than written to shared
2209
- * instance state, so parallel tool calls each capture their own traces
2210
- * independently.
2211
- *
2212
- * Deliberately a list of traces, not one slot. A single handler may call
2213
- * `requestSubAgent` more than once — a code-driven scheduler dispatching a
2214
- * dependency graph, a retry of a timed-out child, any fan-out helper — and a
2215
- * single slot kept only the last, so every other child ran, was billed by the
2216
- * provider, and then vanished from history. The loss was silent in the worst
2217
- * way: `sumUsage` and `usageRows` both recurse into the trace, so they summed a
2218
- * truncated input and still agreed with each other. Measured at 1 of 7 traces
2219
- * kept on a seven-way fan-out, reporting ~2.3x under the real cost, with the
2220
- * error growing as the fan-out widens.
2221
- */
2222
- private buildHandlerContext(
2223
- activeToolCallId?: string,
2224
- traceCapture?: { traces: ChatMessage[][] },
2225
- ) {
2226
- return {
2227
- requestInteraction: <T>(
2228
- componentName: string,
2229
- data: any,
2230
- options?: InteractionRequestOptions,
2231
- ): Promise<T> => this.requestInteraction(componentName, data, options),
2232
- ...(this.subAgentsMap.size > 0 && {
2233
- requestSubAgent: <T = never>(
2234
- name: string,
2235
- options?: SubAgentRequestOptions,
2236
- ): Promise<
2237
- | { ok: true; result: T; reason?: never }
2238
- | { ok: false; result?: never; reason: SubAgentFailureReason }
2239
- > =>
2240
- this.invokeSubAgent<T>(name, options).then(({ outcome, trace }) => {
2241
- // Append, never assign: see `traceCapture` on `buildHandlerContext`. Order
2242
- // is completion order, which is fine — every message carries its own
2243
- // timestamp and the timeline sorts on that.
2244
- if (traceCapture && trace) traceCapture.traces.push(trace);
2245
- return outcome;
2246
- }),
2247
- }),
2248
- completeSubAgent: (result: unknown): void => {
2249
- if (this.subAgentCompletion) {
2250
- logger.warn(
2251
- `ChatDriver(${this.activeAgentName ?? 'unknown'}): completeSubAgent called more than once — ignoring`,
2252
- );
2253
- return;
2254
- }
2255
- this.subAgentCompletion = { result };
2256
- // This activation's flow has ended — advance the clock so its
2257
- // `agentEnd` payloads collapse and a re-run starts a fresh activation.
2258
- this.currentActivation += 1;
2259
- },
2260
- releaseAgent: (): void => {
2261
- if (this.agentReleaseRequested) {
2262
- logger.warn(
2263
- `ChatDriver(${this.activeAgentName ?? 'unknown'}): releaseAgent called more than once — ignoring`,
2264
- );
2265
- return;
2266
- }
2267
- this.agentReleaseRequested = true;
2268
- // The stateful flow has wrapped up — advance the clock so its `agentEnd`
2269
- // payloads collapse and a re-run starts a fresh activation.
2270
- this.currentActivation += 1;
2271
- },
2272
- endPhase: (): void => {
2273
- // Advance the phase epoch so payloads registered with `{ on: phaseEnd }`
2274
- // during the phase that just ended collapse on the next provider call.
2275
- // Repeatable (no once-only guard, unlike releaseAgent/completeSubAgent):
2276
- // each call opens a fresh phase for subsequent reads to accumulate in.
2277
- this.currentPhaseEpoch += 1;
2278
- },
2279
- condenseWhen: (policy: CondensePolicy): void => {
2280
- // No addressable tool call (e.g. fold-close handler) — nothing to attach to.
2281
- if (!activeToolCallId) return;
2282
- if (!policy?.args && !policy?.response) {
2283
- logger.warn(
2284
- `ChatDriver(${this.activeAgentName ?? 'unknown'}): condenseWhen called with neither args nor response — ignoring`,
2285
- );
2286
- return;
2287
- }
2288
- const triggers = Array.isArray(policy.on) ? policy.on : [policy.on];
2289
- const validTrigger = (t: CondenseTrigger | undefined): boolean =>
2290
- t?.kind === 'superseded'
2291
- ? typeof t.by === 'string' && t.by.length > 0
2292
- : t?.kind === 'age'
2293
- ? typeof t.turns === 'number' && t.turns >= 1
2294
- : t?.kind === 'turnEnd' || t?.kind === 'agentEnd' || t?.kind === 'phaseEnd';
2295
- if (triggers.length === 0 || !triggers.every(validTrigger)) {
2296
- logger.warn(
2297
- `ChatDriver(${this.activeAgentName ?? 'unknown'}): condenseWhen called with an invalid trigger — ignoring`,
2298
- );
2299
- return;
2300
- }
2301
- // First-wins: a handler declares its policy once per call. A second
2302
- // declaration for the same tool call is a handler bug, not a refinement.
2303
- if (this.condensePolicies.has(activeToolCallId)) {
2304
- logger.error(
2305
- `ChatDriver(${this.activeAgentName ?? 'unknown'}): condenseWhen called more than once for the same tool call — keeping the first policy, ignoring this one`,
2306
- );
2307
- return;
2308
- }
2309
- // Stamp the three clocks NOW (read straight off the driver — the handler
2310
- // runs during the current model-call's tool batch).
2311
- this.condensePolicies.set(activeToolCallId, {
2312
- policy,
2313
- iteration: this.modelCallSeq,
2314
- turn: this.turnSeq,
2315
- activation: this.currentActivation,
2316
- phaseEpoch: this.currentPhaseEpoch,
2317
- });
2318
- },
2319
- };
2320
- }
2321
-
2322
- /**
2323
- * Creates a child `ChatDriver` for the named sub-agent, runs it to completion,
2324
- * and returns its structured result (or final text fallback) together with the
2325
- * full child conversation trace. Callers receive both values so each parallel
2326
- * invocation can capture its own trace without touching shared instance state.
2327
- */
2328
- private async invokeSubAgent<T = never>(
2329
- name: string,
2330
- options?: SubAgentRequestOptions,
2331
- ): Promise<{
2332
- // Closed union (see ChatToolHandlers.requestSubAgent) — keeps `result`/`reason`
2333
- // accessible for consumers compiled without strictNullChecks.
2334
- outcome:
2335
- | { ok: true; result: T; reason?: never }
2336
- | { ok: false; result?: never; reason: SubAgentFailureReason };
2337
- trace: ChatMessage[];
2338
- }> {
2339
- const subConfig = this.subAgentsMap.get(name);
2340
- if (!subConfig) {
2341
- const available = [...this.subAgentsMap.keys()].join(', ') || '(none)';
2342
- throw new Error(
2343
- `Sub-agent "${name}" not found on agent "${this.activeAgentName}". Available: ${available}`,
2344
- );
2345
- }
2346
-
2347
- const { task, historyCap, context, timeoutMs } = options ?? {};
2348
-
2349
- // Exclude the current in-flight assistant message (the one with tool calls that
2350
- // triggered this invocation) — it has unresolved tool calls that would confuse
2351
- // the sub-agent into thinking it needs to handle tools it doesn't own.
2352
- const lastMsg = this.history[this.history.length - 1];
2353
- const baseHistory =
2354
- lastMsg?.role === 'assistant' && lastMsg.toolCalls?.length
2355
- ? this.history.slice(0, -1)
2356
- : this.history;
2357
-
2358
- const snapshotHistory =
2359
- historyCap != null ? applyHistoryCap(baseHistory, historyCap) : [...baseHistory];
2360
-
2361
- const contextMessages: ChatMessage[] = context
2362
- ? [{ role: 'user', content: `[Sub-agent context]: ${JSON.stringify(context)}` }]
2363
- : [];
2364
-
2365
- const effectivePrimer: ChatMessage[] = [
2366
- ...snapshotHistory,
2367
- ...contextMessages,
2368
- ...(subConfig.primerHistory ?? []),
2369
- ];
2370
-
2371
- // Unique per-invocation id — reused for the lifecycle event bracket below —
2372
- // and a child session key derived from it. The child files its meta events
2373
- // under this own bucket (rather than the shared empty-key sink), so they can
2374
- // be harvested into THIS session on completion and then discarded.
2375
- const invocationId = crypto.randomUUID();
2376
- const childSessionKey = `${this.sessionKey}::sub:${invocationId}`;
2377
- const child = new ChatDriver(this.providerRegistry, {
2378
- sessionKey: childSessionKey,
2379
- // Inherit the parent's bus so the sub-agent's tool-loop events still surface
2380
- // (off-browser this is the shared no-op).
2381
- activityBus: this.activityBus,
2382
- });
2383
- // Mark before the first turn so the child forces tool use and reports a
2384
- // typed failure (rather than user-facing text) if it never completes.
2385
- child.markAsSubAgent();
2386
- // Propagate disposal: if this (parent) driver is torn down while the
2387
- // sub-agent is mid-flight, dispose the child too so its in-flight request
2388
- // aborts. Detached in the `finally` below once the sub-agent completes.
2389
- const disposeChild = () => child.dispose();
2390
- if (this.lifecycleController.signal.aborted) {
2391
- disposeChild();
2392
- } else {
2393
- this.lifecycleController.signal.addEventListener('abort', disposeChild, { once: true });
2394
- }
2395
- child.applyAgent({ ...subConfig, primerHistory: effectivePrimer });
2396
- // Route interactions back through this driver so widgets render in the
2397
- // parent's (ultimately the root's) history and resolve via the same
2398
- // pending map the main UI is wired to. Recurses naturally for nested
2399
- // sub-agents.
2400
- child.setHostInteractionRequester(
2401
- <R>(componentName: string, data: any, opts?: InteractionRequestOptions): Promise<R> =>
2402
- this.requestInteraction<R>(componentName, data, opts),
2403
- );
2404
-
2405
- const forwardTrace = (e: Event) => {
2406
- this.dispatchEvent(
2407
- new CustomEvent('sub-agent-history-updated', {
2408
- detail: { agentName: subConfig.name, history: (e as CustomEvent<ChatMessage[]>).detail },
2409
- }),
2410
- );
2411
- };
2412
- // Re-dispatch the child's `provider-changed` so the UI reflects whichever
2413
- // provider is *actually* running right now (the sub-agent may use a
2414
- // different provider than the parent). Restoration of the parent's
2415
- // provider on `sub-agent-stop` is handled by the listener in main.ts.
2416
- const forwardProviderChanged = (e: Event) => {
2417
- this.dispatchEvent(
2418
- new CustomEvent('provider-changed', { detail: (e as CustomEvent).detail }),
2419
- );
2420
- };
2421
- child.addEventListener('history-updated', forwardTrace);
2422
- child.addEventListener('provider-changed', forwardProviderChanged);
2423
-
2424
- const chatInputDuringExecution = options?.chatInputDuringExecution;
2425
- const lifecycleDetail = { name, invocationId, chatInputDuringExecution };
2426
-
2427
- this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: lifecycleDetail }));
2428
- // Bracket the run on the timeline (mirrors turn.start/turn.end). Recorded
2429
- // under THIS (parent) session — like subagent.completed/failed below — so the
2430
- // sub-agent's span is visible on the user-facing timeline, not the child's.
2431
- recordMetaEvent(this.sessionKey, 'subagent.started', { agent: name });
2432
- let timedOut = false;
2433
- const runPromise = child.sendMessage(task ?? '');
2434
- try {
2435
- if (timeoutMs == null) {
2436
- await runPromise;
2437
- } else {
2438
- // Race the run against the deadline. On timeout, abort the child (which
2439
- // cancels its in-flight provider request) and let the aborted run settle
2440
- // before we tear down and return the timeout outcome.
2441
- let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
2442
- try {
2443
- const timeoutSignal = new Promise<'timeout'>((resolveTimeout) => {
2444
- timeoutHandle = setTimeout(() => resolveTimeout('timeout'), timeoutMs);
2445
- });
2446
- const winner = await Promise.race([
2447
- runPromise.then(() => 'done' as const),
2448
- timeoutSignal,
2449
- ]);
2450
- if (winner === 'timeout') {
2451
- timedOut = true;
2452
- child.dispose();
2453
- // The sub-agent may be parked awaiting a user interaction, which is
2454
- // routed up to this driver and parked in *this* pending map (widgets
2455
- // resolve on the host, not the child). Disposing the child does not
2456
- // settle that promise, so the child stays suspended — cancel the
2457
- // interaction to close the widget and unblock the child. Without this,
2458
- // `await runPromise` below would hang forever and the timeout would
2459
- // never complete (or record `subagent.failed`).
2460
- this.cancelPendingInteractions(`Sub-agent "${name}" timed out after ${timeoutMs}ms.`);
2461
- await runPromise.catch(() => undefined);
2462
- }
2463
- } finally {
2464
- // Always clear the timer — even if the race rejects — so it never leaks.
2465
- if (timeoutHandle) clearTimeout(timeoutHandle);
2466
- }
2467
- }
2468
- } finally {
2469
- this.lifecycleController.signal.removeEventListener('abort', disposeChild);
2470
- child.removeEventListener('history-updated', forwardTrace);
2471
- child.removeEventListener('provider-changed', forwardProviderChanged);
2472
- // Tear the child down on every exit path, not just timeout/parent-abort.
2473
- // A child that completes normally is otherwise never disposed, so its
2474
- // provider-registry subscription (wired in the ChatDriver constructor)
2475
- // would leak — the long-lived registry would retain every completed
2476
- // sub-agent driver. dispose() is idempotent and only aborts the (already
2477
- // settled) lifecycle, so the snapshot/completion reads below still work.
2478
- child.dispose();
2479
- this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
2480
- // Capture the child's diagnostics into THIS session, then ALWAYS discard its
2481
- // transient bucket — done in the `finally` so an unexpected `sendMessage`
2482
- // rejection (which propagates out of this method) can't orphan the bucket in
2483
- // the registry, and a crashed sub-agent still leaves its turns/events behind.
2484
- // Forward the child's per-LLM-call snapshots so they show as `kind:'turn'`
2485
- // entries in the exported debug log, re-numbered under the activating parent turn.
2486
- this.forwardSubAgentSnapshots(child.getTurnSnapshots());
2487
- // Fold the sub-agent's high-value meta events (retries/errors/tool failures —
2488
- // see HARVESTED_SUBAGENT_EVENTS) into THIS session, preserving their original
2489
- // timestamps so they interleave within the subagent.started→completed/failed
2490
- // bracket. Each is breadcrumbed `"<parent> › <sub-agent>"`, composing for a
2491
- // nested sub-agent whose own breadcrumb the child already merged.
2492
- const parentName = this.activeAgentName ?? '?';
2493
- const harvested = getMetaEvents(childSessionKey)
2494
- .filter((e) => HARVESTED_SUBAGENT_EVENTS.has(e.type))
2495
- .map((e) => {
2496
- const existing = e.detail?.subAgent as string | undefined;
2497
- return { ...e, detail: { ...e.detail, subAgent: `${parentName} › ${existing ?? name}` } };
2498
- });
2499
- mergeMetaEvents(this.sessionKey, harvested);
2500
- clearSession(childSessionKey);
2501
- }
2502
-
2503
- const trace = child.getHistory() as ChatMessage[];
2504
-
2505
- if (timedOut) {
2506
- // Same failure shape as any other non-completion — the parent handler
2507
- // recovers on its existing `{ ok: false }` branch. Recorded under THIS
2508
- // (parent) session so it lands on the user-visible debug-log timeline.
2509
- recordMetaEvent(this.sessionKey, 'subagent.failed', { agent: name, reason: 'timeout' });
2510
- return { outcome: { ok: false, reason: 'timeout' }, trace };
2511
- }
2512
-
2513
- const completion = child.getSubAgentCompletion();
2514
-
2515
- if (completion) {
2516
- recordMetaEvent(this.sessionKey, 'subagent.completed', { agent: name });
2517
- return { outcome: { ok: true, result: completion.result as T }, trace };
2518
- }
2519
-
2520
- // No completion → the sub-agent's loop ended without calling its completion
2521
- // tool. Surface the typed reason it recorded; default to 'max_iterations'
2522
- // for the defensive case where the loop ended with no reason set (e.g. a
2523
- // provider ignored forced tool use and returned text). The previous
2524
- // final-text fallback is intentionally gone — sub-agents return a
2525
- // structured outcome only, and the parent handler decides how to recover.
2526
- const failure = child.getSubAgentFailure();
2527
- const reason = failure?.reason ?? 'max_iterations';
2528
- // Record under THIS (parent) driver's session so the failure lands on the
2529
- // user-visible debug-log timeline — the child ran under its own session key.
2530
- // This is also the only telemetry for the defensive default above, where the
2531
- // child's loop ended without recording an explicit failure reason.
2532
- recordMetaEvent(this.sessionKey, 'subagent.failed', { agent: name, reason });
2533
- // A child that hit the budget wall walls this driver too — the cap is shared,
2534
- // and the parent's very next model call would 402 as well. Flagged (not
2535
- // thrown) because the handler's return value is still appended as a tool
2536
- // result: the loop reads the flag before the next provider call and ends the
2537
- // turn there, which is what makes the `'budget_exhausted'` doc's "terminal
2538
- // for the parent too" actually true.
2539
- //
2540
- // The child's attribution is inherited with it: the vendor that refused is
2541
- // the child's, which under a mixed registry is not this driver's. See
2542
- // `budgetWallDetail` for what mis-attributing it costs.
2543
- //
2544
- // FIRST attribution wins (`??=`). With batched delegations, child A walling
2545
- // WITH attribution can be followed by child B walling WITHOUT one (a
2546
- // figure-less 402 behind a transport label no vendor claims); a plain
2547
- // assignment reset the field to `undefined` and the short-circuit's
2548
- // fallback then named the parent's own vendor — the one known NOT to have
2549
- // refused.
2550
- if (reason === 'budget_exhausted') {
2551
- this.budgetExhaustedThisTurn = true;
2552
- this.budgetWallViaSubAgent = true;
2553
- this.budgetWallDetail ??= failure?.budget;
2554
- }
2555
- // The PROVIDER wall via a child (GENC-1506) — the same shape as the budget wall above, and needed
2556
- // for the same non-obvious reason: **the child is a separate driver instance**, so the flag it set
2557
- // on itself is invisible here. Without this branch the child's failure was appended as an ordinary
2558
- // tool result, the parent's short-circuit never fired, and the parent issued another model call
2559
- // straight into the same wall. Worse, if that call happened to succeed the turn could finish
2560
- // WITHOUT `failureReason: 'provider-refused'` at all — reporting a clean turn over a dead account.
2561
- //
2562
- // FIRST attribution wins (`??=`), for the reason spelled out on `budgetWallDetail`: batched
2563
- // delegations can pair an attributable child refusal with an unattributable one, and a plain
2564
- // assignment would let the later `undefined` erase what the earlier child knew.
2565
- if (reason === 'provider_refused') {
2566
- this.providerRefusedThisTurn = true;
2567
- this.providerRefusedDetail ??= failure?.providerRefused;
2568
- }
2569
- return { outcome: { ok: false, reason }, trace };
2570
- }
2571
-
2572
- /**
2573
- * Continue the tool loop from current history without appending a new user message.
2574
- * Used by OrchestratingDriver after an agent handoff.
2575
- */
2576
- async continueFromHistory(transientPrimer?: ChatMessage[]): Promise<ChatDriverResult> {
2577
- if (this.busy) return { reason: 'done' };
2578
-
2579
- this.busy = true;
2580
- this.beginTurn();
2581
- this.subAgentCompletion = undefined;
2582
- this.subAgentFailure = undefined;
2583
- this.budgetExhaustedThisTurn = false;
2584
- this.budgetWallDetail = undefined;
2585
- this.budgetWallViaSubAgent = false;
2586
- this.providerRefusedThisTurn = false;
2587
- this.providerRefusedDetail = undefined;
2588
- this.turnStartedAt = Date.now();
2589
- recordMetaEvent(this.sessionKey, 'turn.start', {
2590
- phase: 'continueFromHistory',
2591
- agent: this.activeAgentName,
2592
- });
2593
- this.activityBus.publish('tool-loop-start', undefined);
2594
- // Captured so the `finally` can carry the turn's outcome onto `tool-loop-end`.
2595
- let result: ChatDriverResult = { reason: 'done' };
2596
- try {
2597
- result = await this.runToolLoop('', undefined, transientPrimer);
2598
- return result;
2599
- } catch (e) {
2600
- logger.error('ChatDriver error:', e);
2601
- recordTurnError(this.sessionKey, 'exception', {
2602
- phase: 'continueFromHistory',
2603
- agent: this.activeAgentName,
2604
- provider: this.lastResolvedProviderName,
2605
- name: e instanceof Error ? e.name : undefined,
2606
- message: e instanceof Error ? e.message : String(e),
2607
- });
2608
- this.appendToHistory({
2609
- role: 'assistant',
2610
- content: 'Sorry, something went wrong on my end. Please try again in a moment.',
2611
- });
2612
- result = this.turnDone('exception');
2613
- return result;
2614
- } finally {
2615
- recordMetaEvent(this.sessionKey, 'turn.end', {
2616
- phase: 'continueFromHistory',
2617
- agent: this.activeAgentName,
2618
- durationMs: Date.now() - this.turnStartedAt,
2619
- });
2620
- this.busy = false;
2621
- this.endTurn();
2622
- this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
2623
- }
2624
- }
2625
-
2626
- // ---------------------------------------------------------------------------
2627
- // Fold mechanics
2628
- // ---------------------------------------------------------------------------
2629
-
2630
- /** Extract ToolFold metadata from a handler, or undefined if it isn't a fold facade. */
2631
- private getFold(toolName: string): ToolFold | undefined {
2632
- const handler = this.toolHandlers[toolName];
2633
- return handler ? ((handler as any)[TOOL_FOLD_SYMBOL] as ToolFold | undefined) : undefined;
2634
- }
2635
-
2636
- /**
2637
- * Search all currently registered fold facades (and their nested folds recursively)
2638
- * to find which fold contains a given tool name. Returns the immediate parent fold name.
2639
- */
2640
- private findFoldContaining(toolName: string, handlers?: ChatToolHandlers): string | null {
2641
- const source = handlers ?? this.toolHandlers;
2642
- for (const [, handler] of Object.entries(source)) {
2643
- const fold = (handler as any)[TOOL_FOLD_SYMBOL] as ToolFold | undefined;
2644
- if (!fold) continue;
2645
- // Direct inner tool match
2646
- if (fold.handlers[toolName]) return fold.name;
2647
- // Recurse into nested folds
2648
- const nested = this.findFoldContaining(toolName, fold.handlers);
2649
- if (nested) return fold.name;
2650
- }
2651
- return null;
2652
- }
2653
-
2654
- /**
2655
- * If an open fold is hiding a previously-available tool, return the name of
2656
- * the fold to close to start getting it back. Only exclusive folds hide tools
2657
- * (they replace the tool set on open rather than extending it), so a base tool
2658
- * that was visible before the fold opened now sits in a fold-stack frame's
2659
- * `previousHandlers` but not in the live handler map. Only the top fold's
2660
- * `close_` tool is active, so that's always the actionable next step — even
2661
- * when the tool lives further down the stack, closing repeatedly walks back to
2662
- * it. Returns null when no open fold accounts for the tool.
2663
- */
2664
- private foldHidingTool(toolName: string): string | null {
2665
- if (this.foldStack.length === 0) return null;
2666
- const hidden = this.foldStack.some((f) => f.previousHandlers[toolName]);
2667
- return hidden ? this.foldStack[this.foldStack.length - 1].foldName : null;
2668
- }
2669
-
2670
- /**
2671
- * Install the fold's inner tool set, replacing (exclusive) or extending (non-exclusive)
2672
- * the current tool set. Also injects the close tool. Does NOT touch the fold stack.
2673
- */
2674
- private applyFoldToolSet(fold: ToolFold, foldName: string): void {
2675
- const closeToolName = `close_${foldName}`;
2676
- const newDefs: ChatToolDefinition[] = [];
2677
- const newHandlers: ChatToolHandlers = {};
2678
-
2679
- if (!fold.exclusive) {
2680
- // Non-exclusive: keep existing tools minus the facade we just opened
2681
- for (const def of this.toolDefinitions) {
2682
- if (def.name !== foldName) newDefs.push(def);
2683
- }
2684
- for (const [name, handler] of Object.entries(this.toolHandlers)) {
2685
- if (name !== foldName) newHandlers[name] = handler;
2686
- }
2687
- }
2688
-
2689
- // Install inner tools from fold metadata
2690
- newDefs.push(...fold.tools);
2691
- Object.assign(newHandlers, fold.handlers);
2692
-
2693
- // Inject the close tool
2694
- newDefs.push({
2695
- name: closeToolName,
2696
- description: `Close the ${foldName} fold and return to the previous set of tools.`,
2697
- parameters: { type: 'object', properties: {} },
2698
- });
2699
- newHandlers[closeToolName] = async (): Promise<string> => this.closeFold();
2700
-
2701
- this.toolDefinitions = newDefs;
2702
- this.toolHandlers = newHandlers;
2703
- }
2704
-
2705
- /** Open a fold: push a stack frame, swap the tool set, return the response message. */
2706
- private openFold(
2707
- foldName: string,
2708
- fold: ToolFold,
2709
- args: Record<string, unknown>,
2710
- activeToolCallId?: string,
2711
- ): Promise<string> {
2712
- // Shortcut dispatch: model passed inner tool args directly, e.g.
2713
- // trading_tools({ search_trades: { side: "BUY" } })
2714
- for (const key of Object.keys(args)) {
2715
- const innerHandler = fold.handlers[key];
2716
- if (innerHandler) {
2717
- logger.debug(`ChatDriver: fold shortcut dispatch "${foldName}" → "${key}"`);
2718
- // Open the fold first so the tool set is correct for subsequent calls
2719
- this.pushFoldFrame(foldName);
2720
- this.applyFoldToolSet(fold, foldName);
2721
- this.consecutiveFoldOps = 0; // shortcut dispatch counts as real work
2722
- const innerArgs =
2723
- typeof args[key] === 'object' && args[key] !== null
2724
- ? (args[key] as Record<string, unknown>)
2725
- : {};
2726
- return innerHandler(innerArgs, this.buildHandlerContext(activeToolCallId)).then((r) =>
2727
- typeof r === 'string' ? r : JSON.stringify(r),
2728
- );
2729
- }
2730
- }
2731
-
2732
- // Normal two-step open
2733
- this.pushFoldFrame(foldName);
2734
- this.applyFoldToolSet(fold, foldName);
2735
-
2736
- const innerToolNames = fold.tools.map((t) => t.name);
2737
- const closeToolName = `close_${foldName}`;
2738
- let message = `Fold opened: ${foldName}. Tools now available: ${[...innerToolNames, closeToolName].join(', ')}.`;
2739
- if (fold.usageNotes) message += ` Notes: ${fold.usageNotes}`;
2740
- message += ` Call ${closeToolName} when done to return to the previous tools.`;
2741
- return Promise.resolve(message);
2742
- }
2743
-
2744
- private pushFoldFrame(foldName: string): void {
2745
- this.foldStack.push({
2746
- foldName,
2747
- previousDefinitions: [...this.toolDefinitions],
2748
- previousHandlers: { ...this.toolHandlers },
2749
- });
2750
- }
2751
-
2752
- /** Close the top fold: pop the stack frame, restore the previous tool set. */
2753
- private closeFold(): string {
2754
- const frame = this.foldStack.pop();
2755
- if (!frame) return 'No fold is currently open.';
2756
-
2757
- this.toolDefinitions = frame.previousDefinitions;
2758
- this.toolHandlers = frame.previousHandlers;
2759
-
2760
- const toolNames = this.toolDefinitions.map((t) => t.name);
2761
- return `Fold closed: ${frame.foldName}. Tools now available: ${toolNames.join(', ')}.`;
2762
- }
2763
-
2764
- /** Build the fold-awareness suffix appended to the system prompt each LLM call. */
2765
- private buildFoldSystemPromptSuffix(): string {
2766
- // Collect fold facades from the current handler map
2767
- const activeFolds: ToolFold[] = [];
2768
- for (const handler of Object.values(this.toolHandlers)) {
2769
- const fold = (handler as any)[TOOL_FOLD_SYMBOL] as ToolFold | undefined;
2770
- if (fold) activeFolds.push(fold);
2771
- }
2772
-
2773
- if (activeFolds.length === 0 && this.foldStack.length === 0) return '';
2774
-
2775
- const parts: string[] = ['\n\n--- Tool Folds ---'];
2776
- parts.push(
2777
- 'Some tools are grouped into folds. You may see tool calls in the conversation history for tools that are not currently available — they are inside a fold. To access them, invoke the fold tool first.',
2778
- );
2779
-
2780
- if (this.foldStack.length > 0) {
2781
- const current = this.foldStack[this.foldStack.length - 1];
2782
- parts.push(
2783
- `You are currently inside the "${current.foldName}" fold. Call close_${current.foldName} when you are done with these tools.`,
2784
- );
2785
- }
2786
-
2787
- for (const fold of activeFolds) {
2788
- parts.push(`• ${fold.name}: ${fold.description}`);
2789
- }
2790
-
2791
- return parts.join('\n');
2792
- }
2793
-
2794
- // ---------------------------------------------------------------------------
2795
- // Tool loop
2796
- // ---------------------------------------------------------------------------
2797
-
2798
- // oxlint-disable-next-line complexity
2799
- private async runToolLoop(
2800
- userInput: string,
2801
- attachments?: ChatAttachment[],
2802
- transientPrimer?: ChatMessage[],
2803
- ): Promise<ChatDriverResult> {
2804
- if (!this.systemPrompt) {
2805
- logger.warn(
2806
- 'ChatDriver: no systemPrompt set. The assistant will have no instructions — provide a systemPrompt via agents config or the foundation-ai-assistant property.',
2807
- );
2808
- }
2809
-
2810
- let currentInput = userInput;
2811
- let currentAttachments: ChatAttachment[] | undefined = attachments;
2812
- let iterations = 0;
2813
- let malformedAttempts = 0;
2814
- let emptyResponseAttempts = 0;
2815
- // Bounded retries for transient throws while resolving the per-turn tool surface or
2816
- // calling the provider. Without this, a single transient throw tears down the whole turn
2817
- // and strands the agent's unflushed work behind an opaque error.
2818
- let setupTransportAttempts = 0;
2819
- // True only for the very first LLM call. Used to exclude the pending user message
2820
- // from history (it is passed separately as currentInput). Must not be derived from
2821
- // `iterations` because fold operations decrement iterations, which would incorrectly
2822
- // re-trigger the slice on subsequent calls after a fold open/close.
2823
- let firstLlmCall = !!currentInput;
2824
- // Thinking posture for this WHOLE turn, resolved on the first model call and then held.
2825
- //
2826
- // Unlike temperature or toolChoice, this one cannot vary per iteration: a tool-use loop is
2827
- // a single assistant turn, and Anthropic requires one thinking mode for its duration.
2828
- // Toggling mid-loop does not error — the API silently disables thinking for that request
2829
- // and strips blocks that would leave the turn structure invalid, so an `'auto' -> 'off'`
2830
- // switch loses the reasoning continuity the opening call established while an
2831
- // `'off' -> 'auto'` switch simply does not deliver the reasoning asked for. It also
2832
- // invalidates the prompt cache, which costs more than the reasoning it was meant to save.
2833
- // Resolved per USER turn instead, which is where the docs say to choose it.
2834
- let pinnedThinkingPolicy: ChatThinkingPolicy | undefined;
2835
- let thinkingPolicyPinned = false;
2836
-
2837
- while (iterations < this.maxToolIterations) {
2838
- iterations += 1;
2839
- // Monotonic across the driver's life — the age clock. Unlike `iterations`,
2840
- // it is NOT reset per turn and NOT decremented for folds, so `age` keeps
2841
- // counting model-calls once a turn ends. (See the `modelCallSeq` field.)
2842
- this.modelCallSeq += 1;
2843
-
2844
- // A cancel (or dispose) that landed while the previous iteration's tool
2845
- // batch was running takes effect here — before issuing another LLM call.
2846
- // An abort during the call itself is handled by the catch below.
2847
- if (this.turnController.signal.aborted) {
2848
- return this.completeAbortedTurn();
2849
- }
2850
-
2851
- // A budget wall observed earlier this turn ends it HERE, before another
2852
- // model call. Reachable only via a sub-agent (this driver's own 402
2853
- // returns straight out of the catch below) — `invokeSubAgent` sets the
2854
- // flag, its `{ ok: false, reason: 'budget_exhausted' }` outcome is
2855
- // appended as a tool result, and without this the loop would call the
2856
- // provider again into the same wall: N batched sub-agent calls otherwise
2857
- // cost N doomed children AND a doomed parent call.
2858
- //
2859
- // Scoped strictly to the budget reason: every other SubAgentFailureReason
2860
- // is something the parent can legitimately recover from, so those still
2861
- // let the loop continue.
2862
- // A provider refusal seen earlier this turn ends it HERE, before another model call. Reachable
2863
- // only via a sub-agent — this driver's own refusal returns straight out of the catch — and
2864
- // without it the loop would call the provider again into the same wall (GENC-1506).
2865
- if (this.providerRefusedThisTurn) {
2866
- logger.error('ChatDriver: ending the turn — a sub-agent hit the provider wall');
2867
- recordTurnError(this.sessionKey, 'provider-refused', {
2868
- agent: this.activeAgentName,
2869
- provider: this.lastResolvedProviderName,
2870
- kind: this.providerRefusedDetail?.kind,
2871
- via: 'sub-agent',
2872
- isSubAgent: this.isSubAgent,
2873
- });
2874
- if (this.isSubAgent) {
2875
- // The detail is forwarded, not just the reason — this driver may itself be an INTERMEDIATE
2876
- // sub-agent, and a refusal that started in a grandchild reaches its own parent only through
2877
- // this call. Dropping it here still stopped the top-level turn (the reason travels), but
2878
- // arrived with no vendor and no kind, which is the whole diagnostic this payload exists to
2879
- // carry across hops. The adjacent budget path forwards `budgetWallDetail` for the same reason.
2880
- this.failSubAgent('provider_refused', undefined, this.providerRefusedDetail);
2881
- } else {
2882
- this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
2883
- }
2884
- return this.turnDone('provider-refused', undefined, this.providerRefusedDetail);
2885
- }
2886
- if (this.budgetExhaustedThisTurn) {
2887
- logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
2888
- recordTurnError(this.sessionKey, 'budget-exhausted', {
2889
- agent: this.activeAgentName,
2890
- provider: this.lastResolvedProviderName,
2891
- // The CHILD's vendor when it knew one, and NOTHING otherwise. On this
2892
- // path the wall is definitionally the child's, and on a mixed registry
2893
- // `lastResolvedProvider` is this driver's own vendor — the one known
2894
- // NOT to have refused. An unattributable child wall must degrade to
2895
- // the vendor-agnostic block (which `latchBlockedFrom` handles
2896
- // fail-safe), never to a vendor that is known to be wrong: naming the
2897
- // parent's vendor here walled BOTH — the child's via its own
2898
- // tool-loop-end, the parent's via this event — and derived `blocked`
2899
- // over headroom that still existed.
2900
- vendor: this.budgetWallDetail?.vendor,
2901
- via: 'sub-agent',
2902
- isSubAgent: this.isSubAgent,
2903
- });
2904
- if (this.isSubAgent) {
2905
- this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2906
- } else {
2907
- // Composed from the DETAIL, not an error object: on this path the wall
2908
- // arrived as a sub-agent's failure payload — `budgetWallDetail`, first
2909
- // attribution wins — and an unattributable child wall (detail
2910
- // undefined) composes the neutral default.
2911
- this.appendToHistory({
2912
- role: 'assistant',
2913
- content: this.budgetExhaustedBubble(this.budgetWallDetail),
2914
- });
2915
- }
2916
- // Carried onto the result so `loopEndDetail` publishes the refusing
2917
- // vendor rather than falling through to `lastResolvedProvider`, and the
2918
- // host's latch walls the vendor that actually ran out.
2919
- return this.turnDone('budget-exhausted', this.budgetWallDetail);
2920
- }
2921
-
2922
- const promptCtx: SystemPromptContext = {
2923
- agentName: this.activeAgentName ?? '',
2924
- history: this.history,
2925
- turnIndex: iterations - 1,
2926
- signal: this.turnController.signal,
2927
- };
2928
-
2929
- // Re-resolve dynamic tool definitions before each LLM call. The static
2930
- // case is a no-op (factory is undefined and `this.toolDefinitions` was
2931
- // set by applyAgent). Folds operate on `this.toolDefinitions` and are
2932
- // forbidden when a factory is set, so the array form is always valid.
2933
- // Sequential await is required — each iteration must see fresh values
2934
- // before constructing the LLM request.
2935
- // A transient throw while building the tool surface should retry the iteration, not
2936
- // tear down the whole turn and strand the agent's unflushed buffer behind an opaque
2937
- // error. The handler-map factory re-resolves in lockstep so dispatch sees only the
2938
- // handlers valid for the current state, in step with the tool definitions exposed
2939
- // above. Folds are forbidden when either factory is set, so the fold-mutation paths
2940
- // on `this.toolDefinitions` / `this.toolHandlers` are unreachable.
2941
- try {
2942
- if (this.toolDefinitionsFactory) {
2943
- // oxlint-disable-next-line no-await-in-loop
2944
- this.toolDefinitions = await this.toolDefinitionsFactory(promptCtx);
2945
- }
2946
- if (this.toolHandlersFactory) {
2947
- // oxlint-disable-next-line no-await-in-loop
2948
- this.toolHandlers = await this.toolHandlersFactory(promptCtx);
2949
- }
2950
- } catch (e) {
2951
- setupTransportAttempts += 1;
2952
- if (setupTransportAttempts < MAX_SETUP_TRANSPORT_RETRIES) {
2953
- logger.warn(
2954
- `ChatDriver: tool-surface resolution failed, retrying (${setupTransportAttempts}/${MAX_SETUP_TRANSPORT_RETRIES})`,
2955
- );
2956
- iterations -= 1;
2957
- continue;
2958
- }
2959
- throw e;
2960
- }
2961
-
2962
- // Record everything advertised this turn so the unknown-tool path can tell
2963
- // a stale tool (real earlier, retired now) from a hallucinated one. Runs
2964
- // for both the static and factory cases; folds also flow through here as
2965
- // their inner tools become visible on the iteration after they open.
2966
- for (const def of this.toolDefinitions) this.everSeenToolNames.add(def.name);
2967
-
2968
- const resolvedSystemPrompt =
2969
- typeof this.systemPrompt === 'function'
2970
- ? // oxlint-disable-next-line no-await-in-loop
2971
- await this.systemPrompt(promptCtx)
2972
- : this.systemPrompt;
2973
-
2974
- // Re-resolve the per-turn display label. Falls back to the canonical
2975
- // agent name when displayName is unset. Stamped onto outgoing messages
2976
- // and turn snapshots for UX only; routing/history-transform continues
2977
- // to read `activeAgentName`.
2978
- if (typeof this.displayName === 'function') {
2979
- // oxlint-disable-next-line no-await-in-loop
2980
- this.activeAgentLabel = await this.displayName(promptCtx);
2981
- }
2982
-
2983
- // Volatile framework additions — the fold-surface suffix and the per-attempt malformed/empty
2984
- // retry nudge — that would otherwise destabilize a cached system prompt. They are routed to
2985
- // the framed tail (below), never appended to the system string, so the system prompt stays
2986
- // byte-stable for every agent.
2987
- const foldSuffix = this.buildFoldSystemPromptSuffix();
2988
- const retryNudge =
2989
- malformedAttempts > 0
2990
- ? '\n\nIMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools.'
2991
- : emptyResponseAttempts > 0
2992
- ? "\n\nIMPORTANT: You must respond to the user's message. Call the appropriate tool or provide a text response — do not return an empty response."
2993
- : '';
2994
- const frameworkSystemSuffix = `${foldSuffix}${retryNudge}`;
2995
-
2996
- // Normalize the primer at the same chokepoint as the driver's own history
2997
- // (GENC-1351 §5.7): a parent's `compacted-summary` message rides into a
2998
- // sub-agent's primer (`requestSubAgent` snapshots the parent's raw history),
2999
- // and it must be translated to a `<conversation_summary>` user message here —
3000
- // otherwise the transports' role filter silently drops it and the sub-agent
3001
- // loses all summarized context. `normalizeForProvider` only touches
3002
- // `compacted-summary`, so it's a safe pass-through for everything else.
3003
- const primer = normalizeForProvider([
3004
- ...(this.primerHistory ?? []),
3005
- ...(transientPrimer ?? []),
3006
- ]);
3007
- const baseHistory = firstLlmCall ? this.history.slice(0, -1) : this.history;
3008
- firstLlmCall = false;
3009
- // Collapse stale tool payloads this agent declared via `condenseWhen`
3010
- // (GENC-1369), then translate any compacted-summary → a
3011
- // <conversation_summary> user message (GENC-1351 §5.7) before any per-agent
3012
- // masking, so the raw display-only role can never reach a transport. Each
3013
- // step runs on a copy — stored history is untouched.
3014
- const condensedHistory = applyCondensation(
3015
- [...baseHistory],
3016
- this.condensePolicies,
3017
- {
3018
- modelCall: this.modelCallSeq,
3019
- turn: this.turnSeq,
3020
- // A call's activation has ended once a later activation is current — a
3021
- // swap or release/complete both advance the counter.
3022
- activationEnded: (activation) => activation < this.currentActivation,
3023
- // A call's phase has ended once the app declared a later phase epoch
3024
- // current via `endPhase()`.
3025
- phaseEnded: (phaseEpoch) => phaseEpoch < this.currentPhaseEpoch,
3026
- batchCalls: this.condenseBatchCalls,
3027
- },
3028
- (detail) => recordMetaEvent(this.sessionKey, 'context.condensed', detail),
3029
- );
3030
- const normalizedHistory = normalizeForProvider(condensedHistory);
3031
- const historyForProvider = this.providerHistoryTransform
3032
- ? this.providerHistoryTransform(normalizedHistory)
3033
- : normalizedHistory;
3034
- const historyForCall = [...primer, ...historyForProvider];
3035
-
3036
- // Resolve the per-turn temperature and tool-call mode the same way the
3037
- // provider is resolved — static value or a function of the turn context
3038
- // (which carries the live state for stateful agents). Resolved before the
3039
- // snapshot so the debug log records the exact request config the model saw.
3040
- const [
3041
- resolvedTemperature,
3042
- resolvedToolChoice,
3043
- resolvedCachePolicy,
3044
- resolvedTailContext,
3045
- resolvedResponseSchema,
3046
- firstResolvedThinkingPolicy,
3047
- ] =
3048
- // oxlint-disable-next-line no-await-in-loop
3049
- await Promise.all([
3050
- this.resolveTurnInput<number>(this.activeTemperatureInput, promptCtx),
3051
- this.resolveTurnInput<ChatToolChoice>(this.activeToolChoiceInput, promptCtx),
3052
- this.resolveTurnInput<CachePolicy>(this.activeCachePolicyInput, promptCtx),
3053
- this.resolveTurnInput<string>(this.activeTailContextInput, promptCtx),
3054
- this.resolveTurnInput<object | undefined>(this.activeResponseSchemaInput, promptCtx),
3055
- // Only consulted on the first iteration (see `pinnedThinkingPolicy`); resolved
3056
- // alongside the others so a resolver still sees the same turn context.
3057
- thinkingPolicyPinned
3058
- ? Promise.resolve(undefined)
3059
- : this.resolveTurnInput<ChatThinkingPolicy | undefined>(
3060
- this.activeThinkingPolicyInput,
3061
- promptCtx,
3062
- ),
3063
- ]);
3064
- if (!thinkingPolicyPinned) {
3065
- pinnedThinkingPolicy = firstResolvedThinkingPolicy;
3066
- thinkingPolicyPinned = true;
3067
- }
3068
- // The system prompt is always just the agent's resolved prompt — byte-stable, so it can be
3069
- // cached. The framework's volatile additions (fold suffix, retry nudge) and the agent's tail
3070
- // context all go to the framed tail: one uniform channel, no cache-scope branch. On a normal
3071
- // turn `frameworkSystemSuffix` is empty, so the tail is empty too — a no-op. The framework
3072
- // owns the framing; the corrective retry nudge lands last (recency).
3073
- const systemPrompt = resolvedSystemPrompt || undefined;
3074
- const tailBody = [resolvedTailContext, frameworkSystemSuffix]
3075
- .map((part) => (part ?? '').trim())
3076
- .filter((part) => part.length > 0)
3077
- .join('\n\n');
3078
- const tailContext = tailBody
3079
- ? `<system-reminder>\n${tailBody}\n</system-reminder>`
3080
- : undefined;
3081
- // An agent/state-configured tool-call mode wins. Otherwise sub-agents must
3082
- // finish by calling a tool (their completion tool) so the turn can't end
3083
- // on a free-text answer; top-level agents stay 'auto'. (Transports no-op a
3084
- // force when no tools are advertised.)
3085
- const effectiveToolChoice = resolvedToolChoice ?? (this.isSubAgent ? 'required' : undefined);
3086
-
3087
- const turnSnapshot = this.recordTurnSnapshot(
3088
- systemPrompt,
3089
- resolvedTemperature,
3090
- effectiveToolChoice,
3091
- tailContext,
3092
- );
3093
-
3094
- // Capture the pending user input, then clear the slots BEFORE the chat
3095
- // call. `sendMessage` already appended the user message to `this.history`,
3096
- // so on retries (empty / malformed) we must rely on history alone —
3097
- // otherwise the message gets sent twice (once via history, once via
3098
- // `currentInput`), which Gemini answers with an empty response and then
3099
- // we retry forever.
3100
- const userInputForCall = currentInput;
3101
- const attachmentsForCall = currentAttachments;
3102
- currentInput = '';
3103
- currentAttachments = undefined;
3104
-
3105
- const options: ChatRequestOptions = {
3106
- systemPrompt,
3107
- // Strip fold-only properties (foldEvent, foldPath) before sending to provider
3108
- tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
3109
- attachments: attachmentsForCall,
3110
- // Per-turn signal: aborts on user cancel, and (via beginTurn's chain)
3111
- // on driver dispose. Cancels the in-flight request either way.
3112
- signal: this.turnController.signal,
3113
- toolChoice: effectiveToolChoice,
3114
- // Agent/state-configured sampling temperature (normalized 0–1); each
3115
- // transport translates it to its native range. Undefined → provider default.
3116
- temperature: resolvedTemperature,
3117
- // Prompt-cache policy for this turn (Anthropic places breakpoints per scope; Gemini
3118
- // caches implicitly regardless). Undefined → no caching requested.
3119
- cachePolicy: resolvedCachePolicy,
3120
- // Extended-thinking posture, pinned for the whole tool loop (one assistant turn) rather
3121
- // than re-resolved per iteration — see `pinnedThinkingPolicy`. Undefined — unset, or the
3122
- // resolver's answer for this turn — is NOT "off": it leaves the model on its own default,
3123
- // so agents that never set this are priced exactly as before. Transports clamp models
3124
- // that can't honour it.
3125
- thinkingPolicy: pinnedThinkingPolicy,
3126
- // Framed volatile context injected at the message tail (never stored). Undefined → none.
3127
- tailContext,
3128
- // Structured-output schema for this turn (agent/state-resolved). When set, the transport
3129
- // constrains the final answer to it natively where the model supports it. Undefined → free text.
3130
- responseSchema: resolvedResponseSchema,
3131
- // Refusal-fallback chain (e.g. Fable 5 → Opus 4.8). Passed to the provider; applied
3132
- // server-side where supported. Undefined → no fallback.
3133
- fallbacks: this.activeFallbacks,
3134
- };
3135
-
3136
- // Resolve the active provider for this turn. Static names were validated
3137
- // in `applyAgent`; function-form names are validated on first resolution
3138
- // here and cached for the agent's lifetime.
3139
- // oxlint-disable-next-line no-await-in-loop
3140
- const activeProvider = await this.resolveProviderForTurn(promptCtx);
3141
-
3142
- // Attribute the turn to the tier/model it resolved. Stamped HERE, not inside
3143
- // `recordTurnSnapshot`: the snapshot is taken before this line runs, so reading
3144
- // `lastResolved*` there yields the PREVIOUS call's model — wrong on precisely the
3145
- // turn where an agent's per-state `provider` selector switches tier, which is the
3146
- // turn a reader is looking for. `model` is refined to the serving model once the
3147
- // response lands (see below); until then — and on a call that throws — it is the
3148
- // model we ASKED for, which is the only thing knowable at that point.
3149
- if (this.lastResolvedProviderName !== undefined) {
3150
- turnSnapshot.providerName = this.lastResolvedProviderName;
3151
- }
3152
- if (this.lastResolvedProvider !== undefined)
3153
- turnSnapshot.provider = this.lastResolvedProvider;
3154
- if (this.lastResolvedModel !== undefined) turnSnapshot.model = this.lastResolvedModel;
3155
-
3156
- let response: ChatMessage;
3157
- try {
3158
- // oxlint-disable-next-line no-await-in-loop
3159
- response = await activeProvider.chat!(historyForCall, userInputForCall, options);
3160
- } catch (e) {
3161
- if (e instanceof MalformedFunctionCallError) {
3162
- malformedAttempts += 1;
3163
- if (malformedAttempts < MAX_MALFORMED_RETRIES) {
3164
- logger.warn(
3165
- `ChatDriver: MALFORMED_FUNCTION_CALL, retrying (${malformedAttempts}/${MAX_MALFORMED_RETRIES})`,
3166
- );
3167
- recordTurnRetry(this.sessionKey, 'malformed-function-call', {
3168
- agent: this.activeAgentName,
3169
- provider: this.lastResolvedProviderName,
3170
- attempt: malformedAttempts,
3171
- maxAttempts: MAX_MALFORMED_RETRIES,
3172
- finishMessage: e.finishMessage,
3173
- });
3174
- iterations -= 1;
3175
- continue;
3176
- }
3177
- logger.error('ChatDriver: MALFORMED_FUNCTION_CALL, max retries reached');
3178
- recordTurnError(this.sessionKey, 'malformed-function-call', {
3179
- agent: this.activeAgentName,
3180
- provider: this.lastResolvedProviderName,
3181
- attempts: malformedAttempts,
3182
- finishMessage: e.finishMessage,
3183
- isSubAgent: this.isSubAgent,
3184
- });
3185
- if (this.isSubAgent) {
3186
- // Bubble a typed failure to the parent instead of speaking to the user.
3187
- this.failSubAgent('malformed_tool_call');
3188
- } else {
3189
- this.appendToHistory({
3190
- role: 'assistant',
3191
- content:
3192
- 'While working on your request, I repeatedly called my tools incorrectly. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
3193
- });
3194
- }
3195
- return this.turnDone('malformed-function-call');
3196
- }
3197
- // The response was truncated at the provider's output-token cap while it
3198
- // still carried a tool call — its arguments are incomplete and unusable.
3199
- // Unlike a malformed call this is DETERMINISTIC: re-issuing the same
3200
- // request hits the same cap, so a silent retry just loops. Bail with a
3201
- // clear, actionable signal instead. The remedy is config (raise the
3202
- // provider maxTokens) or a smaller step — neither is fixed by retrying.
3203
- if (e instanceof ResponseTruncatedError) {
3204
- logger.error('ChatDriver: response truncated at the output-token cap', e);
3205
- recordTurnError(this.sessionKey, 'response-truncated', {
3206
- agent: this.activeAgentName,
3207
- provider: this.lastResolvedProviderName,
3208
- model: e.model,
3209
- maxTokens: e.maxTokens,
3210
- outputTokens: e.outputTokens,
3211
- tools: e.toolNames,
3212
- isSubAgent: this.isSubAgent,
3213
- });
3214
- if (this.isSubAgent) {
3215
- // Bubble a typed failure to the parent instead of speaking to the user.
3216
- this.failSubAgent('response_truncated');
3217
- } else {
3218
- this.appendToHistory({
3219
- role: 'assistant',
3220
- content:
3221
- 'My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once — try breaking your request into smaller steps.',
3222
- });
3223
- }
3224
- return this.turnDone('response-truncated');
3225
- }
3226
- // The AI-spend budget is gone and the proxy refused the request (HTTP
3227
- // 402). Terminal in the strongest sense available to us: unlike a
3228
- // truncation — which a smaller request would get past — *no* request
3229
- // succeeds until someone raises the budget out of band. So there is no
3230
- // retry here and no "try again" in the copy; the turn ends and the host
3231
- // locks the composer off the `'budget-exhausted'` failure reason (see
3232
- // `FoundationAiAssistant.blocked`).
3233
- // The upstream vendor refused this ACCOUNT (GENC-1506) — its credit is gone, a usage cap is
3234
- // reached, or the credential is dead. Terminal in the strongest sense available: unlike a
3235
- // truncation, no smaller request gets past it, and unlike our own budget wall nobody in this
3236
- // system can raise anything to clear it. So no retry and no "try again" in the copy.
3237
- //
3238
- // Placed before the transient-retry step below, which is the whole point: without this the
3239
- // error is an untyped transport failure, gets re-issued MAX_SETUP_TRANSPORT_RETRIES times
3240
- // against a wall that cannot move, and then surfaces as "something went wrong on my end" —
3241
- // wrong twice over, since nothing went wrong on our end and trying again will not help.
3242
- if (e instanceof ProviderRefusedError) {
3243
- if (this.isSubAgent) {
3244
- logger.error('ChatDriver: provider refused the request', e);
3245
- recordTurnError(this.sessionKey, 'provider-refused', {
3246
- agent: this.activeAgentName,
3247
- provider: this.lastResolvedProviderName,
3248
- vendor: vendorTypeOfLabel(e.vendorLabel) ?? this.lastResolvedProvider,
3249
- kind: e.kind,
3250
- upstreamStatus: e.upstreamStatus,
3251
- upstreamType: e.upstreamType,
3252
- isSubAgent: true,
3253
- });
3254
- // The detail is BUBBLED, not merely flagged. Setting `providerRefusedThisTurn` on `this`
3255
- // would be pointless here — `this` is the CHILD, and the parent that must stop is a
3256
- // different driver instance. `invokeSubAgent` reads this payload off the child and sets its
3257
- // own flag; see the branch there.
3258
- const detail = providerRefusedDetailOf(e);
3259
- this.failSubAgent('provider_refused', undefined, detail);
3260
- return this.turnDone('provider-refused', undefined, detail);
3261
- }
3262
- return this.reportProviderRefused(e);
3263
- }
3264
- if (e instanceof BudgetExhaustedError) {
3265
- // Flagged as well as returned: a sub-agent's wall reaches the PARENT
3266
- // only as a tool result, and the parent must not issue another model
3267
- // call after it (see the short-circuit at the top of this loop).
3268
- this.budgetExhaustedThisTurn = true;
3269
- this.budgetWallDetail = budgetDetailOf(e);
3270
- // Paired with the detail everywhere it is written: this is the
3271
- // driver's OWN wall, so the sub-agent marker must not survive from an
3272
- // earlier child and suppress the `lastResolvedProvider` fallback that
3273
- // is legitimate here. Unreachable today (the short-circuit fires
3274
- // before a second wall can land in one turn) — kept structural so the
3275
- // pairing does not depend on that ordering staying true.
3276
- this.budgetWallViaSubAgent = false;
3277
- if (this.isSubAgent) {
3278
- logger.error('ChatDriver: AI budget exhausted', e);
3279
- recordTurnError(this.sessionKey, 'budget-exhausted', {
3280
- agent: this.activeAgentName,
3281
- provider: this.lastResolvedProviderName,
3282
- vendor:
3283
- vendorTypeOfLabel(e.vendorLabel) ??
3284
- vendorTypeOfLabel(e.serverVendor) ??
3285
- this.lastResolvedProvider,
3286
- budgetUsd: e.budgetUsd,
3287
- spentUsd: e.spentUsd,
3288
- isSubAgent: true,
3289
- });
3290
- // Bubble a typed failure to the parent instead of speaking to the
3291
- // user, carrying the attribution so the parent walls the vendor that
3292
- // actually refused rather than its own.
3293
- this.failSubAgent('budget_exhausted', this.budgetWallDetail);
3294
- return this.turnDone('budget-exhausted', this.budgetWallDetail);
3295
- }
3296
- return this.reportBudgetExhausted(e);
3297
- }
3298
- // A request timeout from the transport (tagged `TimeoutError`) is not a
3299
- // bug on our end — surface it distinctly instead of letting it fall
3300
- // through to the generic "something went wrong" catch. No auto-retry:
3301
- // the timeout ceiling is already minutes long, so a silent retry would
3302
- // just make the user wait again.
3303
- if (e instanceof DOMException && e.name === 'TimeoutError') {
3304
- logger.error('ChatDriver: request timed out', e);
3305
- recordTurnError(this.sessionKey, 'exception', {
3306
- agent: this.activeAgentName,
3307
- provider: this.lastResolvedProviderName,
3308
- name: e.name,
3309
- message: e.message,
3310
- isSubAgent: this.isSubAgent,
3311
- });
3312
- if (this.isSubAgent) {
3313
- // Bubble a typed failure to the parent instead of speaking to the user.
3314
- this.failSubAgent('timeout');
3315
- } else {
3316
- this.appendToHistory({
3317
- role: 'assistant',
3318
- content:
3319
- 'The request timed out. You can ask me to try again, or break this into a smaller step.',
3320
- });
3321
- }
3322
- // Recorded as `exception` above (there is no separate `timeout` member of
3323
- // TurnFailureReason for the main turn); surface the same reason here.
3324
- return this.turnDone('exception');
3325
- }
3326
- // The request was aborted: either a user cancel (turnController) or a
3327
- // driver dispose (lifecycleController, chained into the turn). A user
3328
- // cancel adds a "Stopped." marker; a dispose stops quietly. Handled
3329
- // before the transient-retry below so an intentional abort/timeout is
3330
- // never retried.
3331
- if (e instanceof DOMException && e.name === 'AbortError') {
3332
- return this.completeAbortedTurn();
3333
- }
3334
- // A transient provider/transport error should retry the SAME iteration a bounded
3335
- // number of times rather than tearing down the whole turn (which strands the
3336
- // agent's unflushed buffer behind an opaque error message).
3337
- setupTransportAttempts += 1;
3338
- if (setupTransportAttempts < MAX_SETUP_TRANSPORT_RETRIES) {
3339
- logger.warn(
3340
- `ChatDriver: provider/transport error, retrying (${setupTransportAttempts}/${MAX_SETUP_TRANSPORT_RETRIES})`,
3341
- );
3342
- iterations -= 1;
3343
- continue;
3344
- }
3345
- throw e;
3346
- }
3347
-
3348
- // Attribute the response to the concrete model + registry slot that
3349
- // produced it (resolved for this turn in `resolveProviderForTurn`). Carried
3350
- // on the assistant message so the debug-log timeline shows per-message
3351
- // model lineage — and thus per-tool-call, since tool calls ride on the
3352
- // assistant message that requests them. Harmless on a response later
3353
- // discarded as empty/retried; only kept copies reach history.
3354
- //
3355
- // Attach each key only when resolved: a provider with no `getStatus` (etc.)
3356
- // leaves the key off entirely rather than carrying it as `undefined`.
3357
- // JSON.stringify already drops undefined from the exported log, so this is
3358
- // chiefly about keeping the in-memory message shape honest.
3359
- // Fill, never overwrite — but still only when there is something to fill with.
3360
- // A transport that already stamped a model knows something the driver does not:
3361
- // `lastResolvedModel` is the model we ASKED for, so overwriting would relabel a
3362
- // fallback-served turn as the requested model and misattribute its spend.
3363
- // Written as a guard rather than `??=` because `??=` ASSIGNS undefined, which
3364
- // would create the key and break the omit-when-unresolved contract above.
3365
- if (response.model === undefined && this.lastResolvedModel !== undefined) {
3366
- response.model = this.lastResolvedModel;
3367
- }
3368
- if (this.lastResolvedProvider !== undefined) response.provider = this.lastResolvedProvider;
3369
- if (this.lastResolvedProviderName !== undefined) {
3370
- response.providerName = this.lastResolvedProviderName;
3371
- }
3372
-
3373
- // Back-fill what this call cost onto the snapshot taken just before it, so the
3374
- // exported debug log prices each model call next to the prompt/tools/state that
3375
- // produced it (GENC-1480 follow-up). Stamped BEFORE the empty-response branch
3376
- // below deliberately: a blank or refused response is billed and then thrown away,
3377
- // so the snapshot is the only place that spend is ever recorded.
3378
- turnSnapshot.usage = messageUsage(response);
3379
- // Take the SERVING model over the requested one, now that it is known. `response.model`
3380
- // was just filled from `lastResolvedModel` if the transport left it unset, so this is
3381
- // the same rule the message gets — which is the point: a turn and the message it
3382
- // produced must never disagree about which model ran, including when a server-side
3383
- // fallback chain answered on a different model than the one we asked for.
3384
- if (response.model !== undefined) turnSnapshot.model = response.model;
3385
-
3386
- const isThinkingStep = response.content && response.toolCalls?.length;
3387
- const isEmptyResponse = !response.content?.trim() && !response.toolCalls?.length;
3388
- // A pre-output refusal (safety-classifier decline, e.g. Fable 5 `stop_reason: 'refusal'`)
3389
- // comes back with empty content, so it looks like a blank response — but it is deterministic:
3390
- // retrying re-sends the identical request and refuses again, burning up to
3391
- // MAX_EMPTY_RESPONSE_RETRIES turns on the most expensive models for the same outcome, ending
3392
- // in the misleading "blank response" message. Treat it as a terminal, non-retried failure with
3393
- // its own reason and message. (GENC-1461)
3394
- const isRefusal = response.responseMeta?.finishReason === 'refusal';
3395
-
3396
- if (isEmptyResponse) {
3397
- emptyResponseAttempts += 1;
3398
- if (!isRefusal && emptyResponseAttempts < MAX_EMPTY_RESPONSE_RETRIES) {
3399
- logger.warn(
3400
- `ChatDriver: empty model response, retrying (${emptyResponseAttempts}/${MAX_EMPTY_RESPONSE_RETRIES})`,
3401
- );
3402
- recordTurnRetry(this.sessionKey, 'empty-response', {
3403
- agent: this.activeAgentName,
3404
- provider: this.lastResolvedProviderName,
3405
- attempt: emptyResponseAttempts,
3406
- maxAttempts: MAX_EMPTY_RESPONSE_RETRIES,
3407
- // Provider diagnostic (Gemini: finishReason, thoughtsTokens, parts,
3408
- // blockReason) so the timeline shows *why* the turn came back blank —
3409
- // e.g. a high thoughtsTokens with a 'STOP' finish is "thought, then
3410
- // stopped without answering".
3411
- ...response.responseMeta,
3412
- });
3413
- iterations -= 1;
3414
- continue;
3415
- }
3416
- const failureReason = isRefusal ? 'refusal' : 'empty-response';
3417
- logger.error(
3418
- isRefusal
3419
- ? 'ChatDriver: model refused the request (safety decline) — not retrying'
3420
- : 'ChatDriver: empty model response after all retries',
3421
- );
3422
- recordTurnError(this.sessionKey, failureReason, {
3423
- agent: this.activeAgentName,
3424
- provider: this.lastResolvedProviderName,
3425
- attempts: emptyResponseAttempts,
3426
- isSubAgent: this.isSubAgent,
3427
- ...response.responseMeta,
3428
- });
3429
- if (this.isSubAgent) {
3430
- this.failSubAgent(isRefusal ? 'refusal' : 'empty_response');
3431
- } else {
3432
- this.appendToHistory({
3433
- role: 'assistant',
3434
- content: isRefusal
3435
- ? 'This request was declined. Try rephrasing it, or breaking it into smaller steps.'
3436
- : 'While working on your request, I repeatedly generated a blank response. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
3437
- });
3438
- }
3439
- return this.turnDone(failureReason);
3440
- } else {
3441
- // Split one model response into separate, individually-toggleable messages so each has its
3442
- // own visibility toggle and debug-log category:
3443
- // - reasoning (chain-of-thought summary) → category 'reasoning', hidden unless showThinkingSteps
3444
- // - narration (interstitial prose emitted alongside a tool call) → category 'narration',
3445
- // hidden unless showNarration
3446
- // - the answer / tool-call message → always shown; carries this turn's usage.
3447
- // Cost invariant (GENC-1410): exactly ONE message carries cost/tokens and is appended LAST, so
3448
- // `sumCosts`/`sumTokens` don't double-count and `contextTokens` reads it. Reasoning/narration are
3449
- // display-only (usage undefined) and are skipped when building the provider request. `model` /
3450
- // `provider` / `providerName` stay on every split message so each is still attributed.
3451
- //
3452
- // EVERY usage field has to be cleared here, not just the three the invariant was
3453
- // originally written against — keep this list in step with the usage fields on
3454
- // `ChatMessage`. The cache buckets arrived later (GENC-1475) and were left riding
3455
- // along on the copies, so a response carrying reasoning AND narration counted its
3456
- // cache read/write volume three times in `sumUsage`/`usageRows` — invisible in the
3457
- // cost total (which comes from `cost`) but wrong in every bucket display and in the
3458
- // exported log.
3459
- const { reasoning, ...rest } = response;
3460
- const displayOnly = {
3461
- cost: undefined,
3462
- externalCostUsd: undefined,
3463
- inputTokens: undefined,
3464
- outputTokens: undefined,
3465
- cacheReadTokens: undefined,
3466
- cacheWriteTokens: undefined,
3467
- };
3468
- if (reasoning) {
3469
- this.appendToHistory({
3470
- ...rest,
3471
- ...displayOnly,
3472
- content: reasoning,
3473
- toolCalls: undefined,
3474
- category: 'reasoning',
3475
- });
3476
- }
3477
- if (isThinkingStep) {
3478
- // content + tool call → the content is interstitial narration, not the final answer.
3479
- this.appendToHistory({
3480
- ...rest,
3481
- ...displayOnly,
3482
- toolCalls: undefined,
3483
- category: 'narration',
3484
- });
3485
- this.appendToHistory({ ...rest, content: '' });
3486
- } else {
3487
- // No tool call → `content` is the final answer (always shown), or this is a bare tool-call
3488
- // turn with no narration. Either way it carries this turn's usage; append it last.
3489
- this.appendToHistory(rest);
3490
- }
3491
- }
3492
-
3493
- // Reset retry budgets on any productive (non-empty) response, so the caps mean
3494
- // "N CONSECUTIVE failures" not "N total per turn".
3495
- emptyResponseAttempts = 0;
3496
- malformedAttempts = 0;
3497
- setupTransportAttempts = 0;
3498
-
3499
- if (!response.toolCalls?.length) {
3500
- break;
3501
- }
3502
-
3503
- const [toolCalls, systemCalls] = response.toolCalls.reduce<[ChatToolCall[], ChatToolCall[]]>(
3504
- (acc, tc) => {
3505
- if (tc.name === REQUEST_CONTINUATION_TOOL) acc[1].push(tc);
3506
- else acc[0].push(tc);
3507
- return acc;
3508
- },
3509
- [[], []],
3510
- );
3511
-
3512
- const executedById = new Map<
3513
- string,
3514
- {
3515
- toolCallId: string;
3516
- content: string;
3517
- attachments?: ChatImageAttachment[];
3518
- subAgentTrace?: ChatMessage[];
3519
- }
3520
- >();
3521
- const unknownToolIds = new Set<string>();
3522
- // Subset of unknownToolIds that were stale (previously available) rather
3523
- // than hallucinated — drives the `stale` UI flag back-patched below.
3524
- const staleToolIds = new Set<string>();
3525
- let anyRealToolExecuted = false;
3526
- let hitUnknownToolLimit = false;
3527
-
3528
- if (toolCalls.length > 0) {
3529
- // oxlint-disable-next-line no-await-in-loop -- Promise.all is correct parallelism, not sequential loop awaiting
3530
- await Promise.all(
3531
- toolCalls.map(async (tc) => {
3532
- // Check for fold facade
3533
- const fold = this.getFold(tc.name);
3534
- if (fold) {
3535
- this.consecutiveFoldOps += 1;
3536
- if (this.consecutiveFoldOps > this.maxFoldOperations) {
3537
- logger.warn(
3538
- `ChatDriver: fold operation limit (${this.maxFoldOperations}) reached — injecting guidance`,
3539
- );
3540
- executedById.set(tc.id, {
3541
- toolCallId: tc.id,
3542
- content: `You have opened and closed folds ${this.consecutiveFoldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
3543
- });
3544
- return;
3545
- }
3546
- const content = await this.openFold(tc.name, fold, tc.args, tc.id);
3547
- executedById.set(tc.id, { toolCallId: tc.id, content });
3548
- // Fold open/close does NOT count as a real iteration — decrement to compensate
3549
- iterations -= 1;
3550
- return;
3551
- }
3552
-
3553
- // Check for close-fold tool
3554
- if (tc.name.startsWith('close_') && this.foldStack.length > 0) {
3555
- const topFoldName = this.foldStack[this.foldStack.length - 1].foldName;
3556
- if (tc.name === `close_${topFoldName}`) {
3557
- this.consecutiveFoldOps += 1;
3558
- if (this.consecutiveFoldOps > this.maxFoldOperations) {
3559
- executedById.set(tc.id, {
3560
- toolCallId: tc.id,
3561
- content: `You have opened and closed folds ${this.consecutiveFoldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
3562
- });
3563
- return;
3564
- }
3565
- const content = this.closeFold();
3566
- executedById.set(tc.id, { toolCallId: tc.id, content });
3567
- iterations -= 1;
3568
- return;
3569
- }
3570
- }
3571
-
3572
- // Regular tool — check if it's inside a fold and guide the model
3573
- const handler = this.toolHandlers[tc.name];
3574
- if (!handler) {
3575
- const containingFold = this.findFoldContaining(tc.name);
3576
- if (containingFold) {
3577
- logger.debug(
3578
- `ChatDriver: model called folded tool "${tc.name}" — guiding to open "${containingFold}"`,
3579
- );
3580
- recordMetaEvent(this.sessionKey, 'tool.unresolved', {
3581
- tool: tc.name,
3582
- agent: this.activeAgentName,
3583
- kind: 'folded',
3584
- fold: containingFold,
3585
- });
3586
- executedById.set(tc.id, {
3587
- toolCallId: tc.id,
3588
- content: `"${tc.name}" is not directly available. It is inside the "${containingFold}" fold. Call ${containingFold} first to access it.`,
3589
- });
3590
- // Guidance does not count as a real iteration or fold op
3591
- iterations -= 1;
3592
- return;
3593
- }
3594
-
3595
- // Not in any registered fold. If the tool was advertised earlier
3596
- // in this agent's lifetime it's *stale* (a stateful agent moved on,
3597
- // or an exclusive fold is hiding it) rather than hallucinated — a
3598
- // distinction worth making, because the model should stop retrying
3599
- // a retired tool rather than treat the failure as a typo. Stale
3600
- // calls still trip loop protection, but at a higher ceiling than
3601
- // hallucinated tools (see below) — they are self-correcting, so the
3602
- // guidance, telemetry, and limit differ.
3603
- if (this.everSeenToolNames.has(tc.name)) {
3604
- this.consecutiveUnknownToolCalls += 1;
3605
- const hidingFold = this.foldHidingTool(tc.name);
3606
- let content: string;
3607
- if (hidingFold) {
3608
- content = `"${tc.name}" is not available while the "${hidingFold}" fold is open. Call close_${hidingFold} to return to the previous set of tools, then call ${tc.name}.`;
3609
- logger.warn(
3610
- `ChatDriver: tool "${tc.name}" is hidden behind open fold "${hidingFold}" (${this.consecutiveUnknownToolCalls}/${MAX_STALE_TOOL_CALLS})`,
3611
- );
3612
- } else {
3613
- content = `"${tc.name}" was available earlier but is not part of the current step — that step is complete, so do not call it again. Continue with the tools available now: ${Object.keys(this.toolHandlers).join(', ') || '(none)'}.`;
3614
- logger.warn(
3615
- `ChatDriver: stale tool "${tc.name}" — advertised earlier this activation but retired in the current state (${this.consecutiveUnknownToolCalls}/${MAX_STALE_TOOL_CALLS})`,
3616
- );
3617
- }
3618
- recordMetaEvent(this.sessionKey, 'tool.unresolved', {
3619
- tool: tc.name,
3620
- agent: this.activeAgentName,
3621
- kind: hidingFold ? 'fold-hidden' : 'stale',
3622
- fold: hidingFold ?? undefined,
3623
- consecutive: this.consecutiveUnknownToolCalls,
3624
- max: MAX_STALE_TOOL_CALLS,
3625
- });
3626
- // Fold-hidden tools keep their fold-specific guidance; a plain
3627
- // stale tool is a step-ordering miss, so offer the agent's
3628
- // redirect when it supplies one.
3629
- const staleContent = hidingFold
3630
- ? content
3631
- : await this.resolveUnresolvedToolContent(tc.name, 'stale', content);
3632
- executedById.set(tc.id, { toolCallId: tc.id, content: staleContent });
3633
- unknownToolIds.add(tc.id);
3634
- staleToolIds.add(tc.id);
3635
- this.recentUnknownToolNames.add(tc.name);
3636
- this.recentStaleToolNames.add(tc.name);
3637
- if (this.consecutiveUnknownToolCalls >= MAX_STALE_TOOL_CALLS) {
3638
- hitUnknownToolLimit = true;
3639
- }
3640
- return;
3641
- }
3642
-
3643
- // Never advertised — a hallucinated tool name.
3644
- this.consecutiveUnknownToolCalls += 1;
3645
- logger.warn(
3646
- `ChatDriver: no handler registered for tool "${tc.name}" (${this.consecutiveUnknownToolCalls}/${DEFAULT_MAX_UNKNOWN_TOOL_CALLS}). Available tools: ${Object.keys(this.toolHandlers).join(', ') || '(none)'}`,
3647
- );
3648
- recordMetaEvent(this.sessionKey, 'tool.unresolved', {
3649
- tool: tc.name,
3650
- agent: this.activeAgentName,
3651
- kind: 'unknown',
3652
- consecutive: this.consecutiveUnknownToolCalls,
3653
- max: DEFAULT_MAX_UNKNOWN_TOOL_CALLS,
3654
- availableTools: Object.keys(this.toolHandlers),
3655
- });
3656
- const unknownContent = await this.resolveUnresolvedToolContent(
3657
- tc.name,
3658
- 'unknown',
3659
- `Unknown tool: ${tc.name}`,
3660
- );
3661
- executedById.set(tc.id, { toolCallId: tc.id, content: unknownContent });
3662
- unknownToolIds.add(tc.id);
3663
- this.recentUnknownToolNames.add(tc.name);
3664
- if (this.consecutiveUnknownToolCalls >= DEFAULT_MAX_UNKNOWN_TOOL_CALLS) {
3665
- hitUnknownToolLimit = true;
3666
- }
3667
- return;
3668
- }
3669
-
3670
- // Real tool execution.
3671
- //
3672
- // The accumulator is declared OUTSIDE the try so the catch can attach it
3673
- // too: a handler that ran sub-agents and then threw (post-processing their
3674
- // results failed, say) has already spent real money on children that
3675
- // completed. Losing their traces on the error path would under-report the
3676
- // run exactly as the single-slot bug did — and just as silently, since
3677
- // `sumUsage` and `usageRows` would still agree with each other.
3678
- const traceCapture: { traces: ChatMessage[][] } = { traces: [] };
3679
- const capturedTrace = (): ChatMessage[] | undefined =>
3680
- traceCapture.traces.length ? traceCapture.traces.flat() : undefined;
3681
- try {
3682
- const result = await handler(tc.args, this.buildHandlerContext(tc.id, traceCapture));
3683
- // A handler that returns images gets them carried as real attachments instead of
3684
- // JSON-stringified into the text — the difference between a model reading
3685
- // `{"kind":"image","data":"iVBOR…"}` as prose and actually SEEING the render.
3686
- // The guard is deliberately narrow (every entry must carry `kind: 'image'`) so an
3687
- // ordinary object return that happens to have an `attachments` key still
3688
- // stringifies exactly as before.
3689
- const resultImages = toolResultImages(result);
3690
- const content = resultImages
3691
- ? typeof (result as { content?: unknown }).content === 'string'
3692
- ? (result as { content: string }).content
3693
- : JSON.stringify({ ...(result as object), attachments: undefined })
3694
- : typeof result === 'string'
3695
- ? result
3696
- : JSON.stringify(result);
3697
- executedById.set(tc.id, {
3698
- toolCallId: tc.id,
3699
- content,
3700
- ...(resultImages ? { attachments: resultImages } : {}),
3701
- // Concatenated when a handler invoked several children, so none is lost.
3702
- // Stays `undefined` when nothing was captured — readers key off presence,
3703
- // and an empty array is a different claim from "no sub-agent ran".
3704
- subAgentTrace: capturedTrace(),
3705
- });
3706
- anyRealToolExecuted = true;
3707
- } catch (e) {
3708
- logger.error(`ChatDriver tool "${tc.name}" failed:`, e);
3709
- recordMetaEvent(this.sessionKey, 'tool.failed', {
3710
- tool: tc.name,
3711
- agent: this.activeAgentName,
3712
- message: e instanceof Error ? e.message : String(e),
3713
- });
3714
- executedById.set(tc.id, {
3715
- toolCallId: tc.id,
3716
- // Structured recovery hint so the model retries or routes around a tool
3717
- // failure instead of apologising and giving up.
3718
- content: `Tool error: ${(e as Error).message}\nRECOVERY: this tool failed once — you may retry it, or take a different valid action to make progress. Do NOT abandon the task, ask the user to rephrase, or claim you cannot make changes. If a planning tool failed, retry it or proceed with the information you already have.`,
3719
- // Children that completed before the throw were still billed — keep
3720
- // their traces so the run's cost stays whole.
3721
- subAgentTrace: capturedTrace(),
3722
- });
3723
- anyRealToolExecuted = true; // treat errors as real work for fold op counting
3724
- }
3725
- }),
3726
- );
3727
- }
3728
-
3729
- // Reset counters whenever a real tool executes
3730
- if (anyRealToolExecuted) {
3731
- this.consecutiveFoldOps = 0;
3732
- this.consecutiveUnknownToolCalls = 0;
3733
- this.recentUnknownToolNames.clear();
3734
- this.recentStaleToolNames.clear();
3735
- }
3736
-
3737
- // Tag tool calls with fold UI metadata before appending results
3738
- const foldPath = this.foldStack.map((f) => f.foldName);
3739
-
3740
- for (const tc of response.toolCalls) {
3741
- if (tc.name === REQUEST_CONTINUATION_TOOL) {
3742
- this.appendToHistory({
3743
- role: 'tool',
3744
- content: '',
3745
- toolResult: { toolCallId: tc.id, content: HANDOFF_TOOL_RESULT_PLACEHOLDER },
3746
- });
3747
- } else {
3748
- const r = executedById.get(tc.id);
3749
- if (r) {
3750
- // Persist only `toolCallId`/`content` on the tool result — the sub-agent
3751
- // trace lives on the tool *call* (back-patched below from `executedById`),
3752
- // which is what the UI's trace panel and `sumCosts` read. Appending the
3753
- // trace here too would duplicate the entire child conversation in history
3754
- // (and the exported debug log) for no reader.
3755
- this.appendToHistory({
3756
- role: 'tool',
3757
- content: '',
3758
- toolResult: {
3759
- toolCallId: r.toolCallId,
3760
- content: r.content,
3761
- // Images DO persist, unlike the sub-agent trace above: they are the payload
3762
- // the model must see on every replay of this turn, not a one-off UI artefact.
3763
- // `condenseWhen` is what sheds them once they are spent — see condense-history.
3764
- ...(r.attachments ? { attachments: r.attachments } : {}),
3765
- },
3766
- });
3767
- }
3768
- }
3769
- }
3770
-
3771
- // Back-patch foldEvent and foldPath onto the tool call message we just appended.
3772
- // The response was appended before execution — find it and annotate.
3773
- let tcMsgIdx = -1;
3774
- for (let i = this.history.length - 1; i >= 0; i -= 1) {
3775
- if (this.history[i].role === 'assistant' && this.history[i].toolCalls?.length) {
3776
- tcMsgIdx = i;
3777
- break;
3778
- }
3779
- }
3780
- if (tcMsgIdx !== -1) {
3781
- const tcMsg = this.history[tcMsgIdx];
3782
- const availableToolNames = Object.keys(this.toolHandlers);
3783
- const annotatedCalls = tcMsg.toolCalls!.map((tc) => {
3784
- const isFoldOpen =
3785
- !!this.getFold(tc.name) ||
3786
- // Was a fold facade at time of the call (now the tool set has changed)
3787
- // — detect by checking if the result message indicated a fold open
3788
- executedById.get(tc.id)?.content?.startsWith('Fold opened:');
3789
- const isFoldClose = executedById.get(tc.id)?.content?.startsWith('Fold closed:');
3790
- const isUnknown = unknownToolIds.has(tc.id);
3791
- return {
3792
- ...tc,
3793
- foldEvent: isFoldOpen
3794
- ? ('open' as const)
3795
- : isFoldClose
3796
- ? ('close' as const)
3797
- : undefined,
3798
- // Use the fold path that was active at the START of this iteration (before any opens/closes)
3799
- foldPath: !isFoldOpen && !isFoldClose && foldPath.length > 0 ? foldPath : undefined,
3800
- unknown: isUnknown || undefined,
3801
- availableTools: isUnknown ? availableToolNames : undefined,
3802
- // Distinguish a retired tool from a hallucinated one so the UI can
3803
- // say "no longer available here" rather than "does not exist".
3804
- stale: staleToolIds.has(tc.id) || undefined,
3805
- subAgentTrace: executedById.get(tc.id)?.subAgentTrace,
3806
- };
3807
- });
3808
- this.history[tcMsgIdx] = { ...tcMsg, toolCalls: annotatedCalls };
3809
- this.dispatchEvent(
3810
- new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
3811
- detail: this.history,
3812
- }),
3813
- );
3814
- }
3815
-
3816
- if (hitUnknownToolLimit) {
3817
- logger.error(
3818
- `ChatDriver: unknown-tool limit (${DEFAULT_MAX_UNKNOWN_TOOL_CALLS}) reached — stopping`,
3819
- );
3820
- const unknownTools = [
3821
- ...new Set([
3822
- ...this.recentUnknownToolNames,
3823
- ...(response.toolCalls ?? [])
3824
- .filter((tc) => unknownToolIds.has(tc.id))
3825
- .map((tc) => tc.name),
3826
- ]),
3827
- ];
3828
- // Stale tools were real earlier this activation; hallucinated tools
3829
- // never existed. The hard stop counts both the same way, but the split
3830
- // tells a triager whether the cause is a state/prompt-design problem
3831
- // (stale) or a model inventing tool names (hallucinated).
3832
- const staleTools = [
3833
- ...new Set([
3834
- ...this.recentStaleToolNames,
3835
- ...(response.toolCalls ?? [])
3836
- .filter((tc) => staleToolIds.has(tc.id))
3837
- .map((tc) => tc.name),
3838
- ]),
3839
- ];
3840
- const hallucinatedTools = unknownTools.filter((t) => !staleTools.includes(t));
3841
- recordTurnError(this.sessionKey, 'unknown-tool-limit', {
3842
- agent: this.activeAgentName,
3843
- provider: this.lastResolvedProviderName,
3844
- unknownTools,
3845
- staleTools,
3846
- hallucinatedTools,
3847
- availableTools: Object.keys(this.toolHandlers),
3848
- isSubAgent: this.isSubAgent,
3849
- });
3850
- if (this.isSubAgent) {
3851
- this.failSubAgent('unknown_tool_limit');
3852
- } else {
3853
- this.appendToHistory({
3854
- role: 'assistant',
3855
- content:
3856
- "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue.",
3857
- });
3858
- }
3859
- return this.turnDone('unknown-tool-limit');
3860
- }
3861
-
3862
- const firstContinuation = systemCalls[0];
3863
- if (firstContinuation) {
3864
- const { summary, remaining_task: remainingTask } = firstContinuation.args as {
3865
- summary: string;
3866
- remaining_task: string;
3867
- };
3868
- return { reason: 'agent-handoff', summary, remainingTask };
3869
- }
3870
-
3871
- // Sub-agent early exit — checked here so the exit point mirrors the
3872
- // system-call pattern above. Set by completeSubAgent() in a tool handler.
3873
- if (this.subAgentCompletion) {
3874
- return this.turnDone();
3875
- }
3876
- }
3877
-
3878
- // The loop fell through: either it hit the iteration cap (a failure) or it
3879
- // broke on a clean final answer (success). Only the former carries a reason.
3880
- let failureReason: TurnFailureReason | undefined;
3881
- if (iterations >= this.maxToolIterations) {
3882
- logger.warn('ChatDriver: reached max tool iterations, stopping');
3883
- recordTurnError(this.sessionKey, 'max-iterations', {
3884
- agent: this.activeAgentName,
3885
- provider: this.lastResolvedProviderName,
3886
- iterations,
3887
- limit: this.maxToolIterations,
3888
- isSubAgent: this.isSubAgent,
3889
- });
3890
- if (this.isSubAgent) {
3891
- this.failSubAgent('max_iterations');
3892
- } else {
3893
- this.appendToHistory({
3894
- role: 'assistant',
3895
- content:
3896
- "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off.",
3897
- });
3898
- }
3899
- failureReason = 'max-iterations';
3900
- }
3901
-
3902
- return this.turnDone(failureReason);
3903
- }
3904
-
3905
- private appendToHistory(message: ChatMessage): void {
3906
- const tagged: ChatMessage = {
3907
- ...message,
3908
- // Stamp on first append; preserve any caller-supplied timestamp.
3909
- timestamp: message.timestamp ?? new Date().toISOString(),
3910
- ...(this.activeAgentName
3911
- ? {
3912
- agentName: this.activeAgentName,
3913
- // Display-only — falls back to agentName in renderers when unset.
3914
- agentLabel: this.activeAgentLabel,
3915
- }
3916
- : {}),
3917
- };
3918
- this.history = [...this.history, tagged];
3919
- this.dispatchEvent(
3920
- new CustomEvent<ReadonlyArray<ChatMessage>>('history-updated', {
3921
- detail: this.history,
3922
- }),
3923
- );
3924
- }
3925
- }