@genesislcap/ai-assistant 14.466.0 → 14.467.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "14.466.0",
4
+ "version": "14.467.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -64,24 +64,24 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@genesislcap/foundation-testing": "14.466.0",
68
- "@genesislcap/genx": "14.466.0",
69
- "@genesislcap/rollup-builder": "14.466.0",
70
- "@genesislcap/ts-builder": "14.466.0",
71
- "@genesislcap/uvu-playwright-builder": "14.466.0",
72
- "@genesislcap/vite-builder": "14.466.0",
73
- "@genesislcap/webpack-builder": "14.466.0",
67
+ "@genesislcap/foundation-testing": "14.467.0",
68
+ "@genesislcap/genx": "14.467.0",
69
+ "@genesislcap/rollup-builder": "14.467.0",
70
+ "@genesislcap/ts-builder": "14.467.0",
71
+ "@genesislcap/uvu-playwright-builder": "14.467.0",
72
+ "@genesislcap/vite-builder": "14.467.0",
73
+ "@genesislcap/webpack-builder": "14.467.0",
74
74
  "@types/dompurify": "^3.0.5",
75
75
  "@types/marked": "^5.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "@genesislcap/foundation-ai": "14.466.0",
79
- "@genesislcap/foundation-logger": "14.466.0",
80
- "@genesislcap/foundation-redux": "14.466.0",
81
- "@genesislcap/foundation-ui": "14.466.0",
82
- "@genesislcap/foundation-utils": "14.466.0",
83
- "@genesislcap/rapid-design-system": "14.466.0",
84
- "@genesislcap/web-core": "14.466.0",
78
+ "@genesislcap/foundation-ai": "14.467.0",
79
+ "@genesislcap/foundation-logger": "14.467.0",
80
+ "@genesislcap/foundation-redux": "14.467.0",
81
+ "@genesislcap/foundation-ui": "14.467.0",
82
+ "@genesislcap/foundation-utils": "14.467.0",
83
+ "@genesislcap/rapid-design-system": "14.467.0",
84
+ "@genesislcap/web-core": "14.467.0",
85
85
  "dompurify": "^3.3.1",
86
86
  "marked": "^17.0.3"
87
87
  },
@@ -93,5 +93,5 @@
93
93
  "publishConfig": {
94
94
  "access": "public"
95
95
  },
96
- "gitHead": "a0ced1e97a07712305acd5675461d5f9273c8e51"
96
+ "gitHead": "18901315499f3c83de01431b879ec4cfa2676507"
97
97
  }
