@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
@@ -19,12 +19,14 @@ import type {
19
19
  SpeechContext,
20
20
  SpeechHandlerOptions,
21
21
  ProcessEvent,
22
+ ToolResultEvent,
22
23
  TraceEventListener,
23
24
  ModuleMessage,
24
25
  ModuleContextInjection,
25
26
  MessageQueryResult,
26
27
  } from './types/index.js';
27
28
  import { heldWriteId } from './held-writes.js';
29
+ import { boundedErrorMessage } from './error-message.js';
28
30
  import type { Agent } from './agent.js';
29
31
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from './shutdown.js';
30
32
  import { logValue } from './mcpl/log-value.js';
@@ -38,6 +40,19 @@ export interface ModuleQueue {
38
40
 
39
41
  const MODULE_STATE_PREFIX = 'modules/';
40
42
 
43
+ export function droppedToolResultLine(event: ToolResultEvent, when: string): string {
44
+ const outcome = event.result.success && !event.result.isError
45
+ ? 'succeeded'
46
+ : `failed${event.result.error ? `: ${boundedErrorMessage(event.result.error)}` : ''}`;
47
+ return `[framework] ${event.agentName}: result of tool call ${event.callId} (${event.toolName ? `${event.toolName}, ` : ''}${event.moduleName}) arrived ${when} — dropped; the tool ${outcome}`;
48
+ }
49
+
50
+ export function retiredPushLine(event: ProcessEvent, moduleName: string): string {
51
+ return event.type === 'tool-result'
52
+ ? droppedToolResultLine(event, `after module ${moduleName} stopped`)
53
+ : `[framework] a '${event.type}' event from module ${moduleName} arrived after that module stopped — dropped`;
54
+ }
55
+
41
56
  /**
42
57
  * True when a registerState failure means "this state id already exists" —
43
58
  * the one failure that is safe to swallow on an idempotent re-registration.
@@ -104,6 +119,7 @@ export class ModuleRegistry {
104
119
 
105
120
  private modules: Map<string, Module> = new Map();
106
121
  private stopping = new Map<Module, Promise<void>>();
122
+ private starting = new Map<Module, Promise<void>>();
107
123
  /** Module and namespace pairs already told that a system injection held a block that isn't text. */
108
124
  private notShownWarned = new Set<string>();
109
125
  private moduleContexts: Map<string, ModuleContextImpl> = new Map();
@@ -120,6 +136,11 @@ export class ModuleRegistry {
120
136
  private pushEventFn: (event: ProcessEvent, moduleName?: string) => void;
121
137
  private onTraceFn: (listener: TraceEventListener) => () => void;
122
138
  private callToolFn: (call: ToolCall) => Promise<ToolResult>;
139
+ /**
140
+ * The constructor's `dropRetiredPush` option.
141
+ * @internal For the framework's drop lines; not a host API.
142
+ */
143
+ readonly dropRetiredPushFn?: (event: ProcessEvent, moduleName: string) => void;
123
144
  /** Framework ops-alert channel, surfaced to modules via
124
145
  * ModuleContext.notifyOps. Readonly-public so ModuleContextImpl can
125
146
  * delegate without another positional constructor parameter. */
@@ -142,6 +163,10 @@ export class ModuleRegistry {
142
163
  queryMessages: (filter: MessageQuery, opts?: { forAgent?: string }) => MessageQueryResult;
143
164
  /** Called with the pushing module's name. */
144
165
  pushEvent: (event: ProcessEvent, moduleName?: string) => void;
166
+ /** Logs a push a module made after its stop() completed, which is
167
+ * dropped, not queued. Without it, the registry logs a line of its own.
168
+ * @internal For the framework's drop lines; not a host API. */
169
+ dropRetiredPush?: (event: ProcessEvent, moduleName: string) => void;
145
170
  onTrace: (listener: TraceEventListener) => () => void;
146
171
  callTool: (call: ToolCall) => Promise<ToolResult>;
147
172
  notifyOps?: (kind: string, agentName: string, message: string, data?: Record<string, unknown>) => void;
@@ -159,6 +184,7 @@ export class ModuleRegistry {
159
184
  this.onTraceFn = options.onTrace;
160
185
  this.callToolFn = options.callTool;
161
186
  this.notifyOpsFn = options.notifyOps;
187
+ this.dropRetiredPushFn = options.dropRetiredPush;
162
188
  }
163
189
 
164
190
  /**
@@ -200,7 +226,21 @@ export class ModuleRegistry {
200
226
  this.moduleContexts.set(module.name, context);
201
227
 
202
228
  // Start the module
203
- await module.start(context);
229
+ let release!: (start: Promise<void>) => void;
230
+ const settled = new Promise<void>((resolve) => { release = resolve; }).then(() => undefined, () => undefined);
231
+ this.starting.set(module, settled);
232
+ let started: Promise<void>;
233
+ try {
234
+ started = Promise.resolve(module.start(context));
235
+ } catch (error) {
236
+ started = Promise.reject(error);
237
+ }
238
+ release(started);
239
+ try {
240
+ await started;
241
+ } finally {
242
+ if (this.starting.get(module) === settled) this.starting.delete(module);
243
+ }
204
244
  }
205
245
 
206
246
  /**
@@ -229,6 +269,17 @@ export class ModuleRegistry {
229
269
  return Array.from(this.modules.values());
230
270
  }
231
271
 
272
+ /**
273
+ * The current registration of the module of this name: a value that
274
+ * changes each time a module of that name is added, the same module object
275
+ * included, and that stays the same until its stop() completes;
276
+ * `undefined` while none is registered.
277
+ * @internal For the framework's registration checks; not a host API.
278
+ */
279
+ registrationOf(moduleName: string): object | undefined {
280
+ return this.moduleContexts.get(moduleName);
281
+ }
282
+
232
283
  /**
233
284
  * Set state for a module by name.
234
285
  * Used by the framework to apply stateUpdate from EventResponse after its
@@ -385,7 +436,9 @@ export class ModuleRegistry {
385
436
 
386
437
  /**
387
438
  * Stop all modules. A caller may bound its wait without cancelling cleanup.
388
- * The default waits for each module's own shutdown contract.
439
+ * The default waits for each module's own shutdown contract. A module whose
440
+ * start() is still running is stopped once its start() has settled, within
441
+ * the same wait.
389
442
  */
390
443
  async stopAll(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
391
444
  validateShutdownTimeout(timeoutMs);
@@ -397,11 +450,16 @@ export class ModuleRegistry {
397
450
  private stopModule(module: Module): Promise<void> {
398
451
  const existing = this.stopping.get(module);
399
452
  if (existing) return existing;
400
- const attempt = Promise.resolve().then(() => module.stop()).then(() => {
453
+ const attempt = (this.starting.get(module) ?? Promise.resolve()).then(() => module.stop()).then(() => {
401
454
  if (this.modules.get(module.name) === module) {
455
+ const context = this.moduleContexts.get(module.name);
402
456
  this.modules.delete(module.name);
403
457
  this.moduleContexts.delete(module.name);
404
458
  this.speechHandlers = this.speechHandlers.filter(handler => handler.moduleName !== module.name);
459
+ this.batchHooksRunning.delete(module.name);
460
+ this.stopping.delete(module);
461
+ context?.retire();
462
+ return;
405
463
  }
406
464
  this.stopping.delete(module);
407
465
  }, error => {
@@ -453,7 +511,12 @@ export class ModuleRegistry {
453
511
  * something that isn't an array of objects, or with a `system` entry whose
454
512
  * content isn't an array of objects, every block of which the agent's
455
513
  * buildSystemPrompt reads, and one that answers after its timeout adds
456
- * nothing. An array returned without a promise is taken as the module's
514
+ * nothing. Once every answer is in, the answers are merged, and an answer
515
+ * from a module whose registration has ended by then (its stop()
516
+ * completed, whether or not a module of its name has been added since) is
517
+ * left out, with a line if it held injections; a stop that completes after
518
+ * the merge doesn't change what this returned. An array returned without a
519
+ * promise is taken as the module's
457
520
  * answer. An entry's other fields are the module's to get right. Each
458
521
  * module's budget starts once its call returns, after its own synchronous
459
522
  * work and every earlier module's; a later module's synchronous work still
@@ -473,6 +536,7 @@ export class ModuleRegistry {
473
536
  // that answers after its timeout adds nothing (it's skipped, fail-open).
474
537
  const results: ModuleContextInjection[][] = [];
475
538
  const promises: Promise<void>[] = [];
539
+ const asked: { slot: number; moduleName: string; registration: object | undefined }[] = [];
476
540
 
477
541
  for (const module of this.modules.values()) {
478
542
  const gather = module.gatherContext;
@@ -491,6 +555,7 @@ export class ModuleRegistry {
491
555
  let timedOut = false;
492
556
  const slot = results.length;
493
557
  results.push([]);
558
+ asked.push({ slot, moduleName: module.name, registration: this.moduleContexts.get(module.name) });
494
559
  // Called here, before its timer is set, so its budget starts after its
495
560
  // synchronous work; a call that throws is skipped as a rejection is.
496
561
  let call: Promise<ModuleContextInjection[]>;
@@ -516,7 +581,6 @@ export class ModuleRegistry {
516
581
  if (unreadable) {
517
582
  throw new TypeError(`gatherContext answered with a system injection in namespace ${logValue(unreadable.namespace)} whose content isn't an array of objects`);
518
583
  }
519
- this.noteBlocksNotShown(module.name, r);
520
584
  results[slot] = [...r];
521
585
  }),
522
586
  new Promise<void>((_, rej) => {
@@ -536,6 +600,19 @@ export class ModuleRegistry {
536
600
  }
537
601
 
538
602
  await Promise.all(promises);
603
+ for (const { slot, moduleName, registration } of asked) {
604
+ if (this.moduleContexts.get(moduleName) === registration) {
605
+ this.noteBlocksNotShown(moduleName, results[slot]);
606
+ continue;
607
+ }
608
+ if (results[slot].length > 0) {
609
+ console.error(
610
+ `[framework] module ${moduleName} stopped before its gatherContext answer for ${agentName} was used, ` +
611
+ `so its ${results[slot].length} injection(s) were left out`,
612
+ );
613
+ }
614
+ results[slot] = [];
615
+ }
539
616
  return results.flat();
540
617
  }
541
618
 
@@ -565,7 +642,8 @@ export class ModuleRegistry {
565
642
  * Tell modules an agent's tool batch completed (see
566
643
  * Module.onToolBatchComplete). Hooks run in parallel, each awaited up to
567
644
  * `timeoutMs`; a hook that throws or times out is logged and skipped, so a
568
- * wedged module can't hold the round. Returns the modules that didn't
645
+ * wedged module can't hold the round. A module whose `stop()` completes
646
+ * before its hook is called isn't called. Returns the modules that didn't
569
647
  * finish, for the caller's trace.
570
648
  */
571
649
  async notifyToolBatchComplete(
@@ -583,10 +661,14 @@ export class ModuleRegistry {
583
661
  failures.push({ module: module.name, error: 'still running from an earlier batch' });
584
662
  continue;
585
663
  }
664
+ const registration = this.moduleContexts.get(module.name);
586
665
  // Called from a promise, so a hook that throws synchronously, before
587
666
  // returning its promise, is caught below like any other failure
588
667
  // instead of escaping the round (the result would never be provided).
589
- const call = Promise.resolve().then(() => module.onToolBatchComplete!(agentName));
668
+ const call = Promise.resolve().then(() => {
669
+ if (this.moduleContexts.get(module.name) !== registration) return;
670
+ return module.onToolBatchComplete!(agentName);
671
+ });
590
672
  this.batchHooksRunning.set(module.name, call);
591
673
  const settled = (): void => {
592
674
  if (this.batchHooksRunning.get(module.name) === call) this.batchHooksRunning.delete(module.name);
@@ -623,10 +705,11 @@ export class ModuleRegistry {
623
705
  // Find handlers that match this agent
624
706
  const handlers = this.speechHandlers.filter(
625
707
  (h) => h.agents === '*' || h.agents.includes(agentName)
626
- );
708
+ ).map((handler) => ({ handler, registration: this.moduleContexts.get(handler.moduleName) }));
627
709
 
628
710
  // Call each handler
629
- for (const handler of handlers) {
711
+ for (const { handler, registration } of handlers) {
712
+ if (this.moduleContexts.get(handler.moduleName) !== registration) continue;
630
713
  const module = this.modules.get(handler.moduleName);
631
714
  if (module?.onAgentSpeech) {
632
715
  try {
@@ -669,6 +752,10 @@ class ModuleContextImpl implements ModuleContext {
669
752
  // External ID mapping stored in module state
670
753
  private externalIdMap: Map<string, MessageId> = new Map();
671
754
 
755
+ private retired = false;
756
+ private readonly saidRetired = new Set<string>();
757
+ private readonly traceSubscriptions = new Set<() => void>();
758
+
672
759
  constructor(
673
760
  moduleName: string,
674
761
  store: JsStore,
@@ -690,7 +777,12 @@ class ModuleContextImpl implements ModuleContext {
690
777
  this.store = store;
691
778
  this.stateId = stateId;
692
779
  // The deprecated handle: a push names this module, as pushEvent() does.
693
- this.queue = { push: (event) => queue.push(event, moduleName) };
780
+ this.queue = {
781
+ push: (event) => {
782
+ if (this.retired) this.dropPush(event);
783
+ else queue.push(event, moduleName);
784
+ },
785
+ };
694
786
  this.registry = registry;
695
787
  this.isRestart = isRestart;
696
788
  this.getAgentsFn = getAgents;
@@ -710,12 +802,38 @@ class ModuleContextImpl implements ModuleContext {
710
802
  }
711
803
  }
712
804
 
805
+ retire(): void {
806
+ this.retired = true;
807
+ const subscriptions = [...this.traceSubscriptions];
808
+ this.traceSubscriptions.clear();
809
+ for (const unsubscribe of subscriptions) {
810
+ try {
811
+ unsubscribe();
812
+ } catch (error) {
813
+ console.error(`[framework] module ${JSON.stringify(this.moduleName)} has stopped, and ending one of its trace subscriptions threw: ${boundedErrorMessage(error)}`);
814
+ }
815
+ }
816
+ }
817
+
818
+ private refused(method: string): boolean {
819
+ if (!this.retired) return false;
820
+ if (!this.saidRetired.has(method)) {
821
+ this.saidRetired.add(method);
822
+ console.error(
823
+ `[framework] module ${JSON.stringify(this.moduleName)} has stopped, so its ${method}() call had no effect; ` +
824
+ `later ${method}() calls from it aren't logged`,
825
+ );
826
+ }
827
+ return true;
828
+ }
829
+
713
830
  getState<T>(): T | null {
714
831
  const state = this.store.getStateJson(this.stateId);
715
832
  return state as T | null;
716
833
  }
717
834
 
718
835
  setState<T>(state: T): void {
836
+ if (this.refused('setState')) return;
719
837
  // Merge external ID map into state
720
838
  const fullState = {
721
839
  ...(state as object),
@@ -758,6 +876,7 @@ class ModuleContextImpl implements ModuleContext {
758
876
  name: string,
759
877
  opts?: { deltaSnapshotEvery?: number; fullSnapshotEvery?: number }
760
878
  ): void {
879
+ if (this.refused('registerLogState')) return;
761
880
  ensureStateRegistered(this.store, {
762
881
  id: this.logStateId(name),
763
882
  strategy: 'append_log',
@@ -768,10 +887,12 @@ class ModuleContextImpl implements ModuleContext {
768
887
  }
769
888
 
770
889
  appendToLog<T>(name: string, item: T): void {
890
+ if (this.refused('appendToLog')) return;
771
891
  this.store.appendToStateJson(this.registeredLogStateId(name), item);
772
892
  }
773
893
 
774
894
  editLogItem<T>(name: string, index: number, item: T): void {
895
+ if (this.refused('editLogItem')) return;
775
896
  this.store.editStateItem(this.registeredLogStateId(name), index, Buffer.from(JSON.stringify(item)));
776
897
  }
777
898
 
@@ -785,18 +906,34 @@ class ModuleContextImpl implements ModuleContext {
785
906
  }
786
907
 
787
908
  getModule<T extends Module>(name: string): T | null {
909
+ if (this.refused('getModule')) return null;
788
910
  return this.registry.getModule<T>(name);
789
911
  }
790
912
 
791
913
  onTrace(listener: TraceEventListener): () => void {
792
- return this.onTraceFn(listener);
914
+ if (this.refused('onTrace')) return () => {};
915
+ const unsubscribe = this.onTraceFn((event) => {
916
+ if (!this.retired) listener(event);
917
+ });
918
+ this.traceSubscriptions.add(unsubscribe);
919
+ return () => {
920
+ if (this.traceSubscriptions.delete(unsubscribe)) unsubscribe();
921
+ };
793
922
  }
794
923
 
795
924
  callTool(call: ToolCall): Promise<ToolResult> {
925
+ if (this.refused('callTool')) {
926
+ return Promise.resolve({
927
+ success: false,
928
+ isError: true,
929
+ error: `module ${JSON.stringify(this.moduleName)} has stopped, so its call of ${call.name} wasn't made`,
930
+ });
931
+ }
796
932
  return this.callToolFn(call);
797
933
  }
798
934
 
799
935
  notifyOps(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void {
936
+ if (this.refused('notifyOps')) return;
800
937
  this.registry.notifyOpsFn?.(kind, agentName, message, data);
801
938
  }
802
939
 
@@ -806,6 +943,7 @@ class ModuleContextImpl implements ModuleContext {
806
943
  metadata?: MessageMetadata & { external?: ExternalIdRef },
807
944
  opts?: { forAgent?: string },
808
945
  ): MessageId {
946
+ if (this.refused('addMessage')) return '';
809
947
  // The external id map is read in the primary's store
810
948
  // (findMessageByExternalId), so a mapping into another window would
811
949
  // answer wrong.
@@ -830,10 +968,12 @@ class ModuleContextImpl implements ModuleContext {
830
968
  }
831
969
 
832
970
  editMessage(id: MessageId, content: ContentBlock[]): void {
971
+ if (this.refused('editMessage')) return;
833
972
  this.editMessageFn(id, content);
834
973
  }
835
974
 
836
975
  removeMessage(id: MessageId): void {
976
+ if (this.refused('removeMessage')) return;
837
977
  this.removeMessageFn(id);
838
978
  // Clean up external ID mapping
839
979
  for (const [key, msgId] of this.externalIdMap) {
@@ -850,26 +990,31 @@ class ModuleContextImpl implements ModuleContext {
850
990
  const entry = this.externalIdMap.get(key);
851
991
  if (entry === undefined) return null;
852
992
  // A stored id answers while the store holds its row, and once the row is
853
- // gone the entry goes, as removeMessage drops it. The '' a held write
854
- // keeps here (addMessage) names no row, so the message is looked up in
855
- // the store by its external id, held rows included, and the first row
856
- // found is taken, as context-manager's findByExternalId takes it: a held
857
- // row answers by its handle and leaves the entry as it is, and a stored
858
- // row's id becomes the entry. With nothing found, '' stays, as earlier
859
- // releases kept it: in a module's start(), the writes the last process
860
- // held don't show yet, since create() restores them after the modules
861
- // start, and a write that shows later finds its entry.
993
+ // gone the entry goes, as removeMessage drops it, except from a context
994
+ // whose module has stopped, which keeps its mapping as it was. The '' a held
995
+ // write keeps here (addMessage) names no row, so the message is looked up in
996
+ // the store by its external id, held rows included, and the first row found
997
+ // is taken, as context-manager's findByExternalId takes it: a held row
998
+ // answers by its handle and leaves the entry as it is, and a stored row's id
999
+ // becomes the entry. With nothing found, '' stays, as earlier releases kept
1000
+ // it: in a module's start(), the writes the last process held don't show
1001
+ // yet, since create() restores them after the modules start, and a write
1002
+ // that shows later finds its entry.
862
1003
  if (entry !== '') {
863
1004
  if (this.getMessageFn(entry)) return entry;
864
- this.externalIdMap.delete(key);
865
- this.persistExternalIdMap();
1005
+ if (!this.retired) {
1006
+ this.externalIdMap.delete(key);
1007
+ this.persistExternalIdMap();
1008
+ }
866
1009
  return null;
867
1010
  }
868
1011
  const row = this.queryMessagesFn({ source, externalIds: [externalId] }).messages[0];
869
1012
  if (row?.held) return row.id;
870
1013
  if (!row) return null;
871
- this.externalIdMap.set(key, row.id);
872
- this.persistExternalIdMap();
1014
+ if (!this.retired) {
1015
+ this.externalIdMap.set(key, row.id);
1016
+ this.persistExternalIdMap();
1017
+ }
873
1018
  return row.id;
874
1019
  }
875
1020
 
@@ -893,15 +1038,23 @@ class ModuleContextImpl implements ModuleContext {
893
1038
  agents: '*' | string[],
894
1039
  options?: SpeechHandlerOptions
895
1040
  ): void {
1041
+ if (this.refused('registerSpeechHandler')) return;
896
1042
  this.registry.registerSpeechHandler(this.moduleName, agents, options);
897
1043
  }
898
1044
 
899
1045
  unregisterSpeechHandler(): void {
1046
+ if (this.refused('unregisterSpeechHandler')) return;
900
1047
  this.registry.unregisterSpeechHandler(this.moduleName);
901
1048
  }
902
1049
 
903
1050
  pushEvent(event: ProcessEvent): void {
904
- this.pushEventFn(event, this.moduleName);
1051
+ if (this.retired) this.dropPush(event);
1052
+ else this.pushEventFn(event, this.moduleName);
1053
+ }
1054
+
1055
+ private dropPush(event: ProcessEvent): void {
1056
+ if (this.registry.dropRetiredPushFn) this.registry.dropRetiredPushFn(event, this.moduleName);
1057
+ else console.error(retiredPushLine(event, this.moduleName));
905
1058
  }
906
1059
 
907
1060
  private persistExternalIdMap(): void {
@@ -2,24 +2,18 @@
2
2
  * Discord.js client adapter implementing DiscordClientInterface
3
3
  */
4
4
 
5
- import {
6
- ChannelType,
5
+ import { createRequire } from 'node:module';
6
+ import type {
7
7
  Client,
8
8
  DiscordAPIError,
9
- DiscordjsError,
10
- DiscordjsErrorCodes,
11
- DiscordjsTypeError,
12
- GatewayIntentBits,
13
- Partials,
14
- RESTJSONErrorCodes,
15
9
  TextChannel,
16
10
  DMChannel,
17
- type Channel,
18
- type Message,
19
- type Attachment,
20
- type User,
21
- type Guild,
22
- type GuildBasedChannel,
11
+ Channel,
12
+ Message,
13
+ Attachment,
14
+ User,
15
+ Guild,
16
+ GuildBasedChannel,
23
17
  } from 'discord.js';
24
18
  import type {
25
19
  DiscordClientInterface,
@@ -31,6 +25,12 @@ import type {
31
25
  } from './types.js';
32
26
  import { DiscordRefusal } from './refusal.js';
33
27
 
28
+ const requireHere = createRequire(import.meta.url);
29
+
30
+ function discordJs(): typeof import('discord.js') {
31
+ return requireHere('discord.js') as typeof import('discord.js');
32
+ }
33
+
34
34
  /** The ids a tool's call named, for the words of Discord's refusal. */
35
35
  interface Named {
36
36
  channelId?: string;
@@ -55,6 +55,7 @@ function target(named: Named): string {
55
55
  * `overrides` words a code as one tool means it.
56
56
  */
57
57
  function refusalReason(error: DiscordAPIError, named: Named, overrides: Partial<Record<number, string>> = {}): string {
58
+ const { RESTJSONErrorCodes } = discordJs();
58
59
  const override = typeof error.code === 'number' ? overrides[error.code] : undefined;
59
60
  if (override !== undefined) return override;
60
61
  switch (error.code) {
@@ -103,6 +104,7 @@ function unknownEmoji(named: Named): string {
103
104
  * emoji it can't read. Any other error has no words here.
104
105
  */
105
106
  function localRefusalReason(error: unknown, named: Named): string | undefined {
107
+ const { DiscordjsError, DiscordjsTypeError, DiscordjsErrorCodes } = discordJs();
106
108
  if (!(error instanceof DiscordjsError) && !(error instanceof DiscordjsTypeError)) return undefined;
107
109
  switch ((error as { code?: unknown }).code) {
108
110
  case DiscordjsErrorCodes.MessageThreadParent:
@@ -149,15 +151,16 @@ export class DiscordJsClient implements DiscordClientInterface {
149
151
  this.guildIds = config.guildIds;
150
152
  this.channelIds = config.channelIds;
151
153
 
152
- this.client = new Client({
154
+ const djs = discordJs();
155
+ this.client = new djs.Client({
153
156
  intents: [
154
- GatewayIntentBits.Guilds,
155
- GatewayIntentBits.GuildMessages,
156
- GatewayIntentBits.MessageContent,
157
- GatewayIntentBits.DirectMessages,
158
- GatewayIntentBits.GuildMessageReactions,
157
+ djs.GatewayIntentBits.Guilds,
158
+ djs.GatewayIntentBits.GuildMessages,
159
+ djs.GatewayIntentBits.MessageContent,
160
+ djs.GatewayIntentBits.DirectMessages,
161
+ djs.GatewayIntentBits.GuildMessageReactions,
159
162
  ],
160
- partials: [Partials.Channel, Partials.Message],
163
+ partials: [djs.Partials.Channel, djs.Partials.Message],
161
164
  });
162
165
 
163
166
  this.setupEventHandlers();
@@ -301,17 +304,18 @@ export class DiscordJsClient implements DiscordClientInterface {
301
304
  await message.startThread({ name });
302
305
  return undefined;
303
306
  } catch (error) {
304
- if (error instanceof DiscordAPIError) {
307
+ const djs = discordJs();
308
+ if (error instanceof djs.DiscordAPIError) {
305
309
  // Discord answers a retried start that it had already carried out as
306
310
  // a thread that exists: say it's there.
307
- if (error.code === RESTJSONErrorCodes.ThreadAlreadyCreatedForMessage) return `${sent}, and its thread exists already, with the message's own id, ${message.id}.`;
311
+ if (error.code === djs.RESTJSONErrorCodes.ThreadAlreadyCreatedForMessage) return `${sent}, and its thread exists already, with the message's own id, ${message.id}.`;
308
312
  // The message is out, and `named` holds it, so no sentence points
309
313
  // elsewhere, where posting it again would post it twice.
310
314
  // The bot has just posted in the channel, so Missing Access is about
311
315
  // its threads, not about seeing it.
312
316
  const permission = `this bot isn't allowed to start threads in channel ${channelId} (Discord answered "Missing Permissions"), and calling again won't change that. Ask whoever runs this host to grant the permission.`;
313
317
  const access = `this bot can't start threads in channel ${channelId} (Discord answered "Missing Access"), and calling again won't change that. Ask whoever runs this host to give it access.`;
314
- return `${sent}, but no thread was started: ${refusalReason(error, named, { [RESTJSONErrorCodes.MissingPermissions]: permission, [RESTJSONErrorCodes.MissingAccess]: access })} Once that's settled, ${startOne} starts one.`;
318
+ return `${sent}, but no thread was started: ${refusalReason(error, named, { [djs.RESTJSONErrorCodes.MissingPermissions]: permission, [djs.RESTJSONErrorCodes.MissingAccess]: access })} Once that's settled, ${startOne} starts one.`;
315
319
  }
316
320
  // The thread refusals localRefusalReason words, a message outside a
317
321
  // text or announcement channel and one that has a thread, can't come
@@ -359,7 +363,7 @@ export class DiscordJsClient implements DiscordClientInterface {
359
363
  } catch (error) {
360
364
  // A message that isn't there may be one an earlier call deleted, or a
361
365
  // retried request of this one, so its sentence has no lead.
362
- if (error instanceof DiscordRefusal && error.cause instanceof DiscordAPIError && error.cause.code === RESTJSONErrorCodes.UnknownMessage) {
366
+ if (error instanceof DiscordRefusal && error.cause instanceof discordJs().DiscordAPIError && error.cause.code === discordJs().RESTJSONErrorCodes.UnknownMessage) {
363
367
  throw new DiscordRefusal(`Channel ${channelId} has no message ${messageId} now: it's deleted, or the id is wrong. discord--fetch_history with that channelId shows its recent messages and their ids.`, { cause: error.cause });
364
368
  }
365
369
  throw error;
@@ -393,7 +397,7 @@ export class DiscordJsClient implements DiscordClientInterface {
393
397
  } catch (error) {
394
398
  // Discord's answer for a message that has a thread, whether an earlier
395
399
  // call started it or a retried request of this one did.
396
- if (error instanceof DiscordRefusal && error.cause instanceof DiscordAPIError && error.cause.code === RESTJSONErrorCodes.ThreadAlreadyCreatedForMessage) {
400
+ if (error instanceof DiscordRefusal && error.cause instanceof discordJs().DiscordAPIError && error.cause.code === discordJs().RESTJSONErrorCodes.ThreadAlreadyCreatedForMessage) {
397
401
  throw new DiscordRefusal(`Message ${messageId} has a thread already, whose id is the message's own: discord--send with channelId ${messageId} posts in it.`, { cause: error.cause });
398
402
  }
399
403
  throw error;
@@ -427,7 +431,7 @@ export class DiscordJsClient implements DiscordClientInterface {
427
431
  try {
428
432
  return await run();
429
433
  } catch (error) {
430
- if (error instanceof DiscordAPIError || error instanceof DiscordRefusal) throw error;
434
+ if (error instanceof discordJs().DiscordAPIError || error instanceof DiscordRefusal) throw error;
431
435
  throw new DiscordRefusal(`${lead}: looking up ${what} stopped on an unexpected error: ${error instanceof Error ? error.message : String(error)}. Calling it again is safe.`, { cause: error });
432
436
  }
433
437
  }
@@ -443,7 +447,7 @@ export class DiscordJsClient implements DiscordClientInterface {
443
447
  try {
444
448
  return await run();
445
449
  } catch (error) {
446
- if (error instanceof DiscordAPIError) throw new DiscordRefusal(`${lead}: ${refusalReason(error, named)}`, { cause: error });
450
+ if (error instanceof discordJs().DiscordAPIError) throw new DiscordRefusal(`${lead}: ${refusalReason(error, named)}`, { cause: error });
447
451
  const local = localRefusalReason(error, named);
448
452
  if (local !== undefined) throw new DiscordRefusal(`${lead}: ${local}`, { cause: error });
449
453
  throw error;
@@ -471,7 +475,7 @@ export class DiscordJsClient implements DiscordClientInterface {
471
475
  const inPosts = (kind: string) => `${kind}, whose messages are in its posts, each a thread with its own id, which this module has no tool to list`;
472
476
  const is = type === 'category' ? 'a category, which holds channels, not messages'
473
477
  : type === 'forum' ? inPosts('a forum')
474
- : channel.type === ChannelType.GuildMedia ? inPosts('a media channel')
478
+ : channel.type === discordJs().ChannelType.GuildMedia ? inPosts('a media channel')
475
479
  : type === 'unknown' ? 'a kind of channel that holds no messages'
476
480
  : `a ${type} channel, which holds no messages`;
477
481
  return `channel ${channelId} is ${is}. discord--list_channels with a guildId shows the server's channels and their ids.`;
@@ -1,9 +1,12 @@
1
+ import { markedText } from '../supplied-text.js';
2
+
1
3
  /** The participant a module stores for a sender's name from outside the
2
- * framework: on one line, and marked with its source, so it isn't an
3
- * agent's name unless a host names an agent in that form. The generic
4
- * `user` stays as given, since context-manager places injections by the
5
- * last message whose participant is `user`, in any capitalization. */
4
+ * framework: on one line, with each listed notice opening marked as in
5
+ * supplied text, and marked with its source, so it isn't an agent's name
6
+ * unless a host names an agent in that form. The generic `user` stays as
7
+ * given, since context-manager places injections by the last message whose
8
+ * participant is `user`, in any capitalization. */
6
9
  export function outsideSenderName(name: string, source: string): string {
7
- const line = name.replace(/[\p{Cc}\u2028\u2029]+/gu, ' ');
10
+ const line = markedText(name.replace(/[\p{Cc}\u2028\u2029]+/gu, ' '));
8
11
  return line.toLowerCase() === 'user' ? line : `${line} (${source})`;
9
12
  }