@genesislcap/ai-assistant 14.436.0 → 14.437.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.
@@ -7,6 +7,7 @@ import type {
7
7
  ChatToolCall,
8
8
  ChatToolDefinition,
9
9
  ChatToolHandlers,
10
+ InteractionRequestOptions,
10
11
  SubAgentRequestOptions,
11
12
  } from '@genesislcap/foundation-ai';
12
13
  import { MalformedFunctionCallError } from '@genesislcap/foundation-ai';
@@ -15,6 +16,7 @@ import type {
15
16
  SystemPromptContext,
16
17
  SystemPromptInput,
17
18
  ToolDefinitionsInput,
19
+ ToolHandlersInput,
18
20
  } from '../../config/config';
19
21
  import { applyHistoryCap } from '../../utils/history-transform';
20
22
  import { logger } from '../../utils/logger';
@@ -64,6 +66,12 @@ export interface TurnSnapshot {
64
66
  systemPrompt?: string;
65
67
  /** Tool names sent to the LLM, in order — definitions are static per name so names alone suffice. */
66
68
  toolNames: string[];
69
+ /**
70
+ * Per-turn display label resolved from the agent's `displayName`, e.g.
71
+ * "Guided Booking (Counterparties)". `agentName` stays as the canonical
72
+ * identity used for routing/filtering.
73
+ */
74
+ agentLabel?: string;
67
75
  /** Agent-supplied snapshot — machine state/context for stateful agents, undefined otherwise. */
68
76
  agentSnapshot?: unknown;
69
77
  }
@@ -89,7 +97,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
89
97
  private busy = false;
90
98
  private pendingInteractions = new Map<
91
99
  string,
92
- { resolve: (value: any) => void; reject: (reason?: any) => void }
100
+ {
101
+ resolve: (value: any) => void;
102
+ reject: (reason?: any) => void;
103
+ /** Present when the call requested a chat-input override. */
104
+ overrideId?: string;
105
+ }
93
106
  >();
94
107
 
95
108
  private systemPrompt?: SystemPromptInput;
