@animalabs/agent-framework 0.30.0 → 0.32.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 (189) 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 +138 -102
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1133 -752
  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 +6 -12
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +65 -24
  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 +1163 -770
  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 +6 -12
  177. package/src/types/framework.ts +66 -24
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
  180. package/dist/src/reasoning-loss-notice.d.ts +0 -23
  181. package/dist/src/reasoning-loss-notice.d.ts.map +0 -1
  182. package/dist/src/reasoning-loss-notice.js +0 -52
  183. package/dist/src/reasoning-loss-notice.js.map +0 -1
  184. package/dist/src/reasoning-loss.d.ts +0 -24
  185. package/dist/src/reasoning-loss.d.ts.map +0 -1
  186. package/dist/src/reasoning-loss.js +0 -102
  187. package/dist/src/reasoning-loss.js.map +0 -1
  188. package/src/reasoning-loss-notice.ts +0 -64
  189. package/src/reasoning-loss.ts +0 -120
@@ -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
@@ -258,17 +258,10 @@ export interface AgentConfig {
258
258
  * `thinking-binding-loss` ops alert, once per incident and again if a
259
259
  * call drops more. The provider reports drops only on a request that
260
260
  * carries the drop control; without it, such a block is refused;
261
- * - the agent is told, in a `[reasoning] <agent>:` system notice of kind
262
- * `reasoning-loss` stored for it as the framework's other notices are,
263
- * when a committing compile leaves out a stored reply's reasoning at a
264
- * change of the request's head, at a branch's first compile, or in a
265
- * fold or other change to an earlier message that takes a reply of the
266
- * current turn, and, with `dropBlock` set too, when the provider drops
267
- * blocks it can place as the agent's stored replies or the stream's
268
- * rounds, once per incident and again when a call drops more. The
269
- * framework hears compiles through `Agent.onThinkingStrip` from the
270
- * agent's first stream on, unless a host has set its own, so an agent
271
- * driven only through the direct API is never told.
261
+ * - the agent is told nothing of reasoning left out or dropped: no line
262
+ * about the mechanics of its own memory or reasoning is put in its
263
+ * context. Hosts can observe compiles' strips through
264
+ * `Agent.onThinkingStrip`;
272
265
  * The record and the notes
273
266
  * vouch for requests as agent-framework hands them to membrane, so a
274
267
  * membrane `beforeRequest` hook that changes a request must change every
@@ -539,7 +532,8 @@ export interface InferenceOptions {
539
532
  * `supportsInjectedMessages` is what the stream declared with the round's
540
533
  * tool calls (membrane's `ToolContext.supportsInjectedMessages`): whether it
541
534
  * 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.
535
+ * the stream doesn't say, which reads as a stream that can't, and when there
536
+ * is no stream, as for the direct API.
543
537
  */
544
538
  export type AgentState =
545
539
  | { 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';
@@ -130,12 +131,12 @@ export interface FrameworkConfig {
130
131
  */
131
132
  announceOrphans?: boolean;
132
133
  /**
133
- * By default every derivation tells the agent it was made from, in one
134
- * host-framed line. After this many derivations of one kind from one
135
- * agent, that agent hears a count line every so many instead of a line
136
- * per fork, so a reader loop that forks a hundred times a session doesn't
137
- * fill its source's context with stamps; every derivation still emits its
138
- * `dendrite:agent-created` trace. Default 10; 0 never collapses.
134
+ * With a `derivationNotice`, after this many derivations of one kind from
135
+ * one agent, the line it's given is a count line every so many instead of
136
+ * a line per fork, so a reader loop that forks a hundred times a session
137
+ * doesn't fill its source's context with stamps; every derivation still
138
+ * emits its `dendrite:agent-created` trace. Default 10; 0 never collapses.
139
+ * Without a `derivationNotice` the source is told nothing.
139
140
  */
140
141
  collapseDerivationNoticesAfter?: number;
141
142
  /**
@@ -151,9 +152,11 @@ export interface FrameworkConfig {
151
152
  beforeDerive?: (spec: import('../dendrite/types.js').AgentSpec) => void | { refuse: string };
152
153
  /**
153
154
  * What an agent is told when something is derived from it. Given the
154
- * record, the agent it was derived from and the framework's default line,
155
- * return the text to add, or null to add nothing. Absent, the default
156
- * line is used. The `dendrite:agent-created` trace is emitted either way.
155
+ * record, the agent it was derived from and the framework's own line,
156
+ * return the text to add (that line, or the host's own), or null to add
157
+ * nothing. Absent, nothing is added: an agent isn't told of derivations
158
+ * unless the host asks for it. The `dendrite:agent-created` trace is
159
+ * emitted either way.
157
160
  */
158
161
  derivationNotice?: (record: import('../dendrite/types.js').AgentRecord, from: string, defaultText: string) => string | null;
159
162
  };
@@ -299,6 +302,30 @@ export interface FrameworkConfig {
299
302
  /** Custom error policy */
300
303
  errorPolicy?: ErrorPolicy;
301
304
 
305
+ /**
306
+ * How long a model call may go without progress before it is cut. A
307
+ * resident's streamed call, and an MCPL server's, gets membrane's progress
308
+ * bounds where its adapter's stream reports content (Anthropic, Bedrock),
309
+ * and on every adapter a flat bound from its last provider event: the
310
+ * larger of a setup term (the larger of `beforeContentMs` and
311
+ * `betweenEventsMs`) plus the request's `maxTokens` at
312
+ * `thinkTokensPerSecond`, and `flatMinimumMs`. An auxiliary call
313
+ * (compression, the reply judge) and an MCPL server's non-streamed call get
314
+ * a bound on the whole call from its send, the setup term plus that
315
+ * allowance (the larger of the setup term and `flatMinimumMs` when it
316
+ * names no `maxTokens`), unless its
317
+ * caller passed a `progressTimeoutMs` of its own; where an adapter's
318
+ * `complete()` streams under the hood, membrane renews it at each parsed
319
+ * provider event. A call a bound cuts fails
320
+ * with type `timeout`, and on an agent's turn DefaultErrorPolicy gives each
321
+ * such call one fresh call, except in a turn that re-arms an exhausted one,
322
+ * where `exhaustedTurnRearmMs` says what a cut does. One setting covers
323
+ * every agent. `false` sets
324
+ * none of these bounds. Default: membrane's PROGRESS_BOUNDS and
325
+ * FLAT_MINIMUM_MS.
326
+ */
327
+ inferenceBounds?: false | InferenceBoundsOptions;
328
+
302
329
  /**
303
330
  * Host verdict on a failed inference: "this cannot succeed until later".
304
331
  * Consulted before the error policy and before the built-in
@@ -337,20 +364,32 @@ export interface FrameworkConfig {
337
364
  * channel isn't sent again: one whose turn was woken for a channel id that
338
365
  * names no one registered channel and never found one, and whose request
339
366
  * 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
367
+ * first, as any turn's is, and a turn that ends on a deadline, whether the
368
+ * host set it or membrane marks one as run out (the runtime's or undici's
369
+ * on a sent request, the Anthropic adapter's watchdogs, or the Anthropic
370
+ * SDK's timeout, which covers its connect timeout too), isn't re-armed,
371
+ * save the exception below. Until one of the re-armed turn's rounds has run
372
+ * or had its tool calls cut off, its failure gets no retries from the error
373
+ * policy: on another transient failure the next delay follows, until the
374
+ * list runs out, and a failure of any other kind ends the re-arming. The
375
+ * one exception is a re-armed turn's send that fails with a deadline marked
376
+ * as run out, membrane's or a bound the host sets on the call
377
+ * (`inferenceBounds`), as when a stream stalls. Such a cut counts as a
378
+ * transient failure, so a turn that ends on it is re-armed as for a
379
+ * transient failure, unless it follows another such cut with no transient
380
+ * failure, answered round or message said to the agent between them: two
381
+ * such cuts in a row end the re-arming, and the second gets no retry from
382
+ * the error policy, even in a re-armed turn that got partway. A transient
383
+ * failure or an answered round in any of the agent's turns clears an
384
+ * earlier cut, and so does a message said to the agent, or the agent's end.
385
+ * Every such cut counts, including one the error policy retries or a host's
386
+ * provider hold takes. A wake for the agent while it waits starts its turn
387
+ * at once, with the re-armed trigger merged in. That turn is the
388
+ * wake's: the error policy retries its failures. A completed turn starts the
389
+ * count again, and so does a message said to the agent: not a system message
390
+ * or notice; not an edit, reaction or deletion, unless an edit is the first
391
+ * version of a message the agent sees; not a message its own view leaves
392
+ * out, such as one a tune-out holds from it; and not the agent's own
354
393
  * schedule, the heartbeat's tick or reminder, a self-wake timer it set, or a
355
394
  * script's wake. A notice a module or an MCPL server stores as a message
356
395
  * counts. A message in the residents' shared slot counts for each resident
@@ -558,7 +597,10 @@ export interface InferencePolicy {
558
597
  */
559
598
  export interface ErrorPolicy {
560
599
  /**
561
- * Handle an inference error.
600
+ * Handle an inference error. `attempt` is how many times the turn's
601
+ * request has failed before this one; for a deadline expiry (a progress
602
+ * bound's cut, or one membrane marks), how many times that call's request
603
+ * has expired in a row before this one.
562
604
  */
563
605
  onInferenceError(
564
606
  error: Error,