@animalabs/agent-framework 0.5.6 → 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 +1033 -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 +28 -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 +73 -1
  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 +2 -2
  85. package/src/agent.ts +53 -18
  86. package/src/framework.ts +1188 -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 +36 -0
package/src/framework.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { JsStore } from '@animalabs/chronicle';
3
- import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult } from '@animalabs/membrane';
3
+ import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
4
4
  import { MembraneError } from '@animalabs/membrane';
5
5
  import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
6
6
  import type {
@@ -50,7 +50,9 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
50
50
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
51
51
  import { InferenceRouter } from './mcpl/inference-router.js';
52
52
  import { ChannelRegistry } from './mcpl/channel-registry.js';
53
+ import { ConversationRouter } from './mcpl/conversation-router.js';
53
54
  import { safeSlice } from './safe-slice.js';
55
+ import { toolResultDataToHistoryString } from './tool-result-history.js';
54
56
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
55
57
  import { isToolAllowed } from './mcpl/tool-policy.js';
56
58
  import { EventGate } from './gate/event-gate.js';
@@ -74,6 +76,7 @@ import type {
74
76
  import type { ContextInjection } from '@animalabs/context-manager';
75
77
 
76
78
  const FRAMEWORK_STATE_ID = 'framework/state';
79
+ const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
77
80
  const INFERENCE_LOG_ID = 'framework/inference-log';
78
81
  const PROCESS_LOG_ID = 'framework/process-log';
79
82
  const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
@@ -180,6 +183,24 @@ function extractGateFields(event: ProcessEvent): {
180
183
  /**
181
184
  * The main agent framework.
182
185
  */
186
+ /** Params for a `host/command` request from an MCPL surface server. */
187
+ interface HostCommandParams {
188
+ command?: string;
189
+ agentName?: string;
190
+ turns?: number;
191
+ /** Message-granular undo: branch the chronicle so the last N messages
192
+ * (regardless of participant) are no longer on the active branch.
193
+ * Mutually exclusive with `turns`. */
194
+ messages?: number;
195
+ /** For the `hide` command: Discord message id of the (first) message to
196
+ * remove. With `toMessageId`, removes the inclusive range between them. */
197
+ fromMessageId?: string;
198
+ /** For the `hide` command: Discord message id ending an inclusive range. */
199
+ toMessageId?: string;
200
+ requesterId?: string;
201
+ requesterName?: string;
202
+ }
203
+
183
204
  export class AgentFramework {
184
205
  private store: JsStore;
185
206
  private ownsStore: boolean;
@@ -199,6 +220,12 @@ export class AgentFramework {
199
220
  private processLoggingBroadcast: boolean;
200
221
  private activeStreams: Map<string, Promise<void>> = new Map();
201
222
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
223
+ /** Per-agent count of consecutive exhausted inferences (reset on any success).
224
+ * Drives hard-down escalation — see noteInferenceExhausted. */
225
+ private consecutiveInferenceFailures: Map<string, number> = new Map();
226
+ /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
227
+ * escalated loudly to stderr. */
228
+ private readonly inferenceFailureEscalationThreshold = 3;
202
229
  /** Name of the primary (non-ephemeral) agent for routing framework messages. */
203
230
  private primaryAgentName: string | null = null;
204
231
 
@@ -219,6 +246,22 @@ export class AgentFramework {
219
246
  private inferenceRouter: InferenceRouter | null = null;
220
247
  private channelRegistry: ChannelRegistry | null = null;
221
248
  private checkpointManager: CheckpointManager | null = null;
249
+ /** Per-channel conversation routing (null unless config.conversations set). */
250
+ private conversationRouter: ConversationRouter | null = null;
251
+ /** Agent configs by name — fork agents are built from the template's config. */
252
+ private agentConfigs: Map<string, AgentConfig> = new Map();
253
+ /**
254
+ * Fork agent → its home channel. Permanent (unlike router bindings, which
255
+ * expire): publish/injection scoping must survive unbinding so the closure
256
+ * turn still lands in the right channel.
257
+ */
258
+ private conversationAgentHomes: Map<string, string> = new Map();
259
+ /** Last idle-TTL sweep timestamp. */
260
+ private lastConversationSweep = 0;
261
+
262
+ /** Forks whose TTL closure turn has been queued — disposed (removed from
263
+ * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
264
+ private closingConversationAgents: Set<string> = new Set();
222
265
  private mcplTools: import('./types/index.js').ToolDefinition[] = [];
223
266
  private mcplToolRefreshInFlight = false;
224
267
  private mcplToolRefreshPending = false;
@@ -265,6 +308,7 @@ export class AgentFramework {
265
308
  getMessage: (id) => this.getMessage(id),
266
309
  queryMessages: (filter) => this.queryMessages(filter),
267
310
  pushEvent: (event) => this.pushEvent(event),
311
+ onTrace: (listener) => this.onTrace(listener),
268
312
  });
269
313
  }
270
314
 
@@ -352,6 +396,35 @@ export class AgentFramework {
352
396
  await framework.addModule(module);
353
397
  }
354
398
 
399
+ // Initialize per-channel conversation routing (if configured)
400
+ if (config.conversations) {
401
+ if (!framework.agents.has(config.conversations.templateAgent)) {
402
+ throw new Error(
403
+ `conversations.templateAgent "${config.conversations.templateAgent}" ` +
404
+ `is not a configured agent`
405
+ );
406
+ }
407
+ framework.conversationRouter = new ConversationRouter(config.conversations);
408
+
409
+ // Generation counters persist across restarts — reusing generation 1's
410
+ // agent name after a restart would reopen (and re-seed) the previous
411
+ // engagement's Chronicle namespace.
412
+ try {
413
+ store.registerState({ id: CONVERSATION_ROUTER_STATE_ID, strategy: 'snapshot' });
414
+ } catch {
415
+ // Already registered
416
+ }
417
+ try {
418
+ const data = store.getStateJson(CONVERSATION_ROUTER_STATE_ID) as
419
+ { generations?: Record<string, number> } | null;
420
+ if (data?.generations) {
421
+ framework.conversationRouter.hydrateGenerations(data.generations);
422
+ }
423
+ } catch {
424
+ // No persisted state yet
425
+ }
426
+ }
427
+
355
428
  // Initialize EventGate if configured (before MCPL so it can be wired as trigger filter)
356
429
  if (config.gate) {
357
430
  const configPath = config.gate.configPath
@@ -361,6 +434,7 @@ export class AgentFramework {
361
434
  framework.eventGate = new EventGate({
362
435
  configPath,
363
436
  initialConfig: config.gate.config,
437
+ privilegedUsersPath: config.gate.privilegedUsersPath,
364
438
  emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
365
439
  addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata),
366
440
  requestInference: (agentName, reason, source) => {
@@ -486,8 +560,15 @@ export class AgentFramework {
486
560
  /**
487
561
  * Add a trace event listener for observability.
488
562
  */
489
- onTrace(listener: TraceEventListener): void {
563
+ onTrace(listener: TraceEventListener): () => void {
490
564
  this.traceListeners.push(listener);
565
+ // Return an unsubscribe so callers with a bounded lifetime (e.g. modules
566
+ // that get torn down and recreated on session switch) don't leak
567
+ // listeners. Existing callers that ignore the return value are unaffected.
568
+ return () => {
569
+ const idx = this.traceListeners.indexOf(listener);
570
+ if (idx >= 0) this.traceListeners.splice(idx, 1);
571
+ };
491
572
  }
492
573
 
493
574
  /**
@@ -540,6 +621,14 @@ export class AgentFramework {
540
621
  return Array.from(this.agents.values());
541
622
  }
542
623
 
624
+ /**
625
+ * Get the per-channel conversation router (null unless config.conversations
626
+ * was set). Exposed for modules/UIs that surface active engagements.
627
+ */
628
+ getConversationRouter(): ConversationRouter | null {
629
+ return this.conversationRouter;
630
+ }
631
+
543
632
  /**
544
633
  * Abort an in-flight inference for an agent.
545
634
  */
@@ -568,13 +657,88 @@ export class AgentFramework {
568
657
  getAllTools(): import('./types/index.js').ToolDefinition[] {
569
658
  const moduleTools = this.moduleRegistry.getAllTools();
570
659
  const channelTools = this.channelRegistry?.getChannelTools() ?? [];
571
- const gateTools = this.eventGate ? [this.eventGate.getToolDefinition()] : [];
660
+ const gateTools = this.eventGate
661
+ ? [this.eventGate.getToolDefinition(), ...AgentFramework.SLEEP_TOOLS]
662
+ : [];
572
663
  if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
573
664
  return moduleTools;
574
665
  }
575
666
  return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
576
667
  }
577
668
 
669
+ /**
670
+ * Build the membrane-normalized request that WOULD be emitted if `agentName`
671
+ * were activated right now — WITHOUT running inference, opening a stream, or
672
+ * mutating agent state. Intended for debug/preview tooling.
673
+ *
674
+ * Transparency contract (default): the preview is side-effect-free and
675
+ * leaves no trace on the system. It does only read-only work —
676
+ * `ContextManager.compile` (which never triggers compression itself;
677
+ * compression runs in the background out-of-band), tool filtering, and
678
+ * system-prompt assembly — then delegates to `Agent.buildActivationRequest`.
679
+ * No tokens are spent, nothing is written to Chronicle, and no external
680
+ * MCPL server is contacted.
681
+ *
682
+ * The trade-off is fidelity: the dynamically-gathered ContextInjection[]
683
+ * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
684
+ * by default, because gathering them is not transparent —
685
+ * - module `gatherContext` can run inference (e.g. RetrievalModule makes
686
+ * Haiku calls — real token cost and latency), and
687
+ * - MCPL `beforeInference` hooks are arbitrary RPCs to external servers
688
+ * with side effects, and a preview never sends the paired
689
+ * `afterInference`, which can leave a stateful server half-open.
690
+ *
691
+ * Pass `{ injections: true }` to opt into full-fidelity gathering and accept
692
+ * those side effects (byte-faithful to a real activation's injected context).
693
+ */
694
+ async previewActivation(
695
+ agentName: string,
696
+ opts?: { injections?: boolean }
697
+ ): Promise<NormalizedRequest> {
698
+ const agent = this.agents.get(agentName);
699
+ if (!agent) {
700
+ throw new Error(`Agent not found: ${agentName}`);
701
+ }
702
+
703
+ const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
704
+
705
+ // Default: no dynamic injection gathering → fully transparent (no
706
+ // inference, no Chronicle writes, no external RPC). Opt in explicitly.
707
+ if (!opts?.injections) {
708
+ return agent.buildActivationRequest(tools, undefined);
709
+ }
710
+
711
+ // Full-fidelity path: mirrors startAgentStream's injection gathering.
712
+ // NOT transparent — see the doc comment above.
713
+ let injections: ContextInjection[] | undefined;
714
+
715
+ // Module gatherContext (fail-open, matches startAgentStream)
716
+ try {
717
+ const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
718
+ if (moduleInjections.length > 0) {
719
+ injections = moduleInjections;
720
+ }
721
+ } catch (error) {
722
+ console.error('Module gatherContext error (preview):', error);
723
+ }
724
+
725
+ // MCPL beforeInference hooks (fail-open). Note: the paired afterInference
726
+ // is intentionally never sent here — this is a preview, not a real turn.
727
+ if (this.hookOrchestrator) {
728
+ try {
729
+ const hookParams = this.buildBeforeInferenceParams(agent);
730
+ const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
731
+ if (hookInjections.length > 0) {
732
+ injections = injections ? [...injections, ...hookInjections] : hookInjections;
733
+ }
734
+ } catch (error) {
735
+ console.error('beforeInference hook error (preview):', error);
736
+ }
737
+ }
738
+
739
+ return agent.buildActivationRequest(tools, injections);
740
+ }
741
+
578
742
  /**
579
743
  * Check if process logging is enabled.
580
744
  */
@@ -840,6 +1004,313 @@ export class AgentFramework {
840
1004
  * Query inference logs.
841
1005
  * Returns entries with summary info (doesn't resolve blobs).
842
1006
  */
1007
+ /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
1008
+ private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
1009
+ {
1010
+ name: 'sleep',
1011
+ description:
1012
+ 'Go quiet: suppress external pings and wakes for a number of seconds. ' +
1013
+ 'Messages still accumulate in your context — you just won’t be woken to ' +
1014
+ 'respond to them until the window passes. Your heartbeat still beats: you’ll ' +
1015
+ 'briefly rouse on each tick and can keep resting or call `wake` to get up early. ' +
1016
+ 'Privileged users can also still reach you. By default announces in your current channel.',
1017
+ inputSchema: {
1018
+ type: 'object',
1019
+ properties: {
1020
+ seconds: { type: 'number', description: 'How long to stay asleep, in seconds.' },
1021
+ announce: {
1022
+ type: 'boolean',
1023
+ description: 'Announce the sleep in the current sticky channel (default true).',
1024
+ },
1025
+ message: {
1026
+ type: 'string',
1027
+ description: 'Optional custom announcement text (overrides the default).',
1028
+ },
1029
+ },
1030
+ required: ['seconds'],
1031
+ },
1032
+ },
1033
+ {
1034
+ name: 'wake',
1035
+ description: 'End your current sleep early, resuming normal wakes immediately.',
1036
+ inputSchema: { type: 'object' },
1037
+ },
1038
+ ];
1039
+
1040
+ /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
1041
+ private static readonly REFUSAL_REACTIONS: Record<string, string> = {
1042
+ bio: '☣️',
1043
+ chem: '🧪',
1044
+ nuclear: '☢️',
1045
+ cyber: '💻',
1046
+ reasoning_extraction: '🧠',
1047
+ };
1048
+
1049
+ /**
1050
+ * Mark an inference refusal visibly: react on the message that holds the
1051
+ * conversational locus (the most recent incoming channel message) with a
1052
+ * category-specific emoji. Best-effort — failures are logged, never thrown,
1053
+ * and non-Discord loci are silently skipped.
1054
+ */
1055
+ private async reactToRefusal(agentName: string, category: string): Promise<void> {
1056
+ try {
1057
+ const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
1058
+ if (!incoming) return;
1059
+ // incoming.channelId is the MCPL composite id ("discord:<guild>:<channel>");
1060
+ // the reaction tool wants the raw Discord channel (or thread) id — the
1061
+ // last segment.
1062
+ const parts = incoming.channelId.split(':');
1063
+ if (parts[0] !== 'discord') return;
1064
+ const channelId = parts[parts.length - 1];
1065
+ const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
1066
+ // Resolve the MCPL server that owns the locus channel and call
1067
+ // tools/call directly on its connection (bare tool name — no prefix
1068
+ // games), bypassing the agent event queue so no synthetic tool-result
1069
+ // is injected into a turn the agent never took.
1070
+ const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
1071
+ const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
1072
+ if (!server) {
1073
+ console.error(
1074
+ `[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`,
1075
+ );
1076
+ return;
1077
+ }
1078
+ await server.sendToolsCall('add_reaction', {
1079
+ channelId,
1080
+ messageId: incoming.messageId,
1081
+ emoji,
1082
+ });
1083
+ } catch (err) {
1084
+ console.error(
1085
+ '[inference-refusal] reaction failed:',
1086
+ err instanceof Error ? err.message : err,
1087
+ );
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * Handle a `host/command` request from an MCPL surface server (e.g. a
1093
+ * Discord slash command). Currently supports:
1094
+ *
1095
+ * undo — revert the last N inference turns (branch-based, see
1096
+ * `undoLastTurn`). The response includes the last message the agent
1097
+ * would see in its context after the undo, obtained via the transparent
1098
+ * `previewActivation` render (no inference, no Chronicle writes).
1099
+ */
1100
+ private async handleHostCommand(
1101
+ serverId: string,
1102
+ params: HostCommandParams,
1103
+ ): Promise<{
1104
+ ok: boolean;
1105
+ error?: string;
1106
+ undone?: number;
1107
+ requested?: number;
1108
+ messagesRemoved?: number;
1109
+ hidden?: number;
1110
+ /** For `hide`: the Discord (channelId, messageId) of each removed message
1111
+ * that carried one — so the surface can mark them with a reaction. */
1112
+ hiddenRefs?: Array<{ channelId: string; messageId: string }>;
1113
+ lastVisible?: { participant?: string; role?: string; preview?: string } | null;
1114
+ }> {
1115
+ if (params.command !== 'undo' && params.command !== 'hide') {
1116
+ return { ok: false, error: `Unknown host command: ${String(params.command)}` };
1117
+ }
1118
+
1119
+ const agentName = params.agentName ?? [...this.agents.keys()][0];
1120
+ if (!agentName || !this.agents.has(agentName)) {
1121
+ return { ok: false, error: `Unknown agent: ${String(agentName)}` };
1122
+ }
1123
+
1124
+ // hide: redact a single message (or an inclusive range) from the active
1125
+ // branch, addressed by Discord message id. Unlike undo this is a
1126
+ // removal-in-place (chronicle redact), not a branch rewind.
1127
+ if (params.command === 'hide') {
1128
+ const agent = this.agents.get(agentName)!;
1129
+ if (agent.state.status !== 'idle') {
1130
+ return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
1131
+ }
1132
+ if (!params.fromMessageId) {
1133
+ return { ok: false, error: 'hide: fromMessageId is required' };
1134
+ }
1135
+ const cm = agent.getContextManager();
1136
+ const all = cm.getAllMessages();
1137
+ const byDiscordId = (did: string) =>
1138
+ all.findIndex((m) => String((m.metadata as { messageId?: unknown } | undefined)?.messageId) === String(did));
1139
+
1140
+ // Collect Discord refs of every message in [lo, hi] that carries one,
1141
+ // so the surface can mark them. channelId here is whatever the ingest
1142
+ // path stored (raw id or "discord:guild:channel"); the surface
1143
+ // normalizes it.
1144
+ const refsIn = (lo: number, hi: number) => {
1145
+ const refs: Array<{ channelId: string; messageId: string }> = [];
1146
+ for (let i = lo; i <= hi; i++) {
1147
+ const md = all[i].metadata as { messageId?: unknown; channelId?: unknown } | undefined;
1148
+ if (md?.messageId && md?.channelId) {
1149
+ refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
1150
+ }
1151
+ }
1152
+ return refs;
1153
+ };
1154
+
1155
+ const fromIdx = byDiscordId(params.fromMessageId);
1156
+ if (fromIdx < 0) {
1157
+ return {
1158
+ ok: false,
1159
+ error: `Message ${params.fromMessageId} is not an addressable message in context (it may only exist inside backscroll text, or predates this session).`,
1160
+ };
1161
+ }
1162
+
1163
+ try {
1164
+ if (params.toMessageId) {
1165
+ const toIdx = byDiscordId(params.toMessageId);
1166
+ if (toIdx < 0) {
1167
+ return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
1168
+ }
1169
+ const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
1170
+ const refs = refsIn(lo, hi);
1171
+ cm.removeMessages(all[lo].id, all[hi].id);
1172
+ console.error(
1173
+ `[host-command] hide agent=${agentName} range removed=${hi - lo + 1} ` +
1174
+ `(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1175
+ );
1176
+ return {
1177
+ ok: true,
1178
+ hidden: hi - lo + 1,
1179
+ hiddenRefs: refs,
1180
+ lastVisible: await this.lastVisiblePreview(agentName),
1181
+ };
1182
+ }
1183
+ const refs = refsIn(fromIdx, fromIdx);
1184
+ cm.removeMessage(all[fromIdx].id);
1185
+ console.error(
1186
+ `[host-command] hide agent=${agentName} removed=1 (${params.fromMessageId}) ` +
1187
+ `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1188
+ );
1189
+ return {
1190
+ ok: true,
1191
+ hidden: 1,
1192
+ hiddenRefs: refs,
1193
+ lastVisible: await this.lastVisiblePreview(agentName),
1194
+ };
1195
+ } catch (error) {
1196
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1197
+ }
1198
+ }
1199
+
1200
+ // Message-granular mode: branch the chronicle at the message that should
1201
+ // become the new tail (ContextManager.branchAt — origin-sequence-based
1202
+ // time-travel branching; see chronicle's rfc-state-item-origins addendum).
1203
+ // No turn checkpoints involved, so this reaches past turn boundaries.
1204
+ if (typeof params.messages === 'number' && params.messages > 0) {
1205
+ const agent = this.agents.get(agentName)!;
1206
+ if (agent.state.status !== 'idle') {
1207
+ return { ok: false, error: `Cannot undo while agent is ${agent.state.status}` };
1208
+ }
1209
+ const n = Math.max(1, Math.min(50, Math.floor(params.messages)));
1210
+ const cm = agent.getContextManager();
1211
+ const allMessages = cm.getAllMessages();
1212
+ if (n >= allMessages.length) {
1213
+ return {
1214
+ ok: false,
1215
+ error: `Cannot remove ${n} message(s) — history has ${allMessages.length}; at least one must remain.`,
1216
+ };
1217
+ }
1218
+ const target = allMessages[allMessages.length - 1 - n];
1219
+ const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
1220
+ await cm.switchBranch(branchName);
1221
+
1222
+ // Materialize config files from the new branch (fire-and-forget; gate
1223
+ // picks up via mtime) — mirrors undoLastTurn.
1224
+ const wsUndo = this.moduleRegistry.getModule('workspace');
1225
+ if (wsUndo && 'materializeMount' in wsUndo) {
1226
+ (wsUndo as { materializeMount: (m: string) => Promise<void> })
1227
+ .materializeMount('_config')
1228
+ .catch(() => {});
1229
+ }
1230
+
1231
+ console.error(
1232
+ `[host-command] undo-messages agent=${agentName} removed=${n} branch=${branchName}` +
1233
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1234
+ );
1235
+
1236
+ return {
1237
+ ok: true,
1238
+ messagesRemoved: n,
1239
+ lastVisible: await this.lastVisiblePreview(agentName),
1240
+ };
1241
+ }
1242
+
1243
+ const requested = Math.max(1, Math.min(20, Math.floor(params.turns ?? 1)));
1244
+ let undone = 0;
1245
+ try {
1246
+ for (let i = 0; i < requested; i++) {
1247
+ const r = this.undoLastTurn(agentName);
1248
+ if (!r.undone) break;
1249
+ undone++;
1250
+ }
1251
+ } catch (error) {
1252
+ // e.g. "Cannot undo while agent is streaming" — report what happened,
1253
+ // including any turns already undone before the failure.
1254
+ const msg = error instanceof Error ? error.message : String(error);
1255
+ if (undone === 0) return { ok: false, error: msg };
1256
+ console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
1257
+ }
1258
+
1259
+ console.error(
1260
+ `[host-command] undo agent=${agentName} requested=${requested} undone=${undone}` +
1261
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1262
+ );
1263
+
1264
+ if (undone === 0) {
1265
+ return { ok: true, undone: 0, requested, lastVisible: null };
1266
+ }
1267
+
1268
+ return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
1269
+ }
1270
+
1271
+ /**
1272
+ * The last message the agent would see in its context right now, via the
1273
+ * transparent `previewActivation` render (no inference, no Chronicle
1274
+ * writes). Returns null if the preview fails or the context is empty.
1275
+ */
1276
+ private async lastVisiblePreview(
1277
+ agentName: string,
1278
+ ): Promise<{ participant?: string; role?: string; preview?: string } | null> {
1279
+ try {
1280
+ const preview = await this.previewActivation(agentName);
1281
+ const messages = (preview as { messages?: unknown[] }).messages ?? [];
1282
+ const last = messages[messages.length - 1] as
1283
+ | { role?: string; participant?: string; content?: unknown }
1284
+ | undefined;
1285
+ if (!last) return null;
1286
+ const blocks = Array.isArray(last.content) ? last.content : [];
1287
+ const text = blocks
1288
+ .map((b) => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
1289
+ .join(' ')
1290
+ .trim();
1291
+ // Surface-injected preambles (<system>…</system>, <backscroll>…
1292
+ // </backscroll>) can dominate the head of a bundled incoming message
1293
+ // and bury the actual conversational line. Strip them for the
1294
+ // preview; fall back to the raw text if nothing else remains.
1295
+ const stripped = text
1296
+ .replace(/<system>[\s\S]*?<\/system>/g, '')
1297
+ .replace(/<backscroll\b[\s\S]*?<\/backscroll>/g, '')
1298
+ .trim();
1299
+ const body = stripped.length > 0 ? stripped : text;
1300
+ return {
1301
+ participant: last.participant,
1302
+ role: last.role,
1303
+ preview: body.length > 400 ? `${body.slice(0, 400)}…` : body,
1304
+ };
1305
+ } catch (error) {
1306
+ console.error(
1307
+ '[host-command] post-undo context preview failed:',
1308
+ error instanceof Error ? error.message : error,
1309
+ );
1310
+ return null;
1311
+ }
1312
+ }
1313
+
843
1314
  queryInferenceLogs(query?: InferenceLogQuery): InferenceLogQueryResult {
844
1315
  const limit = query?.limit ?? 50;
845
1316
  const offset = query?.offset ?? 0;
@@ -1307,6 +1778,7 @@ export class AgentFramework {
1307
1778
 
1308
1779
  const agent = new Agent(config, contextManager, this.membrane);
1309
1780
  this.agents.set(config.name, agent);
1781
+ this.agentConfigs.set(config.name, config);
1310
1782
 
1311
1783
  // First non-ephemeral agent becomes the primary for message routing
1312
1784
  if (!this.primaryAgentName) {
@@ -1334,6 +1806,9 @@ export class AgentFramework {
1334
1806
  await this.handleProcessEvent(event);
1335
1807
  }
1336
1808
 
1809
+ // Close idle conversation forks (no-op unless conversations configured)
1810
+ this.sweepExpiredConversations();
1811
+
1337
1812
  // Check for inference requests
1338
1813
  await this.processInferenceRequests();
1339
1814
 
@@ -1392,25 +1867,18 @@ export class AgentFramework {
1392
1867
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
1393
1868
  const maxChars = this.getMaxToolResultChars(agent);
1394
1869
 
1395
- // Store tool results as a user message (tool_result blocks)
1396
- const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => {
1397
- let content: string;
1398
- if (tc.result.isError) {
1399
- content = tc.result.error ?? 'Unknown error';
1400
- } else {
1401
- content = JSON.stringify(tc.result.data);
1402
- if (maxChars && content.length > maxChars) {
1403
- content = safeSlice(content, 0, maxChars)
1404
- + '\n\n[truncated — original was ' + content.length + ' chars]';
1405
- }
1406
- }
1407
- return {
1408
- type: 'tool_result' as const,
1409
- toolUseId: tc.id,
1410
- content,
1411
- isError: tc.result.isError,
1412
- };
1413
- });
1870
+ // Store tool results as a user message (tool_result blocks).
1871
+ // Use the history serializer so MCP image blocks become a short
1872
+ // `[image: type, size]` placeholder instead of megabytes of base64
1873
+ // that would corrupt under truncation.
1874
+ const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => ({
1875
+ type: 'tool_result' as const,
1876
+ toolUseId: tc.id,
1877
+ content: tc.result.isError
1878
+ ? tc.result.error ?? 'Unknown error'
1879
+ : toolResultDataToHistoryString(tc.result.data, maxChars),
1880
+ isError: tc.result.isError,
1881
+ }));
1414
1882
  agent.getContextManager().addMessage('user', toolResultContent);
1415
1883
 
1416
1884
  // Flush any messages that were deferred while waiting for tool results.
@@ -1482,7 +1950,7 @@ export class AgentFramework {
1482
1950
  // These events are protocol-level (spec Sections 9 & 14) and always
1483
1951
  // represent content intended for the model's context window.
1484
1952
  if (event.type === 'mcpl:channel-incoming') {
1485
- this.handleMcplChannelIncoming(event as unknown as {
1953
+ await this.handleMcplChannelIncoming(event as unknown as {
1486
1954
  type: 'mcpl:channel-incoming';
1487
1955
  serverId: string;
1488
1956
  channelId: string;
@@ -1578,9 +2046,11 @@ export class AgentFramework {
1578
2046
  if (!decision.trigger) return;
1579
2047
  }
1580
2048
 
2049
+ // Broadcast requests exclude conversation forks — they are driven by
2050
+ // their own channel's messages, not by framework-wide events.
1581
2051
  const targetAgents =
1582
2052
  response.requestInference === true
1583
- ? Array.from(this.agents.keys())
2053
+ ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
1584
2054
  : response.requestInference;
1585
2055
 
1586
2056
  for (const agentName of targetAgents) {
@@ -1605,7 +2075,7 @@ export class AgentFramework {
1605
2075
  * Convert an incoming MCPL channel message to a context message.
1606
2076
  * This replaces the old MCPLModule.onProcess() message conversion.
1607
2077
  */
1608
- private handleMcplChannelIncoming(event: {
2078
+ private async handleMcplChannelIncoming(event: {
1609
2079
  type: 'mcpl:channel-incoming';
1610
2080
  serverId: string;
1611
2081
  channelId: string;
@@ -1616,7 +2086,7 @@ export class AgentFramework {
1616
2086
  timestamp: string;
1617
2087
  metadata?: Record<string, unknown>;
1618
2088
  triggerInference?: boolean;
1619
- }): void {
2089
+ }): Promise<void> {
1620
2090
  const metadata: Record<string, unknown> = {
1621
2091
  ...event.metadata,
1622
2092
  channelId: event.channelId,
@@ -1627,6 +2097,14 @@ export class AgentFramework {
1627
2097
  };
1628
2098
  if (event.threadId) metadata.threadId = event.threadId;
1629
2099
 
2100
+ // Per-channel conversation routing: messages go to the channel's fork
2101
+ // agent (spawned from the template on first qualifying message), never
2102
+ // to the primary conversation.
2103
+ if (this.conversationRouter) {
2104
+ await this.routeConversationIncoming(event, metadata);
2105
+ return;
2106
+ }
2107
+
1630
2108
  const id = this.addMessage('user', event.content, metadata);
1631
2109
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
1632
2110
 
@@ -1642,6 +2120,261 @@ export class AgentFramework {
1642
2120
  }
1643
2121
  }
1644
2122
 
2123
+ /**
2124
+ * Route an incoming channel message through the ConversationRouter:
2125
+ * deliver to the channel's bound fork agent, spawning it from the template
2126
+ * agent first when the bind policy matches. Unrouted messages are dropped —
2127
+ * the template ("trunk") is a dormant warm checkpoint, not a listener.
2128
+ */
2129
+ private async routeConversationIncoming(
2130
+ event: {
2131
+ serverId: string;
2132
+ channelId: string;
2133
+ messageId: string;
2134
+ author: { id: string; name: string };
2135
+ content: ContentBlock[];
2136
+ metadata?: Record<string, unknown>;
2137
+ triggerInference?: boolean;
2138
+ },
2139
+ messageMetadata: Record<string, unknown>,
2140
+ ): Promise<void> {
2141
+ const router = this.conversationRouter!;
2142
+ const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
2143
+
2144
+ const decision = router.route({
2145
+ channelId: event.channelId,
2146
+ mentioned: event.metadata?.mentioned === true,
2147
+ kind: ConversationRouter.classifyChannel(descriptor, event.metadata),
2148
+ });
2149
+
2150
+ if (decision.kind === 'unbound') {
2151
+ this.emitTrace({
2152
+ type: 'mcpl:conversation-unrouted',
2153
+ channelId: event.channelId,
2154
+ messageId: event.messageId,
2155
+ });
2156
+ return;
2157
+ }
2158
+
2159
+ let agent: Agent | undefined;
2160
+ if (decision.kind === 'spawn') {
2161
+ try {
2162
+ agent = await this.createConversationAgent(decision.agentName, event.channelId);
2163
+ router.bind(event.channelId, decision.agentName, decision.generation);
2164
+ this.persistConversationRouterState();
2165
+ this.emitTrace({
2166
+ type: 'mcpl:conversation-spawned',
2167
+ channelId: event.channelId,
2168
+ agentName: decision.agentName,
2169
+ generation: decision.generation,
2170
+ template: router.templateAgent,
2171
+ });
2172
+ } catch (error) {
2173
+ const err = error instanceof Error ? error : new Error(String(error));
2174
+ console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
2175
+ this.emitTrace({
2176
+ type: 'mcpl:conversation-spawn-failed',
2177
+ channelId: event.channelId,
2178
+ agentName: decision.agentName,
2179
+ error: err.message,
2180
+ });
2181
+ return; // next qualifying message proposes the same spawn again
2182
+ }
2183
+ } else {
2184
+ agent = this.agents.get(decision.agentName);
2185
+ if (!agent) {
2186
+ // Bound agent vanished (e.g. external reset) — unbind so the next
2187
+ // qualifying message respawns a fresh fork.
2188
+ router.unbind(event.channelId);
2189
+ this.emitTrace({
2190
+ type: 'mcpl:conversation-binding-orphaned',
2191
+ channelId: event.channelId,
2192
+ agentName: decision.agentName,
2193
+ });
2194
+ return;
2195
+ }
2196
+ }
2197
+
2198
+ // Respect an explicit server-config-level veto (shouldTriggerInference)
2199
+ // on top of the router's own trigger policy.
2200
+ const trigger = decision.trigger && event.triggerInference !== false;
2201
+ messageMetadata.triggered = trigger;
2202
+
2203
+ const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
2204
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
2205
+
2206
+ if (trigger) {
2207
+ this.pendingRequests.push({
2208
+ agentName: agent.name,
2209
+ reason: 'mcpl:channel-incoming',
2210
+ source: event.serverId,
2211
+ timestamp: Date.now(),
2212
+ });
2213
+ }
2214
+ }
2215
+
2216
+ /**
2217
+ * Spawn a persistent per-channel conversation agent ("fork") from the
2218
+ * template agent: own ContextManager under `conversations/{name}`, seeded
2219
+ * with a copy of the template's compiled context (the SubagentModule
2220
+ * wholesale-copy pattern), registered in the event loop like any agent —
2221
+ * but never primary.
2222
+ */
2223
+ private async createConversationAgent(name: string, channelId: string): Promise<Agent> {
2224
+ const router = this.conversationRouter!;
2225
+ const templateConfig = this.agentConfigs.get(router.templateAgent);
2226
+ const template = this.agents.get(router.templateAgent);
2227
+ if (!template || !templateConfig) {
2228
+ throw new Error(`conversation template agent "${router.templateAgent}" not found`);
2229
+ }
2230
+
2231
+ const contextManager = await ContextManager.open({
2232
+ store: this.store,
2233
+ namespace: `conversations/${name}`,
2234
+ isolate: true,
2235
+ // Strategy instances are stateful — never share the template's.
2236
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
2237
+ membrane: this.membrane,
2238
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
2239
+ });
2240
+
2241
+ // Seed with the template's compiled context, renaming the template
2242
+ // participant so the fork reads its inheritance as its own history.
2243
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
2244
+ // persisted precisely so names aren't reused, but if this namespace has
2245
+ // history anyway (counter state lost, crash between spawn and persist),
2246
+ // seeding again would stack another template copy on top of it.
2247
+ const { messages: existing } = await contextManager.compile();
2248
+ if (existing.length === 0) {
2249
+ const { messages: compiled } = await template.getContextManager().compile();
2250
+ for (const msg of compiled) {
2251
+ const participant = msg.participant === template.name ? name : msg.participant;
2252
+ contextManager.addMessage(participant, msg.content);
2253
+ }
2254
+ }
2255
+
2256
+ const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
2257
+ const agent = new Agent(config, contextManager, this.membrane);
2258
+ this.agents.set(name, agent);
2259
+ this.agentConfigs.set(name, config);
2260
+ this.conversationAgentHomes.set(name, channelId);
2261
+ return agent;
2262
+ }
2263
+
2264
+ /** Persist the router's generation counters (see hydration in create()). */
2265
+ private persistConversationRouterState(): void {
2266
+ if (!this.conversationRouter) return;
2267
+ try {
2268
+ this.store.setStateJson(CONVERSATION_ROUTER_STATE_ID, {
2269
+ generations: this.conversationRouter.exportGenerations(),
2270
+ });
2271
+ } catch (error) {
2272
+ console.error('Failed to persist conversation router state:', error);
2273
+ }
2274
+ }
2275
+
2276
+ /**
2277
+ * Remove a closed conversation fork from the framework: its closure turn
2278
+ * has finished, so the home mapping has served its purpose and keeping the
2279
+ * agent registered would just grow every agent scan and broadcast filter
2280
+ * forever. The fork's context stays in Chronicle for investigation.
2281
+ */
2282
+ private disposeConversationAgent(agentName: string): void {
2283
+ this.closingConversationAgents.delete(agentName);
2284
+ const channelId = this.conversationAgentHomes.get(agentName);
2285
+ this.agents.delete(agentName);
2286
+ this.agentConfigs.delete(agentName);
2287
+ this.conversationAgentHomes.delete(agentName);
2288
+ this.emitTrace({
2289
+ type: 'mcpl:conversation-disposed',
2290
+ agentName,
2291
+ channelId,
2292
+ });
2293
+ }
2294
+
2295
+ /**
2296
+ * Scope MCPL context injections for a conversation-bound agent: drop
2297
+ * injections that are another open channel's context (injection namespace
2298
+ * = channelId by adapter convention), keep its own channel and anything
2299
+ * that isn't channel context.
2300
+ */
2301
+ private scopeInjectionsForAgent(
2302
+ agentName: string,
2303
+ injections: ContextInjection[],
2304
+ ): ContextInjection[] {
2305
+ const home = this.conversationAgentHomes.get(agentName);
2306
+ if (!home || !this.channelRegistry || injections.length === 0) {
2307
+ return injections;
2308
+ }
2309
+ const openChannelIds = new Set(
2310
+ this.channelRegistry.getOpenChannels().map((e) => e.descriptor.id),
2311
+ );
2312
+ return injections.filter((inj) => {
2313
+ const ns = inj.namespace;
2314
+ if (!ns || !openChannelIds.has(ns)) return true;
2315
+ return ns === home;
2316
+ });
2317
+ }
2318
+
2319
+ /**
2320
+ * Idle-TTL sweep for conversation bindings (runs at most once per minute
2321
+ * from the event loop). Expired forks get a final system-initiated closure
2322
+ * turn — publish scoping still works because the agent's home channel
2323
+ * mapping is permanent — and the channel unbinds immediately, so the next
2324
+ * qualifying message spawns a fresh fork from the current template.
2325
+ */
2326
+ private sweepExpiredConversations(): void {
2327
+ if (!this.conversationRouter) return;
2328
+ const now = Date.now();
2329
+ if (now - this.lastConversationSweep < 60_000) return;
2330
+ this.lastConversationSweep = now;
2331
+
2332
+ for (const binding of this.conversationRouter.expired(now)) {
2333
+ this.conversationRouter.unbind(binding.channelId);
2334
+ this.emitTrace({
2335
+ type: 'mcpl:conversation-closed',
2336
+ channelId: binding.channelId,
2337
+ agentName: binding.agentName,
2338
+ reason: 'idle-ttl',
2339
+ });
2340
+
2341
+ const agent = this.agents.get(binding.agentName);
2342
+ if (!agent) {
2343
+ // Agent vanished (external reset) — nothing to close, just make sure
2344
+ // its bookkeeping doesn't linger.
2345
+ this.agentConfigs.delete(binding.agentName);
2346
+ this.conversationAgentHomes.delete(binding.agentName);
2347
+ continue;
2348
+ }
2349
+ agent.getContextManager().addMessage(
2350
+ 'user',
2351
+ [{ type: 'text', text: this.conversationRouter.closurePrompt }],
2352
+ { channelId: binding.channelId, conversationClosure: true },
2353
+ );
2354
+ this.pendingRequests.push({
2355
+ agentName: binding.agentName,
2356
+ reason: 'conversation:closure',
2357
+ source: 'framework',
2358
+ timestamp: now,
2359
+ });
2360
+ // Disposed when the closure stream ends (driveStream finally), with
2361
+ // the reaper below as fallback.
2362
+ this.closingConversationAgents.add(binding.agentName);
2363
+ }
2364
+
2365
+ // Fallback reaper: a closing fork whose closure inference never ran
2366
+ // (request dropped as stale, inference policy veto) would otherwise stay
2367
+ // registered forever. No active stream + no pending request = done.
2368
+ for (const agentName of [...this.closingConversationAgents]) {
2369
+ if (
2370
+ !this.activeStreams.has(agentName) &&
2371
+ !this.pendingRequests.some((r) => r.agentName === agentName)
2372
+ ) {
2373
+ this.disposeConversationAgent(agentName);
2374
+ }
2375
+ }
2376
+ }
2377
+
1645
2378
  /**
1646
2379
  * Convert an MCPL push event to a context message.
1647
2380
  */
@@ -1658,7 +2391,9 @@ export class AgentFramework {
1658
2391
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
1659
2392
 
1660
2393
  if (event.triggerInference) {
1661
- const targetAgents = event.targetAgents ?? [...this.agents.keys()];
2394
+ // Default broadcast excludes conversation forks (channel-driven).
2395
+ const targetAgents = event.targetAgents
2396
+ ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
1662
2397
  for (const agentName of targetAgents) {
1663
2398
  this.pendingRequests.push({
1664
2399
  agentName,
@@ -1753,6 +2488,11 @@ export class AgentFramework {
1753
2488
  let injections: ContextInjection[] | undefined;
1754
2489
 
1755
2490
  // Module gatherContext (fail-open, 5s timeout per module)
2491
+ // NOTE: module injections are NOT channel-scoped — only the MCPL hook
2492
+ // injections below pass through scopeInjectionsForAgent (channel
2493
+ // context arrives via beforeInference hooks by adapter convention). A
2494
+ // module that starts emitting per-channel context must scope it per
2495
+ // agent itself, or conversation forks will see cross-channel content.
1756
2496
  try {
1757
2497
  const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
1758
2498
  if (moduleInjections.length > 0) {
@@ -1766,7 +2506,10 @@ export class AgentFramework {
1766
2506
  if (this.hookOrchestrator) {
1767
2507
  try {
1768
2508
  const hookParams = this.buildBeforeInferenceParams(agent, trigger);
1769
- const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
2509
+ const hookInjections = this.scopeInjectionsForAgent(
2510
+ agent.name,
2511
+ await this.hookOrchestrator.beforeInference(hookParams),
2512
+ );
1770
2513
  if (hookInjections.length > 0) {
1771
2514
  injections = injections ? [...injections, ...hookInjections] : hookInjections;
1772
2515
  }
@@ -1819,6 +2562,13 @@ export class AgentFramework {
1819
2562
  const myStreamId = agent.streamId;
1820
2563
  let hadToolCalls = false;
1821
2564
 
2565
+ // Typing indicator: show "<agent> is typing…" in the channel she's
2566
+ // responding to, for the whole duration of this turn. Started here (paired
2567
+ // with the finally below, so it can never leak) and refreshed on a 7s
2568
+ // interval by the ChannelRegistry until stopped on any exit path.
2569
+ const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
2570
+ if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
2571
+
1822
2572
  try {
1823
2573
  for await (const event of stream) {
1824
2574
  switch (event.type) {
@@ -1852,13 +2602,27 @@ export class AgentFramework {
1852
2602
  calls: event.calls.map((c) => ({ id: c.id, name: c.name, input: c.input })),
1853
2603
  });
1854
2604
 
1855
- // Build assistant content blocks from preamble text + tool_use blocks
1856
- const assistantBlocks: ContentBlock[] = [];
1857
- if (event.context.preamble) {
1858
- assistantBlocks.push({ type: 'text', text: event.context.preamble });
1859
- }
1860
- for (const c of event.calls) {
1861
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input as Record<string, unknown> });
2605
+ // Build assistant content blocks for this round. Prefer the
2606
+ // membrane's verbatim round content (membrane ≥0.5.64): it
2607
+ // preserves native thinking / redacted_thinking blocks with
2608
+ // their signatures IN ORDER — the API requires signed thinking
2609
+ // to precede its tool_use in the same assistant turn, so
2610
+ // rebuilding from preamble + calls would break thinking+tools.
2611
+ const roundContent = (event.context as { roundContent?: ContentBlock[] }).roundContent;
2612
+ let assistantBlocks: ContentBlock[];
2613
+ if (roundContent && roundContent.length > 0) {
2614
+ assistantBlocks = roundContent.filter(
2615
+ (b) => b.type !== 'tool_result'
2616
+ );
2617
+ } else {
2618
+ // Fallback (older membrane / XML tool mode): preamble + calls
2619
+ assistantBlocks = [];
2620
+ if (event.context.preamble) {
2621
+ assistantBlocks.push({ type: 'text', text: event.context.preamble });
2622
+ }
2623
+ for (const c of event.calls) {
2624
+ assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input as Record<string, unknown> });
2625
+ }
1862
2626
  }
1863
2627
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
1864
2628
 
@@ -1909,7 +2673,9 @@ export class AgentFramework {
1909
2673
  const toolResultContent: ContentBlock[] = readyState.toolResults.map(tc => ({
1910
2674
  type: 'tool_result' as const,
1911
2675
  toolUseId: tc.id,
1912
- content: tc.result.isError ? (tc.result.error ?? 'Unknown error') : JSON.stringify(tc.result.data),
2676
+ content: tc.result.isError
2677
+ ? (tc.result.error ?? 'Unknown error')
2678
+ : toolResultDataToHistoryString(tc.result.data),
1913
2679
  isError: tc.result.isError,
1914
2680
  }));
1915
2681
  agent.getContextManager().addMessage('user', toolResultContent);
@@ -1917,18 +2683,20 @@ export class AgentFramework {
1917
2683
  }
1918
2684
 
1919
2685
  // Add assistant response to context.
1920
- // If we had tool calls, the tool_use blocks were already stored as
1921
- // pendingAssistantBlocks and flushed when tool results arrived.
1922
- // Only store trailing content (text after the last tool round) to
1923
- // avoid double-storing tool_use blocks.
1924
- const hadToolCalls = response.content.some(
1925
- (b: ContentBlock) => b.type === 'tool_use' || b.type === 'tool_result'
2686
+ // If we had tool calls, each round's blocks (thinking + text +
2687
+ // tool_use) were already stored as pendingAssistantBlocks and
2688
+ // flushed when tool results arrived. Only store TRAILING content
2689
+ // — blocks after the last tool block. A type-based filter would
2690
+ // double-store earlier rounds' text/thinking AND detach signed
2691
+ // thinking blocks from their tool_use turn (the API requires
2692
+ // thinking to precede tool_use in the same assistant turn).
2693
+ const lastToolIdx = response.content.reduce(
2694
+ (last: number, b: ContentBlock, i: number) =>
2695
+ b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
2696
+ -1
1926
2697
  );
1927
- if (hadToolCalls) {
1928
- // Filter to only trailing text/thinking blocks (no tool_use — those are already stored)
1929
- const trailingContent = response.content.filter(
1930
- (block: ContentBlock) => block.type !== 'tool_use' && block.type !== 'tool_result'
1931
- );
2698
+ if (lastToolIdx >= 0) {
2699
+ const trailingContent = response.content.slice(lastToolIdx + 1);
1932
2700
  if (trailingContent.length > 0) {
1933
2701
  agent.addAssistantResponse(trailingContent);
1934
2702
  }
@@ -2034,6 +2802,24 @@ export class AgentFramework {
2034
2802
  stopReason: response.stopReason,
2035
2803
  });
2036
2804
 
2805
+ // Surface refusals instead of going silently mute: stderr line
2806
+ // (headless inference failures are otherwise under-logged) + an
2807
+ // emoji reaction on the triggering Discord message, keyed by the
2808
+ // refusal category from stop_details.
2809
+ if (response.stopReason === 'refusal') {
2810
+ // stop_details lives on the raw PROVIDER response
2811
+ // (response.raw is RawAccess = { request, response, headers }).
2812
+ const stopDetails = (response.raw?.response as {
2813
+ stop_details?: { category?: string; explanation?: string };
2814
+ } | undefined)?.stop_details;
2815
+ const category = stopDetails?.category ?? 'unknown';
2816
+ console.error(
2817
+ `[inference-refusal] agent=${agent.name} category=${category}` +
2818
+ (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
2819
+ );
2820
+ void this.reactToRefusal(agent.name, category);
2821
+ }
2822
+
2037
2823
  // Dispatch speech (and thoughts if any)
2038
2824
  if (speechContent.length > 0 || thoughts.length > 0) {
2039
2825
  const speechContext: SpeechContext = {
@@ -2052,6 +2838,81 @@ export class AgentFramework {
2052
2838
  );
2053
2839
  }
2054
2840
 
2841
+ // Host-owned output routing (see LOCUS-ROUTING-DESIGN). On a
2842
+ // text-only turn (no tool calls => speechContent populated),
2843
+ // publish the agent's speech to the conversational locus — the
2844
+ // most recent incoming channel, tracked cross-surface in the
2845
+ // ChannelRegistry. This replaces discord-mcpl's per-surface sticky
2846
+ // auto-post. Tool-call turns produce `thoughts`, not `speech`, so
2847
+ // they are never routed here — which is precisely how the `think`
2848
+ // tool (and any explicit send tool) yields a silent turn.
2849
+ if (speechContent.length > 0 && this.channelRegistry) {
2850
+ const speechText = speechContent
2851
+ .map((b) => (b as ContentBlock & { type: 'text' }).text)
2852
+ .join('\n')
2853
+ .trim();
2854
+ if (speechText) {
2855
+ try {
2856
+ await this.channelRegistry.routeSpeech(agent.name, speechText);
2857
+ } catch (err) {
2858
+ console.error('speech routing failed:', err);
2859
+ }
2860
+ }
2861
+ } else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
2862
+ // Tool-call turn that also produced prose. Per design, route the
2863
+ // prose to the locus as a reply UNLESS the turn used an explicit
2864
+ // send/publish tool (channel_publish, *--send_message,
2865
+ // *--reply_message, *--send_dm) — already delivered, so routing
2866
+ // again would double-post. `think` and other non-sending tools
2867
+ // (shell, workspace, etc.) still let the agent speak in the same
2868
+ // turn. The global speech/thoughts split is left untouched
2869
+ // (module/TUI rendering unaffected) — this only governs what
2870
+ // reaches the channel.
2871
+ // Tools whose presence suppresses the trailing prose, for two
2872
+ // distinct reasons:
2873
+ // - `skip_reply` — the agent EXPLICITLY chose not to reply this
2874
+ // turn (the deliberate "stay silent" signal).
2875
+ // - explicit delivery tools (channel_publish / *send_message /
2876
+ // *reply_message / *send_dm) — already sent the message, so
2877
+ // routing it again would double-post.
2878
+ // `think` is deliberately NOT here: it is silent *reasoning*, but
2879
+ // prose written after a think is the agent's actual reply and must
2880
+ // be delivered. (A think-only turn has no trailing prose and stays
2881
+ // silent via the `!t` check below.)
2882
+ const SILENCING = new Set([
2883
+ 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
2884
+ ]);
2885
+ const bare = (n: string) => (n.includes('--') ? n.split('--').pop()! : n);
2886
+ const toolNames = response.content
2887
+ .filter((b) => b.type === 'tool_use')
2888
+ .map((b) => (b as unknown as { name?: string }).name)
2889
+ .filter((n): n is string => typeof n === 'string');
2890
+ const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
2891
+ const t = allText
2892
+ .map((b) => (b as ContentBlock & { type: 'text' }).text)
2893
+ .join('\n')
2894
+ .trim();
2895
+ if (silenced || !t) {
2896
+ console.error(
2897
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
2898
+ `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
2899
+ );
2900
+ } else {
2901
+ console.error(
2902
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing trailing prose (${t.length} chars) as reply`,
2903
+ );
2904
+ try {
2905
+ await this.channelRegistry.routeSpeech(agent.name, t);
2906
+ } catch (err) {
2907
+ console.error('speech routing failed:', err);
2908
+ }
2909
+ }
2910
+ }
2911
+ // NOTE: agent.reset() + onInferenceEnded() already ran above, BEFORE
2912
+ // dispatchSpeech (main's zombie-subagent fix, PR #32 — sync listeners
2913
+ // must see status === 'idle'). Locus routing is speech dispatch and
2914
+ // doesn't depend on the status field, so it correctly runs after.
2915
+
2055
2916
  break;
2056
2917
  }
2057
2918
 
@@ -2147,9 +3008,18 @@ export class AgentFramework {
2147
3008
  agent.reset();
2148
3009
  this.eventGate?.onInferenceEnded(agent.name);
2149
3010
  } finally {
3011
+ // Stop the typing indicator on every exit path (complete, error,
3012
+ // exhausted, abort) so it never sticks after the turn ends.
3013
+ this.channelRegistry?.stopTyping();
2150
3014
  this.activeStreams.delete(agent.name);
2151
3015
  this.pendingAssistantBlocks.delete(agent.name);
2152
3016
 
3017
+ // A conversation fork whose TTL closure turn just finished is done for
3018
+ // good — dispose it so the agent map doesn't grow monotonically.
3019
+ if (this.closingConversationAgents.has(agent.name)) {
3020
+ this.disposeConversationAgent(agent.name);
3021
+ }
3022
+
2153
3023
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
2154
3024
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
2155
3025
  const deferred = this.deferredMessages.splice(0);
@@ -2216,17 +3086,57 @@ export class AgentFramework {
2216
3086
  }
2217
3087
 
2218
3088
  private toMembraneToolResult(callId: string, afResult: ToolResult, maxChars?: number): MembraneToolResult {
2219
- let content: string;
2220
3089
  if (afResult.isError) {
2221
- content = afResult.error ?? 'Unknown error';
2222
- } else {
2223
- content = JSON.stringify(afResult.data);
2224
- if (maxChars && content.length > maxChars) {
2225
- content = safeSlice(content, 0, maxChars)
2226
- + '\n\n[truncated — original was ' + content.length + ' chars]';
3090
+ return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
3091
+ }
3092
+ // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
3093
+ // blocks natively rather than JSON-stringifying them away. Anything else
3094
+ // (objects, scalars) falls through to JSON. The error path was handled
3095
+ // above, so isError is always false on these return paths.
3096
+ const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
3097
+ if (blocks) {
3098
+ return { toolUseId: callId, content: blocks, isError: false };
3099
+ }
3100
+ let content = JSON.stringify(afResult.data);
3101
+ if (maxChars && content.length > maxChars) {
3102
+ content = safeSlice(content, 0, maxChars)
3103
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
3104
+ }
3105
+ return { toolUseId: callId, content, isError: false };
3106
+ }
3107
+
3108
+ /**
3109
+ * If `data` is an MCP tool-result content array carrying at least one image,
3110
+ * convert to Membrane's native ToolResultContentBlock[]. Returns null when
3111
+ * the array is text-only (let JSON path handle it; saves a code path).
3112
+ * `maxChars`, when provided, caps each accompanying text block so an image
3113
+ * inlined alongside an enormous text payload can't blow the context.
3114
+ */
3115
+ private tryNativeToolResultContent(data: unknown, maxChars?: number): ToolResultContentBlock[] | null {
3116
+ if (!Array.isArray(data)) return null;
3117
+ let hasImage = false;
3118
+ const blocks: ToolResultContentBlock[] = [];
3119
+ for (const raw of data) {
3120
+ if (!raw || typeof raw !== 'object') return null;
3121
+ const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
3122
+ if (b.type === 'text' && typeof b.text === 'string') {
3123
+ let text = b.text;
3124
+ if (maxChars && text.length > maxChars) {
3125
+ text = safeSlice(text, 0, maxChars)
3126
+ + '\n\n[truncated — original was ' + text.length + ' chars]';
3127
+ }
3128
+ blocks.push({ type: 'text', text });
3129
+ } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
3130
+ hasImage = true;
3131
+ blocks.push({
3132
+ type: 'image',
3133
+ source: { type: 'base64', data: b.data, mediaType: b.mimeType },
3134
+ });
3135
+ } else {
3136
+ return null; // unknown shape — bail to JSON path
2227
3137
  }
2228
3138
  }
2229
- return { toolUseId: callId, content, isError: afResult.isError };
3139
+ return hasImage ? blocks : null;
2230
3140
  }
2231
3141
 
2232
3142
  private getMaxToolResultChars(agent: Agent): number | undefined {
@@ -2320,12 +3230,27 @@ export class AgentFramework {
2320
3230
  return;
2321
3231
  }
2322
3232
 
2323
- // Route gate:status tool
2324
- if (enrichedCall.name === 'gate:status' && this.eventGate) {
3233
+ // Route gate_status tool
3234
+ if (enrichedCall.name === 'gate_status' && this.eventGate) {
2325
3235
  this.dispatchGateToolCall(agentName, enrichedCall);
2326
3236
  return;
2327
3237
  }
2328
3238
 
3239
+ // Route sleep / wake tools
3240
+ if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
3241
+ this.dispatchSleepToolCall(agentName, enrichedCall);
3242
+ return;
3243
+ }
3244
+
3245
+ // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
3246
+ // stay-silent) tools — handled by the channel registry like the other
3247
+ // synthesized channel tools, but they aren't `channel_`-prefixed so they
3248
+ // need an explicit route here.
3249
+ if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
3250
+ this.dispatchChannelToolCall(agentName, enrichedCall);
3251
+ return;
3252
+ }
3253
+
2329
3254
  const sepIndex = enrichedCall.name.indexOf('--');
2330
3255
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
2331
3256
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -2479,6 +3404,27 @@ export class AgentFramework {
2479
3404
  }
2480
3405
 
2481
3406
  private emitTrace(event: { type: TraceEvent['type']; [key: string]: unknown }): void {
3407
+ // Centralized inference-health observability. Every terminal failure path
3408
+ // funnels through an `inference:exhausted` trace and every successful model
3409
+ // response through `inference:completed`, so intercepting here is the one
3410
+ // place that reliably sees all outcomes regardless of which code path
3411
+ // produced them. In headless/daemon mode no trace client is attached, so
3412
+ // without this the only durable record of a failed inference is a field in
3413
+ // llm-calls.jsonl — invisible to operator, agent, and monitoring.
3414
+ if (event.type === 'inference:exhausted') {
3415
+ this.noteInferenceExhausted(
3416
+ (event.agentName as string) ?? 'unknown',
3417
+ (event.error as string) ?? 'unknown error',
3418
+ );
3419
+ } else if (event.type === 'inference:completed') {
3420
+ // A successful response — even mid-turn between tool calls — proves the
3421
+ // agent isn't hard-down; clear its consecutive-failure streak.
3422
+ const name = event.agentName as string | undefined;
3423
+ if (name && this.consecutiveInferenceFailures.get(name)) {
3424
+ this.consecutiveInferenceFailures.set(name, 0);
3425
+ }
3426
+ }
3427
+
2482
3428
  const traceEvent = {
2483
3429
  ...event,
2484
3430
  timestamp: Date.now(),
@@ -2493,6 +3439,59 @@ export class AgentFramework {
2493
3439
  }
2494
3440
  }
2495
3441
 
3442
+ /**
3443
+ * Handle a fully-exhausted inference (the agent could not produce a response
3444
+ * this turn, after retries). Severity here is high — the agent can't think
3445
+ * at all — yet historically the only durable record was a buried field in
3446
+ * llm-calls.jsonl. This surfaces it three ways:
3447
+ *
3448
+ * 1. stderr — always, with the underlying API reason. The place an
3449
+ * operator greps; previously empty in headless mode.
3450
+ * 2. chronicle marker — a `[inference-failed]` message so the agent itself
3451
+ * learns its turn failed and why (otherwise it's an experiential blank,
3452
+ * indistinguishable from "not addressed"). addMessage does NOT request
3453
+ * inference, so this never causes a retry/wake loop.
3454
+ * 3. escalation — after N consecutive failures the agent is hard-down;
3455
+ * log that loudly (a repeated identical failure is the textbook signal).
3456
+ */
3457
+ private noteInferenceExhausted(agentName: string, reason: string): void {
3458
+ const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
3459
+ this.consecutiveInferenceFailures.set(agentName, streak);
3460
+
3461
+ // (1) Durable stderr line — works in headless/daemon mode with no client.
3462
+ console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
3463
+
3464
+ // (2) Agent-facing chronicle marker (no inference triggered → no loop).
3465
+ const agent = this.agents.get(agentName);
3466
+ if (agent) {
3467
+ try {
3468
+ agent.getContextManager().addMessage(
3469
+ 'user',
3470
+ [{
3471
+ type: 'text',
3472
+ text:
3473
+ `[inference-failed] Your previous turn did not complete: the model ` +
3474
+ `call failed and produced no response, so nothing was sent. Reason: ` +
3475
+ `${reason}. If this recurs with the same cause, change approach ` +
3476
+ `rather than retrying identically (e.g. drop an oversized attachment ` +
3477
+ `or an unsupported setting).`,
3478
+ }],
3479
+ { system: true, kind: 'inference-failed', reason, consecutive: streak },
3480
+ );
3481
+ } catch (err) {
3482
+ console.error(`[inference-failed] could not record chronicle marker for ${agentName}:`, err);
3483
+ }
3484
+ }
3485
+
3486
+ // (3) Hard-down escalation on repeated identical failure.
3487
+ if (streak >= this.inferenceFailureEscalationThreshold) {
3488
+ console.error(
3489
+ `[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
3490
+ `inferences — it cannot complete a turn. Last reason: ${reason}`,
3491
+ );
3492
+ }
3493
+ }
3494
+
2496
3495
  // ==========================================================================
2497
3496
  // MCPL subsystem wiring
2498
3497
  // ==========================================================================
@@ -2563,6 +3562,26 @@ export class AgentFramework {
2563
3562
  }
2564
3563
  },
2565
3564
  shouldTriggerInference: triggerFilter,
3565
+ // A text-only turn whose speech couldn't be delivered must not vanish
3566
+ // silently: record a `[discord-send-failed]` marker in chronicle so the
3567
+ // agent sees, on her next turn, that her reply never reached the human.
3568
+ // addMessage() alone does not request inference, so this never wakes
3569
+ // her (matching the `discord-send-failed-skip` gate intent: context
3570
+ // yes, wake no).
3571
+ onRouteFailure: ({ channelId, reason, textLen }) => {
3572
+ try {
3573
+ this.addMessage(
3574
+ 'user',
3575
+ [{
3576
+ type: 'text',
3577
+ 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.`,
3578
+ }],
3579
+ { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
3580
+ );
3581
+ } catch (err) {
3582
+ console.error('onRouteFailure: failed to record send-failure marker:', err);
3583
+ }
3584
+ },
2566
3585
  },
2567
3586
  );
2568
3587
 
@@ -2716,6 +3735,21 @@ export class AgentFramework {
2716
3735
  this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
2717
3736
  });
2718
3737
 
3738
+ // Handle host-level admin commands from a surface (e.g. Discord /undo)
3739
+ connection.on('host-command', async (
3740
+ params: HostCommandParams,
3741
+ responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
3742
+ ) => {
3743
+ if (!responder) return;
3744
+ try {
3745
+ const result = await this.handleHostCommand(connection.id, params ?? {});
3746
+ responder.respond(result);
3747
+ } catch (error) {
3748
+ const err = error instanceof Error ? error : new Error(String(error));
3749
+ responder.respondError(-32603, err.message);
3750
+ }
3751
+ });
3752
+
2719
3753
  // Handle dynamic tool list changes (notifications/tools/list_changed)
2720
3754
  connection.on('tools-list-changed', () => {
2721
3755
  this.handleToolsListChanged(connection.id);
@@ -2887,11 +3921,22 @@ export class AgentFramework {
2887
3921
  }
2888
3922
  }
2889
3923
 
2890
- // Convert MCP tool result to framework ToolResult
3924
+ // Convert MCP tool result to framework ToolResult.
3925
+ // When the result contains non-text blocks (e.g. images from an MCP
3926
+ // tool like zulip-mcp's fetch_attachment), pass the full content array
3927
+ // through so toMembraneToolResult can preserve image blocks natively.
3928
+ // Text-only results still collapse to a joined string for backward
3929
+ // compatibility with callers that expect data to be string-ish.
2891
3930
  const textContent = result.content
2892
3931
  ?.filter((c) => c.type === 'text' && c.text)
2893
3932
  .map((c) => c.text!)
2894
3933
  .join('\n');
3934
+ const hasNonText = result.content?.some((c) => c.type !== 'text');
3935
+ const data = result.isError
3936
+ ? undefined
3937
+ : hasNonText
3938
+ ? result.content
3939
+ : (textContent || undefined);
2895
3940
 
2896
3941
  this.pushEvent({
2897
3942
  type: 'tool-result',
@@ -2900,7 +3945,7 @@ export class AgentFramework {
2900
3945
  moduleName: `mcpl:${serverId}`,
2901
3946
  result: {
2902
3947
  success: !result.isError,
2903
- data: result.isError ? undefined : textContent || undefined,
3948
+ data,
2904
3949
  error: result.isError ? (textContent || 'Tool call failed') : undefined,
2905
3950
  isError: result.isError ?? false,
2906
3951
  },
@@ -2927,6 +3972,43 @@ export class AgentFramework {
2927
3972
  * Dispatch a synthesized channel tool call.
2928
3973
  */
2929
3974
  private dispatchChannelToolCall(agentName: string, call: ToolCall): void {
3975
+ // Conversation forks act only on their home channel. channel_publish and
3976
+ // channel_close default a missing channelId to home and reject foreign
3977
+ // ones; channel_open is rejected outright — opening channels mutates
3978
+ // framework-global state (the open-channel set every agent's injections
3979
+ // are scoped against), which is not a fork's call to make.
3980
+ const home = this.conversationAgentHomes.get(agentName);
3981
+ if (home) {
3982
+ const reject = (error: string): void => {
3983
+ this.emitTrace({
3984
+ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
3985
+ error: `conversation agent ${agentName}: ${error}`,
3986
+ });
3987
+ this.pushEvent({
3988
+ type: 'tool-result',
3989
+ callId: call.id,
3990
+ agentName,
3991
+ moduleName: 'channels',
3992
+ result: { success: false, error, isError: true },
3993
+ });
3994
+ };
3995
+
3996
+ if (call.name === 'channel_open') {
3997
+ reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`);
3998
+ return;
3999
+ }
4000
+ if (call.name === 'channel_publish' || call.name === 'channel_close') {
4001
+ const input = (call.input ?? {}) as { channelId?: string };
4002
+ if (!input.channelId) {
4003
+ call = { ...call, input: { ...input, channelId: home } };
4004
+ } else if (input.channelId !== home) {
4005
+ const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing';
4006
+ reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`);
4007
+ return;
4008
+ }
4009
+ }
4010
+ }
4011
+
2930
4012
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
2931
4013
  const startTime = Date.now();
2932
4014
 
@@ -2955,6 +4037,53 @@ export class AgentFramework {
2955
4037
  });
2956
4038
  }
2957
4039
 
4040
+ /**
4041
+ * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
4042
+ * suppression window, optionally announces in the sticky channel, and ends
4043
+ * the turn (the agent goes idle immediately). `wake` clears sleep.
4044
+ */
4045
+ private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
4046
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4047
+ const gate = this.eventGate!;
4048
+ const input = (call.input ?? {}) as { seconds?: number; announce?: boolean; message?: string };
4049
+
4050
+ const finish = (result: ToolResult) => {
4051
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
4052
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
4053
+ };
4054
+
4055
+ if (call.name === 'wake') {
4056
+ const was = gate.clearSleep();
4057
+ finish({ success: true, data: { woke: was } });
4058
+ return;
4059
+ }
4060
+
4061
+ const seconds = Number(input.seconds);
4062
+ if (!Number.isFinite(seconds) || seconds <= 0) {
4063
+ finish({ success: false, error: 'sleep: `seconds` must be a positive number', isError: true });
4064
+ return;
4065
+ }
4066
+
4067
+ const { until } = gate.setSleep(seconds, input.message);
4068
+ const announce = input.announce !== false; // default true
4069
+ const human =
4070
+ seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
4071
+ : seconds >= 60 ? `${Math.round(seconds / 60)}m`
4072
+ : `${Math.round(seconds)}s`;
4073
+
4074
+ // Announce in the sticky channel (best-effort; never blocks the result).
4075
+ if (announce && this.channelRegistry) {
4076
+ const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
4077
+ this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
4078
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
4079
+ });
4080
+ }
4081
+
4082
+ console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
4083
+ // endTurn: the agent stops here and goes idle for the duration.
4084
+ finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
4085
+ }
4086
+
2958
4087
  private dispatchGateToolCall(agentName: string, call: ToolCall): void {
2959
4088
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
2960
4089
  const startTime = Date.now();