@animalabs/agent-framework 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/dist/src/agent.d.ts +20 -4
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +45 -14
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +220 -5
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1489 -69
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +71 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +292 -9
  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 +35 -3
  22. package/dist/src/gate/types.d.ts.map +1 -1
  23. package/dist/src/index.d.ts +2 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +2 -0
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/mcpl/channel-registry.d.ts +93 -1
  28. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  29. package/dist/src/mcpl/channel-registry.js +260 -6
  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/conversation-router.d.ts +161 -0
  36. package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
  37. package/dist/src/mcpl/conversation-router.js +179 -0
  38. package/dist/src/mcpl/conversation-router.js.map +1 -0
  39. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  40. package/dist/src/mcpl/feature-set-manager.js +10 -1
  41. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  42. package/dist/src/mcpl/index.d.ts +2 -0
  43. package/dist/src/mcpl/index.d.ts.map +1 -1
  44. package/dist/src/mcpl/index.js +2 -0
  45. package/dist/src/mcpl/index.js.map +1 -1
  46. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  47. package/dist/src/mcpl/push-handler.js +1 -0
  48. package/dist/src/mcpl/push-handler.js.map +1 -1
  49. package/dist/src/mcpl/server-connection.d.ts +35 -30
  50. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  51. package/dist/src/mcpl/server-connection.js +149 -206
  52. package/dist/src/mcpl/server-connection.js.map +1 -1
  53. package/dist/src/mcpl/transport.d.ts +98 -0
  54. package/dist/src/mcpl/transport.d.ts.map +1 -0
  55. package/dist/src/mcpl/transport.js +281 -0
  56. package/dist/src/mcpl/transport.js.map +1 -0
  57. package/dist/src/mcpl/types.d.ts +42 -2
  58. package/dist/src/mcpl/types.d.ts.map +1 -1
  59. package/dist/src/mcpl/types.js.map +1 -1
  60. package/dist/src/module-registry.d.ts +5 -1
  61. package/dist/src/module-registry.d.ts.map +1 -1
  62. package/dist/src/module-registry.js +16 -2
  63. package/dist/src/module-registry.js.map +1 -1
  64. package/dist/src/modules/discord/index.d.ts +10 -0
  65. package/dist/src/modules/discord/index.d.ts.map +1 -1
  66. package/dist/src/modules/discord/index.js +83 -0
  67. package/dist/src/modules/discord/index.js.map +1 -1
  68. package/dist/src/prose-segments.d.ts +18 -0
  69. package/dist/src/prose-segments.d.ts.map +1 -0
  70. package/dist/src/prose-segments.js +39 -0
  71. package/dist/src/prose-segments.js.map +1 -0
  72. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  73. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  74. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  75. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  76. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  77. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  78. package/dist/src/runtime/liveness-watchdog.js +106 -0
  79. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  80. package/dist/src/tool-result-history.d.ts +17 -0
  81. package/dist/src/tool-result-history.d.ts.map +1 -0
  82. package/dist/src/tool-result-history.js +60 -0
  83. package/dist/src/tool-result-history.js.map +1 -0
  84. package/dist/src/types/agent.d.ts +21 -0
  85. package/dist/src/types/agent.d.ts.map +1 -1
  86. package/dist/src/types/framework.d.ts +22 -0
  87. package/dist/src/types/framework.d.ts.map +1 -1
  88. package/dist/src/types/module.d.ts +27 -0
  89. package/dist/src/types/module.d.ts.map +1 -1
  90. package/dist/src/types/trace.d.ts +28 -0
  91. package/dist/src/types/trace.d.ts.map +1 -1
  92. package/dist/test/channel-registry-routing.test.d.ts +2 -0
  93. package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
  94. package/dist/test/channel-registry-routing.test.js +182 -0
  95. package/dist/test/channel-registry-routing.test.js.map +1 -0
  96. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  97. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  98. package/dist/test/checkpoint-attribution.test.js +40 -0
  99. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  100. package/dist/test/conversation-router.test.d.ts +2 -0
  101. package/dist/test/conversation-router.test.d.ts.map +1 -0
  102. package/dist/test/conversation-router.test.js +160 -0
  103. package/dist/test/conversation-router.test.js.map +1 -0
  104. package/dist/test/conversation-routing.test.d.ts +7 -0
  105. package/dist/test/conversation-routing.test.d.ts.map +1 -0
  106. package/dist/test/conversation-routing.test.js +203 -0
  107. package/dist/test/conversation-routing.test.js.map +1 -0
  108. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  109. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  110. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  111. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  112. package/dist/test/event-gate-script.test.d.ts +2 -0
  113. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  114. package/dist/test/event-gate-script.test.js +95 -0
  115. package/dist/test/event-gate-script.test.js.map +1 -0
  116. package/dist/test/event-gate-tags.test.d.ts +2 -0
  117. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  118. package/dist/test/event-gate-tags.test.js +76 -0
  119. package/dist/test/event-gate-tags.test.js.map +1 -0
  120. package/dist/test/framework.test.js +73 -1
  121. package/dist/test/framework.test.js.map +1 -1
  122. package/dist/test/helpers/mock-membrane.d.ts +41 -0
  123. package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
  124. package/dist/test/helpers/mock-membrane.js +161 -0
  125. package/dist/test/helpers/mock-membrane.js.map +1 -0
  126. package/dist/test/inference-failure-observability.test.d.ts +2 -0
  127. package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
  128. package/dist/test/inference-failure-observability.test.js +75 -0
  129. package/dist/test/inference-failure-observability.test.js.map +1 -0
  130. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  131. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  132. package/dist/test/liveness-watchdog.test.js +78 -0
  133. package/dist/test/liveness-watchdog.test.js.map +1 -0
  134. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  135. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  136. package/dist/test/mcpl-ws-transport.test.js +155 -0
  137. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  138. package/dist/test/prose-segments.test.d.ts +2 -0
  139. package/dist/test/prose-segments.test.d.ts.map +1 -0
  140. package/dist/test/prose-segments.test.js +52 -0
  141. package/dist/test/prose-segments.test.js.map +1 -0
  142. package/dist/test/tool-result-history.test.d.ts +7 -0
  143. package/dist/test/tool-result-history.test.d.ts.map +1 -0
  144. package/dist/test/tool-result-history.test.js +77 -0
  145. package/dist/test/tool-result-history.test.js.map +1 -0
  146. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  147. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  148. package/dist/test/trunk-channel-routing.test.js +175 -0
  149. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  150. package/dist/tsconfig.tsbuildinfo +1 -1
  151. package/package.json +3 -3
  152. package/src/agent.ts +53 -18
  153. package/src/framework.ts +1685 -71
  154. package/src/gate/event-gate.ts +297 -9
  155. package/src/gate/gate-script-worker.ts +73 -0
  156. package/src/gate/gate-script.ts +180 -0
  157. package/src/gate/types.ts +36 -3
  158. package/src/index.ts +12 -0
  159. package/src/mcpl/channel-registry.ts +331 -6
  160. package/src/mcpl/checkpoint-manager.ts +16 -0
  161. package/src/mcpl/conversation-router.ts +284 -0
  162. package/src/mcpl/feature-set-manager.ts +14 -1
  163. package/src/mcpl/index.ts +12 -0
  164. package/src/mcpl/push-handler.ts +1 -0
  165. package/src/mcpl/server-connection.ts +157 -224
  166. package/src/mcpl/transport.ts +307 -0
  167. package/src/mcpl/types.ts +49 -2
  168. package/src/module-registry.ts +25 -2
  169. package/src/modules/discord/index.ts +93 -0
  170. package/src/prose-segments.ts +42 -0
  171. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  172. package/src/runtime/liveness-watchdog.ts +122 -0
  173. package/src/tool-result-history.ts +57 -0
  174. package/src/types/agent.ts +21 -0
  175. package/src/types/framework.ts +24 -0
  176. package/src/types/module.ts +29 -0
  177. package/src/types/trace.ts +36 -0
@@ -12,12 +12,16 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
12
12
  import { PushHandler } from './mcpl/push-handler.js';
13
13
  import { InferenceRouter } from './mcpl/inference-router.js';
14
14
  import { ChannelRegistry } from './mcpl/channel-registry.js';
15
+ import { ConversationRouter } from './mcpl/conversation-router.js';
15
16
  import { safeSlice } from './safe-slice.js';
17
+ import { toolResultDataToHistoryString } from './tool-result-history.js';
18
+ import { splitProseSegments } from './prose-segments.js';
16
19
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
17
20
  import { isToolAllowed } from './mcpl/tool-policy.js';
18
21
  import { EventGate } from './gate/event-gate.js';
19
22
  import { UsageTracker } from './usage/usage-tracker.js';
20
23
  const FRAMEWORK_STATE_ID = 'framework/state';
24
+ const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
21
25
  const INFERENCE_LOG_ID = 'framework/inference-log';
22
26
  const PROCESS_LOG_ID = 'framework/process-log';
23
27
  const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
@@ -91,9 +95,6 @@ function extractGateFields(event) {
91
95
  : undefined;
92
96
  return { content, metadata, mount, paths };
93
97
  }
