@genesislcap/ai-assistant 14.434.0 → 14.436.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/ai-assistant.api.json +1513 -70
  2. package/dist/ai-assistant.d.ts +367 -7
  3. package/dist/dts/components/ai-driver/ai-driver.d.ts +8 -0
  4. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  5. package/dist/dts/components/chat-driver/chat-driver.d.ts +79 -3
  6. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  7. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +23 -0
  8. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  9. package/dist/dts/config/config.d.ts +106 -2
  10. package/dist/dts/config/config.d.ts.map +1 -1
  11. package/dist/dts/config/define-stateful-agent.d.ts +115 -0
  12. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -0
  13. package/dist/dts/index.d.ts +1 -0
  14. package/dist/dts/index.d.ts.map +1 -1
  15. package/dist/dts/main/main.d.ts +36 -4
  16. package/dist/dts/main/main.d.ts.map +1 -1
  17. package/dist/dts/main/main.template.d.ts.map +1 -1
  18. package/dist/esm/components/chat-driver/chat-driver.js +126 -11
  19. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +192 -33
  20. package/dist/esm/config/define-stateful-agent.js +174 -0
  21. package/dist/esm/index.js +1 -0
  22. package/dist/esm/main/main.js +164 -21
  23. package/dist/esm/main/main.template.js +2 -11
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +16 -16
  26. package/src/components/ai-driver/ai-driver.ts +9 -0
  27. package/src/components/chat-driver/chat-driver.ts +178 -8
  28. package/src/components/orchestrating-driver/orchestrating-driver.ts +191 -17
  29. package/src/config/config.ts +112 -2
  30. package/src/config/define-stateful-agent.ts +293 -0
  31. package/src/index.ts +1 -0
  32. package/src/main/main.template.ts +2 -9
  33. package/src/main/main.ts +167 -14
@@ -10,7 +10,12 @@ import type {
10
10
  SubAgentRequestOptions,
11
11
  } from '@genesislcap/foundation-ai';
12
12
  import { MalformedFunctionCallError } from '@genesislcap/foundation-ai';
13
- import type { AgentConfig } from '../../config/config';
13
+ import type {
14
+ AgentConfig,
15
+ SystemPromptContext,
16
+ SystemPromptInput,
17
+ ToolDefinitionsInput,
18
+ } from '../../config/config';
14
19
  import { applyHistoryCap } from '../../utils/history-transform';
15
20
  import { logger } from '../../utils/logger';
16
21
  import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
@@ -18,6 +23,7 @@ import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
18
23
 
19
24
  const DEFAULT_MAX_TOOL_ITERATIONS = 50;
20
25
  const DEFAULT_MAX_FOLD_OPERATIONS = 5;
26
+ const DEFAULT_MAX_TURN_SNAPSHOTS = 40;
21
27
  const DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5;
22
28
  const MAX_MALFORMED_RETRIES = 2;
23
29
  const MAX_EMPTY_RESPONSE_RETRIES = 3;
@@ -37,6 +43,31 @@ const HANDOFF_TOOL_RESULT_PLACEHOLDER =
37
43
  */
38
44
  export type ChatHistoryUpdatedEvent = CustomEvent<ReadonlyArray<ChatMessage>>;
39
45
 
