@genesislcap/ai-assistant 15.84.3 → 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 +1514 -424
  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
@@ -45,7 +45,6 @@ __export(chat_driver_node_exports, {
45
45
  OrchestratingDriver: () => OrchestratingDriver,
46
46
  PROVIDER_REFUSED_CODE: () => PROVIDER_REFUSED_CODE,
47
47
  ProviderRefusedError: () => ProviderRefusedError,
48
- REQUEST_CONTINUATION_TOOL: () => REQUEST_CONTINUATION_TOOL,
49
48
  SUPPORTED_ANTHROPIC_MODEL_IDS: () => SUPPORTED_ANTHROPIC_MODEL_IDS,
50
49
  SUPPORTED_GEMINI_MODEL_IDS: () => SUPPORTED_GEMINI_MODEL_IDS,
51
50
  UNROUTABLE_INSTRUCTION: () => UNROUTABLE_INSTRUCTION,
@@ -4493,7 +4492,7 @@ function toolCallIdsIn(history) {
4493
4492
  }
4494
4493
  return ids;
4495
4494
  }
4496
- function snapshotCondensePolicies(policies, clocks, history) {
4495
+ function snapshotCondensePolicies(policies, clocks, history, suspended = /* @__PURE__ */ new Map()) {
4497
4496
  if (policies.size === 0) return [];
4498
4497
  const present = toolCallIdsIn(history);
4499
4498
  const out = [];
@@ -4508,8 +4507,11 @@ function snapshotCondensePolicies(policies, clocks, history) {
4508
4507
  // the clamp only stops a malformed entry from being written out as "the future".
4509
4508
  modelCalls: Math.max(0, clocks.modelCall - entry.iteration),
4510
4509
  turns: Math.max(0, clocks.turn - entry.turn),
4511
- activations: Math.max(0, clocks.activation - entry.activation),
4512
- phases: Math.max(0, clocks.phaseEpoch - entry.phaseEpoch)
4510
+ activations: suspended.has(entry.activation) ? 0 : Math.max(0, clocks.activation - entry.activation),
4511
+ phases: Math.max(
4512
+ 0,
4513
+ (suspended.get(entry.activation) ?? clocks.phaseEpoch) - entry.phaseEpoch
4514
+ )
4513
4515
  }
4514
4516
  });
4515
4517
  }
@@ -4629,7 +4631,7 @@ function applyCondensation(history, policies, ctx, onCondensed) {
4629
4631
  // backstop, when the whole activation has ended, so a phase boundary that
4630
4632
  // is never declared still collapses at `agentEnd` rather than never.
4631
4633
  case "phaseEnd":
4632
- return ctx.phaseEnded(entry.phaseEpoch) || ctx.activationEnded(entry.activation);
4634
+ return ctx.phaseEnded(entry.phaseEpoch, entry.activation) || ctx.activationEnded(entry.activation);
4633
4635
  default:
4634
4636
  return latestByKey.get(trig.by) !== toolCallId;
4635
4637
  }
@@ -5004,6 +5006,17 @@ var AgentSession = class {
5004
5006
  this.restoredStates = {};
5005
5007
  /** The tail of the activation queue — see {@link AgentSession.activate}. */
5006
5008
  this.activation = Promise.resolve();
5009
+ /**
5010
+ * Callers waiting in a hand-off (GENC-1737), oldest first: still live, so never deactivated by
5011
+ * the hand-off, and resumed without `onActivate`. Empty after a reload, when a caller has no live
5012
+ * state and is activated again from its saved state instead.
5013
+ */
5014
+ this.suspended = [];
5015
+ /**
5016
+ * The `data` of the hand-off that activated each agent (GENC-1737), for its tool handlers: kept
5017
+ * while it is active or waits as a caller, and dropped when its activation ends.
5018
+ */
5019
+ this.handOffData = /* @__PURE__ */ new Map();
5007
5020
  }
5008
5021
  /** The active agent, or `undefined` before the first activation and after a release. */