@@ -326,6 +326,134 @@ stale('splits stale vs hallucinated tools on the unknown-tool-limit error', asyn
326
326
 
327
327
  stale.run();
328
328
 
329
+ // ---------------------------------------------------------------------------
330
+ // onUnresolvedTool hook — an agent can redirect an unresolved tool call
331
+ // ---------------------------------------------------------------------------
332
+
333
+ const hook = createLogicSuite('ChatDriver onUnresolvedTool hook');
334
+
335
+ hook.after(() => {
336
+ agenticActivityBus.close();
337
+ });
338
+
339
+ hook('replaces the default for a hallucinated tool when the hook returns a string', async () => {
340
+ const config = agent({
341
+ name: 'Hooked',
342
+ toolDefinitions: [def('real_tool')],
343
+ toolHandlers: { real_tool: async () => 'ok' },
344
+ onUnresolvedTool: ({ toolName, kind, availableTools }) =>
345
+ `redirect: ${toolName} is ${kind}; use ${availableTools.join(', ')}`,
346
+ });
347
+ const provider = scriptedProvider([callsTool('made_up', 'm1')]);
348
+ const driver = makeDriver(config, provider);
349
+
350
+ await driver.sendMessage('go');
351
+
352
+ assert.ok(
353
+ toolResultContents(driver).includes('redirect: made_up is unknown; use real_tool'),
354
+ 'the hook string should replace the default Unknown tool message',
355
+ );
356
+ assert.not.ok(
357
+ toolResultContents(driver).some((c) => c.startsWith('Unknown tool:')),
358
+ 'the default unknown-tool message must not appear',
359
+ );
360
+ });
361
+
362
+ hook('replaces the default for a stale tool when the hook returns a string', async () => {
363
+ let state: 'A' | 'B' = 'A';
364
+ const config = agent({
365
+ name: 'HookedStateful',
366
+ toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
367
+ toolHandlers: () =>
368
+ state === 'A'
369
+ ? {
370
+ tool_a: async () => {
371
+ state = 'B';
372
+ return 'advanced to B';
373
+ },
374
+ }
375
+ : { tool_b: async () => 'b done' },
376
+ onUnresolvedTool: ({ toolName, kind }) => `redirect: ${toolName}/${kind}`,
377
+ });
378
+ const provider = scriptedProvider([
379
+ callsTool('tool_a', 't1'), // real — advances A -> B
380
+ callsTool('tool_a', 't2'), // stale — tool_a no longer in state B
381
+ callsTool('tool_b', 't3'), // real — valid in state B
382
+ ]);
383
+ const driver = makeDriver(config, provider);
384
+
385
+ await driver.sendMessage('go');
386
+
387
+ assert.ok(
388
+ toolResultContents(driver).includes('redirect: tool_a/stale'),
389
+ 'the hook string should replace the default stale message',
390
+ );
391
+ assert.not.ok(
392
+ toolResultContents(driver).some((c) =>
393
+ c.includes('was available earlier but is not part of the current step'),
394
+ ),
395
+ 'the default stale message must not appear',
396
+ );
397
+ });
398
+
399
+ hook('falls back to the default when the hook returns undefined', async () => {
400
+ const config = agent({
401
+ name: 'HookUndefined',
402
+ toolDefinitions: [def('real_tool')],
403
+ toolHandlers: { real_tool: async () => 'ok' },
404
+ onUnresolvedTool: () => undefined,
405
+ });
406
+ const provider = scriptedProvider([callsTool('made_up', 'm1')]);
407
+ const driver = makeDriver(config, provider);
408
+
409
+ await driver.sendMessage('go');
410
+
411
+ assert.ok(
412
+ toolResultContents(driver).includes('Unknown tool: made_up'),
413
+ 'an undefined hook result should fall back to the default message',
414
+ );
415
+ });
416
+
417
+ hook('falls back to the default when the hook returns a whitespace-only string', async () => {
418
+ const config = agent({
419
+ name: 'HookEmpty',
420
+ toolDefinitions: [def('real_tool')],
421
+ toolHandlers: { real_tool: async () => 'ok' },
422
+ onUnresolvedTool: () => ' ',
423
+ });
424
+ const provider = scriptedProvider([callsTool('made_up', 'm1')]);
425
+ const driver = makeDriver(config, provider);
426
+
427
+ await driver.sendMessage('go');
428
+
429
+ assert.ok(
430
+ toolResultContents(driver).includes('Unknown tool: made_up'),
431
+ 'a whitespace-only hook result should fall back to the default message',
432
+ );
433
+ });
434
+
435
+ hook('falls back to the default when the hook throws', async () => {
436
+ const config = agent({
437
+ name: 'HookThrows',
438
+ toolDefinitions: [def('real_tool')],
439
+ toolHandlers: { real_tool: async () => 'ok' },
440
+ onUnresolvedTool: () => {
441
+ throw new Error('boom');
442
+ },
443
+ });
444
+ const provider = scriptedProvider([callsTool('made_up', 'm1')]);
445
+ const driver = makeDriver(config, provider);
446
+
447
+ await driver.sendMessage('go');
448
+
449
+ assert.ok(
450
+ toolResultContents(driver).includes('Unknown tool: made_up'),
451
+ 'a throwing hook should fall back to the default message',
452
+ );
453
+ });
454
+
455
+ hook.run();
456
+
329
457
  // ---------------------------------------------------------------------------
330
458
  // timeout handling — a transport `TimeoutError` surfaces a distinct message
331
459
  // ---------------------------------------------------------------------------
@@ -28,6 +28,7 @@ import type {
28
28
  ToolChoiceInput,
29
29
  ToolDefinitionsInput,
30
30
  ToolHandlersInput,
31
+ UnresolvedToolInput,
31
32
  } from '../../config/config';
32
33
  import { resolveChatProvider } from '../../config/validate-providers';
33
34
  import { recordMetaEvent, recordTurnError, recordTurnRetry } from '../../state/debug-event-log';
@@ -332,6 +333,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
332
333
  * call; top-level turns are `'auto'`).
333
334
  */
334
335
  private activeToolChoiceInput?: ToolChoiceInput;
