@genesislcap/ai-assistant 15.84.2 → 15.85.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 +2175 -138
- package/dist/ai-assistant.d.ts +420 -43
- package/dist/chat-driver.cjs +780 -210
- package/dist/chat-driver.cjs.map +4 -4
- package/dist/chat-driver.mjs +780 -209
- package/dist/chat-driver.mjs.map +4 -4
- package/dist/custom-elements.json +2902 -1812
- package/dist/dts/components/agent-session/agent-session.d.ts +50 -2
- package/dist/dts/components/agent-session/agent-session.d.ts.map +1 -1
- package/dist/dts/components/ai-driver/ai-driver.d.ts +59 -1
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/ai-driver/base-driver-decorator.d.ts +13 -1
- package/dist/dts/components/ai-driver/base-driver-decorator.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.constants.d.ts +0 -8
- package/dist/dts/components/chat-driver/chat-driver.constants.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +74 -15
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/hand-off.d.ts +20 -0
- package/dist/dts/components/orchestrating-driver/hand-off.d.ts.map +1 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +121 -22
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/config/config.d.ts +66 -0
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +3 -0
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.types.d.ts +6 -0
- package/dist/dts/main/main.types.d.ts.map +1 -1
- package/dist/dts/react.d.ts +4 -1
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-snapshot.d.ts +33 -1
- package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
- package/dist/dts/utils/condense-history.d.ts +10 -3
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- package/dist/esm/components/agent-session/agent-session.js +128 -7
- package/dist/esm/components/ai-driver/ai-driver.js +3 -0
- package/dist/esm/components/ai-driver/base-driver-decorator.js +24 -0
- package/dist/esm/components/chat-driver/chat-driver.constants.js +0 -8
- package/dist/esm/components/chat-driver/chat-driver.js +247 -74
- package/dist/esm/components/orchestrating-driver/hand-off.js +59 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +480 -299
- package/dist/esm/main/main.js +32 -6
- package/dist/esm/main/main.types.js +6 -0
- package/dist/esm/state/debug-event-log.js +1 -1
- package/dist/esm/state/persistence/session-persister.js +55 -8
- package/dist/esm/state/persistence/session-snapshot.js +12 -6
- package/dist/esm/utils/condense-history.js +13 -4
- package/dist/react.cjs +16 -1
- package/dist/react.mjs +16 -1
- package/docs/sub_agent.md +1 -1
- package/package.json +17 -17
|
@@ -8,8 +8,10 @@ import { transformHistoryForAgent } from '../../utils/history-transform';
|
|
|
8
8
|
import { logger } from '../../utils/logger';
|
|
9
9
|
import { messageUsage } from '../../utils/sum-usage';
|
|
10
10
|
import { BaseDriverDecorator } from '../ai-driver/base-driver-decorator';
|
|
11
|
-
import { ChatDriver
|
|
11
|
+
import { ChatDriver } from '../chat-driver/chat-driver';
|
|
12
|
+
import { frameCopy, handBackNote, handOffNote, parseHandOffFrames } from './hand-off';
|
|
12
13
|
const DEFAULT_MAX_HANDOFFS = 3;
|
|
14
|
+
const DEFAULT_MAX_HAND_OFF_DEPTH = 1;
|
|
13
15
|
const DEFAULT_CLASSIFIER_HISTORY_LENGTH = 4;
|
|
14
16
|
const DEFAULT_CLASSIFIER_RETRIES = 2;
|
|
15
17
|
/**
|
|
@@ -24,30 +26,27 @@ const DEFAULT_CLASSIFIER_RETRIES = 2;
|
|
|
24
26
|
*/
|
|
25
27
|
const CLASSIFIER_MESSAGE_CHARS = 600;
|
|
26
28
|
const CLASSIFIER_TRANSCRIPT_CHARS = 3000;
|
|
27
|
-
const REQUEST_CONTINUATION_DEFINITION = {
|
|
28
|
-
name: REQUEST_CONTINUATION_TOOL,
|
|
29
|
-
description: "Call this when you have completed your part of the task but fulfilling the user's full request requires capabilities outside your domain. Do not call this if you can complete the request yourself. Pass a plain-language description of what still needs to be done — another specialist will be selected automatically.",
|
|
30
|
-
parameters: {
|
|
31
|
-
type: 'object',
|
|
32
|
-
required: ['summary', 'remaining_task'],
|
|
33
|
-
properties: {
|
|
34
|
-
summary: {
|
|
35
|
-
type: 'string',
|
|
36
|
-
description: 'What you found or did — passed as context to the next specialist.',
|
|
37
|
-
},
|
|
38
|
-
remaining_task: {
|
|
39
|
-
type: 'string',
|
|
40
|
-
description: 'What still needs to be done, in plain language. Used to route to the right specialist.',
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
29
|
function isSpecialist(agent) {
|
|
46
30
|
return !agent.fallback;
|
|
47
31
|
}
|
|
48
32
|
function isFallback(agent) {
|
|
49
33
|
return agent.fallback === true;
|
|
50
34
|
}
|
|
35
|
+
/** Whether an agent holds a flow of its own, which takes the flow lock when it activates. */
|
|
36
|
+
function isStateful(agent) {
|
|
37
|
+
return !!(agent.onActivate || agent.onDeactivate);
|
|
38
|
+
}
|
|
39
|
+
/** Run an agent's hand-off hook, logging rather than propagating a throw, as the session does. */
|
|
40
|
+
function callHandOffHook(agent, hook, run) {
|
|
41
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
42
|
+
try {
|
|
43
|
+
yield run();
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
logger.warn(`OrchestratingDriver: ${hook}("${agent}") threw:`, e);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
51
50
|
/**
|
|
52
51
|
* Flatten a `contextReserve` declaration to the two numbers the gate reads
|
|
53
52
|
* (GENC-1582), or `undefined` when the agent declared nothing.
|
|
@@ -95,7 +94,7 @@ export const UNROUTABLE_PLACEHOLDER = '{{unroutable}}';
|
|
|
95
94
|
* A fallback prompt written by a consumer reliably covers two cases — small talk, and a request
|
|
96
95
|
* that belongs to a specialist — because those are the ones you think of when you write it. The
|
|
97
96
|
* case it exists FOR is the third: a substantive request that no specialist covers, and no way to
|
|
98
|
-
* act on it, because a fallback holds no tools
|
|
97
|
+
* act on it, because a fallback holds no tools.
|
|
99
98
|
*
|
|
100
99
|
* Create's fallback prompt is the worked example (GENC-1573). It covered chit-chat and
|
|
101
100
|
* "out of scope, here are the specialists", said nothing about a request nothing can serve, and
|
|
@@ -143,24 +142,6 @@ function buildFallbackSystemPrompt(fallback, specialists) {
|
|
|
143
142
|
}
|
|
144
143
|
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.\n\n${UNROUTABLE_INSTRUCTION}`;
|
|
145
144
|
}
|
|
146
|
-
/**
|
|
147
|
-
* The note a handoff's continuation ends on, so the request closes on a user turn (see the call in
|
|
148
|
-
* `runOrchestratedTurn`).
|
|
149
|
-
*
|
|
150
|
-
* The fallback's note names no work, because its routing note already says the work is nobody's
|
|
151
|
-
* here, and the same work restated last would read as a work order — the shape that drew blank
|
|
152
|
-
* turns from it (GENC-1573). A specialist gets what remains or, when the agent handing off did not
|
|
153
|
-
* say, the user's request, which is also what the re-classify routed on.
|
|
154
|
-
*/
|
|
155
|
-
function handoffTailNote(agent, remainingTask) {
|
|
156
|
-
if (isFallback(agent)) {
|
|
157
|
-
return '[Hand-off]: no specialist here can take on what remains. Reply to the user now, as the routing note says.';
|
|
158
|
-
}
|
|
159
|
-
const work = remainingTask === null || remainingTask === void 0 ? void 0 : remainingTask.trim();
|
|
160
|
-
return work
|
|
161
|
-
? `[Hand-off]: the previous agent passed the rest of this request to you. What remains: ${work}`
|
|
162
|
-
: "[Hand-off]: the previous agent passed the rest of this request to you without saying what remains. Carry on with the user's request above.";
|
|
163
|
-
}
|
|
164
145
|
/**
|
|
165
146
|
* Orchestrates multiple specialist agents. Sits between `FoundationAiAssistant`
|
|
166
147
|
* and `ChatDriver`, classifying each user message and routing it to the right
|
|
@@ -202,32 +183,42 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
202
183
|
return this.agentSession.getActive();
|
|
203
184
|
}
|
|
204
185
|
constructor(providerRegistry, agents, options = {}) {
|
|
205
|
-
var _a, _b, _c, _d;
|
|
186
|
+
var _a, _b, _c, _d, _e;
|
|
206
187
|
super(OrchestratingDriver.createInner(providerRegistry, agents, options));
|
|
207
188
|
this.providerRegistry = providerRegistry;
|
|
208
189
|
this.agents = agents;
|
|
209
190
|
/**
|
|
210
|
-
* Set by `cancel()` for the duration of a
|
|
211
|
-
*
|
|
212
|
-
* user stops. Reset at the start of each
|
|
191
|
+
* Set by `cancel()` for the duration of a run. Stops it before the next agent
|
|
192
|
+
* runs — a hand-off's target or a hand-back's caller — so nothing more is asked
|
|
193
|
+
* of a provider after the user stops. Reset at the start of each run.
|
|
213
194
|
*/
|
|
214
195
|
this.cancelled = false;
|
|
215
196
|
/**
|
|
216
197
|
* Whether the CURRENT turn's user message has reached the inner driver's
|
|
217
198
|
* history. False through the pre-first-turn classify (where the only echo of
|
|
218
199
|
* the message is an optimistic `history-updated` dispatch), true from the
|
|
219
|
-
* moment `chatDriver.sendMessage` is entered
|
|
220
|
-
*
|
|
200
|
+
* moment `chatDriver.sendMessage` is entered. Read by the budget-wall catch in
|
|
201
|
+
* `sendMessage` to decide
|
|
221
202
|
* whether `reportBudgetExhausted` must append the message itself: appending
|
|
222
203
|
* it when already appended duplicated it; not appending it when unappended
|
|
223
204
|
* made it vanish. Reset at the top of each `runOrchestratedTurn`.
|
|
224
205
|
*/
|
|
225
206
|
this.userMessageAppended = false;
|
|
207
|
+
/**
|
|
208
|
+
* The hand-offs in progress, oldest first (GENC-1737). The top frame's target is the active agent
|
|
209
|
+
* and holds the flow lock; each caller waits, suspended, for the target above it. Saved with the
|
|
210
|
+
* session through {@link OrchestratingDriver.getHandOffStack}.
|
|
211
|
+
*/
|
|
212
|
+
this.handOffStack = [];
|
|
213
|
+
/** Hand-offs and hand-backs this user turn, against `maxHandoffs`. */
|
|
214
|
+
this.handOffsThisTurn = 0;
|
|
226
215
|
this.sessionKey = (_a = options.sessionKey) !== null && _a !== void 0 ? _a : '';
|
|
227
216
|
this.maxHandoffs = (_b = options.maxHandoffs) !== null && _b !== void 0 ? _b : DEFAULT_MAX_HANDOFFS;
|
|
217
|
+
this.maxHandOffDepth = (_c = options.maxHandOffDepth) !== null && _c !== void 0 ? _c : DEFAULT_MAX_HAND_OFF_DEPTH;
|
|
218
|
+
this.chatDriver.setHandOffCheck((request, caller) => this.checkHandOff(request, caller));
|
|
228
219
|
this.classifierHistoryLength =
|
|
229
|
-
(
|
|
230
|
-
this.classifierRetries = (
|
|
220
|
+
(_d = options.classifierHistoryLength) !== null && _d !== void 0 ? _d : DEFAULT_CLASSIFIER_HISTORY_LENGTH;
|
|
221
|
+
this.classifierRetries = (_e = options.classifierRetries) !== null && _e !== void 0 ? _e : DEFAULT_CLASSIFIER_RETRIES;
|
|
231
222
|
// Specialists drive the classifier. `excludeFromClassifier` agents are still
|
|
232
223
|
// resolvable by name (so manual pinning works) but never auto-routed.
|
|
233
224
|
this.specialists = agents.filter(isSpecialist).filter((a) => !a.excludeFromClassifier);
|
|
@@ -257,7 +248,7 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
257
248
|
activityBus: options.activityBus,
|
|
258
249
|
budgetExhaustedMessage: options.budgetExhaustedMessage,
|
|
259
250
|
aiServiceErrorMessages: options.aiServiceErrorMessages,
|
|
260
|
-
// A release
|
|
251
|
+
// A release can hand back to a waiting caller, so this driver acts on the flag itself.
|
|
261
252
|
deferAgentRelease: true,
|
|
262
253
|
});
|
|
263
254
|
}
|
|
@@ -293,15 +284,10 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
293
284
|
// the driver that owns the transcript, which appends the user-facing copy
|
|
294
285
|
// and returns the same terminal result an in-loop wall would.
|
|
295
286
|
//
|
|
296
|
-
// The user's message rides along ONLY while it is genuinely unappended
|
|
297
|
-
// `
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// wall prevents from ever running — and again at the END of every handoff
|
|
301
|
-
// iteration, by which point `chatDriver.sendMessage` HAS appended it.
|
|
302
|
-
// Passing the message unconditionally duplicated it on the handoff seam:
|
|
303
|
-
// `['user','assistant','tool','user','assistant']` in the transcript, the
|
|
304
|
-
// snapshot, and the history sent to the model once the budget is raised.
|
|
287
|
+
// The user's message rides along ONLY while it is genuinely unappended: the
|
|
288
|
+
// `history-updated` dispatch in `runOrchestratedTurn` is optimistic only, and
|
|
289
|
+
// the real append lives inside `chatDriver.sendMessage`, which a wall in the
|
|
290
|
+
// classify prevents from ever running.
|
|
305
291
|
const pendingUserMessage = this.userMessageAppended
|
|
306
292
|
? undefined
|
|
307
293
|
: Object.assign(Object.assign({ role: 'user', content: input }, (attachments ? { attachments } : {})), delegated);
|
|
@@ -318,15 +304,20 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
318
304
|
}
|
|
319
305
|
runOrchestratedTurn(input, attachments, options, delegated) {
|
|
320
306
|
return __awaiter(this, void 0, void 0, function* () {
|
|
307
|
+
var _a;
|
|
321
308
|
this.cancelled = false;
|
|
322
309
|
this.userMessageAppended = false;
|
|
310
|
+
this.handOffsThisTurn = 0;
|
|
311
|
+
// A restored hand-back whose resumed turn the host withdrew is over: its caller is active.
|
|
312
|
+
if ((_a = this.pendingHandBack) === null || _a === void 0 ? void 0 : _a.resumed)
|
|
313
|
+
this.pendingHandBack = undefined;
|
|
323
314
|
const history = this.chatDriver.getHistory();
|
|
324
315
|
// Emit the user message immediately so the UI reflects it during the classify
|
|
325
316
|
// round-trip — without this the chat appears frozen until classify returns.
|
|
326
317
|
this.dispatchEvent(new CustomEvent('history-updated', {
|
|
327
318
|
detail: [...history, Object.assign({ role: 'user', content: input, attachments }, delegated)],
|
|
328
319
|
}));
|
|
329
|
-
// What the turn is routed on
|
|
320
|
+
// What the turn is routed on.
|
|
330
321
|
const routingInput = delegated.delegate
|
|
331
322
|
? frameDelegatedContent(input, delegated.delegateFraming)
|
|
332
323
|
: input;
|
|
@@ -346,151 +337,107 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
346
337
|
else {
|
|
347
338
|
routed = yield this.classify(routingInput, history, this.activeAgent);
|
|
348
339
|
}
|
|
349
|
-
let currentAgent = routed.agent;
|
|
350
|
-
let isHandoff = false;
|
|
351
|
-
let handoffs = 0;
|
|
352
|
-
let handoffSummary = '';
|
|
353
|
-
let remainingTask = '';
|
|
354
|
-
/**
|
|
355
|
-
* Specialists that have already handled part of THIS turn and handed on. Fed to each
|
|
356
|
-
* re-classify so the remaining work cannot be routed back to an agent that just declined it
|
|
357
|
-
* — see the `excludeAgents` parameter on `classify`.
|
|
358
|
-
*/
|
|
359
|
-
const attempted = [];
|
|
360
|
-
// The outcome of the agent turn that ENDS the loop — see the return below.
|
|
361
340
|
let lastResult;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (this.cancelled)
|
|
374
|
-
break;
|
|
375
|
-
let result;
|
|
376
|
-
if (isHandoff) {
|
|
377
|
-
const contextPrimer = handoffSummary
|
|
378
|
-
? [{ role: 'user', content: `[Context from previous agent]: ${handoffSummary}` }]
|
|
379
|
-
: [];
|
|
380
|
-
// A handoff that reaches the fallback is the one case where routing has genuinely run out
|
|
381
|
-
// of options, and it used to be the worst-served: the fallback holds no tools (it is
|
|
382
|
-
// denied the continuation tool as a leaf), so it was handed a remaining task phrased as
|
|
383
|
-
// work — "debug and fix the null pointer in the event handler" — with no instruction for
|
|
384
|
-
// what to do when nothing can. Measured (GENC-1573), it answered with three consecutive
|
|
385
|
-
// blank turns.
|
|
386
|
-
//
|
|
387
|
-
// Naming the situation is what it was missing. The remaining task and the fact that no
|
|
388
|
-
// specialist covers it are both real, useful things to tell a user, and an agent told
|
|
389
|
-
// them plainly has something to say instead of nothing. Transient primer, so it steers
|
|
390
|
-
// this turn without becoming a permanent instruction in the transcript.
|
|
391
|
-
if (isFallback(currentAgent)) {
|
|
392
|
-
// `remainingTask` is model-supplied — it arrives from `request_continuation`'s
|
|
393
|
-
// `remaining_task` through an unvalidated cast — and this is the one primer where a
|
|
394
|
-
// degraded input is already known to be in play, since it exists because the fallback
|
|
395
|
-
// answered this path with three blank turns. Name the situation without the clause
|
|
396
|
-
// rather than render "the remaining work: undefined".
|
|
397
|
-
const work = (remainingTask === null || remainingTask === void 0 ? void 0 : remainingTask.trim()) || (handoffSummary === null || handoffSummary === void 0 ? void 0 : handoffSummary.trim());
|
|
398
|
-
contextPrimer.push({
|
|
399
|
-
role: 'user',
|
|
400
|
-
content: (work
|
|
401
|
-
? `[Routing note]: no specialist is available for the remaining work: ${work}\n`
|
|
402
|
-
: '[Routing note]: no specialist is available for the work that remains.\n') +
|
|
403
|
-
'Tell the user plainly what was established, that this part falls outside what any ' +
|
|
404
|
-
'specialist here can do, and what they could do next. Do not claim you will do it ' +
|
|
405
|
-
'yourself, and do not return an empty response.',
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
// The primer LEADS the request, and the history after it closes on the previous agent's
|
|
409
|
-
// turn: its `request_continuation` call, flattened to assistant prose for an agent that did
|
|
410
|
-
// not make it (`transformHistoryForAgent`). A request ending on an assistant turn is a
|
|
411
|
-
// prefill, which Anthropic refuses outright: a handoff on Claude ended in "Sorry, something
|
|
412
|
-
// went wrong" unless the agent reached had a tail context of its own, and Gemini, which
|
|
413
|
-
// tolerates it, never showed it. The tail note lands after the history as a user turn. It
|
|
414
|
-
// rides the first model call only, so the primer stays where it is: it has to outlast that
|
|
415
|
-
// call, for the whole tool loop.
|
|
416
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
417
|
-
result = yield this.chatDriver.continueFromHistory(contextPrimer, {
|
|
418
|
-
tailNote: handoffTailNote(currentAgent, remainingTask),
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
// From here the inner driver owns the user message — it appends it as
|
|
423
|
-
// its first act, before any provider call can throw. Set BEFORE the
|
|
424
|
-
// await so a wall raised anywhere inside the turn (or on a later
|
|
425
|
-
// handoff classify) sees the append as done.
|
|
341
|
+
// Cancelled during the classify. The chat driver appends its own "Stopped." marker when a turn
|
|
342
|
+
// it was running is cancelled, so we just stop here without a duplicate.
|
|
343
|
+
if (!this.cancelled) {
|
|
344
|
+
yield this.endOrphanedHandOffs(routed.agent);
|
|
345
|
+
yield this.installAgent(routed.agent);
|
|
346
|
+
// A Stop that landed while the agent was being installed. The inner driver was idle, so
|
|
347
|
+
// nothing else would stop it.
|
|
348
|
+
if (!this.cancelled) {
|
|
349
|
+
// From here the inner driver owns the user message — it appends it as its first act,
|
|
350
|
+
// before any provider call can throw. Set BEFORE the await so a wall raised anywhere
|
|
351
|
+
// inside the turn sees the append as done.
|
|
426
352
|
this.userMessageAppended = true;
|
|
427
|
-
|
|
428
|
-
result = yield this.chatDriver.sendMessage(input, attachments, options);
|
|
429
|
-
}
|
|
430
|
-
lastResult = result;
|
|
431
|
-
// Release check: a stateful agent called `releaseAgent` from a terminal
|
|
432
|
-
// tool handler. Fire onDeactivate, clear the pin, drop the user back to
|
|
433
|
-
// classifier-mode. The LLM has already emitted its final wrap-up message
|
|
434
|
-
// by the time we get here — release is purely a teardown.
|
|
435
|
-
if (this.chatDriver.getAgentReleaseRequested()) {
|
|
436
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
437
|
-
yield this.releaseActiveAgent();
|
|
438
|
-
break;
|
|
439
|
-
}
|
|
440
|
-
// Pinned agents never hand off — the continuation tool is filtered out in
|
|
441
|
-
// installAgent, but this guards against a model hallucinating a handoff result.
|
|
442
|
-
if (result.reason !== 'agent-handoff' || isFallback(currentAgent) || pinned) {
|
|
443
|
-
break;
|
|
353
|
+
lastResult = yield this.carryOn(yield this.chatDriver.sendMessage(input, attachments, options));
|
|
444
354
|
}
|
|
445
|
-
// A Stop that landed in a batch which also handed off. The loop's own check would stop the
|
|
446
|
-
// next agent, but only after the classify that picks it: a provider call after the user
|
|
447
|
-
// pressed Stop.
|
|
448
|
-
if (this.cancelled)
|
|
449
|
-
break;
|
|
450
|
-
handoffs += 1;
|
|
451
|
-
if (handoffs > this.maxHandoffs) {
|
|
452
|
-
this.appendInlineMessage(`I wasn't able to fully complete your request — the task required more hand-offs between specialists than allowed. Please try breaking your request into smaller steps.`);
|
|
453
|
-
break;
|
|
454
|
-
}
|
|
455
|
-
handoffSummary = result.summary;
|
|
456
|
-
remainingTask = result.remainingTask;
|
|
457
|
-
isHandoff = true;
|
|
458
|
-
if (isSpecialist(currentAgent) && !attempted.includes(currentAgent.name)) {
|
|
459
|
-
attempted.push(currentAgent.name);
|
|
460
|
-
}
|
|
461
|
-
const updatedHistory = this.chatDriver.getHistory();
|
|
462
|
-
this.dispatchEvent(new CustomEvent('orchestrating-start'));
|
|
463
|
-
// The tool schema requires `remaining_task`, but nothing holds a model to `required`: Claude
|
|
464
|
-
// handed off with a `summary` alone, and an empty task is a classifier request with no
|
|
465
|
-
// message in it, which Anthropic refuses — on every attempt, so the turn fell to the fallback
|
|
466
|
-
// as `classifier-failed`. Route on the request the turn began with instead. The agent that
|
|
467
|
-
// just declined it is off the ballot, so the question put is the right one: who else.
|
|
468
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
469
|
-
routed = yield this.classify((remainingTask === null || remainingTask === void 0 ? void 0 : remainingTask.trim()) || routingInput, updatedHistory, undefined, attempted);
|
|
470
|
-
currentAgent = routed.agent;
|
|
471
355
|
}
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// `'budget-exhausted'`, which the element latches its blocked state off.
|
|
476
|
-
//
|
|
477
|
-
// "Last wins" cannot hide an earlier failure behind a later success: a
|
|
478
|
-
// failing inner turn always ends the loop, because the loop only continues
|
|
479
|
-
// on `reason === 'agent-handoff'`. The two breaks that hold a non-'done'
|
|
480
|
-
// result (the handoff cap, and the pinned/fallback break) both carry
|
|
481
|
-
// `'agent-handoff'`, which is an internal routing protocol that must never
|
|
482
|
-
// escape to the host — the collapse below strips it. A cancel before the
|
|
483
|
-
// first turn leaves `lastResult` undefined; the release break returns the
|
|
484
|
-
// inner turn's real result, which is correct, because that turn ran.
|
|
485
|
-
// A Stop before any agent turn took the message — in the first classify, or the first agent's
|
|
486
|
-
// install — leaves it shown only by the optimistic dispatch above, and it would vanish on the
|
|
487
|
-
// next update and from a reload. Kept, as the budget wall keeps it (see sendMessage).
|
|
356
|
+
// A Stop before any agent turn took the message — in the classify, or the agent's install —
|
|
357
|
+
// leaves it shown only by the optimistic dispatch above, and it would vanish on the next update
|
|
358
|
+
// and from a reload. Kept, as the budget wall keeps it (see sendMessage).
|
|
488
359
|
if (this.cancelled && !this.userMessageAppended) {
|
|
489
360
|
this.chatDriver.reportStoppedBeforeTurn(Object.assign(Object.assign({ role: 'user', content: input }, (attachments ? { attachments } : {})), delegated));
|
|
490
361
|
}
|
|
491
|
-
|
|
362
|
+
// The outcome of the agent call that ended the turn; a cancel before the first leaves none.
|
|
363
|
+
return lastResult !== null && lastResult !== void 0 ? lastResult : { reason: 'done' };
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Act on what the agent call that just ended asked for, and run the next agent in the same user
|
|
368
|
+
* turn while there is one (GENC-1737): a `handOff` runs its target with the brief, and a target
|
|
369
|
+
* that releases hands back to its caller. Returns the last call's result.
|
|
370
|
+
*
|
|
371
|
+
* A failed call ends the turn there, as a Stop does: what it asked for still happens, and the
|
|
372
|
+
* next agent runs on the next message. Running it now would meet the same wall (a budget, a
|
|
373
|
+
* refused account, a full context window), or build on a reply that never came.
|
|
374
|
+
*/
|
|
375
|
+
carryOn(result) {
|
|
376
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
377
|
+
let last = result;
|
|
378
|
+
let tailNote = yield this.afterAgentCall();
|
|
379
|
+
while (tailNote !== undefined && !this.cancelled && !last.failureReason) {
|
|
380
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
381
|
+
last = yield this.chatDriver.continueFromHistory(undefined, { tailNote });
|
|
382
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
383
|
+
tailNote = yield this.afterAgentCall();
|
|
384
|
+
}
|
|
385
|
+
return last;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* After one agent call: start the hand-off it asked for, or release it and, when it was a
|
|
390
|
+
* hand-off's target, hand back. Returns the tail note for the agent to run next, or `undefined`
|
|
391
|
+
* when the turn ends here.
|
|
392
|
+
*
|
|
393
|
+
* A plain target hands back when its call ends, released or not, as a plain agent is done once
|
|
394
|
+
* it has answered. A stateful one holds the hand-off until it releases, as it holds its flow.
|
|
395
|
+
*/
|
|
396
|
+
afterAgentCall() {
|
|
397
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
398
|
+
const request = this.chatDriver.getHandOffRequest();
|
|
399
|
+
if (request)
|
|
400
|
+
return this.startHandOff(request);
|
|
401
|
+
const active = this.agentSession.getActive();
|
|
402
|
+
if (!active)
|
|
403
|
+
return undefined;
|
|
404
|
+
const frame = this.topFrameFor(active.name);
|
|
405
|
+
const released = this.chatDriver.getAgentReleaseRequested();
|
|
406
|
+
if (!released && !(frame && !isStateful(active))) {
|
|
407
|
+
if (!isStateful(active))
|
|
408
|
+
this.endPlainTurn(active);
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
if (!frame) {
|
|
412
|
+
yield this.releaseActiveAgent();
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
// On the frame before the release, whose `onDeactivate` may take a while: a save in between
|
|
416
|
+
// then hands back after a reload, rather than leaving a frame nothing ends.
|
|
417
|
+
frame.outcome = this.outcomeOf(frame);
|
|
418
|
+
yield this.releaseActiveAgent();
|
|
419
|
+
return this.handBack(frame, frame.outcome);
|
|
492
420
|
});
|
|
493
421
|
}
|
|
422
|
+
/** How the target of `frame` finished: with `completeHandOff`, or a release and its last reply. */
|
|
423
|
+
outcomeOf(frame) {
|
|
424
|
+
const completion = this.chatDriver.getHandOffCompletion();
|
|
425
|
+
if (completion) {
|
|
426
|
+
return Object.assign({ kind: 'completed', message: completion.message }, (completion.data !== undefined && { data: completion.data }));
|
|
427
|
+
}
|
|
428
|
+
const finalReply = this.lastReply(frame);
|
|
429
|
+
return Object.assign({ kind: 'released' }, (finalReply ? { message: finalReply } : {}));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* A plain agent is done once it has answered (GENC-1737): the data a one-way hand-off gave it ends
|
|
433
|
+
* with its turn, and so does a flow lock no hand-off holds, such as one a restore left on a
|
|
434
|
+
* target whose hand-off it could not resume.
|
|
435
|
+
*/
|
|
436
|
+
endPlainTurn(agent) {
|
|
437
|
+
this.agentSession.setHandOffData(agent.name, undefined);
|
|
438
|
+
if (this.agentSession.getFlowOwner() === agent.name)
|
|
439
|
+
this.agentSession.dropFlowLock();
|
|
440
|
+
}
|
|
494
441
|
/** {@inheritDoc AiDriver.canRestoreFlowOwner} */
|
|
495
442
|
canRestoreFlowOwner(agentName) {
|
|
496
443
|
return this.agents.some((a) => a.name === agentName);
|
|
@@ -514,6 +461,8 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
514
461
|
*/
|
|
515
462
|
resumeRestoredFlow(resumeNote) {
|
|
516
463
|
return __awaiter(this, void 0, void 0, function* () {
|
|
464
|
+
if (this.pendingHandBack)
|
|
465
|
+
return this.resumeHandBack(resumeNote);
|
|
517
466
|
// The FLOW owner only — not a bare user pin. Only a stateful agent holds a flow, and only its
|
|
518
467
|
// state was restored; activating a pinned plain agent here would do nothing useful.
|
|
519
468
|
const owner = this.agentSession.getFlowOwner();
|
|
@@ -521,12 +470,30 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
521
470
|
if (!agent)
|
|
522
471
|
return undefined;
|
|
523
472
|
this.cancelled = false;
|
|
473
|
+
this.handOffsThisTurn = 0;
|
|
524
474
|
yield this.installAgent(agent);
|
|
525
475
|
if (!resumeNote || agent.resumeOnRestore === false || this.cancelled)
|
|
526
476
|
return undefined;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
477
|
+
return this.carryOn(yield this.chatDriver.continueFromHistory(undefined, { tailNote: resumeNote }));
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The hand-back a restore found due (GENC-1737). The first call, inside the host's restore gate,
|
|
482
|
+
* makes the caller active again and tells it the outcome; the call with the note runs its turn.
|
|
483
|
+
*/
|
|
484
|
+
resumeHandBack(note) {
|
|
485
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
486
|
+
const pending = this.pendingHandBack;
|
|
487
|
+
this.cancelled = false;
|
|
488
|
+
if (!pending.resumed) {
|
|
489
|
+
pending.resumed = true;
|
|
490
|
+
yield this.handBack(pending.frame, pending.frame.outcome);
|
|
491
|
+
}
|
|
492
|
+
if (!note || this.cancelled)
|
|
493
|
+
return undefined;
|
|
494
|
+
this.pendingHandBack = undefined;
|
|
495
|
+
this.handOffsThisTurn = 0;
|
|
496
|
+
return this.carryOn(yield this.chatDriver.continueFromHistory(undefined, { tailNote: note }));
|
|
530
497
|
});
|
|
531
498
|
}
|
|
532
499
|
/**
|
|
@@ -536,17 +503,9 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
536
503
|
*/
|
|
537
504
|
continueFromHistory(transientPrimer, options) {
|
|
538
505
|
return __awaiter(this, void 0, void 0, function* () {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
return
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
/** Release the flow if the turn that just ended asked to, and an agent is still active to release. */
|
|
545
|
-
releaseIfRequested() {
|
|
546
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
547
|
-
if (this.chatDriver.getAgentReleaseRequested() && this.agentSession.getActive()) {
|
|
548
|
-
yield this.releaseActiveAgent();
|
|
549
|
-
}
|
|
506
|
+
this.cancelled = false;
|
|
507
|
+
this.handOffsThisTurn = 0;
|
|
508
|
+
return this.carryOn(yield this.chatDriver.continueFromHistory(transientPrimer, options));
|
|
550
509
|
});
|
|
551
510
|
}
|
|
552
511
|
/**
|
|
@@ -555,22 +514,20 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
555
514
|
* Two cases, because routing has two modes and they need opposite answers.
|
|
556
515
|
*/
|
|
557
516
|
getContextReserve() {
|
|
558
|
-
// LOCKED — a user pin or a flow-owner lock.
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
// into play until it clears. Its reserve is the whole requirement.
|
|
517
|
+
// LOCKED — a user pin or a flow-owner lock. The turn routes straight to this
|
|
518
|
+
// agent, so no other agent's headroom can come into play until it clears,
|
|
519
|
+
// unless it hands off. Its reserve is the whole requirement.
|
|
562
520
|
const locked = this.resolvePinnedAgent();
|
|
563
521
|
if (locked) {
|
|
564
522
|
const reserve = normalizeContextReserve(locked.contextReserve);
|
|
565
523
|
return reserve == null ? undefined : Object.assign(Object.assign({}, reserve), { agentName: locked.name, basis: 'locked' });
|
|
566
524
|
}
|
|
567
|
-
// AUTOMATIC — the classifier picks a specialist,
|
|
568
|
-
// fallback
|
|
569
|
-
//
|
|
570
|
-
// refused before we know which, so the requirement is the largest reserve
|
|
525
|
+
// AUTOMATIC — the classifier picks a specialist, or falls through to the
|
|
526
|
+
// fallback. Any of them could be the agent that runs, and the send is allowed
|
|
527
|
+
// or refused before we know which, so the requirement is the largest reserve
|
|
571
528
|
// among them: the turn has to fit whichever way it routes.
|
|
572
529
|
//
|
|
573
|
-
// The MAX, not the sum, even though a
|
|
530
|
+
// The MAX, not the sum, even though a hand-off chain really does add each leg's
|
|
574
531
|
// growth on top of the last. Summing every reachable agent would reserve room
|
|
575
532
|
// for a journey nobody is taking and strand most of the window on a two-line
|
|
576
533
|
// question; the mid-loop context guard is what bounds a chain that does run
|
|
@@ -603,53 +560,32 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
603
560
|
}
|
|
604
561
|
return winner ? Object.assign(Object.assign({}, winner), { agentName, basis: 'reachable-max' }) : undefined;
|
|
605
562
|
}
|
|
606
|
-
/**
|
|
607
|
-
|
|
608
|
-
* {@link OrchestratingDriver.resolvePinnedAgent}.
|
|
609
|
-
*/
|
|
610
|
-
installAgent(agent, pin) {
|
|
563
|
+
/** Make `agent` active through the session, firing its hooks, and install it. */
|
|
564
|
+
installAgent(agent) {
|
|
611
565
|
return __awaiter(this, void 0, void 0, function* () {
|
|
612
566
|
// The transition — handoff record, lifecycle hooks, restored state, flow lock — belongs to
|
|
613
|
-
// the session, and the inner driver raises its events (GENC-1485).
|
|
614
|
-
// driver's own job: what the agent may route to, and what the transcript says happened.
|
|
567
|
+
// the session, and the inner driver raises its events (GENC-1485).
|
|
615
568
|
const { previousAgent } = yield this.agentSession.activate(agent);
|
|
616
|
-
|
|
617
|
-
// • fallback — already a leaf; handoff would loop
|
|
618
|
-
// • user-pinned — user explicitly selected this agent; do not auto-route away
|
|
619
|
-
// • flow-owner — a stateful flow is mid-stream; do not auto-route away
|
|
620
|
-
// • stateful — agents with lifecycle hooks own state for the duration of
|
|
621
|
-
// their flow. Initiating a handoff mid-flow would abandon
|
|
622
|
-
// that state with no clean exit and dump the user into the
|
|
623
|
-
// classifier mid-machine. Capture the tool loop until the
|
|
624
|
-
// user (or the agent itself, via `releaseAgent`) releases.
|
|
625
|
-
const isTerminal = isFallback(agent) ||
|
|
626
|
-
(pin === undefined ? this.agentSession.getPinned() : pin) !== null ||
|
|
627
|
-
this.agentSession.getFlowOwner() !== null ||
|
|
628
|
-
!!(agent.onActivate || agent.onDeactivate);
|
|
629
|
-
let agentToApply = agent;
|
|
630
|
-
if (!isTerminal) {
|
|
631
|
-
const declaredTools = agent.toolDefinitions;
|
|
632
|
-
agentToApply = Object.assign(Object.assign({}, agent), { toolDefinitions: typeof declaredTools === 'function'
|
|
633
|
-
? (ctx) => __awaiter(this, void 0, void 0, function* () {
|
|
634
|
-
return [
|
|
635
|
-
...(yield declaredTools(ctx)),
|
|
636
|
-
REQUEST_CONTINUATION_DEFINITION,
|
|
637
|
-
];
|
|
638
|
-
})
|
|
639
|
-
: [...(declaredTools !== null && declaredTools !== void 0 ? declaredTools : []), REQUEST_CONTINUATION_DEFINITION] });
|
|
640
|
-
}
|
|
641
|
-
if (previousAgent && previousAgent.name !== agent.name) {
|
|
642
|
-
const rawHistory = this.chatDriver.getHistory();
|
|
643
|
-
this.chatDriver.loadHistory([
|
|
644
|
-
...rawHistory,
|
|
645
|
-
{ role: 'system-event', content: agent.name, timestamp: new Date().toISOString() },
|
|
646
|
-
]);
|
|
647
|
-
}
|
|
648
|
-
this.chatDriver.setProviderHistoryTransform((h) => transformHistoryForAgent(h, agent.name));
|
|
649
|
-
this.chatDriver.installAgent(agentToApply);
|
|
650
|
-
this.dispatchEvent(new CustomEvent('orchestrating-stop'));
|
|
569
|
+
this.applyAgent(agent, previousAgent === null || previousAgent === void 0 ? void 0 : previousAgent.name);
|
|
651
570
|
});
|
|
652
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Install an agent the session has already made active on the inner driver: the transcript's
|
|
574
|
+
* divider when another agent spoke before it, and its own view of the history. What is left here
|
|
575
|
+
* is this driver's own job, the transcript; the session owns the lifecycle.
|
|
576
|
+
*/
|
|
577
|
+
applyAgent(agent, previousName) {
|
|
578
|
+
if (previousName && previousName !== agent.name) {
|
|
579
|
+
const rawHistory = this.chatDriver.getHistory();
|
|
580
|
+
this.chatDriver.loadHistory([
|
|
581
|
+
...rawHistory,
|
|
582
|
+
{ role: 'system-event', content: agent.name, timestamp: new Date().toISOString() },
|
|
583
|
+
]);
|
|
584
|
+
}
|
|
585
|
+
this.chatDriver.setProviderHistoryTransform((h) => transformHistoryForAgent(h, agent.name));
|
|
586
|
+
this.chatDriver.installAgent(agent);
|
|
587
|
+
this.dispatchEvent(new CustomEvent('orchestrating-stop'));
|
|
588
|
+
}
|
|
653
589
|
/**
|
|
654
590
|
* Release the current stateful agent: fire `onDeactivate` and clear the flow-owner lock. The
|
|
655
591
|
* inner driver raises the events from the session's transition, so the host (and Redux) reflect
|
|
@@ -666,10 +602,277 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
666
602
|
yield this.agentSession.release();
|
|
667
603
|
});
|
|
668
604
|
}
|
|
605
|
+
/** The agent named `name` as this driver installs it: the fallback with its prompt built. */
|
|
606
|
+
agentNamed(name) {
|
|
607
|
+
var _a;
|
|
608
|
+
if (((_a = this.fallback) === null || _a === void 0 ? void 0 : _a.name) === name)
|
|
609
|
+
return this.fallback;
|
|
610
|
+
return this.agents.find((a) => a.name === name);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* End the hand-offs when a turn runs an agent other than the top target (GENC-1737): the lock
|
|
614
|
+
* that kept turns on the target was taken away, by a host clearing `flowOwnerAgentName` say,
|
|
615
|
+
* and its caller would otherwise wait for good.
|
|
616
|
+
*/
|
|
617
|
+
endOrphanedHandOffs(agent) {
|
|
618
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
619
|
+
const top = this.handOffStack[this.handOffStack.length - 1];
|
|
620
|
+
if (!top || top.outcome || top.target === agent.name)
|
|
621
|
+
return;
|
|
622
|
+
yield this.unwindHandOffs('cleared');
|
|
623
|
+
// No teardown follows, as one does on Clear, so the callers that waited are deactivated here.
|
|
624
|
+
yield this.agentSession.endSuspended();
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Check a handler's `handOff` when it is made (GENC-1737), so a refusal reaches its model. Checked
|
|
629
|
+
* against the agents and the stack as they are now; the hand-off itself starts after the call.
|
|
630
|
+
*/
|
|
631
|
+
checkHandOff(request, caller) {
|
|
632
|
+
var _a;
|
|
633
|
+
const target = this.agentNamed(request.to);
|
|
634
|
+
if (!target)
|
|
635
|
+
return { ok: false, reason: 'unknown-agent' };
|
|
636
|
+
const allowed = (_a = this.agents.find((a) => a.name === caller)) === null || _a === void 0 ? void 0 : _a.handOffTargets;
|
|
637
|
+
if (allowed && !allowed.includes(target.name))
|
|
638
|
+
return { ok: false, reason: 'not-allowed' };
|
|
639
|
+
if (target.name === caller ||
|
|
640
|
+
this.handOffStack.some((f) => f.caller === target.name || f.target === target.name)) {
|
|
641
|
+
return { ok: false, reason: 're-entry' };
|
|
642
|
+
}
|
|
643
|
+
if (request.returnTo !== false && this.handOffStack.length >= this.maxHandOffDepth) {
|
|
644
|
+
return { ok: false, reason: 'depth' };
|
|
645
|
+
}
|
|
646
|
+
if (this.handOffsThisTurn >= this.maxHandoffs)
|
|
647
|
+
return { ok: false, reason: 'limit' };
|
|
648
|
+
return { ok: true };
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Start the hand-off the agent call just made, and return the target's tail note (GENC-1737).
|
|
652
|
+
*
|
|
653
|
+
* - `returnTo` (the default): push a frame. The caller waits without being deactivated, its
|
|
654
|
+
* `agentEnd` payloads suspended, and the target takes the flow lock.
|
|
655
|
+
* - One-way from a hand-off's target: that frame's caller now waits for the new target instead.
|
|
656
|
+
* - One-way with nobody waiting: a plain switch, the caller deactivated as on any swap.
|
|
657
|
+
*
|
|
658
|
+
* Either way the brief is recorded as a message the caller sent on the user's behalf.
|
|
659
|
+
*/
|
|
660
|
+
startHandOff(request) {
|
|
661
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
662
|
+
var _a;
|
|
663
|
+
const caller = this.agentSession.getActive();
|
|
664
|
+
const target = this.agentNamed(request.to);
|
|
665
|
+
if (!caller || !target)
|
|
666
|
+
return undefined;
|
|
667
|
+
this.handOffsThisTurn += 1;
|
|
668
|
+
const frame = Object.assign(Object.assign({ id: crypto.randomUUID(), caller: caller.name, target: target.name, returnTo: request.returnTo !== false, message: request.message }, (request.data !== undefined && { data: request.data })), { startedAt: new Date().toISOString() });
|
|
669
|
+
const top = this.handOffStack[this.handOffStack.length - 1];
|
|
670
|
+
if (frame.returnTo) {
|
|
671
|
+
this.handOffStack.push(frame);
|
|
672
|
+
yield callHandOffHook(caller.name, 'onHandOffStart', () => { var _a; return (_a = caller.onHandOffStart) === null || _a === void 0 ? void 0 : _a.call(caller, frameCopy(frame)); });
|
|
673
|
+
this.chatDriver.suspendActivation(caller.name);
|
|
674
|
+
yield this.agentSession.handOff(target, { suspendCaller: true });
|
|
675
|
+
}
|
|
676
|
+
else if ((top === null || top === void 0 ? void 0 : top.target) === caller.name) {
|
|
677
|
+
top.target = target.name;
|
|
678
|
+
yield this.agentSession.handOff(target, { suspendCaller: false });
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
yield this.agentSession.activate(target);
|
|
682
|
+
}
|
|
683
|
+
this.applyAgent((_a = this.agentSession.getActive()) !== null && _a !== void 0 ? _a : target, caller.name);
|
|
684
|
+
// For the target's handlers, one-way or not, until its activation ends. A copy of its own.
|
|
685
|
+
this.agentSession.setHandOffData(target.name, frameCopy(frame).data);
|
|
686
|
+
this.recordDelegated(caller.name, frame.message, `Handed over by ${caller.name}`);
|
|
687
|
+
this.dispatchEvent(new CustomEvent('handoff-started', { detail: frameCopy(frame) }));
|
|
688
|
+
return handOffNote(caller.name);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Hand back to `frame`'s caller with `outcome`, and return its tail note (GENC-1737). The outcome
|
|
693
|
+
* is kept on the frame until the caller is active again and its `onHandOffReturn` has run, so a
|
|
694
|
+
* reload in between still hands back; the frame then goes, before the caller's model call.
|
|
695
|
+
*/
|
|
696
|
+
handBack(frame, outcome) {
|
|
697
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
698
|
+
var _a;
|
|
699
|
+
frame.outcome = outcome;
|
|
700
|
+
this.handOffsThisTurn += 1;
|
|
701
|
+
const remaining = this.handOffStack.filter((f) => f !== frame);
|
|
702
|
+
const caller = this.agentNamed(frame.caller);
|
|
703
|
+
if (!caller) {
|
|
704
|
+
this.handOffStack = remaining;
|
|
705
|
+
this.unwound(frame, 'agent-removed');
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
yield this.agentSession.resume(caller, remaining.length > 0 || isStateful(caller));
|
|
709
|
+
// The live instance, when it waited live: its closures hold its state.
|
|
710
|
+
const live = (_a = this.agentSession.getActive()) !== null && _a !== void 0 ? _a : caller;
|
|
711
|
+
this.applyAgent(live, frame.target);
|
|
712
|
+
// In the transcript, as the brief is, so the caller reads it whenever it next runs: in this
|
|
713
|
+
// turn, or on the next message when this turn stops or a reload does not resume it.
|
|
714
|
+
if (outcome.kind === 'completed' && outcome.message) {
|
|
715
|
+
this.recordDelegated(frame.target, outcome.message, `Handed back by ${frame.target}`);
|
|
716
|
+
}
|
|
717
|
+
yield callHandOffHook(live.name, 'onHandOffReturn', () => { var _a; return (_a = live.onHandOffReturn) === null || _a === void 0 ? void 0 : _a.call(live, structuredClone(outcome), frameCopy(frame)); });
|
|
718
|
+
this.handOffStack = remaining;
|
|
719
|
+
this.dispatchEvent(new CustomEvent('handoff-returned', { detail: frameCopy(frame) }));
|
|
720
|
+
return handBackNote(frame, outcome);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
/** The top frame, when `agent` is its target. */
|
|
724
|
+
topFrameFor(agent) {
|
|
725
|
+
const top = this.handOffStack[this.handOffStack.length - 1];
|
|
726
|
+
return (top === null || top === void 0 ? void 0 : top.target) === agent ? top : undefined;
|
|
727
|
+
}
|
|
728
|
+
/** The target's last reply since its brief, for a target that released without a summary. */
|
|
729
|
+
lastReply(frame) {
|
|
730
|
+
var _a;
|
|
731
|
+
const history = this.chatDriver.getHistory();
|
|
732
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
733
|
+
const m = history[i];
|
|
734
|
+
if (m.role === 'user' && m.delegate === frame.caller)
|
|
735
|
+
return undefined;
|
|
736
|
+
if (m.role === 'assistant' &&
|
|
737
|
+
m.agentName === frame.target &&
|
|
738
|
+
reachesProvider(m) &&
|
|
739
|
+
((_a = m.content) === null || _a === void 0 ? void 0 : _a.trim())) {
|
|
740
|
+
return m.content;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
/** The brief, in the transcript as a message the caller sent on the user's behalf. */
|
|
746
|
+
recordDelegated(from, message, framing) {
|
|
747
|
+
const history = this.chatDriver.getHistory();
|
|
748
|
+
this.chatDriver.loadHistory([
|
|
749
|
+
...history,
|
|
750
|
+
{
|
|
751
|
+
role: 'user',
|
|
752
|
+
content: message,
|
|
753
|
+
delegate: from,
|
|
754
|
+
delegateFraming: framing,
|
|
755
|
+
timestamp: new Date().toISOString(),
|
|
756
|
+
},
|
|
757
|
+
]);
|
|
758
|
+
this.dispatchEvent(new CustomEvent('history-updated', { detail: this.chatDriver.getHistory() }));
|
|
759
|
+
}
|
|
760
|
+
/** End `frame` with `kind` and no hand-back, for the host. */
|
|
761
|
+
unwound(frame, kind) {
|
|
762
|
+
frame.outcome = { kind };
|
|
763
|
+
this.dispatchEvent(new CustomEvent('handoff-unwound', { detail: frameCopy(frame) }));
|
|
764
|
+
}
|
|
765
|
+
/** {@inheritDoc AiDriver.getHandOffStack} */
|
|
766
|
+
getHandOffStack() {
|
|
767
|
+
return this.handOffStack.map(frameCopy);
|
|
768
|
+
}
|
|
769
|
+
/** {@inheritDoc AiDriver.restoreHandOffStack} */
|
|
770
|
+
restoreHandOffStack(saved, savedStates, interrupted) {
|
|
771
|
+
this.handOffStack = [];
|
|
772
|
+
this.pendingHandBack = undefined;
|
|
773
|
+
const kept = [];
|
|
774
|
+
const unwoundTargets = new Set();
|
|
775
|
+
for (const frame of parseHandOffFrames(saved)) {
|
|
776
|
+
const caller = this.agents.find((a) => a.name === frame.caller);
|
|
777
|
+
const outcome = frame.outcome;
|
|
778
|
+
if (!caller) {
|
|
779
|
+
this.unwound(frame, 'agent-removed');
|
|
780
|
+
}
|
|
781
|
+
else if (interrupted || (caller.serialize && (savedStates === null || savedStates === void 0 ? void 0 : savedStates[caller.name]) === undefined)) {
|
|
782
|
+
// The caller's state is gone: a frozen save drops every agent's, so nothing can resume it.
|
|
783
|
+
this.unwound(frame, 'state-lost');
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
kept.push(frame);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
// Its activation is over, and with it the data it was handed.
|
|
790
|
+
this.agentSession.setHandOffData(frame.caller, undefined);
|
|
791
|
+
unwoundTargets.add(frame.target);
|
|
792
|
+
// The caller below waited for this one, which cannot resume: it waits for this frame's
|
|
793
|
+
// target instead, as after a one-way hand-off, and is handed back what this one would be.
|
|
794
|
+
const below = kept[kept.length - 1];
|
|
795
|
+
if ((below === null || below === void 0 ? void 0 : below.target) === frame.caller) {
|
|
796
|
+
below.target = frame.target;
|
|
797
|
+
if (outcome)
|
|
798
|
+
below.outcome = outcome;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const top = kept[kept.length - 1];
|
|
802
|
+
if (top && !top.outcome && !this.agents.some((a) => a.name === top.target)) {
|
|
803
|
+
top.outcome = { kind: 'agent-removed' };
|
|
804
|
+
}
|
|
805
|
+
this.handOffStack = kept;
|
|
806
|
+
this.chatDriver.suspendRestoredActivations(kept.map((f) => f.caller));
|
|
807
|
+
// A plain target held its lock only for an unwound hand-off, so the saved lock goes too.
|
|
808
|
+
const droppedLocks = [...unwoundTargets].filter((name) => {
|
|
809
|
+
const target = this.agents.find((a) => a.name === name);
|
|
810
|
+
return !!target && !isStateful(target) && !kept.some((f) => f.target === name);
|
|
811
|
+
});
|
|
812
|
+
const dropped = droppedLocks.length ? { droppedLocks } : {};
|
|
813
|
+
if (!(top === null || top === void 0 ? void 0 : top.outcome))
|
|
814
|
+
return dropped;
|
|
815
|
+
this.pendingHandBack = { frame: top, resumed: false };
|
|
816
|
+
return Object.assign(Object.assign({}, dropped), { resume: { agent: top.caller, note: handBackNote(top, top.outcome) } });
|
|
817
|
+
}
|
|
818
|
+
/** {@inheritDoc AiDriver.unwindHandOffs} */
|
|
819
|
+
unwindHandOffs(kind) {
|
|
820
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
821
|
+
const frames = [...this.handOffStack].reverse();
|
|
822
|
+
this.handOffStack = [];
|
|
823
|
+
this.pendingHandBack = undefined;
|
|
824
|
+
this.chatDriver.endSuspendedActivations();
|
|
825
|
+
for (const frame of frames) {
|
|
826
|
+
this.unwound(frame, kind);
|
|
827
|
+
// A cleared caller is still live, until the teardown that follows deactivates it.
|
|
828
|
+
const live = this.agentSession.getSuspended().find((a) => a.name === frame.caller);
|
|
829
|
+
if (kind === 'cleared' && live) {
|
|
830
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
831
|
+
yield callHandOffHook(live.name, 'onHandOffReturn', () => { var _a; return (_a = live.onHandOffReturn) === null || _a === void 0 ? void 0 : _a.call(live, structuredClone(frame.outcome), frameCopy(frame)); });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/** {@inheritDoc AiDriver.getHandOffData} */
|
|
837
|
+
getHandOffData() {
|
|
838
|
+
// Every caller on the stack, live or not: after a reload none is live until it resumes.
|
|
839
|
+
const live = [this.agentSession.getFlowOwner(), ...this.handOffStack.map((f) => f.caller)];
|
|
840
|
+
const saved = {};
|
|
841
|
+
for (const name of live) {
|
|
842
|
+
const data = name ? this.agentSession.getHandOffData(name) : undefined;
|
|
843
|
+
if (name && data !== undefined)
|
|
844
|
+
saved[name] = data;
|
|
845
|
+
}
|
|
846
|
+
return saved;
|
|
847
|
+
}
|
|
848
|
+
/** {@inheritDoc AiDriver.restoreHandOffData} */
|
|
849
|
+
restoreHandOffData(saved, interrupted) {
|
|
850
|
+
if (interrupted || !saved || typeof saved !== 'object' || Array.isArray(saved))
|
|
851
|
+
return;
|
|
852
|
+
for (const [name, data] of Object.entries(saved))
|
|
853
|
+
this.agentSession.setHandOffData(name, data);
|
|
854
|
+
}
|
|
855
|
+
/** {@inheritDoc AiDriver.serializeWaitingCallers} */
|
|
856
|
+
serializeWaitingCallers() {
|
|
857
|
+
const states = {};
|
|
858
|
+
for (const caller of this.agentSession.getSuspended()) {
|
|
859
|
+
if (!caller.serialize)
|
|
860
|
+
continue;
|
|
861
|
+
try {
|
|
862
|
+
const state = caller.serialize();
|
|
863
|
+
if (state !== undefined)
|
|
864
|
+
states[caller.name] = state;
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
logger.error(`OrchestratingDriver: serialize("${caller.name}") threw:`, e);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return states;
|
|
871
|
+
}
|
|
669
872
|
/**
|
|
670
873
|
* Stop the current turn (user "stop" button). Cancels the inner chat driver's
|
|
671
|
-
* in-flight request and
|
|
672
|
-
*
|
|
874
|
+
* in-flight request, and no further agent runs this turn, not even a hand-off's
|
|
875
|
+
* target or a hand-back. The driver stays usable for the next message.
|
|
673
876
|
*/
|
|
674
877
|
cancel() {
|
|
675
878
|
this.cancelled = true;
|
|
@@ -748,15 +951,9 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
748
951
|
}
|
|
749
952
|
return lines.join('\n');
|
|
750
953
|
}
|
|
751
|
-
classify(
|
|
752
|
-
return __awaiter(this,
|
|
753
|
-
|
|
754
|
-
* Specialists to leave off the ballot — the agents that have already handled (and handed off)
|
|
755
|
-
* part of this turn. Without it a re-classify can hand the remaining work straight back to
|
|
756
|
-
* the agent that just declined it, and in the worst case ping-pong until `maxHandoffs`.
|
|
757
|
-
*/
|
|
758
|
-
excludeAgents = []) {
|
|
759
|
-
var _a, _b, _c;
|
|
954
|
+
classify(input, history, contextAgent) {
|
|
955
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
956
|
+
var _a, _b;
|
|
760
957
|
// Started before every early return, and EVERY exit below reports against it — so
|
|
761
958
|
// `durationMs` is present on every `routing.decided`, and a decision that made no call says so
|
|
762
959
|
// with a ~0ms reading rather than an absent field. Uniform on purpose: a field that is always
|
|
@@ -794,35 +991,14 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
794
991
|
this.recordRouting('no-specialists', leaf, { durationMs: Date.now() - classifyStartedAt });
|
|
795
992
|
return { agent: leaf, decision: 'no-specialists' };
|
|
796
993
|
}
|
|
797
|
-
// The ballot
|
|
798
|
-
|
|
799
|
-
const ballot = excludeAgents.length
|
|
800
|
-
? this.specialists.filter((a) => !excludeAgents.includes(a.name))
|
|
801
|
-
: this.specialists;
|
|
802
|
-
if (ballot.length === 0) {
|
|
803
|
-
// Every specialist has already had a go at this turn. Asking again can only return one of
|
|
804
|
-
// them, so go straight to the escape hatch. `durationMs` is reported even though it is ~0,
|
|
805
|
-
// because that is the documented signal for "no network call happened" — omitting it left a
|
|
806
|
-
// reader with no way to tell this record from a truncated one.
|
|
807
|
-
const leaf = (_b = this.fallback) !== null && _b !== void 0 ? _b : this.specialists[0];
|
|
808
|
-
this.recordRouting('ballot-exhausted', leaf, {
|
|
809
|
-
excluded: excludeAgents,
|
|
810
|
-
durationMs: Date.now() - classifyStartedAt,
|
|
811
|
-
});
|
|
812
|
-
return { agent: leaf, decision: 'ballot-exhausted' };
|
|
813
|
-
}
|
|
994
|
+
// The ballot: indices in the classifier's answer are positions in it.
|
|
995
|
+
const ballot = this.specialists;
|
|
814
996
|
const agentList = ballot.map((a, i) => `${i}: ${a.name} — ${a.description}`).join('\n');
|
|
815
997
|
const recentMessages = this.classifierTranscript(history);
|
|
816
|
-
const continuationHint = contextAgent && isSpecialist(contextAgent)
|
|
998
|
+
const continuationHint = contextAgent && isSpecialist(contextAgent)
|
|
817
999
|
? `\n\nThe previous turn was handled by "${contextAgent.name}". If the new message is a refinement, correction, or continuation of that work, prefer "${contextAgent.name}" — unless the topic has clearly shifted to another specialist's domain.`
|
|
818
1000
|
: '';
|
|
819
|
-
|
|
820
|
-
// model can tell "nobody here covers this" from "I haven't looked hard enough" — the latter
|
|
821
|
-
// is what makes a classifier pick a poor closest match.
|
|
822
|
-
const attemptedHint = excludeAgents.length
|
|
823
|
-
? `\n\nAlready attempted this turn and handed the work on, so not listed above: ${excludeAgents.join(', ')}. Do not expect another agent to repeat their part.`
|
|
824
|
-
: '';
|
|
825
|
-
const classifierPrompt = `You are a routing classifier. Call select_agent with the index of the best matching agent for the user message. Use -1 only if the request is entirely unrelated to every agent listed — prefer the closest match when in doubt. If the request spans multiple agents, pick the one that should act first; I will re-classify and route again once that agent is done.\n\nAgents:\n${agentList}${continuationHint}${attemptedHint}${recentMessages ? `\n\nRecent conversation:\n${recentMessages}` : ''}`;
|
|
1001
|
+
const classifierPrompt = `You are a routing classifier. Call select_agent with the index of the best matching agent for the user message. Use -1 only if the request is entirely unrelated to every agent listed — prefer the closest match when in doubt. If the request spans multiple agents, pick the one that should act first.\n\nAgents:\n${agentList}${continuationHint}${recentMessages ? `\n\nRecent conversation:\n${recentMessages}` : ''}`;
|
|
826
1002
|
const routingTool = {
|
|
827
1003
|
name: 'select_agent',
|
|
828
1004
|
description: 'Select the most appropriate agent for the user message.',
|
|
@@ -862,12 +1038,17 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
862
1038
|
// oxlint-disable-next-line no-await-in-loop
|
|
863
1039
|
const response = yield classifierProvider.chat([], input, options);
|
|
864
1040
|
lastResponse = response;
|
|
865
|
-
const tc = (
|
|
1041
|
+
const tc = (_b = response.toolCalls) === null || _b === void 0 ? void 0 : _b[0];
|
|
866
1042
|
const index = (tc === null || tc === void 0 ? void 0 : tc.name) === 'select_agent' ? tc.args.agent_index : -1;
|
|
867
1043
|
if (index >= 0 && index < ballot.length) {
|
|
868
1044
|
const chosen = ballot[index];
|
|
869
|
-
this.recordRouting('classified', chosen,
|
|
870
|
-
|
|
1045
|
+
this.recordRouting('classified', chosen, {
|
|
1046
|
+
index,
|
|
1047
|
+
attempts,
|
|
1048
|
+
durationMs: Date.now() - classifyStartedAt,
|
|
1049
|
+
response,
|
|
1050
|
+
ballot: ballot.map((a) => a.name),
|
|
1051
|
+
});
|
|
871
1052
|
return { agent: chosen, decision: 'classified' };
|
|
872
1053
|
}
|
|
873
1054
|
// index === -1 — fall through to fallback
|
|
@@ -898,7 +1079,7 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
898
1079
|
// `durationMs` is the whole ladder: a decision that reached this point in a few tens of
|
|
899
1080
|
// milliseconds did not make a network call at all, and that gap between "the classifier
|
|
900
1081
|
// declined" and "the classifier never ran" is precisely what an exported log could not show.
|
|
901
|
-
const routingDetail = Object.assign(Object.assign(
|
|
1082
|
+
const routingDetail = Object.assign(Object.assign({ attempts, durationMs: Date.now() - classifyStartedAt, ballot: ballot.map((a) => a.name) }, (lastError ? { error: lastError } : {})), (lastResponse ? { response: lastResponse } : {}));
|
|
902
1083
|
if (this.fallback) {
|
|
903
1084
|
this.recordRouting(decision, this.fallback, routingDetail);
|
|
904
1085
|
return { agent: this.fallback, decision };
|
|
@@ -938,7 +1119,7 @@ export class OrchestratingDriver extends BaseDriverDecorator {
|
|
|
938
1119
|
return match;
|
|
939
1120
|
}
|
|
940
1121
|
/**
|
|
941
|
-
* Append prose the ORCHESTRATOR wrote — the
|
|
1122
|
+
* Append prose the ORCHESTRATOR wrote — the no-specialist apology. Flagged
|
|
942
1123
|
* `excludeFromProvider` for the same reason the driver's own notices are: it is not something a
|
|
943
1124
|
* model said, and feeding it back invites the model to echo it. See
|
|
944
1125
|
* `ChatMessage.excludeFromProvider`.
|