@animalabs/agent-framework 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/src/framework.d.ts +67 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +469 -26
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +44 -1
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +152 -8
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  10. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  11. package/dist/src/gate/gate-script-worker.js +69 -0
  12. package/dist/src/gate/gate-script-worker.js.map +1 -0
  13. package/dist/src/gate/gate-script.d.ts +44 -0
  14. package/dist/src/gate/gate-script.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script.js +164 -0
  16. package/dist/src/gate/gate-script.js.map +1 -0
  17. package/dist/src/gate/types.d.ts +18 -3
  18. package/dist/src/gate/types.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +43 -1
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +82 -8
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  24. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  25. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  26. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  27. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  28. package/dist/src/mcpl/push-handler.js +1 -0
  29. package/dist/src/mcpl/push-handler.js.map +1 -1
  30. package/dist/src/mcpl/server-connection.d.ts +35 -30
  31. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  32. package/dist/src/mcpl/server-connection.js +145 -206
  33. package/dist/src/mcpl/server-connection.js.map +1 -1
  34. package/dist/src/mcpl/transport.d.ts +98 -0
  35. package/dist/src/mcpl/transport.d.ts.map +1 -0
  36. package/dist/src/mcpl/transport.js +281 -0
  37. package/dist/src/mcpl/transport.js.map +1 -0
  38. package/dist/src/mcpl/types.d.ts +42 -2
  39. package/dist/src/mcpl/types.d.ts.map +1 -1
  40. package/dist/src/mcpl/types.js.map +1 -1
  41. package/dist/src/module-registry.d.ts +2 -0
  42. package/dist/src/module-registry.d.ts.map +1 -1
  43. package/dist/src/module-registry.js +9 -2
  44. package/dist/src/module-registry.js.map +1 -1
  45. package/dist/src/prose-segments.d.ts +18 -0
  46. package/dist/src/prose-segments.d.ts.map +1 -0
  47. package/dist/src/prose-segments.js +39 -0
  48. package/dist/src/prose-segments.js.map +1 -0
  49. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  50. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  51. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  52. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  53. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  54. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  55. package/dist/src/runtime/liveness-watchdog.js +106 -0
  56. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  57. package/dist/src/types/agent.d.ts +10 -0
  58. package/dist/src/types/agent.d.ts.map +1 -1
  59. package/dist/src/types/framework.d.ts +14 -0
  60. package/dist/src/types/framework.d.ts.map +1 -1
  61. package/dist/src/types/module.d.ts +12 -0
  62. package/dist/src/types/module.d.ts.map +1 -1
  63. package/dist/test/channel-registry-routing.test.js +108 -1
  64. package/dist/test/channel-registry-routing.test.js.map +1 -1
  65. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  66. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  67. package/dist/test/checkpoint-attribution.test.js +40 -0
  68. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  69. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  70. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  71. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  72. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  73. package/dist/test/event-gate-script.test.d.ts +2 -0
  74. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  75. package/dist/test/event-gate-script.test.js +95 -0
  76. package/dist/test/event-gate-script.test.js.map +1 -0
  77. package/dist/test/event-gate-tags.test.d.ts +2 -0
  78. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  79. package/dist/test/event-gate-tags.test.js +76 -0
  80. package/dist/test/event-gate-tags.test.js.map +1 -0
  81. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  82. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  83. package/dist/test/liveness-watchdog.test.js +78 -0
  84. package/dist/test/liveness-watchdog.test.js.map +1 -0
  85. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  86. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  87. package/dist/test/mcpl-ws-transport.test.js +155 -0
  88. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  89. package/dist/test/prose-segments.test.d.ts +2 -0
  90. package/dist/test/prose-segments.test.d.ts.map +1 -0
  91. package/dist/test/prose-segments.test.js +52 -0
  92. package/dist/test/prose-segments.test.js.map +1 -0
  93. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  94. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  95. package/dist/test/trunk-channel-routing.test.js +175 -0
  96. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  97. package/dist/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +2 -2
  99. package/src/framework.ts +509 -24
  100. package/src/gate/event-gate.ts +153 -8
  101. package/src/gate/gate-script-worker.ts +73 -0
  102. package/src/gate/gate-script.ts +180 -0
  103. package/src/gate/types.ts +19 -3
  104. package/src/mcpl/channel-registry.ts +112 -8
  105. package/src/mcpl/checkpoint-manager.ts +16 -0
  106. package/src/mcpl/push-handler.ts +1 -0
  107. package/src/mcpl/server-connection.ts +153 -224
  108. package/src/mcpl/transport.ts +307 -0
  109. package/src/mcpl/types.ts +49 -2
  110. package/src/module-registry.ts +13 -2
  111. package/src/prose-segments.ts +42 -0
  112. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  113. package/src/runtime/liveness-watchdog.ts +122 -0
  114. package/src/types/agent.ts +10 -0
  115. package/src/types/framework.ts +15 -0
  116. package/src/types/module.ts +13 -0