336
+ /**
337
+ * Active agent's unresolved-tool hook, captured from `applyAgent`. Consulted
338
+ * only when a tool call cannot be dispatched (a stale or hallucinated name);
339
+ * `undefined` keeps the framework's default messages. See
340
+ * `resolveUnresolvedToolContent`.
341
+ */
342
+ private activeOnUnresolvedTool?: UnresolvedToolInput;
335
343
  /**
336
344
  * Caches validated provider lookups per name within the current agent. Cleared
337
345
  * by `applyAgent` so each new agent's static/function-resolved names are
@@ -527,6 +535,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
527
535
  this.activeProviderInput = config.provider;
528
536
  this.activeTemperatureInput = config.temperature;
529
537
  this.activeToolChoiceInput = config.toolChoice;
538
+ this.activeOnUnresolvedTool = config.onUnresolvedTool;
530
539
  this.resolvedProviderCache.clear();
531
540
  this.lastResolvedProviderName = undefined;
532
541
  // Static validation: resolve the name now so unknown-provider and missing-
@@ -544,6 +553,38 @@ export class ChatDriver extends EventTarget implements AiDriver {
544
553
  this.everSeenToolNames.clear();
545
554
  }
546
555
 
556
+ /**
557
+ * Resolve the tool-result content for an unresolved tool call. Consults the
558
+ * active agent's `onUnresolvedTool` hook (if any) with the attempted tool
559
+ * name, the failure `kind`, and the currently dispatchable tools, and returns
560
+ * the hook's non-empty string. Falls back to `fallback` when no hook is set,
561
+ * the hook returns nothing/empty, or the hook throws — so a misbehaving hook
562
+ * can never break tool dispatch.
563
+ */
564
+ private async resolveUnresolvedToolContent(
565
+ toolName: string,
566
+ kind: 'stale' | 'unknown',
567
+ fallback: string,
568
+ ): Promise<string> {
569
+ if (typeof this.activeOnUnresolvedTool !== 'function') {
570
+ return fallback;
571
+ }
572
+ try {
573
+ const custom = await this.activeOnUnresolvedTool({
574
+ toolName,
575
+ kind,
576
+ availableTools: Object.keys(this.toolHandlers),
577
+ });
578
+ return typeof custom === 'string' && custom.trim().length > 0 ? custom : fallback;
579
+ } catch (e) {
580
+ logger.warn(
581
+ `ChatDriver: onUnresolvedTool threw for "${toolName}" — using default message`,
582
+ e,
583
+ );
584
+ return fallback;
585
+ }
586
+ }
587
+
547
588
  /**
548
589
  * Returns the most recently resolved provider name. Falls back to the
549
590
  * registry's default when no per-turn resolution has happened yet.
@@ -2044,7 +2085,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
2044
2085
  consecutive: this.consecutiveUnknownToolCalls,
2045
2086
  max: MAX_STALE_TOOL_CALLS,
2046
2087
  });
2047
- executedById.set(tc.id, { toolCallId: tc.id, content });
2088
+ // Fold-hidden tools keep their fold-specific guidance; a plain
2089
+ // stale tool is a step-ordering miss, so offer the agent's
2090
+ // redirect when it supplies one.
2091
+ const staleContent = hidingFold
2092
+ ? content
2093
+ : await this.resolveUnresolvedToolContent(tc.name, 'stale', content);
2094
+ executedById.set(tc.id, { toolCallId: tc.id, content: staleContent });
2048
2095
  unknownToolIds.add(tc.id);
2049
2096
  staleToolIds.add(tc.id);
2050
2097
  this.recentUnknownToolNames.add(tc.name);
@@ -2068,7 +2115,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
2068
2115
  max: DEFAULT_MAX_UNKNOWN_TOOL_CALLS,
2069
2116
  availableTools: Object.keys(this.toolHandlers),
2070
2117
  });
2071
- executedById.set(tc.id, { toolCallId: tc.id, content: `Unknown tool: ${tc.name}` });
2118
+ const unknownContent = await this.resolveUnresolvedToolContent(
2119
+ tc.name,
2120
+ 'unknown',
2121
+ `Unknown tool: ${tc.name}`,
2122
+ );
2123
+ executedById.set(tc.id, { toolCallId: tc.id, content: unknownContent });
2072
2124
  unknownToolIds.add(tc.id);
2073
2125
  this.recentUnknownToolNames.add(tc.name);
2074
2126
  if (this.consecutiveUnknownToolCalls >= DEFAULT_MAX_UNKNOWN_TOOL_CALLS) {
@@ -116,6 +116,46 @@ export type ToolChoiceInput =
116
116
  | ChatToolChoice
117
117
  | ((ctx: SystemPromptContext) => ChatToolChoice | Promise<ChatToolChoice>);
118
118
 
119
+ /**
120
+ * Context passed to an agent's `onUnresolvedTool` hook when the model calls a
121
+ * tool the driver cannot dispatch.
122
+ *
123
+ * @beta
124
+ */
125
+ export interface UnresolvedToolContext {
126
+ /** The tool name the model attempted to call. */
127
+ toolName: string;
128
+ /**
129
+ * Why the call could not be dispatched:
130
+ * - `'stale'` — the tool was advertised earlier this activation but is not
131
+ * part of the current step (e.g. a stateful agent has moved on).
132
+ * - `'unknown'` — the tool was never advertised this activation (a
133
+ * hallucinated name).
134
+ */
135
+ kind: 'stale' | 'unknown';
136
+ /**
137
+ * The tool names dispatchable right now, so the hook can steer the model
138
+ * back to a valid call.
139
+ */
140
+ availableTools: string[];
141
+ }
142
+
143
+ /**
144
+ * Optional per-agent hook consulted only on the already-failing unresolved-tool
145
+ * path — when the model calls a tool the driver cannot dispatch. Lets the agent
146
+ * replace the framework's default message with a context-aware redirect.
147
+ *
148
+ * Return a non-empty string to override the default tool-result message; return
149
+ * `undefined` (or an empty/whitespace string, or throw) to fall back to the
150
+ * framework default. The happy path is never affected. See
151
+ * {@link UnresolvedToolContext}.
152
+ *
153
+ * @beta
154
+ */
155
+ export type UnresolvedToolInput = (
156
+ ctx: UnresolvedToolContext,
157
+ ) => string | undefined | Promise<string | undefined>;
158
+
119
159
  /**
120
160
  * Opts an agent in to manual selection from the assistant's agent picker.
121
161
  *
@@ -218,6 +258,18 @@ interface BaseAgentConfig {
218
258
  * @beta
219
259
  */
