@animalabs/agent-framework 0.30.0 → 0.31.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 (179) hide show
  1. package/README.md +71 -8
  2. package/dist/src/agent.d.ts +29 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +82 -17
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +10 -3
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  10. package/dist/src/code-execution/isolate/render.js +7 -6
  11. package/dist/src/code-execution/isolate/render.js.map +1 -1
  12. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  13. package/dist/src/code-execution/isolate/scripts.js +5 -4
  14. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  15. package/dist/src/framework.d.ts +129 -92
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1115 -704
  18. package/dist/src/framework.js.map +1 -1
  19. package/dist/src/gate/event-gate.d.ts +24 -17
  20. package/dist/src/gate/event-gate.d.ts.map +1 -1
  21. package/dist/src/gate/event-gate.js +198 -144
  22. package/dist/src/gate/event-gate.js.map +1 -1
  23. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  24. package/dist/src/gate/recipe-reconcile.js +6 -5
  25. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  26. package/dist/src/host-presentation.d.ts.map +1 -1
  27. package/dist/src/host-presentation.js +5 -7
  28. package/dist/src/host-presentation.js.map +1 -1
  29. package/dist/src/index.d.ts +1 -0
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +1 -0
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/inference-bounds.d.ts +37 -0
  34. package/dist/src/inference-bounds.d.ts.map +1 -0
  35. package/dist/src/inference-bounds.js +84 -0
  36. package/dist/src/inference-bounds.js.map +1 -0
  37. package/dist/src/line-reader.d.ts +7 -1
  38. package/dist/src/line-reader.d.ts.map +1 -1
  39. package/dist/src/line-reader.js +17 -6
  40. package/dist/src/line-reader.js.map +1 -1
  41. package/dist/src/mcpl/errors.d.ts +1 -0
  42. package/dist/src/mcpl/errors.d.ts.map +1 -1
  43. package/dist/src/mcpl/errors.js +3 -0
  44. package/dist/src/mcpl/errors.js.map +1 -1
  45. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  46. package/dist/src/mcpl/inbound-source.js +2 -2
  47. package/dist/src/mcpl/inference-router.d.ts +11 -1
  48. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  49. package/dist/src/mcpl/inference-router.js +65 -9
  50. package/dist/src/mcpl/inference-router.js.map +1 -1
  51. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  52. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/modern-connection.js +85 -74
  54. package/dist/src/mcpl/modern-connection.js.map +1 -1
  55. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  57. package/dist/src/mcpl/protocol-family.js +45 -3
  58. package/dist/src/mcpl/protocol-family.js.map +1 -1
  59. package/dist/src/mcpl/push-coalescer.d.ts +2 -7
  60. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  62. package/dist/src/mcpl/references.d.ts +1 -2
  63. package/dist/src/mcpl/references.d.ts.map +1 -1
  64. package/dist/src/mcpl/references.js +3 -3
  65. package/dist/src/mcpl/references.js.map +1 -1
  66. package/dist/src/mcpl/server-connection.d.ts +14 -0
  67. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  68. package/dist/src/mcpl/server-connection.js +26 -22
  69. package/dist/src/mcpl/server-connection.js.map +1 -1
  70. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  71. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  72. package/dist/src/mcpl/tool-signature.js +16 -11
  73. package/dist/src/mcpl/tool-signature.js.map +1 -1
  74. package/dist/src/mcpl/transport.d.ts +44 -21
  75. package/dist/src/mcpl/transport.d.ts.map +1 -1
  76. package/dist/src/mcpl/transport.js +283 -64
  77. package/dist/src/mcpl/transport.js.map +1 -1
  78. package/dist/src/mcpl/types.d.ts +17 -1
  79. package/dist/src/mcpl/types.d.ts.map +1 -1
  80. package/dist/src/mcpl/types.js.map +1 -1
  81. package/dist/src/module-registry.d.ts +32 -4
  82. package/dist/src/module-registry.d.ts.map +1 -1
  83. package/dist/src/module-registry.js +189 -25
  84. package/dist/src/module-registry.js.map +1 -1
  85. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  86. package/dist/src/modules/discord/discord-js-client.js +24 -16
  87. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  88. package/dist/src/modules/sender-name.d.ts +5 -4
  89. package/dist/src/modules/sender-name.d.ts.map +1 -1
  90. package/dist/src/modules/sender-name.js +7 -5
  91. package/dist/src/modules/sender-name.js.map +1 -1
  92. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  93. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  95. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  96. package/dist/src/modules/workspace/index.d.ts +6 -3
  97. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/index.js +61 -15
  99. package/dist/src/modules/workspace/index.js.map +1 -1
  100. package/dist/src/modules/workspace/observe.d.ts +9 -1
  101. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  102. package/dist/src/modules/workspace/observe.js +50 -10
  103. package/dist/src/modules/workspace/observe.js.map +1 -1
  104. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  105. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/reconcile.js +19 -8
  107. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +8 -0
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  112. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  113. package/dist/src/modules/workspace/watcher.js +7 -5
  114. package/dist/src/modules/workspace/watcher.js.map +1 -1
  115. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  116. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  117. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  118. package/dist/src/supplied-text.d.ts +2 -0
  119. package/dist/src/supplied-text.d.ts.map +1 -1
  120. package/dist/src/supplied-text.js +16 -7
  121. package/dist/src/supplied-text.js.map +1 -1
  122. package/dist/src/tool-result-guard.js +1 -1
  123. package/dist/src/tool-result-guard.js.map +1 -1
  124. package/dist/src/tune-out/coordinator.d.ts +2 -20
  125. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  126. package/dist/src/tune-out/coordinator.js +175 -70
  127. package/dist/src/tune-out/coordinator.js.map +1 -1
  128. package/dist/src/tune-out/tools.d.ts +14 -2
  129. package/dist/src/tune-out/tools.d.ts.map +1 -1
  130. package/dist/src/tune-out/tools.js.map +1 -1
  131. package/dist/src/types/agent.d.ts +2 -1
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +54 -15
  134. package/dist/src/types/framework.d.ts.map +1 -1
  135. package/dist/src/types/module.d.ts +42 -3
  136. package/dist/src/types/module.d.ts.map +1 -1
  137. package/dist/src/types/trace.d.ts +4 -3
  138. package/dist/src/types/trace.d.ts.map +1 -1
  139. package/package.json +3 -3
  140. package/src/agent.ts +90 -25
  141. package/src/api/mcp-server-core.ts +10 -3
  142. package/src/code-execution/isolate/render.ts +7 -6
  143. package/src/code-execution/isolate/scripts.ts +5 -4
  144. package/src/framework.ts +1145 -723
  145. package/src/gate/event-gate.ts +220 -145
  146. package/src/gate/recipe-reconcile.ts +7 -5
  147. package/src/host-presentation.ts +4 -7
  148. package/src/index.ts +1 -0
  149. package/src/inference-bounds.ts +111 -0
  150. package/src/line-reader.ts +22 -5
  151. package/src/mcpl/errors.ts +4 -0
  152. package/src/mcpl/inbound-source.ts +2 -2
  153. package/src/mcpl/inference-router.ts +67 -10
  154. package/src/mcpl/modern-connection.ts +83 -68
  155. package/src/mcpl/protocol-family.ts +37 -4
  156. package/src/mcpl/push-coalescer.ts +2 -7
  157. package/src/mcpl/references.ts +3 -4
  158. package/src/mcpl/server-connection.ts +30 -21
  159. package/src/mcpl/tool-signature.ts +18 -11
  160. package/src/mcpl/transport.ts +261 -60
  161. package/src/mcpl/types.ts +18 -1
  162. package/src/module-registry.ts +178 -25
  163. package/src/modules/discord/discord-js-client.ts +33 -29
  164. package/src/modules/sender-name.ts +8 -5
  165. package/src/modules/workspace/disk-agreement.ts +39 -15
  166. package/src/modules/workspace/index.ts +65 -14
  167. package/src/modules/workspace/observe.ts +51 -10
  168. package/src/modules/workspace/reconcile.ts +22 -8
  169. package/src/modules/workspace/types.ts +5 -0
  170. package/src/modules/workspace/watcher.ts +8 -6
  171. package/src/recovery/discord-awareness-outbox.ts +1 -11
  172. package/src/supplied-text.ts +17 -6
  173. package/src/tool-result-guard.ts +1 -1
  174. package/src/tune-out/coordinator.ts +187 -85
  175. package/src/tune-out/tools.ts +14 -2
  176. package/src/types/agent.ts +2 -1
  177. package/src/types/framework.ts +55 -15
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
@@ -98,6 +98,69 @@ function writeIdOf(m: Pick<StoredMessage, 'metadata'>): string | undefined {
98
98
  return typeof writeId === 'string' ? writeId : undefined;
99
99
  }