46
+ /**
47
+ * One captured frame of what the LLM saw on a single tool-loop iteration.
48
+ * The driver records these as a ring buffer (cap: configurable via
49
+ * `chatConfig.agent.maxTurnSnapshots`, default 40) so the export log can show,
50
+ * per turn: which agent was active, the resolved system prompt, the tool names
51
+ * visible to the LLM, and any agent-supplied debug snapshot (e.g. machine
52
+ * state for stateful agents).
53
+ *
54
+ * @beta
55
+ */
56
+ export interface TurnSnapshot {
57
+ /** Monotonic counter across the driver's lifetime (does not reset on agent swap). */
58
+ turnIndex: number;
59
+ /** ISO timestamp captured just before the LLM call. */
60
+ timestamp: string;
61
+ /** Name of the agent active when this LLM call ran. */
62
+ agentName?: string;
63
+ /** Final system prompt sent to the LLM (post-fold-suffix, post-retry hint). */
64
+ systemPrompt?: string;
65
+ /** Tool names sent to the LLM, in order — definitions are static per name so names alone suffice. */
66
+ toolNames: string[];
67
+ /** Agent-supplied snapshot — machine state/context for stateful agents, undefined otherwise. */
68
+ agentSnapshot?: unknown;
69
+ }
70
+
40
71
  interface FoldStackFrame {
41
72
  foldName: string;
42
73
  previousDefinitions: ChatToolDefinition[];
@@ -61,8 +92,22 @@ export class ChatDriver extends EventTarget implements AiDriver {
61
92
  { resolve: (value: any) => void; reject: (reason?: any) => void }
62
93
  >();
63
94
 
64
- private systemPrompt?: string;
95
+ private systemPrompt?: SystemPromptInput;
96
+ /**
97
+ * Resolved tool definitions visible to the LLM. Folds mutate this in place
98
+ * (push/pop on open/close). When `toolDefinitionsFactory` is set, this is
99
+ * overwritten each tool-loop iteration with the factory's output.
100
+ */
65
101
  private toolDefinitions: ChatToolDefinition[];
102
+ /**
103
+ * Optional dynamic-tools source. When set, called each tool-loop iteration
104
+ * to recompute `toolDefinitions` before the LLM call. `defineStatefulAgent`
105
+ * forbids folds when this is set, so the fold-mutation path is unreachable
106
+ * in that case.
107
+ */
108
+ private toolDefinitionsFactory?: (
109
+ ctx: SystemPromptContext,
110
+ ) => ChatToolDefinition[] | Promise<ChatToolDefinition[]>;
66
111
  private toolHandlers: ChatToolHandlers;
67
112
  private primerHistory?: ChatMessage[];
68
113
  private activeAgentName?: string;
@@ -96,22 +141,51 @@ export class ChatDriver extends EventTarget implements AiDriver {
96
141
  * `undefined` means the loop has not been stopped early.
97
142
  */
98
143
  private subAgentCompletion: { result: unknown } | undefined;
144
+ /**
145
+ * Set by `releaseAgent` inside a top-level tool handler — typically a stateful
146
+ * agent's terminal-state handler signalling that its flow is complete and the
147
+ * auto-pin lock can release. Checked by the orchestrator after `sendMessage`
148
+ * returns; the orchestrator fires `onDeactivate` and clears the pin.
149
+ *
150
+ * Reset at the start of each `sendMessage` so a release from a previous turn
151
+ * doesn't leak forward.
152
+ */
153
+ private agentReleaseRequested = false;
154
+ /**
155
+ * Ring buffer of per-LLM-call snapshots. Cap is configurable via
156
+ * `chatConfig.agent.maxTurnSnapshots`; older entries drop off as new ones
157
+ * arrive. See {@link TurnSnapshot} for the captured shape.
158
+ */
159
+ private turnSnapshots: TurnSnapshot[] = [];
160
+ /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
161
+ private globalTurnIndex = 0;
162
+ /** Captured from `applyAgent` so we don't store the whole `AgentConfig`. */
163
+ private debugSnapshotter?: () => unknown;
164
+ private readonly maxTurnSnapshots: number;
99
165
 
100
166
  constructor(
101
167
  private readonly aiProvider: AIProvider,
102
168
  toolHandlers: ChatToolHandlers = {},
103
- toolDefinitions: ChatToolDefinition[] = [],
104
- systemPrompt?: string,
169
+ toolDefinitions: ToolDefinitionsInput = [],
170
+ systemPrompt?: SystemPromptInput,
105
171
  primerHistory?: ChatMessage[],
106
172
  private readonly maxToolIterations: number = DEFAULT_MAX_TOOL_ITERATIONS,
107
173
  maxFoldOperations: number = DEFAULT_MAX_FOLD_OPERATIONS,
174
+ maxTurnSnapshots: number = DEFAULT_MAX_TURN_SNAPSHOTS,
108
175
  ) {
109
176
  super();
110
177
  this.toolHandlers = toolHandlers;
111
- this.toolDefinitions = toolDefinitions;
178
+ if (typeof toolDefinitions === 'function') {
179
+ this.toolDefinitionsFactory = toolDefinitions;
180
+ this.toolDefinitions = [];
181
+ } else {
182
+ this.toolDefinitionsFactory = undefined;
183
+ this.toolDefinitions = toolDefinitions;
184
+ }
112
185
  this.systemPrompt = systemPrompt;
113
186
  this.primerHistory = primerHistory;
114
187
  this.maxFoldOperations = maxFoldOperations;
188
+ this.maxTurnSnapshots = maxTurnSnapshots;
115
189
  }
116
190
 
117
191
  /**
@@ -120,10 +194,19 @@ export class ChatDriver extends EventTarget implements AiDriver {
120
194
  */
121
195
  applyAgent(config: AgentConfig): void {
122
196
  this.systemPrompt = config.systemPrompt;
123
- this.toolDefinitions = config.toolDefinitions ?? [];
197
+ if (typeof config.toolDefinitions === 'function') {
198
+ this.toolDefinitionsFactory = config.toolDefinitions;
199
+ // Cleared each turn by the factory in runToolLoop; empty is safe in the
200
+ // meantime (no LLM call happens before resolution).
201
+ this.toolDefinitions = [];
202
+ } else {
203
+ this.toolDefinitionsFactory = undefined;
204
+ this.toolDefinitions = config.toolDefinitions ?? [];
205
+ }
124
206
  this.toolHandlers = config.toolHandlers ?? {};
125
207
  this.primerHistory = config.primerHistory;
126
208
  this.activeAgentName = config.name;
209
+ this.debugSnapshotter = config.getDebugSnapshot;
127
210
  this.subAgentsMap = new Map((config.subAgents ?? []).map((s) => [s.name, s]));
128
211
  // Reset fold state when agent changes — each specialist starts fresh
129
212
  this.foldStack = [];
@@ -138,6 +221,57 @@ export class ChatDriver extends EventTarget implements AiDriver {
138
221
  return this.subAgentCompletion;
139
222
  }
140
223
 
224
+ /**
225
+ * Returns true if `releaseAgent` was called during the most recent turn.
226
+ * Consumed by the orchestrator to trigger the auto-pin release path.
227
+ */
228
+ getAgentReleaseRequested(): boolean {
229
+ return this.agentReleaseRequested;
230
+ }
231
+
232
+ /**
233
+ * Return the per-turn snapshots captured so far. Used by the host's debug
234
+ * log exporter to show what the LLM saw on each turn — system prompt, tool
235
+ * surface, and agent-supplied state (e.g. a machine snapshot).
236
+ *
237
+ * Ring-buffered at `MAX_TURN_SNAPSHOTS`; older entries are dropped.
238
+ */
239
+ getTurnSnapshots(): ReadonlyArray<TurnSnapshot> {
240
+ return this.turnSnapshots;
241
+ }
242
+
243
+ /**
244
+ * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
245
+ * before each LLM call — that's the latest point where the prompt, tool
246
+ * surface, and agent state line up with what the model is about to see.
247
+ */
248
+ private recordTurnSnapshot(resolvedSystemPrompt: string | undefined): void {
249
+ let agentSnapshot: unknown;
250
+ if (this.debugSnapshotter) {
251
+ try {
252
+ agentSnapshot = this.debugSnapshotter();
253
+ } catch (e) {
254
+ // A snapshotter throwing must not derail the LLM call — capture the
255
+ // error string in place of the snapshot so the export still shows
256
+ // *something* happened.
257
+ agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
258
+ }
259
+ }
260
+ const turnIndex = this.globalTurnIndex;
261
+ this.globalTurnIndex += 1;
262
+ this.turnSnapshots.push({
263
+ turnIndex,
264
+ timestamp: new Date().toISOString(),
265
+ agentName: this.activeAgentName,
266
+ systemPrompt: resolvedSystemPrompt,
267
+ toolNames: this.toolDefinitions.map((t) => t.name),
268
+ agentSnapshot,
269
+ });
270
+ if (this.turnSnapshots.length > this.maxTurnSnapshots) {
271
+ this.turnSnapshots.shift();
272
+ }
273
+ }
274
+
141
275
  /**
142
276
  * Optional transform applied to conversation history immediately before each LLM request.
143
277
  * Cleared when `undefined`. Does not alter stored history.
@@ -340,6 +474,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
340
474
 
341
475
  this.busy = true;
342
476
  this.subAgentCompletion = undefined;
477
+ this.agentReleaseRequested = false;
343
478
  this.appendToHistory({ role: 'user', content: userInput, attachments });
344
479
 
345
480
  try {
@@ -385,6 +520,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
385
520
  }
386
521
  this.subAgentCompletion = { result };
387
522
  },
523
+ releaseAgent: (): void => {
524
+ if (this.agentReleaseRequested) {
525
+ logger.warn(
526
+ `ChatDriver(${this.activeAgentName ?? 'unknown'}): releaseAgent called more than once — ignoring`,
527
+ );
528
+ return;
529
+ }
530
+ this.agentReleaseRequested = true;
531
+ },
388
532
  };
389
533
  }
390
534
 
@@ -681,9 +825,33 @@ export class ChatDriver extends EventTarget implements AiDriver {
681
825
  while (iterations < this.maxToolIterations) {
682
826
  iterations += 1;
683
827
 
828
+ const promptCtx: SystemPromptContext = {
829
+ agentName: this.activeAgentName ?? '',
830
+ history: this.history,
831
+ turnIndex: iterations - 1,
832
+ signal: new AbortController().signal,
833
+ };
834
+
835
+ // Re-resolve dynamic tool definitions before each LLM call. The static
836
+ // case is a no-op (factory is undefined and `this.toolDefinitions` was
837
+ // set by applyAgent). Folds operate on `this.toolDefinitions` and are
838
+ // forbidden when a factory is set, so the array form is always valid.
839
+ // Sequential await is required — each iteration must see fresh values
840
+ // before constructing the LLM request.
841
+ if (this.toolDefinitionsFactory) {
842
+ // eslint-disable-next-line no-await-in-loop
843
+ this.toolDefinitions = await this.toolDefinitionsFactory(promptCtx);
844
+ }
845
+
846
+ const resolvedSystemPrompt =
847
+ typeof this.systemPrompt === 'function'
848
+ ? // eslint-disable-next-line no-await-in-loop
849
+ await this.systemPrompt(promptCtx)
850
+ : this.systemPrompt;
851
+
684
852
  const foldSuffix = this.buildFoldSystemPromptSuffix();
685
- const baseSystemPrompt = this.systemPrompt
686
- ? `${this.systemPrompt}${foldSuffix}`
853
+ const baseSystemPrompt = resolvedSystemPrompt
854
+ ? `${resolvedSystemPrompt}${foldSuffix}`
687
855
  : foldSuffix || undefined;
688
856
 
689
857
  const primer = [...(this.primerHistory ?? []), ...(transientPrimer ?? [])];
@@ -701,6 +869,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
701
869
  ? `${baseSystemPrompt ?? ''}\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.`
702
870
  : baseSystemPrompt;
703
871
 
872
+ this.recordTurnSnapshot(systemPrompt);
873
+
704
874
  // Capture the pending user input, then clear the slots BEFORE the chat
705
875
  // call. `sendMessage` already appended the user message to `this.history`,
706
876
  // so on retries (empty / malformed) we must rely on history alone —
@@ -5,11 +5,21 @@ import type {
5
5
  ChatMessage,
6
6
  ChatRequestOptions,
7
7
  } from '@genesislcap/foundation-ai';
8
- import type { AgentConfig, FallbackAgentConfig, SpecialistAgentConfig } from '../../config/config';
8
+ import type {
9
+ AgentConfig,
10
+ FallbackAgentConfig,
11
+ SpecialistAgentConfig,
12
+ SystemPromptContext,
13
+ SystemPromptInput,
14
+ } from '../../config/config';
9
15
  import { transformHistoryForAgent } from '../../utils/history-transform';
10
16
  import { logger } from '../../utils/logger';
11
17
  import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
12
- import { ChatDriver, REQUEST_CONTINUATION_TOOL } from '../chat-driver/chat-driver';
18
+ import {
19
+ ChatDriver,
20
+ REQUEST_CONTINUATION_TOOL,
21
+ type TurnSnapshot,
22
+ } from '../chat-driver/chat-driver';
13
23
 
14
24
  const DEFAULT_MAX_HANDOFFS = 3;
15
25
  const DEFAULT_CLASSIFIER_HISTORY_LENGTH = 4;
@@ -47,11 +57,17 @@ function isFallback(agent: AgentConfig): agent is FallbackAgentConfig {
47
57
  function buildFallbackSystemPrompt(
48
58
  fallback: FallbackAgentConfig,
49
59
  specialists: SpecialistAgentConfig[],
50
- ): string {
60
+ ): SystemPromptInput | undefined {
51
61
  const agentList = specialists.map((s) => `- ${s.name}: ${s.description}`).join('\n');
52
- if (fallback.systemPrompt) {
62
+ if (typeof fallback.systemPrompt === 'string') {
53
63
  return fallback.systemPrompt.replace('{{agents}}', agentList);
54
64
  }
65
+ if (typeof fallback.systemPrompt === 'function') {
66
+ // Function-form fallback prompt — pass through unchanged. The `{{agents}}`
67
+ // substitution is a string-template convenience; consumers using the
68
+ // function form can compose the agent list themselves if they want it.
69
+ return fallback.systemPrompt;
70
+ }
55
71
  return `You are a helpful assistant. You cannot directly help with the user's request, but the following specialists are available:\n\n${agentList}\n\nPolitely let the user know what you can help with and invite them to rephrase their request.`;
56
72
  }
57
73
 
@@ -69,6 +85,12 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
69
85
  private readonly maxHandoffs: number;
70
86
  private readonly classifierHistoryLength: number;
71
87
  private readonly classifierRetries: number;
88
+ private readonly sessionKey: string;
89
+ /**
90
+ * Aborted on driver disposal. Threaded into `AgentLifecycleContext.signal`
91
+ * so long-running `onActivate` work can bail if the session disconnects.
92
+ */
93
+ private readonly lifecycleAbortController = new AbortController();
72
94
  private pinnedAgentName: string | null = null;
73
95
 
74
96
  activeAgent?: AgentConfig;
@@ -77,20 +99,25 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
77
99
  private readonly aiProvider: AIProvider,
78
100
  private readonly agents: AgentConfig[],
79
101
  options: {
102
+ sessionKey?: string;
80
103
  maxHandoffs?: number;
81
104
  classifierHistoryLength?: number;
82
105
  classifierRetries?: number;
83
106
  maxToolIterations?: number;
84
107
  maxFoldOperations?: number;
108
+ maxTurnSnapshots?: number;
85
109
  } = {},
86
110
  ) {
87
111
  super();
112
+ this.sessionKey = options.sessionKey ?? '';
88
113
  this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
89
114
  this.classifierHistoryLength =
90
115
  options.classifierHistoryLength ?? DEFAULT_CLASSIFIER_HISTORY_LENGTH;
91
116
  this.classifierRetries = options.classifierRetries ?? DEFAULT_CLASSIFIER_RETRIES;
92
117
 
93
- this.specialists = agents.filter(isSpecialist);
118
+ // Specialists drive the classifier. `excludeFromClassifier` agents are still
119
+ // resolvable by name (so manual pinning works) but never auto-routed.
120
+ this.specialists = agents.filter(isSpecialist).filter((a) => !a.excludeFromClassifier);
94
121
  const fallbacks = agents.filter(isFallback);
95
122
  if (fallbacks.length > 1) {
96
123
  logger.warn(
@@ -111,6 +138,7 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
111
138
  undefined,
112
139
  options.maxToolIterations,
113
140
  options.maxFoldOperations,
141
+ options.maxTurnSnapshots,
114
142
  );
115
143
 
116
144
  // Proxy events from the shared driver
@@ -156,6 +184,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
156
184
  return this.chatDriver.getHistory();
157
185
  }
158
186
 
187
+ /** Delegates to the inner {@link ChatDriver} — turns are captured there. */
188
+ getTurnSnapshots(): ReadonlyArray<TurnSnapshot> {
189
+ return this.chatDriver.getTurnSnapshots();
190
+ }
191
+
159
192
  async getSuggestions(
160
193
  history: ChatMessage[],
161
194
  prompt: string,
@@ -169,7 +202,10 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
169
202
  const agentInfo = candidates.map((s) => ({
170
203
  name: s.name,
171
204
  description: s.description,
172
- tools: s.toolDefinitions ?? [],
205
+ // Suggestions use tool names for prompt hints. Dynamic agents resolve
206
+ // their tools per-turn against a SystemPromptContext we don't have here
207
+ // — pass an empty list rather than invoke the factory with a fake one.
208
+ tools: Array.isArray(s.toolDefinitions) ? s.toolDefinitions : [],
173
209
  }));
174
210
  return this.chatDriver.getSuggestions(history, prompt, count, agentInfo);
175
211
  }
@@ -194,7 +230,8 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
194
230
  let remainingTask = '';
195
231
 
196
232
  while (true) {
197
- this.applyAgent(currentAgent);
233
+ // eslint-disable-next-line no-await-in-loop
234
+ await this.applyAgent(currentAgent);
198
235
 
199
236
  let result: ChatDriverResult;
200
237
  if (isHandoff) {
@@ -208,6 +245,16 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
208
245
  result = await this.chatDriver.sendMessage(input, attachments);
209
246
  }
210
247
 
248
+ // Release check: a stateful agent called `releaseAgent` from a terminal
249
+ // tool handler. Fire onDeactivate, clear the pin, drop the user back to
250
+ // classifier-mode. The LLM has already emitted its final wrap-up message
251
+ // by the time we get here — release is purely a teardown.
252
+ if (this.chatDriver.getAgentReleaseRequested()) {
253
+ // eslint-disable-next-line no-await-in-loop
254
+ await this.releaseActiveAgent();
255
+ break;
256
+ }
257
+
211
258
  // Pinned agents never hand off — the continuation tool is filtered out in
212
259
  // applyAgent, but this guards against a model hallucinating a handoff result.
213
260
  if (result.reason !== 'agent-handoff' || isFallback(currentAgent) || pinned) {
@@ -239,17 +286,74 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
239
286
  return this.chatDriver.continueFromHistory(transientPrimer);
240
287
  }
241
288
 
242
- private applyAgent(agent: AgentConfig): void {
243
- // Fallback and pinned agents are terminal — neither should hand off.
244
- const isTerminal = isFallback(agent) || this.pinnedAgentName !== null;
245
- const agentToApply = isTerminal
246
- ? agent
247
- : {
248
- ...agent,
249
- toolDefinitions: [...(agent.toolDefinitions ?? []), REQUEST_CONTINUATION_DEFINITION],
250
- };
251
-
289
+ private async applyAgent(agent: AgentConfig): Promise<void> {
252
290
  const previousAgent = this.activeAgent;
291
+ const isSwitch = !previousAgent || previousAgent.name !== agent.name;
292
+
293
+ // Fire lifecycle hooks around the swap — outgoing first, then incoming.
294
+ // Both are awaited so a heavy `onActivate` (e.g. machine restore) completes
295
+ // before the agent's first turn runs.
296
+ if (isSwitch && previousAgent?.onDeactivate) {
297
+ try {
298
+ await previousAgent.onDeactivate({
299
+ agentName: previousAgent.name,
300
+ sessionKey: this.sessionKey,
301
+ signal: this.lifecycleAbortController.signal,
302
+ });
303
+ } catch (e) {
304
+ logger.warn(`OrchestratingDriver: onDeactivate("${previousAgent.name}") threw:`, e);
305
+ }
306
+ }
307
+ if (isSwitch && agent.onActivate) {
308
+ try {
309
+ await agent.onActivate({
310
+ agentName: agent.name,
311
+ sessionKey: this.sessionKey,
312
+ previousAgentName: previousAgent?.name,
313
+ signal: this.lifecycleAbortController.signal,
314
+ });
315
+ } catch (e) {
316
+ logger.warn(`OrchestratingDriver: onActivate("${agent.name}") threw:`, e);
317
+ }
318
+ }
319
+
320
+ const hasLifecycleHooks = !!(agent.onActivate || agent.onDeactivate);
321
+
322
+ // Stateful agents auto-pin on activation. The pin guarantees the machine
323
+ // survives subsequent turns (the classifier would otherwise be free to
324
+ // route away mid-flow, tearing the machine down). Release happens when the
325
+ // agent calls `releaseAgent` from a terminal-state tool handler — see the
326
+ // post-sendMessage check below.
327
+ if (isSwitch && hasLifecycleHooks && this.pinnedAgentName !== agent.name) {
328
+ this.pinnedAgentName = agent.name;
329
+ this.dispatchEvent(new CustomEvent('pinned-changed', { detail: agent.name }));
330
+ }
331
+
332
+ // Terminal agents do not get the cross-agent handoff tool. Three cases:
333
+ // • fallback — already a leaf; handoff would loop
334
+ // • pinned — user explicitly selected this agent; do not auto-route away
335
+ // • stateful — agents with lifecycle hooks own state for the duration of
336
+ // their flow. Initiating a handoff mid-flow would abandon
337
+ // that state with no clean exit and dump the user into the
338
+ // classifier mid-machine. Capture the tool loop until the
339
+ // user (or the agent itself, via `releaseAgent`) releases.
340
+ const isTerminal = isFallback(agent) || this.pinnedAgentName !== null || hasLifecycleHooks;
341
+
342
+ let agentToApply: AgentConfig = agent;
343
+ if (!isTerminal) {
344
+ const declaredTools = agent.toolDefinitions;
345
+ agentToApply = {
346
+ ...agent,
347
+ toolDefinitions:
348
+ typeof declaredTools === 'function'
349
+ ? async (ctx: SystemPromptContext) => [
350
+ ...(await declaredTools(ctx)),
351
+ REQUEST_CONTINUATION_DEFINITION,
352
+ ]
353
+ : [...(declaredTools ?? []), REQUEST_CONTINUATION_DEFINITION],
354
+ };
355
+ }
356
+
253
357
  if (previousAgent && previousAgent.name !== agent.name) {
254
358
  const rawHistory = this.chatDriver.getHistory() as ChatMessage[];
255
359
  this.chatDriver.loadHistory([...rawHistory, { role: 'system-event', content: agent.name }]);
@@ -262,12 +366,82 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
262
366
  this.dispatchEvent(new CustomEvent('agent-changed', { detail: agent }));
263
367
  }
264
368
 
369
+ /**
370
+ * Release the current stateful agent: fire `onDeactivate`, clear the pin,
371
+ * dispatch events so the host (and Redux) reflect the unpinned state. Called
372
+ * automatically when a tool handler invokes `context.releaseAgent`.
373
+ */
374
+ private async releaseActiveAgent(): Promise<void> {
375
+ const agent = this.activeAgent;
376
+ if (!agent) return;
377
+ if (agent.onDeactivate) {
378
+ try {
379
+ await agent.onDeactivate({
380
+ agentName: agent.name,
381
+ sessionKey: this.sessionKey,
382
+ signal: this.lifecycleAbortController.signal,
383
+ });
384
+ } catch (e) {
385
+ logger.warn(`OrchestratingDriver: release onDeactivate("${agent.name}") threw:`, e);
386
+ }
387
+ }
388
+ this.activeAgent = undefined;
389
+ if (this.pinnedAgentName !== null) {
390
+ this.pinnedAgentName = null;
391
+ this.dispatchEvent(new CustomEvent('pinned-changed', { detail: null }));
392
+ }
393
+ this.dispatchEvent(new CustomEvent('agent-released', { detail: agent }));
394
+ this.dispatchEvent(new CustomEvent('agent-changed', { detail: undefined }));
395
+ }
396
+
397
+ /**
398
+ * Fire `onDeactivate` on the current active agent and abort any pending
399
+ * lifecycle work. Called by the host on session teardown so machines can
400
+ * release resources cleanly.
401
+ */
402
+ async dispose(): Promise<void> {
403
+ const previousAgent = this.activeAgent;
404
+ if (previousAgent?.onDeactivate) {
405
+ try {
406
+ await previousAgent.onDeactivate({
407
+ agentName: previousAgent.name,
408
+ sessionKey: this.sessionKey,
409
+ signal: this.lifecycleAbortController.signal,
410
+ });
411
+ } catch (e) {
412
+ logger.warn(`OrchestratingDriver: dispose onDeactivate("${previousAgent.name}") threw:`, e);
413
+ }
414
+ }
415
+ this.lifecycleAbortController.abort();
416
+ this.activeAgent = undefined;
417
+ }
418
+
265
419
  private async classify(
266
420
  input: string,
267
421
  history: ChatMessage[],
268
422
  contextAgent?: AgentConfig,
269
423
  ): Promise<AgentConfig> {
424
+ // Single-candidate short-circuits. No point asking the LLM to route
425
+ // when there's only one viable choice. Skipped if a fallback is
426
+ // configured — that's an explicit "escape hatch" signal from the
427
+ // consumer, and we preserve the LLM's ability to send unrelated
428
+ // messages there by returning -1 from select_agent.
429
+ if (this.specialists.length === 1 && !this.fallback) {
430
+ return this.specialists[0];
431
+ }
270
432
  if (this.specialists.length === 0) {
433
+ // No classifier-eligible specialists. If exactly one non-fallback
434
+ // agent exists (typically a stateful agent flagged
435
+ // `excludeFromClassifier`) and there's no fallback to preserve as an
436
+ // escape hatch, route to it — this is what fixes the previously-
437
+ // silent single-stateful-agent case. Otherwise drop to the fallback;
438
+ // excluded specialists remain reachable via manual pin.
439
+ if (!this.fallback) {
440
+ const routable = this.agents.filter((a) => !isFallback(a));
441
+ if (routable.length === 1) {
442
+ return routable[0];
443
+ }
444
+ }
271
445
  return this.fallback ?? { name: 'Assistant', fallback: true };
272
446
  }
273
447