220
260
  toolChoice?: ToolChoiceInput;
261
+ /**
262
+ * Optional hook consulted when the model calls a tool the driver cannot
263
+ * dispatch — either a *stale* tool (advertised earlier this activation but
264
+ * not part of the current step) or an *unknown* one (never advertised this
265
+ * activation). Return a context-aware redirect string to replace the
266
+ * framework's default message, or `undefined` to keep it. Consulted only on
267
+ * the already-failing unresolved-tool path; the happy path is unaffected.
268
+ * See {@link UnresolvedToolInput}.
269
+ *
270
+ * @beta
271
+ */
272
+ onUnresolvedTool?: UnresolvedToolInput;
221
273
  /**
222
274
  * Optional primer history prepended to every call (not visible to the user).
223
275
  * Used to establish agent identity and behavioural rules.
@@ -0,0 +1,64 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import type { AgentLifecycleContext } from './config';
3
+ import { defineStatefulAgent } from './define-stateful-agent';
4
+
5
+ // A minimal lifecycle context — `onActivate`/`onDeactivate` only read what the
6
+ // helper passes through, so the agent name, session key, and a live signal are
7
+ // enough to drive init/dispose in a Node-runnable test.
8
+ const lifecycleCtx = (): AgentLifecycleContext => ({
9
+ agentName: 'wizard',
10
+ sessionKey: 'test-session',
11
+ signal: new AbortController().signal,
12
+ });
13
+
14
+ const onUnresolvedTool = createLogicSuite('defineStatefulAgent onUnresolvedTool');
15
+
16
+ onUnresolvedTool('threads the live state into the hook after init', async () => {
17
+ const config = defineStatefulAgent<{ step: string }>({
18
+ name: 'wizard',
19
+ description: 'guided wizard',
20
+ init: () => ({ step: 'intake' }),
21
+ onUnresolvedTool: ({ state, toolName, kind, availableTools }) =>
22
+ `step=${state.step} tool=${toolName} kind=${kind} avail=${availableTools.join(',')}`,
23
+ });
24
+
25
+ await config.onActivate!(lifecycleCtx());
26
+
27
+ const result = await config.onUnresolvedTool!({
28
+ toolName: 'generate_view',
29
+ kind: 'stale',
30
+ availableTools: ['ask_user', 'validate'],
31
+ });
32
+ assert.is(result, 'step=intake tool=generate_view kind=stale avail=ask_user,validate');
33
+ });
34
+
35
+ onUnresolvedTool('returns undefined before init rather than throwing', async () => {
36
+ const config = defineStatefulAgent<{ step: string }>({
37
+ name: 'wizard',
38
+ description: 'guided wizard',
39
+ init: () => ({ step: 'intake' }),
40
+ // Would throw if it were ever reached without state — proves the wrapper
41
+ // short-circuits before delegating.
42
+ onUnresolvedTool: ({ state }) => `step=${state.step}`,
43
+ });
44
+
45
+ // No `onActivate` call — state has not been built yet.
46
+ const result = await config.onUnresolvedTool!({
47
+ toolName: 'generate_view',
48
+ kind: 'unknown',
49
+ availableTools: [],
50
+ });
51
+ assert.is(result, undefined, 'a pre-init call must degrade to the framework default');
52
+ });
53
+
54
+ onUnresolvedTool('omits the hook from the config when not supplied', () => {
55
+ const config = defineStatefulAgent<{ step: string }>({
56
+ name: 'wizard',
57
+ description: 'guided wizard',
58
+ init: () => ({ step: 'intake' }),
59
+ });
60
+
61
+ assert.is(config.onUnresolvedTool, undefined);
62
+ });
63
+
64
+ onUnresolvedTool.run();
@@ -17,6 +17,8 @@ import type {
17
17
  ToolChoiceInput,
18
18
  ToolDefinitionsInput,
19
19
  ToolHandlersInput,
20
+ UnresolvedToolContext,
21
+ UnresolvedToolInput,
20
22
  } from './config';
21
23
 
22
24
  /**
@@ -153,6 +155,20 @@ export interface StatefulAgentInit<S> {
153
155
  | ChatToolChoice
154
156
  | ((ctx: StatefulAgentContext<S>) => ChatToolChoice | Promise<ChatToolChoice>);
155
157
 
158
+ /**
159
+ * Hook consulted when the model calls a tool that isn't dispatchable in the
160
+ * current state — a *stale* tool (valid in an earlier state of this agent) or
161
+ * an *unknown* one. Receives the current `state` alongside the attempted tool
162
+ * name and the currently dispatchable tools, so a machine-driven agent can
163
+ * return a step-aware redirect (e.g. "that belongs to a later step — finish
164
+ * this one first"). Return `undefined` to keep the framework default; a
165
+ * pre-init call also degrades to the default rather than throwing.
166
+ * See {@link UnresolvedToolContext}.
167
+ */
168
+ onUnresolvedTool?: (
169
+ ctx: UnresolvedToolContext & { state: S },
170
+ ) => string | undefined | Promise<string | undefined>;
171
+
156
172
  /**
157
173
  * Optional getter for the debug-log snapshot. Defaults to auto-snapshotting
158
174
  * any property on `state` that looks like a foundation-state-machine
@@ -328,6 +344,20 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
328
344
  }
329
345
  : opts.toolChoice;
330
346
 
347
+ // `onUnresolvedTool` threads `state` in like the resolvers above, but unlike
348
+ // them returns `undefined` before init rather than throwing: it fires on the
349
+ // tool-dispatch path, so a pre-init call must degrade to the framework
350
+ // default instead of breaking dispatch.
351
+ const wrappedOnUnresolvedTool: UnresolvedToolInput | undefined =
352
+ typeof opts.onUnresolvedTool === 'function'
353
+ ? async (ctx) => {
354
+ if (!state) {
355
+ return undefined;
356
+ }
357
+ return opts.onUnresolvedTool!({ ...ctx, state });
358
+ }
359
+ : opts.onUnresolvedTool;
360
+
331
361
  const base = {
332
362
  name: opts.name,
333
363
  displayName: wrappedDisplayName,
@@ -340,6 +370,7 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
340
370
  provider: wrappedProvider,
341
371
  temperature: wrappedTemperature,
342
372
  toolChoice: wrappedToolChoice,
373
+ onUnresolvedTool: wrappedOnUnresolvedTool,
343
374
 
344
375
  onActivate: async (ctx: AgentLifecycleContext) => {
345
376
  state = await opts.init(ctx);
package/src/main/main.ts CHANGED
@@ -151,43 +151,33 @@ avoidTreeShaking(
151
151
  );
152
152
 
153
153
  /**
154
- * Recursively strips non-serializable fields from an agent before storing in Redux:
155
- * - `toolHandlers` (functions),
156
- * - `onActivate` / `onDeactivate` (lifecycle hooks, functions),
157
- * - `getDebugSnapshot` (function),
158
- * - function-form `systemPrompt` / `toolDefinitions` / `displayName` / `provider` /
159
- * `temperature` / `toolChoice`
160
- * (downgraded to `undefined` in the snapshot — the live config on the driver
161
- * is still the source of truth; the slice only stores a serializable projection).
162
- * Static forms (string / number / plain-object `toolChoice`) pass through.
154
+ * Recursively strips non-serializable fields from an agent before storing in
155
+ * Redux. Drops **every function-valued property** — `toolHandlers`, the
156
+ * lifecycle/dispatch hooks (`onActivate`, `onDeactivate`, `getDebugSnapshot`,
157
+ * `onUnresolvedTool`), and the function form of the per-turn resolvers
158
+ * (`systemPrompt`, `toolDefinitions`, `displayName`, `provider`, `temperature`,
159
+ * `toolChoice`). Static forms (string / number / array / plain object) pass
160
+ * through unchanged; `subAgents` are stripped recursively.
161
+ *
162
+ * Filtering by *value* (any function) rather than by an explicit field list
163
+ * means a new function-valued field added to `AgentConfig` is handled
164
+ * automatically and can never leak a live function into serialized store
165
+ * state — no denylist to keep in sync. The live config on the driver stays the
166
+ * source of truth; the slice only holds this serializable projection, and
167
+ * functions are never read back from it.
163
168
  */