100
100
 
101
+ type HeldRow = Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & { id?: string; writeId?: string };
102
+
103
+ interface RowKeys {
104
+ ids: string[];
105
+ writeIds: string[];
106
+ }
107
+
108
+ interface ReleaseRecord extends RowKeys {
109
+ total: number;
110
+ truncated: number;
111
+ report: RowKeys;
112
+ }
113
+
114
+ function keysOf(rows: readonly HeldRow[]): RowKeys {
115
+ const keys: RowKeys = { ids: [], writeIds: [] };
116
+ for (const m of rows) {
117
+ const writeId = m.writeId ?? writeIdOf(m);
118
+ if (writeId !== undefined) keys.writeIds.push(writeId);
119
+ else if (m.id !== undefined) keys.ids.push(m.id);
120
+ }
121
+ return keys;
122
+ }
123
+
124
+ function keyedBy(keys: readonly RowKeys[]): (m: HeldRow) => boolean {
125
+ const ids = new Set(keys.flatMap((k) => k.ids));
126
+ const writeIds = new Set(keys.flatMap((k) => k.writeIds));
127
+ return (m) => {
128
+ const writeId = m.writeId ?? writeIdOf(m);
129
+ return writeId !== undefined ? writeIds.has(writeId) : m.id !== undefined && ids.has(m.id);
130
+ };
131
+ }
132
+
133
+ const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every((v) => typeof v === 'string');
134
+ const isRowKeys = (value: unknown): value is RowKeys =>
135
+ typeof value === 'object' && value !== null && isStrings((value as RowKeys).ids) && isStrings((value as RowKeys).writeIds);
136
+
137
+ function isReleaseRecord(value: unknown): value is ReleaseRecord {
138
+ if (!isRowKeys(value)) return false;
139
+ const record = value as Partial<ReleaseRecord>;
140
+ return typeof record.total === 'number' && typeof record.truncated === 'number' && isRowKeys(record.report);
141
+ }
142
+
143
+ function releaseRecordsOf(rows: readonly HeldRow[], epochId: string): ReleaseRecord[] {
144
+ const records: ReleaseRecord[] = [];
145
+ for (const m of rows) {
146
+ const meta = m.metadata as { kind?: unknown; epochId?: unknown; released?: unknown } | undefined;
147
+ if (meta?.kind !== 'tune-out-cancel' || meta.epochId !== epochId) continue;
148
+ if (isReleaseRecord(meta.released) && !records.some((r) => JSON.stringify(r) === JSON.stringify(meta.released))) {
149
+ records.push(meta.released);
150
+ }
151
+ }
152
+ return records;
153
+ }
154
+
155
+ function truncationNote(records: readonly ReleaseRecord[]): string {
156
+ const truncated = records.reduce((n, r) => n + r.truncated, 0);
157
+ if (truncated === 0) return '';
158
+ const total = records.reduce((n, r) => n + r.total, 0);
159
+ return records.length === 1
160
+ ? ` ${truncated} of ${total} diverted messages, the oldest, weren't delivered raw to the resident.`
161
+ : ` ${truncated} of ${total} diverted messages across ${records.length} dumps, the oldest in each dump that left any out, weren't delivered raw to the resident.`;
162
+ }
163
+
101
164
  /**
102
165
  * Whether a held message is one of this channel's: by its channel id, and by
103
166
  * its server where it records one, since two servers can register the same
@@ -145,7 +208,7 @@ export interface TuneOutCheck {
145
208
  peek?: string[];
146
209
  }
147
210
 
148
- /** How long after an end time whose cancel threw the cancel is tried again. */
211
+ /** How long after an end time's or a wake budget's cancel threw the cancel is tried again. */
149
212
  const EXPIRY_RETRY_MS = 60_000;
