@animalabs/agent-framework 0.5.4 → 0.5.7

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 (101) 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 +153 -5
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1046 -56
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +27 -0
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +140 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/types.d.ts +17 -0
  14. package/dist/src/gate/types.d.ts.map +1 -1
  15. package/dist/src/index.d.ts +2 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +2 -0
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +50 -0
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +180 -0
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/conversation-router.d.ts +161 -0
  24. package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
  25. package/dist/src/mcpl/conversation-router.js +179 -0
  26. package/dist/src/mcpl/conversation-router.js.map +1 -0
  27. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  28. package/dist/src/mcpl/feature-set-manager.js +10 -1
  29. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  30. package/dist/src/mcpl/index.d.ts +2 -0
  31. package/dist/src/mcpl/index.d.ts.map +1 -1
  32. package/dist/src/mcpl/index.js +2 -0
  33. package/dist/src/mcpl/index.js.map +1 -1
  34. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  35. package/dist/src/mcpl/server-connection.js +4 -0
  36. package/dist/src/mcpl/server-connection.js.map +1 -1
  37. package/dist/src/module-registry.d.ts +3 -1
  38. package/dist/src/module-registry.d.ts.map +1 -1
  39. package/dist/src/module-registry.js +9 -2
  40. package/dist/src/module-registry.js.map +1 -1
  41. package/dist/src/modules/discord/index.d.ts +10 -0
  42. package/dist/src/modules/discord/index.d.ts.map +1 -1
  43. package/dist/src/modules/discord/index.js +83 -0
  44. package/dist/src/modules/discord/index.js.map +1 -1
  45. package/dist/src/tool-result-history.d.ts +17 -0
  46. package/dist/src/tool-result-history.d.ts.map +1 -0
  47. package/dist/src/tool-result-history.js +60 -0
  48. package/dist/src/tool-result-history.js.map +1 -0
  49. package/dist/src/types/agent.d.ts +11 -0
  50. package/dist/src/types/agent.d.ts.map +1 -1
  51. package/dist/src/types/framework.d.ts +8 -0
  52. package/dist/src/types/framework.d.ts.map +1 -1
  53. package/dist/src/types/module.d.ts +15 -0
  54. package/dist/src/types/module.d.ts.map +1 -1
  55. package/dist/src/types/trace.d.ts +48 -0
  56. package/dist/src/types/trace.d.ts.map +1 -1
  57. package/dist/test/channel-registry-routing.test.d.ts +2 -0
  58. package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
  59. package/dist/test/channel-registry-routing.test.js +75 -0
  60. package/dist/test/channel-registry-routing.test.js.map +1 -0
  61. package/dist/test/conversation-router.test.d.ts +2 -0
  62. package/dist/test/conversation-router.test.d.ts.map +1 -0
  63. package/dist/test/conversation-router.test.js +160 -0
  64. package/dist/test/conversation-router.test.js.map +1 -0
  65. package/dist/test/conversation-routing.test.d.ts +7 -0
  66. package/dist/test/conversation-routing.test.d.ts.map +1 -0
  67. package/dist/test/conversation-routing.test.js +203 -0
  68. package/dist/test/conversation-routing.test.js.map +1 -0
  69. package/dist/test/framework.test.js +120 -2
  70. package/dist/test/framework.test.js.map +1 -1
  71. package/dist/test/helpers/mock-membrane.d.ts +41 -0
  72. package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
  73. package/dist/test/helpers/mock-membrane.js +161 -0
  74. package/dist/test/helpers/mock-membrane.js.map +1 -0
  75. package/dist/test/inference-failure-observability.test.d.ts +2 -0
  76. package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
  77. package/dist/test/inference-failure-observability.test.js +75 -0
  78. package/dist/test/inference-failure-observability.test.js.map +1 -0
  79. package/dist/test/tool-result-history.test.d.ts +7 -0
  80. package/dist/test/tool-result-history.test.d.ts.map +1 -0
  81. package/dist/test/tool-result-history.test.js +77 -0
  82. package/dist/test/tool-result-history.test.js.map +1 -0
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +6 -2
  85. package/src/agent.ts +53 -18
  86. package/src/framework.ts +1202 -59
  87. package/src/gate/event-gate.ts +144 -1
  88. package/src/gate/types.ts +17 -0
  89. package/src/index.ts +12 -0
  90. package/src/mcpl/channel-registry.ts +221 -0
  91. package/src/mcpl/conversation-router.ts +284 -0
  92. package/src/mcpl/feature-set-manager.ts +14 -1
  93. package/src/mcpl/index.ts +12 -0
  94. package/src/mcpl/server-connection.ts +4 -0
  95. package/src/module-registry.ts +14 -2
  96. package/src/modules/discord/index.ts +93 -0
  97. package/src/tool-result-history.ts +57 -0
  98. package/src/types/agent.ts +11 -0
  99. package/src/types/framework.ts +9 -0
  100. package/src/types/module.ts +16 -0
  101. package/src/types/trace.ts +57 -0
@@ -12,12 +12,15 @@ 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';
16
18
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
17
19
  import { isToolAllowed } from './mcpl/tool-policy.js';
18
20
  import { EventGate } from './gate/event-gate.js';
19
21
  import { UsageTracker } from './usage/usage-tracker.js';
20
22
  const FRAMEWORK_STATE_ID = 'framework/state';
23
+ const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
21
24
  const INFERENCE_LOG_ID = 'framework/inference-log';
22
25
  const PROCESS_LOG_ID = 'framework/process-log';
23
26
  const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
@@ -91,9 +94,6 @@ function extractGateFields(event) {
91
94
  : undefined;
92
95
  return { content, metadata, mount, paths };
93
96
  }
