@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.
- package/package.json +17 -18
- package/api-extractor.json +0 -11
- package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +0 -1
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts +0 -2
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts.map +0 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +0 -2
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +0 -1
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +0 -2
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +0 -1
- package/dist/dts/config/define-stateful-agent.test.d.ts +0 -2
- package/dist/dts/config/define-stateful-agent.test.d.ts.map +0 -1
- package/dist/dts/config/validate-providers.test.d.ts +0 -2
- package/dist/dts/config/validate-providers.test.d.ts.map +0 -1
- package/dist/dts/main/blocked-state.test.d.ts +0 -2
- package/dist/dts/main/blocked-state.test.d.ts.map +0 -1
- package/dist/dts/main/budget-meter.test.d.ts +0 -2
- package/dist/dts/main/budget-meter.test.d.ts.map +0 -1
- package/dist/dts/main/cost-session-banking.test.d.ts +0 -2
- package/dist/dts/main/cost-session-banking.test.d.ts.map +0 -1
- package/dist/dts/main/file-attachments.test.d.ts +0 -2
- package/dist/dts/main/file-attachments.test.d.ts.map +0 -1
- package/dist/dts/main/interaction-cost.test.d.ts +0 -2
- package/dist/dts/main/interaction-cost.test.d.ts.map +0 -1
- package/dist/dts/main/main.styles.test.d.ts +0 -2
- package/dist/dts/main/main.styles.test.d.ts.map +0 -1
- package/dist/dts/main/persistence-broken-sources.test.d.ts +0 -2
- package/dist/dts/main/persistence-broken-sources.test.d.ts.map +0 -1
- package/dist/dts/main/popout-interaction-gate.test.d.ts +0 -2
- package/dist/dts/main/popout-interaction-gate.test.d.ts.map +0 -1
- package/dist/dts/state/ai-assistant-slice.test.d.ts +0 -2
- package/dist/dts/state/ai-assistant-slice.test.d.ts.map +0 -1
- package/dist/dts/state/debug-event-log.test.d.ts +0 -2
- package/dist/dts/state/debug-event-log.test.d.ts.map +0 -1
- package/dist/dts/state/driver-registry.test.d.ts +0 -2
- package/dist/dts/state/driver-registry.test.d.ts.map +0 -1
- package/dist/dts/state/interaction-context.test.d.ts +0 -2
- package/dist/dts/state/interaction-context.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts +0 -2
- package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/diagnostics.test.d.ts +0 -2
- package/dist/dts/state/persistence/diagnostics.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persistence-provider.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persistence-provider.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persistence.integration.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persistence.integration.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persister.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persister.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-snapshot.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-snapshot.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts +0 -2
- package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts.map +0 -1
- package/dist/dts/utils/animation-exclusivity.test.d.ts +0 -2
- package/dist/dts/utils/animation-exclusivity.test.d.ts.map +0 -1
- package/dist/dts/utils/collect-session-models.test.d.ts +0 -2
- package/dist/dts/utils/collect-session-models.test.d.ts.map +0 -1
- package/dist/dts/utils/condense-history.test.d.ts +0 -2
- package/dist/dts/utils/condense-history.test.d.ts.map +0 -1
- package/dist/dts/utils/cost-session-history.test.d.ts +0 -2
- package/dist/dts/utils/cost-session-history.test.d.ts.map +0 -1
- package/dist/dts/utils/derive-cost-session-title.test.d.ts +0 -2
- package/dist/dts/utils/derive-cost-session-title.test.d.ts.map +0 -1
- package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts +0 -2
- package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts.map +0 -1
- package/dist/dts/utils/history-transform.test.d.ts +0 -2
- package/dist/dts/utils/history-transform.test.d.ts.map +0 -1
- package/dist/dts/utils/message-partition.test.d.ts +0 -2
- package/dist/dts/utils/message-partition.test.d.ts.map +0 -1
- package/dist/dts/utils/resolve-cost-history-config.test.d.ts +0 -2
- package/dist/dts/utils/resolve-cost-history-config.test.d.ts.map +0 -1
- package/dist/dts/utils/resolve-preference-baseline.test.d.ts +0 -2
- package/dist/dts/utils/resolve-preference-baseline.test.d.ts.map +0 -1
- package/dist/dts/utils/strip-agent-handlers.test.d.ts +0 -2
- package/dist/dts/utils/strip-agent-handlers.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-costs.test.d.ts +0 -2
- package/dist/dts/utils/sum-costs.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-tokens.test.d.ts +0 -2
- package/dist/dts/utils/sum-tokens.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-usage.test.d.ts +0 -2
- package/dist/dts/utils/sum-usage.test.d.ts.map +0 -1
- package/dist/dts/utils/usage-rows.test.d.ts +0 -2
- package/dist/dts/utils/usage-rows.test.d.ts.map +0 -1
- package/dist/esm/components/chat-driver/chat-driver.compact.test.js +0 -93
- package/dist/esm/components/chat-driver/chat-driver.invocation-scope.test.js +0 -238
- package/dist/esm/components/chat-driver/chat-driver.test.js +0 -2770
- package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +0 -137
- package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +0 -200
- package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +0 -268
- package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +0 -104
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +0 -312
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +0 -76
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.pin.test.js +0 -61
- package/dist/esm/components/settings-modal/settings-modal.styles.test.js +0 -80
- package/dist/esm/components/settings-modal/settings-modal.template.test.js +0 -91
- package/dist/esm/config/define-stateful-agent.test.js +0 -53
- package/dist/esm/config/validate-providers.test.js +0 -100
- package/dist/esm/main/blocked-state.test.js +0 -1065
- package/dist/esm/main/budget-meter.test.js +0 -359
- package/dist/esm/main/cost-session-banking.test.js +0 -538
- package/dist/esm/main/file-attachments.test.js +0 -164
- package/dist/esm/main/interaction-cost.test.js +0 -107
- package/dist/esm/main/main.styles.test.js +0 -161
- package/dist/esm/main/persistence-broken-sources.test.js +0 -180
- package/dist/esm/main/popout-interaction-gate.test.js +0 -54
- package/dist/esm/state/ai-assistant-slice.test.js +0 -229
- package/dist/esm/state/debug-event-log.test.js +0 -117
- package/dist/esm/state/driver-registry.test.js +0 -55
- package/dist/esm/state/interaction-context.test.js +0 -78
- package/dist/esm/state/persistence/diagnostics-cursors.test.js +0 -30
- package/dist/esm/state/persistence/diagnostics.test.js +0 -280
- package/dist/esm/state/persistence/session-persistence-provider.test.js +0 -110
- package/dist/esm/state/persistence/session-persistence.integration.test.js +0 -113
- package/dist/esm/state/persistence/session-persister.test.js +0 -613
- package/dist/esm/state/persistence/session-snapshot.test.js +0 -256
- package/dist/esm/state/persistence/stateful-restore.e2e.test.js +0 -158
- package/dist/esm/utils/animation-exclusivity.test.js +0 -52
- package/dist/esm/utils/collect-session-models.test.js +0 -53
- package/dist/esm/utils/condense-history.test.js +0 -547
- package/dist/esm/utils/cost-session-history.test.js +0 -174
- package/dist/esm/utils/derive-cost-session-title.test.js +0 -37
- package/dist/esm/utils/flatten-sub-agent-messages.test.js +0 -139
- package/dist/esm/utils/history-transform.test.js +0 -158
- package/dist/esm/utils/message-partition.test.js +0 -90
- package/dist/esm/utils/resolve-cost-history-config.test.js +0 -32
- package/dist/esm/utils/resolve-preference-baseline.test.js +0 -69
- package/dist/esm/utils/strip-agent-handlers.test.js +0 -81
- package/dist/esm/utils/sum-costs.test.js +0 -114
- package/dist/esm/utils/sum-tokens.test.js +0 -51
- package/dist/esm/utils/sum-usage.test.js +0 -164
- package/dist/esm/utils/usage-rows.test.js +0 -189
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/license.txt +0 -46
- package/sandbox/tsconfig.json +0 -8
- package/src/channel/ai-activity-bus.ts +0 -209
- package/src/channel/ai-activity-channel.ts +0 -146
- package/src/chat-driver-node.ts +0 -159
- package/src/components/activity-halo/activity-halo.ts +0 -106
- package/src/components/agent-picker/agent-picker.constants.ts +0 -2
- package/src/components/agent-picker/agent-picker.styles.ts +0 -24
- package/src/components/agent-picker/agent-picker.template.ts +0 -54
- package/src/components/agent-picker/agent-picker.ts +0 -99
- package/src/components/agent-picker/index.ts +0 -1
- package/src/components/ai-driver/ai-driver.ts +0 -166
- package/src/components/ai-driver/index.ts +0 -1
- package/src/components/chat-bubble/chat-bubble.styles.ts +0 -194
- package/src/components/chat-bubble/chat-bubble.template.ts +0 -67
- package/src/components/chat-bubble/chat-bubble.ts +0 -436
- package/src/components/chat-bubble/index.ts +0 -1
- package/src/components/chat-driver/align-event-globals.ts +0 -23
- package/src/components/chat-driver/chat-driver.compact.test.ts +0 -131
- package/src/components/chat-driver/chat-driver.invocation-scope.test.ts +0 -285
- package/src/components/chat-driver/chat-driver.test.ts +0 -3679
- package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +0 -185
- package/src/components/chat-driver/chat-driver.trace-capture.test.ts +0 -251
- package/src/components/chat-driver/chat-driver.ts +0 -3925
- package/src/components/chat-driver/chat-driver.turn-usage.test.ts +0 -362
- package/src/components/chat-driver/index.ts +0 -1
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts +0 -7
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts +0 -6
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +0 -144
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +0 -243
- package/src/components/chat-interaction-wrapper/index.ts +0 -3
- package/src/components/chat-markdown/chat-markdown.ts +0 -89
- package/src/components/chat-markdown/index.ts +0 -1
- package/src/components/flowing-waves-indicator.ts +0 -286
- package/src/components/halo-overlay.ts +0 -184
- package/src/components/orchestrating-driver/index.ts +0 -1
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +0 -438
- package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +0 -107
- package/src/components/orchestrating-driver/orchestrating-driver.pin.test.ts +0 -96
- package/src/components/orchestrating-driver/orchestrating-driver.ts +0 -813
- package/src/components/plasma-orb-indicator.ts +0 -281
- package/src/components/popout-manager/index.ts +0 -1
- package/src/components/popout-manager/popout-manager.ts +0 -212
- package/src/components/settings-modal/index.ts +0 -2
- package/src/components/settings-modal/settings-modal.styles.test.ts +0 -94
- package/src/components/settings-modal/settings-modal.styles.ts +0 -853
- package/src/components/settings-modal/settings-modal.template.test.ts +0 -124
- package/src/components/settings-modal/settings-modal.template.ts +0 -943
- package/src/components/waves-indicator.ts +0 -241
- package/src/config/config.ts +0 -608
- package/src/config/define-stateful-agent.test.ts +0 -64
- package/src/config/define-stateful-agent.ts +0 -642
- package/src/config/fallback-agents.ts +0 -29
- package/src/config/index.ts +0 -2
- package/src/config/validate-providers.test.ts +0 -148
- package/src/config/validate-providers.ts +0 -58
- package/src/index.ts +0 -29
- package/src/main/blocked-state.test.ts +0 -1444
- package/src/main/budget-meter.test.ts +0 -477
- package/src/main/cost-session-banking.test.ts +0 -679
- package/src/main/file-attachments.test.ts +0 -215
- package/src/main/index.ts +0 -3
- package/src/main/interaction-cost.test.ts +0 -140
- package/src/main/main.styles.test.ts +0 -216
- package/src/main/main.styles.ts +0 -995
- package/src/main/main.template.ts +0 -1119
- package/src/main/main.ts +0 -5432
- package/src/main/main.types.ts +0 -225
- package/src/main/persistence-broken-sources.test.ts +0 -219
- package/src/main/popout-interaction-gate.test.ts +0 -87
- package/src/provider/ai-provider-switcher.ts +0 -40
- package/src/provider/assistant-app-settings.ts +0 -84
- package/src/state/ai-assistant-slice.test.ts +0 -336
- package/src/state/ai-assistant-slice.ts +0 -604
- package/src/state/debug-event-log.test.ts +0 -154
- package/src/state/debug-event-log.ts +0 -358
- package/src/state/driver-registry.test.ts +0 -70
- package/src/state/driver-registry.ts +0 -79
- package/src/state/interaction-context.test.ts +0 -90
- package/src/state/interaction-context.ts +0 -101
- package/src/state/persistence/build-timeline-entries.ts +0 -66
- package/src/state/persistence/diagnostics-cursors.test.ts +0 -53
- package/src/state/persistence/diagnostics-cursors.ts +0 -80
- package/src/state/persistence/diagnostics.test.ts +0 -346
- package/src/state/persistence/diagnostics.ts +0 -220
- package/src/state/persistence/index.ts +0 -10
- package/src/state/persistence/persister-registry.ts +0 -55
- package/src/state/persistence/session-persistence-provider.test.ts +0 -136
- package/src/state/persistence/session-persistence-provider.ts +0 -226
- package/src/state/persistence/session-persistence.integration.test.ts +0 -141
- package/src/state/persistence/session-persister.test.ts +0 -787
- package/src/state/persistence/session-persister.ts +0 -917
- package/src/state/persistence/session-snapshot.test.ts +0 -352
- package/src/state/persistence/session-snapshot.ts +0 -344
- package/src/state/persistence/stateful-restore.e2e.test.ts +0 -229
- package/src/state/session-store.ts +0 -56
- package/src/styles/ai-colours.ts +0 -30
- package/src/styles/menu.ts +0 -187
- package/src/styles/settings-section.ts +0 -40
- package/src/suggestions/chat-suggestions.ts +0 -186
- package/src/tags/index.ts +0 -1
- package/src/types/ai-chat-widget.ts +0 -67
- package/src/types/interaction-context.ts +0 -82
- package/src/utils/animated-panel-toggle.ts +0 -62
- package/src/utils/animation-exclusivity.test.ts +0 -72
- package/src/utils/animation-exclusivity.ts +0 -40
- package/src/utils/banked-usage-baselines.ts +0 -90
- package/src/utils/collect-session-models.test.ts +0 -82
- package/src/utils/collect-session-models.ts +0 -65
- package/src/utils/condense-history.test.ts +0 -746
- package/src/utils/condense-history.ts +0 -448
- package/src/utils/cost-session-history.test.ts +0 -226
- package/src/utils/cost-session-history.ts +0 -218
- package/src/utils/derive-cost-session-title.test.ts +0 -63
- package/src/utils/derive-cost-session-title.ts +0 -76
- package/src/utils/flatten-sub-agent-messages.test.ts +0 -163
- package/src/utils/flatten-sub-agent-messages.ts +0 -88
- package/src/utils/format-usd.ts +0 -26
- package/src/utils/history-transform.test.ts +0 -228
- package/src/utils/history-transform.ts +0 -163
- package/src/utils/index.ts +0 -3
- package/src/utils/logger.ts +0 -3
- package/src/utils/message-partition.test.ts +0 -129
- package/src/utils/message-partition.ts +0 -115
- package/src/utils/resolve-cost-history-config.test.ts +0 -38
- package/src/utils/resolve-cost-history-config.ts +0 -38
- package/src/utils/resolve-preference-baseline.test.ts +0 -84
- package/src/utils/resolve-preference-baseline.ts +0 -80
- package/src/utils/strip-agent-handlers.test.ts +0 -99
- package/src/utils/strip-agent-handlers.ts +0 -53
- package/src/utils/sum-costs.test.ts +0 -136
- package/src/utils/sum-costs.ts +0 -18
- package/src/utils/sum-tokens.test.ts +0 -93
- package/src/utils/sum-tokens.ts +0 -68
- package/src/utils/sum-usage.test.ts +0 -191
- package/src/utils/sum-usage.ts +0 -208
- package/src/utils/tool-fold.ts +0 -181
- package/src/utils/usage-rows.test.ts +0 -237
- package/src/utils/usage-rows.ts +0 -187
- package/src/utils/with-timeout.ts +0 -24
- 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
|
-
}
|