150
213
 
151
214
  /** Ends a backlog line cut to fit in one of the residents' messages. */
@@ -215,12 +278,7 @@ export interface TuneOutFrameworkHooks {
215
278
  * message held mid-turn is in the backlog it belongs to (#232 gap 7).
216
279
  */
217
280
  getDeferredMessages?: () => Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & { writeId?: string; heldAt?: number }>;
218
- /**
219
- * Make what was just delivered durable, including anything the delivery
220
- * left in the deferred queue: a release flips durable epoch state, so its
221
- * dump must survive a crash in the same window (#232 gap 6).
222
- */
223
- commitDeliveries?: () => void;
281
+ commitDeliveries?: () => boolean;
224
282
  /**
225
283
  * The history-reading tool the resident actually has (e.g. a server's
226
284
  * `fetch_history`), or null. A truncation note names it only when it exists
@@ -321,8 +379,6 @@ export class TuneOutCoordinator {
321
379
  private lateForks = new WeakSet<object>();
322
380
  /** The all-except scope's durable record, cached (undefined = not read yet). */
323
381
  private scopeCache: AllExceptState | null | undefined;
324
- /** The epoch whose release this process delivered and whose hold is not yet recorded as cleared. */
325
- private scopeReleased: string | undefined;
326
382
  /** Timer key for the scope's cadence and expiry. */
327
383
  private static readonly SCOPE_KEY = '\0all-except';
328
384
 
@@ -378,25 +434,53 @@ export class TuneOutCoordinator {
378
434
  // An epoch whose deadline passed while the process was down cancels
379
435
  // immediately — the resident gets their backlog on the schedule they
380
436
  // committed to, not whenever the host happened to restart.
381
- if (params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now()) {
382
- this.cancel(serverId, channelId, 'duration', 'duration elapsed');
383
- continue;
437
+ const inForce = (): boolean => this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === params.epochId;
438
+ const expired = params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now();
439
+ if (expired) {
440
+ try {
441
+ this.cancel(serverId, channelId, 'duration', 'duration elapsed');
442
+ continue;
443
+ } catch (err) {
444
+ if (!inForce()) {
445
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} reached its end time while the process was down and its cancel ended it, but a step after the end failed:`, err);
446
+ continue;
447
+ }
448
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} reached its end time while the process was down, and its cancel failed, so it stays tuned out and its cancel is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
449
+ }
384
450
  }
385
451
  this.startCadence(serverId, channelId, params.cadenceSeconds);
386
- this.armExpiry(serverId, channelId, params);
452
+ const wakeCount = this.channelRegistry.getTuneOutState(serverId, channelId)?.wakeCount ?? 0;
453
+ if (wakeCount > params.maxWakes) {
454
+ this.cancelPastBudget(serverId, channelId, params.maxWakes, 'start');
455
+ if (!inForce()) continue;
456
+ } else {
457
+ this.armExpiry(serverId, channelId, expired ? { ...params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS } : params);
458
+ }
387
459
  if (oldestAnchor === null || params.startedAtSequence < oldestAnchor) {
388
460
  oldestAnchor = params.startedAtSequence;
389
461
  }
390
462
  }
391
463
  const scope = this.getAllExcept();
392
464
  if (scope) {
465
+ let inForce = true;
393
466
  if (scope.params.expiresAtMs <= Date.now()) {
394
- this.cancelAllExcept('duration', 'duration elapsed');
467
+ try {
468
+ this.cancelAllExcept('duration', 'duration elapsed');
469
+ inForce = false;
470
+ } catch (err) {
471
+ if (this.getAllExcept()?.params.epochId !== scope.params.epochId) {
472
+ console.error('[tune-out] the all-except hold reached its end time while the process was down and its release ended it, but a step after the end failed:', err);
473
+ inForce = false;
474
+ } else {
475
+ console.error(`[tune-out] the all-except hold reached its end time while the process was down, and its release failed, so it stays in force and its release is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
476
+ this.armScopeTimers({ ...scope.params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS });
477
+ }
478
+ }
395
479
  } else {
396
480
  this.armScopeTimers(scope.params);
397
- if (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor) {
398
- oldestAnchor = scope.params.startedAtSequence;
399
- }
481
+ }
482
+ if (inForce && (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor)) {
483
+ oldestAnchor = scope.params.startedAtSequence;
400
484
  }
401
485
  }
402
486
  if (oldestAnchor !== null) {
@@ -600,13 +684,6 @@ export class TuneOutCoordinator {
600
684
  return this.readerAvailable();
601
685
  }
602
686
 
603
- /**
604
- * End a tune-out: durable flip, system-framed backlog dump into the
605
- * resident's window (capped), lifecycle notice, one wake for the
606
- * resident. `subconsciousNote` is the subconscious's own text (from its
607
- * cancel_tuneout call) — delivered verbatim under its name, never
608
- * synthesized here.
609
- */
610
687
  cancel(
611
688
  serverId: string,
612
689
  channelId: string,
@@ -618,31 +695,25 @@ export class TuneOutCoordinator {
618
695
  /** The lifecycle state the channel returns to (a tool close ends in 'closed'). */
619
696
  nextState: 'open' | 'closed' = 'open',
620
697
  ): { ok: true } | { ok: false; error: string } {
621
- const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
622
- if (!ended) {
698
+ const ending = this.channelRegistry.getTuneOutState(serverId, channelId);
699
+ if (!ending) {
623
700
  // Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
624
701
  return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') };
625
702
  }
626
- this.clearTimers(serverId, channelId);
627
- this.divertedSinceInvocation.delete(this.key(serverId, channelId));
628
703
 
629
- // Backlog: every message held under this epoch. The stored ones come in
630
- // store order, then those still in the deferred queue (held mid-turn,
631
- // not yet written), oldest first. That's arrival order, except when a
632
- // held message stays queued while a later one is stored: a store write
633
- // refused at a turn-start flush leaves it queued, and that turn's
634
- // assembly can then store later coalesced messages directly.
635
- const epochId = ended.params.epochId;
704
+ const epochId = ending.params.epochId;
636
705
  const ofEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
637
706
  (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
638
- const held = (this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch);
639
- const backlog: Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'>> = [
640
- ...this.hooks.getStoredMessages().filter(ofEpoch),
641
- ...held,
642
- ];
707
+ const storedRows: HeldRow[] = this.hooks.getStoredMessages();
708
+ const queuedRows: HeldRow[] = this.hooks.getDeferredMessages?.() ?? [];
709
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
710
+ const dumpedEarlier = keyedBy(earlier);
711
+ const unreleased = (m: HeldRow): boolean => ofEpoch(m) && !dumpedEarlier(m);
712
+ const held = queuedRows.filter(unreleased);
713
+ const backlog: HeldRow[] = [...storedRows.filter(unreleased), ...held];
643
714
  // Whole messages: a cap below one shows none, where slice(-0.5) would
644
715
  // start at 0 and take them all.
645
- const cap = Math.floor(ended.params.backlogCap);
716
+ const cap = Math.floor(ending.params.backlogCap);
646
717
  const authorOf = (m: Pick<StoredMessage, 'participant' | 'metadata'>): string =>
647
718
  suppliedValue((m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant);
648
719
  // A line names its author as the resident knows them and marks what a
@@ -653,7 +724,7 @@ export class TuneOutCoordinator {
653
724
  const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history';
654
725
  // The channel as the resident and the subconscious are shown it.
655
726
  const name = this.shown(serverId, channelId);
656
- const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`;
727
+ const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ending.wakeCount} wake${ending.wakeCount === 1 ? '' : 's'}]`;
657
728
  const render = (lines: string[], truncated: number): string => {
658
729
  const attrs = [
659
730
  `channel=${this.quotedShown(serverId, channelId)}`,
@@ -725,14 +796,30 @@ export class TuneOutCoordinator {
725
796
  m.content.some((b) => b.type !== 'text') || (newestCut && m === shown[0])),
726
797
  ];
727
798
 
728
- // Host-framed system artifacts — never voiced as anyone.
729
- this.hooks.addMessage(
730
- 'user',
731
- [{ type: 'text', text: render(lines, truncated) }],
732
- { system: true, kind: 'tune-out-cancel', channelId, epochId },
733
- );
734
- // The epoch is already ended durably; the release must be as durable.
735
- this.hooks.commitDeliveries?.();
799
+ const records = [...earlier];
800
+ if (earlier.length === 0 || backlog.length > 0) {
801
+ const record: ReleaseRecord = { ...keysOf(backlog), total: backlog.length, truncated, report: keysOf(heldForReport) };
802
+ this.hooks.addMessage(
803
+ 'user',
804
+ [{ type: 'text', text: render(lines, truncated) }],
805
+ { system: true, kind: 'tune-out-cancel', channelId, epochId, released: record },
806
+ );
807
+ records.push(record);
808
+ }
809
+ if (this.hooks.commitDeliveries?.() === false) {
810
+ throw new Error(`the backlog of ${this.quotedShown(serverId, channelId)} could not be made durable, so its tune-out stays in force`);
811
+ }
812
+ this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
813
+ this.clearTimers(serverId, channelId);
814
+ this.divertedSinceInvocation.delete(this.key(serverId, channelId));
815
+ const reportedEarlier = keyedBy(earlier.map((r) => r.report));
816
+ const release = {
817
+ truncated: records.reduce((n, r) => n + r.truncated, 0),
818
+ heldForReport: [
819
+ ...[...storedRows, ...queuedRows].filter((m) => ofEpoch(m) && reportedEarlier(m)),
820
+ ...(records.length > earlier.length ? heldForReport : []),
821
+ ],
822
+ };
736
823
  // The subconscious's own words, under its own name, verbatim.
737
824
  const subName = this.hooks.subconsciousName();
738
825
  if (subconsciousNote && subName) {
@@ -766,12 +853,10 @@ export class TuneOutCoordinator {
766
853
  // and when it wrote no note and the dump left any message out, since
767
854
  // nothing else then covers it. Otherwise its note speaks for what it saw.
768
855
  const selfCancel = source === 'subconscious';
769
- if (subName && (!selfCancel || heldForReport.length > 0 || (!subconsciousNote && truncated > 0))) {
770
- const truncNote = truncated > 0
771
- ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
772
- : '';
773
- const heldNote = heldForReport.length > 0
774
- ? ` Still held at the cancel, so your view may not have them yet: the ${heldForReport.length} message${heldForReport.length === 1 ? '' : 's'} after this one.`
856
+ if (subName && (!selfCancel || release.heldForReport.length > 0 || (!subconsciousNote && release.truncated > 0))) {
857
+ const truncNote = truncationNote(records);
858
+ const heldNote = release.heldForReport.length > 0
859
+ ? ` Still held at the cancel, so your view may not have them yet: the ${release.heldForReport.length} message${release.heldForReport.length === 1 ? '' : 's'} after this one.`
775
860
  : '';
776
861
  this.hooks.addMessage(
777
862
  'user',
@@ -781,7 +866,7 @@ export class TuneOutCoordinator {
781
866
  );
782
867
  // Each held message is a row of its own, so a per-message cap cuts each
783
868
  // one on its own, its author's label included, not the batch's newest.
784
- for (const m of heldForReport) {
869
+ for (const m of release.heldForReport) {
785
870
  this.hooks.addMessage(
786
871
  'user',
787
872
  [{ type: 'text', text: `${authorOf(m)}:` }, ...m.content],
@@ -803,9 +888,7 @@ export class TuneOutCoordinator {
803
888
  const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
804
889
  const unread = rest.backlog.length + rest.queued.length;
805
890
  if (!selfCancel || unread > 0) {
806
- const truncNote = truncated > 0
807
- ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
808
- : '';
891
+ const truncNote = truncationNote(records);
809
892
  const lateNote = selfCancel
810
893
  ? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.`
811
894
  : '';
@@ -818,7 +901,7 @@ export class TuneOutCoordinator {
818
901
  // there that its reader hasn't looked at are counted again, as the
819
902
  // channel's own tune-out's count stood in for theirs.
820
903
  this.rebuildTally(serverId, channelId);
821
- this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
904
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ending.wakeCount });
822
905
  return { ok: true };
823
906
  }
824
907
 
@@ -1043,11 +1126,6 @@ export class TuneOutCoordinator {
1043
1126
  return counts;
1044
1127
  }
1045
1128
 
1046
- /**
1047
- * End the all-except hold: durable flip, one release notice with a
1048
- * per-channel backlog (each capped), the reader's note if any, one wake
1049
- * for the resident, and the reader's own look at what it had not seen.
1050
- */
1051
1129
  cancelAllExcept(
1052
1130
  source: string,
1053
1131
  reason: string,
@@ -1060,9 +1138,14 @@ export class TuneOutCoordinator {
1060
1138
 
1061
1139
  const ofEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
1062
1140
  (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
1063
- const all = [...this.hooks.getStoredMessages().filter(ofEpoch), ...(this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch)];
1064
- const byChannel = new Map<string, { serverId: string; channelId: string; backlog: Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'>> }>();
1065
- for (const m of all) {
1141
+ const storedRows: HeldRow[] = this.hooks.getStoredMessages();
1142
+ const queuedRows: HeldRow[] = this.hooks.getDeferredMessages?.() ?? [];
1143
+ const all: HeldRow[] = [...storedRows.filter(ofEpoch), ...queuedRows.filter(ofEpoch)];
1144
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
1145
+ const releasedEarlier = keyedBy(earlier);
1146
+ const fresh = all.filter((m) => !releasedEarlier(m));
1147
+ const byChannel = new Map<string, { serverId: string; channelId: string; backlog: HeldRow[] }>();
1148
+ for (const m of fresh) {
1066
1149
  const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
1067
1150
  const serverId = meta?.serverId ?? '';
1068
1151
  const channelId = meta?.channelId ?? '(unknown channel)';
@@ -1072,12 +1155,14 @@ export class TuneOutCoordinator {
1072
1155
  byChannel.set(k, entry);
1073
1156
  }
1074
1157
  const historyTool = this.hooks.historyTool?.() ?? null;
1158
+ let truncatedInAll = 0;
1075
1159
  const dumps = [...byChannel.values()].map(({ serverId, channelId, backlog }) => {
1076
1160
  // Floored here too: a hold stored before entry floored its cap can hold
1077
1161
  // a fraction.
1078
1162
  const cap = Math.floor(backlogCap);
1079
1163
  const shown = cap > 0 ? backlog.slice(-cap) : [];
1080
1164
  const truncated = backlog.length - shown.length;
1165
+ truncatedInAll += truncated;
1081
1166
  const attrs = [
1082
1167
  `channel=${quotedValue(this.shown(serverId, channelId))}`,
1083
1168
  `messages=${backlog.length}`,
@@ -1087,22 +1172,19 @@ export class TuneOutCoordinator {
1087
1172
  });
1088
1173
  const summary = byChannel.size === 0
1089
1174
  ? 'Nothing was held.'
1090
- : `${all.length} message${all.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
1091
- if (this.scopeReleased !== epochId) {
1175
+ : `${fresh.length} message${fresh.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
1176
+ if (earlier.length === 0 || fresh.length > 0) {
1177
+ const record: ReleaseRecord = { ...keysOf(fresh), total: fresh.length, truncated: truncatedInAll, report: { ids: [], writeIds: [] } };
1092
1178
  this.hooks.addMessage(
1093
1179
  'user',
1094
1180
  [{ type: 'text', text: `[${NOTICE_TAG.tuneOut}: all channels except ${this.shown(except.serverId, except.channelId)} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }],
1095
- { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId },
1181
+ { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, released: record },
1096
1182
  );
1097
- this.hooks.commitDeliveries?.();
1098
- this.scopeReleased = epochId;
1099
1183
  }
1100
- // The release is recorded before the hold is: a failure past this point
1101
- // leaves the hold in force with its release already delivered. A retry in
1102
- // this process records only the hold's end; a restart between the two
1103
- // delivers the release again, where the other order lost it outright.
1184
+ if (this.hooks.commitDeliveries?.() === false) {
1185
+ throw new Error('the all-except release could not be made durable, so the hold stays in force');
1186
+ }
1104
1187
  this.writeScope(null);
1105
- this.scopeReleased = undefined;
1106
1188
  this.clearScopeTimers();
1107
1189
  for (const [key, pending] of this.pendingWakes) {
1108
1190
  const [serverId, channelId] = key.split('\0') as [string, string];
@@ -1335,6 +1417,31 @@ export class TuneOutCoordinator {
1335
1417
  this.pendingWakes.set(key, { count: 1, timer });
1336
1418
  }
1337
1419
 
1420
+ private cancelPastBudget(serverId: string, channelId: string, maxWakes: number, from: 'wake' | 'start' = 'wake'): void {
1421
+ const key = this.key(serverId, channelId);
1422
+ const existing = this.expiryTimers.get(key);
1423
+ if (existing) clearTimeout(existing);
1424
+ const end = (first: boolean): void => {
1425
+ try {
1426
+ this.expiryTimers.set(key, setTimeout(() => end(false), EXPIRY_RETRY_MS));
1427
+ if (!this.cancel(serverId, channelId, 'tune-out-max-wakes', `wake budget exhausted (${maxWakes})`).ok) {
1428
+ this.clearTimers(serverId, channelId);
1429
+ }
1430
+ } catch (err) {
1431
+ if (first && from === 'start') {
1432
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
1433
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} was past its wake budget at start, and its cancel failed, so it stays tuned out and its cancel is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
1434
+ } else {
1435
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} was past its wake budget at start and its cancel ended it, but a step after the end failed:`, err);
1436
+ }
1437
+ } else {
1438
+ this.timerFailed('wake', serverId, channelId, err);
1439
+ }
1440
+ }
1441
+ };
1442
+ end(true);
1443
+ }
1444
+
1338
1445
  /** One coalesced wake: counted durably against the epoch that holds the channel. */
1339
1446
  private fireWake(serverId: string, channelId: string, count: number): void {
1340
1447
  if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
@@ -1342,12 +1449,7 @@ export class TuneOutCoordinator {
1342
1449
  const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
1343
1450
  if (!counted) return; // cancelled inside the window
1344
1451
  if (counted.wakeCount > counted.params.maxWakes) {
1345
- this.cancel(
1346
- serverId,
1347
- channelId,
1348
- 'tune-out-max-wakes',
1349
- `wake budget exhausted (${counted.params.maxWakes})`,
1350
- );
1452
+ this.cancelPastBudget(serverId, channelId, counted.params.maxWakes);
1351
1453
  return;
1352
1454
  }
1353
1455
  this.invokeSubconscious(serverId, channelId, 'wake', count);
@@ -14,7 +14,7 @@ import type { StoredMessage } from '@animalabs/context-manager';
14
14
  * never leak bare prose to the default locus.
15
15
  */
16
16
 
17
- import type { ToolDefinition } from '../types/index.js';
17
+ import type { AgentConfig, ToolDefinition } from '../types/index.js';
18
18
 
19
19
  export const SUBCONSCIOUS_TOOL_NAMES = [
20
20
  'deliver_summary',
@@ -143,6 +143,17 @@ interface SubconsciousConfigBase {
143
143
  * shared prefix).
144
144
  */
145
145
  allowChannelSpeech?: boolean;
146
+ /** The reader's model's context window, as AgentConfig.physicalWindowTokens,
147
+ * in place of the primary's. Left out or null, a reader on the primary's
148
+ * model has the primary's; on another model, the persistent reader has
149
+ * none and a fork has the primary's. */
150
+ physicalWindowTokens?: AgentConfig['physicalWindowTokens'];
151
+ /** The reader's model's thinking binding, as AgentConfig.thinkingBinding,
152
+ * in place of the primary's. Left out, null or false, a reader on the
153
+ * primary's model has the primary's; on another model, the persistent
154
+ * reader has none and a fork has the primary's. `{ binds: false }` gives
155
+ * a reader none. */
156
+ thinkingBinding?: AgentConfig['thinkingBinding'];
146
157
  }
147
158
 
148
159
  export interface PersistentReaderConfig extends SubconsciousConfigBase {
@@ -156,7 +167,8 @@ export interface PersistentReaderConfig extends SubconsciousConfigBase {
156
167
  /** Model id; defaults to the primary agent's (same-model side-process).
157
168
  * Another id, compared exactly, makes the subconscious leave out the
158
169
  * fields that describe the primary's model, its window and its thinking
159
- * binding (SUBCONSCIOUS_MODEL_FIELDS); nothing here sets its own. */
170
+ * binding (SUBCONSCIOUS_MODEL_FIELDS); `physicalWindowTokens` and
171
+ * `thinkingBinding` set its own. */
160
172
  model?: string;
161
173
  /**
162
174
  * The voice/criteria mode block — recipe-side and co-authored with the
@@ -539,7 +539,8 @@ export interface InferenceOptions {
539
539
  * `supportsInjectedMessages` is what the stream declared with the round's
540
540
  * tool calls (membrane's `ToolContext.supportsInjectedMessages`): whether it
541
541
  * can carry messages injected with that round's results. It's absent when
542
- * the stream doesn't say, as on membrane 0.5.87 and earlier.
542
+ * the stream doesn't say, which reads as a stream that can't, and when there
543
+ * is no stream, as for the direct API.
543
544
  */
544
545
  export type AgentState =
545
546
  | { status: 'idle' }
@@ -6,6 +6,7 @@ import type { ProcessEvent } from './events.js';
6
6
  import type { McplServerConfig, InferenceRoutingPolicy } from '../mcpl/types.js';
7
7
  import type { ConversationRouterConfig } from '../mcpl/conversation-router.js';
8
8
  import type { GateOptions } from '../gate/types.js';
9
+ import type { InferenceBoundsOptions } from '../inference-bounds.js';
9
10
 
10
11
  // Re-export trace types
11
12
  export type { TraceEvent, TraceEventListener } from './trace.js';
@@ -299,6 +300,30 @@ export interface FrameworkConfig {
299
300
  /** Custom error policy */
300
301
  errorPolicy?: ErrorPolicy;
301
302
 
303
+ /**
304
+ * How long a model call may go without progress before it is cut. A
305
+ * resident's streamed call, and an MCPL server's, gets membrane's progress
306
+ * bounds where its adapter's stream reports content (Anthropic, Bedrock),
307
+ * and on every adapter a flat bound from its last provider event: the
308
+ * larger of a setup term (the larger of `beforeContentMs` and
309
+ * `betweenEventsMs`) plus the request's `maxTokens` at
310
+ * `thinkTokensPerSecond`, and `flatMinimumMs`. An auxiliary call
311
+ * (compression, the reply judge) and an MCPL server's non-streamed call get
312
+ * a bound on the whole call from its send, the setup term plus that
313
+ * allowance (the larger of the setup term and `flatMinimumMs` when it
314
+ * names no `maxTokens`), unless its
315
+ * caller passed a `progressTimeoutMs` of its own; where an adapter's
316
+ * `complete()` streams under the hood, membrane renews it at each parsed
317
+ * provider event. A call a bound cuts fails
318
+ * with type `timeout`, and on an agent's turn DefaultErrorPolicy gives each
319
+ * such call one fresh call, except in a turn that re-arms an exhausted one,
320
+ * where `exhaustedTurnRearmMs` says what a cut does. One setting covers
321
+ * every agent. `false` sets
322
+ * none of these bounds. Default: membrane's PROGRESS_BOUNDS and
323
+ * FLAT_MINIMUM_MS.
324
+ */
325
+ inferenceBounds?: false | InferenceBoundsOptions;
326
+
302
327
  /**
303
328
  * Host verdict on a failed inference: "this cannot succeed until later".
304
329
  * Consulted before the error policy and before the built-in
@@ -337,20 +362,32 @@ export interface FrameworkConfig {
337
362
  * channel isn't sent again: one whose turn was woken for a channel id that
338
363
  * names no one registered channel and never found one, and whose request
339
364
  * names none of its own. A failure that states a wait is held for that wait
340
- * first, as any turn's is, and a deadline ends the turn as before, whether
341
- * the host set it or membrane marks one as run out: the runtime's or
342
- * undici's on a sent request, the Anthropic adapter's watchdogs, or the
343
- * Anthropic SDK's timeout, which covers its connect timeout too. Until one
344
- * of the re-armed turn's rounds has run or had its tool calls cut off, its
345
- * failure gets no retries from the error policy: on another transient
346
- * failure the next delay follows, until the list runs out, and a failure of
347
- * any other kind ends the re-arming. A wake for the agent while it waits
348
- * starts its turn at once, with the re-armed trigger merged in. That turn is
349
- * the wake's: the error policy retries its failures. A completed turn starts
350
- * the count again, and so does a message said to the agent: not a system
351
- * message or notice; not an edit, reaction or deletion, unless an edit is
352
- * the first version of a message the agent sees; not a message its own view
353
- * leaves out, such as one a tune-out holds from it; and not the agent's own
365
+ * first, as any turn's is, and a turn that ends on a deadline, whether the
366
+ * host set it or membrane marks one as run out (the runtime's or undici's
367
+ * on a sent request, the Anthropic adapter's watchdogs, or the Anthropic
368
+ * SDK's timeout, which covers its connect timeout too), isn't re-armed,
369
+ * save the exception below. Until one of the re-armed turn's rounds has run
370
+ * or had its tool calls cut off, its failure gets no retries from the error
371
+ * policy: on another transient failure the next delay follows, until the
372
+ * list runs out, and a failure of any other kind ends the re-arming. The
373
+ * one exception is a re-armed turn's send that fails with a deadline marked
374
+ * as run out, membrane's or a bound the host sets on the call
375
+ * (`inferenceBounds`), as when a stream stalls. Such a cut counts as a
376
+ * transient failure, so a turn that ends on it is re-armed as for a
377
+ * transient failure, unless it follows another such cut with no transient
378
+ * failure, answered round or message said to the agent between them: two
379
+ * such cuts in a row end the re-arming, and the second gets no retry from
380
+ * the error policy, even in a re-armed turn that got partway. A transient
381
+ * failure or an answered round in any of the agent's turns clears an
382
+ * earlier cut, and so does a message said to the agent, or the agent's end.
383
+ * Every such cut counts, including one the error policy retries or a host's
384
+ * provider hold takes. A wake for the agent while it waits starts its turn
385
+ * at once, with the re-armed trigger merged in. That turn is the
386
+ * wake's: the error policy retries its failures. A completed turn starts the
387
+ * count again, and so does a message said to the agent: not a system message
388
+ * or notice; not an edit, reaction or deletion, unless an edit is the first
389
+ * version of a message the agent sees; not a message its own view leaves
390
+ * out, such as one a tune-out holds from it; and not the agent's own
354
391
  * schedule, the heartbeat's tick or reminder, a self-wake timer it set, or a
355
392
  * script's wake. A notice a module or an MCPL server stores as a message
356
393
  * counts. A message in the residents' shared slot counts for each resident
@@ -558,7 +595,10 @@ export interface InferencePolicy {
558
595
  */
559
596
  export interface ErrorPolicy {
560
597
  /**
561
- * Handle an inference error.
598
+ * Handle an inference error. `attempt` is how many times the turn's
599
+ * request has failed before this one; for a deadline expiry (a progress
600
+ * bound's cut, or one membrane marks), how many times that call's request
601
+ * has expired in a row before this one.
562
602
  */
563
603
  onInferenceError(
564
604
  error: Error,
@@ -120,7 +120,10 @@ export interface Module {
120
120
 
121
121
  /**
122
122
  * Start the module.
123
- * Called when the framework starts or when the module is added.
123
+ * Called when the module is added: create() adds the modules it is given,
124
+ * so for those it runs before create() returns. Its own removal and the
125
+ * framework's stop() wait for it to settle before the module's stop()
126
+ * runs, so it mustn't await either.
124
127
  */
125
128
  start(ctx: ModuleContext): Promise<void>;
126
129
 
@@ -132,6 +135,8 @@ export interface Module {
132
135
  * a host wires up after create() returns, such as a WorkspaceModule's
133
136
  * initStore(). A stop() that saves state can therefore run before that
134
137
  * wiring, and shouldn't then save over state it was handed and hasn't used.
138
+ * It isn't called before the module's start() has settled, so it can end
139
+ * what start() set up.
135
140
  */
136
141
  stop(): Promise<void>;
137
142
 
@@ -259,6 +264,41 @@ export interface AgentSettingsExtension {
259
264
 
260
265
  /**
261
266
  * Context provided to modules for interacting with the framework.
267
+ *
268
+ * Once the module's `stop()` has completed, through `removeModule()` or the
269
+ * framework's `stop()`, its context has no effect on the framework:
270
+ * - each push is dropped with a log line, as a push after the framework's
271
+ * `stop()` is;
272
+ * - `setState`, `registerLogState`, `appendToLog`, `editLogItem`,
273
+ * `editMessage`, `removeMessage`, `notifyOps`, `registerSpeechHandler` and
274
+ * `unregisterSpeechHandler` do nothing; `addMessage` returns `''` and adds
275
+ * nothing; `callTool` resolves with an error result and runs nothing;
276
+ * `getModule` returns `null`; and `onTrace` subscribes nothing, while the
277
+ * subscriptions made through the context end. None of these throws, and
278
+ * the first such call of each method logs one line naming the module and
279
+ * the method;
280
+ * - `findMessageByExternalId` answers without updating its mapping, and logs
281
+ * nothing.
282
+ *
283
+ * Its reads (`getState`, `getLog`, `getLogLength`, `getMessage`,
284
+ * `queryMessages`, `getAgents`, `getActiveTools`, `findMessageByExternalId`)
285
+ * answer, and fail, as before; one that meets a held write may record that
286
+ * the write has landed, as any reader's does. The framework calls none of the
287
+ * module's `onProcess`, `onAgentSpeech` and `onToolBatchComplete` again; it
288
+ * drops whole a response of its that hadn't been applied by then, with a line
289
+ * naming what the response asked for if it asked for anything; and it leaves
290
+ * its `gatherContext` answer out of a turn whose answers it hadn't merged by
291
+ * then, with a line if it held injections. After `removeModule()`, the result
292
+ * its `handleToolCall` returns for a call it was running when its `stop()`
293
+ * completed is still delivered while the framework runs. The framework's
294
+ * `stop()` closes its event queue before it stops any module, so through the
295
+ * framework's `stop()` that result is dropped, with the line any tool result
296
+ * arriving after the framework's `stop()` gets. A result it pushes through
297
+ * its context after its `stop()` completes is dropped, as its other pushes
298
+ * are. A module whose `stop()` fails stays registered, and its context keeps
299
+ * working.
300
+ * References the module already holds, to other modules or to its own
301
+ * timers, stay as they are.
262
302
  */
263
303
  export interface ModuleContext {
264
304
  /**
@@ -366,8 +406,7 @@ export interface ModuleContext {
366
406
  * and a tool round moves when it injects messages into a stream that
367
407
  * declares it carries them. So one that waited for its target's end (a
368
408
  * run's end, or a fork's last turn), one stored while it was idle and
369
- * never sent, one stored at a tool round that ended the turn, and one
370
- * injected into a stream that didn't declare it carries injections go to
409
+ * never sent, and one stored at a tool round that ended the turn go to
371
410
  * the primary. The framework's own
372
411
  * writes for a run or a fork are unchanged: they stay in its store,
373
412
  * unless they're notices the primary already takes in their place (a