@@ -108,16 +121,44 @@ export class ChatDriver extends EventTarget implements AiDriver {
108
121
  private toolDefinitionsFactory?: (
109
122
  ctx: SystemPromptContext,
110
123
  ) => ChatToolDefinition[] | Promise<ChatToolDefinition[]>;
124
+ /**
125
+ * Resolved tool handler map used for dispatch. When `toolHandlersFactory` is
126
+ * set, this is overwritten each tool-loop iteration with the factory's output
127
+ * — keeping it in lockstep with `toolDefinitions` so handlers don't have to
128
+ * defend themselves against being dispatched in states where their tool
129
+ * isn't advertised. Folds mutate this in place; `defineStatefulAgent`
130
+ * forbids folds when a factory is set, so the fold-mutation path is
131
+ * unreachable in that case.
132
+ */
111
133
  private toolHandlers: ChatToolHandlers;
134
+ /**
135
+ * Optional per-turn handler-map source. Mirrors `toolDefinitionsFactory` so
136
+ * the LLM-visible tools and the dispatchable handlers can be narrowed in
137
+ * lockstep. Resolved each tool-loop iteration before the LLM call.
138
+ */
139
+ private toolHandlersFactory?: (
140
+ ctx: SystemPromptContext,
141
+ ) => ChatToolHandlers | Promise<ChatToolHandlers>;
112
142
  private primerHistory?: ChatMessage[];
113
143
  private activeAgentName?: string;
144
+ /**
145
+ * Per-turn display label resolved from the agent's `displayName`. Stamped
146
+ * onto outgoing messages and turn snapshots for UX; `activeAgentName` stays
147
+ * stable for routing/history-transform identity matching.
148
+ */
149
+ private activeAgentLabel?: string;
150
+ private displayName?: SystemPromptInput;
114
151
  /**
115
152
  * When set, `requestInteraction` delegates to this callback instead of using
116
153
  * this driver's own pending map. Wired by `invokeSubAgent` so a sub-agent's
117
154
  * widget renders in — and resolves through — the parent (ultimately the
118
155
  * root) driver, where the main UI is listening.
119
156
  */
120
- private hostInteractionRequester?: <T>(componentName: string, data: any) => Promise<T>;
157
+ private hostInteractionRequester?: <T>(
158
+ componentName: string,
159
+ data: any,
160
+ options?: InteractionRequestOptions,
161
+ ) => Promise<T>;
121
162
  /**
122
163
  * When set (e.g. by OrchestratingDriver), applied only to the conversation slice
123
164
  * sent to the model — stored `history` stays unchanged for UI and logging.
@@ -165,7 +206,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
165
206
 
166
207
  constructor(
167
208
  private readonly aiProvider: AIProvider,
168
- toolHandlers: ChatToolHandlers = {},
209
+ toolHandlers: ToolHandlersInput = {},
169
210
  toolDefinitions: ToolDefinitionsInput = [],
170
211
  systemPrompt?: SystemPromptInput,
171
212
  primerHistory?: ChatMessage[],
@@ -174,7 +215,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
174
215
  maxTurnSnapshots: number = DEFAULT_MAX_TURN_SNAPSHOTS,
175
216
  ) {
176
217
  super();
177
- this.toolHandlers = toolHandlers;
218
+ if (typeof toolHandlers === 'function') {
219
+ this.toolHandlersFactory = toolHandlers;
220
+ this.toolHandlers = {};
221
+ } else {
222
+ this.toolHandlersFactory = undefined;
223
+ this.toolHandlers = toolHandlers;
224
+ }
178
225
  if (typeof toolDefinitions === 'function') {
179
226
  this.toolDefinitionsFactory = toolDefinitions;
180
227
  this.toolDefinitions = [];
@@ -203,9 +250,23 @@ export class ChatDriver extends EventTarget implements AiDriver {
203
250
  this.toolDefinitionsFactory = undefined;
204
251
  this.toolDefinitions = config.toolDefinitions ?? [];
205
252
  }
206
- this.toolHandlers = config.toolHandlers ?? {};
253
+ if (typeof config.toolHandlers === 'function') {
254
+ this.toolHandlersFactory = config.toolHandlers;
255
+ // Cleared each turn by the factory in runToolLoop; empty is safe in the
256
+ // meantime (no LLM call happens before resolution).
257
+ this.toolHandlers = {};
258
+ } else {
259
+ this.toolHandlersFactory = undefined;
260
+ this.toolHandlers = config.toolHandlers ?? {};
261
+ }
207
262
  this.primerHistory = config.primerHistory;
208
263
  this.activeAgentName = config.name;
264
+ this.displayName = config.displayName;
265
+ // Static string form resolves to a stable label up-front; the function
266
+ // form gets re-resolved each tool-loop iteration. Falls back to the
267
+ // canonical name when displayName is unset.
268
+ this.activeAgentLabel =
269
+ typeof config.displayName === 'string' ? config.displayName : config.name;
209
270
  this.debugSnapshotter = config.getDebugSnapshot;
210
271
  this.subAgentsMap = new Map((config.subAgents ?? []).map((s) => [s.name, s]));
211
272
  // Reset fold state when agent changes — each specialist starts fresh
@@ -263,6 +324,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
263
324
  turnIndex,
264
325
  timestamp: new Date().toISOString(),
265
326
  agentName: this.activeAgentName,
327
+ agentLabel: this.activeAgentLabel,
266
328
  systemPrompt: resolvedSystemPrompt,
267
329
  toolNames: this.toolDefinitions.map((t) => t.name),
268
330
  agentSnapshot,
@@ -385,7 +447,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
385
447
  * naturally: a grandchild → child → root.
386
448
  */
387
449
  public setHostInteractionRequester(
388
- fn: <T>(componentName: string, data: any) => Promise<T>,
450
+ fn: <T>(componentName: string, data: any, options?: InteractionRequestOptions) => Promise<T>,
389
451
  ): void {
390
452
  this.hostInteractionRequester = fn;
391
453
  }
@@ -403,10 +465,17 @@ export class ChatDriver extends EventTarget implements AiDriver {
403
465
  *
404
466
  * @param componentName - The custom element name to render.
405
467
  * @param data - Data to pass to the component.
468
+ * @param options - Optional per-call overrides, including
469
+ * `chatInputDuringExecution` to hide or disable the main chat input while
470
+ * the widget is awaiting user input. Reverts when the interaction resolves.
406
471
  */
407
- public async requestInteraction<T>(componentName: string, data: any): Promise<T> {
472
+ public async requestInteraction<T>(
473
+ componentName: string,
474
+ data: any,
475
+ options?: InteractionRequestOptions,
476
+ ): Promise<T> {
408
477
  if (this.hostInteractionRequester) {
409
- return this.hostInteractionRequester<T>(componentName, data);
478
+ return this.hostInteractionRequester<T>(componentName, data, options);
410
479
  }
411
480
  if (this.pendingInteractions.size > 0) {
412
481
  throw new Error(
@@ -416,8 +485,20 @@ export class ChatDriver extends EventTarget implements AiDriver {
416
485
  );
417
486
  }
418
487
  const interactionId = crypto.randomUUID();
488
+ const chatInputDuringExecution = options?.chatInputDuringExecution;
419
489
  return new Promise((resolve, reject) => {
420
- this.pendingInteractions.set(interactionId, { resolve, reject });
490
+ this.pendingInteractions.set(interactionId, {
491
+ resolve,
492
+ reject,
493
+ overrideId: chatInputDuringExecution ? interactionId : undefined,
494
+ });
495
+ if (chatInputDuringExecution) {
496
+ this.dispatchEvent(
497
+ new CustomEvent('interaction-start', {
498
+ detail: { interactionId, chatInputDuringExecution },
499
+ }),
500
+ );
501
+ }
421
502
  this.appendToHistory({
422
503
  role: 'assistant',
423
504
  content: '',
@@ -445,6 +526,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
445
526
  }),
446
527
  );
447
528
  }
529
+ if (interaction.overrideId) {
530
+ this.dispatchEvent(new CustomEvent('interaction-stop', { detail: { interactionId } }));
531
+ }
448
532
  interaction.resolve(result);
449
533
  this.pendingInteractions.delete(interactionId);
450
534
  } else {
@@ -499,8 +583,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
499
583
  */
500
584
  private buildHandlerContext(traceCapture?: { trace?: ChatMessage[] }) {
501
585
  return {
502
- requestInteraction: <T>(componentName: string, data: any): Promise<T> =>
503
- this.requestInteraction(componentName, data),
586
+ requestInteraction: <T>(
587
+ componentName: string,
588
+ data: any,
589
+ options?: InteractionRequestOptions,
590
+ ): Promise<T> => this.requestInteraction(componentName, data, options),
504
591
  ...(this.subAgentsMap.size > 0 && {
505
592
  requestSubAgent: <T = never>(
506
593
  name: string,
@@ -581,8 +668,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
581
668
  // pending map the main UI is wired to. Recurses naturally for nested
582
669
  // sub-agents.
583
670
  child.setHostInteractionRequester(
584
- <R>(componentName: string, data: any): Promise<R> =>
585
- this.requestInteraction<R>(componentName, data),
671
+ <R>(componentName: string, data: any, opts?: InteractionRequestOptions): Promise<R> =>
672
+ this.requestInteraction<R>(componentName, data, opts),
586
673
  );
587
674
 
588
675
  const forwardTrace = (e: Event) => {
@@ -800,6 +887,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
800
887
  // Tool loop
801
888
  // ---------------------------------------------------------------------------
802
889
 
890
+ // eslint-disable-next-line complexity
803
891
  private async runToolLoop(
804
892
  userInput: string,
805
893
  attachments?: ChatAttachment[],
@@ -842,6 +930,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
842
930
  // eslint-disable-next-line no-await-in-loop
843
931
  this.toolDefinitions = await this.toolDefinitionsFactory(promptCtx);
844
932
  }
933
+ // Same story for the handler-map factory: re-resolve so dispatch sees
934
+ // only the handlers valid for the current state, in lockstep with the
935
+ // tool definitions exposed above. Folds are forbidden when this is set,
936
+ // so the fold-mutation paths on `this.toolHandlers` are unreachable.
937
+ if (this.toolHandlersFactory) {
938
+ // eslint-disable-next-line no-await-in-loop
939
+ this.toolHandlers = await this.toolHandlersFactory(promptCtx);
940
+ }
845
941
 
846
942
  const resolvedSystemPrompt =
847
943
  typeof this.systemPrompt === 'function'
@@ -849,6 +945,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
849
945
  await this.systemPrompt(promptCtx)
850
946
  : this.systemPrompt;
851
947
 
948
+ // Re-resolve the per-turn display label. Falls back to the canonical
949
+ // agent name when displayName is unset. Stamped onto outgoing messages
950
+ // and turn snapshots for UX only; routing/history-transform continues
951
+ // to read `activeAgentName`.
952
+ if (typeof this.displayName === 'function') {
953
+ // eslint-disable-next-line no-await-in-loop
954
+ this.activeAgentLabel = await this.displayName(promptCtx);
955
+ }
956
+
852
957
  const foldSuffix = this.buildFoldSystemPromptSuffix();
853
958
  const baseSystemPrompt = resolvedSystemPrompt
854
959
  ? `${resolvedSystemPrompt}${foldSuffix}`
@@ -1163,7 +1268,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
1163
1268
 
1164
1269
  private appendToHistory(message: ChatMessage): void {
1165
1270
  const tagged: ChatMessage = this.activeAgentName
1166
- ? { ...message, agentName: this.activeAgentName }
1271
+ ? {
1272
+ ...message,
1273
+ agentName: this.activeAgentName,
1274
+ // Display-only — falls back to agentName in renderers when unset.
1275
+ agentLabel: this.activeAgentLabel,
1276
+ }
1167
1277
  : message;
1168
1278
  this.history = [...this.history, tagged];
1169
1279
  this.dispatchEvent(
@@ -156,6 +156,16 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
156
156
  this.chatDriver.addEventListener('sub-agent-stop', (e: Event) => {
157
157
  this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: (e as CustomEvent).detail }));
158
158
  });
159
+ this.chatDriver.addEventListener('interaction-start', (e: Event) => {
160
+ this.dispatchEvent(
161
+ new CustomEvent('interaction-start', { detail: (e as CustomEvent).detail }),
162
+ );
163
+ });
164
+ this.chatDriver.addEventListener('interaction-stop', (e: Event) => {
165
+ this.dispatchEvent(
166
+ new CustomEvent('interaction-stop', { detail: (e as CustomEvent).detail }),
167
+ );
168
+ });
159
169
  }
160
170
 
161
171
  resolveInteraction(interactionId: string, result: unknown): void {
@@ -61,6 +61,20 @@ export type ToolDefinitionsInput =
61
61
  | ChatToolDefinition[]
62
62
  | ((ctx: SystemPromptContext) => ChatToolDefinition[] | Promise<ChatToolDefinition[]>);
63
63
 
64
+ /**
65
+ * Tool handlers for an agent. Either a static map (the conventional shape) or a
66
+ * function resolved each tool-loop iteration. The function form lets the agent
67
+ * narrow the dispatchable handler set per turn — pair it with the function form
68
+ * of `toolDefinitions` so the LLM-visible tools and the dispatchable handlers
69
+ * stay in lockstep, and handlers don't have to defend themselves against being
70
+ * dispatched in states where their tool isn't advertised.
71
+ *
72
+ * @beta
73
+ */
74
+ export type ToolHandlersInput =
75
+ | ChatToolHandlers
76
+ | ((ctx: SystemPromptContext) => ChatToolHandlers | Promise<ChatToolHandlers>);
77
+
64
78
  /**
65
79
  * Opts an agent in to manual selection from the assistant's agent picker.
66
80
  *
@@ -83,9 +97,21 @@ export interface ManualSelectionConfig {
83
97
 
84
98
  interface BaseAgentConfig {
85
99
  /**
86
- * Display name shown in the chat header when this agent is active.
100
+ * Stable identity for this agent. Used for classifier routing, manual
101
+ * pinning, and history filtering — must not vary per turn. For a per-turn
102
+ * display label (e.g. "Guided Booking (Counterparties)"), supply
103
+ * {@link BaseAgentConfig.displayName}.
87
104
  */
88
105
  name: string;
106
+ /**
107
+ * Optional per-turn display label. Resolved each tool-loop iteration and
108
+ * stamped onto outgoing messages (`agentLabel`) and the debug-log timeline.
109
+ * Renderers fall back to `name` when this is unset. Use the function form
110
+ * to vary the label by current state (e.g. a state machine's step).
111
+ *
112
+ * Identity stays on `name` — this is for UX only.
113
+ */
114
+ displayName?: SystemPromptInput;
89
115
  /**
90
116
  * System prompt injected into every conversation turn for this agent.
91
117
  *
@@ -105,8 +131,13 @@ interface BaseAgentConfig {
105
131
  toolDefinitions?: ToolDefinitionsInput;
106
132
  /**
107
133
  * Tool handler implementations for this agent.
134
+ *
135
+ * Either a static map or a function resolved each tool-loop iteration —
136
+ * pick the function form to narrow the dispatchable handler set per turn,
137
+ * matching the function form of `toolDefinitions`.
138
+ * See {@link ToolHandlersInput}.
108
139
  */
109
- toolHandlers?: ChatToolHandlers;
140
+ toolHandlers?: ToolHandlersInput;
110
141
  /**
111
142
  * Optional primer history prepended to every call (not visible to the user).
112
143
  * Used to establish agent identity and behavioural rules.
@@ -6,9 +6,23 @@ import type {
6
6
  ChatInputDuringExecutionMode,
7
7
  ManualSelectionConfig,
8
8
  SystemPromptContext,
9
+ SystemPromptInput,
9
10
  ToolDefinitionsInput,
11
+ ToolHandlersInput,
10
12
  } from './config';
11
13
 
14
+ /**
15
+ * Context passed to per-turn resolvers on a stateful agent — the standard
16
+ * {@link SystemPromptContext} plus the live `state` value. Used by
17
+ * `systemPrompt`, `displayName`, and the function form of `toolDefinitions`.
18
+ *
19
+ * Exported so consumers can lift resolvers into separate files without
20
+ * re-deriving the shape.
21
+ *
22
+ * @beta
23
+ */
24
+ export type StatefulAgentContext<S> = SystemPromptContext & { state: S };
25
+
12
26
  /**
13
27
  * Init options for {@link defineStatefulAgent}. Generic over the state shape `S`
14
28
  * the agent owns (a state machine, an observable controller, anything).
@@ -57,7 +71,15 @@ export interface StatefulAgentInit<S> {
57
71
  * iteration. Use this to feed the LLM whatever the current state implies
58
72
  * (e.g. a state machine's `meta.systemPrompt` plus captured context).
59
73
  */
60
- systemPrompt?: (ctx: SystemPromptContext & { state: S }) => string | Promise<string>;
74
+ systemPrompt?: (ctx: StatefulAgentContext<S>) => string | Promise<string>;
75
+
76
+ /**
77
+ * Per-turn display label, e.g. "Guided Booking (Counterparties)". Resolved
78
+ * each tool-loop iteration and stamped onto outgoing messages and the
79
+ * debug-log timeline — display only. The agent's `name` stays as the
80
+ * canonical identity used for routing/history filtering.
81
+ */
82
+ displayName?: (ctx: StatefulAgentContext<S>) => string | Promise<string>;
61
83
 
62
84
  /**
63
85
  * Tool definitions the LLM sees. Either a static array (resolved once) or a
@@ -72,21 +94,27 @@ export interface StatefulAgentInit<S> {
72
94
  */
73
95
  toolDefinitions?:
74
96
  | ChatToolDefinition[]
75
- | ((
76
- ctx: SystemPromptContext & { state: S },
77
- ) => ChatToolDefinition[] | Promise<ChatToolDefinition[]>);
97
+ | ((ctx: StatefulAgentContext<S>) => ChatToolDefinition[] | Promise<ChatToolDefinition[]>);
78
98
 
79
99
  /**
80
- * Factory returning the tool handler map. Called with the live `state` at
81
- * `onActivate` time; the handlers it returns are cached for the lifetime of
82
- * this activation. Each handler closes over `state` and any other deps you
83
- * captured.
100
+ * Factory returning the handler map for the **current state**. Called each
101
+ * tool-loop iteration with the live `state` value, so the handler set the
102
+ * driver dispatches against matches what `toolDefinitions` exposes to the
103
+ * LLM that turn. Return only the handlers valid right now — no need to
104
+ * advertise every handler the agent might ever expose, and no defensive
105
+ * `if (!machine.matches(...))` guards inside each handler.
106
+ *
107
+ * Pair with the function form of `toolDefinitions` so the visible tools and
108
+ * the dispatchable handlers stay in lockstep.
84
109
  *
85
- * The handler set returned must be the **union** of every tool name your
86
- * agent might advertise across all states `toolDefinitions` filters which
87
- * are visible to the LLM per turn, but every name still needs an entry here.
110
+ * **Constraint:** resolved handlers must not include fold facades. Folds and
111
+ * state-machine-driven tool filtering both try to control the LLM's tool
112
+ * view pick one. Helper samples once on activation (init state) and
113
+ * throws if a fold-tagged handler is detected; subsequent resolves also
114
+ * validate, so misuse on a non-init state surfaces when that state is
115
+ * reached.
88
116
  */
89
- toolHandlers?: (state: S) => ChatToolHandlers;
117
+ toolHandlers?: (state: S) => ChatToolHandlers | Promise<ChatToolHandlers>;
90
118
 
91
119
  /**
92
120
  * Optional getter for the debug-log snapshot. Defaults to auto-snapshotting
@@ -163,7 +191,6 @@ function defaultStatefulDebugSnapshot(state: unknown): unknown {
163
191
  */
164
192
  export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig {
165
193
  let state: S | undefined;
166
- let cachedHandlers: ChatToolHandlers | undefined;
167
194
 
168
195
  const assertNoFolds = (handlers: ChatToolHandlers): void => {
169
196
  for (const [name, handler] of Object.entries(handlers)) {
@@ -194,62 +221,51 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
194
221
  return td;
195
222
  })();
196
223
 
197
- // Each proxy entry calls into `cachedHandlers`, which is populated eagerly
198
- // inside `onActivate` (see below). Eager population means the no-folds
199
- // assertion fires at activation time not on first tool call — so a
200
- // misconfigured agent fails loud and immediately instead of silently
201
- // appearing to work until the LLM happens to invoke a tool.
202
- const buildHandlerProxy = (names: readonly string[]): ChatToolHandlers => {
203
- const out: ChatToolHandlers = {};
204
- for (const name of names) {
205
- out[name] = async (args, ctx) => {
206
- if (!state || !cachedHandlers) {
207
- throw new Error(`Stateful agent "${opts.name}" handler called before init`);
208
- }
209
- const handler = cachedHandlers[name];
210
- if (!handler) {
211
- throw new Error(`Tool "${name}" has no handler on stateful agent "${opts.name}"`);
224
+ // Wrap the handler factory into the ctx-shaped form the driver expects, and
225
+ // validate folds on each resolve. The driver re-resolves this per tool-loop
226
+ // iteration, so the handler set the driver dispatches against always
227
+ // matches the LLM-visible tools for the current state — handlers don't need
228
+ // defensive `if (!machine.matches(...))` guards.
229
+ const wrappedHandlers: ToolHandlersInput | undefined = opts.toolHandlers
230
+ ? async () => {
231
+ if (!state) {
232
+ throw new Error(`Stateful agent "${opts.name}" handlers called before init`);
212
233
  }
213
- return handler(args, ctx);
214
- };
215
- }
216
- return out;
217
- };
218
-
219
- // Static-tools case: handler names are knowable from the definitions array.
220
- // Dynamic-tools case: we discover names from a sample of `toolHandlers(state)`
221
- // taken inside onActivate. Until then, the handler dict is empty — fine
222
- // because no tool call can happen before activation completes.
223
- const staticHandlerProxy =
224
- Array.isArray(opts.toolDefinitions) && opts.toolHandlers
225
- ? buildHandlerProxy(opts.toolDefinitions.map((d) => d.name))
226
- : undefined;
234
+ const handlers = await opts.toolHandlers!(state);
235
+ assertNoFolds(handlers);
236
+ return handlers;
237
+ }
238
+ : undefined;
227
239
 
228
- // For the dynamic case we patch this object in place on activation. The
229
- // driver reads `toolHandlers` by reference, so mutating in place is enough.
230
- const resolvedHandlers: ChatToolHandlers = staticHandlerProxy ?? {};
240
+ const wrappedDisplayName: SystemPromptInput | undefined = opts.displayName
241
+ ? async (ctx: SystemPromptContext) => {
242
+ if (!state) {
243
+ throw new Error(`Stateful agent "${opts.name}" displayName called before init`);
244
+ }
245
+ return opts.displayName!({ ...ctx, state });
246
+ }
247
+ : undefined;
231
248
 
232
249
  const base = {
233
250
  name: opts.name,
251
+ displayName: wrappedDisplayName,
234
252
  primerHistory: opts.primerHistory,
235
253
  subAgents: opts.subAgents,
236
254
  manualSelection: opts.manualSelection,
237
255
  chatInputDuringExecution: opts.chatInputDuringExecution,
238
256
  toolDefinitions: wrappedTools,
239
- toolHandlers: opts.toolHandlers ? resolvedHandlers : undefined,
257
+ toolHandlers: wrappedHandlers,
240
258
 
241
259
  onActivate: async (ctx: AgentLifecycleContext) => {
242
260
  state = await opts.init(ctx);
243
- // Sample handlers eagerly and validate up-front. Throws here are visible
244
- // at the agent-switch boundary instead of buried inside a future tool
245
- // dispatch.
246
- cachedHandlers = opts.toolHandlers?.(state) ?? {};
247
- assertNoFolds(cachedHandlers);
248
- // Dynamic-tools path: handler names are discovered from the sample.
249
- if (!staticHandlerProxy && opts.toolHandlers) {
250
- const dynamicProxy = buildHandlerProxy(Object.keys(cachedHandlers));
251
- for (const key of Object.keys(resolvedHandlers)) delete resolvedHandlers[key];
252
- Object.assign(resolvedHandlers, dynamicProxy);
261
+ // Sample once with the init state to fail loud on fold misuse at the
262
+ // agent-switch boundary instead of waiting for the first tool call.
263
+ // The driver re-resolves the factory each iteration, so misuse on
264
+ // handlers gated behind a non-init state surfaces when that state is
265
+ // reached — same loud failure mode, narrower coverage.
266
+ if (opts.toolHandlers) {
267
+ const sampled = await opts.toolHandlers(state);
268
+ assertNoFolds(sampled);
253
269
  }
254
270
  },
255
271
 
@@ -261,7 +277,6 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
261
277
  // cannot interleave the read-then-write of `state`.
262
278
  // eslint-disable-next-line require-atomic-updates
263
279
  state = undefined;
264
- cachedHandlers = undefined;
265
280
  },
266
281
 
267
282
  systemPrompt: opts.systemPrompt
@@ -233,7 +233,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
233
233
  messageType(m) === 'ai-function' &&
234
234
  m.agentName &&
235
235
  (c.parent as FoundationAiAssistant).showAgentSwitchIndicator
236
- ? `Tool Call · ${m.agentName}`
236
+ ? `Tool Call · ${m.agentLabel ?? m.agentName}`
237
237
  : senderLabel[messageType(m)]}
238
238
  </div>
239
239
  <div class="content">