@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/dist/ai-assistant.api.json +229 -86
- package/dist/ai-assistant.d.ts +87 -27
- package/dist/dts/components/chat-driver/chat-driver.d.ts +16 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/config/config.d.ts +48 -0
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/config/define-stateful-agent.d.ts +14 -1
- package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
- package/dist/dts/config/define-stateful-agent.test.d.ts +2 -0
- package/dist/dts/config/define-stateful-agent.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +8 -27
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +37 -2
- package/dist/esm/components/chat-driver/chat-driver.test.js +84 -0
- package/dist/esm/config/define-stateful-agent.js +13 -0
- package/dist/esm/config/define-stateful-agent.test.js +53 -0
- package/dist/esm/main/main.js +44 -26
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -16
- package/src/components/chat-driver/chat-driver.test.ts +128 -0
- package/src/components/chat-driver/chat-driver.ts +54 -2
- package/src/config/config.ts +52 -0
- package/src/config/define-stateful-agent.test.ts +64 -0
- package/src/config/define-stateful-agent.ts +31 -0
- package/src/main/main.ts +30 -40
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.
|
|
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.
|
|
68
|
-
"@genesislcap/genx": "14.
|
|
69
|
-
"@genesislcap/rollup-builder": "14.
|
|
70
|
-
"@genesislcap/ts-builder": "14.
|
|
71
|
-
"@genesislcap/uvu-playwright-builder": "14.
|
|
72
|
-
"@genesislcap/vite-builder": "14.
|
|
73
|
-
"@genesislcap/webpack-builder": "14.
|
|
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.
|
|
79
|
-
"@genesislcap/foundation-logger": "14.
|
|
80
|
-
"@genesislcap/foundation-redux": "14.
|
|
81
|
-
"@genesislcap/foundation-ui": "14.
|
|
82
|
-
"@genesislcap/foundation-utils": "14.
|
|
83
|
-
"@genesislcap/rapid-design-system": "14.
|
|
84
|
-
"@genesislcap/web-core": "14.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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'
|