@genesislcap/ai-assistant 15.24.2 → 15.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/ai-assistant.api.json +1065 -33
  2. package/dist/ai-assistant.d.ts +288 -102
  3. package/dist/chat-driver.cjs +630 -378
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +630 -378
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +1198 -388
  8. package/dist/dts/components/agent-session/agent-session.d.ts +134 -0
  9. package/dist/dts/components/agent-session/agent-session.d.ts.map +1 -0
  10. package/dist/dts/components/agent-session/index.d.ts +2 -0
  11. package/dist/dts/components/agent-session/index.d.ts.map +1 -0
  12. package/dist/dts/components/ai-driver/ai-driver.d.ts +31 -4
  13. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  14. package/dist/dts/components/chat-driver/chat-driver.constants.d.ts +52 -0
  15. package/dist/dts/components/chat-driver/chat-driver.constants.d.ts.map +1 -0
  16. package/dist/dts/components/chat-driver/chat-driver.d.ts +100 -82
  17. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  18. package/dist/dts/components/chat-driver/fold-controller.d.ts +82 -0
  19. package/dist/dts/components/chat-driver/fold-controller.d.ts.map +1 -0
  20. package/dist/dts/components/chat-driver/turn-failures.d.ts +20 -1
  21. package/dist/dts/components/chat-driver/turn-failures.d.ts.map +1 -1
  22. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -24
  23. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  24. package/dist/dts/index.d.ts +1 -0
  25. package/dist/dts/index.d.ts.map +1 -1
  26. package/dist/dts/main/main.d.ts +6 -16
  27. package/dist/dts/main/main.d.ts.map +1 -1
  28. package/dist/dts/suggestions/generate-suggestions.d.ts +35 -0
  29. package/dist/dts/suggestions/generate-suggestions.d.ts.map +1 -0
  30. package/dist/dts/utils/agents-key.d.ts +10 -0
  31. package/dist/dts/utils/agents-key.d.ts.map +1 -0
  32. package/dist/dts/utils/resolve-turn-input.d.ts +9 -0
  33. package/dist/dts/utils/resolve-turn-input.d.ts.map +1 -0
  34. package/dist/esm/components/agent-session/agent-session.js +208 -0
  35. package/dist/esm/components/agent-session/index.js +1 -0
  36. package/dist/esm/components/chat-driver/chat-driver.constants.js +72 -0
  37. package/dist/esm/components/chat-driver/chat-driver.js +190 -395
  38. package/dist/esm/components/chat-driver/fold-controller.js +184 -0
  39. package/dist/esm/components/chat-driver/turn-failures.js +14 -0
  40. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +48 -131
  41. package/dist/esm/index.js +1 -0
  42. package/dist/esm/main/main.js +11 -38
  43. package/dist/esm/suggestions/generate-suggestions.js +123 -0
  44. package/dist/esm/utils/agents-key.js +24 -0
  45. package/dist/esm/utils/resolve-turn-input.js +16 -0
  46. package/docs/migration-GENC-1580.md +44 -36
  47. package/package.json +16 -16
