@genesislcap/ai-assistant 15.84.2 → 15.85.0-FUI-2666.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/ai-assistant.api.json +2175 -138
  2. package/dist/ai-assistant.d.ts +420 -43
  3. package/dist/chat-driver.cjs +780 -210
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +780 -209
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +2000 -910
  8. package/dist/dts/components/agent-session/agent-session.d.ts +50 -2
  9. package/dist/dts/components/agent-session/agent-session.d.ts.map +1 -1
  10. package/dist/dts/components/ai-driver/ai-driver.d.ts +59 -1
  11. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  12. package/dist/dts/components/ai-driver/base-driver-decorator.d.ts +13 -1
  13. package/dist/dts/components/ai-driver/base-driver-decorator.d.ts.map +1 -1
  14. package/dist/dts/components/chat-driver/chat-driver.constants.d.ts +0 -8
  15. package/dist/dts/components/chat-driver/chat-driver.constants.d.ts.map +1 -1
  16. package/dist/dts/components/chat-driver/chat-driver.d.ts +74 -15
  17. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  18. package/dist/dts/components/orchestrating-driver/hand-off.d.ts +20 -0
  19. package/dist/dts/components/orchestrating-driver/hand-off.d.ts.map +1 -0
  20. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +121 -22
  21. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  22. package/dist/dts/config/config.d.ts +66 -0
  23. package/dist/dts/config/config.d.ts.map +1 -1
  24. package/dist/dts/main/main.d.ts +3 -0
  25. package/dist/dts/main/main.d.ts.map +1 -1
  26. package/dist/dts/main/main.types.d.ts +6 -0
  27. package/dist/dts/main/main.types.d.ts.map +1 -1
  28. package/dist/dts/react.d.ts +4 -1
  29. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  30. package/dist/dts/state/persistence/session-snapshot.d.ts +33 -1
  31. package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
  32. package/dist/dts/utils/condense-history.d.ts +10 -3
  33. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  34. package/dist/esm/components/agent-session/agent-session.js +128 -7
  35. package/dist/esm/components/ai-driver/ai-driver.js +3 -0
  36. package/dist/esm/components/ai-driver/base-driver-decorator.js +24 -0
  37. package/dist/esm/components/chat-driver/chat-driver.constants.js +0 -8
  38. package/dist/esm/components/chat-driver/chat-driver.js +247 -74
  39. package/dist/esm/components/orchestrating-driver/hand-off.js +59 -0
  40. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +480 -299
  41. package/dist/esm/main/main.js +32 -6
  42. package/dist/esm/main/main.types.js +6 -0
  43. package/dist/esm/state/debug-event-log.js +1 -1
  44. package/dist/esm/state/persistence/session-persister.js +55 -8
  45. package/dist/esm/state/persistence/session-snapshot.js +12 -6
  46. package/dist/esm/utils/condense-history.js +13 -4
  47. package/dist/react.cjs +16 -1
  48. package/dist/react.mjs +16 -1
  49. package/docs/sub_agent.md +1 -1
  50. 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, REQUEST_CONTINUATION_TOOL, } from '../chat-driver/chat-driver';
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 and is denied the continuation tool as a leaf.
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 `sendMessage` run. Breaks the
211
- * handoff loop so we don't classify or start another agent turn after the
212
- * user stops. Reset at the start of each `sendMessage`.
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 — including through every later
220
- * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
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
- (_c = options.classifierHistoryLength) !== null && _c !== void 0 ? _c : DEFAULT_CLASSIFIER_HISTORY_LENGTH;
230
- this.classifierRetries = (_d = options.classifierRetries) !== null && _d !== void 0 ? _d : DEFAULT_CLASSIFIER_RETRIES;
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 ends this driver's handoff loop, so it acts on the flag itself; see the option.
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
- // `classify()` runs at two seams: before the first agent turn — where the
298
- // `history-updated` dispatch in `runOrchestratedTurn` is optimistic only
299
- // and the real append lives inside `chatDriver.sendMessage`, which the
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 — and routed on again after a handoff that names no remaining task.
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
- while (true) {
363
- // Cancelled before a (next) agent turn started — e.g. during classify.
364
- // The chat driver appends its own "Stopped." marker when a turn it was
365
- // running is cancelled, so we just stop here without a duplicate.
366
- if (this.cancelled)
367
- break;
368
- // oxlint-disable-next-line no-await-in-loop
369
- yield this.installAgent(currentAgent, pin);
370
- // A Stop that landed while the agent was being installed, after the check above. The inner
371
- // driver was idle, so nothing else would stop it: the call below would run the next agent,
372
- // and a continuation would even give it a scope of its own.
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
- // oxlint-disable-next-line no-await-in-loop
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
- // The orchestrated turn reports the outcome of the agent turn that ENDED it.
473
- // The hardcoded `{ reason: 'done' }` that used to live here made every
474
- // typed failure unreachable for an agents-configured host — including
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
- return (lastResult === null || lastResult === void 0 ? void 0 : lastResult.reason) === 'done' ? lastResult : { reason: 'done' };
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
- const result = yield this.chatDriver.continueFromHistory(undefined, { tailNote: resumeNote });
528
- yield this.releaseIfRequested();
529
- return result;
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
- const result = yield this.chatDriver.continueFromHistory(transientPrimer, options);
540
- yield this.releaseIfRequested();
541
- return result;
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. `handleTurn` routes straight to
559
- // this agent and the handoff branch at the bottom of the routing loop refuses
560
- // to route away while a lock is held, so no other agent's headroom can come
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, may fall through to the
568
- // fallback, and may hand off and re-classify more than once inside the one
569
- // turn. Any of them could be the agent that runs, and the send is allowed or
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 handoff chain really does add each leg's
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
- * `pin` stands in for the user-pin when given, as for
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). What is left below is this
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
- // Terminal agents do not get the cross-agent handoff tool. Four cases:
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 breaks the handoff loop so no further agent turn or
672
- * classify runs. The driver stays usable for the next message.
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(input_1, history_1, contextAgent_1) {
752
- return __awaiter(this, arguments, void 0, function* (input, history, contextAgent,
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 for THIS classification. Indices are positions in `ballot`, not in
798
- // `this.specialists`, so an exclusion cannot silently shift what an index means.
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) && !excludeAgents.includes(contextAgent.name)
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
- // Naming what has already been tried, rather than just removing it from the list, so the
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 = (_c = response.toolCalls) === null || _c === void 0 ? void 0 : _c[0];
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, Object.assign({ index,
870
- attempts, durationMs: Date.now() - classifyStartedAt, response, ballot: ballot.map((a) => a.name) }, (excludeAgents.length ? { excluded: excludeAgents } : {})));
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(Object.assign({ attempts, durationMs: Date.now() - classifyStartedAt, ballot: ballot.map((a) => a.name) }, (lastError ? { error: lastError } : {})), (lastResponse ? { response: lastResponse } : {})), (excludeAgents.length ? { excluded: excludeAgents } : {}));
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 handoff cap, the no-specialist apology. Flagged
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`.