package/src/framework.ts CHANGED
@@ -53,6 +53,7 @@ import { ChannelRegistry } from './mcpl/channel-registry.js';
53
53
  import { ConversationRouter } from './mcpl/conversation-router.js';
54
54
  import { safeSlice } from './safe-slice.js';
55
55
  import { toolResultDataToHistoryString } from './tool-result-history.js';
56
+ import { splitProseSegments } from './prose-segments.js';
56
57
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
57
58
  import { isToolAllowed } from './mcpl/tool-policy.js';
58
59
  import { EventGate } from './gate/event-gate.js';
@@ -211,6 +212,16 @@ export class AgentFramework {
211
212
  private inferencePolicy: InferencePolicy;
212
213
  private errorPolicy: ErrorPolicy;
213
214
  private pendingRequests: InferenceRequest[] = [];
215
+ /**
216
+ * Per-agent channel that triggered the agent's CURRENT inference turn, if any
217
+ * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
218
+ * route a single-trunk agent's plain-text speech back to the channel it is
219
+ * answering, instead of the process-global most-recent-inbound locus that a
220
+ * concurrent message elsewhere can hijack. Set at turn start (or cleared for a
221
+ * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
222
+ * turn. Never read between turns (a given agent runs one turn at a time).
223
+ */
224
+ private activeTriggerChannels: Map<string, string> = new Map();
214
225
  private running = false;
215
226
  private loopPromise: Promise<void> | null = null;
216
227
  private traceListeners: TraceEventListener[] = [];
@@ -237,6 +248,9 @@ export class AgentFramework {
237
248
  private turnCounters: Map<string, number> = new Map(); // agentName → next turnIndex
238
249
  private redoStacks: Map<string, RedoEntry[]> = new Map(); // agentName → redo entries
239
250
 
251
+ /** Liveness watchdog (fail hard on a wedged main thread). Null unless enabled. */
252
+ private livenessWatchdog: import('./runtime/liveness-watchdog.js').LivenessWatchdog | null = null;
253
+
240
254
  // MCPL subsystems (null when no mcplServers configured)
241
255
  private mcplServerRegistry: McplServerRegistry | null = null;
242
256
  private featureSetManager: FeatureSetManager | null = null;
@@ -309,6 +323,7 @@ export class AgentFramework {
309
323
  queryMessages: (filter) => this.queryMessages(filter),
310
324
  pushEvent: (event) => this.pushEvent(event),
311
325
  onTrace: (listener) => this.onTrace(listener),
326
+ callTool: (call) => this.executeToolCall(call),
312
327
  });
313
328
  }
314
329
 
@@ -444,6 +459,19 @@ export class AgentFramework {
444
459
  });
445
460
  }
446
461
 
462
+ // Liveness watchdog: fail hard if the main thread wedges (opt-in).
463
+ if (config.watchdog?.enabled) {
464
+ const { LivenessWatchdog } = await import('./runtime/liveness-watchdog.js');
465
+ framework.livenessWatchdog = new LivenessWatchdog({
466
+ enabled: true,
467
+ thresholdMs: config.watchdog.thresholdMs,
468
+ action: config.watchdog.action,
469
+ reportPath: config.watchdog.reportPath
470
+ ?? (config.storePath ? join(config.storePath, 'watchdog-wedge.jsonl') : undefined),
471
+ });
472
+ framework.livenessWatchdog.start();
473
+ }
474
+
447
475
  // Initialize MCPL subsystems if configured