5009
5022
  getActive() {
@@ -5045,6 +5058,84 @@ var AgentSession = class {
5045
5058
  hasRestoredState(agentName) {
5046
5059
  return this.restoredStates[agentName] !== void 0;
5047
5060
  }
5061
+ /** The `data` of the hand-off that activated `agentName`, if one did and carried any. */
5062
+ getHandOffData(agentName) {
5063
+ return this.handOffData.get(agentName);
5064
+ }
5065
+ /** Record the `data` a hand-off activated `agentName` with, or restore it; `undefined` drops it. */
5066
+ setHandOffData(agentName, data) {
5067
+ if (data === void 0) this.handOffData.delete(agentName);
5068
+ else this.handOffData.set(agentName, data);
5069
+ }
5070
+ /**
5071
+ * Drop the flow lock and leave the active agent active (GENC-1737): a plain agent holding it with
5072
+ * no hand-off behind it is done once its turn ends.
5073
+ */
5074
+ dropFlowLock() {
5075
+ if (this.flowOwner === null) return;
5076
+ this.flowOwner = null;
5077
+ this.reportTransition({
5078
+ previousAgent: this.active,
5079
+ switched: false,
5080
+ flowOwnerChangedTo: null
5081
+ });
5082
+ }
5083
+ /** The callers waiting in a hand-off whose state is live, oldest first. */
5084
+ getSuspended() {
5085
+ return this.suspended;
5086
+ }
5087
+ /**
5088
+ * Make `target` active for a hand-off (GENC-1737), holding the flow lock whatever kind of agent
5089
+ * it is, since a waiting caller depends on it. With `suspendCaller` the active agent waits:
5090
+ * it is not deactivated and keeps its state. Without it the active agent is deactivated as on
5091
+ * any swap: a one-way hand-off from a target, whose waiting caller then waits for the new
5092
+ * target. Queued with {@link AgentSession.activate}.
5093
+ */
5094
+ handOff(target, options) {
5095
+ const run = this.activation.then(
5096
+ () => this.activateNow(target, { lock: true, suspendPrevious: options.suspendCaller })
5097
+ );
5098
+ this.activation = run.catch(() => void 0);
5099
+ return run;
5100
+ }
5101
+ /**
5102
+ * Make a waiting caller active again when its hand-off ends (GENC-1737). One whose state is live
5103
+ * resumes without `onActivate`, since it was never deactivated; after a reload it is activated
5104
+ * from its saved state like any other agent. `lock` keeps the flow lock on it: it held it before,
5105
+ * or another caller is still waiting further down.
5106
+ */
5107
+ resume(caller, lock) {
5108
+ const run = this.activation.then(async () => {
5109
+ const index = this.suspended.findIndex((a) => a.name === caller.name);
5110
+ if (index === -1) return this.activateNow(caller, { lock });
5111
+ const [live2] = this.suspended.splice(index, 1);
5112
+ const transition = { previousAgent: this.active, switched: true };
5113
+ recordMetaEvent(this.deps.sessionKey(), "agent.handoff", {
5114
+ from: this.active?.name ?? null,
5115
+ to: live2.name,
5116
+ restored: false
5117
+ });
5118
+ this.active = live2;
5119
+ if (lock && this.flowOwner !== caller.name) {
5120
+ this.flowOwner = caller.name;
5121
+ transition.flowOwnerChangedTo = caller.name;
5122
+ }
5123
+ this.reportTransition(transition);
5124
+ return transition;
5125
+ });
5126
+ this.activation = run.catch(() => void 0);
5127
+ return run;
5128
+ }
5129
+ /**
5130
+ * Deactivate the callers left waiting by hand-offs that ended without a hand-back, newest first,
5131
+ * when no teardown follows to do it (GENC-1737).
5132
+ */
5133
+ async endSuspended() {
5134
+ for (const caller of this.suspended.splice(0).reverse()) {
5135
+ this.handOffData.delete(caller.name);
5136
+ await this.fireDeactivate(caller, "release");
5137
+ }
5138
+ }
5048
5139
  /**
5049
5140
  * Make `agent` active, firing the lifecycle hooks around the swap: outgoing `onDeactivate`
5050
5141
  * first, then incoming `onActivate`. Both are awaited so heavy activation work (a machine
@@ -5065,7 +5156,11 @@ var AgentSession = class {
5065
5156
  this.activation = run.catch(() => void 0);
5066
5157
  return run;
5067
5158
  }
5068
- async activateNow(agent) {
5159
+ /**
5160
+ * `lock` takes the flow lock whatever kind of agent it is, and `suspendPrevious` leaves the
5161
+ * outgoing agent waiting instead of deactivating it, as a hand-off does (GENC-1737).
5162
+ */
5163
+ async activateNow(agent, { lock = false, suspendPrevious = false } = {}) {
5069
5164
  const previousAgent = this.active;
5070
5165
  const switched = !previousAgent || previousAgent.name !== agent.name;
5071
5166
  const transition = { previousAgent, switched };
@@ -5077,7 +5172,14 @@ var AgentSession = class {
5077
5172
  restored: this.hasRestoredState(agent.name)
5078
5173
  });
5079
5174
  }
5080
- if (switched && previousAgent) await this.fireDeactivate(previousAgent, "switch");
5175
+ if (switched && previousAgent) {
5176
+ if (suspendPrevious) {
5177
+ this.suspended.push(previousAgent);
5178
+ } else {
5179
+ this.handOffData.delete(previousAgent.name);
5180
+ await this.fireDeactivate(previousAgent, "switch");
5181
+ }
5182
+ }
5081
5183
  if (switched && agent.onActivate) {
5082
5184
  const restored = this.restoredStates[agent.name];
5083
5185
  if (restored !== void 0) delete this.restoredStates[agent.name];
@@ -5094,9 +5196,12 @@ var AgentSession = class {
5094
5196
  }
5095
5197
  }
5096
5198
  const hasLifecycleHooks = !!(agent.onActivate || agent.onDeactivate);
5097
- if (switched && hasLifecycleHooks && this.flowOwner !== agent.name) {
5199
+ if (switched && (hasLifecycleHooks || lock) && this.flowOwner !== agent.name) {
5098
5200
  this.flowOwner = agent.name;
5099
5201
  transition.flowOwnerChangedTo = agent.name;
5202
+ } else if (switched && !suspendPrevious && this.flowOwner === previousAgent?.name) {
5203
+ this.flowOwner = null;
5204
+ transition.flowOwnerChangedTo = null;
5100
5205
  }
5101
5206
  this.active = agent;
5102
5207
  if (switched) this.reportTransition(transition);
@@ -5114,6 +5219,7 @@ var AgentSession = class {
5114
5219
  async release() {
5115
5220
  const agent = this.active;
5116
5221
  if (!agent) return void 0;
5222
+ this.handOffData.delete(agent.name);
5117
5223
  await this.fireDeactivate(agent, "release");
5118
5224
  this.active = void 0;
5119
5225
  const transition = { previousAgent: agent, switched: true };
@@ -5151,6 +5257,11 @@ var AgentSession = class {
5151
5257
  async dispose() {
5152
5258
  const agent = this.active;
5153
5259
  if (agent) await this.fireDeactivate(agent, "dispose");
5260
+ for (const caller of [...this.suspended].reverse()) {
5261
+ await this.fireDeactivate(caller, "dispose");
5262
+ }
5263
+ this.suspended = [];
5264
+ this.handOffData.clear();
5154
5265
  this.lifecycleAbortController.abort();
5155
5266
  this.active = void 0;
5156
5267
  }
@@ -5190,8 +5301,6 @@ var HARVESTED_SUBAGENT_EVENTS = /* @__PURE__ */ new Set([
5190
5301
  "tool.failed",
5191
5302
  "tool.unresolved"
5192
5303
  ]);
5193
- var REQUEST_CONTINUATION_TOOL = "request_continuation";
5194
- var HANDOFF_TOOL_RESULT_PLACEHOLDER = "Handoff to another specialist \u2014 routing continues on the next turn.";
5195
5304
 
5196
5305
  // src/utils/tool-fold.ts
5197
5306
  var TOOL_FOLD_SYMBOL = Symbol("toolFold");
@@ -6819,6 +6928,16 @@ var SERVICE_WIDE_REFUSALS = /* @__PURE__ */ new Set([
6819
6928
  "ACCESS_DENIED",
6820
6929
  "VENDOR_UNREACHABLE"
6821
6930
  ]);
6931
+ var handOffClosingNote = (to) => `[Hand-off]: You handed this request to ${to}, which takes it from here. Tell the user so in one short line. Call no tools, and do not answer the rest of the request.`;
6932
+ var HAND_BACK_CLOSING_NOTE = "[Hand-back]: You have finished this request and handed it back. Tell the user so in one short line. Call no tools, and start nothing new.";
6933
+ var jsonCopy2 = (value) => {
6934
+ try {
6935
+ const text = JSON.stringify(value);
6936
+ return text === void 0 ? void 0 : JSON.parse(text);
6937
+ } catch {
6938
+ return void 0;
6939
+ }
6940
+ };
6822
6941
  var toolResultImages = (result) => {
6823
6942
  if (!result || typeof result !== "object") return void 0;
6824
6943
  const candidate = result.attachments;
@@ -7008,6 +7127,17 @@ var ChatDriver = class _ChatDriver extends EventTarget {
7008
7127
  * doesn't leak forward.
7009
7128
  */
7010
7129
  this.agentReleaseRequested = false;
7130
+ /**
7131
+ * The `agentEnd` activation of each caller waiting in a hand-off, by agent name, with the phase
7132
+ * epoch it was suspended at (GENC-1737). A waiting caller is suspended, not ended, so neither its
7133
+ * `agentEnd` payloads nor its `phaseEnd` ones (to another agent's `endPhase()`) collapse while the
7134
+ * target runs; when it is installed again its entries move to the new activation and, while
7135
+ * their phase had not ended, to the current epoch. A caller restored from a saved session gets
7136
+ * an activation placeholder below zero, which no live activation uses.
7137
+ */
7138
+ this.suspendedActivations = /* @__PURE__ */ new Map();
7139
+ /** The last placeholder handed out for a restored caller; see `suspendedActivations`. */
7140
+ this.restoredSuspensionSeq = 0;
7011
7141
  /**
7012
7142
  * Aborted by `dispose()` on driver teardown (e.g. an agent-config swap).
7013
7143
  * Threaded into every provider call as `ChatRequestOptions.signal`, so a
@@ -7056,8 +7186,8 @@ var ChatDriver = class _ChatDriver extends EventTarget {
7056
7186
  * The stateful-agent contract for this driver: active agent, user pin, flow lock, restored
7057
7187
  * state, and the `onActivate` / `onDeactivate` hooks (§7.1).
7058
7188
  *
7059
- * **Public because `OrchestratingDriver` reaches through to it.** The orchestrator decorates a
7060
- * config before installing it (the cross-agent continuation tool), so it cannot route through
7189
+ * **Public because `OrchestratingDriver` reaches through to it.** The orchestrator builds a
7190
+ * config before installing it (the fallback's prompt), so it cannot route through
7061
7191
  * {@link ChatDriver.activateAgent} and must drive the session itself — and one shared session is
7062
7192
  * the point: two would let `getActiveAgent()` disagree between the outer and inner driver.
7063
7193
  * `@internal` rather than private so that reach is legal without six delegating one-liners.
@@ -7464,19 +7594,15 @@ var ChatDriver = class _ChatDriver extends EventTarget {
7464
7594
  this.aiServiceNoticesShown.add(code);
7465
7595
  return false;
7466
7596
  }
7467
- /**
7468
- * Whether a sub-agent hit a wall this turn that stops this driver too. A handoff goes on past an
7469
- * AI-service wall: the next agent may use another vendor, and if it uses this one its own call
7470
- * meets the wall and says so.
7471
- */
7472
- subAgentWallThisTurn(handingOff = false) {
7473
- return this.providerRefusedThisTurn || this.budgetExhaustedThisTurn || this.aiServiceErrorThisTurn && !handingOff;
7597
+ /** Whether a sub-agent hit a wall this turn that stops this driver too. */
7598
+ subAgentWallThisTurn() {
7599
+ return this.providerRefusedThisTurn || this.budgetExhaustedThisTurn || this.aiServiceErrorThisTurn;
7474
7600
  }
7475
7601
  /**
7476
7602
  * Ends the turn when a sub-agent hit a wall this turn that stops this driver too: the provider
7477
7603
  * refusing the account, the AI service (GENC-1611), or the budget. Checked before each model call,
7478
- * and again right after a tool batch, before a handoff or the iteration cap can report the turn as
7479
- * something else.
7604
+ * and again right after a tool batch, before the iteration cap can report the turn as something
7605
+ * else.
7480
7606
  */
7481
7607
  endTurnOnSubAgentWall() {
7482
7608
  if (!this.subAgentWallThisTurn()) return void 0;
@@ -7684,6 +7810,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
7684
7810
  installAgent(config) {
7685
7811
  if (config.name !== this.activeAgentName) {
7686
7812
  this.currentActivation += 1;
7813
+ this.resumeSuspendedActivation(config.name);
7687
7814
  this.resumeRestoredCondensePolicies(config.name);
7688
7815
  }
7689
7816
  this.systemPrompt = config.systemPrompt;
@@ -7885,6 +8012,109 @@ var ChatDriver = class _ChatDriver extends EventTarget {
7885
8012
  getAgentReleaseRequested() {
7886
8013
  return this.agentReleaseRequested;
7887
8014
  }
8015
+ /**
8016
+ * Offer `handOff` and `completeHandOff` to tool handlers, checking each `handOff` with `check`
8017
+ * (GENC-1737). Set by `OrchestratingDriver`, which knows the agents and the hand-off stack and acts
8018
+ * on the request after the call; a bare `ChatDriver` has no other agent to hand to.
8019
+ *
8020
+ * @internal
8021
+ */
8022
+ setHandOffCheck(check) {
8023
+ this.checkHandOff = check;
8024
+ }
8025
+ /** The `handOff` the most recent call accepted, if any (GENC-1737). */
8026
+ getHandOffRequest() {
8027
+ return this.handOffRequested;
8028
+ }
8029
+ /** What `completeHandOff` handed back in the most recent call, if it was called. */
8030
+ getHandOffCompletion() {
8031
+ return this.handOffCompletion;
8032
+ }
8033
+ /**
8034
+ * A handler's `handOff`: refused once this call has handed off or released, or when `data` is
8035
+ * not JSON, and otherwise checked by the orchestrator. `data` is kept as a JSON copy, since it is
8036
+ * saved with the session and the handler may change its own object afterwards.
8037
+ */
8038
+ requestHandOff(request) {
8039
+ if (this.handOffRequested) return { ok: false, reason: "already-requested" };
8040
+ if (this.agentReleaseRequested) return { ok: false, reason: "released" };
8041
+ let data;
8042
+ if (request?.data !== void 0) {
8043
+ data = jsonCopy2(request.data);
8044
+ if (data === void 0) return { ok: false, reason: "not-json" };
8045
+ }
8046
+ const accepted = {
8047
+ to: request.to,
8048
+ message: String(request.message ?? ""),
8049
+ ...data !== void 0 && { data },
8050
+ ...request.returnTo === false && { returnTo: false }
8051
+ };
8052
+ const result = this.checkHandOff(accepted, this.activeAgentName);
8053
+ if (result.ok) this.handOffRequested = accepted;
8054
+ return result;
8055
+ }
8056
+ /**
8057
+ * The note of the closing call a hand-off asks for (GENC-1737), or `undefined` when none: once a
8058
+ * handler's `handOff` is accepted, or `completeHandOff` is called, the rest of that batch runs,
8059
+ * then one model call with no tools, so the agent can say so but not carry on, which a model told
8060
+ * so in a tool result does anyway. Then the turn ends.
8061
+ */
8062
+ closingCallNote() {
8063
+ if (this.handOffRequested) return handOffClosingNote(this.handOffRequested.to);
8064
+ return this.handOffCompletion ? HAND_BACK_CLOSING_NOTE : void 0;
8065
+ }
8066
+ /**
8067
+ * Suspend the active agent's activation for a hand-off it made (GENC-1737): installing the target
8068
+ * advances the `agentEnd` clock as any swap does, but this caller's payloads stay live.
8069
+ */
8070
+ suspendActivation(agentName) {
8071
+ if (agentName === this.activeAgentName) {
8072
+ this.suspendedActivations.set(agentName, {
8073
+ activation: this.currentActivation,
8074
+ phaseEpoch: this.currentPhaseEpoch
8075
+ });
8076
+ }
8077
+ }
8078
+ /**
8079
+ * Suspend restored callers before their condense policies are restored, so entries still live
8080
+ * at save time stay live instead of waiting for "the next activation", which is the target's.
8081
+ */
8082
+ suspendRestoredActivations(agentNames) {
8083
+ for (const name of agentNames) {
8084
+ this.restoredSuspensionSeq -= 1;
8085
+ this.suspendedActivations.set(name, {
8086
+ activation: this.restoredSuspensionSeq,
8087
+ phaseEpoch: this.currentPhaseEpoch
8088
+ });
8089
+ }
8090
+ }
8091
+ /** End every suspension: the hand-offs were unwound, so their callers' activations are over. */
8092
+ endSuspendedActivations() {
8093
+ this.suspendedActivations.clear();
8094
+ }
8095
+ /**
8096
+ * A caller installed again takes its suspended entries into the activation it resumes in, and
8097
+ * the ones whose phase had not ended into the current phase epoch.
8098
+ */
8099
+ resumeSuspendedActivation(agentName) {
8100
+ const suspended = this.suspendedActivations.get(agentName);
8101
+ if (suspended === void 0) return;
8102
+ this.suspendedActivations.delete(agentName);
8103
+ for (const entry of this.condensePolicies.values()) {
8104
+ if (entry.activation !== suspended.activation) continue;
8105
+ entry.activation = this.currentActivation;
8106
+ if (entry.phaseEpoch >= suspended.phaseEpoch) entry.phaseEpoch = this.currentPhaseEpoch;
8107
+ }
8108
+ }
8109
+ /**
8110
+ * The suspended activations, each with the phase epoch it was suspended at, for the `agentEnd`
8111
+ * and `phaseEnd` checks and for persistence.
8112
+ */
8113
+ suspendedClocks() {
8114
+ return new Map(
8115
+ [...this.suspendedActivations.values()].map((s) => [s.activation, s.phaseEpoch])
8116
+ );
8117
+ }
7888
8118
  /**
7889
8119
  * Act on a `releaseAgent` call from a tool handler: fire `onDeactivate` and drop the flow lock,
7890
8120
  * so the next turn starts clean and the persister does not serialize a flow that is over.
@@ -8023,7 +8253,12 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8023
8253
  * @beta
8024
8254
  */
8025
8255
  getCondensePoliciesForPersistence(messages) {
8026
- return snapshotCondensePolicies(this.condensePolicies, this.condenseClocks(), messages);
8256
+ return snapshotCondensePolicies(
8257
+ this.condensePolicies,
8258
+ this.condenseClocks(),
8259
+ messages,
8260
+ this.suspendedClocks()
8261
+ );
8027
8262
  }
8028
8263
  /**
8029
8264
  * Re-register policies saved by {@link ChatDriver.getCondensePoliciesForPersistence} against
@@ -8072,9 +8307,13 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8072
8307
  const entry = parsePersistedCondensePolicy(raw);
8073
8308
  if (!entry || !present.has(entry.toolCallId)) continue;
8074
8309
  if (this.condensePolicies.has(entry.toolCallId)) continue;
8075
- this.condensePolicies.set(entry.toolCallId, rebaseRestoredCondensePolicy(entry, clocks));
8310
+ const registered = rebaseRestoredCondensePolicy(entry, clocks);
8311
+ this.condensePolicies.set(entry.toolCallId, registered);
8076
8312
  restored += 1;
8077
- if (entry.elapsed.activations === 0 && entry.agent !== this.activeAgentName) {
8313
+ const suspended = entry.elapsed.activations === 0 && entry.agent !== void 0 ? this.suspendedActivations.get(entry.agent) : void 0;
8314
+ if (suspended !== void 0) {
8315
+ registered.activation = suspended.activation;
8316
+ } else if (entry.elapsed.activations === 0 && entry.agent !== this.activeAgentName) {
8078
8317
  this.condenseAwaitingReactivation.add(entry.toolCallId);
8079
8318
  awaitingReactivation += 1;
8080
8319
  }
@@ -8431,6 +8670,8 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8431
8670
  this.subAgentCompletion = void 0;
8432
8671
  this.subAgentFailure = void 0;
8433
8672
  this.agentReleaseRequested = false;
8673
+ this.handOffRequested = void 0;
8674
+ this.handOffCompletion = void 0;
8434
8675
  this.budgetExhaustedThisTurn = false;
8435
8676
  this.budgetWallDetail = void 0;
8436
8677
  this.budgetWallViaSubAgent = false;
@@ -8564,6 +8805,12 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8564
8805
  this.currentActivation += 1;
8565
8806
  },
8566
8807
  releaseAgent: () => {
8808
+ if (this.handOffRequested) {
8809
+ logger2.warn(
8810
+ `ChatDriver(${this.activeAgentName ?? "unknown"}): releaseAgent called after handOff \u2014 ignoring; the hand-off ends this agent's turn`
8811
+ );
8812
+ return;
8813
+ }
8567
8814
  if (this.agentReleaseRequested) {
8568
8815
  logger2.warn(
8569
8816
  `ChatDriver(${this.activeAgentName ?? "unknown"}): releaseAgent called more than once \u2014 ignoring`
@@ -8573,6 +8820,34 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8573
8820
  this.agentReleaseRequested = true;
8574
8821
  this.currentActivation += 1;
8575
8822
  },
8823
+ ...this.checkHandOff && {
8824
+ handOff: (request) => this.requestHandOff(request),
8825
+ // A copy, so a handler cannot change what is saved.
8826
+ handOffData: () => jsonCopy2(this.agentSession.getHandOffData(this.activeAgentName ?? "")),
8827
+ completeHandOff: (completion) => {
8828
+ if (this.agentReleaseRequested || this.handOffRequested) {
8829
+ logger2.warn(
8830
+ `ChatDriver(${this.activeAgentName ?? "unknown"}): completeHandOff called after a release or a hand-off \u2014 ignoring`
8831
+ );
8832
+ return;
8833
+ }
8834
+ let data;
8835
+ if (completion?.data !== void 0) {
8836
+ data = jsonCopy2(completion.data);
8837
+ if (data === void 0) {
8838
+ logger2.warn(
8839
+ `ChatDriver(${this.activeAgentName ?? "unknown"}): completeHandOff data cannot be saved as JSON \u2014 handing back without it`
8840
+ );
8841
+ }
8842
+ }
8843
+ this.handOffCompletion = {
8844
+ message: String(completion?.message ?? ""),
8845
+ ...data !== void 0 && { data }
8846
+ };
8847
+ this.agentReleaseRequested = true;
8848
+ this.currentActivation += 1;
8849
+ }
8850
+ },
8576
8851
  endPhase: () => {
8577
8852
  this.currentPhaseEpoch += 1;
8578
8853
  },
@@ -8775,6 +9050,9 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8775
9050
  this.beginTurn();
8776
9051
  this.subAgentCompletion = void 0;
8777
9052
  this.subAgentFailure = void 0;
9053
+ this.agentReleaseRequested = false;
9054
+ this.handOffRequested = void 0;
9055
+ this.handOffCompletion = void 0;
8778
9056
  this.budgetExhaustedThisTurn = false;
8779
9057
  this.budgetWallDetail = void 0;
8780
9058
  this.budgetWallViaSubAgent = false;
@@ -8842,7 +9120,8 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8842
9120
  let firstLlmCall = !!currentInput;
8843
9121
  let pinnedThinkingPolicy;
8844
9122
  let thinkingPolicyPinned = false;
8845
- while (iterations < this.maxToolIterations) {
9123
+ let closingCallMade = false;
9124
+ while (iterations < this.maxToolIterations || this.closingCallNote() && !closingCallMade) {
8846
9125
  iterations += 1;
8847
9126
  this.modelCallSeq += 1;
8848
9127
  if (this.turnController.signal.aborted) {
@@ -8850,6 +9129,8 @@ var ChatDriver = class _ChatDriver extends EventTarget {
8850
9129
  }
8851
9130
  const wall = this.endTurnOnSubAgentWall();
8852
9131
  if (wall) return wall;
9132
+ if (closingCallMade) return turnDone();
9133
+ const closing = this.closingCallNote();
8853
9134
  const promptCtx = {
8854
9135
  agentName: this.activeAgentName ?? "",
8855
9136
  history: this.history,
@@ -8891,13 +9172,17 @@ ${EMPTY_RESPONSE_NUDGE}` : "";
8891
9172
  const firstCallNote = tailNote && iterations === 1 ? `
8892
9173
 
8893
9174
  ${tailNote}` : "";
8894
- const frameworkSystemSuffix = `${foldSuffix}${firstCallNote}${retryNudge}`;
9175
+ const closingNote = closing ? `
9176
+
9177
+ ${closing}` : "";
9178
+ const frameworkSystemSuffix = `${foldSuffix}${firstCallNote}${closingNote}${retryNudge}`;
8895
9179
  const primer = normalizeForProvider([
8896
9180
  ...this.primerHistory ?? [],
8897
9181
  ...transientPrimer ?? []
8898
9182
  ]);
8899
9183
  const baseHistory = firstLlmCall ? this.history.slice(0, -1) : this.history;
8900
9184
  firstLlmCall = false;
9185
+ const suspended = this.suspendedClocks();
8901
9186
  const condensedHistory = applyCondensation(
8902
9187
  [...baseHistory],
8903
9188
  this.condensePolicies,
@@ -8906,10 +9191,11 @@ ${tailNote}` : "";
8906
9191
  turn: this.turnSeq,
8907
9192
  // A call's activation has ended once a later activation is current — a
8908
9193
  // swap or release/complete both advance the counter.
8909
- activationEnded: (activation) => activation < this.currentActivation,
9194
+ // A caller waiting in a hand-off is suspended, not ended (GENC-1737).
9195
+ activationEnded: (activation) => activation < this.currentActivation && !suspended.has(activation),
8910
9196
  // A call's phase has ended once the app declared a later phase epoch
8911
- // current via `endPhase()`.
8912
- phaseEnded: (phaseEpoch) => phaseEpoch < this.currentPhaseEpoch,
9197
+ // current via `endPhase()`; a waiting caller's, only by one before it waited.
9198
+ phaseEnded: (phaseEpoch, activation) => phaseEpoch < (suspended.get(activation) ?? this.currentPhaseEpoch),
8913
9199
  batchCalls: this.condenseBatchCalls
8914
9200
  },
8915
9201
  (detail) => recordMetaEvent(this.sessionKey, "context.condensed", detail)
@@ -8949,7 +9235,7 @@ ${tailNote}` : "";
8949
9235
  const tailContext = tailBody ? `<system-reminder>
8950
9236
  ${tailBody}
8951
9237
  </system-reminder>` : void 0;
8952
- const effectiveToolChoice = resolvedToolChoice ?? (this.isSubAgent ? "required" : void 0);
9238
+ const effectiveToolChoice = closing ? "none" : resolvedToolChoice ?? (this.isSubAgent ? "required" : void 0);
8953
9239
  const turnSnapshot = this.turnRecorder.record({
8954
9240
  agentName: this.activeAgentName,
8955
9241
  agentLabel: this.activeAgentLabel,
@@ -9021,6 +9307,7 @@ ${tailBody}
9021
9307
  });
9022
9308
  return this.endTurnWithFailure(TURN_FAILURE_EXITS.contextGuard);
9023
9309
  }
9310
+ if (closing) closingCallMade = true;
9024
9311
  let response;
9025
9312
  try {
9026
9313
  response = await activeProvider.chat(historyForCall, userInputForCall, options);
@@ -9154,6 +9441,10 @@ ${tailBody}
9154
9441
  }
9155
9442
  turnSnapshot.usage = messageUsage(response);
9156
9443
  if (response.model !== void 0) turnSnapshot.model = response.model;
9444
+ if (closing) {
9445
+ if (response.toolCalls?.length) response = { ...response, toolCalls: void 0 };
9446
+ if (!response.content?.trim()) return turnDone();
9447
+ }
9157
9448
  const isThinkingStep = response.content && response.toolCalls?.length;
9158
9449
  const isEmptyResponse = !response.content?.trim() && !response.toolCalls?.length;
9159
9450
  const isRefusal = response.responseMeta?.finishReason === "refusal";
@@ -9231,17 +9522,11 @@ ${tailBody}
9231
9522
  emptyResponseAttempts = 0;
9232
9523
  malformedAttempts = 0;
9233
9524
  setupTransportAttempts = 0;
9525
+ if (closing) return turnDone();
9234
9526
  if (!response.toolCalls?.length) {
9235
9527
  break;
9236
9528
  }
9237
- const [toolCalls, systemCalls] = response.toolCalls.reduce(
9238
- (acc, tc) => {
9239
- if (tc.name === REQUEST_CONTINUATION_TOOL) acc[1].push(tc);
9240
- else acc[0].push(tc);
9241
- return acc;
9242
- },
9243
- [[], []]
9244
- );
9529
+ const toolCalls = response.toolCalls;
9245
9530
  const executedById = /* @__PURE__ */ new Map();
9246
9531
  const unknownToolIds = /* @__PURE__ */ new Set();
9247
9532
  const staleToolIds = /* @__PURE__ */ new Set();
@@ -9419,28 +9704,20 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
9419
9704
  }
9420
9705
  const foldPath = this.folds.activeNames();
9421
9706
  for (const tc of response.toolCalls) {
9422
- if (tc.name === REQUEST_CONTINUATION_TOOL) {
9707
+ const r = executedById.get(tc.id);
9708
+ if (r) {
9423
9709
  this.appendToHistory({
9424
9710
  role: "tool",
9425
9711
  content: "",
9426
- toolResult: { toolCallId: tc.id, content: HANDOFF_TOOL_RESULT_PLACEHOLDER }
9712
+ toolResult: {
9713
+ toolCallId: r.toolCallId,
9714
+ content: r.content,
9715
+ // Images DO persist, unlike the sub-agent trace above: they are the payload
9716
+ // the model must see on every replay of this turn, not a one-off UI artefact.
9717
+ // `condenseWhen` is what sheds them once they are spent — see condense-history.
9718
+ ...r.attachments ? { attachments: r.attachments } : {}
9719
+ }
9427
9720
  });
9428
- } else {
9429
- const r = executedById.get(tc.id);
9430
- if (r) {
9431
- this.appendToHistory({
9432
- role: "tool",
9433
- content: "",
9434
- toolResult: {
9435
- toolCallId: r.toolCallId,
9436
- content: r.content,
9437
- // Images DO persist, unlike the sub-agent trace above: they are the payload
9438
- // the model must see on every replay of this turn, not a one-off UI artefact.
9439
- // `condenseWhen` is what sheds them once they are spent — see condense-history.
9440
- ...r.attachments ? { attachments: r.attachments } : {}
9441
- }
9442
- });
9443
- }
9444
9721
  }
9445
9722
  }
9446
9723
  let tcMsgIdx = -1;
@@ -9504,8 +9781,7 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
9504
9781
  });
9505
9782
  return this.endTurnWithFailure(TURN_FAILURE_EXITS.unknownToolLimit);
9506
9783
  }
9507
- const firstContinuation = systemCalls[0];
9508
- if (!this.subAgentCompletion && this.subAgentWallThisTurn(!!firstContinuation)) {
9784
+ if (!this.subAgentCompletion && this.subAgentWallThisTurn()) {
9509
9785
  if (this.turnController.signal.aborted) return this.completeAbortedTurn();
9510
9786
  const wallAfterBatch = this.endTurnOnSubAgentWall();
9511
9787
  if (wallAfterBatch) return wallAfterBatch;
@@ -9514,10 +9790,6 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
9514
9790
  this.noticeAIServiceRefusal(this.aiServiceWall.code);
9515
9791
  this.flushAIServiceNotices();
9516
9792
  }
9517
- if (firstContinuation) {
9518
- const { summary, remaining_task: remainingTask } = firstContinuation.args;
9519
- return { reason: "agent-handoff", summary, remainingTask };
9520
- }
9521
9793
  if (this.subAgentCompletion) {
9522
9794
  return turnDone();
9523
9795
  }
@@ -9593,6 +9865,9 @@ var AI_DRIVER_EVENT_TYPES = Object.keys({
9593
9865
  "agent-changed": true,
9594
9866
  "agent-released": true,
9595
9867
  "flow-owner-changed": true,
9868
+ "handoff-started": true,
9869
+ "handoff-returned": true,
9870
+ "handoff-unwound": true,
9596
9871
  "orchestrating-start": true,
9597
9872
  "orchestrating-stop": true
9598
9873
  });
@@ -9673,6 +9948,24 @@ var BaseDriverDecorator = class _BaseDriverDecorator extends EventTarget {
9673
9948
  restoreCondensePolicies(entries) {
9674
9949
  return this.inner.restoreCondensePolicies?.(entries) ?? { restored: 0, awaitingReactivation: 0 };
9675
9950
  }
9951
+ getHandOffStack() {
9952
+ return this.inner.getHandOffStack?.() ?? [];
9953
+ }
9954
+ restoreHandOffStack(saved, savedStates, interrupted) {
9955
+ return this.inner.restoreHandOffStack?.(saved, savedStates, interrupted) ?? {};
9956
+ }
9957
+ unwindHandOffs(kind) {
9958
+ return this.inner.unwindHandOffs?.(kind) ?? Promise.resolve();
9959
+ }
9960
+ serializeWaitingCallers() {
9961
+ return this.inner.serializeWaitingCallers?.() ?? {};
9962
+ }
9963
+ getHandOffData() {
9964
+ return this.inner.getHandOffData?.() ?? {};
9965
+ }
9966
+ restoreHandOffData(saved, interrupted) {
9967
+ this.inner.restoreHandOffData?.(saved, interrupted);
9968
+ }
9676
9969
  getActiveAgent() {
9677
9970
  return this.inner.getActiveAgent();
9678
9971
  }
@@ -9781,36 +10074,64 @@ var BaseDriverDecorator = class _BaseDriverDecorator extends EventTarget {
9781
10074
  }
9782
10075
  };
9783
10076
 
10077
+ // src/components/orchestrating-driver/hand-off.ts
10078
+ var OUTCOME_KINDS = [
10079
+ "completed",
10080
+ "released",
10081
+ "cleared",
10082
+ "agent-removed",
10083
+ "state-lost"
10084
+ ];
10085
+ function handOffNote(caller) {
10086
+ return `[Hand-off]: ${caller} handed this request to you. Its brief is the message above. Work on it as you would on a request from the user.`;
10087
+ }
10088
+ function handBackNote(frame, outcome) {
10089
+ const how = {
10090
+ completed: `${frame.target} has finished the request you handed it.`,
10091
+ released: `${frame.target} has finished the request you handed it, without a summary. Its last reply is above.`,
10092
+ cleared: "The hand-off was cleared.",
10093
+ "agent-removed": `${frame.target} is no longer available, so the request you handed it was not finished.`,
10094
+ "state-lost": `The hand-off to ${frame.target} could not be resumed.`
10095
+ };
10096
+ const summary = outcome.kind === "completed" && outcome.message ? " Its summary is the message above." : "";
10097
+ return `[Hand-back]: ${how[outcome.kind]}${summary} Carry on with the user from here.`;
10098
+ }
10099
+ var frameCopy = (frame) => structuredClone(frame);
10100
+ var isOutcome = (value) => {
10101
+ const o = value;
10102
+ return !!o && typeof o === "object" && OUTCOME_KINDS.includes(o.kind) && (o.message === void 0 || typeof o.message === "string");
10103
+ };
10104
+ function parseHandOffFrames(saved) {
10105
+ if (!Array.isArray(saved)) return [];
10106
+ return saved.filter((value) => {
10107
+ const f = value;
10108
+ return !!f && typeof f === "object" && typeof f.id === "string" && typeof f.caller === "string" && typeof f.target === "string" && typeof f.returnTo === "boolean" && typeof f.message === "string" && typeof f.startedAt === "string" && (f.outcome === void 0 || isOutcome(f.outcome));
10109
+ });
10110
+ }
10111
+
9784
10112
  // src/components/orchestrating-driver/orchestrating-driver.ts
9785
10113
  var DEFAULT_MAX_HANDOFFS = 3;
10114
+ var DEFAULT_MAX_HAND_OFF_DEPTH = 1;
9786
10115
  var DEFAULT_CLASSIFIER_HISTORY_LENGTH = 4;
9787
10116
  var DEFAULT_CLASSIFIER_RETRIES = 2;
9788
10117
  var CLASSIFIER_MESSAGE_CHARS = 600;
9789
10118
  var CLASSIFIER_TRANSCRIPT_CHARS = 3e3;
9790
- var REQUEST_CONTINUATION_DEFINITION = {
9791
- name: REQUEST_CONTINUATION_TOOL,
9792
- 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 \u2014 another specialist will be selected automatically.",
9793
- parameters: {
9794
- type: "object",
9795
- required: ["summary", "remaining_task"],
9796
- properties: {
9797
- summary: {
9798
- type: "string",
9799
- description: "What you found or did \u2014 passed as context to the next specialist."
9800
- },
9801
- remaining_task: {
9802
- type: "string",
9803
- description: "What still needs to be done, in plain language. Used to route to the right specialist."
9804
- }
9805
- }
9806
- }
9807
- };
9808
10119
  function isSpecialist(agent) {
9809
10120
  return !agent.fallback;
9810
10121
  }
9811
10122
  function isFallback(agent) {
9812
10123
  return agent.fallback === true;
9813
10124
  }
10125
+ function isStateful(agent) {
10126
+ return !!(agent.onActivate || agent.onDeactivate);
10127
+ }
10128
+ async function callHandOffHook(agent, hook, run) {
10129
+ try {
10130
+ await run();
10131
+ } catch (e) {
10132
+ logger2.warn(`OrchestratingDriver: ${hook}("${agent}") threw:`, e);
10133
+ }
10134
+ }
9814
10135
  function normalizeContextReserve(input) {
9815
10136
  if (input == null) return void 0;
9816
10137
  const positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
@@ -9843,37 +10164,40 @@ Politely let the user know what you can help with and invite them to rephrase th
9843
10164
 
9844
10165
  ${UNROUTABLE_INSTRUCTION}`;
9845
10166
  }
9846
- function handoffTailNote(agent, remainingTask) {
9847
- if (isFallback(agent)) {
9848
- return "[Hand-off]: no specialist here can take on what remains. Reply to the user now, as the routing note says.";
9849
- }
9850
- const work = remainingTask?.trim();
9851
- return work ? `[Hand-off]: the previous agent passed the rest of this request to you. What remains: ${work}` : "[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.";
9852
- }
9853
10167
  var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator {
9854
10168
  constructor(providerRegistry, agents, options = {}) {
9855
10169
  super(_OrchestratingDriver.createInner(providerRegistry, agents, options));
9856
10170
  this.providerRegistry = providerRegistry;
9857
10171
  this.agents = agents;
9858
10172
  /**
9859
- * Set by `cancel()` for the duration of a `sendMessage` run. Breaks the
9860
- * handoff loop so we don't classify or start another agent turn after the
9861
- * user stops. Reset at the start of each `sendMessage`.
10173
+ * Set by `cancel()` for the duration of a run. Stops it before the next agent
10174
+ * runs — a hand-off's target or a hand-back's caller — so nothing more is asked
10175
+ * of a provider after the user stops. Reset at the start of each run.
9862
10176
  */
9863
10177
  this.cancelled = false;
9864
10178
  /**
9865
10179
  * Whether the CURRENT turn's user message has reached the inner driver's
9866
10180
  * history. False through the pre-first-turn classify (where the only echo of
9867
10181
  * the message is an optimistic `history-updated` dispatch), true from the
9868
- * moment `chatDriver.sendMessage` is entered — including through every later
9869
- * handoff classify. Read by the budget-wall catch in `sendMessage` to decide
10182
+ * moment `chatDriver.sendMessage` is entered. Read by the budget-wall catch in
10183
+ * `sendMessage` to decide
9870
10184
  * whether `reportBudgetExhausted` must append the message itself: appending
9871
10185
  * it when already appended duplicated it; not appending it when unappended
9872
10186
  * made it vanish. Reset at the top of each `runOrchestratedTurn`.
9873
10187
  */
9874
10188
  this.userMessageAppended = false;
10189
+ /**
10190
+ * The hand-offs in progress, oldest first (GENC-1737). The top frame's target is the active agent
10191
+ * and holds the flow lock; each caller waits, suspended, for the target above it. Saved with the
10192
+ * session through {@link OrchestratingDriver.getHandOffStack}.
10193
+ */
10194
+ this.handOffStack = [];
10195
+ /** Hand-offs and hand-backs this user turn, against `maxHandoffs`. */
10196
+ this.handOffsThisTurn = 0;
9875
10197
  this.sessionKey = options.sessionKey ?? "";
9876
10198
  this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
10199
+ this.maxHandOffDepth = options.maxHandOffDepth ?? DEFAULT_MAX_HAND_OFF_DEPTH;
10200
+ this.chatDriver.setHandOffCheck((request, caller) => this.checkHandOff(request, caller));
9877
10201
  this.classifierHistoryLength = options.classifierHistoryLength ?? DEFAULT_CLASSIFIER_HISTORY_LENGTH;
9878
10202
  this.classifierRetries = options.classifierRetries ?? DEFAULT_CLASSIFIER_RETRIES;
9879
10203
  this.specialists = agents.filter(isSpecialist).filter((a) => !a.excludeFromClassifier);
@@ -9926,7 +10250,7 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
9926
10250
  activityBus: options.activityBus,
9927
10251
  budgetExhaustedMessage: options.budgetExhaustedMessage,
9928
10252
  aiServiceErrorMessages: options.aiServiceErrorMessages,
9929
- // A release ends this driver's handoff loop, so it acts on the flag itself; see the option.
10253
+ // A release can hand back to a waiting caller, so this driver acts on the flag itself.
9930
10254
  deferAgentRelease: true
9931
10255
  });
9932
10256
  }
@@ -9966,6 +10290,8 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
9966
10290
  async runOrchestratedTurn(input, attachments, options, delegated) {
9967
10291
  this.cancelled = false;
9968
10292
  this.userMessageAppended = false;
10293
+ this.handOffsThisTurn = 0;
10294
+ if (this.pendingHandBack?.resumed) this.pendingHandBack = void 0;
9969
10295
  const history = this.chatDriver.getHistory();
9970
10296
  this.dispatchEvent(
9971
10297
  new CustomEvent("history-updated", {
@@ -9983,66 +10309,16 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
9983
10309
  } else {
9984
10310
  routed = await this.classify(routingInput, history, this.activeAgent);
9985
10311
  }
9986
- let currentAgent = routed.agent;
9987
- let isHandoff = false;
9988
- let handoffs = 0;
9989
- let handoffSummary = "";
9990
- let remainingTask = "";
9991
- const attempted = [];
9992
10312
  let lastResult;
9993
- while (true) {
9994
- if (this.cancelled) break;
9995
- await this.installAgent(currentAgent, pin);
9996
- if (this.cancelled) break;
9997
- let result;
9998
- if (isHandoff) {
9999
- const contextPrimer = handoffSummary ? [{ role: "user", content: `[Context from previous agent]: ${handoffSummary}` }] : [];
10000
- if (isFallback(currentAgent)) {
10001
- const work = remainingTask?.trim() || handoffSummary?.trim();
10002
- contextPrimer.push({
10003
- role: "user",
10004
- content: (work ? `[Routing note]: no specialist is available for the remaining work: ${work}
10005
- ` : "[Routing note]: no specialist is available for the work that remains.\n") + "Tell the user plainly what was established, that this part falls outside what any specialist here can do, and what they could do next. Do not claim you will do it yourself, and do not return an empty response."
10006
- });
10007
- }
10008
- result = await this.chatDriver.continueFromHistory(contextPrimer, {
10009
- tailNote: handoffTailNote(currentAgent, remainingTask)
10010
- });
10011
- } else {
10313
+ if (!this.cancelled) {
10314
+ await this.endOrphanedHandOffs(routed.agent);
10315
+ await this.installAgent(routed.agent);
10316
+ if (!this.cancelled) {
10012
10317
  this.userMessageAppended = true;
10013
- result = await this.chatDriver.sendMessage(input, attachments, options);
10014
- }
10015
- lastResult = result;
10016
- if (this.chatDriver.getAgentReleaseRequested()) {
10017
- await this.releaseActiveAgent();
10018
- break;
10019
- }
10020
- if (result.reason !== "agent-handoff" || isFallback(currentAgent) || pinned) {
10021
- break;
10022
- }
10023
- if (this.cancelled) break;
10024
- handoffs += 1;
10025
- if (handoffs > this.maxHandoffs) {
10026
- this.appendInlineMessage(
10027
- `I wasn't able to fully complete your request \u2014 the task required more hand-offs between specialists than allowed. Please try breaking your request into smaller steps.`
10318
+ lastResult = await this.carryOn(
10319
+ await this.chatDriver.sendMessage(input, attachments, options)
10028
10320
  );
10029
- break;
10030
10321
  }
10031
- handoffSummary = result.summary;
10032
- remainingTask = result.remainingTask;
10033
- isHandoff = true;
10034
- if (isSpecialist(currentAgent) && !attempted.includes(currentAgent.name)) {
10035
- attempted.push(currentAgent.name);
10036
- }
10037
- const updatedHistory = this.chatDriver.getHistory();
10038
- this.dispatchEvent(new CustomEvent("orchestrating-start"));
10039
- routed = await this.classify(
10040
- remainingTask?.trim() || routingInput,
10041
- updatedHistory,
10042
- void 0,
10043
- attempted
10044
- );
10045
- currentAgent = routed.agent;
10046
10322
  }
10047
10323
  if (this.cancelled && !this.userMessageAppended) {
10048
10324
  this.chatDriver.reportStoppedBeforeTurn({
@@ -10052,7 +10328,74 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10052
10328
  ...delegated
10053
10329
  });
10054
10330
  }
10055
- return lastResult?.reason === "done" ? lastResult : { reason: "done" };
10331
+ return lastResult ?? { reason: "done" };
10332
+ }
10333
+ /**
10334
+ * Act on what the agent call that just ended asked for, and run the next agent in the same user
10335
+ * turn while there is one (GENC-1737): a `handOff` runs its target with the brief, and a target
10336
+ * that releases hands back to its caller. Returns the last call's result.
10337
+ *
10338
+ * A failed call ends the turn there, as a Stop does: what it asked for still happens, and the
10339
+ * next agent runs on the next message. Running it now would meet the same wall (a budget, a
10340
+ * refused account, a full context window), or build on a reply that never came.
10341
+ */
10342
+ async carryOn(result) {
10343
+ let last = result;
10344
+ let tailNote = await this.afterAgentCall();
10345
+ while (tailNote !== void 0 && !this.cancelled && !last.failureReason) {
10346
+ last = await this.chatDriver.continueFromHistory(void 0, { tailNote });
10347
+ tailNote = await this.afterAgentCall();
10348
+ }
10349
+ return last;
10350
+ }
10351
+ /**
10352
+ * After one agent call: start the hand-off it asked for, or release it and, when it was a
10353
+ * hand-off's target, hand back. Returns the tail note for the agent to run next, or `undefined`
10354
+ * when the turn ends here.
10355
+ *
10356
+ * A plain target hands back when its call ends, released or not, as a plain agent is done once
10357
+ * it has answered. A stateful one holds the hand-off until it releases, as it holds its flow.
10358
+ */
10359
+ async afterAgentCall() {
10360
+ const request = this.chatDriver.getHandOffRequest();
10361
+ if (request) return this.startHandOff(request);
10362
+ const active = this.agentSession.getActive();
10363
+ if (!active) return void 0;
10364
+ const frame = this.topFrameFor(active.name);
10365
+ const released = this.chatDriver.getAgentReleaseRequested();
10366
+ if (!released && !(frame && !isStateful(active))) {
10367
+ if (!isStateful(active)) this.endPlainTurn(active);
10368
+ return void 0;
10369
+ }
10370
+ if (!frame) {
10371
+ await this.releaseActiveAgent();
10372
+ return void 0;
10373
+ }
10374
+ frame.outcome = this.outcomeOf(frame);
10375
+ await this.releaseActiveAgent();
10376
+ return this.handBack(frame, frame.outcome);
10377
+ }
10378
+ /** How the target of `frame` finished: with `completeHandOff`, or a release and its last reply. */
10379
+ outcomeOf(frame) {
10380
+ const completion = this.chatDriver.getHandOffCompletion();
10381
+ if (completion) {
10382
+ return {
10383
+ kind: "completed",
10384
+ message: completion.message,
10385
+ ...completion.data !== void 0 && { data: completion.data }
10386
+ };
10387
+ }
10388
+ const finalReply = this.lastReply(frame);
10389
+ return { kind: "released", ...finalReply ? { message: finalReply } : {} };
10390
+ }
10391
+ /**
10392
+ * A plain agent is done once it has answered (GENC-1737): the data a one-way hand-off gave it ends
10393
+ * with its turn, and so does a flow lock no hand-off holds, such as one a restore left on a
10394
+ * target whose hand-off it could not resume.
10395
+ */
10396
+ endPlainTurn(agent) {
10397
+ this.agentSession.setHandOffData(agent.name, void 0);
10398
+ if (this.agentSession.getFlowOwner() === agent.name) this.agentSession.dropFlowLock();
10056
10399
  }
10057
10400
  /** {@inheritDoc AiDriver.canRestoreFlowOwner} */
10058
10401
  canRestoreFlowOwner(agentName) {
@@ -10076,15 +10419,33 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10076
10419
  * undecorated.
10077
10420
  */
10078
10421
  async resumeRestoredFlow(resumeNote) {
10422
+ if (this.pendingHandBack) return this.resumeHandBack(resumeNote);
10079
10423
  const owner = this.agentSession.getFlowOwner();
10080
10424
  const agent = owner ? this.agents.find((a) => a.name === owner) : void 0;
10081
10425
  if (!agent) return void 0;
10082
10426
  this.cancelled = false;
10427
+ this.handOffsThisTurn = 0;
10083
10428
  await this.installAgent(agent);
10084
10429
  if (!resumeNote || agent.resumeOnRestore === false || this.cancelled) return void 0;
10085
- const result = await this.chatDriver.continueFromHistory(void 0, { tailNote: resumeNote });
10086
- await this.releaseIfRequested();
10087
- return result;
10430
+ return this.carryOn(
10431
+ await this.chatDriver.continueFromHistory(void 0, { tailNote: resumeNote })
10432
+ );
10433
+ }
10434
+ /**
10435
+ * The hand-back a restore found due (GENC-1737). The first call, inside the host's restore gate,
10436
+ * makes the caller active again and tells it the outcome; the call with the note runs its turn.
10437
+ */
10438
+ async resumeHandBack(note) {
10439
+ const pending = this.pendingHandBack;
10440
+ this.cancelled = false;
10441
+ if (!pending.resumed) {
10442
+ pending.resumed = true;
10443
+ await this.handBack(pending.frame, pending.frame.outcome);
10444
+ }
10445
+ if (!note || this.cancelled) return void 0;
10446
+ this.pendingHandBack = void 0;
10447
+ this.handOffsThisTurn = 0;
10448
+ return this.carryOn(await this.chatDriver.continueFromHistory(void 0, { tailNote: note }));
10088
10449
  }
10089
10450
  /**
10090
10451
  * A continuation outside a routed turn — a decorator carrying the agent on, say — can finish the
@@ -10092,15 +10453,9 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10092
10453
  * (GENC-1660), only `sendMessage` checked, and a flow finished by a continuation stayed locked.
10093
10454
  */
10094
10455
  async continueFromHistory(transientPrimer, options) {
10095
- const result = await this.chatDriver.continueFromHistory(transientPrimer, options);
10096
- await this.releaseIfRequested();
10097
- return result;
10098
- }
10099
- /** Release the flow if the turn that just ended asked to, and an agent is still active to release. */
10100
- async releaseIfRequested() {
10101
- if (this.chatDriver.getAgentReleaseRequested() && this.agentSession.getActive()) {
10102
- await this.releaseActiveAgent();
10103
- }
10456
+ this.cancelled = false;
10457
+ this.handOffsThisTurn = 0;
10458
+ return this.carryOn(await this.chatDriver.continueFromHistory(transientPrimer, options));
10104
10459
  }
10105
10460
  /**
10106
10461
  * {@inheritDoc AiDriver.getContextReserve}
@@ -10128,25 +10483,18 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10128
10483
  }
10129
10484
  return winner ? { ...winner, agentName, basis: "reachable-max" } : void 0;
10130
10485
  }
10486
+ /** Make `agent` active through the session, firing its hooks, and install it. */
10487
+ async installAgent(agent) {
10488
+ const { previousAgent } = await this.agentSession.activate(agent);
10489
+ this.applyAgent(agent, previousAgent?.name);
10490
+ }
10131
10491
  /**
10132
- * `pin` stands in for the user-pin when given, as for
10133
- * {@link OrchestratingDriver.resolvePinnedAgent}.
10492
+ * Install an agent the session has already made active on the inner driver: the transcript's
10493
+ * divider when another agent spoke before it, and its own view of the history. What is left here
10494
+ * is this driver's own job, the transcript; the session owns the lifecycle.
10134
10495
  */
10135
- async installAgent(agent, pin) {
10136
- const { previousAgent } = await this.agentSession.activate(agent);
10137
- const isTerminal = isFallback(agent) || (pin === void 0 ? this.agentSession.getPinned() : pin) !== null || this.agentSession.getFlowOwner() !== null || !!(agent.onActivate || agent.onDeactivate);
10138
- let agentToApply = agent;
10139
- if (!isTerminal) {
10140
- const declaredTools = agent.toolDefinitions;
10141
- agentToApply = {
10142
- ...agent,
10143
- toolDefinitions: typeof declaredTools === "function" ? async (ctx) => [
10144
- ...await declaredTools(ctx),
10145
- REQUEST_CONTINUATION_DEFINITION
10146
- ] : [...declaredTools ?? [], REQUEST_CONTINUATION_DEFINITION]
10147
- };
10148
- }
10149
- if (previousAgent && previousAgent.name !== agent.name) {
10496
+ applyAgent(agent, previousName) {
10497
+ if (previousName && previousName !== agent.name) {
10150
10498
  const rawHistory = this.chatDriver.getHistory();
10151
10499
  this.chatDriver.loadHistory([
10152
10500
  ...rawHistory,
@@ -10154,7 +10502,7 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10154
10502
  ]);
10155
10503
  }
10156
10504
  this.chatDriver.setProviderHistoryTransform((h) => transformHistoryForAgent(h, agent.name));
10157
- this.chatDriver.installAgent(agentToApply);
10505
+ this.chatDriver.installAgent(agent);
10158
10506
  this.dispatchEvent(new CustomEvent("orchestrating-stop"));
10159
10507
  }
10160
10508
  /**
@@ -10171,10 +10519,250 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10171
10519
  async releaseActiveAgent() {
10172
10520
  await this.agentSession.release();
10173
10521
  }
10522
+ /** The agent named `name` as this driver installs it: the fallback with its prompt built. */
10523
+ agentNamed(name) {
10524
+ if (this.fallback?.name === name) return this.fallback;
10525
+ return this.agents.find((a) => a.name === name);
10526
+ }
10527
+ /**
10528
+ * End the hand-offs when a turn runs an agent other than the top target (GENC-1737): the lock
10529
+ * that kept turns on the target was taken away, by a host clearing `flowOwnerAgentName` say,
10530
+ * and its caller would otherwise wait for good.
10531
+ */
10532
+ async endOrphanedHandOffs(agent) {
10533
+ const top = this.handOffStack[this.handOffStack.length - 1];
10534
+ if (!top || top.outcome || top.target === agent.name) return;
10535
+ await this.unwindHandOffs("cleared");
10536
+ await this.agentSession.endSuspended();
10537
+ }
10538
+ /**
10539
+ * Check a handler's `handOff` when it is made (GENC-1737), so a refusal reaches its model. Checked
10540
+ * against the agents and the stack as they are now; the hand-off itself starts after the call.
10541
+ */
10542
+ checkHandOff(request, caller) {
10543
+ const target = this.agentNamed(request.to);
10544
+ if (!target) return { ok: false, reason: "unknown-agent" };
10545
+ const allowed = this.agents.find((a) => a.name === caller)?.handOffTargets;
10546
+ if (allowed && !allowed.includes(target.name)) return { ok: false, reason: "not-allowed" };
10547
+ if (target.name === caller || this.handOffStack.some((f) => f.caller === target.name || f.target === target.name)) {
10548
+ return { ok: false, reason: "re-entry" };
10549
+ }
10550
+ if (request.returnTo !== false && this.handOffStack.length >= this.maxHandOffDepth) {
10551
+ return { ok: false, reason: "depth" };
10552
+ }
10553
+ if (this.handOffsThisTurn >= this.maxHandoffs) return { ok: false, reason: "limit" };
10554
+ return { ok: true };
10555
+ }
10556
+ /**
10557
+ * Start the hand-off the agent call just made, and return the target's tail note (GENC-1737).
10558
+ *
10559
+ * - `returnTo` (the default): push a frame. The caller waits without being deactivated, its
10560
+ * `agentEnd` payloads suspended, and the target takes the flow lock.
10561
+ * - One-way from a hand-off's target: that frame's caller now waits for the new target instead.
10562
+ * - One-way with nobody waiting: a plain switch, the caller deactivated as on any swap.
10563
+ *
10564
+ * Either way the brief is recorded as a message the caller sent on the user's behalf.
10565
+ */
10566
+ async startHandOff(request) {
10567
+ const caller = this.agentSession.getActive();
10568
+ const target = this.agentNamed(request.to);
10569
+ if (!caller || !target) return void 0;
10570
+ this.handOffsThisTurn += 1;
10571
+ const frame = {
10572
+ id: crypto.randomUUID(),
10573
+ caller: caller.name,
10574
+ target: target.name,
10575
+ returnTo: request.returnTo !== false,
10576
+ message: request.message,
10577
+ ...request.data !== void 0 && { data: request.data },
10578
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
10579
+ };
10580
+ const top = this.handOffStack[this.handOffStack.length - 1];
10581
+ if (frame.returnTo) {
10582
+ this.handOffStack.push(frame);
10583
+ await callHandOffHook(
10584
+ caller.name,
10585
+ "onHandOffStart",
10586
+ () => caller.onHandOffStart?.(frameCopy(frame))
10587
+ );
10588
+ this.chatDriver.suspendActivation(caller.name);
10589
+ await this.agentSession.handOff(target, { suspendCaller: true });
10590
+ } else if (top?.target === caller.name) {
10591
+ top.target = target.name;
10592
+ await this.agentSession.handOff(target, { suspendCaller: false });
10593
+ } else {
10594
+ await this.agentSession.activate(target);
10595
+ }
10596
+ this.applyAgent(this.agentSession.getActive() ?? target, caller.name);
10597
+ this.agentSession.setHandOffData(target.name, frameCopy(frame).data);
10598
+ this.recordDelegated(caller.name, frame.message, `Handed over by ${caller.name}`);
10599
+ this.dispatchEvent(new CustomEvent("handoff-started", { detail: frameCopy(frame) }));
10600
+ return handOffNote(caller.name);
10601
+ }
10602
+ /**
10603
+ * Hand back to `frame`'s caller with `outcome`, and return its tail note (GENC-1737). The outcome
10604
+ * is kept on the frame until the caller is active again and its `onHandOffReturn` has run, so a
10605
+ * reload in between still hands back; the frame then goes, before the caller's model call.
10606
+ */
10607
+ async handBack(frame, outcome) {
10608
+ frame.outcome = outcome;
10609
+ this.handOffsThisTurn += 1;
10610
+ const remaining = this.handOffStack.filter((f) => f !== frame);
10611
+ const caller = this.agentNamed(frame.caller);
10612
+ if (!caller) {
10613
+ this.handOffStack = remaining;
10614
+ this.unwound(frame, "agent-removed");
10615
+ return void 0;
10616
+ }
10617
+ await this.agentSession.resume(caller, remaining.length > 0 || isStateful(caller));
10618
+ const live2 = this.agentSession.getActive() ?? caller;
10619
+ this.applyAgent(live2, frame.target);
10620
+ if (outcome.kind === "completed" && outcome.message) {
10621
+ this.recordDelegated(frame.target, outcome.message, `Handed back by ${frame.target}`);
10622
+ }
10623
+ await callHandOffHook(
10624
+ live2.name,
10625
+ "onHandOffReturn",
10626
+ () => live2.onHandOffReturn?.(structuredClone(outcome), frameCopy(frame))
10627
+ );
10628
+ this.handOffStack = remaining;
10629
+ this.dispatchEvent(new CustomEvent("handoff-returned", { detail: frameCopy(frame) }));
10630
+ return handBackNote(frame, outcome);
10631
+ }
10632
+ /** The top frame, when `agent` is its target. */
10633
+ topFrameFor(agent) {
10634
+ const top = this.handOffStack[this.handOffStack.length - 1];
10635
+ return top?.target === agent ? top : void 0;
10636
+ }
10637
+ /** The target's last reply since its brief, for a target that released without a summary. */
10638
+ lastReply(frame) {
10639
+ const history = this.chatDriver.getHistory();
10640
+ for (let i = history.length - 1; i >= 0; i -= 1) {
10641
+ const m = history[i];
10642
+ if (m.role === "user" && m.delegate === frame.caller) return void 0;
10643
+ if (m.role === "assistant" && m.agentName === frame.target && reachesProvider(m) && m.content?.trim()) {
10644
+ return m.content;
10645
+ }
10646
+ }
10647
+ return void 0;
10648
+ }
10649
+ /** The brief, in the transcript as a message the caller sent on the user's behalf. */
10650
+ recordDelegated(from, message, framing) {
10651
+ const history = this.chatDriver.getHistory();
10652
+ this.chatDriver.loadHistory([
10653
+ ...history,
10654
+ {
10655
+ role: "user",
10656
+ content: message,
10657
+ delegate: from,
10658
+ delegateFraming: framing,
10659
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
10660
+ }
10661
+ ]);
10662
+ this.dispatchEvent(
10663
+ new CustomEvent("history-updated", { detail: this.chatDriver.getHistory() })
10664
+ );
10665
+ }
10666
+ /** End `frame` with `kind` and no hand-back, for the host. */
10667
+ unwound(frame, kind) {
10668
+ frame.outcome = { kind };
10669
+ this.dispatchEvent(new CustomEvent("handoff-unwound", { detail: frameCopy(frame) }));
10670
+ }
10671
+ /** {@inheritDoc AiDriver.getHandOffStack} */
10672
+ getHandOffStack() {
10673
+ return this.handOffStack.map(frameCopy);
10674
+ }
10675
+ /** {@inheritDoc AiDriver.restoreHandOffStack} */
10676
+ restoreHandOffStack(saved, savedStates, interrupted) {
10677
+ this.handOffStack = [];
10678
+ this.pendingHandBack = void 0;
10679
+ const kept = [];
10680
+ const unwoundTargets = /* @__PURE__ */ new Set();
10681
+ for (const frame of parseHandOffFrames(saved)) {
10682
+ const caller = this.agents.find((a) => a.name === frame.caller);
10683
+ const outcome = frame.outcome;
10684
+ if (!caller) {
10685
+ this.unwound(frame, "agent-removed");
10686
+ } else if (interrupted || caller.serialize && savedStates?.[caller.name] === void 0) {
10687
+ this.unwound(frame, "state-lost");
10688
+ } else {
10689
+ kept.push(frame);
10690
+ continue;
10691
+ }
10692
+ this.agentSession.setHandOffData(frame.caller, void 0);
10693
+ unwoundTargets.add(frame.target);
10694
+ const below = kept[kept.length - 1];
10695
+ if (below?.target === frame.caller) {
10696
+ below.target = frame.target;
10697
+ if (outcome) below.outcome = outcome;
10698
+ }
10699
+ }
10700
+ const top = kept[kept.length - 1];
10701
+ if (top && !top.outcome && !this.agents.some((a) => a.name === top.target)) {
10702
+ top.outcome = { kind: "agent-removed" };
10703
+ }
10704
+ this.handOffStack = kept;
10705
+ this.chatDriver.suspendRestoredActivations(kept.map((f) => f.caller));
10706
+ const droppedLocks = [...unwoundTargets].filter((name) => {
10707
+ const target = this.agents.find((a) => a.name === name);
10708
+ return !!target && !isStateful(target) && !kept.some((f) => f.target === name);
10709
+ });
10710
+ const dropped = droppedLocks.length ? { droppedLocks } : {};
10711
+ if (!top?.outcome) return dropped;
10712
+ this.pendingHandBack = { frame: top, resumed: false };
10713
+ return { ...dropped, resume: { agent: top.caller, note: handBackNote(top, top.outcome) } };
10714
+ }
10715
+ /** {@inheritDoc AiDriver.unwindHandOffs} */
10716
+ async unwindHandOffs(kind) {
10717
+ const frames = [...this.handOffStack].reverse();
10718
+ this.handOffStack = [];
10719
+ this.pendingHandBack = void 0;
10720
+ this.chatDriver.endSuspendedActivations();
10721
+ for (const frame of frames) {
10722
+ this.unwound(frame, kind);
10723
+ const live2 = this.agentSession.getSuspended().find((a) => a.name === frame.caller);
10724
+ if (kind === "cleared" && live2) {
10725
+ await callHandOffHook(
10726
+ live2.name,
10727
+ "onHandOffReturn",
10728
+ () => live2.onHandOffReturn?.(structuredClone(frame.outcome), frameCopy(frame))
10729
+ );
10730
+ }
10731
+ }
10732
+ }
10733
+ /** {@inheritDoc AiDriver.getHandOffData} */
10734
+ getHandOffData() {
10735
+ const live2 = [this.agentSession.getFlowOwner(), ...this.handOffStack.map((f) => f.caller)];
10736
+ const saved = {};
10737
+ for (const name of live2) {
10738
+ const data = name ? this.agentSession.getHandOffData(name) : void 0;
10739
+ if (name && data !== void 0) saved[name] = data;
10740
+ }
10741
+ return saved;
10742
+ }
10743
+ /** {@inheritDoc AiDriver.restoreHandOffData} */
10744
+ restoreHandOffData(saved, interrupted) {
10745
+ if (interrupted || !saved || typeof saved !== "object" || Array.isArray(saved)) return;
10746
+ for (const [name, data] of Object.entries(saved)) this.agentSession.setHandOffData(name, data);
10747
+ }
10748
+ /** {@inheritDoc AiDriver.serializeWaitingCallers} */
10749
+ serializeWaitingCallers() {
10750
+ const states = {};
10751
+ for (const caller of this.agentSession.getSuspended()) {
10752
+ if (!caller.serialize) continue;
10753
+ try {
10754
+ const state = caller.serialize();
10755
+ if (state !== void 0) states[caller.name] = state;
10756
+ } catch (e) {
10757
+ logger2.error(`OrchestratingDriver: serialize("${caller.name}") threw:`, e);
10758
+ }
10759
+ }
10760
+ return states;
10761
+ }
10174
10762
  /**
10175
10763
  * Stop the current turn (user "stop" button). Cancels the inner chat driver's
10176
- * in-flight request and breaks the handoff loop so no further agent turn or
10177
- * classify runs. The driver stays usable for the next message.
10764
+ * in-flight request, and no further agent runs this turn, not even a hand-off's
10765
+ * target or a hand-back. The driver stays usable for the next message.
10178
10766
  */
10179
10767
  cancel() {
10180
10768
  this.cancelled = true;
@@ -10211,12 +10799,8 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10211
10799
  decision,
10212
10800
  to: agent.name,
10213
10801
  fallback: isFallback(agent),
10214
- // The BALLOT, not `this.specialists`. `index` is a position in the ballot (see its
10215
- // construction in `classify`), so publishing the unfiltered specialist list beside it made
10216
- // `candidates[index]` resolve to the wrong agent whenever `excluded` was non-empty — and the
10217
- // README sentence invited exactly that join. `this.specialists` stays recoverable as
10218
- // `candidates ∪ excluded`. Absent where nothing was put to a vote, which is more truthful
10219
- // than a full list. Reviewer catch on #2496.
10802
+ // The ballot, so `candidates[index]` is the agent chosen. Absent where nothing was put to a
10803
+ // vote, which is more truthful than a full list.
10220
10804
  ...ballot ? { candidates: ballot } : {},
10221
10805
  ...rest,
10222
10806
  ...usage ? { usage } : {},
@@ -10239,7 +10823,7 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10239
10823
  }
10240
10824
  return lines.join("\n");
10241
10825
  }
10242
- async classify(input, history, contextAgent, excludeAgents = []) {
10826
+ async classify(input, history, contextAgent) {
10243
10827
  const classifyStartedAt = Date.now();
10244
10828
  if (this.specialists.length === 1 && !this.fallback) {
10245
10829
  const only = this.specialists[0];
@@ -10262,27 +10846,16 @@ var OrchestratingDriver = class _OrchestratingDriver extends BaseDriverDecorator
10262
10846
  this.recordRouting("no-specialists", leaf, { durationMs: Date.now() - classifyStartedAt });
10263
10847
  return { agent: leaf, decision: "no-specialists" };
10264
10848
  }
10265
- const ballot = excludeAgents.length ? this.specialists.filter((a) => !excludeAgents.includes(a.name)) : this.specialists;
10266
- if (ballot.length === 0) {
10267
- const leaf = this.fallback ?? this.specialists[0];
10268
- this.recordRouting("ballot-exhausted", leaf, {
10269
- excluded: excludeAgents,
10270
- durationMs: Date.now() - classifyStartedAt
10271
- });
10272
- return { agent: leaf, decision: "ballot-exhausted" };
10273
- }
10849
+ const ballot = this.specialists;
10274
10850
  const agentList = ballot.map((a, i) => `${i}: ${a.name} \u2014 ${a.description}`).join("\n");
10275
10851
  const recentMessages = this.classifierTranscript(history);
10276
- const continuationHint = contextAgent && isSpecialist(contextAgent) && !excludeAgents.includes(contextAgent.name) ? `
10852
+ const continuationHint = contextAgent && isSpecialist(contextAgent) ? `
10277
10853
 
10278
10854
  The previous turn was handled by "${contextAgent.name}". If the new message is a refinement, correction, or continuation of that work, prefer "${contextAgent.name}" \u2014 unless the topic has clearly shifted to another specialist's domain.` : "";
10279
- const attemptedHint = excludeAgents.length ? `
10280
-
10281
- Already attempted this turn and handed the work on, so not listed above: ${excludeAgents.join(", ")}. Do not expect another agent to repeat their part.` : "";
10282
- 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 \u2014 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.
10855
+ 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 \u2014 prefer the closest match when in doubt. If the request spans multiple agents, pick the one that should act first.
10283
10856
 
10284
10857
  Agents:
10285
- ${agentList}${continuationHint}${attemptedHint}${recentMessages ? `
10858
+ ${agentList}${continuationHint}${recentMessages ? `
10286
10859
 
10287
10860
  Recent conversation:
10288
10861
  ${recentMessages}` : ""}`;
@@ -10330,8 +10903,7 @@ ${recentMessages}` : ""}`;
10330
10903
  attempts,
10331
10904
  durationMs: Date.now() - classifyStartedAt,
10332
10905
  response,
10333
- ballot: ballot.map((a) => a.name),
10334
- ...excludeAgents.length ? { excluded: excludeAgents } : {}
10906
+ ballot: ballot.map((a) => a.name)
10335
10907
  });
10336
10908
  return { agent: chosen, decision: "classified" };
10337
10909
  }
@@ -10352,8 +10924,7 @@ ${recentMessages}` : ""}`;
10352
10924
  durationMs: Date.now() - classifyStartedAt,
10353
10925
  ballot: ballot.map((a) => a.name),
10354
10926
  ...lastError ? { error: lastError } : {},
10355
- ...lastResponse ? { response: lastResponse } : {},
10356
- ...excludeAgents.length ? { excluded: excludeAgents } : {}
10927
+ ...lastResponse ? { response: lastResponse } : {}
10357
10928
  };
10358
10929
  if (this.fallback) {
10359
10930
  this.recordRouting(decision, this.fallback, routingDetail);
@@ -10391,7 +10962,7 @@ ${recentMessages}` : ""}`;
10391
10962
  return match;
10392
10963
  }
10393
10964
  /**
10394
- * Append prose the ORCHESTRATOR wrote — the handoff cap, the no-specialist apology. Flagged
10965
+ * Append prose the ORCHESTRATOR wrote — the no-specialist apology. Flagged
10395
10966
  * `excludeFromProvider` for the same reason the driver's own notices are: it is not something a
10396
10967
  * model said, and feeding it back invites the model to echo it. See
10397
10968
  * `ChatMessage.excludeFromProvider`.
@@ -10900,7 +11471,6 @@ function withFreshMetaSnapshot(stored, fresh) {
10900
11471
  OrchestratingDriver,
10901
11472
  PROVIDER_REFUSED_CODE,
10902
11473
  ProviderRefusedError,
10903
- REQUEST_CONTINUATION_TOOL,
10904
11474
  SUPPORTED_ANTHROPIC_MODEL_IDS,
10905
11475
  SUPPORTED_GEMINI_MODEL_IDS,
10906
11476
  UNROUTABLE_INSTRUCTION,