@genesislcap/ai-assistant 14.437.0 → 14.437.2

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.
@@ -16,6 +16,7 @@ import type {
16
16
  SystemPromptContext,
17
17
  SystemPromptInput,
18
18
  ToolDefinitionsInput,
19
+ ToolHandlersInput,
19
20
  } from '../../config/config';
20
21
  import { applyHistoryCap } from '../../utils/history-transform';
21
22
  import { logger } from '../../utils/logger';
@@ -65,6 +66,12 @@ export interface TurnSnapshot {
65
66
  systemPrompt?: string;
66
67
  /** Tool names sent to the LLM, in order — definitions are static per name so names alone suffice. */
67
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;
68
75
  /** Agent-supplied snapshot — machine state/context for stateful agents, undefined otherwise. */
69
76
  agentSnapshot?: unknown;
70
77
  }
@@ -114,9 +121,33 @@ export class ChatDriver extends EventTarget implements AiDriver {
114
121
  private toolDefinitionsFactory?: (
115
122
  ctx: SystemPromptContext,
116
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
+ */
117
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>;
118
142
  private primerHistory?: ChatMessage[];
119
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;
120
151
  /**
121
152
  * When set, `requestInteraction` delegates to this callback instead of using
122
153
  * this driver's own pending map. Wired by `invokeSubAgent` so a sub-agent's
@@ -175,7 +206,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
175
206
 
176
207
  constructor(
177
208
  private readonly aiProvider: AIProvider,
178
- toolHandlers: ChatToolHandlers = {},
209
+ toolHandlers: ToolHandlersInput = {},
179
210
  toolDefinitions: ToolDefinitionsInput = [],
180
211
  systemPrompt?: SystemPromptInput,
181
212
  primerHistory?: ChatMessage[],
@@ -184,7 +215,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
184
215
  maxTurnSnapshots: number = DEFAULT_MAX_TURN_SNAPSHOTS,
185
216
  ) {
186
217
  super();
187
- 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
+ }
188
225
  if (typeof toolDefinitions === 'function') {
189
226
  this.toolDefinitionsFactory = toolDefinitions;
190
227
  this.toolDefinitions = [];
@@ -213,9 +250,23 @@ export class ChatDriver extends EventTarget implements AiDriver {
213
250
  this.toolDefinitionsFactory = undefined;
214
251
  this.toolDefinitions = config.toolDefinitions ?? [];
215
252
  }
216
- 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
+ }
217
262
  this.primerHistory = config.primerHistory;
218
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;
219
270
  this.debugSnapshotter = config.getDebugSnapshot;
220
271
  this.subAgentsMap = new Map((config.subAgents ?? []).map((s) => [s.name, s]));
221
272
  // Reset fold state when agent changes — each specialist starts fresh
@@ -273,6 +324,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
273
324
  turnIndex,
274
325
  timestamp: new Date().toISOString(),
275
326
  agentName: this.activeAgentName,
327
+ agentLabel: this.activeAgentLabel,
276
328
  systemPrompt: resolvedSystemPrompt,
277
329
  toolNames: this.toolDefinitions.map((t) => t.name),
278
330
  agentSnapshot,
@@ -835,6 +887,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
835
887
  // Tool loop
836
888
  // ---------------------------------------------------------------------------
837
889
 
890
+ // eslint-disable-next-line complexity
838
891
  private async runToolLoop(
839
892
  userInput: string,
840
893
  attachments?: ChatAttachment[],
@@ -877,6 +930,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
877
930
  // eslint-disable-next-line no-await-in-loop
878
931
  this.toolDefinitions = await this.toolDefinitionsFactory(promptCtx);
879
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
+ }
880
941
 
881
942
  const resolvedSystemPrompt =
882
943
  typeof this.systemPrompt === 'function'
@@ -884,6 +945,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
884
945
  await this.systemPrompt(promptCtx)
885
946
  : this.systemPrompt;
886
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
+
887
957
  const foldSuffix = this.buildFoldSystemPromptSuffix();
888
958
  const baseSystemPrompt = resolvedSystemPrompt
889
959
  ? `${resolvedSystemPrompt}${foldSuffix}`
@@ -1198,7 +1268,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
1198
1268
 
1199
1269
  private appendToHistory(message: ChatMessage): void {
1200
1270
  const tagged: ChatMessage = this.activeAgentName
1201
- ? { ...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
+ }
1202
1277
  : message;
1203
1278
  this.history = [...this.history, tagged];
1204
1279
  this.dispatchEvent(
@@ -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">
package/src/main/main.ts CHANGED
@@ -113,9 +113,10 @@ avoidTreeShaking(
113
113
  * Recursively strips non-serializable fields from an agent before storing in Redux:
114
114
  * - `toolHandlers` (functions),
115
115
  * - `onActivate` / `onDeactivate` (lifecycle hooks, functions),
116
- * - function-form `systemPrompt` / `toolDefinitions` (downgraded to `undefined`
117
- * in the snapshot the live config on the driver is still the source of
118
- * truth; the slice only stores a serializable projection).
116
+ * - `getDebugSnapshot` (function),
117
+ * - function-form `systemPrompt` / `toolDefinitions` / `displayName` (downgraded
118
+ * to `undefined` in the snapshot the live config on the driver is still
119
+ * the source of truth; the slice only stores a serializable projection).
119
120
  */
120
121
  function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
121
122
  const {
@@ -126,12 +127,14 @@ function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
126
127
  subAgents,
127
128
  systemPrompt,
128
129
  toolDefinitions,
130
+ displayName,
129
131
  ...rest
130
132
  } = agent;
131
133
  const stripped = {
132
134
  ...rest,
133
135
  systemPrompt: typeof systemPrompt === 'function' ? undefined : systemPrompt,
134
136
  toolDefinitions: typeof toolDefinitions === 'function' ? undefined : toolDefinitions,
137
+ displayName: typeof displayName === 'function' ? undefined : displayName,
135
138
  };
136
139
  return subAgents?.length
137
140
  ? { ...stripped, subAgents: subAgents.map(stripHandlers) as AgentConfig[] }
@@ -544,9 +547,16 @@ export class FoundationAiAssistant extends GenesisElement {
544
547
  if (!agents?.length) return '';
545
548
  return agents
546
549
  .map((a) => {
547
- const toolNames = Object.keys(a.toolHandlers ?? {})
548
- .sort()
549
- .join('+');
550
+ // Function-form handlers are resolved per turn — their full key set
551
+ // isn't knowable at fingerprint time. A constant marker still
552
+ // distinguishes static-vs-dynamic shape changes; finer-grained
553
+ // detection isn't load-bearing here.
554
+ const toolNames =
555
+ typeof a.toolHandlers === 'function'
556
+ ? '<fn>'
557
+ : Object.keys(a.toolHandlers ?? {})
558
+ .sort()
559
+ .join('+');
550
560
  return `${a.name}[${toolNames}]`;
551
561
  })
552
562
  .join(',');
@@ -1170,7 +1180,11 @@ export class FoundationAiAssistant extends GenesisElement {
1170
1180
  agentSummary: this.agents?.map((a) => ({
1171
1181
  ...a,
1172
1182
  toolDefinitions: Array.isArray(a.toolDefinitions)
1173
- ? expandToolTree(a.toolDefinitions, a.toolHandlers ?? {})
1183
+ ? typeof a.toolHandlers === 'function'
1184
+ ? // Static defs + dynamic handlers — can't walk fold tree
1185
+ // because the handler map isn't materialized at log time.
1186
+ a.toolDefinitions
1187
+ : expandToolTree(a.toolDefinitions, a.toolHandlers ?? {})
1174
1188
  : typeof a.toolDefinitions === 'function'
1175
1189
  ? '<dynamic — resolved per turn>'
1176
1190
  : [],