448
476
  if (config.mcplServers && config.mcplServers.length > 0) {
449
477
  // Validate tool prefixes: no collisions with module names or between servers
@@ -529,6 +557,7 @@ export class AgentFramework {
529
557
 
530
558
  // Dispose EventGate (clear debounce timers)
531
559
  this.eventGate?.dispose();
560
+ this.livenessWatchdog?.stop();
532
561
 
533
562
  // Stop modules and MCPL servers in parallel
534
563
  const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll()];
@@ -658,7 +687,12 @@ export class AgentFramework {
658
687
  const moduleTools = this.moduleRegistry.getAllTools();
659
688
  const channelTools = this.channelRegistry?.getChannelTools() ?? [];
660
689
  const gateTools = this.eventGate
661
- ? [this.eventGate.getToolDefinition(), ...AgentFramework.SLEEP_TOOLS]
690
+ ? [
691
+ this.eventGate.getToolDefinition(),
692
+ ...AgentFramework.SLEEP_TOOLS,
693
+ ...AgentFramework.WAKE_RULE_TOOLS,
694
+ AgentFramework.EVENT_TAGS_TOOL,
695
+ ]
662
696
  : [];
663
697
  if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
664
698
  return moduleTools;
@@ -763,6 +797,45 @@ export class AgentFramework {
763
797
  return this.moduleRegistry.getModule(name);
764
798
  }
765
799
 
800
+ // =========================================================================
801
+ // Wake-rule surface (gate policy mutation) — backs wake_add_rule /
802
+ // wake_remove_rule and lets host modules compose higher-level wake modes
803
+ // (e.g. an "every-message-debounced" channel mode) without reaching into
804
+ // the private EventGate.
805
+ // =========================================================================
806
+
807
+ /**
808
+ * Add or replace (upsert) a gate policy at runtime. Validated and persisted
809
+ * to gate.json; hot-applied in memory. Throws if no gate is configured or the
810
+ * policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
811
+ * defer/debounce rules (first match wins).
812
+ */
813
+ addGatePolicy(
814
+ rawPolicy: unknown,
815
+ options?: { position?: 'append' | 'prepend' },
816
+ ): import('./gate/types.js').GatePolicy {
817
+ if (!this.eventGate) {
818
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
819
+ }
820
+ return this.eventGate.addPolicy(rawPolicy, options);
821
+ }
822
+
823
+ /**
824
+ * Remove a gate policy by name at runtime. Returns false if it didn't exist.
825
+ * Throws if no gate is configured.
826
+ */
827
+ removeGatePolicy(name: string): boolean {
828
+ if (!this.eventGate) {
829
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
830
+ }
831
+ return this.eventGate.removePolicy(name);
832
+ }
833
+
834
+ /** Current gate policy names (freshest on-disk view). Empty when no gate. */
835
+ getGatePolicyNames(): string[] {
836
+ return this.eventGate?.listPolicyNames() ?? [];
837
+ }
838
+
766
839
  /**
767
840
  * Get the Membrane instance.
768
841
  */
@@ -1037,6 +1110,154 @@ export class AgentFramework {
1037
1110
  },
1038
1111
  ];
1039
1112
 
1113
+ /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
1114
+ * Present when a gate is wired. They write validated policies into the
1115
+ * hot-reloaded gate.json (same validation as load), so a rule takes effect
1116
+ * immediately and survives restart. */
1117
+ private static readonly WAKE_RULE_TOOLS: import('./types/index.js').ToolDefinition[] = [
1118
+ {
1119
+ name: 'wake_add_rule',
1120
+ description:
1121
+ 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
1122
+ 'hand-edit the file. The rule is validated and hot-applied immediately. ' +
1123
+ 'A rule with the same `name` replaces the existing one in place. Use ' +
1124
+ '`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
1125
+ 'rules (first match wins). Two common shapes:\n' +
1126
+ '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
1127
+ '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
1128
+ '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
1129
+ 'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, behavior: { debounce: 60000 } }` ' +
1130
+ '(the channel must be subscribed for ambient events to arrive — see channel mode).',
1131
+ inputSchema: {
1132
+ type: 'object',
1133
+ properties: {
1134
+ name: { type: 'string', description: 'Unique rule name. Reusing a name replaces that rule.' },
1135
+ match: {
1136
+ type: 'object',
1137
+ description: 'Match criteria (all AND together). Omitted fields match anything.',
1138
+ properties: {
1139
+ scope: { type: 'array', items: { type: 'string' }, description: 'Event types, e.g. ["mcpl:channel-incoming","workspace:modified"].' },
1140
+ source: { type: 'string', description: 'Integration/serverId, glob ok (e.g. "discord").' },
1141
+ channel: { type: 'string', description: 'Channel id, glob ok (e.g. "discord:*:12345").' },
1142
+ mount: { type: 'string', description: 'Workspace mount name, glob ok (workspace:* events).' },
1143
+ pathGlob: { type: 'string', description: 'Glob over touched paths (workspace:* events).' },
1144
+ tagsAny: { type: 'array', items: { type: 'string' }, description: 'Match if ANY tag matches (globs ok).' },
1145
+ tagsAll: { type: 'array', items: { type: 'string' }, description: 'Match only if EVERY tag matches.' },
1146
+ tagsNone: { type: 'array', items: { type: 'string' }, description: 'Match only if NONE match.' },
1147
+ metadataTrue: { type: 'array', items: { type: 'string' }, description: 'Match if ANY listed metadata field is truthy.' },
1148
+ filter: {
1149
+ type: 'object',
1150
+ description: 'Content filter.',
1151
+ properties: {
1152
+ type: { type: 'string', enum: ['text', 'regex'] },
1153
+ pattern: { type: 'string' },
1154
+ },
1155
+ required: ['type', 'pattern'],
1156
+ },
1157
+ },
1158
+ },
1159
+ behavior: {
1160
+ type: 'string',
1161
+ enum: ['always', 'defer', 'skip'],
1162
+ description:
1163
+ 'Simple behavior: "always" (wake now) or "defer" (don\'t wake; still enters ' +
1164
+ 'context). For debounce / rate-limit / sampling, use debounceMs / rateLimit / ' +
1165
+ 'passiveSample below instead of this field. Exactly one behavior must be given.',
1166
+ },
1167
+ debounceMs: {
1168
+ type: 'number',
1169
+ description: 'Shorthand for { debounce: ms }: wake once after ms of quiet (100–300000).',
1170
+ },
1171
+ rateLimit: {
1172
+ type: 'object',
1173
+ description: 'Token-bucket wake: at most `tokens` wakes per window; refills one per refillIntervalMs.',
1174
+ properties: {
1175
+ tokens: { type: 'number', description: 'Bucket capacity (> 0).' },
1176
+ refillIntervalMs: { type: 'number', description: 'Ms between token refills (> 0).' },
1177
+ keyBy: { type: 'string', description: 'Metadata field to partition buckets by (e.g. "channelId").' },
1178
+ },
1179
+ required: ['tokens', 'refillIntervalMs'],
1180
+ },
1181
+ passiveSample: {
1182
+ type: 'object',
1183
+ description: 'Wake every Nth matching event.',
1184
+ properties: {
1185
+ every: { type: 'number', description: 'Fire every N matches (positive integer).' },
1186
+ keyBy: { type: 'string', description: 'Metadata field for separate per-key counters.' },
1187
+ },
1188
+ required: ['every'],
1189
+ },
1190
+ position: {
1191
+ type: 'string',
1192
+ enum: ['append', 'prepend'],
1193
+ description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
1194
+ },
1195
+ resets: {
1196
+ type: 'array',
1197
+ items: { type: 'string' },
1198
+ description: 'Names of other rules whose runtime state to clear when this rule fires.',
1199
+ },
1200
+ },
1201
+ required: ['name'],
1202
+ },
1203
+ },
1204
+ {
1205
+ name: 'wake_remove_rule',
1206
+ description:
1207
+ 'Remove a wake rule (gate.json policy) by name at runtime. Any pending ' +
1208
+ 'debounce batch for that rule is delivered first. Returns whether a rule ' +
1209
+ 'was removed (false if no rule had that name).',
1210
+ inputSchema: {
1211
+ type: 'object',
1212
+ properties: {
1213
+ name: { type: 'string', description: 'The rule name to remove.' },
1214
+ },
1215
+ required: ['name'],
1216
+ },
1217
+ },
1218
+ ];
1219
+
1220
+ /** Discoverability for event tags (MCPL RFC-001): the reserved chat:* core,
1221
+ * each connected server's declared tag ontology, and gate.js status. */
1222
+ private static readonly EVENT_TAGS_TOOL: import('./types/index.js').ToolDefinition = {
1223
+ name: 'event_tags',
1224
+ description:
1225
+ 'List the event tags available for gating: the reserved cross-platform ' +
1226
+ 'chat:* core, each connected server\'s declared tag ontology (descriptions, ' +
1227
+ 'implications, suggested treatments), and the status of your programmable ' +
1228
+ 'gate (gate.js). Use these tag names in gate.json policies (tagsAny / ' +
1229
+ 'tagsAll / tagsNone) or in gate.js.',
1230
+ inputSchema: { type: 'object' },
1231
+ };
1232
+
1233
+ /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1234
+ * the agent can author rules without reading the spec. */
1235
+ private static readonly CHAT_CORE_TAGS: Record<string, string> = {
1236
+ 'chat:addressed': 'Directed at you (umbrella: dm/mention/reply)',
1237
+ 'chat:mention': 'You were explicitly @-mentioned',
1238
+ 'chat:reply': 'A reply to your own message',
1239
+ 'chat:dm': 'A direct/private message to you',
1240
+ 'chat:ambient': 'Overheard in a followed channel; not addressed',
1241
+ 'chat:broadcast': 'Channel-wide ping (@everyone / channel post)',
1242
+ 'chat:to-self': 'Acts on your own content (reaction/reply to you)',
1243
+ 'chat:from-human': 'Authored by a human',
1244
+ 'chat:from-bot': 'Authored by a bot/automation',
1245
+ 'chat:from-self': 'Your own message, echoed back',
1246
+ 'chat:from-agent': 'Authored by another persona/agent',
1247
+ 'chat:edited': 'An edit of an existing message',
1248
+ 'chat:deleted': 'A deletion',
1249
+ 'chat:reaction': 'An emoji reaction was added',
1250
+ 'chat:reaction-remove': 'A reaction was removed',
1251
+ 'chat:has-image': 'Has an image attachment',
1252
+ 'chat:has-audio': 'Has an audio attachment',
1253
+ 'chat:has-file': 'Has a file attachment',
1254
+ 'chat:has-link': 'Contains a link',
1255
+ 'chat:command': 'A slash/bot command invocation',
1256
+ 'chat:private': 'Private conversation',
1257
+ 'chat:group': 'Group (multi-party) conversation',
1258
+ 'chat:thread': 'Occurred in a thread',
1259
+ };
1260
+
1040
1261
  /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
1041
1262
  private static readonly REFUSAL_REACTIONS: Record<string, string> = {
1042
1263
  bio: '☣️',
@@ -2115,6 +2336,9 @@ export class AgentFramework {
2115
2336
  reason: 'mcpl:channel-incoming',
2116
2337
  source: event.serverId,
2117
2338
  timestamp: Date.now(),
2339
+ // Route this turn's auto-published speech back to THIS channel, not
2340
+ // the global most-recent-inbound locus (item-3 redux, trunk agents).
2341
+ channelId: event.channelId,
2118
2342
  });
2119
2343
  }
