@animalabs/agent-framework 0.5.7 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/src/agent.d.ts +4 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +15 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +243 -0
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1378 -114
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +64 -2
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +212 -12
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  14. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script-worker.js +69 -0
  16. package/dist/src/gate/gate-script-worker.js.map +1 -0
  17. package/dist/src/gate/gate-script.d.ts +44 -0
  18. package/dist/src/gate/gate-script.d.ts.map +1 -0
  19. package/dist/src/gate/gate-script.js +164 -0
  20. package/dist/src/gate/gate-script.js.map +1 -0
  21. package/dist/src/gate/types.d.ts +18 -3
  22. package/dist/src/gate/types.d.ts.map +1 -1
  23. package/dist/src/index.d.ts +3 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +4 -0
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/mcpl/channel-registry.d.ts +60 -2
  28. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  29. package/dist/src/mcpl/channel-registry.js +114 -9
  30. package/dist/src/mcpl/channel-registry.js.map +1 -1
  31. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  32. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  33. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  34. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  35. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  36. package/dist/src/mcpl/push-handler.js +5 -0
  37. package/dist/src/mcpl/push-handler.js.map +1 -1
  38. package/dist/src/mcpl/server-connection.d.ts +64 -30
  39. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  40. package/dist/src/mcpl/server-connection.js +247 -210
  41. package/dist/src/mcpl/server-connection.js.map +1 -1
  42. package/dist/src/mcpl/transport.d.ts +98 -0
  43. package/dist/src/mcpl/transport.d.ts.map +1 -0
  44. package/dist/src/mcpl/transport.js +281 -0
  45. package/dist/src/mcpl/transport.js.map +1 -0
  46. package/dist/src/mcpl/types.d.ts +59 -3
  47. package/dist/src/mcpl/types.d.ts.map +1 -1
  48. package/dist/src/mcpl/types.js.map +1 -1
  49. package/dist/src/module-registry.d.ts +17 -1
  50. package/dist/src/module-registry.d.ts.map +1 -1
  51. package/dist/src/module-registry.js +101 -6
  52. package/dist/src/module-registry.js.map +1 -1
  53. package/dist/src/prose-segments.d.ts +18 -0
  54. package/dist/src/prose-segments.d.ts.map +1 -0
  55. package/dist/src/prose-segments.js +39 -0
  56. package/dist/src/prose-segments.js.map +1 -0
  57. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  58. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  59. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  60. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  61. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  62. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  63. package/dist/src/runtime/liveness-watchdog.js +106 -0
  64. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +47 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +14 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/types/module.d.ts +58 -0
  70. package/dist/src/types/module.d.ts.map +1 -1
  71. package/dist/src/types/trace.d.ts +42 -0
  72. package/dist/src/types/trace.d.ts.map +1 -1
  73. package/dist/test/agent-cache-ttl.test.d.ts +2 -0
  74. package/dist/test/agent-cache-ttl.test.d.ts.map +1 -0
  75. package/dist/test/agent-cache-ttl.test.js +25 -0
  76. package/dist/test/agent-cache-ttl.test.js.map +1 -0
  77. package/dist/test/channel-registry-routing.test.js +108 -1
  78. package/dist/test/channel-registry-routing.test.js.map +1 -1
  79. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  80. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  81. package/dist/test/checkpoint-attribution.test.js +40 -0
  82. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  83. package/dist/test/conversation-fork-injection-scoping.test.d.ts +2 -0
  84. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +1 -0
  85. package/dist/test/conversation-fork-injection-scoping.test.js +184 -0
  86. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -0
  87. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  88. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  89. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  90. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  91. package/dist/test/event-gate-script.test.d.ts +2 -0
  92. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  93. package/dist/test/event-gate-script.test.js +95 -0
  94. package/dist/test/event-gate-script.test.js.map +1 -0
  95. package/dist/test/event-gate-tags.test.d.ts +2 -0
  96. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  97. package/dist/test/event-gate-tags.test.js +76 -0
  98. package/dist/test/event-gate-tags.test.js.map +1 -0
  99. package/dist/test/gather-context-timeout.test.d.ts +2 -0
  100. package/dist/test/gather-context-timeout.test.d.ts.map +1 -0
  101. package/dist/test/gather-context-timeout.test.js +120 -0
  102. package/dist/test/gather-context-timeout.test.js.map +1 -0
  103. package/dist/test/inference-exhaustion-rewind.test.d.ts +2 -0
  104. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +1 -0
  105. package/dist/test/inference-exhaustion-rewind.test.js +181 -0
  106. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -0
  107. package/dist/test/inference-failure-observability.test.js +4 -0
  108. package/dist/test/inference-failure-observability.test.js.map +1 -1
  109. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  110. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  111. package/dist/test/liveness-watchdog.test.js +78 -0
  112. package/dist/test/liveness-watchdog.test.js.map +1 -0
  113. package/dist/test/mcpl-reconnect-events.test.d.ts +2 -0
  114. package/dist/test/mcpl-reconnect-events.test.d.ts.map +1 -0
  115. package/dist/test/mcpl-reconnect-events.test.js +119 -0
  116. package/dist/test/mcpl-reconnect-events.test.js.map +1 -0
  117. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +2 -0
  118. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +1 -0
  119. package/dist/test/mcpl-reconnect-reregistration.test.js +181 -0
  120. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -0
  121. package/dist/test/mcpl-request-timeout.test.d.ts +2 -0
  122. package/dist/test/mcpl-request-timeout.test.d.ts.map +1 -0
  123. package/dist/test/mcpl-request-timeout.test.js +123 -0
  124. package/dist/test/mcpl-request-timeout.test.js.map +1 -0
  125. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +7 -0
  126. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +1 -0
  127. package/dist/test/mcpl-runtime-lifecycle.test.js +142 -0
  128. package/dist/test/mcpl-runtime-lifecycle.test.js.map +1 -0
  129. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  130. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  131. package/dist/test/mcpl-ws-transport.test.js +155 -0
  132. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  133. package/dist/test/prose-segments.test.d.ts +2 -0
  134. package/dist/test/prose-segments.test.d.ts.map +1 -0
  135. package/dist/test/prose-segments.test.js +52 -0
  136. package/dist/test/prose-segments.test.js.map +1 -0
  137. package/dist/test/refusal-rewind.test.d.ts +2 -0
  138. package/dist/test/refusal-rewind.test.d.ts.map +1 -0
  139. package/dist/test/refusal-rewind.test.js +108 -0
  140. package/dist/test/refusal-rewind.test.js.map +1 -0
  141. package/dist/test/state-scaling-e2e.test.d.ts +22 -0
  142. package/dist/test/state-scaling-e2e.test.d.ts.map +1 -0
  143. package/dist/test/state-scaling-e2e.test.js +200 -0
  144. package/dist/test/state-scaling-e2e.test.js.map +1 -0
  145. package/dist/test/state-scaling.test.d.ts +12 -0
  146. package/dist/test/state-scaling.test.d.ts.map +1 -0
  147. package/dist/test/state-scaling.test.js +243 -0
  148. package/dist/test/state-scaling.test.js.map +1 -0
  149. package/dist/test/tool-result-content.test.d.ts +2 -0
  150. package/dist/test/tool-result-content.test.d.ts.map +1 -0
  151. package/dist/test/tool-result-content.test.js +37 -0
  152. package/dist/test/tool-result-content.test.js.map +1 -0
  153. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  154. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  155. package/dist/test/trunk-channel-routing.test.js +175 -0
  156. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  157. package/dist/tsconfig.tsbuildinfo +1 -1
  158. package/package.json +4 -3
  159. package/src/agent.ts +16 -0
  160. package/src/framework.ts +1576 -121
  161. package/src/gate/event-gate.ts +209 -12
  162. package/src/gate/gate-script-worker.ts +73 -0
  163. package/src/gate/gate-script.ts +180 -0
  164. package/src/gate/types.ts +19 -3
  165. package/src/index.ts +10 -0
  166. package/src/mcpl/channel-registry.ts +143 -8
  167. package/src/mcpl/checkpoint-manager.ts +16 -0
  168. package/src/mcpl/push-handler.ts +5 -0
  169. package/src/mcpl/server-connection.ts +262 -227
  170. package/src/mcpl/transport.ts +307 -0
  171. package/src/mcpl/types.ts +68 -3
  172. package/src/module-registry.ts +126 -6
  173. package/src/prose-segments.ts +42 -0
  174. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  175. package/src/runtime/liveness-watchdog.ts +122 -0
  176. package/src/types/agent.ts +48 -0
  177. package/src/types/framework.ts +15 -0
  178. package/src/types/module.ts +65 -0
  179. package/src/types/trace.ts +51 -0
package/src/framework.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
3
  import { JsStore } from '@animalabs/chronicle';
3
4
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
4
5
  import { MembraneError } from '@animalabs/membrane';
@@ -42,7 +43,7 @@ import type {
42
43
  } from './types/index.js';
43
44
  import { ProcessQueueImpl } from './queue.js';
44
45
  import { Agent } from './agent.js';
45
- import { ModuleRegistry } from './module-registry.js';
46
+ import { ModuleRegistry, isStateExistsError } from './module-registry.js';
46
47
  import { McplServerRegistry } from './mcpl/server-registry.js';
47
48
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
48
49
  import { ScopeManager } from './mcpl/scope-manager.js';
@@ -53,6 +54,7 @@ import { ChannelRegistry } from './mcpl/channel-registry.js';
53
54
  import { ConversationRouter } from './mcpl/conversation-router.js';
54
55
  import { safeSlice } from './safe-slice.js';
55
56
  import { toolResultDataToHistoryString } from './tool-result-history.js';
57
+ import { splitProseSegments } from './prose-segments.js';
56
58
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
57
59
  import { isToolAllowed } from './mcpl/tool-policy.js';
58
60
  import { EventGate } from './gate/event-gate.js';
@@ -79,7 +81,8 @@ const FRAMEWORK_STATE_ID = 'framework/state';
79
81
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
80
82
  const INFERENCE_LOG_ID = 'framework/inference-log';
81
83
  const PROCESS_LOG_ID = 'framework/process-log';
82
- const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
84
+ const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints'; // legacy single-map layout, read-only fallback
85
+ const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
83
86
 
84
87
  /** Maximum number of turn checkpoints to keep per agent. */
85
88
  const MAX_TURN_CHECKPOINTS = 20;
@@ -197,10 +200,34 @@ interface HostCommandParams {
197
200
  fromMessageId?: string;
198
201
  /** For the `hide` command: Discord message id ending an inclusive range. */
199
202
  toMessageId?: string;
203
+ /** For the `unstick` command: max rewind/retry attempts (default = the
204
+ * agent's refusalHandling.maxRewinds, else 3). */
205
+ maxRewinds?: number;
206
+ /** For the `unstick` command: raw channel id to post the outcome report to. */
207
+ channelId?: string;
200
208
  requesterId?: string;
201
209
  requesterName?: string;
202
210
  }
203
211
 