164
169
  function stripHandlers(agent: AgentConfig): Omit<AgentConfig, 'toolHandlers'> {
165
- const {
166
- toolHandlers: _h,
167
- onActivate: _on,
168
- onDeactivate: _off,
169
- getDebugSnapshot: _g,
170
- subAgents,
171
- systemPrompt,
172
- toolDefinitions,
173
- displayName,
174
- provider,
175
- temperature,
176
- toolChoice,
177
- ...rest
178
- } = agent;
179
- const stripped = {
180
- ...rest,
181
- systemPrompt: typeof systemPrompt === 'function' ? undefined : systemPrompt,
182
- toolDefinitions: typeof toolDefinitions === 'function' ? undefined : toolDefinitions,
183
- displayName: typeof displayName === 'function' ? undefined : displayName,
184
- provider: typeof provider === 'function' ? undefined : provider,
185
- temperature: typeof temperature === 'function' ? undefined : temperature,
186
- toolChoice: typeof toolChoice === 'function' ? undefined : toolChoice,
187
- };
188
- return subAgents?.length
189
- ? { ...stripped, subAgents: subAgents.map(stripHandlers) as AgentConfig[] }
190
- : stripped;
170
+ const serializable: Record<string, unknown> = {};
171
+ for (const [key, value] of Object.entries(agent)) {
172
+ // `subAgents` is handled separately (recursively, below); drop everything
173
+ // function-valued.
174
+ if (key === 'subAgents' || typeof value === 'function') continue;
175
+ serializable[key] = value;
176
+ }
177
+ if (agent.subAgents?.length) {
178
+ serializable.subAgents = agent.subAgents.map(stripHandlers);
179
+ }
180
+ return serializable as unknown as Omit<AgentConfig, 'toolHandlers'>;
191
181
  }