2120
2344
  }
@@ -2209,6 +2433,9 @@ export class AgentFramework {
2209
2433
  reason: 'mcpl:channel-incoming',
2210
2434
  source: event.serverId,
2211
2435
  timestamp: Date.now(),
2436
+ // A fork's home channel wins in routeSpeech regardless, but carry the
2437
+ // triggering channel too so the trunk/active path stays consistent.
2438
+ channelId: event.channelId,
2212
2439
  });
2213
2440
  }
2214
2441
  }
@@ -2390,6 +2617,20 @@ export class AgentFramework {
2390
2617
  const id = this.addMessage('user', event.content, metadata);
2391
2618
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
2392
2619
 
2620
+ // Some push events carry a channel of origin (Discord DMs arrive here, not
2621
+ // via channels/incoming, because discord-mcpl forwards them with the channel
2622
+ // closed). Register + open that channel so routeSpeech can resolve it, and
2623
+ // thread it onto the wake request so the reply routes back to it instead of
2624
+ // the global locus (item-3 redux, DM sub-case).
2625
+ const triggerChannel = this.derivePushEventChannel(event.origin);
2626
+ if (triggerChannel && this.channelRegistry) {
2627
+ this.channelRegistry.ensureChannelRegistered(
2628
+ event.serverId,
2629
+ triggerChannel.channelId,
2630
+ triggerChannel.label,
2631
+ );
2632
+ }
2633
+
2393
2634
  if (event.triggerInference) {
2394
2635
  // Default broadcast excludes conversation forks (channel-driven).
2395
2636
  const targetAgents = event.targetAgents
@@ -2400,11 +2641,50 @@ export class AgentFramework {
2400
2641
  reason: 'mcpl:push-event',
2401
2642
  source: event.serverId,
2402
2643
  timestamp: Date.now(),
2644
+ channelId: triggerChannel?.channelId,
2403
2645
  });
2404
2646
  }