212
+ /** Descriptor of a single refusal-driven rewind (see rewindTriggeringTurn). */
213
+ interface RewindRecord {
214
+ /** tool = machine tool exchange; human = an ingested message; other = else. */
215
+ kind: 'tool' | 'human' | 'other';
216
+ /** Content-free, safe-to-replay description of what was withheld. */
217
+ descriptor: string;
218
+ /** All message ids removed in this shed (a tool exchange removes 2+). */
219
+ removedIds: MessageId[];
220
+ /** Discord (channelId, messageId) of the removed message, if it had one. */
221
+ discordRef?: { channelId: string; messageId: string };
222
+ }
223
+
224
+ /** Cap an API error message for inline use in a rewind marker: keep enough to
225
+ * identify the failure class without pasting a wall of provider JSON into the
226
+ * agent's context. */
227
+ function truncateReason(reason: string, max = 160): string {
228
+ return reason.length <= max ? reason : reason.slice(0, max) + '…';
229
+ }
230
+
204
231
  export class AgentFramework {
205
232
  private store: JsStore;
206
233
  private ownsStore: boolean;
@@ -211,11 +238,25 @@ export class AgentFramework {
211
238
  private inferencePolicy: InferencePolicy;
212
239
  private errorPolicy: ErrorPolicy;
213
240
  private pendingRequests: InferenceRequest[] = [];
241
+ /**
242
+ * Per-agent channel that triggered the agent's CURRENT inference turn, if any
243
+ * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
244
+ * route a single-trunk agent's plain-text speech back to the channel it is
245
+ * answering, instead of the process-global most-recent-inbound locus that a
246
+ * concurrent message elsewhere can hijack. Set at turn start (or cleared for a
247
+ * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
248
+ * turn. Never read between turns (a given agent runs one turn at a time).
249
+ */
250
+ private activeTriggerChannels: Map<string, string> = new Map();
214
251
  private running = false;
215
252
  private loopPromise: Promise<void> | null = null;
216
253
  private traceListeners: TraceEventListener[] = [];
217
254
  private syncIntervalMs: number;
218
255
  private syncTimer: ReturnType<typeof setInterval> | null = null;
256
+ /** Last time we console-warned about stale (busy-requeued) inference requests, per agent. */
257
+ private staleWarnAt = new Map<string, number>();
258
+ /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
259
+ private lastInferenceAt = new Map<string, { startedAt?: number; endedAt?: number; failedAt?: number; lastError?: string }>();
219
260
  private processLoggingPersist: boolean;
220
261
  private processLoggingBroadcast: boolean;
221
262
  private activeStreams: Map<string, Promise<void>> = new Map();
@@ -226,6 +267,28 @@ export class AgentFramework {
226
267
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
227
268
  * escalated loudly to stderr. */
228
269
  private readonly inferenceFailureEscalationThreshold = 3;
270
+ /** Per-agent count of consecutive refusal-driven rewinds in the current turn
271
+ * chain (reset when a turn completes without a refusal). Bounds the auto
272
+ * rewind loop — see refusalHandling + rewindTriggeringTurn. */
273
+ private refusalRewinds: Map<string, number> = new Map();
274
+ /** Per-agent count of poison-history rewinds performed by the hard-down
275
+ * breaker (noteInferenceExhausted). Reset on any successful inference.
276
+ * Bounds the automatic quarantine loop the same way refusalRewinds bounds
277
+ * the refusal loop, so the breaker can never shed the whole history. */
278
+ private exhaustionRewinds: Map<string, number> = new Map();
279
+ /** Per-agent current rewind episode: the single consolidated marker's id and
280
+ * how many turns have been shed so far. One marker per episode, updated in
281
+ * place (see updateRewindMarker); cleared when the episode ends. */
282
+ private rewindEpisode: Map<string, { markerId: MessageId; count: number; category: string }> = new Map();
283
+ /** Active `/unstick` sessions: an admin-forced rewind-until-clean loop that
284
+ * runs even when the agent's autoRewind toggle is off. Tracks the remaining
285
+ * budget, what was shed (for the report), and where to post the outcome. */
286
+ private forcedRewind: Map<string, {
287
+ remaining: number;
288
+ removed: RewindRecord[];
289
+ serverId: string;
290
+ channelId: string;
291
+ }> = new Map();
229
292
  /** Name of the primary (non-ephemeral) agent for routing framework messages. */
230
293
  private primaryAgentName: string | null = null;
231
294
 
@@ -237,6 +300,9 @@ export class AgentFramework {
237
300
  private turnCounters: Map<string, number> = new Map(); // agentName → next turnIndex
238
301
  private redoStacks: Map<string, RedoEntry[]> = new Map(); // agentName → redo entries
239
302
 
303
+ /** Liveness watchdog (fail hard on a wedged main thread). Null unless enabled. */
304
+ private livenessWatchdog: import('./runtime/liveness-watchdog.js').LivenessWatchdog | null = null;
305
+
240
306
  // MCPL subsystems (null when no mcplServers configured)
241
307
  private mcplServerRegistry: McplServerRegistry | null = null;
242
308
  private featureSetManager: FeatureSetManager | null = null;
@@ -269,6 +335,13 @@ export class AgentFramework {
269
335
  private mcplPrefixMap: Map<string, string> = new Map();
270
336
  /** Maps serverId → McplServerConfig for prefix lookup. */
271
337
  private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
338
+ /** Host capabilities advertised during the MCP handshake — stored so servers
339
+ * can be connected at runtime (connectMcplServer) after initialization. */
340
+ private mcplHostCapabilities: McplHostCapabilities | null = null;
341
+ /** Inference routing policy from FrameworkConfig — stored so the MCPL
342
+ * subsystem can be lazily initialized by connectMcplServer when the
343
+ * framework started with zero configured servers. */
344
+ private mcplInferenceRoutingConfig: import('./mcpl/types.js').InferenceRoutingPolicy | null = null;
272
345
 
273
346
  // EventGate (null when FrameworkConfig.gate is omitted)
274
347
  private eventGate: EventGate | null = null;
@@ -309,6 +382,7 @@ export class AgentFramework {
309
382
  queryMessages: (filter) => this.queryMessages(filter),
310
383
  pushEvent: (event) => this.pushEvent(event),
311
384
  onTrace: (listener) => this.onTrace(listener),
385
+ callTool: (call) => this.executeToolCall(call),
312
386
  });
313
387
  }
314
388
 
@@ -348,10 +422,12 @@ export class AgentFramework {
348
422
  // Already registered
349
423
  }
350
424
 
425
+ // The legacy single-map checkpoint state (TURN_CHECKPOINTS_ID) is no longer
426
+ // registered for new stores — it's read-only fallback data in old ones.
351
427
  try {
352
- store.registerState({ id: TURN_CHECKPOINTS_ID, strategy: 'snapshot' });
353
- } catch {
354
- // Already registered
428
+ store.registerState({ id: TURN_CHECKPOINTS_TREE_ID, strategy: 'tree' });
429
+ } catch (error) {
430
+ if (!isStateExistsError(error)) throw error;
355
431
  }
356
432
 
357
433
  // Process logging config (default: disabled)
@@ -444,6 +520,23 @@ export class AgentFramework {
444
520
  });
445
521
  }
446
522
 
523
+ // Liveness watchdog: fail hard if the main thread wedges (opt-in).
524
+ if (config.watchdog?.enabled) {
525
+ const { LivenessWatchdog } = await import('./runtime/liveness-watchdog.js');
526
+ framework.livenessWatchdog = new LivenessWatchdog({
527
+ enabled: true,
528
+ thresholdMs: config.watchdog.thresholdMs,
529
+ action: config.watchdog.action,
530
+ reportPath: config.watchdog.reportPath
531
+ ?? (config.storePath ? join(config.storePath, 'watchdog-wedge.jsonl') : undefined),
532
+ });
533
+ framework.livenessWatchdog.start();
534
+ }
535
+
536
+ // Stored for lazy MCPL initialization (connectMcplServer on a framework
537
+ // that started with zero configured servers).
538
+ framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null;
539
+
447
540
  // Initialize MCPL subsystems if configured
448
541
  if (config.mcplServers && config.mcplServers.length > 0) {
449
542
  // Validate tool prefixes: no collisions with module names or between servers
@@ -468,6 +561,31 @@ export class AgentFramework {
468
561
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
469
562
  }
470
563
 
564
+ // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
565
+ // (journal) without a restart — for catching the wake-wedge on the running
566
+ // process. Shows the gate's `inferring` set + buffered-event count (the
567
+ // wedge signature), active streams, and pending inference requests.
568
+ try {
569
+ process.on('SIGUSR2', () => {
570
+ try {
571
+ const gate = framework.eventGate?.inferenceDiagnostics() ?? null;
572
+ console.error(
573
+ '[diagnostics] ' + JSON.stringify({
574
+ at: new Date().toISOString(),
575
+ gate,
576
+ activeStreams: [...framework.activeStreams.keys()],
577
+ pendingRequests: framework.pendingRequests.length,
578
+ agents: [...framework.agents.keys()],
579
+ }),
580
+ );
581
+ } catch (err) {
582
+ console.error('[diagnostics] dump failed:', err instanceof Error ? err.message : err);
583
+ }
584
+ });
585
+ } catch {
586
+ // SIGUSR2 not available on this platform — non-fatal.
587
+ }
588
+
471
589
  return framework;
472
590
  }
473
591
 
@@ -529,6 +647,7 @@ export class AgentFramework {
529
647
 
530
648
  // Dispose EventGate (clear debounce timers)
531
649
  this.eventGate?.dispose();
650
+ this.livenessWatchdog?.stop();
532
651
 
533
652
  // Stop modules and MCPL servers in parallel
534
653
  const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll()];
@@ -658,7 +777,12 @@ export class AgentFramework {
658
777
  const moduleTools = this.moduleRegistry.getAllTools();
659
778
  const channelTools = this.channelRegistry?.getChannelTools() ?? [];
660
779
  const gateTools = this.eventGate
661
- ? [this.eventGate.getToolDefinition(), ...AgentFramework.SLEEP_TOOLS]
780
+ ? [
781
+ this.eventGate.getToolDefinition(),
782
+ ...AgentFramework.SLEEP_TOOLS,
783
+ ...AgentFramework.WAKE_RULE_TOOLS,
784
+ AgentFramework.EVENT_TAGS_TOOL,
785
+ ]
662
786
  : [];
663
787
  if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
664
788
  return moduleTools;
@@ -715,8 +839,9 @@ export class AgentFramework {
715
839
  // Module gatherContext (fail-open, matches startAgentStream)
716
840
  try {
717
841
  const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
718
- if (moduleInjections.length > 0) {
719
- injections = moduleInjections;
842
+ const scopedModuleInjections = this.scopeInjectionsForAgent(agentName, moduleInjections);
843
+ if (scopedModuleInjections.length > 0) {
844
+ injections = scopedModuleInjections;
720
845
  }
721
846
  } catch (error) {
722
847
  console.error('Module gatherContext error (preview):', error);
@@ -727,7 +852,10 @@ export class AgentFramework {
727
852
  if (this.hookOrchestrator) {
728
853
  try {
729
854
  const hookParams = this.buildBeforeInferenceParams(agent);
730
- const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
855
+ const hookInjections = this.scopeInjectionsForAgent(
856
+ agentName,
857
+ await this.hookOrchestrator.beforeInference(hookParams),
858
+ );
731
859
  if (hookInjections.length > 0) {
732
860
  injections = injections ? [...injections, ...hookInjections] : hookInjections;
733
861
  }
@@ -763,6 +891,45 @@ export class AgentFramework {
763
891
  return this.moduleRegistry.getModule(name);
764
892
  }
765
893
 
894
+ // =========================================================================
895
+ // Wake-rule surface (gate policy mutation) — backs wake_add_rule /
896
+ // wake_remove_rule and lets host modules compose higher-level wake modes
897
+ // (e.g. an "every-message-debounced" channel mode) without reaching into
898
+ // the private EventGate.
899
+ // =========================================================================
900
+
901
+ /**
902
+ * Add or replace (upsert) a gate policy at runtime. Validated and persisted
903
+ * to gate.json; hot-applied in memory. Throws if no gate is configured or the
904
+ * policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
905
+ * defer/debounce rules (first match wins).
906
+ */
907
+ addGatePolicy(
908
+ rawPolicy: unknown,
909
+ options?: { position?: 'append' | 'prepend' },
910
+ ): import('./gate/types.js').GatePolicy {
911
+ if (!this.eventGate) {
912
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
913
+ }
914
+ return this.eventGate.addPolicy(rawPolicy, options);
915
+ }
916
+
917
+ /**
918
+ * Remove a gate policy by name at runtime. Returns false if it didn't exist.
919
+ * Throws if no gate is configured.
920
+ */
921
+ removeGatePolicy(name: string): boolean {
922
+ if (!this.eventGate) {
923
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
924
+ }
925
+ return this.eventGate.removePolicy(name);
926
+ }
927
+
928
+ /** Current gate policy names (freshest on-disk view). Empty when no gate. */
929
+ getGatePolicyNames(): string[] {
930
+ return this.eventGate?.listPolicyNames() ?? [];
931
+ }
932
+
766
933
  /**
767
934
  * Get the Membrane instance.
768
935
  */
@@ -878,6 +1045,7 @@ export class AgentFramework {
878
1045
  clearInterval(completionWatchdog);
879
1046
  this.offTrace(traceListener);
880
1047
  this.agents.delete(agent.name);
1048
+ this.evictTurnCheckpoints(agent.name);
881
1049
  };
882
1050
 
883
1051
  // Watchdog: if inference never starts within 30s, the agent is a zombie.
@@ -1037,6 +1205,154 @@ export class AgentFramework {
1037
1205
  },
1038
1206
  ];
1039
1207
 
1208
+ /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
1209
+ * Present when a gate is wired. They write validated policies into the
1210
+ * hot-reloaded gate.json (same validation as load), so a rule takes effect
1211
+ * immediately and survives restart. */
1212
+ private static readonly WAKE_RULE_TOOLS: import('./types/index.js').ToolDefinition[] = [
1213
+ {
1214
+ name: 'wake_add_rule',
1215
+ description:
1216
+ 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
1217
+ 'hand-edit the file. The rule is validated and hot-applied immediately. ' +
1218
+ 'A rule with the same `name` replaces the existing one in place. Use ' +
1219
+ '`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
1220
+ 'rules (first match wins). Two common shapes:\n' +
1221
+ '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
1222
+ '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
1223
+ '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
1224
+ 'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, behavior: { debounce: 60000 } }` ' +
1225
+ '(the channel must be subscribed for ambient events to arrive — see channel mode).',
1226
+ inputSchema: {
1227
+ type: 'object',
1228
+ properties: {
1229
+ name: { type: 'string', description: 'Unique rule name. Reusing a name replaces that rule.' },
1230
+ match: {
1231
+ type: 'object',
1232
+ description: 'Match criteria (all AND together). Omitted fields match anything.',
1233
+ properties: {
1234
+ scope: { type: 'array', items: { type: 'string' }, description: 'Event types, e.g. ["mcpl:channel-incoming","workspace:modified"].' },
1235
+ source: { type: 'string', description: 'Integration/serverId, glob ok (e.g. "discord").' },
1236
+ channel: { type: 'string', description: 'Channel id, glob ok (e.g. "discord:*:12345").' },
1237
+ mount: { type: 'string', description: 'Workspace mount name, glob ok (workspace:* events).' },
1238
+ pathGlob: { type: 'string', description: 'Glob over touched paths (workspace:* events).' },
1239
+ tagsAny: { type: 'array', items: { type: 'string' }, description: 'Match if ANY tag matches (globs ok).' },
1240
+ tagsAll: { type: 'array', items: { type: 'string' }, description: 'Match only if EVERY tag matches.' },
1241
+ tagsNone: { type: 'array', items: { type: 'string' }, description: 'Match only if NONE match.' },
1242
+ metadataTrue: { type: 'array', items: { type: 'string' }, description: 'Match if ANY listed metadata field is truthy.' },
1243
+ filter: {
1244
+ type: 'object',
1245
+ description: 'Content filter.',
1246
+ properties: {
1247
+ type: { type: 'string', enum: ['text', 'regex'] },
1248
+ pattern: { type: 'string' },
1249
+ },
1250
+ required: ['type', 'pattern'],
1251
+ },
1252
+ },
1253
+ },
1254
+ behavior: {
1255
+ type: 'string',
1256
+ enum: ['always', 'defer', 'skip'],
1257
+ description:
1258
+ 'Simple behavior: "always" (wake now) or "defer" (don\'t wake; still enters ' +
1259
+ 'context). For debounce / rate-limit / sampling, use debounceMs / rateLimit / ' +
1260
+ 'passiveSample below instead of this field. Exactly one behavior must be given.',
1261
+ },
1262
+ debounceMs: {
1263
+ type: 'number',
1264
+ description: 'Shorthand for { debounce: ms }: wake once after ms of quiet (100–300000).',
1265
+ },
1266
+ rateLimit: {
1267
+ type: 'object',
1268
+ description: 'Token-bucket wake: at most `tokens` wakes per window; refills one per refillIntervalMs.',
1269
+ properties: {
1270
+ tokens: { type: 'number', description: 'Bucket capacity (> 0).' },
1271
+ refillIntervalMs: { type: 'number', description: 'Ms between token refills (> 0).' },
1272
+ keyBy: { type: 'string', description: 'Metadata field to partition buckets by (e.g. "channelId").' },
1273
+ },
1274
+ required: ['tokens', 'refillIntervalMs'],
1275
+ },
1276
+ passiveSample: {
1277
+ type: 'object',
1278
+ description: 'Wake every Nth matching event.',
1279
+ properties: {
1280
+ every: { type: 'number', description: 'Fire every N matches (positive integer).' },
1281
+ keyBy: { type: 'string', description: 'Metadata field for separate per-key counters.' },
1282
+ },
1283
+ required: ['every'],
1284
+ },
1285
+ position: {
1286
+ type: 'string',
1287
+ enum: ['append', 'prepend'],
1288
+ description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
1289
+ },
1290
+ resets: {
1291
+ type: 'array',
1292
+ items: { type: 'string' },
1293
+ description: 'Names of other rules whose runtime state to clear when this rule fires.',
1294
+ },
1295
+ },
1296
+ required: ['name'],
1297
+ },
1298
+ },
1299
+ {
1300
+ name: 'wake_remove_rule',
1301
+ description:
1302
+ 'Remove a wake rule (gate.json policy) by name at runtime. Any pending ' +
1303
+ 'debounce batch for that rule is delivered first. Returns whether a rule ' +
1304
+ 'was removed (false if no rule had that name).',
1305
+ inputSchema: {
1306
+ type: 'object',
1307
+ properties: {
1308
+ name: { type: 'string', description: 'The rule name to remove.' },
1309
+ },
1310
+ required: ['name'],
1311
+ },
1312
+ },
1313
+ ];
1314
+
1315
+ /** Discoverability for event tags (MCPL RFC-001): the reserved chat:* core,
1316
+ * each connected server's declared tag ontology, and gate.js status. */
1317
+ private static readonly EVENT_TAGS_TOOL: import('./types/index.js').ToolDefinition = {
1318
+ name: 'event_tags',
1319
+ description:
1320
+ 'List the event tags available for gating: the reserved cross-platform ' +
1321
+ 'chat:* core, each connected server\'s declared tag ontology (descriptions, ' +
1322
+ 'implications, suggested treatments), and the status of your programmable ' +
1323
+ 'gate (gate.js). Use these tag names in gate.json policies (tagsAny / ' +
1324
+ 'tagsAll / tagsNone) or in gate.js.',
1325
+ inputSchema: { type: 'object' },
1326
+ };
1327
+
1328
+ /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1329
+ * the agent can author rules without reading the spec. */
1330
+ private static readonly CHAT_CORE_TAGS: Record<string, string> = {
1331
+ 'chat:addressed': 'Directed at you (umbrella: dm/mention/reply)',
1332
+ 'chat:mention': 'You were explicitly @-mentioned',
1333
+ 'chat:reply': 'A reply to your own message',
1334
+ 'chat:dm': 'A direct/private message to you',
1335
+ 'chat:ambient': 'Overheard in a followed channel; not addressed',
1336
+ 'chat:broadcast': 'Channel-wide ping (@everyone / channel post)',
1337
+ 'chat:to-self': 'Acts on your own content (reaction/reply to you)',
1338
+ 'chat:from-human': 'Authored by a human',
1339
+ 'chat:from-bot': 'Authored by a bot/automation',
1340
+ 'chat:from-self': 'Your own message, echoed back',
1341
+ 'chat:from-agent': 'Authored by another persona/agent',
1342
+ 'chat:edited': 'An edit of an existing message',
1343
+ 'chat:deleted': 'A deletion',
1344
+ 'chat:reaction': 'An emoji reaction was added',
1345
+ 'chat:reaction-remove': 'A reaction was removed',
1346
+ 'chat:has-image': 'Has an image attachment',
1347
+ 'chat:has-audio': 'Has an audio attachment',
1348
+ 'chat:has-file': 'Has a file attachment',
1349
+ 'chat:has-link': 'Contains a link',
1350
+ 'chat:command': 'A slash/bot command invocation',
1351
+ 'chat:private': 'Private conversation',
1352
+ 'chat:group': 'Group (multi-party) conversation',
1353
+ 'chat:thread': 'Occurred in a thread',
1354
+ };
1355
+
1040
1356
  /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
1041
1357
  private static readonly REFUSAL_REACTIONS: Record<string, string> = {
1042
1358
  bio: '☣️',
@@ -1088,6 +1404,194 @@ export class AgentFramework {
1088
1404
  }
1089
1405
  }
1090
1406
 
1407
+ /**
1408
+ * Rewind the single turn that fed a refused generation: redact the newest
1409
+ * message the agent did NOT author (a tool result, or an incoming message)
1410
+ * and inject a metadata-only marker in its place. The marker carries a
1411
+ * *description* of what was withheld (kind + size), never the content — so it
1412
+ * cannot itself re-trip the classifier — while the raw record survives in the
1413
+ * chronicle log for forensics. Returns a record describing the removal, or
1414
+ * null if there is nothing eligible to rewind (e.g. only the agent's own
1415
+ * turns, or a prior system marker, remain).
1416
+ *
1417
+ * Shared by the auto path (on `stop_reason: refusal`) and the `/rewind`
1418
+ * host command.
1419
+ */
1420
+ private shedNewestTurn(agent: Agent): RewindRecord | null {
1421
+ const cm = agent.getContextManager();
1422
+ const all = cm.getAllMessages();
1423
+ const typeOf = (b: unknown) => (b as { type?: string }).type;
1424
+ const hasBlock = (m: { content?: unknown } | undefined, t: string) =>
1425
+ Array.isArray(m?.content) && (m!.content as unknown[]).some((b) => typeOf(b) === t);
1426
+
1427
+ // Newest message that is not our own episode marker. We shed strictly
1428
+ // newest-first, in sequence — INCLUDING the agent's own turns (poison lives
1429
+ // in tool_use/narration turns too), because newest-first reaches whatever is
1430
+ // poisoning the context if allowed to run deep enough. The one invariant:
1431
+ // shed COMPLETE exchanges. Removing a `tool_result` also removes its paired
1432
+ // `tool_use` assistant turn, so we never leave an orphaned tool_use / signed
1433
+ // `thinking` block — which the API rejects with a 400 ("thinking blocks in
1434
+ // the latest assistant message cannot be modified"). We do NOT add a marker
1435
+ // here; the caller keeps one consolidated marker (updateRewindMarker).
1436
+ let idx = -1;
1437
+ for (let i = all.length - 1; i >= 0; i--) {
1438
+ const meta = (all[i].metadata ?? {}) as { system?: unknown };
1439
+ if (meta.system) continue;
1440
+ idx = i; break;
1441
+ }
1442
+ if (idx < 0) return null;
1443
+ const msg = all[idx];
1444
+ const md = (msg.metadata ?? {}) as {
1445
+ messageId?: unknown; channelId?: unknown; system?: unknown;
1446
+ };
1447
+
1448
+ const content = Array.isArray(msg.content) ? msg.content : [];
1449
+ const toolBlocks = content.filter((b) => typeOf(b) === 'tool_result');
1450
+ const images = content.filter((b) => typeOf(b) === 'image').length;
1451
+ const textLen = content
1452
+ .filter((b) => typeOf(b) === 'text')
1453
+ .reduce((n, b) => n + String((b as { text?: string }).text ?? '').length, 0);
1454
+
1455
+ // Determine the complete exchange to remove.
1456
+ const removedIds: MessageId[] = [msg.id];
1457
+ let kind: RewindRecord['kind'];
1458
+ let descriptor: string;
1459
+ if (toolBlocks.length > 0) {
1460
+ kind = 'tool';
1461
+ // Pair the tool_result with the tool_use assistant turn right before it.
1462
+ if (idx - 1 >= 0 && hasBlock(all[idx - 1], 'tool_use')) {
1463
+ removedIds.push(all[idx - 1].id);
1464
+ }
1465
+ const sz = toolBlocks.reduce(
1466
+ (n, b) => n + String((b as { content?: unknown }).content ?? '').length, 0);
1467
+ descriptor = `a tool exchange (~${Math.max(1, Math.round(sz / 1024))}KB` +
1468
+ `${images ? `, ${images} image(s)` : ''})`;
1469
+ } else if (md.messageId) {
1470
+ kind = 'human';
1471
+ descriptor = `an incoming message from ${msg.participant} ` +
1472
+ `(${textLen} chars${images ? `, ${images} image(s)` : ''})`;
1473
+ } else {
1474
+ kind = 'other';
1475
+ descriptor = `a ${msg.participant} turn ` +
1476
+ `(${textLen} chars${images ? `, ${images} image(s)` : ''})`;
1477
+ }
1478
+ const discordRef = md.messageId && md.channelId
1479
+ ? { channelId: String(md.channelId), messageId: String(md.messageId) }
1480
+ : undefined;
1481
+
1482
+ for (const id of removedIds) cm.removeMessage(id);
1483
+ return { kind, descriptor, removedIds, discordRef };
1484
+ }
1485
+
1486
+ /**
1487
+ * Maintain exactly ONE consolidated marker for the current rewind episode,
1488
+ * updated in place as more turns are shed. Six rewinds ⇒ one message that
1489
+ * says "the 6 most recent turns were set aside", not six separate notes — so
1490
+ * the context converges (shed N, add 1) instead of growing, and the marker
1491
+ * sits at the tail giving the model something actionable to answer once the
1492
+ * refusal clears. Returns the running count.
1493
+ */
1494
+ private updateRewindMarker(
1495
+ agent: Agent,
1496
+ category: string,
1497
+ cause: 'refusal' | 'inference-failure' = 'refusal',
1498
+ ): number {
1499
+ const cm = agent.getContextManager();
1500
+ const ep = this.rewindEpisode.get(agent.name);
1501
+ const count = (ep?.count ?? 0) + 1;
1502
+ const why = cause === 'refusal'
1503
+ ? `the model refused on them (content filter: ${category}). Their content is ` +
1504
+ `not reproduced here (so this note can't re-trigger the filter); the ` +
1505
+ `originals remain in the raw record`
1506
+ : `the model API kept rejecting the conversation on them (${category}). ` +
1507
+ `Their content is not reproduced here (so this note can't re-trigger the ` +
1508
+ `rejection); the originals remain in the raw record`;
1509
+ const text =
1510
+ `[refusal-rewind] The ${count} most recent turn(s) were set aside because ` +
1511
+ `${why}. You are clear to continue — if you ` +
1512
+ `were mid-task, take a different approach; otherwise carry on with whatever ` +
1513
+ `is now in front of you, or briefly acknowledge the gap and ask what's next.`;
1514
+ const blocks: ContentBlock[] = [{ type: 'text', text }];
1515
+ if (ep) {
1516
+ cm.editMessage(ep.markerId, blocks);
1517
+ this.rewindEpisode.set(agent.name, { markerId: ep.markerId, count, category });
1518
+ } else {
1519
+ const id = cm.addMessage('user', blocks, {
1520
+ system: true, kind: 'refusal-rewind', category, count, cause,
1521
+ });
1522
+ this.rewindEpisode.set(agent.name, { markerId: id, count, category });
1523
+ }
1524
+ return count;
1525
+ }
1526
+
1527
+ /**
1528
+ * Announce a refusal-rewind on the conversational surface (Discord), used
1529
+ * when the withheld turn was a *human* message so it isn't dropped silently.
1530
+ * Best-effort; mirrors reactToRefusal's locus resolution.
1531
+ */
1532
+ private async announceRewind(
1533
+ agentName: string,
1534
+ rec: RewindRecord,
1535
+ category: string,
1536
+ ): Promise<void> {
1537
+ try {
1538
+ const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
1539
+ if (!incoming) return;
1540
+ const parts = incoming.channelId.split(':');
1541
+ if (parts[0] !== 'discord') return;
1542
+ const channelId = parts[parts.length - 1];
1543
+ const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
1544
+ const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
1545
+ if (!server) return;
1546
+ await server.sendToolsCall('send_message', {
1547
+ channelId,
1548
+ content:
1549
+ `⚠️ I had to set aside ${rec.descriptor} — it tripped a content ` +
1550
+ `filter (${category}), so it's withheld from my context and I'm ` +
1551
+ `continuing without it. If it was important, please rephrase or re-send.`,
1552
+ });
1553
+ } catch (err) {
1554
+ console.error(
1555
+ '[refusal-rewind] announce failed:',
1556
+ err instanceof Error ? err.message : err,
1557
+ );
1558
+ }
1559
+ }
1560
+
1561
+ /**
1562
+ * Conclude an active `/unstick` session: post the outcome (what was shed and
1563
+ * whether the model stopped refusing) to the channel the command came from,
1564
+ * then clear the session. Idempotent — a no-op if there's no session.
1565
+ */
1566
+ private finishUnstick(agentName: string, success: boolean, category?: string): void {
1567
+ const s = this.forcedRewind.get(agentName);
1568
+ if (!s) return;
1569
+ this.forcedRewind.delete(agentName);
1570
+ const n = s.removed.length;
1571
+ const list = n
1572
+ ? '\n' + s.removed.map((r) => `• ${r.descriptor}`).join('\n')
1573
+ : '';
1574
+ const content = success
1575
+ ? `🔧 Unstuck **${agentName}** — shed ${n} turn(s); the model responded.${list}`
1576
+ : `⚠️ Couldn't unstick **${agentName}** — still refusing after ${n} rewind(s)` +
1577
+ `${category ? ` (category=${category})` : ''}.${list}`;
1578
+ console.error(
1579
+ `[unstick] agent=${agentName} ${success ? 'succeeded' : 'gave up'} after ${n} rewind(s)`,
1580
+ );
1581
+ if (s.channelId) void this.postToChannel(s.serverId, s.channelId, content);
1582
+ }
1583
+
1584
+ /** Best-effort send_message to a raw channel via a named MCPL server. */
1585
+ private async postToChannel(serverId: string, channelId: string, content: string): Promise<void> {
1586
+ try {
1587
+ const server = this.mcplServerRegistry?.getServer(serverId);
1588
+ if (!server) return;
1589
+ await server.sendToolsCall('send_message', { channelId, content });
1590
+ } catch (err) {
1591
+ console.error('[unstick] report post failed:', err instanceof Error ? err.message : err);
1592
+ }
1593
+ }
1594
+
1091
1595
  /**
1092
1596
  * Handle a `host/command` request from an MCPL surface server (e.g. a
1093
1597
  * Discord slash command). Currently supports:
@@ -1111,8 +1615,12 @@ export class AgentFramework {
1111
1615
  * that carried one — so the surface can mark them with a reaction. */
1112
1616
  hiddenRefs?: Array<{ channelId: string; messageId: string }>;
1113
1617
  lastVisible?: { participant?: string; role?: string; preview?: string } | null;
1618
+ /** For `unstick`: acknowledges the forced-rewind loop has started; the
1619
+ * outcome report is posted to the channel asynchronously. */
1620
+ started?: boolean;
1621
+ cap?: number;
1114
1622
  }> {
1115
- if (params.command !== 'undo' && params.command !== 'hide') {
1623
+ if (params.command !== 'undo' && params.command !== 'hide' && params.command !== 'unstick') {
1116
1624
  return { ok: false, error: `Unknown host command: ${String(params.command)}` };
1117
1625
  }
1118
1626
 
@@ -1121,6 +1629,38 @@ export class AgentFramework {
1121
1629
  return { ok: false, error: `Unknown agent: ${String(agentName)}` };
1122
1630
  }
1123
1631
 
1632
+ // unstick: force the refusal-rewind loop on demand (even if the agent's
1633
+ // autoRewind toggle is off). Redacts the turn that fed the refusal and
1634
+ // re-runs, up to `cap` times, until the model stops refusing. Kicks the
1635
+ // loop and returns immediately; the outcome (what was shed + whether it
1636
+ // cleared) is posted to the channel when the chain resolves.
1637
+ if (params.command === 'unstick') {
1638
+ const agent = this.agents.get(agentName)!;
1639
+ if (agent.state.status !== 'idle') {
1640
+ return { ok: false, error: `Cannot unstick while agent is ${agent.state.status}` };
1641
+ }
1642
+ const cap = Math.max(1, Math.min(10,
1643
+ Math.floor(params.maxRewinds ?? agent.refusalHandling?.maxRewinds ?? 3)));
1644
+ this.forcedRewind.set(agentName, {
1645
+ remaining: cap,
1646
+ removed: [],
1647
+ serverId,
1648
+ channelId: params.channelId ?? '',
1649
+ });
1650
+ this.refusalRewinds.set(agentName, 0);
1651
+ this.pendingRequests.push({
1652
+ agentName,
1653
+ reason: 'unstick',
1654
+ source: 'framework',
1655
+ timestamp: Date.now(),
1656
+ });
1657
+ console.error(
1658
+ `[unstick] agent=${agentName} started cap=${cap} ` +
1659
+ `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1660
+ );
1661
+ return { ok: true, started: true, cap };
1662
+ }
1663
+
1124
1664
  // hide: redact a single message (or an inclusive range) from the active
1125
1665
  // branch, addressed by Discord message id. Unlike undo this is a
1126
1666
  // removal-in-place (chronicle redact), not a branch rewind.
@@ -1738,18 +2278,91 @@ export class AgentFramework {
1738
2278
  this.saveTurnCheckpoints(agentName, checkpoints);
1739
2279
  }
1740
2280
 
2281
+ /**
2282
+ * Checkpoints live in one Tree state (TURN_CHECKPOINTS_TREE_ID) keyed by agent
2283
+ * name, each key pointing at a JSON blob of that agent's ≤MAX_TURN_CHECKPOINTS
2284
+ * list. This kills two unbounded-in-agents-ever dimensions at once:
2285
+ *
2286
+ * - the original single Record<agentName, list> map was rewritten whole on
2287
+ * every turn of every agent and never evicted a departed subagent's key;
2288
+ * - the intermediate design (one state slot per agent) fixed the writes but
2289
+ * leaked a permanent chronicle state *registration* per spawn — chronicle
2290
+ * has no deregistration, and the state index is rewritten and fsynced on
2291
+ * every sync tick, so per-tick cost grew with fleet history anyway.
2292
+ *
2293
+ * A tree gives per-key O(entry) writes, real key removal (treeRemove), and
2294
+ * exactly one registration for the life of the store. The legacy map state
2295
+ * (TURN_CHECKPOINTS_ID) is kept as a read-only fallback for stores written
2296
+ * before the split; eviction tombstones legacy keys so a reused agent name
2297
+ * can't inherit a dead agent's checkpoints through the fallback.
2298
+ */
2299
+ private legacyCheckpointKeys: Set<string> | null = null;
2300
+
2301
+ private hasLegacyCheckpoints(agentName: string): boolean {
2302
+ if (!this.legacyCheckpointKeys) {
2303
+ const legacy = this.store.getStateJson(TURN_CHECKPOINTS_ID);
2304
+ this.legacyCheckpointKeys = new Set(
2305
+ legacy && typeof legacy === 'object' ? Object.keys(legacy) : []
2306
+ );
2307
+ }
2308
+ return this.legacyCheckpointKeys.has(agentName);
2309
+ }
2310
+
1741
2311
  private getTurnCheckpoints(agentName: string): TurnCheckpoint[] {
1742
- const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
1743
- if (!data || typeof data !== 'object') return [];
1744
- const allCheckpoints = data as Record<string, TurnCheckpoint[]>;
1745
- return Array.isArray(allCheckpoints[agentName]) ? [...allCheckpoints[agentName]] : [];
2312
+ const entry = this.store.treeGet(TURN_CHECKPOINTS_TREE_ID, agentName);
2313
+ if (entry) {
2314
+ const blob = this.store.getBlob(entry.blobHash);
2315
+ if (!blob) return [];
2316
+ try {
2317
+ const parsed = JSON.parse(blob.toString());
2318
+ return Array.isArray(parsed) ? parsed : [];
2319
+ } catch {
2320
+ return [];
2321
+ }
2322
+ }
2323
+ if (!this.hasLegacyCheckpoints(agentName)) return [];
2324
+ const legacy = this.store.getStateJson(TURN_CHECKPOINTS_ID) as
2325
+ | Record<string, TurnCheckpoint[]>
2326
+ | null;
2327
+ const list = legacy?.[agentName];
2328
+ return Array.isArray(list) ? [...list] : [];
1746
2329
  }
1747
2330
 
1748
2331
  private saveTurnCheckpoints(agentName: string, checkpoints: TurnCheckpoint[]): void {
1749
- const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
1750
- const allCheckpoints = (data && typeof data === 'object' ? data : {}) as Record<string, TurnCheckpoint[]>;
1751
- allCheckpoints[agentName] = checkpoints;
1752
- this.store.setStateJson(TURN_CHECKPOINTS_ID, allCheckpoints);
2332
+ const bytes = Buffer.from(JSON.stringify(checkpoints));
2333
+ const blobHash = this.store.storeBlob(bytes, 'application/json');
2334
+ this.store.treeSet(TURN_CHECKPOINTS_TREE_ID, agentName, {
2335
+ blobHash,
2336
+ size: bytes.length,
2337
+ mode: 0o644,
2338
+ });
2339
+ }
2340
+
2341
+ /**
2342
+ * Drop a departed agent's checkpoint tree key and turn/redo bookkeeping.
2343
+ * Without this, spawn-and-dispose agents leave a key (and in-memory map
2344
+ * entries) behind for the life of the store/session.
2345
+ */
2346
+ private evictTurnCheckpoints(agentName: string): void {
2347
+ this.turnCounters.delete(agentName);
2348
+ this.redoStacks.delete(agentName);
2349
+ // Diagnostics maps are also keyed by agent name and never evicted —
2350
+ // spawn-and-dispose fleets would grow them for the session's life.
2351
+ this.staleWarnAt.delete(agentName);
2352
+ this.lastInferenceAt.delete(agentName);
2353
+ const entry = this.store.treeGet(TURN_CHECKPOINTS_TREE_ID, agentName);
2354
+ if (this.hasLegacyCheckpoints(agentName)) {
2355
+ // A bare treeRemove would resurrect the legacy map's list through the
2356
+ // fallback next time this name is reused — shadow it with an empty
2357
+ // tombstone instead. size <= 2 ⇔ the blob is already "[]".
2358
+ if (!entry || entry.size > 2) {
2359
+ this.saveTurnCheckpoints(agentName, []);
2360
+ }
2361
+ return;
2362
+ }
2363
+ if (entry) {
2364
+ this.store.treeRemove(TURN_CHECKPOINTS_TREE_ID, agentName);
2365
+ }
1753
2366
  }
1754
2367
 
1755
2368
  /**
@@ -1759,6 +2372,9 @@ export class AgentFramework {
1759
2372
  async runUntilIdle(): Promise<void> {
1760
2373
  while (
1761
2374
  !this.queue.isEmpty ||
2375
+ // Direct inference requests (e.g. runEphemeralToCompletion) bypass the
2376
+ // event queue — without this the loop can exit before they're drained.
2377
+ this.pendingRequests.length > 0 ||
1762
2378
  this.activeStreams.size > 0 ||
1763
2379
  Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')
1764
2380
  ) {
@@ -2115,6 +2731,9 @@ export class AgentFramework {
2115
2731
  reason: 'mcpl:channel-incoming',
2116
2732
  source: event.serverId,
2117
2733
  timestamp: Date.now(),
2734
+ // Route this turn's auto-published speech back to THIS channel, not
2735
+ // the global most-recent-inbound locus (item-3 redux, trunk agents).
2736
+ channelId: event.channelId,
2118
2737
  });
2119
2738
  }
2120
2739
  }
@@ -2209,6 +2828,9 @@ export class AgentFramework {
2209
2828
  reason: 'mcpl:channel-incoming',
2210
2829
  source: event.serverId,
2211
2830
  timestamp: Date.now(),
2831
+ // A fork's home channel wins in routeSpeech regardless, but carry the
2832
+ // triggering channel too so the trunk/active path stays consistent.
2833
+ channelId: event.channelId,
2212
2834
  });
2213
2835
  }
2214
2836
  }
@@ -2285,6 +2907,7 @@ export class AgentFramework {
2285
2907
  this.agents.delete(agentName);
2286
2908
  this.agentConfigs.delete(agentName);
2287
2909
  this.conversationAgentHomes.delete(agentName);
2910
+ this.evictTurnCheckpoints(agentName);
2288
2911
  this.emitTrace({
2289
2912
  type: 'mcpl:conversation-disposed',
2290
2913
  agentName,
@@ -2390,6 +3013,20 @@ export class AgentFramework {
2390
3013
  const id = this.addMessage('user', event.content, metadata);
2391
3014
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
2392
3015
 
3016
+ // Some push events carry a channel of origin (Discord DMs arrive here, not
3017
+ // via channels/incoming, because discord-mcpl forwards them with the channel
3018
+ // closed). Register + open that channel so routeSpeech can resolve it, and
3019
+ // thread it onto the wake request so the reply routes back to it instead of
3020
+ // the global locus (item-3 redux, DM sub-case).
3021
+ const triggerChannel = this.derivePushEventChannel(event.origin);
3022
+ if (triggerChannel && this.channelRegistry) {
3023
+ this.channelRegistry.ensureChannelRegistered(
3024
+ event.serverId,
3025
+ triggerChannel.channelId,
3026
+ triggerChannel.label,
3027
+ );
3028
+ }
3029
+
2393
3030
  if (event.triggerInference) {
2394
3031
  // Default broadcast excludes conversation forks (channel-driven).
2395
3032
  const targetAgents = event.targetAgents
@@ -2400,11 +3037,50 @@ export class AgentFramework {
2400
3037
  reason: 'mcpl:push-event',
2401
3038
  source: event.serverId,
2402
3039
  timestamp: Date.now(),
3040
+ channelId: triggerChannel?.channelId,
2403
3041
  });
2404
3042
  }
2405
3043
  }
2406
3044
  }
2407
3045
 
3046
+ /**
3047
+ * Derive the MCPL composite channel id (the outbound routing locus) for a
3048
+ * push event from its server-defined `origin`, if it carries one.
3049
+ *
3050
+ * Discord DMs are the motivating case: discord-mcpl forwards them via
3051
+ * push/event (the DM channel is closed), so they never pass through
3052
+ * channels/incoming and their channel is never registered — leaving the
3053
+ * agent's reply with nowhere to route (item-3 redux, DM sub-case). Prefers an
3054
+ * explicit `origin.mcplChannelId` (a surface declaring its own composite id —
3055
+ * the surface-agnostic contract); otherwise reconstructs the Discord form
3056
+ * `discord:{guildId|dm}:{channelId}` from origin fields, matching
3057
+ * discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the
3058
+ * fix works even against a discord-mcpl build that predates `mcplChannelId`.
3059
+ * Returns undefined for push events with no channel provenance (heartbeats,
3060
+ * timers), which correctly keep the global fallback.
3061
+ */
3062
+ private derivePushEventChannel(
3063
+ origin: Record<string, unknown> | undefined,
3064
+ ): { channelId: string; label?: string } | undefined {
3065
+ if (!origin) return undefined;
3066
+ const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
3067
+
3068
+ const explicit = origin.mcplChannelId;
3069
+ if (typeof explicit === 'string' && explicit) {
3070
+ return { channelId: explicit, label };
3071
+ }
3072
+
3073
+ // Discord fallback: reconstruct the composite from origin parts. `guildId`
3074
+ // is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
3075
+ if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
3076
+ const guild =
3077
+ typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
3078
+ return { channelId: `discord:${guild}:${origin.channelId}`, label };
3079
+ }
3080
+
3081
+ return undefined;
3082
+ }
3083
+
2408
3084
  private async processInferenceRequests(): Promise<void> {
2409
3085
  if (this.pendingRequests.length === 0) {
2410
3086
  return;
@@ -2438,6 +3114,7 @@ export class AgentFramework {
2438
3114
  requestCount: requests.length,
2439
3115
  oldestRequestAge: now - oldest,
2440
3116
  });
3117
+ console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
2441
3118
  continue;
2442
3119
  }
2443
3120
 
@@ -2453,6 +3130,14 @@ export class AgentFramework {
2453
3130
  requestCount: requests.length,
2454
3131
  oldestRequestAge: now - oldest,
2455
3132
  });
3133
+ // Loud (but throttled) note that requests are waiting on a busy agent.
3134
+ if ((this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
3135
+ this.staleWarnAt.set(agentName, now);
3136
+ console.error(
3137
+ `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
3138
+ `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
3139
+ );
3140
+ }
2456
3141
  }
2457
3142
  this.pendingRequests.push(...requests);
2458
3143
  continue;
@@ -2460,12 +3145,31 @@ export class AgentFramework {
2460
3145
 
2461
3146
  // Check policy
2462
3147
  if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) {
3148
+ // Loud drop: a queued request that dies here is otherwise invisible —
3149
+ // the 2026-07-09 mythos "not responding" diagnosis burned hours on
3150
+ // exactly this class of silent drop. One stderr line per drop.
3151
+ console.error(
3152
+ `[inference-dropped] agent=${agentName} reason=policy-skip ` +
3153
+ `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
3154
+ );
2463
3155
  continue;
2464
3156
  }
2465
3157
 
2466
3158
  // Start streaming inference (non-blocking — driveStream runs in background)
2467
3159
  const trigger = requests[0];
2468
- await this.startAgentStream(agent, trigger);
3160
+ // Route this turn's auto-published speech to the channel that triggered
3161
+ // it (item-3 redux). A batched wake may carry several triggering channels
3162
+ // (messages arrived in >1 channel while the agent was busy/idle) — reply
3163
+ // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
3164
+ // is the message the agent is most likely answering. The common case is
3165
+ // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
3166
+ // channelId, leaving the field undefined → global fallback. `reduce`
3167
+ // keeps the last defined channelId across the (FIFO-ordered) batch.
3168
+ const triggerChannel = requests.reduce<string | undefined>(
3169
+ (acc, r) => r.channelId ?? acc,
3170
+ undefined,
3171
+ );
3172
+ await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
2469
3173
  }
2470
3174
  }
2471
3175
 
@@ -2476,8 +3180,21 @@ export class AgentFramework {
2476
3180
  this.redoStacks.delete(agent.name); // new work invalidates redo
2477
3181
  }
2478
3182
 
3183
+ // Establish this turn's outbound routing locus (item-3 redux). Set it to the
3184
+ // triggering channel for a channel/DM-triggered turn; clear it otherwise so
3185
+ // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
3186
+ // instead falls back to the global default. A given agent runs one turn at a
3187
+ // time, so a set here is only read during THIS turn's routeSpeech /
3188
+ // buildChannelContext; retries re-run with the same trigger, re-setting it.
3189
+ if (trigger?.channelId) {
3190
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
3191
+ } else {
3192
+ this.activeTriggerChannels.delete(agent.name);
3193
+ }
3194
+
2479
3195
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
2480
3196
  this.eventGate?.onInferenceStarted(agent.name);
3197
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
2481
3198
 
2482
3199
  try {
2483
3200
  const allTools = this.getAllTools();
@@ -2487,14 +3204,16 @@ export class AgentFramework {
2487
3204
  // Both produce ContextInjection[] that get merged before inference.
2488
3205
  let injections: ContextInjection[] | undefined;
2489
3206
 
2490
- // Module gatherContext (fail-open, 5s timeout per module)
2491
- // NOTE: module injections are NOT channel-scoped — only the MCPL hook
2492
- // injections below pass through scopeInjectionsForAgent (channel
2493
- // context arrives via beforeInference hooks by adapter convention). A
2494
- // module that starts emitting per-channel context must scope it per
2495
- // agent itself, or conversation forks will see cross-channel content.
3207
+ // Module gatherContext (fail-open, per-module timeout — the module's
3208
+ // contextTimeoutMs, else the registry default). Injections are
3209
+ // channel-scoped via scopeInjectionsForAgent, matching the MCPL hook
3210
+ // injections below, so conversation forks don't see cross-channel
3211
+ // content.
2496
3212
  try {
2497
- const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
3213
+ const moduleInjections = this.scopeInjectionsForAgent(
3214
+ agent.name,
3215
+ await this.moduleRegistry.gatherContext(agent.name),
3216
+ );
2498
3217
  if (moduleInjections.length > 0) {
2499
3218
  injections = moduleInjections;
2500
3219
  }
@@ -2541,6 +3260,13 @@ export class AgentFramework {
2541
3260
  type: 'inference:exhausted',
2542
3261
  agentName: agent.name,
2543
3262
  error: err.message,
3263
+ // Drives the poison-history breaker. `retryable` is kept for
3264
+ // observability, but the breaker gates on `errorType` — only an
3265
+ // `invalid_request` (a 400-class rejection of the history itself)
3266
+ // means retrying the same context can never succeed. auth/abort/
3267
+ // context_length are also non-retryable but must NOT cost history.
3268
+ retryable: err instanceof MembraneError ? err.retryable : undefined,
3269
+ errorType: err instanceof MembraneError ? err.type : undefined,
2544
3270
  });
2545
3271
  this.eventGate?.onInferenceEnded(agent.name);
2546
3272
  if (action.emit) {
@@ -2817,7 +3543,84 @@ export class AgentFramework {
2817
3543
  `[inference-refusal] agent=${agent.name} category=${category}` +
2818
3544
  (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
2819
3545
  );
2820
- void this.reactToRefusal(agent.name, category);
3546
+
3547
+ // Rewind: excise the turn that fed the refusal, drop a
3548
+ // metadata-only marker, and retry — keeping the agent on its own
3549
+ // model instead of substituting a fallback. Driven either by an
3550
+ // admin `/unstick` (forced session) or the agent's autoRewind
3551
+ // config; both are bounded.
3552
+ let handledByRewind = false;
3553
+ const rh = agent.refusalHandling;
3554
+ const forced = this.forcedRewind.get(agent.name);
3555
+ // Shed exactly one more (newest, in-sequence) turn and keep the
3556
+ // single episode marker current. `budgetLeft` bounds the loop.
3557
+ const doRewind = (
3558
+ budgetLeft: boolean,
3559
+ onStep: (count: number) => void,
3560
+ onGiveUp: () => void,
3561
+ ): void => {
3562
+ if (!budgetLeft) { onGiveUp(); return; }
3563
+ const rec = this.shedNewestTurn(agent);
3564
+ if (!rec) { onGiveUp(); return; }
3565
+ const count = this.updateRewindMarker(agent, category);
3566
+ onStep(count);
3567
+ if (forced) forced.removed.push(rec);
3568
+ this.pendingRequests.push({
3569
+ agentName: agent.name,
3570
+ reason: forced ? 'unstick-retry' : 'refusal-rewind-retry',
3571
+ source: 'framework',
3572
+ timestamp: Date.now(),
3573
+ });
3574
+ handledByRewind = true;
3575
+ };
3576
+
3577
+ if (forced) {
3578
+ doRewind(
3579
+ forced.remaining > 0,
3580
+ (count) => {
3581
+ forced.remaining -= 1;
3582
+ console.error(
3583
+ `[unstick] agent=${agent.name} shed ${count} turn(s) ` +
3584
+ `(remaining ${forced.remaining})`,
3585
+ );
3586
+ },
3587
+ () => this.finishUnstick(agent.name, false, category),
3588
+ );
3589
+ } else if (rh?.autoRewind) {
3590
+ const cap = Math.max(1, rh.maxRewinds ?? 3);
3591
+ const used = this.refusalRewinds.get(agent.name) ?? 0;
3592
+ doRewind(
3593
+ used < cap,
3594
+ (count) => {
3595
+ this.refusalRewinds.set(agent.name, used + 1);
3596
+ console.error(
3597
+ `[refusal-rewind] agent=${agent.name} shed ${count} turn(s) so far ` +
3598
+ `(cap ${cap})`,
3599
+ );
3600
+ },
3601
+ () => {
3602
+ console.error(
3603
+ `[refusal-rewind] agent=${agent.name} gave up: ` +
3604
+ `${used >= cap ? `cap ${cap} reached` : 'nothing left to shed'}`,
3605
+ );
3606
+ this.refusalRewinds.set(agent.name, 0);
3607
+ this.rewindEpisode.delete(agent.name);
3608
+ },
3609
+ );
3610
+ }
3611
+
3612
+ if (!handledByRewind) void this.reactToRefusal(agent.name, category);
3613
+ } else {
3614
+ // A turn that completed WITHOUT a refusal ends the rewind episode:
3615
+ // the model responded. Leave the consolidated marker in place as
3616
+ // the durable record; just clear the per-episode counters.
3617
+ if (this.forcedRewind.has(agent.name)) {
3618
+ this.finishUnstick(agent.name, true);
3619
+ }
3620
+ if (this.refusalRewinds.get(agent.name)) {
3621
+ this.refusalRewinds.set(agent.name, 0);
3622
+ }
3623
+ this.rewindEpisode.delete(agent.name);
2821
3624
  }
2822
3625
 
2823
3626
  // Dispatch speech (and thoughts if any)
@@ -2888,23 +3691,44 @@ export class AgentFramework {
2888
3691
  .map((b) => (b as unknown as { name?: string }).name)
2889
3692
  .filter((n): n is string => typeof n === 'string');
2890
3693
  const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
2891
- const t = allText
2892
- .map((b) => (b as ContentBlock & { type: 'text' }).text)
2893
- .join('\n')
2894
- .trim();
2895
- if (silenced || !t) {
3694
+
3695
+ // Split the turn's content into ordered prose segments, broken at
3696
+ // each tool_use / tool_result boundary. `response.content` holds the
3697
+ // WHOLE turn's blocks in provider order — earlier tool rounds
3698
+ // included (see the trailing-slice note above, which slices exactly
3699
+ // because the full turn is present) — so a left-to-right walk
3700
+ // reconstructs the emission order. Interleaved prose
3701
+ // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
3702
+ // separate messages IN ORDER instead of being collapsed into one
3703
+ // trailing post (item 4). The silencing rule stays turn-wide: an
3704
+ // explicit send / skip suppresses ALL auto-routed prose this turn,
3705
+ // preserving the double-post guard.
3706
+ const segments = splitProseSegments(response.content);
3707
+
3708
+ if (silenced || segments.length === 0) {
2896
3709
  console.error(
2897
3710
  `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
2898
3711
  `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
2899
3712
  );
2900
3713
  } else {
3714
+ // Snapshot the locus ONCE. This loop dispatches AFTER the agent
3715
+ // is idle (see PR #32 note below), so a queued inbound from
3716
+ // another channel can start a NEW turn and overwrite the per-agent
3717
+ // triggering channel (`activeTriggerChannels`) BETWEEN segments —
3718
+ // splitting one turn's reply across channels (the residual
3719
+ // multi-segment race left by 83dedf8's per-agent locus). Pin every
3720
+ // segment of THIS turn to the channel resolved right now.
3721
+ const turnLocus = this.channelRegistry.resolveLocus(agent.name);
2901
3722
  console.error(
2902
- `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing trailing prose (${t.length} chars) as reply`,
3723
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order -> ${turnLocus ?? '(default)'}`,
2903
3724
  );
2904
- try {
2905
- await this.channelRegistry.routeSpeech(agent.name, t);
2906
- } catch (err) {
2907
- console.error('speech routing failed:', err);
3725
+ // Deliver sequentially (await each) so the segments land in order.
3726
+ for (const seg of segments) {
3727
+ try {
3728
+ await this.channelRegistry.routeSpeech(agent.name, seg, turnLocus);
3729
+ } catch (err) {
3730
+ console.error('speech routing failed:', err);
3731
+ }
2908
3732
  }
2909
3733
  }
2910
3734
  }
@@ -2950,6 +3774,12 @@ export class AgentFramework {
2950
3774
  type: 'inference:exhausted',
2951
3775
  agentName: agent.name,
2952
3776
  error: err.message,
3777
+ // Drives the poison-history breaker. `retryable` is kept for
3778
+ // observability, but the breaker gates on `errorType` — only an
3779
+ // `invalid_request` (a 400-class rejection of the history
3780
+ // itself) means retrying the same context can never succeed.
3781
+ retryable: err instanceof MembraneError ? err.retryable : undefined,
3782
+ errorType: err instanceof MembraneError ? err.type : undefined,
2953
3783
  });
2954
3784
  this.eventGate?.onInferenceEnded(agent.name);
2955
3785
  if (action.emit) {
@@ -2964,19 +3794,58 @@ export class AgentFramework {
2964
3794
  // Only reset if this is still the active stream (a budget restart
2965
3795
  // may have already started a new stream, bumping streamId)
2966
3796
  if (agent.streamId === myStreamId) {
3797
+ const durationMs = Date.now() - startTime;
2967
3798
  agent.reset();
2968
3799
  this.emitTrace({
2969
3800
  type: 'inference:exhausted',
2970
3801
  agentName: agent.name,
2971
3802
  error: `Stream aborted: ${reason}`,
2972
3803
  });
3804
+ // Postmortem 2026-05-28 P2 #7: persist the abort to the
3805
+ // inference log so future investigations can attribute the
3806
+ // terminal cause without relying on live in-memory reducer
3807
+ // state. Without this, abort-terminated inferences are
3808
+ // invisible to forensic queries (only request-side telemetry
3809
+ // via llm-calls.jsonl shows them, and only by absence).
3810
+ this.logInference({
3811
+ timestamp: startTime,
3812
+ agentName: agent.name,
3813
+ requestId,
3814
+ success: false,
3815
+ error: `Stream aborted: ${reason}`,
3816
+ request: compiledRequest ?? { note: 'streaming request aborted' },
3817
+ durationMs,
3818
+ });
2973
3819
  this.eventGate?.onInferenceEnded(agent.name);
2974
3820
  }
2975
3821
  break;
2976
3822
  }
2977
3823
 
2978
- case 'usage':
3824
+ case 'usage': {
2979
3825
  agent.lastStreamInputTokens = event.usage.inputTokens;
3826
+
3827
+ // Closed-loop estimator calibration (2026-07-12). Sample the REAL
3828
+ // prefix size of THIS API call (fresh + cache write + cache read)
3829
+ // and hand it to the context strategy, which accepts exactly one
3830
+ // sample per compile (its arm-once gate) and rejects out-of-band
3831
+ // ratios. It must be sampled HERE, per call: `response.details.
3832
+ // usage` at turn completion is CUMULATIVE across the tool-use
3833
+ // loop (5 calls x ~160k reported as 884k), which is not a
3834
+ // window-shaped number and drove the multiplier to 2.37 before
3835
+ // the guards caught it.
3836
+ try {
3837
+ const realTotal =
3838
+ (event.usage.inputTokens ?? 0) +
3839
+ (event.usage.cacheCreationTokens ?? 0) +
3840
+ (event.usage.cacheReadTokens ?? 0);
3841
+ const strat = (agent as unknown as {
3842
+ getContextManager?: () => { getStrategy?: () => unknown };
3843
+ }).getContextManager?.()?.getStrategy?.() as
3844
+ | { reportRealInputTokens?: (n: number) => void }
3845
+ | undefined;
3846
+ strat?.reportRealInputTokens?.(realTotal);
3847
+ } catch { /* calibration is best-effort */ }
3848
+
2980
3849
  this.emitTrace({
2981
3850
  type: 'inference:usage',
2982
3851
  agentName: agent.name,
@@ -2988,12 +3857,14 @@ export class AgentFramework {
2988
3857
  },
2989
3858
  });
2990
3859
  break;
3860
+ }
2991
3861
  }
2992
3862
  }
2993
3863
  } catch (error) {
2994
3864
  // Stream itself threw (unexpected) — no retry path here, so also emit
2995
3865
  // inference:exhausted so ephemeral agent promises can settle.
2996
3866
  const err = error instanceof Error ? error : new Error(String(error));
3867
+ const durationMs = Date.now() - startTime;
2997
3868
  this.emitTrace({
2998
3869
  type: 'inference:failed',
2999
3870
  agentName: agent.name,
@@ -3005,9 +3876,31 @@ export class AgentFramework {
3005
3876
  agentName: agent.name,
3006
3877
  error: err.message,
3007
3878
  });
3879
+ // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
3880
+ // threw) were previously only visible via in-memory trace listeners.
3881
+ // Persist to inference log for forensic attribution.
3882
+ this.logInference({
3883
+ timestamp: startTime,
3884
+ agentName: agent.name,
3885
+ requestId,
3886
+ success: false,
3887
+ error: `Stream threw: ${err.message}`,
3888
+ request: compiledRequest ?? { note: 'streaming request threw' },
3889
+ durationMs,
3890
+ });
3008
3891
  agent.reset();
3009
3892
  this.eventGate?.onInferenceEnded(agent.name);
3010
3893
  } finally {
3894
+ // Clear the gate's inference flag on EVERY exit path (paired with the
3895
+ // onInferenceStarted in startAgentStream). The branch-level calls above
3896
+ // are kept but are best-effort; if any exit path bypassed them the agent
3897
+ // stayed stuck in the gate's `inferring` set, which permanently BUFFERS
3898
+ // all incoming events → the agent silently stops waking on messages
3899
+ // (typing still stops, compression still runs — matching the observed
3900
+ // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
3901
+ this.eventGate?.onInferenceEnded(agent.name);
3902
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
3903
+
3011
3904
  // Stop the typing indicator on every exit path (complete, error,
3012
3905
  // exhausted, abort) so it never sticks after the turn ends.
3013
3906
  this.channelRegistry?.stopTyping();
@@ -3097,7 +3990,10 @@ export class AgentFramework {
3097
3990
  if (blocks) {
3098
3991
  return { toolUseId: callId, content: blocks, isError: false };
3099
3992
  }
3100
- let content = JSON.stringify(afResult.data);
3993
+ // JSON.stringify returns the VALUE undefined (not a string) for undefined
3994
+ // input — a module tool returning `{ success: true }` with no data would
3995
+ // otherwise make `content.length` below throw a TypeError mid-turn.
3996
+ let content = JSON.stringify(afResult.data) ?? '';
3101
3997
  if (maxChars && content.length > maxChars) {
3102
3998
  content = safeSlice(content, 0, maxChars)
3103
3999
  + '\n\n[truncated — original was ' + content.length + ' chars]';
@@ -3169,11 +4065,11 @@ export class AgentFramework {
3169
4065
  }
3170
4066
  }
3171
4067
 
3172
- // Append to the inference log state
3173
- const data = this.store.getStateJson(INFERENCE_LOG_ID);
3174
- const entries = Array.isArray(data) ? data : [];
3175
- entries.push(entryToStore);
3176
- this.store.setStateJson(INFERENCE_LOG_ID, entries);
4068
+ // Append one record per inference. A read-modify-write via setStateJson
4069
+ // would emit a whole-array Set — record size then grows with accumulated
4070
+ // history (O(n²) aggregate disk) and the append_log strategy registered
4071
+ // for this state never sees an append.
4072
+ this.store.appendToStateJson(INFERENCE_LOG_ID, entryToStore);
3177
4073
  }
3178
4074
 
3179
4075
  private logProcessEvent(event: ProcessEvent, responses: ModuleProcessResponse[]): void {
@@ -3193,11 +4089,8 @@ export class AgentFramework {
3193
4089
  entryToStore.responses = { blobId };
3194
4090
  }
3195
4091
 
3196
- // Append to the process log state
3197
- const data = this.store.getStateJson(PROCESS_LOG_ID);
3198
- const entries = Array.isArray(data) ? data : [];
3199
- entries.push(entryToStore);
3200
- this.store.setStateJson(PROCESS_LOG_ID, entries);
4092
+ // Append one record per event — same rationale as logInference.
4093
+ this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
3201
4094
  }
3202
4095
 
3203
4096
  /**
@@ -3242,6 +4135,18 @@ export class AgentFramework {
3242
4135
  return;
3243
4136
  }
3244
4137
 
4138
+ // Route wake-rule tools (runtime gate.json policy add/remove)
4139
+ if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
4140
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
4141
+ return;
4142
+ }
4143
+
4144
+ // Route event_tags (tag/ontology discovery)
4145
+ if (enrichedCall.name === 'event_tags' && this.eventGate) {
4146
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
4147
+ return;
4148
+ }
4149
+
3245
4150
  // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
3246
4151
  // stay-silent) tools — handled by the channel registry like the other
3247
4152
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
@@ -3415,14 +4320,20 @@ export class AgentFramework {
3415
4320
  this.noteInferenceExhausted(
3416
4321
  (event.agentName as string) ?? 'unknown',
3417
4322
  (event.error as string) ?? 'unknown error',
4323
+ event.retryable as boolean | undefined,
4324
+ event.errorType as string | undefined,
3418
4325
  );
3419
4326
  } else if (event.type === 'inference:completed') {
3420
4327
  // A successful response — even mid-turn between tool calls — proves the
3421
- // agent isn't hard-down; clear its consecutive-failure streak.
4328
+ // agent isn't hard-down; clear its consecutive-failure streak (and the
4329
+ // poison-history breaker's rewind budget).
3422
4330
  const name = event.agentName as string | undefined;
3423
4331
  if (name && this.consecutiveInferenceFailures.get(name)) {
3424
4332
  this.consecutiveInferenceFailures.set(name, 0);
3425
4333
  }
4334
+ if (name && this.exhaustionRewinds.get(name)) {
4335
+ this.exhaustionRewinds.set(name, 0);
4336
+ }
3426
4337
  }
3427
4338
 
3428
4339
  const traceEvent = {
@@ -3453,17 +4364,63 @@ export class AgentFramework {
3453
4364
  * inference, so this never causes a retry/wake loop.
3454
4365
  * 3. escalation — after N consecutive failures the agent is hard-down;
3455
4366
  * log that loudly (a repeated identical failure is the textbook signal).
4367
+ * 4. breaker — when the failures are an `invalid_request` (a 400-class
4368
+ * rejection of the history itself, e.g. corrupted tool_use/tool_result
4369
+ * pairing or an oversized attachment), retrying the same context can
4370
+ * never succeed: every new push event wakes the agent onto the same
4371
+ * poisoned history forever. At the hard-down threshold, automatically
4372
+ * quarantine: shed the newest complete exchange (the same forced-rewind
4373
+ * primitive `/unstick` uses — shedNewestTurn never orphans a
4374
+ * tool_use/thinking block) and retry, bounded by the same rewind cap so
4375
+ * the breaker can never eat the whole history.
3456
4376
  */
3457
- private noteInferenceExhausted(agentName: string, reason: string): void {
4377
+ /**
4378
+ * Live health snapshot for /healthz and doctor tooling: gate state, queued
4379
+ * work, per-agent status + last inference activity. Cheap and read-only.
4380
+ */
4381
+ healthSnapshot(): Record<string, unknown> {
4382
+ return {
4383
+ at: new Date().toISOString(),
4384
+ uptimeSec: Math.round(process.uptime()),
4385
+ gate: this.eventGate?.inferenceDiagnostics() ?? null,
4386
+ pendingRequests: this.pendingRequests.length,
4387
+ activeStreams: [...this.activeStreams.keys()],
4388
+ agents: [...this.agents.entries()].map(([name, agent]) => ({
4389
+ name,
4390
+ status: agent.state.status,
4391
+ consecutiveInferenceFailures: this.consecutiveInferenceFailures.get(name) ?? 0,
4392
+ lastInference: this.lastInferenceAt.get(name) ?? null,
4393
+ })),
4394
+ };
4395
+ }
4396
+
4397
+ private noteInferenceExhausted(
4398
+ agentName: string,
4399
+ reason: string,
4400
+ retryable?: boolean,
4401
+ errorType?: string,
4402
+ ): void {
3458
4403
  const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
3459
4404
  this.consecutiveInferenceFailures.set(agentName, streak);
4405
+ this.lastInferenceAt.set(agentName, { ...this.lastInferenceAt.get(agentName), failedAt: Date.now(), lastError: reason.slice(0, 300) });
3460
4406
 
3461
4407
  // (1) Durable stderr line — works in headless/daemon mode with no client.
3462
4408
  console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
3463
4409
 
4410
+ // (1b) Machine-greppable durable record, independent of journald/unit log
4411
+ // redirects: logs/failures.log under the host's working directory. This is
4412
+ // what connectome-doctor reads.
4413
+ try {
4414
+ mkdirSync('logs', { recursive: true });
4415
+ appendFileSync(
4416
+ 'logs/failures.log',
4417
+ JSON.stringify({ at: new Date().toISOString(), agent: agentName, consecutive: streak, reason }) + '\n',
4418
+ );
4419
+ } catch { /* best-effort */ }
4420
+
3464
4421
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
3465
4422
  const agent = this.agents.get(agentName);
3466
- if (agent) {
4423
+ if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
3467
4424
  try {
3468
4425
  agent.getContextManager().addMessage(
3469
4426
  'user',
@@ -3489,7 +4446,82 @@ export class AgentFramework {
3489
4446
  `[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
3490
4447
  `inferences — it cannot complete a turn. Last reason: ${reason}`,
3491
4448
  );
4449
+
4450
+ // Optional ops alert: set CONNECTOME_OPS_WEBHOOK to a Discord webhook URL.
4451
+ const hook = process.env.CONNECTOME_OPS_WEBHOOK;
4452
+ if (hook) {
4453
+ fetch(hook, {
4454
+ method: 'POST',
4455
+ headers: { 'content-type': 'application/json' },
4456
+ body: JSON.stringify({
4457
+ content: `\u{1F6A8} **${agentName}** hard-down: ${streak} consecutive inference failures.\nLast reason: ${reason.slice(0, 500)}`,
4458
+ }),
4459
+ }).catch((err) => console.error('[inference-hard-down] webhook post failed:', err instanceof Error ? err.message : err));
4460
+ }
4461
+
4462
+ // (4) Poison-history breaker: ONLY for `invalid_request` — a 400-class
4463
+ // rejection of the history itself. Deliberately NOT keyed on
4464
+ // `retryable === false`, which membrane also returns for auth (expired
4465
+ // key), abort (deliberate cancel), context_length (compression's job,
4466
+ // and shedding newest is the wrong direction), safety and unsupported —
4467
+ // none of which mean the history is poisoned, and all of which would
4468
+ // otherwise shed good exchanges and stamp a false "the API kept
4469
+ // rejecting your history" marker. `retryable` is kept only for the trace.
4470
+ void retryable;
4471
+ if (errorType === 'invalid_request' && agent) {
4472
+ this.quarantinePoisonedHistory(agent, reason);
4473
+ }
4474
+ }
4475
+ }
4476
+
4477
+ /**
4478
+ * Automatic poison-history quarantine (the actual "breaker"). Sheds the
4479
+ * newest complete exchange from the agent's history — reusing the same
4480
+ * primitives as the refusal auto-rewind / `/unstick` (shedNewestTurn +
4481
+ * the single consolidated episode marker) — and queues a retry so the
4482
+ * rewound history is verified immediately instead of waiting for the next
4483
+ * push event to wake the agent onto the same poisoned context.
4484
+ *
4485
+ * Bounded: at most `refusalHandling.maxRewinds` (default 3, hard cap 10)
4486
+ * sheds per failure episode; the budget resets on any successful inference.
4487
+ * At the cap (or with nothing left to shed) it stops — the agent stays up
4488
+ * and hard-down logging continues, but no further history is consumed.
4489
+ */
4490
+ private quarantinePoisonedHistory(agent: Agent, reason: string): void {
4491
+ const cap = Math.max(1, Math.min(10, agent.refusalHandling?.maxRewinds ?? 3));
4492
+ const used = this.exhaustionRewinds.get(agent.name) ?? 0;
4493
+ if (used >= cap) {
4494
+ console.error(
4495
+ `[inference-rewind] agent=${agent.name} rewind cap ${cap} reached — ` +
4496
+ `not shedding further history. Manual repair (/unstick or /undo) needed.`,
4497
+ );
4498
+ return;
4499
+ }
4500
+
4501
+ const rec = this.shedNewestTurn(agent);
4502
+ if (!rec) {
4503
+ console.error(
4504
+ `[inference-rewind] agent=${agent.name} has nothing left to shed — ` +
4505
+ `history is only system markers. Manual repair needed.`,
4506
+ );
4507
+ return;
3492
4508
  }
4509
+
4510
+ this.exhaustionRewinds.set(agent.name, used + 1);
4511
+ const count = this.updateRewindMarker(agent, truncateReason(reason), 'inference-failure');
4512
+ console.error(
4513
+ `[inference-rewind] agent=${agent.name} auto-quarantined ${rec.descriptor} ` +
4514
+ `(${count} turn(s) shed this episode, cap ${cap}) after non-retryable ` +
4515
+ `inference failures — retrying on the rewound history.`,
4516
+ );
4517
+
4518
+ // Retry immediately on the repaired history (bounded by the cap above).
4519
+ this.pendingRequests.push({
4520
+ agentName: agent.name,
4521
+ reason: 'inference-failure-rewind-retry',
4522
+ source: 'framework',
4523
+ timestamp: Date.now(),
4524
+ });
3493
4525
  }
3494
4526
 
3495
4527
  // ==========================================================================
@@ -3562,6 +4594,22 @@ export class AgentFramework {
3562
4594
  }
3563
4595
  },
3564
4596
  shouldTriggerInference: triggerFilter,
4597
+ // Route a conversation fork's plain-text speech to its HOME channel, not
4598
+ // the process-global most-recent-inbound locus (item 3). The trunk agent
4599
+ // has no home entry, so this returns undefined and routeSpeech falls back
4600
+ // to defaultPublishChannel. `conversationAgentHomes` is the permanent
4601
+ // spawn-time binding; `channelForAgent` is the router's live binding as a
4602
+ // belt-and-suspenders fallback.
4603
+ homeChannelResolver: (agentName) =>
4604
+ this.conversationAgentHomes.get(agentName)
4605
+ ?? this.conversationRouter?.channelForAgent(agentName),
4606
+ // Route a single TRUNK agent's plain-text speech to the channel that
4607
+ // triggered its CURRENT turn (item-3 redux). connectome-host runs every
4608
+ // agent as a trunk (it never exposes conversation forks), so without this
4609
+ // a reply falls back to the process-global most-recent-inbound locus and
4610
+ // a concurrent message in another channel hijacks it. Empty for
4611
+ // heartbeat / no-trigger turns → correct global fallback.
4612
+ activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
3565
4613
  // A text-only turn whose speech couldn't be delivered must not vanish
3566
4614
  // silently: record a `[discord-send-failed]` marker in chronicle so the
3567
4615
  // agent sees, on her next turn, that her reply never reached the human.
@@ -3585,8 +4633,9 @@ export class AgentFramework {
3585
4633
  },
3586
4634
  );
3587
4635
 
3588
- // Host capabilities advertised during the MCP handshake
3589
- const hostCapabilities: McplHostCapabilities = {
4636
+ // Host capabilities advertised during the MCP handshake — stored so
4637
+ // servers can also be connected later at runtime (connectMcplServer).
4638
+ this.mcplHostCapabilities = {
3590
4639
  version: '0.4',
3591
4640
  pushEvents: true,
3592
4641
  contextHooks: {
@@ -3598,66 +4647,242 @@ export class AgentFramework {
3598
4647
 
3599
4648
  for (const config of serverConfigs) {
3600
4649
  try {
3601
- // Record per-server channel subscription policy before the server
3602
- // registers channels — handleRegister fires during the handshake.
3603
- if (config.channelSubscription !== undefined && this.channelRegistry) {
3604
- this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
3605
- }
4650
+ await this.connectMcplServerInternal(config);
4651
+ } catch (error) {
4652
+ // Fail-open: log and continue with remaining servers
4653
+ const err = error instanceof Error ? error : new Error(String(error));
4654
+ console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
4655
+ // Reaching this catch means no stub was created, so nothing will
4656
+ // retry — except in the edge case where addServer succeeded (with a
4657
+ // reconnecting stub) and a later setup step threw; config.reconnect
4658
+ // is the best cheap approximation of that.
4659
+ this.emitTrace({
4660
+ type: 'mcpl:server-connect-failed',
4661
+ serverId: config.id,
4662
+ error: err.message,
4663
+ attempt: 0,
4664
+ willRetry: config.reconnect === true,
4665
+ });
4666
+ }
4667
+ }
3606
4668
 
3607
- const connection = await this.mcplServerRegistry.addServer(config, hostCapabilities);
3608
-
3609
- // Wire event listeners then flush any events that arrived during the
3610
- // handshake window (between setupMessageRouting and now).
3611
- this.wireMcplEvents(connection);
3612
- connection.ready();
3613
-
3614
- // Initialize feature sets if server advertises MCPL capabilities
3615
- if (connection.capabilities) {
3616
- const updateParams = this.featureSetManager.initializeServer(
3617
- config.id,
3618
- connection.capabilities,
3619
- {
3620
- enabledFeatureSets: config.enabledFeatureSets,
3621
- disabledFeatureSets: config.disabledFeatureSets,
3622
- },
3623
- );
4669
+ // Discover tools from all connected servers
4670
+ await this.refreshMcplTools();
4671
+ }
3624
4672
 
3625
- // Inform server which feature sets are enabled/disabled
3626
- if (updateParams.enabled?.length || updateParams.disabled?.length) {
3627
- connection.sendFeatureSetsUpdate(updateParams);
3628
- }
4673
+ /**
4674
+ * Connect a single MCPL server: register routing entries, open the
4675
+ * connection, wire events, and initialize feature sets / scopes /
4676
+ * checkpoints. Shared by startup (initializeMcpl) and the runtime
4677
+ * lifecycle API (connectMcplServer). Throws on connection failure.
4678
+ */
4679
+ private async connectMcplServerInternal(
4680
+ config: import('./mcpl/types.js').McplServerConfig,
4681
+ ): Promise<void> {
4682
+ if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
4683
+ throw new Error('MCPL subsystem is not initialized');
4684
+ }
3629
4685
 
3630
- // Configure scope whitelist/blacklist patterns
3631
- if (config.scopes) {
3632
- this.scopeManager.configureAll(config.scopes);
3633
- }
4686
+ // Register prefix + config for tool dispatch routing (idempotent with
4687
+ // the pre-registration pass in initializeMcpl).
4688
+ const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
4689
+ this.mcplPrefixMap.set(prefix, config.id);
4690
+ this.mcplServerConfigs.set(config.id, config);
3634
4691
 
3635
- // Register stateful feature sets with checkpoint manager (Step 8)
3636
- if (this.checkpointManager) {
3637
- const declared = this.featureSetManager.getDeclaredFeatureSets(config.id);
3638
- if (declared) {
3639
- for (const [fsName, fsDecl] of Object.entries(declared)) {
3640
- if (fsDecl.rollback || fsDecl.hostState) {
3641
- this.checkpointManager.registerFeatureSet(config.id, fsName, {
3642
- hostState: fsDecl.hostState ?? false,
3643
- rollback: fsDecl.rollback ?? false,
3644
- });
3645
- }
3646
- }
3647
- }
4692
+ // Record per-server channel subscription policy before the server
4693
+ // registers channels — handleRegister fires during the handshake.
4694
+ if (config.channelSubscription !== undefined && this.channelRegistry) {
4695
+ this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
4696
+ }
4697
+
4698
+ const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
4699
+
4700
+ // Wire event listeners then flush any events that arrived during the
4701
+ // handshake window (between setupMessageRouting and now).
4702
+ this.wireMcplEvents(connection);
4703
+ connection.ready();
4704
+
4705
+ // Initialize feature sets if server advertises MCPL capabilities
4706
+ this.registerMcplServerFeatures(config, connection);
4707
+
4708
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
4709
+ }
4710
+
4711
+ /**
4712
+ * (Re-)establish a server's host-side MCPL registration: feature-set state
4713
+ * (plus the featureSets/update sent to the server), scope patterns, and
4714
+ * stateful-feature-set checkpoint registration.
4715
+ *
4716
+ * Called on initial connect (connectMcplServerInternal) AND after every
4717
+ * auto-reconnect. The 'close' handler drops the feature-set registration, so
4718
+ * without the reconnect re-run FeatureSetManager.validateInbound would throw
4719
+ * "Unknown server" forever — every push event and inference request from the
4720
+ * revived server silently rejected until a full host restart.
4721
+ *
4722
+ * CheckpointManager.registerFeatureSet is idempotent (no-op for a key that
4723
+ * already has a tree), so checkpoint trees preserved across a transient
4724
+ * disconnect are resumed, not reset.
4725
+ */
4726
+ private registerMcplServerFeatures(
4727
+ config: import('./mcpl/types.js').McplServerConfig,
4728
+ connection: McplServerConnection,
4729
+ ): void {
4730
+ if (!connection.capabilities) return;
4731
+
4732
+ const updateParams = this.featureSetManager!.initializeServer(
4733
+ config.id,
4734
+ connection.capabilities,
4735
+ {
4736
+ enabledFeatureSets: config.enabledFeatureSets,
4737
+ disabledFeatureSets: config.disabledFeatureSets,
4738
+ },
4739
+ );
4740
+
4741
+ // Inform server which feature sets are enabled/disabled
4742
+ if (updateParams.enabled?.length || updateParams.disabled?.length) {
4743
+ connection.sendFeatureSetsUpdate(updateParams);
4744
+ }
4745
+
4746
+ // Configure scope whitelist/blacklist patterns
4747
+ if (config.scopes) {
4748
+ this.scopeManager!.configureAll(config.scopes);
4749
+ }
4750
+
4751
+ // Register stateful feature sets with checkpoint manager (Step 8)
4752
+ if (this.checkpointManager) {
4753
+ const declared = this.featureSetManager!.getDeclaredFeatureSets(config.id);
4754
+ if (declared) {
4755
+ for (const [fsName, fsDecl] of Object.entries(declared)) {
4756
+ if (fsDecl.rollback || fsDecl.hostState) {
4757
+ this.checkpointManager.registerFeatureSet(config.id, fsName, {
4758
+ hostState: fsDecl.hostState ?? false,
4759
+ rollback: fsDecl.rollback ?? false,
4760
+ });
3648
4761
  }
3649
4762
  }
3650
-
3651
- this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
3652
- } catch (error) {
3653
- // Fail-open: log and continue with remaining servers
3654
- const err = error instanceof Error ? error : new Error(String(error));
3655
- console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
3656
4763
  }
3657
4764
  }
4765
+ }
3658
4766
 
3659
- // Discover tools from all connected servers
4767
+ // ==========================================================================
4768
+ // Runtime MCPL server lifecycle (agent-facing hot deploy/restart/unload)
4769
+ // ==========================================================================
4770
+
4771
+ /**
4772
+ * Connect a new MCPL server at runtime. Refreshes the tool list and
4773
+ * notifies the agent of newly available tools. Throws if the MCPL
4774
+ * subsystem is not initialized, the id is already connected, or the
4775
+ * connection/handshake fails.
4776
+ */
4777
+ async connectMcplServer(
4778
+ config: import('./mcpl/types.js').McplServerConfig,
4779
+ ): Promise<void> {
4780
+ // Lazily bring up the MCPL subsystem — a framework that started with zero
4781
+ // configured servers can still deploy its first one at runtime.
4782
+ if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
4783
+ await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined);
4784
+ }
4785
+
4786
+ // Same collision rules create() enforces at startup: the tool prefix must
4787
+ // not shadow a module name or another server's prefix.
4788
+ const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
4789
+ if (this.moduleRegistry.getAllModules().some(m => m.name === prefix)) {
4790
+ throw new Error(
4791
+ `MCPL server "${config.id}" toolPrefix "${prefix}" collides with module "${prefix}"`,
4792
+ );
4793
+ }
4794
+ const prefixOwner = this.mcplPrefixMap.get(prefix);
4795
+ if (prefixOwner && prefixOwner !== config.id) {
4796
+ throw new Error(
4797
+ `MCPL server "${config.id}" toolPrefix "${prefix}" collides with server "${prefixOwner}"`,
4798
+ );
4799
+ }
4800
+
4801
+ const oldToolNames = new Set(this.mcplTools.map(t => t.name));
4802
+ await this.connectMcplServerInternal(config);
3660
4803
  await this.refreshMcplTools();
4804
+ this.emitMcplToolDiff(oldToolNames, config.id);
4805
+ }
4806
+
4807
+ /**
4808
+ * Disconnect an MCPL server at runtime: close the connection, destroy its
4809
+ * feature-set and checkpoint state (permanent removal — unlike a transient
4810
+ * transport close, which preserves checkpoints for the reconnect), remove
4811
+ * its channels from the registry, drop routing entries, and refresh tools.
4812
+ * No-op-ish if the server is not connected (still clears routing state).
4813
+ */
4814
+ async disconnectMcplServer(id: string): Promise<void> {
4815
+ if (!this.mcplServerRegistry) {
4816
+ throw new Error('MCPL subsystem is not initialized');
4817
+ }
4818
+ const config = this.mcplServerConfigs.get(id);
4819
+ const oldToolNames = new Set(this.mcplTools.map(t => t.name));
4820
+
4821
+ await this.mcplServerRegistry.removeServer(id);
4822
+ this.channelRegistry?.removeServer(id);
4823
+
4824
+ // Permanent removal: also destroy feature-set and checkpoint state
4825
+ // explicitly. The 'close' handler usually does this, but a connection that
4826
+ // already transiently closed (reconnect pending) emits no second 'close'
4827
+ // from close(), and the transient path deliberately preserves checkpoints.
4828
+ this.featureSetManager?.removeServer(id);
4829
+ this.checkpointManager?.removeServer(id);
4830
+
4831
+ const prefix = config?.toolPrefix ?? `mcpl--${id}`;
4832
+ this.mcplPrefixMap.delete(prefix);
4833
+ this.mcplServerConfigs.delete(id);
4834
+
4835
+ await this.refreshMcplTools();
4836
+ this.emitMcplToolDiff(oldToolNames, id);
4837
+ }
4838
+
4839
+ /**
4840
+ * Restart an MCPL server at runtime: disconnect, then reconnect with the
4841
+ * same (or an updated) config. This actually respawns a stdio child —
4842
+ * unlike `reconnect: true`, which only retries after transport-level
4843
+ * failures. Throws if the server has no stored config and none is given.
4844
+ */
4845
+ async restartMcplServer(
4846
+ id: string,
4847
+ newConfig?: import('./mcpl/types.js').McplServerConfig,
4848
+ ): Promise<void> {
4849
+ const config = newConfig ?? this.mcplServerConfigs.get(id);
4850
+ if (!config) {
4851
+ throw new Error(`MCPL server "${id}" is not configured`);
4852
+ }
4853
+ await this.disconnectMcplServer(id);
4854
+ await this.connectMcplServer(config);
4855
+ }
4856
+
4857
+ /**
4858
+ * List configured MCPL servers with live connection status and the number
4859
+ * of tools each currently contributes.
4860
+ */
4861
+ listMcplServers(): Array<{
4862
+ id: string;
4863
+ connected: boolean;
4864
+ toolPrefix: string;
4865
+ toolCount: number;
4866
+ command?: string;
4867
+ url?: string;
4868
+ }> {
4869
+ const result: Array<{
4870
+ id: string; connected: boolean; toolPrefix: string; toolCount: number;
4871
+ command?: string; url?: string;
4872
+ }> = [];
4873
+ for (const [id, config] of this.mcplServerConfigs) {
4874
+ const prefix = config.toolPrefix ?? `mcpl--${id}`;
4875
+ const connection = this.mcplServerRegistry?.getServer(id) ?? null;
4876
+ result.push({
4877
+ id,
4878
+ connected: connection?.isConnected ?? false,
4879
+ toolPrefix: prefix,
4880
+ toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
4881
+ command: config.command,
4882
+ url: config.url,
4883
+ });
4884
+ }
4885
+ return result;
3661
4886
  }
3662
4887
 
3663
4888
  /**
@@ -3755,15 +4980,105 @@ export class AgentFramework {
3755
4980
  this.handleToolsListChanged(connection.id);
3756
4981
  });
3757
4982
 
3758
- // Also refresh tools on reconnect (server may have different tools)
3759
- connection.on('reconnect', () => {
4983
+ // Re-establish full server registration on reconnect. The 'close' handler
4984
+ // removed the feature-set state, so pushes / inference requests from the
4985
+ // revived server would otherwise be rejected with "Unknown server" until a
4986
+ // host restart. Re-runs the same init as the initial connect path (the
4987
+ // fresh handshake refreshed connection.capabilities); checkpoint trees
4988
+ // preserved across the transient close are resumed by idempotent
4989
+ // registration. Then refresh tools (server may have different tools).
4990
+ connection.on('reconnect', (info?: { attempts?: number }) => {
4991
+ try {
4992
+ const config = this.mcplServerConfigs.get(connection.id);
4993
+ if (config) {
4994
+ this.registerMcplServerFeatures(config, connection);
4995
+ }
4996
+ } catch (error) {
4997
+ console.error(
4998
+ `MCPL server "${connection.id}" re-registration after reconnect failed:`,
4999
+ error instanceof Error ? error.message : error,
5000
+ );
5001
+ }
3760
5002
  this.handleToolsListChanged(connection.id);
5003
+ this.emitTrace({
5004
+ type: 'mcpl:server-reconnected',
5005
+ serverId: connection.id,
5006
+ attempts: info?.attempts ?? 0,
5007
+ });
5008
+ // Mirror the module:removed emitted on 'close' so module-lifecycle
5009
+ // consumers see the server come back, not just vanish.
5010
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
3761
5011
  });
3762
5012
 
3763
- // Cleanup on disconnect
3764
- connection.on('close', () => {
5013
+ // Surface connect/reconnect failures. Before these traces existed the
5014
+ // only receipt was a console.error on the host's own stderr — invisible
5015
+ // unless someone ssh'd in and read the process log.
5016
+ connection.on('connect-failed', (params: { error: string; attempt: number }) => {
5017
+ this.emitTrace({
5018
+ type: 'mcpl:server-connect-failed',
5019
+ serverId: connection.id,
5020
+ error: params.error,
5021
+ attempt: params.attempt,
5022
+ willRetry: connection.willReconnect,
5023
+ });
5024
+ });
5025
+ connection.on('reconnect-failed', (params: { error: string; attempt: number }) => {
5026
+ this.emitTrace({
5027
+ type: 'mcpl:server-connect-failed',
5028
+ serverId: connection.id,
5029
+ error: params.error,
5030
+ attempt: params.attempt,
5031
+ willRetry: connection.willReconnect,
5032
+ });
5033
+ });
5034
+
5035
+ // A late response to a tools/call that already timed out, carrying
5036
+ // stateful data the server advanced to. The host can't re-inject it (the
5037
+ // dispatch context is gone), but surfacing it makes the checkpoint-tree
5038
+ // divergence greppable instead of a silent drift into stale state.
5039
+ connection.on('orphaned-response', (info: {
5040
+ id: string | number;
5041
+ hadState: boolean;
5042
+ hadCheckpoint: boolean;
5043
+ }) => {
5044
+ this.emitTrace({
5045
+ type: 'mcpl:orphaned-response',
5046
+ serverId: connection.id,
5047
+ responseId: info.id,
5048
+ hadState: info.hadState,
5049
+ hadCheckpoint: info.hadCheckpoint,
5050
+ });
5051
+ });
5052
+
5053
+ // Connection-level errors (e.g. child process 'error' after spawn).
5054
+ // Attaching this listener also keeps an unhandled EventEmitter 'error'
5055
+ // from crashing the host process.
5056
+ connection.on('error', (err: Error) => {
5057
+ this.emitTrace({
5058
+ type: 'mcpl:server-error',
5059
+ serverId: connection.id,
5060
+ error: err.message,
5061
+ });
5062
+ });
5063
+
5064
+ // Cleanup on disconnect. Feature-set state is always dropped (the server
5065
+ // can't be validated while down; reconnect re-registers it). Checkpoint
5066
+ // trees, however, are durable state: CheckpointManager.removeServer deletes
5067
+ // them AND persists the deletion to Chronicle. The close handler must
5068
+ // therefore NEVER destroy checkpoints — `willReconnect` is false on a clean
5069
+ // stop() too (reconnectEnabled is cleared before 'close' fires), so gating
5070
+ // on it would erase every checkpoint tree on an ordinary host restart while
5071
+ // a SIGKILL preserves them. Permanent removal is owned solely by
5072
+ // disconnectMcplServer, which deletes the trees explicitly.
5073
+ connection.on('close', (code?: number | null, signal?: string | null) => {
3765
5074
  this.featureSetManager?.removeServer(connection.id);
3766
- this.checkpointManager?.removeServer(connection.id);
5075
+ this.emitTrace({
5076
+ type: 'mcpl:server-closed',
5077
+ serverId: connection.id,
5078
+ code: code ?? null,
5079
+ signal: signal ?? null,
5080
+ willReconnect: connection.willReconnect,
5081
+ });
3767
5082
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
3768
5083
  });
3769
5084
  }
@@ -3894,14 +5209,20 @@ export class AgentFramework {
3894
5209
  const startTime = Date.now();
3895
5210
  const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
3896
5211
 
3897
- // Build state params for stateful tools (Step 8)
5212
+ // Build state params for stateful tools (Step 8).
5213
+ // Host-managed state is single-valued per server, so inject the host-managed
5214
+ // set's `state` for any call; otherwise fall back to a server-managed set's
5215
+ // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
5216
+ // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
5217
+ // that misattributes the feed's state.
3898
5218
  let stateParams: { state?: unknown; checkpoint?: string } | undefined;
3899
5219
  if (this.checkpointManager) {
3900
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3901
- if (fs) {
3902
- if (this.checkpointManager.isHostManaged(serverId, fs)) {
3903
- stateParams = { state: this.checkpointManager.getCurrentState(serverId, fs) };
3904
- } else {
5220
+ const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
5221
+ if (hostFs) {
5222
+ stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
5223
+ } else {
5224
+ const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
5225
+ if (fs) {
3905
5226
  const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
3906
5227
  if (cp) stateParams = { checkpoint: cp };
3907
5228
  }
@@ -3913,10 +5234,17 @@ export class AgentFramework {
3913
5234
  const durationMs = Date.now() - startTime;
3914
5235
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
3915
5236
 
3916
- // Record checkpoint from stateful tool response (Step 8)
5237
+ // Record checkpoint from stateful tool response (Step 8).
5238
+ // Attribute to the set the server tagged (result.state.featureSet) first;
5239
+ // else the host-managed set (host-managed checkpoints carry data/patch);
5240
+ // else the single stateful set. Avoids recording a feed checkpoint onto a
5241
+ // server-managed set just because it registered first.
3917
5242
  if (result.state && this.checkpointManager) {
3918
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3919
- if (fs) {
5243
+ const tagged = result.state.featureSet;
5244
+ const fs = tagged
5245
+ ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
5246
+ ?? this.checkpointManager.getStatefulFeatureSet(serverId);
5247
+ if (fs && this.checkpointManager.isStateful(serverId, fs)) {
3920
5248
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
3921
5249
  }
3922
5250
  }
@@ -4037,6 +5365,91 @@ export class AgentFramework {
4037
5365
  });
4038
5366
  }
4039
5367
 
5368
+ /**
5369
+ * Handle the synthesized `wake_add_rule` / `wake_remove_rule` tools: mutate
5370
+ * the hot-reloaded gate.json at runtime. Validation lives in the EventGate
5371
+ * (same path as gate.json load), so an invalid rule is surfaced as a tool
5372
+ * error and nothing is written.
5373
+ */
5374
+ private dispatchWakeRuleToolCall(agentName: string, call: ToolCall): void {
5375
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
5376
+ const finish = (result: ToolResult) => {
5377
+ this.emitTrace({
5378
+ type: result.isError ? 'tool:failed' : 'tool:completed',
5379
+ module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
5380
+ ...(result.isError ? { error: result.error } : {}),
5381
+ });
5382
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
5383
+ };
5384
+
5385
+ try {
5386
+ if (call.name === 'wake_remove_rule') {
5387
+ const input = (call.input ?? {}) as { name?: unknown };
5388
+ if (typeof input.name !== 'string' || !input.name) {
5389
+ finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
5390
+ return;
5391
+ }
5392
+ const removed = this.removeGatePolicy(input.name);
5393
+ finish({
5394
+ success: true,
5395
+ data: { removed, name: input.name, policies: this.getGatePolicyNames() },
5396
+ });
5397
+ return;
5398
+ }
5399
+
5400
+ // wake_add_rule — assemble the canonical behavior from the typed fields
5401
+ // (exactly one), then let the gate's own validator do the authoritative
5402
+ // range/shape checks.
5403
+ const input = (call.input ?? {}) as {
5404
+ name?: unknown; match?: unknown; resets?: unknown; position?: unknown;
5405
+ behavior?: unknown; debounceMs?: unknown; rateLimit?: unknown; passiveSample?: unknown;
5406
+ };
5407
+ const behaviorSources = [
5408
+ input.behavior !== undefined ? 'behavior' : null,
5409
+ input.debounceMs !== undefined ? 'debounceMs' : null,
5410
+ input.rateLimit !== undefined ? 'rateLimit' : null,
5411
+ input.passiveSample !== undefined ? 'passiveSample' : null,
5412
+ ].filter((s): s is string => s !== null);
5413
+ if (behaviorSources.length === 0) {
5414
+ finish({
5415
+ success: false,
5416
+ error: 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
5417
+ isError: true,
5418
+ });
5419
+ return;
5420
+ }
5421
+ if (behaviorSources.length > 1) {
5422
+ finish({
5423
+ success: false,
5424
+ error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
5425
+ isError: true,
5426
+ });
5427
+ return;
5428
+ }
5429
+ const behavior: unknown =
5430
+ input.debounceMs !== undefined ? { debounce: input.debounceMs }
5431
+ : input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
5432
+ : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
5433
+ : input.behavior;
5434
+
5435
+ const position = input.position === 'prepend' ? 'prepend' : 'append';
5436
+ const rawPolicy = {
5437
+ name: input.name,
5438
+ match: input.match ?? {},
5439
+ behavior,
5440
+ ...(input.resets !== undefined ? { resets: input.resets } : {}),
5441
+ };
5442
+ const policy = this.addGatePolicy(rawPolicy, { position });
5443
+ finish({
5444
+ success: true,
5445
+ data: { added: policy.name, policies: this.getGatePolicyNames() },
5446
+ });
5447
+ } catch (err) {
5448
+ const msg = err instanceof Error ? err.message : String(err);
5449
+ finish({ success: false, error: msg, isError: true });
5450
+ }
5451
+ }
5452
+
4040
5453
  /**
4041
5454
  * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
4042
5455
  * suppression window, optionally announces in the sticky channel, and ends
@@ -4064,7 +5477,7 @@ export class AgentFramework {
4064
5477
  return;
4065
5478
  }
4066
5479
 
4067
- const { until } = gate.setSleep(seconds, input.message);
5480
+ const { until } = gate.setSleep(seconds, input.message, agentName);
4068
5481
  const announce = input.announce !== false; // default true
4069
5482
  const human =
4070
5483
  seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
@@ -4084,6 +5497,42 @@ export class AgentFramework {
4084
5497
  finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
4085
5498
  }
4086
5499
 
5500
+ /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
5501
+ * server's declared tag ontology + gate.js status. */
5502
+ private buildEventTagsResult(): Record<string, unknown> {
5503
+ const servers: Record<string, Record<string, unknown>> = {};
5504
+ for (const conn of this.mcplServerRegistry?.getAllServers() ?? []) {
5505
+ const declared = this.featureSetManager?.getDeclaredFeatureSets(conn.id) ?? {};
5506
+ const sets: Record<string, unknown> = {};
5507
+ for (const [name, decl] of Object.entries(declared)) {
5508
+ if (decl.tagOntology) sets[name] = decl.tagOntology;
5509
+ }
5510
+ if (Object.keys(sets).length > 0) servers[conn.id] = sets;
5511
+ }
5512
+ return {
5513
+ core: AgentFramework.CHAT_CORE_TAGS,
5514
+ servers,
5515
+ gateScript: this.eventGate?.getStatus().script ?? null,
5516
+ hint:
5517
+ 'Use these in gate.json policies (match.tagsAny / tagsAll / tagsNone) or ' +
5518
+ 'in gate.js. Unknown/undeclared tags are tolerated (open ontologies).',
5519
+ };
5520
+ }
5521
+
5522
+ private dispatchEventTagsToolCall(agentName: string, call: ToolCall): void {
5523
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
5524
+ let result: import('./types/events.js').ToolResult;
5525
+ try {
5526
+ result = { success: true, data: this.buildEventTagsResult() };
5527
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
5528
+ } catch (error) {
5529
+ const err = error instanceof Error ? error : new Error(String(error));
5530
+ this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
5531
+ result = { success: false, error: err.message, isError: true };
5532
+ }
5533
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
5534
+ }
5535
+
4087
5536
  private dispatchGateToolCall(agentName: string, call: ToolCall): void {
4088
5537
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4089
5538
  const startTime = Date.now();
@@ -4117,7 +5566,13 @@ export class AgentFramework {
4117
5566
  const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4118
5567
  return {
4119
5568
  inferenceId,
4120
- conversationId: agent.name, // Simplified; proper conversation tracking TODO
5569
+ // Conversation identity = the agent (a trunk agent IS its own conversation;
5570
+ // forks get their own agent). The turn's channel LOCUS — the "proper
5571
+ // conversation tracking" this once flagged as a TODO — is now tracked
5572
+ // per-agent in `activeTriggerChannels` and surfaced to the agent via
5573
+ // buildChannelContext (channels.defaultOutgoing) below, so the agent is
5574
+ // told the same channel its speech will route to (item-3 redux).
5575
+ conversationId: agent.name,
4121
5576
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
4122
5577
  userMessage: null, // Could extract from trigger context
4123
5578
  model: {
@@ -4126,7 +5581,7 @@ export class AgentFramework {
4126
5581
  contextWindow: 200000,
4127
5582
  capabilities: ['tools'],
4128
5583
  },
4129
- channels: this.channelRegistry?.buildChannelContext(),
5584
+ channels: this.channelRegistry?.buildChannelContext(agent.name),
4130
5585
  };
4131
5586
  }
4132
5587
  }