94
- /**
95
- * The main agent framework.
96
- */
97
97
  export class AgentFramework {
98
98
  store;
99
99
  ownsStore;
@@ -113,6 +113,12 @@ export class AgentFramework {
113
113
  processLoggingBroadcast;
114
114
  activeStreams = new Map();
115
115
  pendingAssistantBlocks = new Map();
116
+ /** Per-agent count of consecutive exhausted inferences (reset on any success).
117
+ * Drives hard-down escalation — see noteInferenceExhausted. */
118
+ consecutiveInferenceFailures = new Map();
119
+ /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
120
+ * escalated loudly to stderr. */
121
+ inferenceFailureEscalationThreshold = 3;
116
122
  /** Name of the primary (non-ephemeral) agent for routing framework messages. */
117
123
  primaryAgentName = null;
118
124
  // Messages deferred while an agent is waiting_for_tools (to preserve
@@ -130,6 +136,21 @@ export class AgentFramework {
130
136
  inferenceRouter = null;
131
137
  channelRegistry = null;
132
138
  checkpointManager = null;
139
+ /** Per-channel conversation routing (null unless config.conversations set). */
140
+ conversationRouter = null;
141
+ /** Agent configs by name — fork agents are built from the template's config. */
142
+ agentConfigs = new Map();
143
+ /**
144
+ * Fork agent → its home channel. Permanent (unlike router bindings, which
145
+ * expire): publish/injection scoping must survive unbinding so the closure
146
+ * turn still lands in the right channel.
147
+ */
148
+ conversationAgentHomes = new Map();
149
+ /** Last idle-TTL sweep timestamp. */
150
+ lastConversationSweep = 0;
151
+ /** Forks whose TTL closure turn has been queued — disposed (removed from
152
+ * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
153
+ closingConversationAgents = new Set();
133
154
  mcplTools = [];
134
155
  mcplToolRefreshInFlight = false;
135
156
  mcplToolRefreshPending = false;
@@ -163,6 +184,7 @@ export class AgentFramework {
163
184
  getMessage: (id) => this.getMessage(id),
164
185
  queryMessages: (filter) => this.queryMessages(filter),
165
186
  pushEvent: (event) => this.pushEvent(event),
187
+ onTrace: (listener) => this.onTrace(listener),
166
188
  });
167
189
  }
168
190
  /**
@@ -235,6 +257,32 @@ export class AgentFramework {
235
257
  for (const module of config.modules) {
236
258
  await framework.addModule(module);
237
259
  }
260
+ // Initialize per-channel conversation routing (if configured)
261
+ if (config.conversations) {
262
+ if (!framework.agents.has(config.conversations.templateAgent)) {
263
+ throw new Error(`conversations.templateAgent "${config.conversations.templateAgent}" ` +
264
+ `is not a configured agent`);
265
+ }
266
+ framework.conversationRouter = new ConversationRouter(config.conversations);
267
+ // Generation counters persist across restarts — reusing generation 1's
268
+ // agent name after a restart would reopen (and re-seed) the previous
269
+ // engagement's Chronicle namespace.
270
+ try {
271
+ store.registerState({ id: CONVERSATION_ROUTER_STATE_ID, strategy: 'snapshot' });
272
+ }
273
+ catch {
274
+ // Already registered
275
+ }
276
+ try {
277
+ const data = store.getStateJson(CONVERSATION_ROUTER_STATE_ID);
278
+ if (data?.generations) {
279
+ framework.conversationRouter.hydrateGenerations(data.generations);
280
+ }
281
+ }
282
+ catch {
283
+ // No persisted state yet
284
+ }
285
+ }
238
286
  // Initialize EventGate if configured (before MCPL so it can be wired as trigger filter)
239
287
  if (config.gate) {
240
288
  const configPath = config.gate.configPath
@@ -244,6 +292,7 @@ export class AgentFramework {
244
292
  framework.eventGate = new EventGate({
245
293
  configPath,
246
294
  initialConfig: config.gate.config,
295
+ privilegedUsersPath: config.gate.privilegedUsersPath,
247
296
  emitTrace: (e) => framework.emitTrace(e),
248
297
  addMessage: (p, c, m) => framework.addMessage(p, c, m),
249
298
  requestInference: (agentName, reason, source) => {
@@ -351,6 +400,14 @@ export class AgentFramework {
351
400
  */
352
401
  onTrace(listener) {
353
402
  this.traceListeners.push(listener);
403
+ // Return an unsubscribe so callers with a bounded lifetime (e.g. modules
404
+ // that get torn down and recreated on session switch) don't leak
405
+ // listeners. Existing callers that ignore the return value are unaffected.
406
+ return () => {
407
+ const idx = this.traceListeners.indexOf(listener);
408
+ if (idx >= 0)
409
+ this.traceListeners.splice(idx, 1);
410
+ };
354
411
  }
355
412
  /**
356
413
  * Public accessor for the MCPL channel registry.
@@ -396,6 +453,13 @@ export class AgentFramework {
396
453
  getAllAgents() {
397
454
  return Array.from(this.agents.values());
398
455
  }
456
+ /**
457
+ * Get the per-channel conversation router (null unless config.conversations
458
+ * was set). Exposed for modules/UIs that surface active engagements.
459
+ */
460
+ getConversationRouter() {
461
+ return this.conversationRouter;
462
+ }
399
463
  /**
400
464
  * Abort an in-flight inference for an agent.
401
465
  */
@@ -422,12 +486,79 @@ export class AgentFramework {
422
486
  getAllTools() {
423
487
  const moduleTools = this.moduleRegistry.getAllTools();
424
488
  const channelTools = this.channelRegistry?.getChannelTools() ?? [];
425
- const gateTools = this.eventGate ? [this.eventGate.getToolDefinition()] : [];
489
+ const gateTools = this.eventGate
490
+ ? [this.eventGate.getToolDefinition(), ...AgentFramework.SLEEP_TOOLS]
491
+ : [];
426
492
  if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
427
493
  return moduleTools;
428
494
  }
429
495
  return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
430
496
  }
497
+ /**
498
+ * Build the membrane-normalized request that WOULD be emitted if `agentName`
499
+ * were activated right now — WITHOUT running inference, opening a stream, or
500
+ * mutating agent state. Intended for debug/preview tooling.
501
+ *
502
+ * Transparency contract (default): the preview is side-effect-free and
503
+ * leaves no trace on the system. It does only read-only work —
504
+ * `ContextManager.compile` (which never triggers compression itself;
505
+ * compression runs in the background out-of-band), tool filtering, and
506
+ * system-prompt assembly — then delegates to `Agent.buildActivationRequest`.
507
+ * No tokens are spent, nothing is written to Chronicle, and no external
508
+ * MCPL server is contacted.
509
+ *
510
+ * The trade-off is fidelity: the dynamically-gathered ContextInjection[]
511
+ * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
512
+ * by default, because gathering them is not transparent —
513
+ * - module `gatherContext` can run inference (e.g. RetrievalModule makes
514
+ * Haiku calls — real token cost and latency), and
515
+ * - MCPL `beforeInference` hooks are arbitrary RPCs to external servers
516
+ * with side effects, and a preview never sends the paired
517
+ * `afterInference`, which can leave a stateful server half-open.
518
+ *
519
+ * Pass `{ injections: true }` to opt into full-fidelity gathering and accept
520
+ * those side effects (byte-faithful to a real activation's injected context).
521
+ */
522
+ async previewActivation(agentName, opts) {
523
+ const agent = this.agents.get(agentName);
524
+ if (!agent) {
525
+ throw new Error(`Agent not found: ${agentName}`);
526
+ }
527
+ const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
528
+ // Default: no dynamic injection gathering → fully transparent (no
529
+ // inference, no Chronicle writes, no external RPC). Opt in explicitly.
530
+ if (!opts?.injections) {
531
+ return agent.buildActivationRequest(tools, undefined);
532
+ }
533
+ // Full-fidelity path: mirrors startAgentStream's injection gathering.
534
+ // NOT transparent — see the doc comment above.
535
+ let injections;
536
+ // Module gatherContext (fail-open, matches startAgentStream)
537
+ try {
538
+ const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
539
+ if (moduleInjections.length > 0) {
540
+ injections = moduleInjections;
541
+ }
542
+ }
543
+ catch (error) {
544
+ console.error('Module gatherContext error (preview):', error);
545
+ }
546
+ // MCPL beforeInference hooks (fail-open). Note: the paired afterInference
547
+ // is intentionally never sent here — this is a preview, not a real turn.
548
+ if (this.hookOrchestrator) {
549
+ try {
550
+ const hookParams = this.buildBeforeInferenceParams(agent);
551
+ const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
552
+ if (hookInjections.length > 0) {
553
+ injections = injections ? [...injections, ...hookInjections] : hookInjections;
554
+ }
555
+ }
556
+ catch (error) {
557
+ console.error('beforeInference hook error (preview):', error);
558
+ }
559
+ }
560
+ return agent.buildActivationRequest(tools, injections);
561
+ }
431
562
  /**
432
563
  * Check if process logging is enabled.
433
564
  */
@@ -666,6 +797,268 @@ export class AgentFramework {
666
797
  * Query inference logs.
667
798
  * Returns entries with summary info (doesn't resolve blobs).
668
799
  */
800
+ /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
801
+ static SLEEP_TOOLS = [
802
+ {
803
+ name: 'sleep',
804
+ description: 'Go quiet: suppress external pings and wakes for a number of seconds. ' +
805
+ 'Messages still accumulate in your context — you just won’t be woken to ' +
806
+ 'respond to them until the window passes. Your heartbeat still beats: you’ll ' +
807
+ 'briefly rouse on each tick and can keep resting or call `wake` to get up early. ' +
808
+ 'Privileged users can also still reach you. By default announces in your current channel.',
809
+ inputSchema: {
810
+ type: 'object',
811
+ properties: {
812
+ seconds: { type: 'number', description: 'How long to stay asleep, in seconds.' },
813
+ announce: {
814
+ type: 'boolean',
815
+ description: 'Announce the sleep in the current sticky channel (default true).',
816
+ },
817
+ message: {
818
+ type: 'string',
819
+ description: 'Optional custom announcement text (overrides the default).',
820
+ },
821
+ },
822
+ required: ['seconds'],
823
+ },
824
+ },
825
+ {
826
+ name: 'wake',
827
+ description: 'End your current sleep early, resuming normal wakes immediately.',
828
+ inputSchema: { type: 'object' },
829
+ },
830
+ ];
831
+ /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
832
+ static REFUSAL_REACTIONS = {
833
+ bio: '☣️',
834
+ chem: '🧪',
835
+ nuclear: '☢️',
836
+ cyber: '💻',
837
+ reasoning_extraction: '🧠',
838
+ };
839
+ /**
840
+ * Mark an inference refusal visibly: react on the message that holds the
841
+ * conversational locus (the most recent incoming channel message) with a
842
+ * category-specific emoji. Best-effort — failures are logged, never thrown,
843
+ * and non-Discord loci are silently skipped.
844
+ */
845
+ async reactToRefusal(agentName, category) {
846
+ try {
847
+ const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
848
+ if (!incoming)
849
+ return;
850
+ // incoming.channelId is the MCPL composite id ("discord:<guild>:<channel>");
851
+ // the reaction tool wants the raw Discord channel (or thread) id — the
852
+ // last segment.
853
+ const parts = incoming.channelId.split(':');
854
+ if (parts[0] !== 'discord')
855
+ return;
856
+ const channelId = parts[parts.length - 1];
857
+ const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
858
+ // Resolve the MCPL server that owns the locus channel and call
859
+ // tools/call directly on its connection (bare tool name — no prefix
860
+ // games), bypassing the agent event queue so no synthetic tool-result
861
+ // is injected into a turn the agent never took.
862
+ const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
863
+ const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
864
+ if (!server) {
865
+ console.error(`[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`);
866
+ return;
867
+ }
868
+ await server.sendToolsCall('add_reaction', {
869
+ channelId,
870
+ messageId: incoming.messageId,
871
+ emoji,
872
+ });
873
+ }
874
+ catch (err) {
875
+ console.error('[inference-refusal] reaction failed:', err instanceof Error ? err.message : err);
876
+ }
877
+ }
878
+ /**
879
+ * Handle a `host/command` request from an MCPL surface server (e.g. a
880
+ * Discord slash command). Currently supports:
881
+ *
882
+ * undo — revert the last N inference turns (branch-based, see
883
+ * `undoLastTurn`). The response includes the last message the agent
884
+ * would see in its context after the undo, obtained via the transparent
885
+ * `previewActivation` render (no inference, no Chronicle writes).
886
+ */
887
+ async handleHostCommand(serverId, params) {
888
+ if (params.command !== 'undo' && params.command !== 'hide') {
889
+ return { ok: false, error: `Unknown host command: ${String(params.command)}` };
890
+ }
891
+ const agentName = params.agentName ?? [...this.agents.keys()][0];
892
+ if (!agentName || !this.agents.has(agentName)) {
893
+ return { ok: false, error: `Unknown agent: ${String(agentName)}` };
894
+ }
895
+ // hide: redact a single message (or an inclusive range) from the active
896
+ // branch, addressed by Discord message id. Unlike undo this is a
897
+ // removal-in-place (chronicle redact), not a branch rewind.
898
+ if (params.command === 'hide') {
899
+ const agent = this.agents.get(agentName);
900
+ if (agent.state.status !== 'idle') {
901
+ return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
902
+ }
903
+ if (!params.fromMessageId) {
904
+ return { ok: false, error: 'hide: fromMessageId is required' };
905
+ }
906
+ const cm = agent.getContextManager();
907
+ const all = cm.getAllMessages();
908
+ const byDiscordId = (did) => all.findIndex((m) => String(m.metadata?.messageId) === String(did));
909
+ // Collect Discord refs of every message in [lo, hi] that carries one,
910
+ // so the surface can mark them. channelId here is whatever the ingest
911
+ // path stored (raw id or "discord:guild:channel"); the surface
912
+ // normalizes it.
913
+ const refsIn = (lo, hi) => {
914
+ const refs = [];
915
+ for (let i = lo; i <= hi; i++) {
916
+ const md = all[i].metadata;
917
+ if (md?.messageId && md?.channelId) {
918
+ refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
919
+ }
920
+ }
921
+ return refs;
922
+ };
923
+ const fromIdx = byDiscordId(params.fromMessageId);
924
+ if (fromIdx < 0) {
925
+ return {
926
+ ok: false,
927
+ error: `Message ${params.fromMessageId} is not an addressable message in context (it may only exist inside backscroll text, or predates this session).`,
928
+ };
929
+ }
930
+ try {
931
+ if (params.toMessageId) {
932
+ const toIdx = byDiscordId(params.toMessageId);
933
+ if (toIdx < 0) {
934
+ return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
935
+ }
936
+ const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
937
+ const refs = refsIn(lo, hi);
938
+ cm.removeMessages(all[lo].id, all[hi].id);
939
+ console.error(`[host-command] hide agent=${agentName} range removed=${hi - lo + 1} ` +
940
+ `(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
941
+ return {
942
+ ok: true,
943
+ hidden: hi - lo + 1,
944
+ hiddenRefs: refs,
945
+ lastVisible: await this.lastVisiblePreview(agentName),
946
+ };
947
+ }
948
+ const refs = refsIn(fromIdx, fromIdx);
949
+ cm.removeMessage(all[fromIdx].id);
950
+ console.error(`[host-command] hide agent=${agentName} removed=1 (${params.fromMessageId}) ` +
951
+ `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
952
+ return {
953
+ ok: true,
954
+ hidden: 1,
955
+ hiddenRefs: refs,
956
+ lastVisible: await this.lastVisiblePreview(agentName),
957
+ };
958
+ }
959
+ catch (error) {
960
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
961
+ }
962
+ }
963
+ // Message-granular mode: branch the chronicle at the message that should
964
+ // become the new tail (ContextManager.branchAt — origin-sequence-based
965
+ // time-travel branching; see chronicle's rfc-state-item-origins addendum).
966
+ // No turn checkpoints involved, so this reaches past turn boundaries.
967
+ if (typeof params.messages === 'number' && params.messages > 0) {
968
+ const agent = this.agents.get(agentName);
969
+ if (agent.state.status !== 'idle') {
970
+ return { ok: false, error: `Cannot undo while agent is ${agent.state.status}` };
971
+ }
972
+ const n = Math.max(1, Math.min(50, Math.floor(params.messages)));
973
+ const cm = agent.getContextManager();
974
+ const allMessages = cm.getAllMessages();
975
+ if (n >= allMessages.length) {
976
+ return {
977
+ ok: false,
978
+ error: `Cannot remove ${n} message(s) — history has ${allMessages.length}; at least one must remain.`,
979
+ };
980
+ }
981
+ const target = allMessages[allMessages.length - 1 - n];
982
+ const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
983
+ await cm.switchBranch(branchName);
984
+ // Materialize config files from the new branch (fire-and-forget; gate
985
+ // picks up via mtime) — mirrors undoLastTurn.
986
+ const wsUndo = this.moduleRegistry.getModule('workspace');
987
+ if (wsUndo && 'materializeMount' in wsUndo) {
988
+ wsUndo
989
+ .materializeMount('_config')
990
+ .catch(() => { });
991
+ }
992
+ console.error(`[host-command] undo-messages agent=${agentName} removed=${n} branch=${branchName}` +
993
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
994
+ return {
995
+ ok: true,
996
+ messagesRemoved: n,
997
+ lastVisible: await this.lastVisiblePreview(agentName),
998
+ };
999
+ }
1000
+ const requested = Math.max(1, Math.min(20, Math.floor(params.turns ?? 1)));
1001
+ let undone = 0;
1002
+ try {
1003
+ for (let i = 0; i < requested; i++) {
1004
+ const r = this.undoLastTurn(agentName);
1005
+ if (!r.undone)
1006
+ break;
1007
+ undone++;
1008
+ }
1009
+ }
1010
+ catch (error) {
1011
+ // e.g. "Cannot undo while agent is streaming" — report what happened,
1012
+ // including any turns already undone before the failure.
1013
+ const msg = error instanceof Error ? error.message : String(error);
1014
+ if (undone === 0)
1015
+ return { ok: false, error: msg };
1016
+ console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
1017
+ }
1018
+ console.error(`[host-command] undo agent=${agentName} requested=${requested} undone=${undone}` +
1019
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
1020
+ if (undone === 0) {
1021
+ return { ok: true, undone: 0, requested, lastVisible: null };
1022
+ }
1023
+ return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
1024
+ }
1025
+ /**
1026
+ * The last message the agent would see in its context right now, via the
1027
+ * transparent `previewActivation` render (no inference, no Chronicle
1028
+ * writes). Returns null if the preview fails or the context is empty.
1029
+ */
1030
+ async lastVisiblePreview(agentName) {
1031
+ try {
1032
+ const preview = await this.previewActivation(agentName);
1033
+ const messages = preview.messages ?? [];
1034
+ const last = messages[messages.length - 1];
1035
+ if (!last)
1036
+ return null;
1037
+ const blocks = Array.isArray(last.content) ? last.content : [];
1038
+ const text = blocks
1039
+ .map((b) => (b && typeof b === 'object' && 'text' in b ? String(b.text) : ''))
1040
+ .join(' ')
1041
+ .trim();
1042
+ // Surface-injected preambles (<system>…</system>, <backscroll>…
1043
+ // </backscroll>) can dominate the head of a bundled incoming message
1044
+ // and bury the actual conversational line. Strip them for the
1045
+ // preview; fall back to the raw text if nothing else remains.
1046
+ const stripped = text
1047
+ .replace(/<system>[\s\S]*?<\/system>/g, '')
1048
+ .replace(/<backscroll\b[\s\S]*?<\/backscroll>/g, '')
1049
+ .trim();
1050
+ const body = stripped.length > 0 ? stripped : text;
1051
+ return {
1052
+ participant: last.participant,
1053
+ role: last.role,
1054
+ preview: body.length > 400 ? `${body.slice(0, 400)}…` : body,
1055
+ };
1056
+ }
1057
+ catch (error) {
1058
+ console.error('[host-command] post-undo context preview failed:', error instanceof Error ? error.message : error);
1059
+ return null;
1060
+ }
1061
+ }
669
1062
  queryInferenceLogs(query) {
670
1063
  const limit = query?.limit ?? 50;
671
1064
  const offset = query?.offset ?? 0;
@@ -1052,6 +1445,7 @@ export class AgentFramework {
1052
1445
  });
1053
1446
  const agent = new Agent(config, contextManager, this.membrane);
1054
1447
  this.agents.set(config.name, agent);
1448
+ this.agentConfigs.set(config.name, config);
1055
1449
  // First non-ephemeral agent becomes the primary for message routing
1056
1450
  if (!this.primaryAgentName) {
1057
1451
  this.primaryAgentName = config.name;
@@ -1074,6 +1468,8 @@ export class AgentFramework {
1074
1468
  if (event) {
1075
1469
  await this.handleProcessEvent(event);
1076
1470
  }
1471
+ // Close idle conversation forks (no-op unless conversations configured)
1472
+ this.sweepExpiredConversations();
1077
1473
  // Check for inference requests
1078
1474
  await this.processInferenceRequests();
1079
1475
  // Yield to the event loop between iterations.
@@ -1124,26 +1520,18 @@ export class AgentFramework {
1124
1520
  }
1125
1521
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
1126
1522
  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
- });
1523
+ // Store tool results as a user message (tool_result blocks).
1524
+ // Use the history serializer so MCP image blocks become a short
1525
+ // `[image: type, size]` placeholder instead of megabytes of base64
1526
+ // that would corrupt under truncation.
1527
+ const toolResultContent = currentState.toolResults.map(tc => ({
1528
+ type: 'tool_result',
1529
+ toolUseId: tc.id,
1530
+ content: tc.result.isError
1531
+ ? tc.result.error ?? 'Unknown error'
1532
+ : toolResultDataToHistoryString(tc.result.data, maxChars),
1533
+ isError: tc.result.isError,
1534
+ }));
1147
1535
  agent.getContextManager().addMessage('user', toolResultContent);
1148
1536
  // Flush any messages that were deferred while waiting for tool results.
1149
1537
  // Route to the PRIMARY agent — deferred messages are framework-level
@@ -1211,7 +1599,7 @@ export class AgentFramework {
1211
1599
  // These events are protocol-level (spec Sections 9 & 14) and always
1212
1600
  // represent content intended for the model's context window.
1213
1601
  if (event.type === 'mcpl:channel-incoming') {
1214
- this.handleMcplChannelIncoming(event);
1602
+ await this.handleMcplChannelIncoming(event);
1215
1603
  }
1216
1604
  else if (event.type === 'mcpl:push-event') {
1217
1605
  this.handleMcplPushEvent(event);
@@ -1282,8 +1670,10 @@ export class AgentFramework {
1282
1670
  if (!decision.trigger)
1283
1671
  return;
1284
1672
  }
1673
+ // Broadcast requests exclude conversation forks — they are driven by
1674
+ // their own channel's messages, not by framework-wide events.
1285
1675
  const targetAgents = response.requestInference === true
1286
- ? Array.from(this.agents.keys())
1676
+ ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
1287
1677
  : response.requestInference;
1288
1678
  for (const agentName of targetAgents) {
1289
1679
  const agent = this.agents.get(agentName);
@@ -1305,7 +1695,7 @@ export class AgentFramework {
1305
1695
  * Convert an incoming MCPL channel message to a context message.
1306
1696
  * This replaces the old MCPLModule.onProcess() message conversion.
1307
1697
  */
1308
- handleMcplChannelIncoming(event) {
1698
+ async handleMcplChannelIncoming(event) {
1309
1699
  const metadata = {
1310
1700
  ...event.metadata,
1311
1701
  channelId: event.channelId,
@@ -1316,6 +1706,13 @@ export class AgentFramework {
1316
1706
  };
1317
1707
  if (event.threadId)
1318
1708
  metadata.threadId = event.threadId;
1709
+ // Per-channel conversation routing: messages go to the channel's fork
1710
+ // agent (spawned from the template on first qualifying message), never
1711
+ // to the primary conversation.
1712
+ if (this.conversationRouter) {
1713
+ await this.routeConversationIncoming(event, metadata);
1714
+ return;
1715
+ }
1319
1716
  const id = this.addMessage('user', event.content, metadata);
1320
1717
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
1321
1718
  if (event.triggerInference) {
@@ -1329,6 +1726,228 @@ export class AgentFramework {
1329
1726
  }
1330
1727
  }
1331
1728
  }
1729
+ /**
1730
+ * Route an incoming channel message through the ConversationRouter:
1731
+ * deliver to the channel's bound fork agent, spawning it from the template
1732
+ * agent first when the bind policy matches. Unrouted messages are dropped —
1733
+ * the template ("trunk") is a dormant warm checkpoint, not a listener.
1734
+ */
1735
+ async routeConversationIncoming(event, messageMetadata) {
1736
+ const router = this.conversationRouter;
1737
+ const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
1738
+ const decision = router.route({
1739
+ channelId: event.channelId,
1740
+ mentioned: event.metadata?.mentioned === true,
1741
+ kind: ConversationRouter.classifyChannel(descriptor, event.metadata),
1742
+ });
1743
+ if (decision.kind === 'unbound') {
1744
+ this.emitTrace({
1745
+ type: 'mcpl:conversation-unrouted',
1746
+ channelId: event.channelId,
1747
+ messageId: event.messageId,
1748
+ });
1749
+ return;
1750
+ }
1751
+ let agent;
1752
+ if (decision.kind === 'spawn') {
1753
+ try {
1754
+ agent = await this.createConversationAgent(decision.agentName, event.channelId);
1755
+ router.bind(event.channelId, decision.agentName, decision.generation);
1756
+ this.persistConversationRouterState();
1757
+ this.emitTrace({
1758
+ type: 'mcpl:conversation-spawned',
1759
+ channelId: event.channelId,
1760
+ agentName: decision.agentName,
1761
+ generation: decision.generation,
1762
+ template: router.templateAgent,
1763
+ });
1764
+ }
1765
+ catch (error) {
1766
+ const err = error instanceof Error ? error : new Error(String(error));
1767
+ console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
1768
+ this.emitTrace({
1769
+ type: 'mcpl:conversation-spawn-failed',
1770
+ channelId: event.channelId,
1771
+ agentName: decision.agentName,
1772
+ error: err.message,
1773
+ });
1774
+ return; // next qualifying message proposes the same spawn again
1775
+ }
1776
+ }
1777
+ else {
1778
+ agent = this.agents.get(decision.agentName);
1779
+ if (!agent) {
1780
+ // Bound agent vanished (e.g. external reset) — unbind so the next
1781
+ // qualifying message respawns a fresh fork.
1782
+ router.unbind(event.channelId);
1783
+ this.emitTrace({
1784
+ type: 'mcpl:conversation-binding-orphaned',
1785
+ channelId: event.channelId,
1786
+ agentName: decision.agentName,
1787
+ });
1788
+ return;
1789
+ }
1790
+ }
1791
+ // Respect an explicit server-config-level veto (shouldTriggerInference)
1792
+ // on top of the router's own trigger policy.
1793
+ const trigger = decision.trigger && event.triggerInference !== false;
1794
+ messageMetadata.triggered = trigger;
1795
+ const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
1796
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
1797
+ if (trigger) {
1798
+ this.pendingRequests.push({
1799
+ agentName: agent.name,
1800
+ reason: 'mcpl:channel-incoming',
1801
+ source: event.serverId,
1802
+ timestamp: Date.now(),
1803
+ });
1804
+ }
1805
+ }
1806
+ /**
1807
+ * Spawn a persistent per-channel conversation agent ("fork") from the
1808
+ * template agent: own ContextManager under `conversations/{name}`, seeded
1809
+ * with a copy of the template's compiled context (the SubagentModule
1810
+ * wholesale-copy pattern), registered in the event loop like any agent —
1811
+ * but never primary.
1812
+ */
1813
+ async createConversationAgent(name, channelId) {
1814
+ const router = this.conversationRouter;
1815
+ const templateConfig = this.agentConfigs.get(router.templateAgent);
1816
+ const template = this.agents.get(router.templateAgent);
1817
+ if (!template || !templateConfig) {
1818
+ throw new Error(`conversation template agent "${router.templateAgent}" not found`);
1819
+ }
1820
+ const contextManager = await ContextManager.open({
1821
+ store: this.store,
1822
+ namespace: `conversations/${name}`,
1823
+ isolate: true,
1824
+ // Strategy instances are stateful — never share the template's.
1825
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
1826
+ membrane: this.membrane,
1827
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
1828
+ });
1829
+ // Seed with the template's compiled context, renaming the template
1830
+ // participant so the fork reads its inheritance as its own history.
1831
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
1832
+ // persisted precisely so names aren't reused, but if this namespace has
1833
+ // history anyway (counter state lost, crash between spawn and persist),
1834
+ // seeding again would stack another template copy on top of it.
1835
+ const { messages: existing } = await contextManager.compile();
1836
+ if (existing.length === 0) {
1837
+ const { messages: compiled } = await template.getContextManager().compile();
1838
+ for (const msg of compiled) {
1839
+ const participant = msg.participant === template.name ? name : msg.participant;
1840
+ contextManager.addMessage(participant, msg.content);
1841
+ }
1842
+ }
1843
+ const config = { ...templateConfig, name, strategy: undefined };
1844
+ const agent = new Agent(config, contextManager, this.membrane);
1845
+ this.agents.set(name, agent);
1846
+ this.agentConfigs.set(name, config);
1847
+ this.conversationAgentHomes.set(name, channelId);
1848
+ return agent;
1849
+ }
1850
+ /** Persist the router's generation counters (see hydration in create()). */
1851
+ persistConversationRouterState() {
1852
+ if (!this.conversationRouter)
1853
+ return;
1854
+ try {
1855
+ this.store.setStateJson(CONVERSATION_ROUTER_STATE_ID, {
1856
+ generations: this.conversationRouter.exportGenerations(),
1857
+ });
1858
+ }
1859
+ catch (error) {
1860
+ console.error('Failed to persist conversation router state:', error);
1861
+ }
1862
+ }
1863
+ /**
1864
+ * Remove a closed conversation fork from the framework: its closure turn
1865
+ * has finished, so the home mapping has served its purpose and keeping the
1866
+ * agent registered would just grow every agent scan and broadcast filter
1867
+ * forever. The fork's context stays in Chronicle for investigation.
1868
+ */
1869
+ disposeConversationAgent(agentName) {
1870
+ this.closingConversationAgents.delete(agentName);
1871
+ const channelId = this.conversationAgentHomes.get(agentName);
1872
+ this.agents.delete(agentName);
1873
+ this.agentConfigs.delete(agentName);
1874
+ this.conversationAgentHomes.delete(agentName);
1875
+ this.emitTrace({
1876
+ type: 'mcpl:conversation-disposed',
1877
+ agentName,
1878
+ channelId,
1879
+ });
1880
+ }
1881
+ /**
1882
+ * Scope MCPL context injections for a conversation-bound agent: drop
1883
+ * injections that are another open channel's context (injection namespace
1884
+ * = channelId by adapter convention), keep its own channel and anything
1885
+ * that isn't channel context.
1886
+ */
1887
+ scopeInjectionsForAgent(agentName, injections) {
1888
+ const home = this.conversationAgentHomes.get(agentName);
1889
+ if (!home || !this.channelRegistry || injections.length === 0) {
1890
+ return injections;
1891
+ }
1892
+ const openChannelIds = new Set(this.channelRegistry.getOpenChannels().map((e) => e.descriptor.id));
1893
+ return injections.filter((inj) => {
1894
+ const ns = inj.namespace;
1895
+ if (!ns || !openChannelIds.has(ns))
1896
+ return true;
1897
+ return ns === home;
1898
+ });
1899
+ }
1900
+ /**
1901
+ * Idle-TTL sweep for conversation bindings (runs at most once per minute
1902
+ * from the event loop). Expired forks get a final system-initiated closure
1903
+ * turn — publish scoping still works because the agent's home channel
1904
+ * mapping is permanent — and the channel unbinds immediately, so the next
1905
+ * qualifying message spawns a fresh fork from the current template.
1906
+ */
1907
+ sweepExpiredConversations() {
1908
+ if (!this.conversationRouter)
1909
+ return;
1910
+ const now = Date.now();
1911
+ if (now - this.lastConversationSweep < 60_000)
1912
+ return;
1913
+ this.lastConversationSweep = now;
1914
+ for (const binding of this.conversationRouter.expired(now)) {
1915
+ this.conversationRouter.unbind(binding.channelId);
1916
+ this.emitTrace({
1917
+ type: 'mcpl:conversation-closed',
1918
+ channelId: binding.channelId,
1919
+ agentName: binding.agentName,
1920
+ reason: 'idle-ttl',
1921
+ });
1922
+ const agent = this.agents.get(binding.agentName);
1923
+ if (!agent) {
1924
+ // Agent vanished (external reset) — nothing to close, just make sure
1925
+ // its bookkeeping doesn't linger.
1926
+ this.agentConfigs.delete(binding.agentName);
1927
+ this.conversationAgentHomes.delete(binding.agentName);
1928
+ continue;
1929
+ }
1930
+ agent.getContextManager().addMessage('user', [{ type: 'text', text: this.conversationRouter.closurePrompt }], { channelId: binding.channelId, conversationClosure: true });
1931
+ this.pendingRequests.push({
1932
+ agentName: binding.agentName,
1933
+ reason: 'conversation:closure',
1934
+ source: 'framework',
1935
+ timestamp: now,
1936
+ });
1937
+ // Disposed when the closure stream ends (driveStream finally), with
1938
+ // the reaper below as fallback.
1939
+ this.closingConversationAgents.add(binding.agentName);
1940
+ }
1941
+ // Fallback reaper: a closing fork whose closure inference never ran
1942
+ // (request dropped as stale, inference policy veto) would otherwise stay
1943
+ // registered forever. No active stream + no pending request = done.
1944
+ for (const agentName of [...this.closingConversationAgents]) {
1945
+ if (!this.activeStreams.has(agentName) &&
1946
+ !this.pendingRequests.some((r) => r.agentName === agentName)) {
1947
+ this.disposeConversationAgent(agentName);
1948
+ }
1949
+ }
1950
+ }
1332
1951
  /**
1333
1952
  * Convert an MCPL push event to a context message.
1334
1953
  */
@@ -1343,7 +1962,9 @@ export class AgentFramework {
1343
1962
  const id = this.addMessage('user', event.content, metadata);
1344
1963
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
1345
1964
  if (event.triggerInference) {
1346
- const targetAgents = event.targetAgents ?? [...this.agents.keys()];
1965
+ // Default broadcast excludes conversation forks (channel-driven).
1966
+ const targetAgents = event.targetAgents
1967
+ ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
1347
1968
  for (const agentName of targetAgents) {
1348
1969
  this.pendingRequests.push({
1349
1970
  agentName,
@@ -1425,6 +2046,11 @@ export class AgentFramework {
1425
2046
  // Both produce ContextInjection[] that get merged before inference.
1426
2047
  let injections;
1427
2048
  // Module gatherContext (fail-open, 5s timeout per module)
2049
+ // NOTE: module injections are NOT channel-scoped — only the MCPL hook
2050
+ // injections below pass through scopeInjectionsForAgent (channel
2051
+ // context arrives via beforeInference hooks by adapter convention). A
2052
+ // module that starts emitting per-channel context must scope it per
2053
+ // agent itself, or conversation forks will see cross-channel content.
1428
2054
  try {
1429
2055
  const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
1430
2056
  if (moduleInjections.length > 0) {
@@ -1438,7 +2064,7 @@ export class AgentFramework {
1438
2064
  if (this.hookOrchestrator) {
1439
2065
  try {
1440
2066
  const hookParams = this.buildBeforeInferenceParams(agent, trigger);
1441
- const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
2067
+ const hookInjections = this.scopeInjectionsForAgent(agent.name, await this.hookOrchestrator.beforeInference(hookParams));
1442
2068
  if (hookInjections.length > 0) {
1443
2069
  injections = injections ? [...injections, ...hookInjections] : hookInjections;
1444
2070
  }
@@ -1483,6 +2109,13 @@ export class AgentFramework {
1483
2109
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
1484
2110
  const myStreamId = agent.streamId;
1485
2111
  let hadToolCalls = false;
2112
+ // Typing indicator: show "<agent> is typing…" in the channel she's
2113
+ // responding to, for the whole duration of this turn. Started here (paired
2114
+ // with the finally below, so it can never leak) and refreshed on a 7s
2115
+ // interval by the ChannelRegistry until stopped on any exit path.
2116
+ const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
2117
+ if (typingChannel)
2118
+ this.channelRegistry.startTyping(typingChannel);
1486
2119
  try {
1487
2120
  for await (const event of stream) {
1488
2121
  switch (event.type) {
@@ -1491,8 +2124,21 @@ export class AgentFramework {
1491
2124
  type: 'inference:tokens',
1492
2125
  agentName: agent.name,
1493
2126
  content: event.content,
2127
+ blockType: event.meta.type,
2128
+ blockIndex: event.meta.blockIndex,
1494
2129
  });
1495
2130
  break;
2131
+ case 'block': {
2132
+ const { event: phase, index, block } = event.event;
2133
+ this.emitTrace({
2134
+ type: 'inference:content_block',
2135
+ agentName: agent.name,
2136
+ phase,
2137
+ blockType: block.type,
2138
+ blockIndex: index,
2139
+ });
2140
+ break;
2141
+ }
1496
2142
  case 'tool-calls': {
1497
2143
  hadToolCalls = true;
1498
2144
  this.emitTrace({
@@ -1500,13 +2146,26 @@ export class AgentFramework {
1500
2146
  agentName: agent.name,
1501
2147
  calls: event.calls.map((c) => ({ id: c.id, name: c.name, input: c.input })),
1502
2148
  });
1503
- // Build assistant content blocks from preamble text + tool_use blocks
1504
- const assistantBlocks = [];
1505
- if (event.context.preamble) {
1506
- assistantBlocks.push({ type: 'text', text: event.context.preamble });
2149
+ // Build assistant content blocks for this round. Prefer the
2150
+ // membrane's verbatim round content (membrane ≥0.5.64): it
2151
+ // preserves native thinking / redacted_thinking blocks with
2152
+ // their signatures IN ORDER — the API requires signed thinking
2153
+ // to precede its tool_use in the same assistant turn, so
2154
+ // rebuilding from preamble + calls would break thinking+tools.
2155
+ const roundContent = event.context.roundContent;
2156
+ let assistantBlocks;
2157
+ if (roundContent && roundContent.length > 0) {
2158
+ assistantBlocks = roundContent.filter((b) => b.type !== 'tool_result');
1507
2159
  }
1508
- for (const c of event.calls) {
1509
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
2160
+ else {
2161
+ // Fallback (older membrane / XML tool mode): preamble + calls
2162
+ assistantBlocks = [];
2163
+ if (event.context.preamble) {
2164
+ assistantBlocks.push({ type: 'text', text: event.context.preamble });
2165
+ }
2166
+ for (const c of event.calls) {
2167
+ assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
2168
+ }
1510
2169
  }
1511
2170
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
1512
2171
  // Note: max_tokens truncation cannot produce tool-calls events here.
@@ -1550,21 +2209,25 @@ export class AgentFramework {
1550
2209
  const toolResultContent = readyState.toolResults.map(tc => ({
1551
2210
  type: 'tool_result',
1552
2211
  toolUseId: tc.id,
1553
- content: tc.result.isError ? (tc.result.error ?? 'Unknown error') : JSON.stringify(tc.result.data),
2212
+ content: tc.result.isError
2213
+ ? (tc.result.error ?? 'Unknown error')
2214
+ : toolResultDataToHistoryString(tc.result.data),
1554
2215
  isError: tc.result.isError,
1555
2216
  }));
1556
2217
  agent.getContextManager().addMessage('user', toolResultContent);
1557
2218
  }
1558
2219
  }
1559
2220
  // Add assistant response to context.
1560
- // If we had tool calls, the tool_use blocks were already stored as
1561
- // pendingAssistantBlocks and flushed when tool results arrived.
1562
- // Only store trailing content (text after the last tool round) to
1563
- // avoid double-storing tool_use blocks.
1564
- const hadToolCalls = response.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
1565
- if (hadToolCalls) {
1566
- // Filter to only trailing text/thinking blocks (no tool_use — those are already stored)
1567
- const trailingContent = response.content.filter((block) => block.type !== 'tool_use' && block.type !== 'tool_result');
2221
+ // If we had tool calls, each round's blocks (thinking + text +
2222
+ // tool_use) were already stored as pendingAssistantBlocks and
2223
+ // flushed when tool results arrived. Only store TRAILING content
2224
+ // — blocks after the last tool block. A type-based filter would
2225
+ // double-store earlier rounds' text/thinking AND detach signed
2226
+ // thinking blocks from their tool_use turn (the API requires
2227
+ // thinking to precede tool_use in the same assistant turn).
2228
+ const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
2229
+ if (lastToolIdx >= 0) {
2230
+ const trailingContent = response.content.slice(lastToolIdx + 1);
1568
2231
  if (trailingContent.length > 0) {
1569
2232
  agent.addAssistantResponse(trailingContent);
1570
2233
  }
@@ -1660,6 +2323,19 @@ export class AgentFramework {
1660
2323
  tokenUsage,
1661
2324
  stopReason: response.stopReason,
1662
2325
  });
2326
+ // Surface refusals instead of going silently mute: stderr line
2327
+ // (headless inference failures are otherwise under-logged) + an
2328
+ // emoji reaction on the triggering Discord message, keyed by the
2329
+ // refusal category from stop_details.
2330
+ if (response.stopReason === 'refusal') {
2331
+ // stop_details lives on the raw PROVIDER response
2332
+ // (response.raw is RawAccess = { request, response, headers }).
2333
+ const stopDetails = response.raw?.response?.stop_details;
2334
+ const category = stopDetails?.category ?? 'unknown';
2335
+ console.error(`[inference-refusal] agent=${agent.name} category=${category}` +
2336
+ (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''));
2337
+ void this.reactToRefusal(agent.name, category);
2338
+ }
1663
2339
  // Dispatch speech (and thoughts if any)
1664
2340
  if (speechContent.length > 0 || thoughts.length > 0) {
1665
2341
  const speechContext = {
@@ -1673,6 +2349,80 @@ export class AgentFramework {
1673
2349
  };
1674
2350
  await this.moduleRegistry.dispatchSpeech(agent.name, speechContent, speechContext);
1675
2351
  }
2352
+ // Host-owned output routing (see LOCUS-ROUTING-DESIGN). On a
2353
+ // text-only turn (no tool calls => speechContent populated),
2354
+ // publish the agent's speech to the conversational locus — the
2355
+ // most recent incoming channel, tracked cross-surface in the
2356
+ // ChannelRegistry. This replaces discord-mcpl's per-surface sticky
2357
+ // auto-post. Tool-call turns produce `thoughts`, not `speech`, so
2358
+ // they are never routed here — which is precisely how the `think`
2359
+ // tool (and any explicit send tool) yields a silent turn.
2360
+ if (speechContent.length > 0 && this.channelRegistry) {
2361
+ const speechText = speechContent
2362
+ .map((b) => b.text)
2363
+ .join('\n')
2364
+ .trim();
2365
+ if (speechText) {
2366
+ try {
2367
+ await this.channelRegistry.routeSpeech(agent.name, speechText);
2368
+ }
2369
+ catch (err) {
2370
+ console.error('speech routing failed:', err);
2371
+ }
2372
+ }
2373
+ }
2374
+ else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
2375
+ // Tool-call turn that also produced prose. Per design, route the
2376
+ // prose to the locus as a reply UNLESS the turn used an explicit
2377
+ // send/publish tool (channel_publish, *--send_message,
2378
+ // *--reply_message, *--send_dm) — already delivered, so routing
2379
+ // again would double-post. `think` and other non-sending tools
2380
+ // (shell, workspace, etc.) still let the agent speak in the same
2381
+ // turn. The global speech/thoughts split is left untouched
2382
+ // (module/TUI rendering unaffected) — this only governs what
2383
+ // reaches the channel.
2384
+ // Tools whose presence suppresses the trailing prose, for two
2385
+ // distinct reasons:
2386
+ // - `skip_reply` — the agent EXPLICITLY chose not to reply this
2387
+ // turn (the deliberate "stay silent" signal).
2388
+ // - explicit delivery tools (channel_publish / *send_message /
2389
+ // *reply_message / *send_dm) — already sent the message, so
2390
+ // routing it again would double-post.
2391
+ // `think` is deliberately NOT here: it is silent *reasoning*, but
2392
+ // prose written after a think is the agent's actual reply and must
2393
+ // be delivered. (A think-only turn has no trailing prose and stays
2394
+ // silent via the `!t` check below.)
2395
+ const SILENCING = new Set([
2396
+ 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
2397
+ ]);
2398
+ const bare = (n) => (n.includes('--') ? n.split('--').pop() : n);
2399
+ const toolNames = response.content
2400
+ .filter((b) => b.type === 'tool_use')
2401
+ .map((b) => b.name)
2402
+ .filter((n) => typeof n === 'string');
2403
+ const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
2404
+ const t = allText
2405
+ .map((b) => b.text)
2406
+ .join('\n')
2407
+ .trim();
2408
+ if (silenced || !t) {
2409
+ console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
2410
+ `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`);
2411
+ }
2412
+ else {
2413
+ console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing trailing prose (${t.length} chars) as reply`);
2414
+ try {
2415
+ await this.channelRegistry.routeSpeech(agent.name, t);
2416
+ }
2417
+ catch (err) {
2418
+ console.error('speech routing failed:', err);
2419
+ }
2420
+ }
2421
+ }
2422
+ // NOTE: agent.reset() + onInferenceEnded() already ran above, BEFORE
2423
+ // dispatchSpeech (main's zombie-subagent fix, PR #32 — sync listeners
2424
+ // must see status === 'idle'). Locus routing is speech dispatch and
2425
+ // doesn't depend on the status field, so it correctly runs after.
1676
2426
  break;
1677
2427
  }
1678
2428
  case 'error': {
@@ -1765,8 +2515,16 @@ export class AgentFramework {
1765
2515
  this.eventGate?.onInferenceEnded(agent.name);
1766
2516
  }
1767
2517
  finally {
2518
+ // Stop the typing indicator on every exit path (complete, error,
2519
+ // exhausted, abort) so it never sticks after the turn ends.
2520
+ this.channelRegistry?.stopTyping();
1768
2521
  this.activeStreams.delete(agent.name);
1769
2522
  this.pendingAssistantBlocks.delete(agent.name);
2523
+ // A conversation fork whose TTL closure turn just finished is done for
2524
+ // good — dispose it so the agent map doesn't grow monotonically.
2525
+ if (this.closingConversationAgents.has(agent.name)) {
2526
+ this.disposeConversationAgent(agent.name);
2527
+ }
1770
2528
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
1771
2529
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
1772
2530
  const deferred = this.deferredMessages.splice(0);
@@ -1830,18 +2588,60 @@ export class AgentFramework {
1830
2588
  }
1831
2589
  }
1832
2590
  toMembraneToolResult(callId, afResult, maxChars) {
1833
- let content;
1834
2591
  if (afResult.isError) {
1835
- content = afResult.error ?? 'Unknown error';
2592
+ return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
1836
2593
  }
1837
- else {
1838
- content = JSON.stringify(afResult.data);
1839
- if (maxChars && content.length > maxChars) {
1840
- content = safeSlice(content, 0, maxChars)
1841
- + '\n\n[truncated — original was ' + content.length + ' chars]';
2594
+ // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
2595
+ // blocks natively rather than JSON-stringifying them away. Anything else
2596
+ // (objects, scalars) falls through to JSON. The error path was handled
2597
+ // above, so isError is always false on these return paths.
2598
+ const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
2599
+ if (blocks) {
2600
+ return { toolUseId: callId, content: blocks, isError: false };
2601
+ }
2602
+ let content = JSON.stringify(afResult.data);
2603
+ if (maxChars && content.length > maxChars) {
2604
+ content = safeSlice(content, 0, maxChars)
2605
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
2606
+ }
2607
+ return { toolUseId: callId, content, isError: false };
2608
+ }
2609
+ /**
2610
+ * If `data` is an MCP tool-result content array carrying at least one image,
2611
+ * convert to Membrane's native ToolResultContentBlock[]. Returns null when
2612
+ * the array is text-only (let JSON path handle it; saves a code path).
2613
+ * `maxChars`, when provided, caps each accompanying text block so an image
2614
+ * inlined alongside an enormous text payload can't blow the context.
2615
+ */
2616
+ tryNativeToolResultContent(data, maxChars) {
2617
+ if (!Array.isArray(data))
2618
+ return null;
2619
+ let hasImage = false;
2620
+ const blocks = [];
2621
+ for (const raw of data) {
2622
+ if (!raw || typeof raw !== 'object')
2623
+ return null;
2624
+ const b = raw;
2625
+ if (b.type === 'text' && typeof b.text === 'string') {
2626
+ let text = b.text;
2627
+ if (maxChars && text.length > maxChars) {
2628
+ text = safeSlice(text, 0, maxChars)
2629
+ + '\n\n[truncated — original was ' + text.length + ' chars]';
2630
+ }
2631
+ blocks.push({ type: 'text', text });
2632
+ }
2633
+ else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
2634
+ hasImage = true;
2635
+ blocks.push({
2636
+ type: 'image',
2637
+ source: { type: 'base64', data: b.data, mediaType: b.mimeType },
2638
+ });
2639
+ }
2640
+ else {
2641
+ return null; // unknown shape — bail to JSON path
1842
2642
  }
1843
2643
  }
1844
- return { toolUseId: callId, content, isError: afResult.isError };
2644
+ return hasImage ? blocks : null;
1845
2645
  }
1846
2646
  getMaxToolResultChars(agent) {
1847
2647
  const strategy = agent.getContextManager().getStrategy();
@@ -1921,11 +2721,24 @@ export class AgentFramework {
1921
2721
  this.dispatchChannelToolCall(agentName, enrichedCall);
1922
2722
  return;
1923
2723
  }
1924
- // Route gate:status tool
1925
- if (enrichedCall.name === 'gate:status' && this.eventGate) {
2724
+ // Route gate_status tool
2725
+ if (enrichedCall.name === 'gate_status' && this.eventGate) {
1926
2726
  this.dispatchGateToolCall(agentName, enrichedCall);
1927
2727
  return;
1928
2728
  }
2729
+ // Route sleep / wake tools
2730
+ if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
2731
+ this.dispatchSleepToolCall(agentName, enrichedCall);
2732
+ return;
2733
+ }
2734
+ // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
2735
+ // stay-silent) tools — handled by the channel registry like the other
2736
+ // synthesized channel tools, but they aren't `channel_`-prefixed so they
2737
+ // need an explicit route here.
2738
+ if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
2739
+ this.dispatchChannelToolCall(agentName, enrichedCall);
2740
+ return;
2741
+ }
1929
2742
  const sepIndex = enrichedCall.name.indexOf('--');
1930
2743
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
1931
2744
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -2059,6 +2872,24 @@ export class AgentFramework {
2059
2872
  };
2060
2873
  }
2061
2874
  emitTrace(event) {
2875
+ // Centralized inference-health observability. Every terminal failure path
2876
+ // funnels through an `inference:exhausted` trace and every successful model
2877
+ // response through `inference:completed`, so intercepting here is the one
2878
+ // place that reliably sees all outcomes regardless of which code path
2879
+ // produced them. In headless/daemon mode no trace client is attached, so
2880
+ // without this the only durable record of a failed inference is a field in
2881
+ // llm-calls.jsonl — invisible to operator, agent, and monitoring.
2882
+ if (event.type === 'inference:exhausted') {
2883
+ this.noteInferenceExhausted(event.agentName ?? 'unknown', event.error ?? 'unknown error');
2884
+ }
2885
+ else if (event.type === 'inference:completed') {
2886
+ // A successful response — even mid-turn between tool calls — proves the
2887
+ // agent isn't hard-down; clear its consecutive-failure streak.
2888
+ const name = event.agentName;
2889
+ if (name && this.consecutiveInferenceFailures.get(name)) {
2890
+ this.consecutiveInferenceFailures.set(name, 0);
2891
+ }
2892
+ }
2062
2893
  const traceEvent = {
2063
2894
  ...event,
2064
2895
  timestamp: Date.now(),
@@ -2072,6 +2903,49 @@ export class AgentFramework {
2072
2903
  }
2073
2904
  }
2074
2905
  }
2906
+ /**
2907
+ * Handle a fully-exhausted inference (the agent could not produce a response
2908
+ * this turn, after retries). Severity here is high — the agent can't think
2909
+ * at all — yet historically the only durable record was a buried field in
2910
+ * llm-calls.jsonl. This surfaces it three ways:
2911
+ *
2912
+ * 1. stderr — always, with the underlying API reason. The place an
2913
+ * operator greps; previously empty in headless mode.
2914
+ * 2. chronicle marker — a `[inference-failed]` message so the agent itself
2915
+ * learns its turn failed and why (otherwise it's an experiential blank,
2916
+ * indistinguishable from "not addressed"). addMessage does NOT request
2917
+ * inference, so this never causes a retry/wake loop.
2918
+ * 3. escalation — after N consecutive failures the agent is hard-down;
2919
+ * log that loudly (a repeated identical failure is the textbook signal).
2920
+ */
2921
+ noteInferenceExhausted(agentName, reason) {
2922
+ const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
2923
+ this.consecutiveInferenceFailures.set(agentName, streak);
2924
+ // (1) Durable stderr line — works in headless/daemon mode with no client.
2925
+ console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
2926
+ // (2) Agent-facing chronicle marker (no inference triggered → no loop).
2927
+ const agent = this.agents.get(agentName);
2928
+ if (agent) {
2929
+ try {
2930
+ agent.getContextManager().addMessage('user', [{
2931
+ type: 'text',
2932
+ text: `[inference-failed] Your previous turn did not complete: the model ` +
2933
+ `call failed and produced no response, so nothing was sent. Reason: ` +
2934
+ `${reason}. If this recurs with the same cause, change approach ` +
2935
+ `rather than retrying identically (e.g. drop an oversized attachment ` +
2936
+ `or an unsupported setting).`,
2937
+ }], { system: true, kind: 'inference-failed', reason, consecutive: streak });
2938
+ }
2939
+ catch (err) {
2940
+ console.error(`[inference-failed] could not record chronicle marker for ${agentName}:`, err);
2941
+ }
2942
+ }
2943
+ // (3) Hard-down escalation on repeated identical failure.
2944
+ if (streak >= this.inferenceFailureEscalationThreshold) {
2945
+ console.error(`[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
2946
+ `inferences — it cannot complete a turn. Last reason: ${reason}`);
2947
+ }
2948
+ }
2075
2949
  // ==========================================================================
2076
2950
  // MCPL subsystem wiring
2077
2951
  // ==========================================================================
@@ -2112,6 +2986,23 @@ export class AgentFramework {
2112
2986
  }
2113
2987
  },
2114
2988
  shouldTriggerInference: triggerFilter,
2989
+ // A text-only turn whose speech couldn't be delivered must not vanish
2990
+ // silently: record a `[discord-send-failed]` marker in chronicle so the
2991
+ // agent sees, on her next turn, that her reply never reached the human.
2992
+ // addMessage() alone does not request inference, so this never wakes
2993
+ // her (matching the `discord-send-failed-skip` gate intent: context
2994
+ // yes, wake no).
2995
+ onRouteFailure: ({ channelId, reason, textLen }) => {
2996
+ try {
2997
+ this.addMessage('user', [{
2998
+ type: 'text',
2999
+ 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.`,
3000
+ }], { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason });
3001
+ }
3002
+ catch (err) {
3003
+ console.error('onRouteFailure: failed to record send-failure marker:', err);
3004
+ }
3005
+ },
2115
3006
  });
2116
3007
  // Host capabilities advertised during the MCP handshake
2117
3008
  const hostCapabilities = {
@@ -2228,6 +3119,19 @@ export class AgentFramework {
2228
3119
  connection.on('channels-incoming', (params, responder) => {
2229
3120
  this.channelRegistry?.handleIncoming(connection.id, params, responder);
2230
3121
  });
3122
+ // Handle host-level admin commands from a surface (e.g. Discord /undo)
3123
+ connection.on('host-command', async (params, responder) => {
3124
+ if (!responder)
3125
+ return;
3126
+ try {
3127
+ const result = await this.handleHostCommand(connection.id, params ?? {});
3128
+ responder.respond(result);
3129
+ }
3130
+ catch (error) {
3131
+ const err = error instanceof Error ? error : new Error(String(error));
3132
+ responder.respondError(-32603, err.message);
3133
+ }
3134
+ });
2231
3135
  // Handle dynamic tool list changes (notifications/tools/list_changed)
2232
3136
  connection.on('tools-list-changed', () => {
2233
3137
  this.handleToolsListChanged(connection.id);
@@ -2384,11 +3288,22 @@ export class AgentFramework {
2384
3288
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
2385
3289
  }
2386
3290
  }
2387
- // Convert MCP tool result to framework ToolResult
3291
+ // Convert MCP tool result to framework ToolResult.
3292
+ // When the result contains non-text blocks (e.g. images from an MCP
3293
+ // tool like zulip-mcp's fetch_attachment), pass the full content array
3294
+ // through so toMembraneToolResult can preserve image blocks natively.
3295
+ // Text-only results still collapse to a joined string for backward
3296
+ // compatibility with callers that expect data to be string-ish.
2388
3297
  const textContent = result.content
2389
3298
  ?.filter((c) => c.type === 'text' && c.text)
2390
3299
  .map((c) => c.text)
2391
3300
  .join('\n');
3301
+ const hasNonText = result.content?.some((c) => c.type !== 'text');
3302
+ const data = result.isError
3303
+ ? undefined
3304
+ : hasNonText
3305
+ ? result.content
3306
+ : (textContent || undefined);
2392
3307
  this.pushEvent({
2393
3308
  type: 'tool-result',
2394
3309
  callId: call.id,
@@ -2396,7 +3311,7 @@ export class AgentFramework {
2396
3311
  moduleName: `mcpl:${serverId}`,
2397
3312
  result: {
2398
3313
  success: !result.isError,
2399
- data: result.isError ? undefined : textContent || undefined,
3314
+ data,
2400
3315
  error: result.isError ? (textContent || 'Tool call failed') : undefined,
2401
3316
  isError: result.isError ?? false,
2402
3317
  },
@@ -2421,6 +3336,42 @@ export class AgentFramework {
2421
3336
  * Dispatch a synthesized channel tool call.
2422
3337
  */
2423
3338
  dispatchChannelToolCall(agentName, call) {
3339
+ // Conversation forks act only on their home channel. channel_publish and
3340
+ // channel_close default a missing channelId to home and reject foreign
3341
+ // ones; channel_open is rejected outright — opening channels mutates
3342
+ // framework-global state (the open-channel set every agent's injections
3343
+ // are scoped against), which is not a fork's call to make.
3344
+ const home = this.conversationAgentHomes.get(agentName);
3345
+ if (home) {
3346
+ const reject = (error) => {
3347
+ this.emitTrace({
3348
+ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
3349
+ error: `conversation agent ${agentName}: ${error}`,
3350
+ });
3351
+ this.pushEvent({
3352
+ type: 'tool-result',
3353
+ callId: call.id,
3354
+ agentName,
3355
+ moduleName: 'channels',
3356
+ result: { success: false, error, isError: true },
3357
+ });
3358
+ };
3359
+ if (call.name === 'channel_open') {
3360
+ reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`);
3361
+ return;
3362
+ }
3363
+ if (call.name === 'channel_publish' || call.name === 'channel_close') {
3364
+ const input = (call.input ?? {});
3365
+ if (!input.channelId) {
3366
+ call = { ...call, input: { ...input, channelId: home } };
3367
+ }
3368
+ else if (input.channelId !== home) {
3369
+ const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing';
3370
+ reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`);
3371
+ return;
3372
+ }
3373
+ }
3374
+ }
2424
3375
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
2425
3376
  const startTime = Date.now();
2426
3377
  this.channelRegistry.handleChannelToolCall(call.name, call.input)
@@ -2447,6 +3398,45 @@ export class AgentFramework {
2447
3398
  });
2448
3399
  });
2449
3400
  }
3401
+ /**
3402
+ * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
3403
+ * suppression window, optionally announces in the sticky channel, and ends
3404
+ * the turn (the agent goes idle immediately). `wake` clears sleep.
3405
+ */
3406
+ dispatchSleepToolCall(agentName, call) {
3407
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
3408
+ const gate = this.eventGate;
3409
+ const input = (call.input ?? {});
3410
+ const finish = (result) => {
3411
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
3412
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
3413
+ };
3414
+ if (call.name === 'wake') {
3415
+ const was = gate.clearSleep();
3416
+ finish({ success: true, data: { woke: was } });
3417
+ return;
3418
+ }
3419
+ const seconds = Number(input.seconds);
3420
+ if (!Number.isFinite(seconds) || seconds <= 0) {
3421
+ finish({ success: false, error: 'sleep: `seconds` must be a positive number', isError: true });
3422
+ return;
3423
+ }
3424
+ const { until } = gate.setSleep(seconds, input.message);
3425
+ const announce = input.announce !== false; // default true
3426
+ const human = seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
3427
+ : seconds >= 60 ? `${Math.round(seconds / 60)}m`
3428
+ : `${Math.round(seconds)}s`;
3429
+ // Announce in the sticky channel (best-effort; never blocks the result).
3430
+ if (announce && this.channelRegistry) {
3431
+ const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
3432
+ this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
3433
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
3434
+ });
3435
+ }
3436
+ console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
3437
+ // endTurn: the agent stops here and goes idle for the duration.
3438
+ finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
3439
+ }
2450
3440
  dispatchGateToolCall(agentName, call) {
2451
3441
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
2452
3442
  const startTime = Date.now();