2405
2647
  }
2406
2648
  }
2407
2649
 
2650
+ /**
2651
+ * Derive the MCPL composite channel id (the outbound routing locus) for a
2652
+ * push event from its server-defined `origin`, if it carries one.
2653
+ *
2654
+ * Discord DMs are the motivating case: discord-mcpl forwards them via
2655
+ * push/event (the DM channel is closed), so they never pass through
2656
+ * channels/incoming and their channel is never registered — leaving the
2657
+ * agent's reply with nowhere to route (item-3 redux, DM sub-case). Prefers an
2658
+ * explicit `origin.mcplChannelId` (a surface declaring its own composite id —
2659
+ * the surface-agnostic contract); otherwise reconstructs the Discord form
2660
+ * `discord:{guildId|dm}:{channelId}` from origin fields, matching
2661
+ * discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the
2662
+ * fix works even against a discord-mcpl build that predates `mcplChannelId`.
2663
+ * Returns undefined for push events with no channel provenance (heartbeats,
2664
+ * timers), which correctly keep the global fallback.
2665
+ */
2666
+ private derivePushEventChannel(
2667
+ origin: Record<string, unknown> | undefined,
2668
+ ): { channelId: string; label?: string } | undefined {
2669
+ if (!origin) return undefined;
2670
+ const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
2671
+
2672
+ const explicit = origin.mcplChannelId;
2673
+ if (typeof explicit === 'string' && explicit) {
2674
+ return { channelId: explicit, label };
2675
+ }
2676
+
2677
+ // Discord fallback: reconstruct the composite from origin parts. `guildId`
2678
+ // is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
2679
+ if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
2680
+ const guild =
2681
+ typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
2682
+ return { channelId: `discord:${guild}:${origin.channelId}`, label };
2683
+ }
2684
+
2685
+ return undefined;
2686
+ }
2687
+
2408
2688
  private async processInferenceRequests(): Promise<void> {
2409
2689
  if (this.pendingRequests.length === 0) {
2410
2690
  return;
@@ -2465,7 +2745,19 @@ export class AgentFramework {
2465
2745
 
2466
2746
  // Start streaming inference (non-blocking — driveStream runs in background)
2467
2747
  const trigger = requests[0];
2468
- await this.startAgentStream(agent, trigger);
2748
+ // Route this turn's auto-published speech to the channel that triggered
2749
+ // it (item-3 redux). A batched wake may carry several triggering channels
2750
+ // (messages arrived in >1 channel while the agent was busy/idle) — reply
2751
+ // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
2752
+ // is the message the agent is most likely answering. The common case is
2753
+ // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
2754
+ // channelId, leaving the field undefined → global fallback. `reduce`
2755
+ // keeps the last defined channelId across the (FIFO-ordered) batch.
2756
+ const triggerChannel = requests.reduce<string | undefined>(
2757
+ (acc, r) => r.channelId ?? acc,
2758
+ undefined,
2759
+ );
2760
+ await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
2469
2761
  }
2470
2762
  }
2471
2763
 
@@ -2476,6 +2768,18 @@ export class AgentFramework {
2476
2768
  this.redoStacks.delete(agent.name); // new work invalidates redo
2477
2769
  }
2478
2770
 
2771
+ // Establish this turn's outbound routing locus (item-3 redux). Set it to the
2772
+ // triggering channel for a channel/DM-triggered turn; clear it otherwise so
2773
+ // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
2774
+ // instead falls back to the global default. A given agent runs one turn at a
2775
+ // time, so a set here is only read during THIS turn's routeSpeech /
2776
+ // buildChannelContext; retries re-run with the same trigger, re-setting it.
2777
+ if (trigger?.channelId) {
2778
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
2779
+ } else {
2780
+ this.activeTriggerChannels.delete(agent.name);
2781
+ }
2782
+
2479
2783
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
2480
2784
  this.eventGate?.onInferenceStarted(agent.name);
2481
2785
 
@@ -2888,23 +3192,36 @@ export class AgentFramework {
2888
3192
  .map((b) => (b as unknown as { name?: string }).name)
2889
3193
  .filter((n): n is string => typeof n === 'string');
2890
3194
  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) {
3195
+
3196
+ // Split the turn's content into ordered prose segments, broken at
3197
+ // each tool_use / tool_result boundary. `response.content` holds the
3198
+ // WHOLE turn's blocks in provider order — earlier tool rounds
3199
+ // included (see the trailing-slice note above, which slices exactly
3200
+ // because the full turn is present) — so a left-to-right walk
3201
+ // reconstructs the emission order. Interleaved prose
3202
+ // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
3203
+ // separate messages IN ORDER instead of being collapsed into one
3204
+ // trailing post (item 4). The silencing rule stays turn-wide: an
3205
+ // explicit send / skip suppresses ALL auto-routed prose this turn,
3206
+ // preserving the double-post guard.
3207
+ const segments = splitProseSegments(response.content);
3208
+
3209
+ if (silenced || segments.length === 0) {
2896
3210
  console.error(
2897
3211
  `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
2898
3212
  `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
2899
3213
  );
2900
3214
  } else {
2901
3215
  console.error(
2902
- `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing trailing prose (${t.length} chars) as reply`,
3216
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order`,
2903
3217
  );
2904
- try {
2905
- await this.channelRegistry.routeSpeech(agent.name, t);
2906
- } catch (err) {
2907
- console.error('speech routing failed:', err);
3218
+ // Deliver sequentially (await each) so the segments land in order.
3219
+ for (const seg of segments) {
3220
+ try {
3221
+ await this.channelRegistry.routeSpeech(agent.name, seg);
3222
+ } catch (err) {
3223
+ console.error('speech routing failed:', err);
3224
+ }
2908
3225
  }
2909
3226
  }
2910
3227
  }
@@ -3242,6 +3559,18 @@ export class AgentFramework {
3242
3559
  return;
3243
3560
  }
3244
3561
 
3562
+ // Route wake-rule tools (runtime gate.json policy add/remove)
3563
+ if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
3564
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
3565
+ return;
3566
+ }
3567
+
3568
+ // Route event_tags (tag/ontology discovery)
3569
+ if (enrichedCall.name === 'event_tags' && this.eventGate) {
3570
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
3571
+ return;
3572
+ }
3573
+
3245
3574
  // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
3246
3575
  // stay-silent) tools — handled by the channel registry like the other
3247
3576
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
@@ -3463,7 +3792,7 @@ export class AgentFramework {
3463
3792
 
3464
3793
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
3465
3794
  const agent = this.agents.get(agentName);
3466
- if (agent) {
3795
+ if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
3467
3796
  try {
3468
3797
  agent.getContextManager().addMessage(
3469
3798
  'user',
@@ -3562,6 +3891,22 @@ export class AgentFramework {
3562
3891
  }
3563
3892
  },
3564
3893
  shouldTriggerInference: triggerFilter,
3894
+ // Route a conversation fork's plain-text speech to its HOME channel, not
3895
+ // the process-global most-recent-inbound locus (item 3). The trunk agent
3896
+ // has no home entry, so this returns undefined and routeSpeech falls back
3897
+ // to defaultPublishChannel. `conversationAgentHomes` is the permanent
3898
+ // spawn-time binding; `channelForAgent` is the router's live binding as a
3899
+ // belt-and-suspenders fallback.
3900
+ homeChannelResolver: (agentName) =>
3901
+ this.conversationAgentHomes.get(agentName)
3902
+ ?? this.conversationRouter?.channelForAgent(agentName),
3903
+ // Route a single TRUNK agent's plain-text speech to the channel that
3904
+ // triggered its CURRENT turn (item-3 redux). connectome-host runs every
3905
+ // agent as a trunk (it never exposes conversation forks), so without this
3906
+ // a reply falls back to the process-global most-recent-inbound locus and
3907
+ // a concurrent message in another channel hijacks it. Empty for
3908
+ // heartbeat / no-trigger turns → correct global fallback.
3909
+ activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
3565
3910
  // A text-only turn whose speech couldn't be delivered must not vanish
3566
3911
  // silently: record a `[discord-send-failed]` marker in chronicle so the
3567
3912
  // agent sees, on her next turn, that her reply never reached the human.
@@ -3894,14 +4239,20 @@ export class AgentFramework {
3894
4239
  const startTime = Date.now();
3895
4240
  const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
3896
4241
 
3897
- // Build state params for stateful tools (Step 8)
4242
+ // Build state params for stateful tools (Step 8).
4243
+ // Host-managed state is single-valued per server, so inject the host-managed
4244
+ // set's `state` for any call; otherwise fall back to a server-managed set's
4245
+ // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
4246
+ // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
4247
+ // that misattributes the feed's state.
3898
4248
  let stateParams: { state?: unknown; checkpoint?: string } | undefined;
3899
4249
  if (this.checkpointManager) {
3900
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3901
- if (fs) {
3902
- if (this.checkpointManager.isHostManaged(serverId, fs)) {
3903
- stateParams = { state: this.checkpointManager.getCurrentState(serverId, fs) };
3904
- } else {
4250
+ const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
4251
+ if (hostFs) {
4252
+ stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
4253
+ } else {
4254
+ const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
4255
+ if (fs) {
3905
4256
  const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
3906
4257
  if (cp) stateParams = { checkpoint: cp };
3907
4258
  }
@@ -3913,10 +4264,17 @@ export class AgentFramework {
3913
4264
  const durationMs = Date.now() - startTime;
3914
4265
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
3915
4266
 
3916
- // Record checkpoint from stateful tool response (Step 8)
4267
+ // Record checkpoint from stateful tool response (Step 8).
4268
+ // Attribute to the set the server tagged (result.state.featureSet) first;
4269
+ // else the host-managed set (host-managed checkpoints carry data/patch);
4270
+ // else the single stateful set. Avoids recording a feed checkpoint onto a
4271
+ // server-managed set just because it registered first.
3917
4272
  if (result.state && this.checkpointManager) {
3918
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3919
- if (fs) {
4273
+ const tagged = result.state.featureSet;
4274
+ const fs = tagged
4275
+ ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
4276
+ ?? this.checkpointManager.getStatefulFeatureSet(serverId);
4277
+ if (fs && this.checkpointManager.isStateful(serverId, fs)) {
3920
4278
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
3921
4279
  }
3922
4280
  }
@@ -4037,6 +4395,91 @@ export class AgentFramework {
4037
4395
  });
4038
4396
  }
4039
4397
 
4398
+ /**
4399
+ * Handle the synthesized `wake_add_rule` / `wake_remove_rule` tools: mutate
4400
+ * the hot-reloaded gate.json at runtime. Validation lives in the EventGate
4401
+ * (same path as gate.json load), so an invalid rule is surfaced as a tool
4402
+ * error and nothing is written.
4403
+ */
4404
+ private dispatchWakeRuleToolCall(agentName: string, call: ToolCall): void {
4405
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4406
+ const finish = (result: ToolResult) => {
4407
+ this.emitTrace({
4408
+ type: result.isError ? 'tool:failed' : 'tool:completed',
4409
+ module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
4410
+ ...(result.isError ? { error: result.error } : {}),
4411
+ });
4412
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
4413
+ };
4414
+
4415
+ try {
4416
+ if (call.name === 'wake_remove_rule') {
4417
+ const input = (call.input ?? {}) as { name?: unknown };
4418
+ if (typeof input.name !== 'string' || !input.name) {
4419
+ finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
4420
+ return;
4421
+ }
4422
+ const removed = this.removeGatePolicy(input.name);
4423
+ finish({
4424
+ success: true,
4425
+ data: { removed, name: input.name, policies: this.getGatePolicyNames() },
4426
+ });
4427
+ return;
4428
+ }
4429
+
4430
+ // wake_add_rule — assemble the canonical behavior from the typed fields
4431
+ // (exactly one), then let the gate's own validator do the authoritative
4432
+ // range/shape checks.
4433
+ const input = (call.input ?? {}) as {
4434
+ name?: unknown; match?: unknown; resets?: unknown; position?: unknown;
4435
+ behavior?: unknown; debounceMs?: unknown; rateLimit?: unknown; passiveSample?: unknown;
4436
+ };
4437
+ const behaviorSources = [
4438
+ input.behavior !== undefined ? 'behavior' : null,
4439
+ input.debounceMs !== undefined ? 'debounceMs' : null,
4440
+ input.rateLimit !== undefined ? 'rateLimit' : null,
4441
+ input.passiveSample !== undefined ? 'passiveSample' : null,
4442
+ ].filter((s): s is string => s !== null);
4443
+ if (behaviorSources.length === 0) {
4444
+ finish({
4445
+ success: false,
4446
+ error: 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
4447
+ isError: true,
4448
+ });
4449
+ return;
4450
+ }
4451
+ if (behaviorSources.length > 1) {
4452
+ finish({
4453
+ success: false,
4454
+ error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
4455
+ isError: true,
4456
+ });
4457
+ return;
4458
+ }
4459
+ const behavior: unknown =
4460
+ input.debounceMs !== undefined ? { debounce: input.debounceMs }
4461
+ : input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
4462
+ : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
4463
+ : input.behavior;
4464
+
4465
+ const position = input.position === 'prepend' ? 'prepend' : 'append';
4466
+ const rawPolicy = {
4467
+ name: input.name,
4468
+ match: input.match ?? {},
4469
+ behavior,
4470
+ ...(input.resets !== undefined ? { resets: input.resets } : {}),
4471
+ };
4472
+ const policy = this.addGatePolicy(rawPolicy, { position });
4473
+ finish({
4474
+ success: true,
4475
+ data: { added: policy.name, policies: this.getGatePolicyNames() },
4476
+ });
4477
+ } catch (err) {
4478
+ const msg = err instanceof Error ? err.message : String(err);
4479
+ finish({ success: false, error: msg, isError: true });
4480
+ }
4481
+ }
4482
+
4040
4483
  /**
4041
4484
  * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
4042
4485
  * suppression window, optionally announces in the sticky channel, and ends
@@ -4084,6 +4527,42 @@ export class AgentFramework {
4084
4527
  finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
4085
4528
  }
4086
4529
 
4530
+ /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
4531
+ * server's declared tag ontology + gate.js status. */
4532
+ private buildEventTagsResult(): Record<string, unknown> {
4533
+ const servers: Record<string, Record<string, unknown>> = {};
4534
+ for (const conn of this.mcplServerRegistry?.getAllServers() ?? []) {
4535
+ const declared = this.featureSetManager?.getDeclaredFeatureSets(conn.id) ?? {};
4536
+ const sets: Record<string, unknown> = {};
4537
+ for (const [name, decl] of Object.entries(declared)) {
4538
+ if (decl.tagOntology) sets[name] = decl.tagOntology;
4539
+ }
4540
+ if (Object.keys(sets).length > 0) servers[conn.id] = sets;
4541
+ }
4542
+ return {
4543
+ core: AgentFramework.CHAT_CORE_TAGS,
4544
+ servers,
4545
+ gateScript: this.eventGate?.getStatus().script ?? null,
4546
+ hint:
4547
+ 'Use these in gate.json policies (match.tagsAny / tagsAll / tagsNone) or ' +
4548
+ 'in gate.js. Unknown/undeclared tags are tolerated (open ontologies).',
4549
+ };
4550
+ }
4551
+
4552
+ private dispatchEventTagsToolCall(agentName: string, call: ToolCall): void {
4553
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4554
+ let result: import('./types/events.js').ToolResult;
4555
+ try {
4556
+ result = { success: true, data: this.buildEventTagsResult() };
4557
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
4558
+ } catch (error) {
4559
+ const err = error instanceof Error ? error : new Error(String(error));
4560
+ this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
4561
+ result = { success: false, error: err.message, isError: true };
4562
+ }
4563
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
4564
+ }
4565
+
4087
4566
  private dispatchGateToolCall(agentName: string, call: ToolCall): void {
4088
4567
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4089
4568
  const startTime = Date.now();
@@ -4117,7 +4596,13 @@ export class AgentFramework {
4117
4596
  const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4118
4597
  return {
4119
4598
  inferenceId,
4120
- conversationId: agent.name, // Simplified; proper conversation tracking TODO
4599
+ // Conversation identity = the agent (a trunk agent IS its own conversation;
4600
+ // forks get their own agent). The turn's channel LOCUS — the "proper
4601
+ // conversation tracking" this once flagged as a TODO — is now tracked
4602
+ // per-agent in `activeTriggerChannels` and surfaced to the agent via
4603
+ // buildChannelContext (channels.defaultOutgoing) below, so the agent is
4604
+ // told the same channel its speech will route to (item-3 redux).
4605
+ conversationId: agent.name,
4121
4606
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
4122
4607
  userMessage: null, // Could extract from trigger context
4123
4608
  model: {
@@ -4126,7 +4611,7 @@ export class AgentFramework {
4126
4611
  contextWindow: 200000,
4127
4612
  capabilities: ['tools'],
4128
4613
  },
4129
- channels: this.channelRegistry?.buildChannelContext(),
4614
+ channels: this.channelRegistry?.buildChannelContext(agent.name),
4130
4615
  };
4131
4616
  }
4132
4617
  }