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