@@ -4,13 +4,19 @@ import { NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
4
4
  import { resolveChatProvider } from '../../config/validate-providers';
5
5
  import { clearSession, getMetaEvents, mergeMetaEvents, recordMetaEvent, recordTurnError, recordTurnRetry, } from '../../state/debug-event-log';
6
6
  import { createInteractionContext, } from '../../state/interaction-context';
7
+ import { generateSuggestions } from '../../suggestions/generate-suggestions';
7
8
  import { applyCondensation, condenseForPersistence, } from '../../utils/condense-history';
8
9
  import { estimatePreambleTokens, estimateRequestTokens, } from '../../utils/context-tokens';
9
10
  import { applyHistoryCap, buildCompactionSummaryPrompt, planCompaction, normalizeForProvider, } from '../../utils/history-transform';
10
11
  import { logger } from '../../utils/logger';
12
+ import { resolveTurnInput } from '../../utils/resolve-turn-input';
11
13
  import { messageUsage, sumUsage } from '../../utils/sum-usage';
12
- import { TOOL_FOLD_SYMBOL } from '../../utils/tool-fold';
13
- import { TURN_FAILURE_EXITS } from './turn-failures';
14
+ import { AgentSession } from '../agent-session/agent-session';
15
+ import { DEFAULT_MAX_FOLD_OPERATIONS, DEFAULT_MAX_TOOL_ITERATIONS, DEFAULT_MAX_TURN_SNAPSHOTS, DEFAULT_MAX_UNKNOWN_TOOL_CALLS, EMPTY_RESPONSE_NUDGE, HANDOFF_TOOL_RESULT_PLACEHOLDER, HARVESTED_SUBAGENT_EVENTS, MALFORMED_CALL_NUDGE, MAX_EMPTY_RESPONSE_RETRIES, MAX_MALFORMED_RETRIES, MAX_SETUP_TRANSPORT_RETRIES, MAX_STALE_TOOL_CALLS, REQUEST_CONTINUATION_TOOL, } from './chat-driver.constants';
16
+ import { FoldController } from './fold-controller';
17
+ import { TURN_FAILURE_EXITS, turnDone } from './turn-failures';
18
+ /** Re-exported so `from './chat-driver'` keeps resolving these. */
19
+ export { EMPTY_RESPONSE_NUDGE, MALFORMED_CALL_NUDGE, REQUEST_CONTINUATION_TOOL, } from './chat-driver.constants';
14
20
  /**
15
21
  * Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
16
22
  * when it carries none.
@@ -71,74 +77,6 @@ const budgetDetailOf = (e) => {
71
77
  return undefined;
72
78
  return Object.assign(Object.assign({ budgetUsd: e.budgetUsd, spentUsd: e.spentUsd, vendorLabel: e.vendorLabel }, (vendor ? { vendor } : {})), (e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}));
73
79
  };
74
- const DEFAULT_MAX_TOOL_ITERATIONS = 50;
75
- const DEFAULT_MAX_FOLD_OPERATIONS = 5;
76
- // TODO: dedup system prompts in-memory to allow raising this cap much higher.
77
- // Each TurnSnapshot stores the full resolved `systemPrompt`, so the buffer's
78
- // memory cost scales with cap × prompt size — a stable agent repeats a multi-KB
79
- // prompt every turn. `getDebugLog` already dedups identical consecutive prompts
80
- // at export time (`<repeated — identical to turn N>`); applying the same dedup
81
- // at capture time (store the prompt only when it changes, back-reference
82
- // otherwise) would make snapshots nearly as cheap as meta events and let this
83
- // cap reach thousands for full-session capture without the memory blowup.
84
- const DEFAULT_MAX_TURN_SNAPSHOTS = 400;
85
- const DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5;
86
- // Stale tools (advertised in an earlier state, retired now) and fold-hidden tools are
87
- // self-correcting — the model drops them once guided — so they get a higher loop-protection
88
- // ceiling than hallucinated names: a few legitimate stale calls across state transitions must
89
- // not prematurely end the turn. Still bounded so a genuinely stuck loop terminates.
90
- const MAX_STALE_TOOL_CALLS = DEFAULT_MAX_UNKNOWN_TOOL_CALLS * 2;
91
- // Gemini in particular emits short bursts of MALFORMED_FUNCTION_CALL; allow more CONSECUTIVE
92
- // retries. These counters reset on any productive response, so this is a consecutive-failure
93
- // ceiling, not a per-turn total.
94
- const MAX_MALFORMED_RETRIES = 5;
95
- const MAX_EMPTY_RESPONSE_RETRIES = 3;
96
- /**
97
- * Framed-tail nudge added to a retry after a MALFORMED tool call.
98
- *
99
- * Exported so tests can assert placement (framed tail, never the system prompt) without pinning
100
- * the wording — the copy is a prompt, and a prompt should be free to improve.
101
- *
102
- * @beta
103
- */
104
- export const MALFORMED_CALL_NUDGE = 'IMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools.';
105
- /**
106
- * Framed-tail nudge added to a retry after an EMPTY response.
107
- *
108
- * Rewritten in GENC-1573. The previous copy ("You must respond to the user's message. Call the
109
- * appropriate tool or provide a text response") told a model that had just returned nothing to
110
- * respond, without naming the one exit available to an agent that genuinely cannot act: saying so.
111
- * Across nine failures in the measured session it changed nothing — all 29 attempts came back at
112
- * 8–9 output tokens — and most of those were on a tool-less agent, for which "call the appropriate
113
- * tool" is not an option at all. This version names the honest answer explicitly.
114
- *
115
- * @beta
116
- */
117
- export const EMPTY_RESPONSE_NUDGE = "IMPORTANT: Your previous attempt returned nothing at all, which is never a valid response. Answer the user's last message now, in prose — even if all you can honestly say is that you cannot help with it and why. If a tool is the right way to answer, call it. Do not return an empty response.";
118
- // Transient throws while building the per-turn tool surface or calling the provider retry the
119
- // SAME iteration up to this many times before propagating, rather than tearing down the turn.
120
- const MAX_SETUP_TRANSPORT_RETRIES = 3;
121
- const SUGGESTIONS_HISTORY_WINDOW = 8;
122
- /**
123
- * Sub-agent meta events worth folding into the parent's debug timeline: the
124
- * per-attempt and per-failure signals that do NOT otherwise surface in the
125
- * sub-agent's (now hoisted) messages — a retried-away malformed/empty attempt
126
- * produces no message, and the stale-vs-hallucinated/streak diagnostics live only
127
- * on the event. High-volume, message-derivable events (turn.start/turn.end,
128
- * provider.selected, context.updated) are intentionally excluded: read the
129
- * sub-agent's hoisted messages for model/tokens/cost and turn-by-turn activity.
130
- * See `ChatDriver.invokeSubAgent`.
131
- */
132
- const HARVESTED_SUBAGENT_EVENTS = new Set([
133
- 'turn.retry',
134
- 'turn.error',
135
- 'tool.failed',
136
- 'tool.unresolved',
137
- ]);
138
- /** Name reserved for the cross-agent handoff tool — injected by OrchestratingDriver. */
139
- export const REQUEST_CONTINUATION_TOOL = 'request_continuation';
140
- /** Paired in history for each `request_continuation` so tool_calls stay balanced for the provider. */
141
- const HANDOFF_TOOL_RESULT_PLACEHOLDER = 'Handoff to another specialist — routing continues on the next turn.';
142
80
  export class ChatDriver extends EventTarget {
143
81
  constructor(providerRegistry, config = {}) {
144
82
  super();
@@ -201,10 +139,18 @@ export class ChatDriver extends EventTarget {
201
139
  * (`callPhaseEpoch < currentPhaseEpoch`), with an `agentEnd` backstop.
202
140
  */
203
141
  this.currentPhaseEpoch = 0;
204
- /** Stack of fold frames — grows when a fold opens, shrinks when it closes. */
205
- this.foldStack = [];
206
- /** Consecutive fold open/close ops without a real tool call. Reset on real tool execution. */
207
- this.consecutiveFoldOps = 0;
142
+ /** Tool folds: the open-fold stack, the no-progress counter, and the tool-surface swap. */
143
+ this.folds = new FoldController({
144
+ getDefinitions: () => this.toolDefinitions,
145
+ setDefinitions: (defs) => {
146
+ this.toolDefinitions = defs;
147
+ },
148
+ getHandlers: () => this.toolHandlers,
149
+ setHandlers: (handlers) => {
150
+ this.toolHandlers = handlers;
151
+ },
152
+ handlerContext: (activeToolCallId) => this.buildHandlerContext(activeToolCallId),
153
+ });
208
154
  /** Consecutive unknown-tool calls without a real tool call. Reset on real tool execution. */
209
155
  this.consecutiveUnknownToolCalls = 0;
210
156
  /**
@@ -304,6 +250,19 @@ export class ChatDriver extends EventTarget {
304
250
  * picked up on the next turn.
305
251
  */
306
252
  this.resolvedStatusCache = new Map();
253
+ /**
254
+ * The stateful-agent contract for this driver: active agent, user pin, flow lock, restored
255
+ * state, and the `onActivate` / `onDeactivate` hooks (§7.1).
256
+ *
257
+ * **Public because `OrchestratingDriver` reaches through to it.** The orchestrator decorates a
258
+ * config before installing it (the cross-agent continuation tool), so it cannot route through
259
+ * {@link ChatDriver.activateAgent} and must drive the session itself — and one shared session is
260
+ * the point: two would let `getActiveAgent()` disagree between the outer and inner driver.
261
+ * `@internal` rather than private so that reach is legal without six delegating one-liners.
262
+ *
263
+ * @internal
264
+ */
265
+ this.agentSession = new AgentSession({ sessionKey: () => this.sessionKey });
307
266
  /**
308
267
  * A provider refusal was observed this turn, so the tool loop must not call the model again
309
268
  * (GENC-1506).
@@ -330,10 +289,11 @@ export class ChatDriver extends EventTarget {
330
289
  * Reset per turn alongside `budgetWallDetail`.
331
290
  */
332
291
  this.budgetWallViaSubAgent = false;
333
- const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, condenseBatchCalls = 1, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, budgetExhaustedMessage, providerRefusedMessage, } = config;
292
+ const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, condenseBatchCalls = 1, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', deferAgentRelease = false, activityBus = NOOP_ACTIVITY_BUS, budgetExhaustedMessage, providerRefusedMessage, } = config;
334
293
  this.maxToolIterations = maxToolIterations;
335
294
  this.condenseBatchCalls = condenseBatchCalls;
336
295
  this.sessionKey = sessionKey;
296
+ this.deferAgentRelease = deferAgentRelease;
337
297
  this.activityBus = activityBus;
338
298
  this.budgetExhaustedMessageOverride = budgetExhaustedMessage;
339
299
  this.providerRefusedMessageOverride = providerRefusedMessage;
@@ -393,20 +353,32 @@ export class ChatDriver extends EventTarget {
393
353
  * timeout), which also stamps history and reverts the chat-input override.
394
354
  */
395
355
  dispose() {
396
- var _a;
397
- this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
398
- // Drop any live interaction contexts (GENC-1390). Normally empty at teardown
399
- // (a context is created and destroyed with its pending interaction, and dispose
400
- // is not reached mid-interaction); disposed defensively so a stray context can
401
- // never leak an open socket/timer.
402
- for (const handle of this.interactionContexts.values())
403
- handle.dispose();
404
- this.interactionContexts.clear();
405
- // Detach from the provider registry so the long-lived registry doesn't pin
406
- // this driver (and its closure) after teardown. Guard-cleared so a second
407
- // dispose is a no-op, matching this method's idempotent contract.
408
- (_a = this.unsubscribeRegistry) === null || _a === void 0 ? void 0 : _a.call(this);
409
- this.unsubscribeRegistry = undefined;
356
+ return __awaiter(this, void 0, void 0, function* () {
357
+ var _a;
358
+ // Everything up to the `await` below runs SYNCHRONOUSLY on the call, and that is the
359
+ // contract: callers rely on `dispose()` having detached listeners by the time it returns —
360
+ // `chat-driver.test.ts`'s sub-agent listener-leak case asserts it directly, and the host
361
+ // fires this and forgets (`main.ts`, `void old.dispose()`). Awaiting anything first opened a
362
+ // window in which a disposed driver was still subscribed to the provider registry.
363
+ this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
364
+ // Drop any live interaction contexts (GENC-1390). Normally empty at teardown
365
+ // (a context is created and destroyed with its pending interaction, and dispose
366
+ // is not reached mid-interaction); disposed defensively so a stray context can
367
+ // never leak an open socket/timer.
368
+ for (const handle of this.interactionContexts.values())
369
+ handle.dispose();
370
+ this.interactionContexts.clear();
371
+ // Detach from the provider registry so the long-lived registry doesn't pin
372
+ // this driver (and its closure) after teardown. Guard-cleared so a second
373
+ // dispose is a no-op, matching this method's idempotent contract.
374
+ (_a = this.unsubscribeRegistry) === null || _a === void 0 ? void 0 : _a.call(this);
375
+ this.unsubscribeRegistry = undefined;
376
+ // Lifecycle hooks last, and awaited so a caller can know they finished. Idempotent, so the
377
+ // orchestrated path — which fires them through the shared session BEFORE getting here, to
378
+ // keep the pre-GENC-1580 order where `onDeactivate` saw a live driver — costs a no-op.
379
+ // Returning a promise is within `AiDriver.dispose`'s declared `void | Promise<void>`.
380
+ yield this.agentSession.dispose();
381
+ });
410
382
  }
411
383
  /**
412
384
  * Stop the current turn (user "stop" button). Aborts the in-flight provider
@@ -458,21 +430,6 @@ export class ChatDriver extends EventTarget {
458
430
  }
459
431
  return { reason: 'done' };
460
432
  }
461
- /**
462
- * Build the `done` loop result, carrying the typed failure reason when the turn
463
- * bailed (PTC-0). The discriminant stays `'done'` either way — the same value a
464
- * clean turn returns — so consumers matching on `reason === 'done'` are unchanged;
465
- * `failureReason` is simply present on a failure and absent on success. Omitted
466
- * (rather than set to `undefined`) so a happy-path result stays byte-identical to
467
- * the historical `{ reason: 'done' }`.
468
- */
469
- turnDone(failureReason, budget, providerRefused) {
470
- if (!failureReason)
471
- return { reason: 'done' };
472
- // Both payloads are omitted rather than set to `undefined`, so a failure that carries neither has
473
- // a shape unchanged for a consumer that structurally compares it.
474
- return Object.assign(Object.assign({ reason: 'done', failureReason }, (budget ? { budget } : {})), (providerRefused ? { providerRefused } : {}));
475
- }
476
433
  /**
477
434
  * End the turn on a terminal failure: fail upward when this driver is a sub-agent, speak
478
435
  * to the user when it is not, and hand the reason to the caller either way.
@@ -500,7 +457,7 @@ export class ChatDriver extends EventTarget {
500
457
  else {
501
458
  this.appendDriverNotice((_a = overrides === null || overrides === void 0 ? void 0 : overrides.copy) !== null && _a !== void 0 ? _a : exit.copy);
502
459
  }
503
- return this.turnDone(exit.resultReason, overrides === null || overrides === void 0 ? void 0 : overrides.budget, overrides === null || overrides === void 0 ? void 0 : overrides.providerRefused);
460
+ return turnDone(exit.resultReason, overrides === null || overrides === void 0 ? void 0 : overrides.budget, overrides === null || overrides === void 0 ? void 0 : overrides.providerRefused);
504
461
  }
505
462
  /**
506
463
  * Terminal budget outcome for a wall hit **outside** the tool loop — today,
@@ -573,7 +530,7 @@ export class ChatDriver extends EventTarget {
573
530
  isSubAgent: this.isSubAgent,
574
531
  });
575
532
  this.appendDriverNotice(this.budgetExhaustedBubble(e));
576
- return this.turnDone('budget-exhausted', budgetDetailOf(e));
533
+ return turnDone('budget-exhausted', budgetDetailOf(e));
577
534
  }
578
535
  /**
579
536
  * The sentence shown when the upstream provider refuses (GENC-1506).
@@ -630,8 +587,68 @@ export class ChatDriver extends EventTarget {
630
587
  return Object.assign(Object.assign({ failureReason }, (vendor ? { vendor } : {})), (budget ? { budget } : {}));
631
588
  }
632
589
  /**
633
- * Swap in a new agent's configuration. Called by OrchestratingDriver before
634
- * each specialist turn so the shared driver runs with the right tools and prompt.
590
+ * Make `config` the active agent, firing its lifecycle hooks — `onDeactivate` on the outgoing
591
+ * agent, then `onActivate` on this one — before installing it.
592
+ *
593
+ * This is the entry point a host should use. {@link ChatDriver.applyAgent} installs a config
594
+ * *without* firing hooks, which is right for the two callers that need it (the orchestrator,
595
+ * which fires them through the shared session, and `invokeSubAgent`, where a child's config is
596
+ * not an activation) and wrong for everyone else.
597
+ *
598
+ * @beta
599
+ */
600
+ activateAgent(config) {
601
+ return __awaiter(this, void 0, void 0, function* () {
602
+ yield this.agentSession.activate(config);
603
+ this.applyAgent(config);
604
+ });
605
+ }
606
+ /**
607
+ * {@inheritDoc AiDriver.getActiveAgent}
608
+ *
609
+ * @beta
610
+ */
611
+ getActiveAgent() {
612
+ return this.agentSession.getActive();
613
+ }
614
+ /** {@inheritDoc AiDriver.primeRestoredAgentState} */
615
+ primeRestoredAgentState(snapshots) {
616
+ this.agentSession.primeRestoredStates(snapshots);
617
+ }
618
+ /**
619
+ * Record the sticky agent pin. See {@link AiDriver.setPinnedAgent} for the contract.
620
+ *
621
+ * Recorded, not acted on: this driver has one agent and nothing to route between, so a pin
622
+ * changes no behaviour here. It is still honoured as state, because the session outlives the
623
+ * question of which driver is holding it — a host that pins before an orchestrator is built
624
+ * would otherwise lose the pick.
625
+ *
626
+ * @beta
627
+ */
628
+ setPinnedAgent(name) {
629
+ this.agentSession.setPinned(name);
630
+ }
631
+ /**
632
+ * Record the transient flow lock. See {@link AiDriver.setFlowOwner} for the contract.
633
+ *
634
+ * Recorded, not acted on, for the same reason as {@link ChatDriver.setPinnedAgent}.
635
+ *
636
+ * @beta
637
+ */
638
+ setFlowOwner(name) {
639
+ this.agentSession.setFlowOwner(name);
640
+ }
641
+ /**
642
+ * Install `config` as the active agent **without firing lifecycle hooks**.
643
+ *
644
+ * Prefer {@link ChatDriver.activateAgent} unless you are deliberately skipping the hooks. Two
645
+ * callers are: `OrchestratingDriver`, which fires them through the shared
646
+ * {@link ChatDriver.agentSession} and then installs a decorated config, and `invokeSubAgent`,
647
+ * where the child driver's config is a tool surface rather than an activation.
648
+ *
649
+ * Renamed to `installAgent` in a follow-up (GENC-1580) — see `docs/migration-GENC-1580.md`.
650
+ * Until then the name says less than it should, so do not add a third caller to this path
651
+ * without checking which of the two above it resembles.
635
652
  */
636
653
  applyAgent(config) {
637
654
  var _a, _b, _c;
@@ -689,9 +706,10 @@ export class ChatDriver extends EventTarget {
689
706
  if (typeof config.provider === 'string') {
690
707
  this.resolveProviderByName(config.provider, config.name);
691
708
  }
692
- // Reset fold state when agent changes — each specialist starts fresh
693
- this.foldStack = [];
694
- this.consecutiveFoldOps = 0;
709
+ // Reset fold state when agent changes — each specialist starts fresh, and an unclosed fold
710
+ // must not survive into the next agent: its frame holds the PREVIOUS agent's tool surface,
711
+ // which `close()` would restore over the incoming one.
712
+ this.folds.reset();
695
713
  // Forget the previous agent's tools — "previously available" is scoped to
696
714
  // the current activation, so a stateful agent accumulates its tools across
697
715
  // states while a swap to a different specialist starts clean.
@@ -827,20 +845,6 @@ export class ChatDriver extends EventTarget {
827
845
  return status;
828
846
  });
829
847
  }
830
- /**
831
- * Resolve a per-turn config input that is either a static value or a function
832
- * of the turn context — the value-or-resolver shape shared by `provider`,
833
- * `temperature`, and `toolChoice`. Returns undefined when the input is unset.
834
- */
835
- resolveTurnInput(input, ctx) {
836
- return __awaiter(this, void 0, void 0, function* () {
837
- if (input === undefined)
838
- return undefined;
839
- return typeof input === 'function'
840
- ? input(ctx)
841
- : input;
842
- });
843
- }
844
848
  /**
845
849
  * Returns the early-stop result set by `completeSubAgent`, if any.
846
850
  * Called by a parent `ChatDriver` after running this instance as a sub-agent.
@@ -886,6 +890,22 @@ export class ChatDriver extends EventTarget {
886
890
  getAgentReleaseRequested() {
887
891
  return this.agentReleaseRequested;
888
892
  }
893
+ /**
894
+ * Act on a `releaseAgent` call from a tool handler: fire `onDeactivate` and drop the flow lock,
895
+ * so the next turn starts clean and the persister does not serialize a flow that is over.
896
+ *
897
+ * No-op when the owner handles release ({@link ChatDriverConfig.deferAgentRelease}). The flag is
898
+ * cleared here rather than only at the next `sendMessage`, so the signal cannot be acted on twice
899
+ * and release a later agent that never asked.
900
+ */
901
+ releaseIfRequested() {
902
+ return __awaiter(this, void 0, void 0, function* () {
903
+ if (this.deferAgentRelease || !this.agentReleaseRequested)
904
+ return;
905
+ this.agentReleaseRequested = false;
906
+ yield this.agentSession.release();
907
+ });
908
+ }
889
909
  /**
890
910
  * Return the per-turn snapshots captured so far. Used by the host's debug
891
911
  * log exporter to show what the LLM saw on each turn — system prompt, tool
@@ -1065,7 +1085,7 @@ export class ChatDriver extends EventTarget {
1065
1085
  }
1066
1086
  /** Returns the current fold stack names for debugging. */
1067
1087
  getActiveFoldNames() {
1068
- return this.foldStack.map((f) => f.foldName);
1088
+ return this.folds.activeNames();
1069
1089
  }
1070
1090
  /**
1071
1091
  * The transcript as it should be **stored**: every tool payload the model has already stopped
@@ -1087,106 +1107,18 @@ export class ChatDriver extends EventTarget {
1087
1107
  getHistoryForPersistence() {
1088
1108
  return condenseForPersistence([...this.history], this.condensePolicies);
1089
1109
  }
1110
+ /**
1111
+ * {@inheritDoc AiDriver.getSuggestions}
1112
+ *
1113
+ * @beta
1114
+ */
1090
1115
  getSuggestions(history, prompt, count, allAgentInfo) {
1091
1116
  return __awaiter(this, void 0, void 0, function* () {
1092
- // Suggestions are an out-of-turn UI helper, not bound to any single agent —
1093
- // always run against the registry default. Best-effort: a default with no
1094
- // `prompt()` just means no suggestions, not a hard error.
1095
- const defaultProvider = this.providerRegistry.default();
1096
- if (!defaultProvider.prompt) {
1097
- logger.warn('ChatDriver: default AI provider does not implement prompt()');
1098
- return [];
1099
- }
1100
- let agentContext = '';
1101
- let toolContext = '';
1102
- if (allAgentInfo === null || allAgentInfo === void 0 ? void 0 : allAgentInfo.length) {
1103
- const agentDescriptions = allAgentInfo
1104
- .map((agent) => {
1105
- const tools = agent.tools.map((t) => t.name).join(', ');
1106
- return `- ${agent.name} (${agent.description}): ${tools ? `(Tools: ${tools})` : 'No tools'}`;
1107
- })
1108
- .join('\n');
1109
- agentContext = `The assistant has the following capabilities:\n${agentDescriptions}`;
1110
- const allToolNames = allAgentInfo
1111
- .flatMap((agent) => agent.tools.map((t) => t.name))
1112
- .filter((value, index, self) => self.indexOf(value) === index)
1113
- .join(', ');
1114
- toolContext = allToolNames
1115
- ? `You have access to the following tools across all agents: ${allToolNames}.`
1116
- : '';
1117
- }
1118
- else if (this.activeAgentName) {
1119
- const toolNames = this.toolDefinitions.map((tool) => tool.name).join(', ');
1120
- agentContext = `You are currently acting as the "${this.activeAgentName}" agent.`;
1121
- toolContext = toolNames ? `You have access to the following tools: ${toolNames}.` : '';
1122
- }
1123
- // System prompt is intentionally short: it sets the role only. Concrete
1124
- // instructions live in the user message *after* the transcript so the
1125
- // immediately-preceding context for the model is "here is data, now do X"
1126
- // rather than "here is a dialogue, continue it". This matters for Anthropic
1127
- // — without it, Claude reads the transcript as an in-progress conversation
1128
- // and produces a normal assistant turn instead of a list of suggestions.
1129
- const systemPrompt = 'You generate suggested prompts that a user could send to an AI assistant. ' +
1130
- 'You never continue the conversation or roleplay as the assistant.';
1131
- const capabilitiesBlock = agentContext || toolContext
1132
- ? `\n\n<capabilities>\n${[agentContext, toolContext].filter(Boolean).join('\n')}\n</capabilities>\nSuggestions must only cover what the agent is capable of. Do not suggest anything outside these capabilities.`
1133
- : '';
1134
- const guidanceBlock = prompt ? `\n\nAdditional guidance: "${prompt}"` : '';
1135
- let userMessage;
1136
- if (history.length === 0) {
1137
- userMessage =
1138
- `Generate exactly ${count} brief starter prompts to show a user what an AI assistant can do. ` +
1139
- `Phrase each one as the user would write it. Keep them short and generic — do not invent specific names, IDs, or data (e.g. prefer "Search for a trade" over "Find all trades with Client A").` +
1140
- capabilitiesBlock +
1141
- guidanceBlock +
1142
- `\n\nOutput format (strict):\n` +
1143
- `- Exactly ${count} lines.\n` +
1144
- `- One suggestion per line.\n` +
1145
- `- No numbering, bullets, markdown, emojis, code blocks, or quotes around the suggestion.\n` +
1146
- `- No preamble, headings, summary, or commentary before or after the list.`;
1147
- }
1148
- else {
1149
- const conversationContext = history
1150
- .filter((m) => {
1151
- var _a, _b;
1152
- return (m.role === 'user' || m.role === 'assistant') &&
1153
- !((_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length) &&
1154
- !m.thinking &&
1155
- !!((_b = m.content) === null || _b === void 0 ? void 0 : _b.trim());
1156
- })
1157
- .slice(-SUGGESTIONS_HISTORY_WINDOW)
1158
- .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
1159
- .join('\n');
1160
- userMessage =
1161
- `<conversation_history>\n${conversationContext}\n</conversation_history>\n\n` +
1162
- `The conversation above has ended. Do not continue it or respond as the assistant. ` +
1163
- `Generate exactly ${count} follow-up prompts the *user* might send next, phrased in first person as the user would write them. ` +
1164
- `The first ${Math.max(0, count - 1)} should be natural follow-ups to the last turn. ` +
1165
- `Do not invent specific names, IDs, or data values that do not appear in <conversation_history>.` +
1166
- capabilitiesBlock +
1167
- guidanceBlock +
1168
- `\n\nOutput format (strict):\n` +
1169
- `- Exactly ${count} lines.\n` +
1170
- `- One suggestion per line.\n` +
1171
- `- No numbering, bullets, markdown, emojis, code blocks, or quotes around the suggestion.\n` +
1172
- `- No preamble, headings, summary, or commentary before or after the list.`;
1173
- }
1174
- const text = yield defaultProvider.prompt(userMessage, { systemPrompt });
1175
- // Lenient parsing as a defensive backstop: even with the strict prompt,
1176
- // models occasionally slip in numbering, bullets, or surrounding markdown.
1177
- return (text
1178
- .split('\n')
1179
- .map((s) => s.trim())
1180
- // strip leading bullets/numbers ("- ", "* ", "• ", "1. ", "1) ")
1181
- .map((s) => s.replace(/^([-*•]|\d+[.)])\s+/, ''))
1182
- // strip surrounding bold/italic markers
1183
- .map((s) => s.replace(/^[*_]{1,3}|[*_]{1,3}$/g, '').trim())
1184
- // drop empty lines, separators, and obvious preamble lines
1185
- .filter((s) => s.length > 0)
1186
- .filter((s) => !/^[-=]{2,}$/.test(s))
1187
- .filter((s) => !/^here (are|is) /i.test(s))
1188
- .filter((s) => !s.endsWith(':'))
1189
- .slice(0, count));
1117
+ return generateSuggestions({
1118
+ registry: this.providerRegistry,
1119
+ activeAgentName: this.activeAgentName,
1120
+ toolDefinitions: this.toolDefinitions,
1121
+ }, history, prompt, count, allAgentInfo);
1190
1122
  });
1191
1123
  }
1192
1124
  /**
@@ -1668,7 +1600,7 @@ export class ChatDriver extends EventTarget {
1668
1600
  message: e instanceof Error ? e.message : String(e),
1669
1601
  });
1670
1602
  this.appendDriverNotice('Sorry, something went wrong on my end. Please try again in a moment.');
1671
- result = this.turnDone('exception');
1603
+ result = turnDone('exception');
1672
1604
  return result;
1673
1605
  }
1674
1606
  finally {
@@ -1680,6 +1612,9 @@ export class ChatDriver extends EventTarget {
1680
1612
  this.busy = false;
1681
1613
  this.endTurn();
1682
1614
  this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
1615
+ // In `finally` rather than on the success path: the agent asked to be released, and a turn
1616
+ // that later threw does not make its flow any less finished.
1617
+ yield this.releaseIfRequested();
1683
1618
  }
1684
1619
  });
1685
1620
  }
@@ -2046,7 +1981,7 @@ export class ChatDriver extends EventTarget {
2046
1981
  message: e instanceof Error ? e.message : String(e),
2047
1982
  });
2048
1983
  this.appendDriverNotice('Sorry, something went wrong on my end. Please try again in a moment.');
2049
- result = this.turnDone('exception');
1984
+ result = turnDone('exception');
2050
1985
  return result;
2051
1986
  }
2052
1987
  finally {
@@ -2058,153 +1993,13 @@ export class ChatDriver extends EventTarget {
2058
1993
  this.busy = false;
2059
1994
  this.endTurn();
2060
1995
  this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
1996
+ // In `finally` rather than on the success path: the agent asked to be released, and a turn
1997
+ // that later threw does not make its flow any less finished.
1998
+ yield this.releaseIfRequested();
2061
1999
  }
2062
2000
  });
2063
2001
  }
2064
2002
  // ---------------------------------------------------------------------------
2065
- // Fold mechanics
2066
- // ---------------------------------------------------------------------------
2067
- /** Extract ToolFold metadata from a handler, or undefined if it isn't a fold facade. */
2068
- getFold(toolName) {
2069
- const handler = this.toolHandlers[toolName];
2070
- return handler ? handler[TOOL_FOLD_SYMBOL] : undefined;
2071
- }
2072
- /**
2073
- * Search all currently registered fold facades (and their nested folds recursively)
2074
- * to find which fold contains a given tool name. Returns the immediate parent fold name.
2075
- */
2076
- findFoldContaining(toolName, handlers) {
2077
- const source = handlers !== null && handlers !== void 0 ? handlers : this.toolHandlers;
2078
- for (const [, handler] of Object.entries(source)) {
2079
- const fold = handler[TOOL_FOLD_SYMBOL];
2080
- if (!fold)
2081
- continue;
2082
- // Direct inner tool match
2083
- if (fold.handlers[toolName])
2084
- return fold.name;
2085
- // Recurse into nested folds
2086
- const nested = this.findFoldContaining(toolName, fold.handlers);
2087
- if (nested)
2088
- return fold.name;
2089
- }
2090
- return null;
2091
- }
2092
- /**
2093
- * If an open fold is hiding a previously-available tool, return the name of
2094
- * the fold to close to start getting it back. Only exclusive folds hide tools
2095
- * (they replace the tool set on open rather than extending it), so a base tool
2096
- * that was visible before the fold opened now sits in a fold-stack frame's
2097
- * `previousHandlers` but not in the live handler map. Only the top fold's
2098
- * `close_` tool is active, so that's always the actionable next step — even
2099
- * when the tool lives further down the stack, closing repeatedly walks back to
2100
- * it. Returns null when no open fold accounts for the tool.
2101
- */
2102
- foldHidingTool(toolName) {
2103
- if (this.foldStack.length === 0)
2104
- return null;
2105
- const hidden = this.foldStack.some((f) => f.previousHandlers[toolName]);
2106
- return hidden ? this.foldStack[this.foldStack.length - 1].foldName : null;
2107
- }
2108
- /**
2109
- * Install the fold's inner tool set, replacing (exclusive) or extending (non-exclusive)
2110
- * the current tool set. Also injects the close tool. Does NOT touch the fold stack.
2111
- */
2112
- applyFoldToolSet(fold, foldName) {
2113
- const closeToolName = `close_${foldName}`;
2114
- const newDefs = [];
2115
- const newHandlers = {};
2116
- if (!fold.exclusive) {
2117
- // Non-exclusive: keep existing tools minus the facade we just opened
2118
- for (const def of this.toolDefinitions) {
2119
- if (def.name !== foldName)
2120
- newDefs.push(def);
2121
- }
2122
- for (const [name, handler] of Object.entries(this.toolHandlers)) {
2123
- if (name !== foldName)
2124
- newHandlers[name] = handler;
2125
- }
2126
- }
2127
- // Install inner tools from fold metadata
2128
- newDefs.push(...fold.tools);
2129
- Object.assign(newHandlers, fold.handlers);
2130
- // Inject the close tool
2131
- newDefs.push({
2132
- name: closeToolName,
2133
- description: `Close the ${foldName} fold and return to the previous set of tools.`,
2134
- parameters: { type: 'object', properties: {} },
2135
- });
2136
- newHandlers[closeToolName] = () => __awaiter(this, void 0, void 0, function* () { return this.closeFold(); });
2137
- this.toolDefinitions = newDefs;
2138
- this.toolHandlers = newHandlers;
2139
- }
2140
- /** Open a fold: push a stack frame, swap the tool set, return the response message. */
2141
- openFold(foldName, fold, args, activeToolCallId) {
2142
- // Shortcut dispatch: model passed inner tool args directly, e.g.
2143
- // trading_tools({ search_trades: { side: "BUY" } })
2144
- for (const key of Object.keys(args)) {
2145
- const innerHandler = fold.handlers[key];
2146
- if (innerHandler) {
2147
- logger.debug(`ChatDriver: fold shortcut dispatch "${foldName}" → "${key}"`);
2148
- // Open the fold first so the tool set is correct for subsequent calls
2149
- this.pushFoldFrame(foldName);
2150
- this.applyFoldToolSet(fold, foldName);
2151
- this.consecutiveFoldOps = 0; // shortcut dispatch counts as real work
2152
- const innerArgs = typeof args[key] === 'object' && args[key] !== null
2153
- ? args[key]
2154
- : {};
2155
- return innerHandler(innerArgs, this.buildHandlerContext(activeToolCallId)).then((r) => typeof r === 'string' ? r : JSON.stringify(r));
2156
- }
2157
- }
2158
- // Normal two-step open
2159
- this.pushFoldFrame(foldName);
2160
- this.applyFoldToolSet(fold, foldName);
2161
- const innerToolNames = fold.tools.map((t) => t.name);
2162
- const closeToolName = `close_${foldName}`;
2163
- let message = `Fold opened: ${foldName}. Tools now available: ${[...innerToolNames, closeToolName].join(', ')}.`;
2164
- if (fold.usageNotes)
2165
- message += ` Notes: ${fold.usageNotes}`;
2166
- message += ` Call ${closeToolName} when done to return to the previous tools.`;
2167
- return Promise.resolve(message);
2168
- }
2169
- pushFoldFrame(foldName) {
2170
- this.foldStack.push({
2171
- foldName,
2172
- previousDefinitions: [...this.toolDefinitions],
2173
- previousHandlers: Object.assign({}, this.toolHandlers),
2174
- });
2175
- }
2176
- /** Close the top fold: pop the stack frame, restore the previous tool set. */
2177
- closeFold() {
2178
- const frame = this.foldStack.pop();
2179
- if (!frame)
2180
- return 'No fold is currently open.';
2181
- this.toolDefinitions = frame.previousDefinitions;
2182
- this.toolHandlers = frame.previousHandlers;
2183
- const toolNames = this.toolDefinitions.map((t) => t.name);
2184
- return `Fold closed: ${frame.foldName}. Tools now available: ${toolNames.join(', ')}.`;
2185
- }
2186
- /** Build the fold-awareness suffix appended to the system prompt each LLM call. */
2187
- buildFoldSystemPromptSuffix() {
2188
- // Collect fold facades from the current handler map
2189
- const activeFolds = [];
2190
- for (const handler of Object.values(this.toolHandlers)) {
2191
- const fold = handler[TOOL_FOLD_SYMBOL];
2192
- if (fold)
2193
- activeFolds.push(fold);
2194
- }
2195
- if (activeFolds.length === 0 && this.foldStack.length === 0)
2196
- return '';
2197
- const parts = ['\n\n--- Tool Folds ---'];
2198
- parts.push('Some tools are grouped into folds. You may see tool calls in the conversation history for tools that are not currently available — they are inside a fold. To access them, invoke the fold tool first.');
2199
- if (this.foldStack.length > 0) {
2200
- const current = this.foldStack[this.foldStack.length - 1];
2201
- parts.push(`You are currently inside the "${current.foldName}" fold. Call close_${current.foldName} when you are done with these tools.`);
2202
- }
2203
- for (const fold of activeFolds) {
2204
- parts.push(`• ${fold.name}: ${fold.description}`);
2205
- }
2206
- return parts.join('\n');
2207
- }
2208
2003
  // ---------------------------------------------------------------------------
2209
2004
  // Tool loop
2210
2005
  // ---------------------------------------------------------------------------
@@ -2375,7 +2170,7 @@ export class ChatDriver extends EventTarget {
2375
2170
  // retry nudge — that would otherwise destabilize a cached system prompt. They are routed to
2376
2171
  // the framed tail (below), never appended to the system string, so the system prompt stays
2377
2172
  // byte-stable for every agent.
2378
- const foldSuffix = this.buildFoldSystemPromptSuffix();
2173
+ const foldSuffix = this.folds.systemPromptSuffix();
2379
2174
  const retryNudge = malformedAttempts > 0
2380
2175
  ? `\n\n${MALFORMED_CALL_NUDGE}`
2381
2176
  : emptyResponseAttempts > 0
@@ -2423,16 +2218,16 @@ export class ChatDriver extends EventTarget {
2423
2218
  const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext, resolvedResponseSchema, firstResolvedThinkingPolicy,] =
2424
2219
  // oxlint-disable-next-line no-await-in-loop
2425
2220
  yield Promise.all([
2426
- this.resolveTurnInput(this.activeTemperatureInput, promptCtx),
2427
- this.resolveTurnInput(this.activeToolChoiceInput, promptCtx),
2428
- this.resolveTurnInput(this.activeCachePolicyInput, promptCtx),
2429
- this.resolveTurnInput(this.activeTailContextInput, promptCtx),
2430
- this.resolveTurnInput(this.activeResponseSchemaInput, promptCtx),
2221
+ resolveTurnInput(this.activeTemperatureInput, promptCtx),
2222
+ resolveTurnInput(this.activeToolChoiceInput, promptCtx),
2223
+ resolveTurnInput(this.activeCachePolicyInput, promptCtx),
2224
+ resolveTurnInput(this.activeTailContextInput, promptCtx),
2225
+ resolveTurnInput(this.activeResponseSchemaInput, promptCtx),
2431
2226
  // Only consulted on the first iteration (see `pinnedThinkingPolicy`); resolved
2432
2227
  // alongside the others so a resolver still sees the same turn context.
2433
2228
  thinkingPolicyPinned
2434
2229
  ? Promise.resolve(undefined)
2435
- : this.resolveTurnInput(this.activeThinkingPolicyInput, promptCtx),
2230
+ : resolveTurnInput(this.activeThinkingPolicyInput, promptCtx),
2436
2231
  ]);
2437
2232
  if (!thinkingPolicyPinned) {
2438
2233
  pinnedThinkingPolicy = firstResolvedThinkingPolicy;
@@ -2696,7 +2491,7 @@ export class ChatDriver extends EventTarget {
2696
2491
  // user, carrying the attribution so the parent walls the vendor that
2697
2492
  // actually refused rather than its own.
2698
2493
  this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2699
- return this.turnDone('budget-exhausted', this.budgetWallDetail);
2494
+ return turnDone('budget-exhausted', this.budgetWallDetail);
2700
2495
  }
2701
2496
  return this.reportBudgetExhausted(e);
2702
2497
  }
@@ -2876,36 +2671,36 @@ export class ChatDriver extends EventTarget {
2876
2671
  // oxlint-disable-next-line no-await-in-loop -- Promise.all is correct parallelism, not sequential loop awaiting
2877
2672
  yield Promise.all(toolCalls.map((tc) => __awaiter(this, void 0, void 0, function* () {
2878
2673
  // Check for fold facade
2879
- const fold = this.getFold(tc.name);
2674
+ const fold = this.folds.foldFor(tc.name);
2880
2675
  if (fold) {
2881
- this.consecutiveFoldOps += 1;
2882
- if (this.consecutiveFoldOps > this.maxFoldOperations) {
2676
+ const foldOps = this.folds.noteOp();
2677
+ if (foldOps > this.maxFoldOperations) {
2883
2678
  logger.warn(`ChatDriver: fold operation limit (${this.maxFoldOperations}) reached — injecting guidance`);
2884
2679
  executedById.set(tc.id, {
2885
2680
  toolCallId: tc.id,
2886
- content: `You have opened and closed folds ${this.consecutiveFoldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
2681
+ content: `You have opened and closed folds ${foldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
2887
2682
  });
2888
2683
  return;
2889
2684
  }
2890
- const content = yield this.openFold(tc.name, fold, tc.args, tc.id);
2685
+ const content = yield this.folds.open(tc.name, fold, tc.args, tc.id);
2891
2686
  executedById.set(tc.id, { toolCallId: tc.id, content });
2892
2687
  // Fold open/close does NOT count as a real iteration — decrement to compensate
2893
2688
  iterations -= 1;
2894
2689
  return;
2895
2690
  }
2896
2691
  // Check for close-fold tool
2897
- if (tc.name.startsWith('close_') && this.foldStack.length > 0) {
2898
- const topFoldName = this.foldStack[this.foldStack.length - 1].foldName;
2692
+ if (tc.name.startsWith('close_') && this.folds.depth > 0) {
2693
+ const topFoldName = this.folds.topName;
2899
2694
  if (tc.name === `close_${topFoldName}`) {
2900
- this.consecutiveFoldOps += 1;
2901
- if (this.consecutiveFoldOps > this.maxFoldOperations) {
2695
+ const foldOps = this.folds.noteOp();
2696
+ if (foldOps > this.maxFoldOperations) {
2902
2697
  executedById.set(tc.id, {
2903
2698
  toolCallId: tc.id,
2904
- content: `You have opened and closed folds ${this.consecutiveFoldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
2699
+ content: `You have opened and closed folds ${foldOps} times without calling any tools. Please call a specific tool to make progress, or respond to the user.`,
2905
2700
  });
2906
2701
  return;
2907
2702
  }
2908
- const content = this.closeFold();
2703
+ const content = this.folds.close();
2909
2704
  executedById.set(tc.id, { toolCallId: tc.id, content });
2910
2705
  iterations -= 1;
2911
2706
  return;
@@ -2914,7 +2709,7 @@ export class ChatDriver extends EventTarget {
2914
2709
  // Regular tool — check if it's inside a fold and guide the model
2915
2710
  const handler = this.toolHandlers[tc.name];
2916
2711
  if (!handler) {
2917
- const containingFold = this.findFoldContaining(tc.name);
2712
+ const containingFold = this.folds.findContaining(tc.name);
2918
2713
  if (containingFold) {
2919
2714
  logger.debug(`ChatDriver: model called folded tool "${tc.name}" — guiding to open "${containingFold}"`);
2920
2715
  recordMetaEvent(this.sessionKey, 'tool.unresolved', {
@@ -2941,7 +2736,7 @@ export class ChatDriver extends EventTarget {
2941
2736
  // guidance, telemetry, and limit differ.
2942
2737
  if (this.everSeenToolNames.has(tc.name)) {
2943
2738
  this.consecutiveUnknownToolCalls += 1;
2944
- const hidingFold = this.foldHidingTool(tc.name);
2739
+ const hidingFold = this.folds.hidingTool(tc.name);
2945
2740
  let content;
2946
2741
  if (hidingFold) {
2947
2742
  content = `"${tc.name}" is not available while the "${hidingFold}" fold is open. Call close_${hidingFold} to return to the previous set of tools, then call ${tc.name}.`;
@@ -3050,13 +2845,13 @@ export class ChatDriver extends EventTarget {
3050
2845
  }
3051
2846
  // Reset counters whenever a real tool executes
3052
2847
  if (anyRealToolExecuted) {
3053
- this.consecutiveFoldOps = 0;
2848
+ this.folds.clearOps();
3054
2849
  this.consecutiveUnknownToolCalls = 0;
3055
2850
  this.recentUnknownToolNames.clear();
3056
2851
  this.recentStaleToolNames.clear();
3057
2852
  }
3058
2853
  // Tag tool calls with fold UI metadata before appending results
3059
- const foldPath = this.foldStack.map((f) => f.foldName);
2854
+ const foldPath = this.folds.activeNames();
3060
2855
  for (const tc of response.toolCalls) {
3061
2856
  if (tc.name === REQUEST_CONTINUATION_TOOL) {
3062
2857
  this.appendToHistory({
@@ -3095,7 +2890,7 @@ export class ChatDriver extends EventTarget {
3095
2890
  const availableToolNames = Object.keys(this.toolHandlers);
3096
2891
  const annotatedCalls = tcMsg.toolCalls.map((tc) => {
3097
2892
  var _a, _b, _c, _d, _e;
3098
- const isFoldOpen = !!this.getFold(tc.name) ||
2893
+ const isFoldOpen = !!this.folds.foldFor(tc.name) ||
3099
2894
  (
3100
2895
  // Was a fold facade at time of the call (now the tool set has changed)
3101
2896
  // — detect by checking if the result message indicated a fold open
@@ -3160,7 +2955,7 @@ export class ChatDriver extends EventTarget {
3160
2955
  // Sub-agent early exit — checked here so the exit point mirrors the
3161
2956
  // system-call pattern above. Set by completeSubAgent() in a tool handler.
3162
2957
  if (this.subAgentCompletion) {
3163
- return this.turnDone();
2958
+ return turnDone();
3164
2959
  }
3165
2960
  }
3166
2961
  // The loop fell through: either it hit the iteration cap (a failure) or it
@@ -3176,7 +2971,7 @@ export class ChatDriver extends EventTarget {
3176
2971
  });
3177
2972
  return this.endTurnWithFailure(TURN_FAILURE_EXITS.maxIterations);
3178
2973
  }
3179
- return this.turnDone();
2974
+ return turnDone();
3180
2975
  });
3181
2976
  }
3182
2977
  /**