192
182
 
193
183
  /**
@@ -1619,8 +1609,12 @@ export class FoundationAiAssistant extends GenesisElement {
1619
1609
  meta: {
1620
1610
  timestamp,
1621
1611
  host: window.location.host,
1612
+ // stripHandlers drops every function-valued field (handlers, lifecycle
1613
+ // hooks, onUnresolvedTool, function-form resolvers) and recurses
1614
+ // subAgents — no manual exclusion list to keep in sync. We only override
1615
+ // toolDefinitions afterwards to expand the fold tree for the log.
1622
1616
  agentSummary: this.agents?.map((a) => ({
1623
- ...a,
1617
+ ...stripHandlers(a),
1624
1618
  toolDefinitions: Array.isArray(a.toolDefinitions)
1625
1619
  ? typeof a.toolHandlers === 'function'
1626
1620
  ? // Static defs + dynamic handlers — can't walk fold tree
@@ -1630,10 +1624,6 @@ export class FoundationAiAssistant extends GenesisElement {
1630
1624
  : typeof a.toolDefinitions === 'function'
1631
1625
  ? '<dynamic — resolved per turn>'
1632
1626
  : [],
1633
- toolHandlers: undefined,
1634
- onActivate: undefined,
1635
- onDeactivate: undefined,
1636
- getDebugSnapshot: undefined,
1637
1627
  })),
1638
1628
  activeSystemPrompt:
1639
1629
  typeof this.activeAgent?.systemPrompt === 'function'