94
- /**
95
- * The main agent framework.
96
- */
97
98
  export class AgentFramework {
98
99
  store;
99
100
  ownsStore;
@@ -104,6 +105,16 @@ export class AgentFramework {
104
105
  inferencePolicy;
105
106
  errorPolicy;
106
107
  pendingRequests = [];
108
+ /**
109
+ * Per-agent channel that triggered the agent's CURRENT inference turn, if any
110
+ * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
111
+ * route a single-trunk agent's plain-text speech back to the channel it is
112
+ * answering, instead of the process-global most-recent-inbound locus that a
113
+ * concurrent message elsewhere can hijack. Set at turn start (or cleared for a
114
+ * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
115
+ * turn. Never read between turns (a given agent runs one turn at a time).
116
+ */
117
+ activeTriggerChannels = new Map();
107
118
  running = false;
108
119
  loopPromise = null;
109
120
  traceListeners = [];
@@ -113,6 +124,12 @@ export class AgentFramework {
113
124
  processLoggingBroadcast;
114
125
  activeStreams = new Map();
115
126
  pendingAssistantBlocks = new Map();
127
+ /** Per-agent count of consecutive exhausted inferences (reset on any success).
128
+ * Drives hard-down escalation — see noteInferenceExhausted. */
129
+ consecutiveInferenceFailures = new Map();
130
+ /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
131
+ * escalated loudly to stderr. */
132
+ inferenceFailureEscalationThreshold = 3;
116
133
  /** Name of the primary (non-ephemeral) agent for routing framework messages. */
117
134
  primaryAgentName = null;
118
135
  // Messages deferred while an agent is waiting_for_tools (to preserve
@@ -121,6 +138,8 @@ export class AgentFramework {
121
138
  // Undo/redo state
122
139
  turnCounters = new Map(); // agentName → next turnIndex
123
140
  redoStacks = new Map(); // agentName → redo entries
141
+ /** Liveness watchdog (fail hard on a wedged main thread). Null unless enabled. */
142
+ livenessWatchdog = null;
124
143
  // MCPL subsystems (null when no mcplServers configured)
125
144
  mcplServerRegistry = null;
126
145
  featureSetManager = null;
@@ -130,6 +149,21 @@ export class AgentFramework {
130
149
  inferenceRouter = null;
131
150
  channelRegistry = null;
132
151
  checkpointManager = null;
152
+ /** Per-channel conversation routing (null unless config.conversations set). */
153
+ conversationRouter = null;
154
+ /** Agent configs by name — fork agents are built from the template's config. */
155
+ agentConfigs = new Map();
156
+ /**
157
+ * Fork agent → its home channel. Permanent (unlike router bindings, which
158
+ * expire): publish/injection scoping must survive unbinding so the closure
159
+ * turn still lands in the right channel.
160
+ */
161
+ conversationAgentHomes = new Map();
162
+ /** Last idle-TTL sweep timestamp. */
163
+ lastConversationSweep = 0;
164
+ /** Forks whose TTL closure turn has been queued — disposed (removed from
165
+ * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
166
+ closingConversationAgents = new Set();
133
167
  mcplTools = [];
134
168
  mcplToolRefreshInFlight = false;
135
169
  mcplToolRefreshPending = false;
@@ -163,6 +197,8 @@ export class AgentFramework {
163
197
  getMessage: (id) => this.getMessage(id),
164
198
  queryMessages: (filter) => this.queryMessages(filter),
165
199
  pushEvent: (event) => this.pushEvent(event),
200
+ onTrace: (listener) => this.onTrace(listener),
201
+ callTool: (call) => this.executeToolCall(call),
166
202
  });
167
203
  }
168
204
  /**
@@ -235,6 +271,32 @@ export class AgentFramework {
235
271
  for (const module of config.modules) {
236
272
  await framework.addModule(module);
237
273
  }
274
+ // Initialize per-channel conversation routing (if configured)
275
+ if (config.conversations) {
276
+ if (!framework.agents.has(config.conversations.templateAgent)) {
277
+ throw new Error(`conversations.templateAgent "${config.conversations.templateAgent}" ` +
278
+ `is not a configured agent`);
279
+ }
280
+ framework.conversationRouter = new ConversationRouter(config.conversations);
281
+ // Generation counters persist across restarts — reusing generation 1's
282
+ // agent name after a restart would reopen (and re-seed) the previous
283
+ // engagement's Chronicle namespace.
284
+ try {
285
+ store.registerState({ id: CONVERSATION_ROUTER_STATE_ID, strategy: 'snapshot' });
286
+ }
287
+ catch {
288
+ // Already registered
289
+ }
290
+ try {
291
+ const data = store.getStateJson(CONVERSATION_ROUTER_STATE_ID);
292
+ if (data?.generations) {
293
+ framework.conversationRouter.hydrateGenerations(data.generations);
294
+ }
295
+ }
296
+ catch {
297
+ // No persisted state yet
298
+ }
299
+ }
238
300
  // Initialize EventGate if configured (before MCPL so it can be wired as trigger filter)
239
301
  if (config.gate) {
240
302
  const configPath = config.gate.configPath
@@ -244,6 +306,7 @@ export class AgentFramework {
244
306
  framework.eventGate = new EventGate({
245
307
  configPath,
246
308
  initialConfig: config.gate.config,
309
+ privilegedUsersPath: config.gate.privilegedUsersPath,
247
310
  emitTrace: (e) => framework.emitTrace(e),
248
311
  addMessage: (p, c, m) => framework.addMessage(p, c, m),
249
312
  requestInference: (agentName, reason, source) => {
@@ -252,6 +315,18 @@ export class AgentFramework {
252
315
  getAgentNames: () => [...framework.agents.keys()],
253
316
  });
254
317
  }
318
+ // Liveness watchdog: fail hard if the main thread wedges (opt-in).
319
+ if (config.watchdog?.enabled) {
320
+ const { LivenessWatchdog } = await import('./runtime/liveness-watchdog.js');
321
+ framework.livenessWatchdog = new LivenessWatchdog({
322
+ enabled: true,
323
+ thresholdMs: config.watchdog.thresholdMs,
324
+ action: config.watchdog.action,
325
+ reportPath: config.watchdog.reportPath
326
+ ?? (config.storePath ? join(config.storePath, 'watchdog-wedge.jsonl') : undefined),
327
+ });
328
+ framework.livenessWatchdog.start();
329
+ }
255
330
  // Initialize MCPL subsystems if configured
256
331
  if (config.mcplServers && config.mcplServers.length > 0) {
257
332
  // Validate tool prefixes: no collisions with module names or between servers
@@ -322,6 +397,7 @@ export class AgentFramework {
322
397
  this.channelRegistry?.stopAll();
323
398
  // Dispose EventGate (clear debounce timers)
324
399
  this.eventGate?.dispose();
400
+ this.livenessWatchdog?.stop();
325
401
  // Stop modules and MCPL servers in parallel
326
402
  const shutdownPromises = [this.moduleRegistry.stopAll()];
327
403
  if (this.mcplServerRegistry) {
@@ -351,6 +427,14 @@ export class AgentFramework {
351
427
  */
352
428
  onTrace(listener) {
353
429
  this.traceListeners.push(listener);
430
+ // Return an unsubscribe so callers with a bounded lifetime (e.g. modules
431
+ // that get torn down and recreated on session switch) don't leak
432
+ // listeners. Existing callers that ignore the return value are unaffected.
433
+ return () => {
434
+ const idx = this.traceListeners.indexOf(listener);
435
+ if (idx >= 0)
436
+ this.traceListeners.splice(idx, 1);
437
+ };
354
438
  }
355
439
  /**
356
440
  * Public accessor for the MCPL channel registry.
@@ -396,6 +480,13 @@ export class AgentFramework {
396
480
  getAllAgents() {
397
481
  return Array.from(this.agents.values());
398
482
  }
483
+ /**
484
+ * Get the per-channel conversation router (null unless config.conversations
485
+ * was set). Exposed for modules/UIs that surface active engagements.
486
+ */
487
+ getConversationRouter() {
488
+ return this.conversationRouter;
489
+ }
399
490
  /**
400
491
  * Abort an in-flight inference for an agent.
401
492
  */
@@ -422,12 +513,84 @@ export class AgentFramework {
422
513
  getAllTools() {
423
514
  const moduleTools = this.moduleRegistry.getAllTools();
424
515
  const channelTools = this.channelRegistry?.getChannelTools() ?? [];
425
- const gateTools = this.eventGate ? [this.eventGate.getToolDefinition()] : [];
516
+ const gateTools = this.eventGate
517
+ ? [
518
+ this.eventGate.getToolDefinition(),
519
+ ...AgentFramework.SLEEP_TOOLS,
520
+ ...AgentFramework.WAKE_RULE_TOOLS,
521
+ AgentFramework.EVENT_TAGS_TOOL,
522
+ ]
523
+ : [];
426
524
  if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
427
525
  return moduleTools;
428
526
  }
429
527
  return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
430
528
  }
529
+ /**
530
+ * Build the membrane-normalized request that WOULD be emitted if `agentName`
531
+ * were activated right now — WITHOUT running inference, opening a stream, or
532
+ * mutating agent state. Intended for debug/preview tooling.
533
+ *
534
+ * Transparency contract (default): the preview is side-effect-free and
535
+ * leaves no trace on the system. It does only read-only work —
536
+ * `ContextManager.compile` (which never triggers compression itself;
537
+ * compression runs in the background out-of-band), tool filtering, and
538
+ * system-prompt assembly — then delegates to `Agent.buildActivationRequest`.
539
+ * No tokens are spent, nothing is written to Chronicle, and no external
540
+ * MCPL server is contacted.
541
+ *
542
+ * The trade-off is fidelity: the dynamically-gathered ContextInjection[]
543
+ * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
544
+ * by default, because gathering them is not transparent —
545
+ * - module `gatherContext` can run inference (e.g. RetrievalModule makes
546
+ * Haiku calls — real token cost and latency), and
547
+ * - MCPL `beforeInference` hooks are arbitrary RPCs to external servers
548
+ * with side effects, and a preview never sends the paired
549
+ * `afterInference`, which can leave a stateful server half-open.
550
+ *
551
+ * Pass `{ injections: true }` to opt into full-fidelity gathering and accept
552
+ * those side effects (byte-faithful to a real activation's injected context).
553
+ */
554
+ async previewActivation(agentName, opts) {
555
+ const agent = this.agents.get(agentName);
556
+ if (!agent) {
557
+ throw new Error(`Agent not found: ${agentName}`);
558
+ }
559
+ const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
560
+ // Default: no dynamic injection gathering → fully transparent (no
561
+ // inference, no Chronicle writes, no external RPC). Opt in explicitly.
562
+ if (!opts?.injections) {
563
+ return agent.buildActivationRequest(tools, undefined);
564
+ }
565
+ // Full-fidelity path: mirrors startAgentStream's injection gathering.
566
+ // NOT transparent — see the doc comment above.
567
+ let injections;
568
+ // Module gatherContext (fail-open, matches startAgentStream)
569
+ try {
570
+ const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
571
+ if (moduleInjections.length > 0) {
572
+ injections = moduleInjections;
573
+ }
574
+ }
575
+ catch (error) {
576
+ console.error('Module gatherContext error (preview):', error);
577
+ }
578
+ // MCPL beforeInference hooks (fail-open). Note: the paired afterInference
579
+ // is intentionally never sent here — this is a preview, not a real turn.
580
+ if (this.hookOrchestrator) {
581
+ try {
582
+ const hookParams = this.buildBeforeInferenceParams(agent);
583
+ const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
584
+ if (hookInjections.length > 0) {
585
+ injections = injections ? [...injections, ...hookInjections] : hookInjections;
586
+ }
587
+ }
588
+ catch (error) {
589
+ console.error('beforeInference hook error (preview):', error);
590
+ }
591
+ }
592
+ return agent.buildActivationRequest(tools, injections);
593
+ }
431
594
  /**
432
595
  * Check if process logging is enabled.
433
596
  */
@@ -449,6 +612,38 @@ export class AgentFramework {
449
612
  getModule(name) {
450
613
  return this.moduleRegistry.getModule(name);
451
614
  }
615
+ // =========================================================================
616
+ // Wake-rule surface (gate policy mutation) — backs wake_add_rule /
617
+ // wake_remove_rule and lets host modules compose higher-level wake modes
618
+ // (e.g. an "every-message-debounced" channel mode) without reaching into
619
+ // the private EventGate.
620
+ // =========================================================================
621
+ /**
622
+ * Add or replace (upsert) a gate policy at runtime. Validated and persisted
623
+ * to gate.json; hot-applied in memory. Throws if no gate is configured or the
624
+ * policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
625
+ * defer/debounce rules (first match wins).
626
+ */
627
+ addGatePolicy(rawPolicy, options) {
628
+ if (!this.eventGate) {
629
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
630
+ }
631
+ return this.eventGate.addPolicy(rawPolicy, options);
632
+ }
633
+ /**
634
+ * Remove a gate policy by name at runtime. Returns false if it didn't exist.
635
+ * Throws if no gate is configured.
636
+ */
637
+ removeGatePolicy(name) {
638
+ if (!this.eventGate) {
639
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
640
+ }
641
+ return this.eventGate.removePolicy(name);
642
+ }
643
+ /** Current gate policy names (freshest on-disk view). Empty when no gate. */
644
+ getGatePolicyNames() {
645
+ return this.eventGate?.listPolicyNames() ?? [];
646
+ }
452
647
  /**
453
648
  * Get the Membrane instance.
454
649
  */
@@ -666,6 +861,409 @@ export class AgentFramework {
666
861
  * Query inference logs.
667
862
  * Returns entries with summary info (doesn't resolve blobs).
668
863
  */
864
+ /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
865
+ static SLEEP_TOOLS = [
866
+ {
867
+ name: 'sleep',
868
+ description: 'Go quiet: suppress external pings and wakes for a number of seconds. ' +
869
+ 'Messages still accumulate in your context — you just won’t be woken to ' +
870
+ 'respond to them until the window passes. Your heartbeat still beats: you’ll ' +
871
+ 'briefly rouse on each tick and can keep resting or call `wake` to get up early. ' +
872
+ 'Privileged users can also still reach you. By default announces in your current channel.',
873
+ inputSchema: {
874
+ type: 'object',
875
+ properties: {
876
+ seconds: { type: 'number', description: 'How long to stay asleep, in seconds.' },
877
+ announce: {
878
+ type: 'boolean',
879
+ description: 'Announce the sleep in the current sticky channel (default true).',
880
+ },
881
+ message: {
882
+ type: 'string',
883
+ description: 'Optional custom announcement text (overrides the default).',
884
+ },
885
+ },
886
+ required: ['seconds'],
887
+ },
888
+ },
889
+ {
890
+ name: 'wake',
891
+ description: 'End your current sleep early, resuming normal wakes immediately.',
892
+ inputSchema: { type: 'object' },
893
+ },
894
+ ];
895
+ /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
896
+ * Present when a gate is wired. They write validated policies into the
897
+ * hot-reloaded gate.json (same validation as load), so a rule takes effect
898
+ * immediately and survives restart. */
899
+ static WAKE_RULE_TOOLS = [
900
+ {
901
+ name: 'wake_add_rule',
902
+ description: 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
903
+ 'hand-edit the file. The rule is validated and hot-applied immediately. ' +
904
+ 'A rule with the same `name` replaces the existing one in place. Use ' +
905
+ '`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
906
+ 'rules (first match wins). Two common shapes:\n' +
907
+ '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
908
+ '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
909
+ '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
910
+ 'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, behavior: { debounce: 60000 } }` ' +
911
+ '(the channel must be subscribed for ambient events to arrive — see channel mode).',
912
+ inputSchema: {
913
+ type: 'object',
914
+ properties: {
915
+ name: { type: 'string', description: 'Unique rule name. Reusing a name replaces that rule.' },
916
+ match: {
917
+ type: 'object',
918
+ description: 'Match criteria (all AND together). Omitted fields match anything.',
919
+ properties: {
920
+ scope: { type: 'array', items: { type: 'string' }, description: 'Event types, e.g. ["mcpl:channel-incoming","workspace:modified"].' },
921
+ source: { type: 'string', description: 'Integration/serverId, glob ok (e.g. "discord").' },
922
+ channel: { type: 'string', description: 'Channel id, glob ok (e.g. "discord:*:12345").' },
923
+ mount: { type: 'string', description: 'Workspace mount name, glob ok (workspace:* events).' },
924
+ pathGlob: { type: 'string', description: 'Glob over touched paths (workspace:* events).' },
925
+ tagsAny: { type: 'array', items: { type: 'string' }, description: 'Match if ANY tag matches (globs ok).' },
926
+ tagsAll: { type: 'array', items: { type: 'string' }, description: 'Match only if EVERY tag matches.' },
927
+ tagsNone: { type: 'array', items: { type: 'string' }, description: 'Match only if NONE match.' },
928
+ metadataTrue: { type: 'array', items: { type: 'string' }, description: 'Match if ANY listed metadata field is truthy.' },
929
+ filter: {
930
+ type: 'object',
931
+ description: 'Content filter.',
932
+ properties: {
933
+ type: { type: 'string', enum: ['text', 'regex'] },
934
+ pattern: { type: 'string' },
935
+ },
936
+ required: ['type', 'pattern'],
937
+ },
938
+ },
939
+ },
940
+ behavior: {
941
+ type: 'string',
942
+ enum: ['always', 'defer', 'skip'],
943
+ description: 'Simple behavior: "always" (wake now) or "defer" (don\'t wake; still enters ' +
944
+ 'context). For debounce / rate-limit / sampling, use debounceMs / rateLimit / ' +
945
+ 'passiveSample below instead of this field. Exactly one behavior must be given.',
946
+ },
947
+ debounceMs: {
948
+ type: 'number',
949
+ description: 'Shorthand for { debounce: ms }: wake once after ms of quiet (100–300000).',
950
+ },
951
+ rateLimit: {
952
+ type: 'object',
953
+ description: 'Token-bucket wake: at most `tokens` wakes per window; refills one per refillIntervalMs.',
954
+ properties: {
955
+ tokens: { type: 'number', description: 'Bucket capacity (> 0).' },
956
+ refillIntervalMs: { type: 'number', description: 'Ms between token refills (> 0).' },
957
+ keyBy: { type: 'string', description: 'Metadata field to partition buckets by (e.g. "channelId").' },
958
+ },
959
+ required: ['tokens', 'refillIntervalMs'],
960
+ },
961
+ passiveSample: {
962
+ type: 'object',
963
+ description: 'Wake every Nth matching event.',
964
+ properties: {
965
+ every: { type: 'number', description: 'Fire every N matches (positive integer).' },
966
+ keyBy: { type: 'string', description: 'Metadata field for separate per-key counters.' },
967
+ },
968
+ required: ['every'],
969
+ },
970
+ position: {
971
+ type: 'string',
972
+ enum: ['append', 'prepend'],
973
+ description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
974
+ },
975
+ resets: {
976
+ type: 'array',
977
+ items: { type: 'string' },
978
+ description: 'Names of other rules whose runtime state to clear when this rule fires.',
979
+ },
980
+ },
981
+ required: ['name'],
982
+ },
983
+ },
984
+ {
985
+ name: 'wake_remove_rule',
986
+ description: 'Remove a wake rule (gate.json policy) by name at runtime. Any pending ' +
987
+ 'debounce batch for that rule is delivered first. Returns whether a rule ' +
988
+ 'was removed (false if no rule had that name).',
989
+ inputSchema: {
990
+ type: 'object',
991
+ properties: {
992
+ name: { type: 'string', description: 'The rule name to remove.' },
993
+ },
994
+ required: ['name'],
995
+ },
996
+ },
997
+ ];
998
+ /** Discoverability for event tags (MCPL RFC-001): the reserved chat:* core,
999
+ * each connected server's declared tag ontology, and gate.js status. */
1000
+ static EVENT_TAGS_TOOL = {
1001
+ name: 'event_tags',
1002
+ description: 'List the event tags available for gating: the reserved cross-platform ' +
1003
+ 'chat:* core, each connected server\'s declared tag ontology (descriptions, ' +
1004
+ 'implications, suggested treatments), and the status of your programmable ' +
1005
+ 'gate (gate.js). Use these tag names in gate.json policies (tagsAny / ' +
1006
+ 'tagsAll / tagsNone) or in gate.js.',
1007
+ inputSchema: { type: 'object' },
1008
+ };
1009
+ /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1010
+ * the agent can author rules without reading the spec. */
1011
+ static CHAT_CORE_TAGS = {
1012
+ 'chat:addressed': 'Directed at you (umbrella: dm/mention/reply)',
1013
+ 'chat:mention': 'You were explicitly @-mentioned',
1014
+ 'chat:reply': 'A reply to your own message',
1015
+ 'chat:dm': 'A direct/private message to you',
1016
+ 'chat:ambient': 'Overheard in a followed channel; not addressed',
1017
+ 'chat:broadcast': 'Channel-wide ping (@everyone / channel post)',
1018
+ 'chat:to-self': 'Acts on your own content (reaction/reply to you)',
1019
+ 'chat:from-human': 'Authored by a human',
1020
+ 'chat:from-bot': 'Authored by a bot/automation',
1021
+ 'chat:from-self': 'Your own message, echoed back',
1022
+ 'chat:from-agent': 'Authored by another persona/agent',
1023
+ 'chat:edited': 'An edit of an existing message',
1024
+ 'chat:deleted': 'A deletion',
1025
+ 'chat:reaction': 'An emoji reaction was added',
1026
+ 'chat:reaction-remove': 'A reaction was removed',
1027
+ 'chat:has-image': 'Has an image attachment',
1028
+ 'chat:has-audio': 'Has an audio attachment',
1029
+ 'chat:has-file': 'Has a file attachment',
1030
+ 'chat:has-link': 'Contains a link',
1031
+ 'chat:command': 'A slash/bot command invocation',
1032
+ 'chat:private': 'Private conversation',
1033
+ 'chat:group': 'Group (multi-party) conversation',
1034
+ 'chat:thread': 'Occurred in a thread',
1035
+ };
1036
+ /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
1037
+ static REFUSAL_REACTIONS = {
1038
+ bio: '☣️',
1039
+ chem: '🧪',
1040
+ nuclear: '☢️',
1041
+ cyber: '💻',
1042
+ reasoning_extraction: '🧠',
1043
+ };
1044
+ /**
1045
+ * Mark an inference refusal visibly: react on the message that holds the
1046
+ * conversational locus (the most recent incoming channel message) with a
1047
+ * category-specific emoji. Best-effort — failures are logged, never thrown,
1048
+ * and non-Discord loci are silently skipped.
1049
+ */
1050
+ async reactToRefusal(agentName, category) {
1051
+ try {
1052
+ const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
1053
+ if (!incoming)
1054
+ return;
1055
+ // incoming.channelId is the MCPL composite id ("discord:<guild>:<channel>");
1056
+ // the reaction tool wants the raw Discord channel (or thread) id — the
1057
+ // last segment.
1058
+ const parts = incoming.channelId.split(':');
1059
+ if (parts[0] !== 'discord')
1060
+ return;
1061
+ const channelId = parts[parts.length - 1];
1062
+ const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
1063
+ // Resolve the MCPL server that owns the locus channel and call
1064
+ // tools/call directly on its connection (bare tool name — no prefix
1065
+ // games), bypassing the agent event queue so no synthetic tool-result
1066
+ // is injected into a turn the agent never took.
1067
+ const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
1068
+ const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
1069
+ if (!server) {
1070
+ console.error(`[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`);
1071
+ return;
1072
+ }
1073
+ await server.sendToolsCall('add_reaction', {
1074
+ channelId,
1075
+ messageId: incoming.messageId,
1076
+ emoji,
1077
+ });
1078
+ }
1079
+ catch (err) {
1080
+ console.error('[inference-refusal] reaction failed:', err instanceof Error ? err.message : err);
1081
+ }
1082
+ }
1083
+ /**
1084
+ * Handle a `host/command` request from an MCPL surface server (e.g. a
1085
+ * Discord slash command). Currently supports:
1086
+ *
1087
+ * undo — revert the last N inference turns (branch-based, see
1088
+ * `undoLastTurn`). The response includes the last message the agent
1089
+ * would see in its context after the undo, obtained via the transparent
1090
+ * `previewActivation` render (no inference, no Chronicle writes).
1091
+ */
1092
+ async handleHostCommand(serverId, params) {
1093
+ if (params.command !== 'undo' && params.command !== 'hide') {
1094
+ return { ok: false, error: `Unknown host command: ${String(params.command)}` };
1095
+ }
1096
+ const agentName = params.agentName ?? [...this.agents.keys()][0];
1097
+ if (!agentName || !this.agents.has(agentName)) {
1098
+ return { ok: false, error: `Unknown agent: ${String(agentName)}` };
1099
+ }
1100
+ // hide: redact a single message (or an inclusive range) from the active
1101
+ // branch, addressed by Discord message id. Unlike undo this is a
1102
+ // removal-in-place (chronicle redact), not a branch rewind.
1103
+ if (params.command === 'hide') {
1104
+ const agent = this.agents.get(agentName);
1105
+ if (agent.state.status !== 'idle') {
1106
+ return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
1107
+ }
1108
+ if (!params.fromMessageId) {
1109
+ return { ok: false, error: 'hide: fromMessageId is required' };
1110
+ }
1111
+ const cm = agent.getContextManager();
1112
+ const all = cm.getAllMessages();
1113
+ const byDiscordId = (did) => all.findIndex((m) => String(m.metadata?.messageId) === String(did));
1114
+ // Collect Discord refs of every message in [lo, hi] that carries one,
1115
+ // so the surface can mark them. channelId here is whatever the ingest
1116
+ // path stored (raw id or "discord:guild:channel"); the surface
1117
+ // normalizes it.
1118
+ const refsIn = (lo, hi) => {
1119
+ const refs = [];
1120
+ for (let i = lo; i <= hi; i++) {
1121
+ const md = all[i].metadata;
1122
+ if (md?.messageId && md?.channelId) {
1123
+ refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
1124
+ }
1125
+ }
1126
+ return refs;
1127
+ };
1128
+ const fromIdx = byDiscordId(params.fromMessageId);
1129
+ if (fromIdx < 0) {
1130
+ return {
1131
+ ok: false,
1132
+ error: `Message ${params.fromMessageId} is not an addressable message in context (it may only exist inside backscroll text, or predates this session).`,
1133
+ };
1134
+ }
1135
+ try {
1136
+ if (params.toMessageId) {
1137
+ const toIdx = byDiscordId(params.toMessageId);
1138
+ if (toIdx < 0) {
1139
+ return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
1140
+ }
1141
+ const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
1142
+ const refs = refsIn(lo, hi);
1143
+ cm.removeMessages(all[lo].id, all[hi].id);
1144
+ console.error(`[host-command] hide agent=${agentName} range removed=${hi - lo + 1} ` +
1145
+ `(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
1146
+ return {
1147
+ ok: true,
1148
+ hidden: hi - lo + 1,
1149
+ hiddenRefs: refs,
1150
+ lastVisible: await this.lastVisiblePreview(agentName),
1151
+ };
1152
+ }
1153
+ const refs = refsIn(fromIdx, fromIdx);
1154
+ cm.removeMessage(all[fromIdx].id);
1155
+ console.error(`[host-command] hide agent=${agentName} removed=1 (${params.fromMessageId}) ` +
1156
+ `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
1157
+ return {
1158
+ ok: true,
1159
+ hidden: 1,
1160
+ hiddenRefs: refs,
1161
+ lastVisible: await this.lastVisiblePreview(agentName),
1162
+ };
1163
+ }
1164
+ catch (error) {
1165
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1166
+ }
1167
+ }
1168
+ // Message-granular mode: branch the chronicle at the message that should
1169
+ // become the new tail (ContextManager.branchAt — origin-sequence-based
1170
+ // time-travel branching; see chronicle's rfc-state-item-origins addendum).
1171
+ // No turn checkpoints involved, so this reaches past turn boundaries.
1172
+ if (typeof params.messages === 'number' && params.messages > 0) {
1173
+ const agent = this.agents.get(agentName);
1174
+ if (agent.state.status !== 'idle') {
1175
+ return { ok: false, error: `Cannot undo while agent is ${agent.state.status}` };
1176
+ }
1177
+ const n = Math.max(1, Math.min(50, Math.floor(params.messages)));
1178
+ const cm = agent.getContextManager();
1179
+ const allMessages = cm.getAllMessages();
1180
+ if (n >= allMessages.length) {
1181
+ return {
1182
+ ok: false,
1183
+ error: `Cannot remove ${n} message(s) — history has ${allMessages.length}; at least one must remain.`,
1184
+ };
1185
+ }
1186
+ const target = allMessages[allMessages.length - 1 - n];
1187
+ const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
1188
+ await cm.switchBranch(branchName);
1189
+ // Materialize config files from the new branch (fire-and-forget; gate
1190
+ // picks up via mtime) — mirrors undoLastTurn.
1191
+ const wsUndo = this.moduleRegistry.getModule('workspace');
1192
+ if (wsUndo && 'materializeMount' in wsUndo) {
1193
+ wsUndo
1194
+ .materializeMount('_config')
1195
+ .catch(() => { });
1196
+ }
1197
+ console.error(`[host-command] undo-messages agent=${agentName} removed=${n} branch=${branchName}` +
1198
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
1199
+ return {
1200
+ ok: true,
1201
+ messagesRemoved: n,
1202
+ lastVisible: await this.lastVisiblePreview(agentName),
1203
+ };
1204
+ }
1205
+ const requested = Math.max(1, Math.min(20, Math.floor(params.turns ?? 1)));
1206
+ let undone = 0;
1207
+ try {
1208
+ for (let i = 0; i < requested; i++) {
1209
+ const r = this.undoLastTurn(agentName);
1210
+ if (!r.undone)
1211
+ break;
1212
+ undone++;
1213
+ }
1214
+ }
1215
+ catch (error) {
1216
+ // e.g. "Cannot undo while agent is streaming" — report what happened,
1217
+ // including any turns already undone before the failure.
1218
+ const msg = error instanceof Error ? error.message : String(error);
1219
+ if (undone === 0)
1220
+ return { ok: false, error: msg };
1221
+ console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
1222
+ }
1223
+ console.error(`[host-command] undo agent=${agentName} requested=${requested} undone=${undone}` +
1224
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
1225
+ if (undone === 0) {
1226
+ return { ok: true, undone: 0, requested, lastVisible: null };
1227
+ }
1228
+ return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
1229
+ }
1230
+ /**
1231
+ * The last message the agent would see in its context right now, via the
1232
+ * transparent `previewActivation` render (no inference, no Chronicle
1233
+ * writes). Returns null if the preview fails or the context is empty.
1234
+ */
1235
+ async lastVisiblePreview(agentName) {
1236
+ try {
1237
+ const preview = await this.previewActivation(agentName);
1238
+ const messages = preview.messages ?? [];
1239
+ const last = messages[messages.length - 1];
1240
+ if (!last)
1241
+ return null;
1242
+ const blocks = Array.isArray(last.content) ? last.content : [];
1243
+ const text = blocks
1244
+ .map((b) => (b && typeof b === 'object' && 'text' in b ? String(b.text) : ''))
1245
+ .join(' ')
1246
+ .trim();
1247
+ // Surface-injected preambles (<system>…</system>, <backscroll>…
1248
+ // </backscroll>) can dominate the head of a bundled incoming message
1249
+ // and bury the actual conversational line. Strip them for the
1250
+ // preview; fall back to the raw text if nothing else remains.
1251
+ const stripped = text
1252
+ .replace(/<system>[\s\S]*?<\/system>/g, '')
1253
+ .replace(/<backscroll\b[\s\S]*?<\/backscroll>/g, '')
1254
+ .trim();
1255
+ const body = stripped.length > 0 ? stripped : text;
1256
+ return {
1257
+ participant: last.participant,
1258
+ role: last.role,
1259
+ preview: body.length > 400 ? `${body.slice(0, 400)}…` : body,
1260
+ };
1261
+ }
1262
+ catch (error) {
1263
+ console.error('[host-command] post-undo context preview failed:', error instanceof Error ? error.message : error);
1264
+ return null;
1265
+ }
1266
+ }
669
1267
  queryInferenceLogs(query) {
670
1268
  const limit = query?.limit ?? 50;
671
1269
  const offset = query?.offset ?? 0;
@@ -1052,6 +1650,7 @@ export class AgentFramework {
1052
1650
  });
1053
1651
  const agent = new Agent(config, contextManager, this.membrane);
1054
1652
  this.agents.set(config.name, agent);
1653
+ this.agentConfigs.set(config.name, config);
1055
1654
  // First non-ephemeral agent becomes the primary for message routing
1056
1655
  if (!this.primaryAgentName) {
1057
1656
  this.primaryAgentName = config.name;
@@ -1074,6 +1673,8 @@ export class AgentFramework {
1074
1673
  if (event) {
1075
1674
  await this.handleProcessEvent(event);
1076
1675
  }
1676
+ // Close idle conversation forks (no-op unless conversations configured)
1677
+ this.sweepExpiredConversations();
1077
1678
  // Check for inference requests
1078
1679
  await this.processInferenceRequests();
1079
1680
  // Yield to the event loop between iterations.
@@ -1124,26 +1725,18 @@ export class AgentFramework {
1124
1725
  }
1125
1726
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
1126
1727
  const maxChars = this.getMaxToolResultChars(agent);
1127
- // Store tool results as a user message (tool_result blocks)
1128
- const toolResultContent = currentState.toolResults.map(tc => {
1129
- let content;
1130
- if (tc.result.isError) {
1131
- content = tc.result.error ?? 'Unknown error';
1132
- }
1133
- else {
1134
- content = JSON.stringify(tc.result.data);
1135
- if (maxChars && content.length > maxChars) {
1136
- content = safeSlice(content, 0, maxChars)
1137
- + '\n\n[truncated — original was ' + content.length + ' chars]';
1138
- }
1139
- }
1140
- return {
1141
- type: 'tool_result',
1142
- toolUseId: tc.id,
1143
- content,
1144
- isError: tc.result.isError,
1145
- };
1146
- });
1728
+ // Store tool results as a user message (tool_result blocks).
1729
+ // Use the history serializer so MCP image blocks become a short
1730
+ // `[image: type, size]` placeholder instead of megabytes of base64
1731
+ // that would corrupt under truncation.
1732
+ const toolResultContent = currentState.toolResults.map(tc => ({
1733
+ type: 'tool_result',
1734
+ toolUseId: tc.id,
1735
+ content: tc.result.isError
1736
+ ? tc.result.error ?? 'Unknown error'
1737
+ : toolResultDataToHistoryString(tc.result.data, maxChars),
1738
+ isError: tc.result.isError,
1739
+ }));
1147
1740
  agent.getContextManager().addMessage('user', toolResultContent);
1148
1741
  // Flush any messages that were deferred while waiting for tool results.
1149
1742
  // Route to the PRIMARY agent — deferred messages are framework-level
@@ -1211,7 +1804,7 @@ export class AgentFramework {
1211
1804
  // These events are protocol-level (spec Sections 9 & 14) and always
1212
1805
  // represent content intended for the model's context window.
1213
1806
  if (event.type === 'mcpl:channel-incoming') {
1214
- this.handleMcplChannelIncoming(event);
1807
+ await this.handleMcplChannelIncoming(event);
1215
1808
  }
1216
1809
  else if (event.type === 'mcpl:push-event') {
1217
1810
  this.handleMcplPushEvent(event);
@@ -1282,8 +1875,10 @@ export class AgentFramework {
1282
1875
  if (!decision.trigger)
1283
1876
  return;
1284
1877
  }
1878
+ // Broadcast requests exclude conversation forks — they are driven by
1879
+ // their own channel's messages, not by framework-wide events.
1285
1880
  const targetAgents = response.requestInference === true
1286
- ? Array.from(this.agents.keys())
1881
+ ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
1287
1882
  : response.requestInference;
1288
1883
  for (const agentName of targetAgents) {
1289
1884
  const agent = this.agents.get(agentName);
@@ -1305,7 +1900,7 @@ export class AgentFramework {
1305
1900
  * Convert an incoming MCPL channel message to a context message.
1306
1901
  * This replaces the old MCPLModule.onProcess() message conversion.
1307
1902
  */
1308
- handleMcplChannelIncoming(event) {
1903
+ async handleMcplChannelIncoming(event) {
1309
1904
  const metadata = {
1310
1905
  ...event.metadata,
1311
1906
  channelId: event.channelId,
@@ -1316,6 +1911,13 @@ export class AgentFramework {
1316
1911
  };
1317
1912
  if (event.threadId)
1318
1913
  metadata.threadId = event.threadId;
1914
+ // Per-channel conversation routing: messages go to the channel's fork
1915
+ // agent (spawned from the template on first qualifying message), never
1916
+ // to the primary conversation.
1917
+ if (this.conversationRouter) {
1918
+ await this.routeConversationIncoming(event, metadata);
1919
+ return;
1920
+ }
1319
1921
  const id = this.addMessage('user', event.content, metadata);
1320
1922
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
1321
1923
  if (event.triggerInference) {
@@ -1325,8 +1927,236 @@ export class AgentFramework {
1325
1927
  reason: 'mcpl:channel-incoming',
1326
1928
  source: event.serverId,
1327
1929
  timestamp: Date.now(),
1930
+ // Route this turn's auto-published speech back to THIS channel, not
1931
+ // the global most-recent-inbound locus (item-3 redux, trunk agents).
1932
+ channelId: event.channelId,
1933
+ });
1934
+ }
1935
+ }
1936
+ }
1937
+ /**
1938
+ * Route an incoming channel message through the ConversationRouter:
1939
+ * deliver to the channel's bound fork agent, spawning it from the template
1940
+ * agent first when the bind policy matches. Unrouted messages are dropped —
1941
+ * the template ("trunk") is a dormant warm checkpoint, not a listener.
1942
+ */
1943
+ async routeConversationIncoming(event, messageMetadata) {
1944
+ const router = this.conversationRouter;
1945
+ const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
1946
+ const decision = router.route({
1947
+ channelId: event.channelId,
1948
+ mentioned: event.metadata?.mentioned === true,
1949
+ kind: ConversationRouter.classifyChannel(descriptor, event.metadata),
1950
+ });
1951
+ if (decision.kind === 'unbound') {
1952
+ this.emitTrace({
1953
+ type: 'mcpl:conversation-unrouted',
1954
+ channelId: event.channelId,
1955
+ messageId: event.messageId,
1956
+ });
1957
+ return;
1958
+ }
1959
+ let agent;
1960
+ if (decision.kind === 'spawn') {
1961
+ try {
1962
+ agent = await this.createConversationAgent(decision.agentName, event.channelId);
1963
+ router.bind(event.channelId, decision.agentName, decision.generation);
1964
+ this.persistConversationRouterState();
1965
+ this.emitTrace({
1966
+ type: 'mcpl:conversation-spawned',
1967
+ channelId: event.channelId,
1968
+ agentName: decision.agentName,
1969
+ generation: decision.generation,
1970
+ template: router.templateAgent,
1328
1971
  });
1329
1972
  }
1973
+ catch (error) {
1974
+ const err = error instanceof Error ? error : new Error(String(error));
1975
+ console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
1976
+ this.emitTrace({
1977
+ type: 'mcpl:conversation-spawn-failed',
1978
+ channelId: event.channelId,
1979
+ agentName: decision.agentName,
1980
+ error: err.message,
1981
+ });
1982
+ return; // next qualifying message proposes the same spawn again
1983
+ }
1984
+ }
1985
+ else {
1986
+ agent = this.agents.get(decision.agentName);
1987
+ if (!agent) {
1988
+ // Bound agent vanished (e.g. external reset) — unbind so the next
1989
+ // qualifying message respawns a fresh fork.
1990
+ router.unbind(event.channelId);
1991
+ this.emitTrace({
1992
+ type: 'mcpl:conversation-binding-orphaned',
1993
+ channelId: event.channelId,
1994
+ agentName: decision.agentName,
1995
+ });
1996
+ return;
1997
+ }
1998
+ }
1999
+ // Respect an explicit server-config-level veto (shouldTriggerInference)
2000
+ // on top of the router's own trigger policy.
2001
+ const trigger = decision.trigger && event.triggerInference !== false;
2002
+ messageMetadata.triggered = trigger;
2003
+ const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
2004
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
2005
+ if (trigger) {
2006
+ this.pendingRequests.push({
2007
+ agentName: agent.name,
2008
+ reason: 'mcpl:channel-incoming',
2009
+ source: event.serverId,
2010
+ timestamp: Date.now(),
2011
+ // A fork's home channel wins in routeSpeech regardless, but carry the
2012
+ // triggering channel too so the trunk/active path stays consistent.
2013
+ channelId: event.channelId,
2014
+ });
2015
+ }
2016
+ }
2017
+ /**
2018
+ * Spawn a persistent per-channel conversation agent ("fork") from the
2019
+ * template agent: own ContextManager under `conversations/{name}`, seeded
2020
+ * with a copy of the template's compiled context (the SubagentModule
2021
+ * wholesale-copy pattern), registered in the event loop like any agent —
2022
+ * but never primary.
2023
+ */
2024
+ async createConversationAgent(name, channelId) {
2025
+ const router = this.conversationRouter;
2026
+ const templateConfig = this.agentConfigs.get(router.templateAgent);
2027
+ const template = this.agents.get(router.templateAgent);
2028
+ if (!template || !templateConfig) {
2029
+ throw new Error(`conversation template agent "${router.templateAgent}" not found`);
2030
+ }
2031
+ const contextManager = await ContextManager.open({
2032
+ store: this.store,
2033
+ namespace: `conversations/${name}`,
2034
+ isolate: true,
2035
+ // Strategy instances are stateful — never share the template's.
2036
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
2037
+ membrane: this.membrane,
2038
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
2039
+ });
2040
+ // Seed with the template's compiled context, renaming the template
2041
+ // participant so the fork reads its inheritance as its own history.
2042
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
2043
+ // persisted precisely so names aren't reused, but if this namespace has
2044
+ // history anyway (counter state lost, crash between spawn and persist),
2045
+ // seeding again would stack another template copy on top of it.
2046
+ const { messages: existing } = await contextManager.compile();
2047
+ if (existing.length === 0) {
2048
+ const { messages: compiled } = await template.getContextManager().compile();
2049
+ for (const msg of compiled) {
2050
+ const participant = msg.participant === template.name ? name : msg.participant;
2051
+ contextManager.addMessage(participant, msg.content);
2052
+ }
2053
+ }
2054
+ const config = { ...templateConfig, name, strategy: undefined };
2055
+ const agent = new Agent(config, contextManager, this.membrane);
2056
+ this.agents.set(name, agent);
2057
+ this.agentConfigs.set(name, config);
2058
+ this.conversationAgentHomes.set(name, channelId);
2059
+ return agent;
2060
+ }
2061
+ /** Persist the router's generation counters (see hydration in create()). */
2062
+ persistConversationRouterState() {
2063
+ if (!this.conversationRouter)
2064
+ return;
2065
+ try {
2066
+ this.store.setStateJson(CONVERSATION_ROUTER_STATE_ID, {
2067
+ generations: this.conversationRouter.exportGenerations(),
2068
+ });
2069
+ }
2070
+ catch (error) {
2071
+ console.error('Failed to persist conversation router state:', error);
2072
+ }
2073
+ }
2074
+ /**
2075
+ * Remove a closed conversation fork from the framework: its closure turn
2076
+ * has finished, so the home mapping has served its purpose and keeping the
2077
+ * agent registered would just grow every agent scan and broadcast filter
2078
+ * forever. The fork's context stays in Chronicle for investigation.
2079
+ */
2080
+ disposeConversationAgent(agentName) {
2081
+ this.closingConversationAgents.delete(agentName);
2082
+ const channelId = this.conversationAgentHomes.get(agentName);
2083
+ this.agents.delete(agentName);
2084
+ this.agentConfigs.delete(agentName);
2085
+ this.conversationAgentHomes.delete(agentName);
2086
+ this.emitTrace({
2087
+ type: 'mcpl:conversation-disposed',
2088
+ agentName,
2089
+ channelId,
2090
+ });
2091
+ }
2092
+ /**
2093
+ * Scope MCPL context injections for a conversation-bound agent: drop
2094
+ * injections that are another open channel's context (injection namespace
2095
+ * = channelId by adapter convention), keep its own channel and anything
2096
+ * that isn't channel context.
2097
+ */
2098
+ scopeInjectionsForAgent(agentName, injections) {
2099
+ const home = this.conversationAgentHomes.get(agentName);
2100
+ if (!home || !this.channelRegistry || injections.length === 0) {
2101
+ return injections;
2102
+ }
2103
+ const openChannelIds = new Set(this.channelRegistry.getOpenChannels().map((e) => e.descriptor.id));
2104
+ return injections.filter((inj) => {
2105
+ const ns = inj.namespace;
2106
+ if (!ns || !openChannelIds.has(ns))
2107
+ return true;
2108
+ return ns === home;
2109
+ });
2110
+ }
2111
+ /**
2112
+ * Idle-TTL sweep for conversation bindings (runs at most once per minute
2113
+ * from the event loop). Expired forks get a final system-initiated closure
2114
+ * turn — publish scoping still works because the agent's home channel
2115
+ * mapping is permanent — and the channel unbinds immediately, so the next
2116
+ * qualifying message spawns a fresh fork from the current template.
2117
+ */
2118
+ sweepExpiredConversations() {
2119
+ if (!this.conversationRouter)
2120
+ return;
2121
+ const now = Date.now();
2122
+ if (now - this.lastConversationSweep < 60_000)
2123
+ return;
2124
+ this.lastConversationSweep = now;
2125
+ for (const binding of this.conversationRouter.expired(now)) {
2126
+ this.conversationRouter.unbind(binding.channelId);
2127
+ this.emitTrace({
2128
+ type: 'mcpl:conversation-closed',
2129
+ channelId: binding.channelId,
2130
+ agentName: binding.agentName,
2131
+ reason: 'idle-ttl',
2132
+ });
2133
+ const agent = this.agents.get(binding.agentName);
2134
+ if (!agent) {
2135
+ // Agent vanished (external reset) — nothing to close, just make sure
2136
+ // its bookkeeping doesn't linger.
2137
+ this.agentConfigs.delete(binding.agentName);
2138
+ this.conversationAgentHomes.delete(binding.agentName);
2139
+ continue;
2140
+ }
2141
+ agent.getContextManager().addMessage('user', [{ type: 'text', text: this.conversationRouter.closurePrompt }], { channelId: binding.channelId, conversationClosure: true });
2142
+ this.pendingRequests.push({
2143
+ agentName: binding.agentName,
2144
+ reason: 'conversation:closure',
2145
+ source: 'framework',
2146
+ timestamp: now,
2147
+ });
2148
+ // Disposed when the closure stream ends (driveStream finally), with
2149
+ // the reaper below as fallback.
2150
+ this.closingConversationAgents.add(binding.agentName);
2151
+ }
2152
+ // Fallback reaper: a closing fork whose closure inference never ran
2153
+ // (request dropped as stale, inference policy veto) would otherwise stay
2154
+ // registered forever. No active stream + no pending request = done.
2155
+ for (const agentName of [...this.closingConversationAgents]) {
2156
+ if (!this.activeStreams.has(agentName) &&
2157
+ !this.pendingRequests.some((r) => r.agentName === agentName)) {
2158
+ this.disposeConversationAgent(agentName);
2159
+ }
1330
2160
  }
1331
2161
  }
1332
2162
  /**
@@ -1342,18 +2172,62 @@ export class AgentFramework {
1342
2172
  };
1343
2173
  const id = this.addMessage('user', event.content, metadata);
1344
2174
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
2175
+ // Some push events carry a channel of origin (Discord DMs arrive here, not
2176
+ // via channels/incoming, because discord-mcpl forwards them with the channel
2177
+ // closed). Register + open that channel so routeSpeech can resolve it, and
2178
+ // thread it onto the wake request so the reply routes back to it instead of
2179
+ // the global locus (item-3 redux, DM sub-case).
2180
+ const triggerChannel = this.derivePushEventChannel(event.origin);
2181
+ if (triggerChannel && this.channelRegistry) {
2182
+ this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
2183
+ }
1345
2184
  if (event.triggerInference) {
1346
- const targetAgents = event.targetAgents ?? [...this.agents.keys()];
2185
+ // Default broadcast excludes conversation forks (channel-driven).
2186
+ const targetAgents = event.targetAgents
2187
+ ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
1347
2188
  for (const agentName of targetAgents) {
1348
2189
  this.pendingRequests.push({
1349
2190
  agentName,
1350
2191
  reason: 'mcpl:push-event',
1351
2192
  source: event.serverId,
1352
2193
  timestamp: Date.now(),
2194
+ channelId: triggerChannel?.channelId,
1353
2195
  });
1354
2196
  }
1355
2197
  }
1356
2198
  }
2199
+ /**
2200
+ * Derive the MCPL composite channel id (the outbound routing locus) for a
2201
+ * push event from its server-defined `origin`, if it carries one.
2202
+ *
2203
+ * Discord DMs are the motivating case: discord-mcpl forwards them via
2204
+ * push/event (the DM channel is closed), so they never pass through
2205
+ * channels/incoming and their channel is never registered — leaving the
2206
+ * agent's reply with nowhere to route (item-3 redux, DM sub-case). Prefers an
2207
+ * explicit `origin.mcplChannelId` (a surface declaring its own composite id —
2208
+ * the surface-agnostic contract); otherwise reconstructs the Discord form
2209
+ * `discord:{guildId|dm}:{channelId}` from origin fields, matching
2210
+ * discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the
2211
+ * fix works even against a discord-mcpl build that predates `mcplChannelId`.
2212
+ * Returns undefined for push events with no channel provenance (heartbeats,
2213
+ * timers), which correctly keep the global fallback.
2214
+ */
2215
+ derivePushEventChannel(origin) {
2216
+ if (!origin)
2217
+ return undefined;
2218
+ const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
2219
+ const explicit = origin.mcplChannelId;
2220
+ if (typeof explicit === 'string' && explicit) {
2221
+ return { channelId: explicit, label };
2222
+ }
2223
+ // Discord fallback: reconstruct the composite from origin parts. `guildId`
2224
+ // is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
2225
+ if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
2226
+ const guild = typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
2227
+ return { channelId: `discord:${guild}:${origin.channelId}`, label };
2228
+ }
2229
+ return undefined;
2230
+ }
1357
2231
  async processInferenceRequests() {
1358
2232
  if (this.pendingRequests.length === 0) {
1359
2233
  return;
@@ -1407,7 +2281,16 @@ export class AgentFramework {
1407
2281
  }
1408
2282
  // Start streaming inference (non-blocking — driveStream runs in background)
1409
2283
  const trigger = requests[0];
1410
- await this.startAgentStream(agent, trigger);
2284
+ // Route this turn's auto-published speech to the channel that triggered
2285
+ // it (item-3 redux). A batched wake may carry several triggering channels
2286
+ // (messages arrived in >1 channel while the agent was busy/idle) — reply
2287
+ // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
2288
+ // is the message the agent is most likely answering. The common case is
2289
+ // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
2290
+ // channelId, leaving the field undefined → global fallback. `reduce`
2291
+ // keeps the last defined channelId across the (FIFO-ordered) batch.
2292
+ const triggerChannel = requests.reduce((acc, r) => r.channelId ?? acc, undefined);
2293
+ await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
1411
2294
  }
1412
2295
  }
1413
2296
  async startAgentStream(agent, trigger, attempt = 0) {
@@ -1416,6 +2299,18 @@ export class AgentFramework {
1416
2299
  this.recordTurnCheckpoint(agent.name);
1417
2300
  this.redoStacks.delete(agent.name); // new work invalidates redo
1418
2301
  }
2302
+ // Establish this turn's outbound routing locus (item-3 redux). Set it to the
2303
+ // triggering channel for a channel/DM-triggered turn; clear it otherwise so
2304
+ // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
2305
+ // instead falls back to the global default. A given agent runs one turn at a
2306
+ // time, so a set here is only read during THIS turn's routeSpeech /
2307
+ // buildChannelContext; retries re-run with the same trigger, re-setting it.
2308
+ if (trigger?.channelId) {
2309
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
2310
+ }
2311
+ else {
2312
+ this.activeTriggerChannels.delete(agent.name);
2313
+ }
1419
2314
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
1420
2315
  this.eventGate?.onInferenceStarted(agent.name);
1421
2316
  try {
@@ -1425,6 +2320,11 @@ export class AgentFramework {
1425
2320
  // Both produce ContextInjection[] that get merged before inference.
1426
2321
  let injections;
1427
2322
  // Module gatherContext (fail-open, 5s timeout per module)
2323
+ // NOTE: module injections are NOT channel-scoped — only the MCPL hook
2324
+ // injections below pass through scopeInjectionsForAgent (channel
2325
+ // context arrives via beforeInference hooks by adapter convention). A
2326
+ // module that starts emitting per-channel context must scope it per
2327
+ // agent itself, or conversation forks will see cross-channel content.
1428
2328
  try {
1429
2329
  const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
1430
2330
  if (moduleInjections.length > 0) {
@@ -1438,7 +2338,7 @@ export class AgentFramework {
1438
2338
  if (this.hookOrchestrator) {
1439
2339
  try {
1440
2340
  const hookParams = this.buildBeforeInferenceParams(agent, trigger);
1441
- const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
2341
+ const hookInjections = this.scopeInjectionsForAgent(agent.name, await this.hookOrchestrator.beforeInference(hookParams));
1442
2342
  if (hookInjections.length > 0) {
1443
2343
  injections = injections ? [...injections, ...hookInjections] : hookInjections;
1444
2344
  }
@@ -1483,6 +2383,13 @@ export class AgentFramework {
1483
2383
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
1484
2384
  const myStreamId = agent.streamId;
1485
2385
  let hadToolCalls = false;
2386
+ // Typing indicator: show "<agent> is typing…" in the channel she's
2387
+ // responding to, for the whole duration of this turn. Started here (paired
2388
+ // with the finally below, so it can never leak) and refreshed on a 7s
2389
+ // interval by the ChannelRegistry until stopped on any exit path.
2390
+ const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
2391
+ if (typingChannel)
2392
+ this.channelRegistry.startTyping(typingChannel);
1486
2393
  try {
1487
2394
  for await (const event of stream) {
1488
2395
  switch (event.type) {
@@ -1513,13 +2420,26 @@ export class AgentFramework {
1513
2420
  agentName: agent.name,
1514
2421
  calls: event.calls.map((c) => ({ id: c.id, name: c.name, input: c.input })),
1515
2422
  });
1516
- // Build assistant content blocks from preamble text + tool_use blocks
1517
- const assistantBlocks = [];
1518
- if (event.context.preamble) {
1519
- assistantBlocks.push({ type: 'text', text: event.context.preamble });
2423
+ // Build assistant content blocks for this round. Prefer the
2424
+ // membrane's verbatim round content (membrane ≥0.5.64): it
2425
+ // preserves native thinking / redacted_thinking blocks with
2426
+ // their signatures IN ORDER — the API requires signed thinking
2427
+ // to precede its tool_use in the same assistant turn, so
2428
+ // rebuilding from preamble + calls would break thinking+tools.
2429
+ const roundContent = event.context.roundContent;
2430
+ let assistantBlocks;
2431
+ if (roundContent && roundContent.length > 0) {
2432
+ assistantBlocks = roundContent.filter((b) => b.type !== 'tool_result');
1520
2433
  }
1521
- for (const c of event.calls) {
1522
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
2434
+ else {
2435
+ // Fallback (older membrane / XML tool mode): preamble + calls
2436
+ assistantBlocks = [];
2437
+ if (event.context.preamble) {
2438
+ assistantBlocks.push({ type: 'text', text: event.context.preamble });
2439
+ }
2440
+ for (const c of event.calls) {
2441
+ assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
2442
+ }
1523
2443
  }
1524
2444
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
1525
2445
  // Note: max_tokens truncation cannot produce tool-calls events here.
@@ -1563,21 +2483,25 @@ export class AgentFramework {
1563
2483
  const toolResultContent = readyState.toolResults.map(tc => ({
1564
2484
  type: 'tool_result',
1565
2485
  toolUseId: tc.id,
1566
- content: tc.result.isError ? (tc.result.error ?? 'Unknown error') : JSON.stringify(tc.result.data),
2486
+ content: tc.result.isError
2487
+ ? (tc.result.error ?? 'Unknown error')
2488
+ : toolResultDataToHistoryString(tc.result.data),
1567
2489
  isError: tc.result.isError,
1568
2490
  }));
1569
2491
  agent.getContextManager().addMessage('user', toolResultContent);
1570
2492
  }
1571
2493
  }
1572
2494
  // Add assistant response to context.
1573
- // If we had tool calls, the tool_use blocks were already stored as
1574
- // pendingAssistantBlocks and flushed when tool results arrived.
1575
- // Only store trailing content (text after the last tool round) to
1576
- // avoid double-storing tool_use blocks.
1577
- const hadToolCalls = response.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
1578
- if (hadToolCalls) {
1579
- // Filter to only trailing text/thinking blocks (no tool_use — those are already stored)
1580
- const trailingContent = response.content.filter((block) => block.type !== 'tool_use' && block.type !== 'tool_result');
2495
+ // If we had tool calls, each round's blocks (thinking + text +
2496
+ // tool_use) were already stored as pendingAssistantBlocks and
2497
+ // flushed when tool results arrived. Only store TRAILING content
2498
+ // — blocks after the last tool block. A type-based filter would
2499
+ // double-store earlier rounds' text/thinking AND detach signed
2500
+ // thinking blocks from their tool_use turn (the API requires
2501
+ // thinking to precede tool_use in the same assistant turn).
2502
+ const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
2503
+ if (lastToolIdx >= 0) {
2504
+ const trailingContent = response.content.slice(lastToolIdx + 1);
1581
2505
  if (trailingContent.length > 0) {
1582
2506
  agent.addAssistantResponse(trailingContent);
1583
2507
  }
@@ -1673,6 +2597,19 @@ export class AgentFramework {
1673
2597
  tokenUsage,
1674
2598
  stopReason: response.stopReason,
1675
2599
  });
2600
+ // Surface refusals instead of going silently mute: stderr line
2601
+ // (headless inference failures are otherwise under-logged) + an
2602
+ // emoji reaction on the triggering Discord message, keyed by the
2603
+ // refusal category from stop_details.
2604
+ if (response.stopReason === 'refusal') {
2605
+ // stop_details lives on the raw PROVIDER response
2606
+ // (response.raw is RawAccess = { request, response, headers }).
2607
+ const stopDetails = response.raw?.response?.stop_details;
2608
+ const category = stopDetails?.category ?? 'unknown';
2609
+ console.error(`[inference-refusal] agent=${agent.name} category=${category}` +
2610
+ (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''));
2611
+ void this.reactToRefusal(agent.name, category);
2612
+ }
1676
2613
  // Dispatch speech (and thoughts if any)
1677
2614
  if (speechContent.length > 0 || thoughts.length > 0) {
1678
2615
  const speechContext = {
@@ -1686,6 +2623,91 @@ export class AgentFramework {
1686
2623
  };
1687
2624
  await this.moduleRegistry.dispatchSpeech(agent.name, speechContent, speechContext);
1688
2625
  }
2626
+ // Host-owned output routing (see LOCUS-ROUTING-DESIGN). On a
2627
+ // text-only turn (no tool calls => speechContent populated),
2628
+ // publish the agent's speech to the conversational locus — the
2629
+ // most recent incoming channel, tracked cross-surface in the
2630
+ // ChannelRegistry. This replaces discord-mcpl's per-surface sticky
2631
+ // auto-post. Tool-call turns produce `thoughts`, not `speech`, so
2632
+ // they are never routed here — which is precisely how the `think`
2633
+ // tool (and any explicit send tool) yields a silent turn.
2634
+ if (speechContent.length > 0 && this.channelRegistry) {
2635
+ const speechText = speechContent
2636
+ .map((b) => b.text)
2637
+ .join('\n')
2638
+ .trim();
2639
+ if (speechText) {
2640
+ try {
2641
+ await this.channelRegistry.routeSpeech(agent.name, speechText);
2642
+ }
2643
+ catch (err) {
2644
+ console.error('speech routing failed:', err);
2645
+ }
2646
+ }
2647
+ }
2648
+ else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
2649
+ // Tool-call turn that also produced prose. Per design, route the
2650
+ // prose to the locus as a reply UNLESS the turn used an explicit
2651
+ // send/publish tool (channel_publish, *--send_message,
2652
+ // *--reply_message, *--send_dm) — already delivered, so routing
2653
+ // again would double-post. `think` and other non-sending tools
2654
+ // (shell, workspace, etc.) still let the agent speak in the same
2655
+ // turn. The global speech/thoughts split is left untouched
2656
+ // (module/TUI rendering unaffected) — this only governs what
2657
+ // reaches the channel.
2658
+ // Tools whose presence suppresses the trailing prose, for two
2659
+ // distinct reasons:
2660
+ // - `skip_reply` — the agent EXPLICITLY chose not to reply this
2661
+ // turn (the deliberate "stay silent" signal).
2662
+ // - explicit delivery tools (channel_publish / *send_message /
2663
+ // *reply_message / *send_dm) — already sent the message, so
2664
+ // routing it again would double-post.
2665
+ // `think` is deliberately NOT here: it is silent *reasoning*, but
2666
+ // prose written after a think is the agent's actual reply and must
2667
+ // be delivered. (A think-only turn has no trailing prose and stays
2668
+ // silent via the `!t` check below.)
2669
+ const SILENCING = new Set([
2670
+ 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
2671
+ ]);
2672
+ const bare = (n) => (n.includes('--') ? n.split('--').pop() : n);
2673
+ const toolNames = response.content
2674
+ .filter((b) => b.type === 'tool_use')
2675
+ .map((b) => b.name)
2676
+ .filter((n) => typeof n === 'string');
2677
+ const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
2678
+ // Split the turn's content into ordered prose segments, broken at
2679
+ // each tool_use / tool_result boundary. `response.content` holds the
2680
+ // WHOLE turn's blocks in provider order — earlier tool rounds
2681
+ // included (see the trailing-slice note above, which slices exactly
2682
+ // because the full turn is present) — so a left-to-right walk
2683
+ // reconstructs the emission order. Interleaved prose
2684
+ // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
2685
+ // separate messages IN ORDER instead of being collapsed into one
2686
+ // trailing post (item 4). The silencing rule stays turn-wide: an
2687
+ // explicit send / skip suppresses ALL auto-routed prose this turn,
2688
+ // preserving the double-post guard.
2689
+ const segments = splitProseSegments(response.content);
2690
+ if (silenced || segments.length === 0) {
2691
+ console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
2692
+ `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`);
2693
+ }
2694
+ else {
2695
+ console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order`);
2696
+ // Deliver sequentially (await each) so the segments land in order.
2697
+ for (const seg of segments) {
2698
+ try {
2699
+ await this.channelRegistry.routeSpeech(agent.name, seg);
2700
+ }
2701
+ catch (err) {
2702
+ console.error('speech routing failed:', err);
2703
+ }
2704
+ }
2705
+ }
2706
+ }
2707
+ // NOTE: agent.reset() + onInferenceEnded() already ran above, BEFORE
2708
+ // dispatchSpeech (main's zombie-subagent fix, PR #32 — sync listeners
2709
+ // must see status === 'idle'). Locus routing is speech dispatch and
2710
+ // doesn't depend on the status field, so it correctly runs after.
1689
2711
  break;
1690
2712
  }
1691
2713
  case 'error': {
@@ -1778,8 +2800,16 @@ export class AgentFramework {
1778
2800
  this.eventGate?.onInferenceEnded(agent.name);
1779
2801
  }
1780
2802
  finally {
2803
+ // Stop the typing indicator on every exit path (complete, error,
2804
+ // exhausted, abort) so it never sticks after the turn ends.
2805
+ this.channelRegistry?.stopTyping();
1781
2806
  this.activeStreams.delete(agent.name);
1782
2807
  this.pendingAssistantBlocks.delete(agent.name);
2808
+ // A conversation fork whose TTL closure turn just finished is done for
2809
+ // good — dispose it so the agent map doesn't grow monotonically.
2810
+ if (this.closingConversationAgents.has(agent.name)) {
2811
+ this.disposeConversationAgent(agent.name);
2812
+ }
1783
2813
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
1784
2814
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
1785
2815
  const deferred = this.deferredMessages.splice(0);
@@ -1843,18 +2873,60 @@ export class AgentFramework {
1843
2873
  }
1844
2874
  }
1845
2875
  toMembraneToolResult(callId, afResult, maxChars) {
1846
- let content;
1847
2876
  if (afResult.isError) {
1848
- content = afResult.error ?? 'Unknown error';
2877
+ return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
1849
2878
  }
1850
- else {
1851
- content = JSON.stringify(afResult.data);
1852
- if (maxChars && content.length > maxChars) {
1853
- content = safeSlice(content, 0, maxChars)
1854
- + '\n\n[truncated — original was ' + content.length + ' chars]';
2879
+ // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
2880
+ // blocks natively rather than JSON-stringifying them away. Anything else
2881
+ // (objects, scalars) falls through to JSON. The error path was handled
2882
+ // above, so isError is always false on these return paths.
2883
+ const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
2884
+ if (blocks) {
2885
+ return { toolUseId: callId, content: blocks, isError: false };
2886
+ }
2887
+ let content = JSON.stringify(afResult.data);
2888
+ if (maxChars && content.length > maxChars) {
2889
+ content = safeSlice(content, 0, maxChars)
2890
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
2891
+ }
2892
+ return { toolUseId: callId, content, isError: false };
2893
+ }
2894
+ /**
2895
+ * If `data` is an MCP tool-result content array carrying at least one image,
2896
+ * convert to Membrane's native ToolResultContentBlock[]. Returns null when
2897
+ * the array is text-only (let JSON path handle it; saves a code path).
2898
+ * `maxChars`, when provided, caps each accompanying text block so an image
2899
+ * inlined alongside an enormous text payload can't blow the context.
2900
+ */
2901
+ tryNativeToolResultContent(data, maxChars) {
2902
+ if (!Array.isArray(data))
2903
+ return null;
2904
+ let hasImage = false;
2905
+ const blocks = [];
2906
+ for (const raw of data) {
2907
+ if (!raw || typeof raw !== 'object')
2908
+ return null;
2909
+ const b = raw;
2910
+ if (b.type === 'text' && typeof b.text === 'string') {
2911
+ let text = b.text;
2912
+ if (maxChars && text.length > maxChars) {
2913
+ text = safeSlice(text, 0, maxChars)
2914
+ + '\n\n[truncated — original was ' + text.length + ' chars]';
2915
+ }
2916
+ blocks.push({ type: 'text', text });
2917
+ }
2918
+ else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
2919
+ hasImage = true;
2920
+ blocks.push({
2921
+ type: 'image',
2922
+ source: { type: 'base64', data: b.data, mediaType: b.mimeType },
2923
+ });
2924
+ }
2925
+ else {
2926
+ return null; // unknown shape — bail to JSON path
1855
2927
  }
1856
2928
  }
1857
- return { toolUseId: callId, content, isError: afResult.isError };
2929
+ return hasImage ? blocks : null;
1858
2930
  }
1859
2931
  getMaxToolResultChars(agent) {
1860
2932
  const strategy = agent.getContextManager().getStrategy();
@@ -1934,11 +3006,34 @@ export class AgentFramework {
1934
3006
  this.dispatchChannelToolCall(agentName, enrichedCall);
1935
3007
  return;
1936
3008
  }
1937
- // Route gate:status tool
1938
- if (enrichedCall.name === 'gate:status' && this.eventGate) {
3009
+ // Route gate_status tool
3010
+ if (enrichedCall.name === 'gate_status' && this.eventGate) {
1939
3011
  this.dispatchGateToolCall(agentName, enrichedCall);
1940
3012
  return;
1941
3013
  }
3014
+ // Route sleep / wake tools
3015
+ if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
3016
+ this.dispatchSleepToolCall(agentName, enrichedCall);
3017
+ return;
3018
+ }
3019
+ // Route wake-rule tools (runtime gate.json policy add/remove)
3020
+ if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
3021
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
3022
+ return;
3023
+ }
3024
+ // Route event_tags (tag/ontology discovery)
3025
+ if (enrichedCall.name === 'event_tags' && this.eventGate) {
3026
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
3027
+ return;
3028
+ }
3029
+ // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
3030
+ // stay-silent) tools — handled by the channel registry like the other
3031
+ // synthesized channel tools, but they aren't `channel_`-prefixed so they
3032
+ // need an explicit route here.
3033
+ if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
3034
+ this.dispatchChannelToolCall(agentName, enrichedCall);
3035
+ return;
3036
+ }
1942
3037
  const sepIndex = enrichedCall.name.indexOf('--');
1943
3038
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
1944
3039
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -2072,6 +3167,24 @@ export class AgentFramework {
2072
3167
  };
2073
3168
  }
2074
3169
  emitTrace(event) {
3170
+ // Centralized inference-health observability. Every terminal failure path
3171
+ // funnels through an `inference:exhausted` trace and every successful model
3172
+ // response through `inference:completed`, so intercepting here is the one
3173
+ // place that reliably sees all outcomes regardless of which code path
3174
+ // produced them. In headless/daemon mode no trace client is attached, so
3175
+ // without this the only durable record of a failed inference is a field in
3176
+ // llm-calls.jsonl — invisible to operator, agent, and monitoring.
3177
+ if (event.type === 'inference:exhausted') {
3178
+ this.noteInferenceExhausted(event.agentName ?? 'unknown', event.error ?? 'unknown error');
3179
+ }
3180
+ else if (event.type === 'inference:completed') {
3181
+ // A successful response — even mid-turn between tool calls — proves the
3182
+ // agent isn't hard-down; clear its consecutive-failure streak.
3183
+ const name = event.agentName;
3184
+ if (name && this.consecutiveInferenceFailures.get(name)) {
3185
+ this.consecutiveInferenceFailures.set(name, 0);
3186
+ }
3187
+ }
2075
3188
  const traceEvent = {
2076
3189
  ...event,
2077
3190
  timestamp: Date.now(),
@@ -2085,6 +3198,49 @@ export class AgentFramework {
2085
3198
  }
2086
3199
  }
2087
3200
  }
3201
+ /**
3202
+ * Handle a fully-exhausted inference (the agent could not produce a response
3203
+ * this turn, after retries). Severity here is high — the agent can't think
3204
+ * at all — yet historically the only durable record was a buried field in
3205
+ * llm-calls.jsonl. This surfaces it three ways:
3206
+ *
3207
+ * 1. stderr — always, with the underlying API reason. The place an
3208
+ * operator greps; previously empty in headless mode.
3209
+ * 2. chronicle marker — a `[inference-failed]` message so the agent itself
3210
+ * learns its turn failed and why (otherwise it's an experiential blank,
3211
+ * indistinguishable from "not addressed"). addMessage does NOT request
3212
+ * inference, so this never causes a retry/wake loop.
3213
+ * 3. escalation — after N consecutive failures the agent is hard-down;
3214
+ * log that loudly (a repeated identical failure is the textbook signal).
3215
+ */
3216
+ noteInferenceExhausted(agentName, reason) {
3217
+ const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
3218
+ this.consecutiveInferenceFailures.set(agentName, streak);
3219
+ // (1) Durable stderr line — works in headless/daemon mode with no client.
3220
+ console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
3221
+ // (2) Agent-facing chronicle marker (no inference triggered → no loop).
3222
+ const agent = this.agents.get(agentName);
3223
+ if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
3224
+ try {
3225
+ agent.getContextManager().addMessage('user', [{
3226
+ type: 'text',
3227
+ text: `[inference-failed] Your previous turn did not complete: the model ` +
3228
+ `call failed and produced no response, so nothing was sent. Reason: ` +
3229
+ `${reason}. If this recurs with the same cause, change approach ` +
3230
+ `rather than retrying identically (e.g. drop an oversized attachment ` +
3231
+ `or an unsupported setting).`,
3232
+ }], { system: true, kind: 'inference-failed', reason, consecutive: streak });
3233
+ }
3234
+ catch (err) {
3235
+ console.error(`[inference-failed] could not record chronicle marker for ${agentName}:`, err);
3236
+ }
3237
+ }
3238
+ // (3) Hard-down escalation on repeated identical failure.
3239
+ if (streak >= this.inferenceFailureEscalationThreshold) {
3240
+ console.error(`[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
3241
+ `inferences — it cannot complete a turn. Last reason: ${reason}`);
3242
+ }
3243
+ }
2088
3244
  // ==========================================================================
2089
3245
  // MCPL subsystem wiring
2090
3246
  // ==========================================================================
@@ -2125,6 +3281,38 @@ export class AgentFramework {
2125
3281
  }
2126
3282
  },
2127
3283
  shouldTriggerInference: triggerFilter,
3284
+ // Route a conversation fork's plain-text speech to its HOME channel, not
3285
+ // the process-global most-recent-inbound locus (item 3). The trunk agent
3286
+ // has no home entry, so this returns undefined and routeSpeech falls back
3287
+ // to defaultPublishChannel. `conversationAgentHomes` is the permanent
3288
+ // spawn-time binding; `channelForAgent` is the router's live binding as a
3289
+ // belt-and-suspenders fallback.
3290
+ homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName)
3291
+ ?? this.conversationRouter?.channelForAgent(agentName),
3292
+ // Route a single TRUNK agent's plain-text speech to the channel that
3293
+ // triggered its CURRENT turn (item-3 redux). connectome-host runs every
3294
+ // agent as a trunk (it never exposes conversation forks), so without this
3295
+ // a reply falls back to the process-global most-recent-inbound locus and
3296
+ // a concurrent message in another channel hijacks it. Empty for
3297
+ // heartbeat / no-trigger turns → correct global fallback.
3298
+ activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
3299
+ // A text-only turn whose speech couldn't be delivered must not vanish
3300
+ // silently: record a `[discord-send-failed]` marker in chronicle so the
3301
+ // agent sees, on her next turn, that her reply never reached the human.
3302
+ // addMessage() alone does not request inference, so this never wakes
3303
+ // her (matching the `discord-send-failed-skip` gate intent: context
3304
+ // yes, wake no).
3305
+ onRouteFailure: ({ channelId, reason, textLen }) => {
3306
+ try {
3307
+ this.addMessage('user', [{
3308
+ type: 'text',
3309
+ text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${channelId ?? 'the channel'} (${reason}). It was saved to your archive but the human did not receive it.`,
3310
+ }], { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason });
3311
+ }
3312
+ catch (err) {
3313
+ console.error('onRouteFailure: failed to record send-failure marker:', err);
3314
+ }
3315
+ },
2128
3316
  });
2129
3317
  // Host capabilities advertised during the MCP handshake
2130
3318
  const hostCapabilities = {
@@ -2241,6 +3429,19 @@ export class AgentFramework {
2241
3429
  connection.on('channels-incoming', (params, responder) => {
2242
3430
  this.channelRegistry?.handleIncoming(connection.id, params, responder);
2243
3431
  });
3432
+ // Handle host-level admin commands from a surface (e.g. Discord /undo)
3433
+ connection.on('host-command', async (params, responder) => {
3434
+ if (!responder)
3435
+ return;
3436
+ try {
3437
+ const result = await this.handleHostCommand(connection.id, params ?? {});
3438
+ responder.respond(result);
3439
+ }
3440
+ catch (error) {
3441
+ const err = error instanceof Error ? error : new Error(String(error));
3442
+ responder.respondError(-32603, err.message);
3443
+ }
3444
+ });
2244
3445
  // Handle dynamic tool list changes (notifications/tools/list_changed)
2245
3446
  connection.on('tools-list-changed', () => {
2246
3447
  this.handleToolsListChanged(connection.id);
@@ -2371,15 +3572,21 @@ export class AgentFramework {
2371
3572
  this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
2372
3573
  const startTime = Date.now();
2373
3574
  const args = (call.input && typeof call.input === 'object') ? call.input : {};
2374
- // Build state params for stateful tools (Step 8)
3575
+ // Build state params for stateful tools (Step 8).
3576
+ // Host-managed state is single-valued per server, so inject the host-managed
3577
+ // set's `state` for any call; otherwise fall back to a server-managed set's
3578
+ // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
3579
+ // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
3580
+ // that misattributes the feed's state.
2375
3581
  let stateParams;
2376
3582
  if (this.checkpointManager) {
2377
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
2378
- if (fs) {
2379
- if (this.checkpointManager.isHostManaged(serverId, fs)) {
2380
- stateParams = { state: this.checkpointManager.getCurrentState(serverId, fs) };
2381
- }
2382
- else {
3583
+ const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
3584
+ if (hostFs) {
3585
+ stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
3586
+ }
3587
+ else {
3588
+ const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3589
+ if (fs) {
2383
3590
  const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
2384
3591
  if (cp)
2385
3592
  stateParams = { checkpoint: cp };
@@ -2390,18 +3597,36 @@ export class AgentFramework {
2390
3597
  .then((result) => {
2391
3598
  const durationMs = Date.now() - startTime;
2392
3599
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
2393
- // Record checkpoint from stateful tool response (Step 8)
3600
+ // Record checkpoint from stateful tool response (Step 8).
3601
+ // Attribute to the set the server tagged (result.state.featureSet) first;
3602
+ // else the host-managed set (host-managed checkpoints carry data/patch);
3603
+ // else the single stateful set. Avoids recording a feed checkpoint onto a
3604
+ // server-managed set just because it registered first.
2394
3605
  if (result.state && this.checkpointManager) {
2395
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
2396
- if (fs) {
3606
+ const tagged = result.state.featureSet;
3607
+ const fs = tagged
3608
+ ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
3609
+ ?? this.checkpointManager.getStatefulFeatureSet(serverId);
3610
+ if (fs && this.checkpointManager.isStateful(serverId, fs)) {
2397
3611
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
2398
3612
  }
2399
3613
  }
2400
- // Convert MCP tool result to framework ToolResult
3614
+ // Convert MCP tool result to framework ToolResult.
3615
+ // When the result contains non-text blocks (e.g. images from an MCP
3616
+ // tool like zulip-mcp's fetch_attachment), pass the full content array
3617
+ // through so toMembraneToolResult can preserve image blocks natively.
3618
+ // Text-only results still collapse to a joined string for backward
3619
+ // compatibility with callers that expect data to be string-ish.
2401
3620
  const textContent = result.content
2402
3621
  ?.filter((c) => c.type === 'text' && c.text)
2403
3622
  .map((c) => c.text)
2404
3623
  .join('\n');
3624
+ const hasNonText = result.content?.some((c) => c.type !== 'text');
3625
+ const data = result.isError
3626
+ ? undefined
3627
+ : hasNonText
3628
+ ? result.content
3629
+ : (textContent || undefined);
2405
3630
  this.pushEvent({
2406
3631
  type: 'tool-result',
2407
3632
  callId: call.id,
@@ -2409,7 +3634,7 @@ export class AgentFramework {
2409
3634
  moduleName: `mcpl:${serverId}`,
2410
3635
  result: {
2411
3636
  success: !result.isError,
2412
- data: result.isError ? undefined : textContent || undefined,
3637
+ data,
2413
3638
  error: result.isError ? (textContent || 'Tool call failed') : undefined,
2414
3639
  isError: result.isError ?? false,
2415
3640
  },
@@ -2434,6 +3659,42 @@ export class AgentFramework {
2434
3659
  * Dispatch a synthesized channel tool call.
2435
3660
  */
2436
3661
  dispatchChannelToolCall(agentName, call) {
3662
+ // Conversation forks act only on their home channel. channel_publish and
3663
+ // channel_close default a missing channelId to home and reject foreign
3664
+ // ones; channel_open is rejected outright — opening channels mutates
3665
+ // framework-global state (the open-channel set every agent's injections
3666
+ // are scoped against), which is not a fork's call to make.
3667
+ const home = this.conversationAgentHomes.get(agentName);
3668
+ if (home) {
3669
+ const reject = (error) => {
3670
+ this.emitTrace({
3671
+ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
3672
+ error: `conversation agent ${agentName}: ${error}`,
3673
+ });
3674
+ this.pushEvent({
3675
+ type: 'tool-result',
3676
+ callId: call.id,
3677
+ agentName,
3678
+ moduleName: 'channels',
3679
+ result: { success: false, error, isError: true },
3680
+ });
3681
+ };
3682
+ if (call.name === 'channel_open') {
3683
+ reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`);
3684
+ return;
3685
+ }
3686
+ if (call.name === 'channel_publish' || call.name === 'channel_close') {
3687
+ const input = (call.input ?? {});
3688
+ if (!input.channelId) {
3689
+ call = { ...call, input: { ...input, channelId: home } };
3690
+ }
3691
+ else if (input.channelId !== home) {
3692
+ const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing';
3693
+ reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`);
3694
+ return;
3695
+ }
3696
+ }
3697
+ }
2437
3698
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
2438
3699
  const startTime = Date.now();
2439
3700
  this.channelRegistry.handleChannelToolCall(call.name, call.input)
@@ -2460,6 +3721,159 @@ export class AgentFramework {
2460
3721
  });
2461
3722
  });
2462
3723
  }
3724
+ /**
3725
+ * Handle the synthesized `wake_add_rule` / `wake_remove_rule` tools: mutate
3726
+ * the hot-reloaded gate.json at runtime. Validation lives in the EventGate
3727
+ * (same path as gate.json load), so an invalid rule is surfaced as a tool
3728
+ * error and nothing is written.
3729
+ */
3730
+ dispatchWakeRuleToolCall(agentName, call) {
3731
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
3732
+ const finish = (result) => {
3733
+ this.emitTrace({
3734
+ type: result.isError ? 'tool:failed' : 'tool:completed',
3735
+ module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
3736
+ ...(result.isError ? { error: result.error } : {}),
3737
+ });
3738
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
3739
+ };
3740
+ try {
3741
+ if (call.name === 'wake_remove_rule') {
3742
+ const input = (call.input ?? {});
3743
+ if (typeof input.name !== 'string' || !input.name) {
3744
+ finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
3745
+ return;
3746
+ }
3747
+ const removed = this.removeGatePolicy(input.name);
3748
+ finish({
3749
+ success: true,
3750
+ data: { removed, name: input.name, policies: this.getGatePolicyNames() },
3751
+ });
3752
+ return;
3753
+ }
3754
+ // wake_add_rule — assemble the canonical behavior from the typed fields
3755
+ // (exactly one), then let the gate's own validator do the authoritative
3756
+ // range/shape checks.
3757
+ const input = (call.input ?? {});
3758
+ const behaviorSources = [
3759
+ input.behavior !== undefined ? 'behavior' : null,
3760
+ input.debounceMs !== undefined ? 'debounceMs' : null,
3761
+ input.rateLimit !== undefined ? 'rateLimit' : null,
3762
+ input.passiveSample !== undefined ? 'passiveSample' : null,
3763
+ ].filter((s) => s !== null);
3764
+ if (behaviorSources.length === 0) {
3765
+ finish({
3766
+ success: false,
3767
+ error: 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
3768
+ isError: true,
3769
+ });
3770
+ return;
3771
+ }
3772
+ if (behaviorSources.length > 1) {
3773
+ finish({
3774
+ success: false,
3775
+ error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
3776
+ isError: true,
3777
+ });
3778
+ return;
3779
+ }
3780
+ const behavior = input.debounceMs !== undefined ? { debounce: input.debounceMs }
3781
+ : input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
3782
+ : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
3783
+ : input.behavior;
3784
+ const position = input.position === 'prepend' ? 'prepend' : 'append';
3785
+ const rawPolicy = {
3786
+ name: input.name,
3787
+ match: input.match ?? {},
3788
+ behavior,
3789
+ ...(input.resets !== undefined ? { resets: input.resets } : {}),
3790
+ };
3791
+ const policy = this.addGatePolicy(rawPolicy, { position });
3792
+ finish({
3793
+ success: true,
3794
+ data: { added: policy.name, policies: this.getGatePolicyNames() },
3795
+ });
3796
+ }
3797
+ catch (err) {
3798
+ const msg = err instanceof Error ? err.message : String(err);
3799
+ finish({ success: false, error: msg, isError: true });
3800
+ }
3801
+ }
3802
+ /**
3803
+ * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
3804
+ * suppression window, optionally announces in the sticky channel, and ends
3805
+ * the turn (the agent goes idle immediately). `wake` clears sleep.
3806
+ */
3807
+ dispatchSleepToolCall(agentName, call) {
3808
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
3809
+ const gate = this.eventGate;
3810
+ const input = (call.input ?? {});
3811
+ const finish = (result) => {
3812
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
3813
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
3814
+ };
3815
+ if (call.name === 'wake') {
3816
+ const was = gate.clearSleep();
3817
+ finish({ success: true, data: { woke: was } });
3818
+ return;
3819
+ }
3820
+ const seconds = Number(input.seconds);
3821
+ if (!Number.isFinite(seconds) || seconds <= 0) {
3822
+ finish({ success: false, error: 'sleep: `seconds` must be a positive number', isError: true });
3823
+ return;
3824
+ }
3825
+ const { until } = gate.setSleep(seconds, input.message);
3826
+ const announce = input.announce !== false; // default true
3827
+ const human = seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
3828
+ : seconds >= 60 ? `${Math.round(seconds / 60)}m`
3829
+ : `${Math.round(seconds)}s`;
3830
+ // Announce in the sticky channel (best-effort; never blocks the result).
3831
+ if (announce && this.channelRegistry) {
3832
+ const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
3833
+ this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
3834
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
3835
+ });
3836
+ }
3837
+ console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
3838
+ // endTurn: the agent stops here and goes idle for the duration.
3839
+ finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
3840
+ }
3841
+ /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
3842
+ * server's declared tag ontology + gate.js status. */
3843
+ buildEventTagsResult() {
3844
+ const servers = {};
3845
+ for (const conn of this.mcplServerRegistry?.getAllServers() ?? []) {
3846
+ const declared = this.featureSetManager?.getDeclaredFeatureSets(conn.id) ?? {};
3847
+ const sets = {};
3848
+ for (const [name, decl] of Object.entries(declared)) {
3849
+ if (decl.tagOntology)
3850
+ sets[name] = decl.tagOntology;
3851
+ }
3852
+ if (Object.keys(sets).length > 0)
3853
+ servers[conn.id] = sets;
3854
+ }
3855
+ return {
3856
+ core: AgentFramework.CHAT_CORE_TAGS,
3857
+ servers,
3858
+ gateScript: this.eventGate?.getStatus().script ?? null,
3859
+ hint: 'Use these in gate.json policies (match.tagsAny / tagsAll / tagsNone) or ' +
3860
+ 'in gate.js. Unknown/undeclared tags are tolerated (open ontologies).',
3861
+ };
3862
+ }
3863
+ dispatchEventTagsToolCall(agentName, call) {
3864
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
3865
+ let result;
3866
+ try {
3867
+ result = { success: true, data: this.buildEventTagsResult() };
3868
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
3869
+ }
3870
+ catch (error) {
3871
+ const err = error instanceof Error ? error : new Error(String(error));
3872
+ this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
3873
+ result = { success: false, error: err.message, isError: true };
3874
+ }
3875
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
3876
+ }
2463
3877
  dispatchGateToolCall(agentName, call) {
2464
3878
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
2465
3879
  const startTime = Date.now();
@@ -2491,7 +3905,13 @@ export class AgentFramework {
2491
3905
  const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2492
3906
  return {
2493
3907
  inferenceId,
2494
- conversationId: agent.name, // Simplified; proper conversation tracking TODO
3908
+ // Conversation identity = the agent (a trunk agent IS its own conversation;
3909
+ // forks get their own agent). The turn's channel LOCUS — the "proper
3910
+ // conversation tracking" this once flagged as a TODO — is now tracked
3911
+ // per-agent in `activeTriggerChannels` and surfaced to the agent via
3912
+ // buildChannelContext (channels.defaultOutgoing) below, so the agent is
3913
+ // told the same channel its speech will route to (item-3 redux).
3914
+ conversationId: agent.name,
2495
3915
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
2496
3916
  userMessage: null, // Could extract from trigger context
2497
3917
  model: {
@@ -2500,7 +3920,7 @@ export class AgentFramework {
2500
3920
  contextWindow: 200000,
2501
3921
  capabilities: ['tools'],
2502
3922
  },
2503
- channels: this.channelRegistry?.buildChannelContext(),
3923
+ channels: this.channelRegistry?.buildChannelContext(agent.name),
2504
3924
  };
2505
3925
  }
2506
3926
  }