@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
@@ -15,6 +15,7 @@ import { ChannelRegistry } from './mcpl/channel-registry.js';
15
15
  import { ConversationRouter } from './mcpl/conversation-router.js';
16
16
  import { safeSlice } from './safe-slice.js';
17
17
  import { toolResultDataToHistoryString } from './tool-result-history.js';
18
+ import { splitProseSegments } from './prose-segments.js';
18
19
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
19
20
  import { isToolAllowed } from './mcpl/tool-policy.js';
20
21
  import { EventGate } from './gate/event-gate.js';
@@ -104,6 +105,16 @@ export class AgentFramework {
104
105
  inferencePolicy;
105
106
  errorPolicy;
106
107
  pendingRequests = [];
108
+ /**
109
+ * Per-agent channel that triggered the agent's CURRENT inference turn, if any
110
+ * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
111
+ * route a single-trunk agent's plain-text speech back to the channel it is
112
+ * answering, instead of the process-global most-recent-inbound locus that a
113
+ * concurrent message elsewhere can hijack. Set at turn start (or cleared for a
114
+ * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
115
+ * turn. Never read between turns (a given agent runs one turn at a time).
116
+ */
117
+ activeTriggerChannels = new Map();
107
118
  running = false;
108
119
  loopPromise = null;
109
120
  traceListeners = [];
@@ -127,6 +138,8 @@ export class AgentFramework {
127
138
  // Undo/redo state
128
139
  turnCounters = new Map(); // agentName → next turnIndex
129
140
  redoStacks = new Map(); // agentName → redo entries
141
+ /** Liveness watchdog (fail hard on a wedged main thread). Null unless enabled. */
142
+ livenessWatchdog = null;
130
143
  // MCPL subsystems (null when no mcplServers configured)
131
144
  mcplServerRegistry = null;
132
145
  featureSetManager = null;
@@ -185,6 +198,7 @@ export class AgentFramework {
185
198
  queryMessages: (filter) => this.queryMessages(filter),
186
199
  pushEvent: (event) => this.pushEvent(event),
187
200
  onTrace: (listener) => this.onTrace(listener),
201
+ callTool: (call) => this.executeToolCall(call),
188
202
  });
189
203
  }
190
204
  /**
@@ -301,6 +315,18 @@ export class AgentFramework {
301
315
  getAgentNames: () => [...framework.agents.keys()],
302
316
  });
303
317
  }
318
+ // Liveness watchdog: fail hard if the main thread wedges (opt-in).
319
+ if (config.watchdog?.enabled) {
320
+ const { LivenessWatchdog } = await import('./runtime/liveness-watchdog.js');
321
+ framework.livenessWatchdog = new LivenessWatchdog({
322
+ enabled: true,
323
+ thresholdMs: config.watchdog.thresholdMs,
324
+ action: config.watchdog.action,
325
+ reportPath: config.watchdog.reportPath
326
+ ?? (config.storePath ? join(config.storePath, 'watchdog-wedge.jsonl') : undefined),
327
+ });
328
+ framework.livenessWatchdog.start();
329
+ }
304
330
  // Initialize MCPL subsystems if configured
305
331
  if (config.mcplServers && config.mcplServers.length > 0) {
306
332
  // Validate tool prefixes: no collisions with module names or between servers
@@ -371,6 +397,7 @@ export class AgentFramework {
371
397
  this.channelRegistry?.stopAll();
372
398
  // Dispose EventGate (clear debounce timers)
373
399
  this.eventGate?.dispose();
400
+ this.livenessWatchdog?.stop();
374
401
  // Stop modules and MCPL servers in parallel
375
402
  const shutdownPromises = [this.moduleRegistry.stopAll()];
376
403
  if (this.mcplServerRegistry) {
@@ -487,7 +514,12 @@ export class AgentFramework {
487
514
  const moduleTools = this.moduleRegistry.getAllTools();
488
515
  const channelTools = this.channelRegistry?.getChannelTools() ?? [];
489
516
  const gateTools = this.eventGate
490
- ? [this.eventGate.getToolDefinition(), ...AgentFramework.SLEEP_TOOLS]
517
+ ? [
518
+ this.eventGate.getToolDefinition(),
519
+ ...AgentFramework.SLEEP_TOOLS,
520
+ ...AgentFramework.WAKE_RULE_TOOLS,
521
+ AgentFramework.EVENT_TAGS_TOOL,
522
+ ]
491
523
  : [];
492
524
  if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
493
525
  return moduleTools;
@@ -580,6 +612,38 @@ export class AgentFramework {
580
612
  getModule(name) {
581
613
  return this.moduleRegistry.getModule(name);
582
614
  }
615
+ // =========================================================================
616
+ // Wake-rule surface (gate policy mutation) — backs wake_add_rule /
617
+ // wake_remove_rule and lets host modules compose higher-level wake modes
618
+ // (e.g. an "every-message-debounced" channel mode) without reaching into
619
+ // the private EventGate.
620
+ // =========================================================================
621
+ /**
622
+ * Add or replace (upsert) a gate policy at runtime. Validated and persisted
623
+ * to gate.json; hot-applied in memory. Throws if no gate is configured or the
624
+ * policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
625
+ * defer/debounce rules (first match wins).
626
+ */
627
+ addGatePolicy(rawPolicy, options) {
628
+ if (!this.eventGate) {
629
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
630
+ }
631
+ return this.eventGate.addPolicy(rawPolicy, options);
632
+ }
633
+ /**
634
+ * Remove a gate policy by name at runtime. Returns false if it didn't exist.
635
+ * Throws if no gate is configured.
636
+ */
637
+ removeGatePolicy(name) {
638
+ if (!this.eventGate) {
639
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
640
+ }
641
+ return this.eventGate.removePolicy(name);
642
+ }
643
+ /** Current gate policy names (freshest on-disk view). Empty when no gate. */
644
+ getGatePolicyNames() {
645
+ return this.eventGate?.listPolicyNames() ?? [];
646
+ }
583
647
  /**
584
648
  * Get the Membrane instance.
585
649
  */
@@ -828,6 +892,147 @@ export class AgentFramework {
828
892
  inputSchema: { type: 'object' },
829
893
  },
830
894
  ];
895
+ /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
896
+ * Present when a gate is wired. They write validated policies into the
897
+ * hot-reloaded gate.json (same validation as load), so a rule takes effect
898
+ * immediately and survives restart. */
899
+ static WAKE_RULE_TOOLS = [
900
+ {
901
+ name: 'wake_add_rule',
902
+ description: 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
903
+ 'hand-edit the file. The rule is validated and hot-applied immediately. ' +
904
+ 'A rule with the same `name` replaces the existing one in place. Use ' +
905
+ '`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
906
+ 'rules (first match wins). Two common shapes:\n' +
907
+ '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
908
+ '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
909
+ '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
910
+ 'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, behavior: { debounce: 60000 } }` ' +
911
+ '(the channel must be subscribed for ambient events to arrive — see channel mode).',
912
+ inputSchema: {
913
+ type: 'object',
914
+ properties: {
915
+ name: { type: 'string', description: 'Unique rule name. Reusing a name replaces that rule.' },
916
+ match: {
917
+ type: 'object',
918
+ description: 'Match criteria (all AND together). Omitted fields match anything.',
919
+ properties: {
920
+ scope: { type: 'array', items: { type: 'string' }, description: 'Event types, e.g. ["mcpl:channel-incoming","workspace:modified"].' },
921
+ source: { type: 'string', description: 'Integration/serverId, glob ok (e.g. "discord").' },
922
+ channel: { type: 'string', description: 'Channel id, glob ok (e.g. "discord:*:12345").' },
923
+ mount: { type: 'string', description: 'Workspace mount name, glob ok (workspace:* events).' },
924
+ pathGlob: { type: 'string', description: 'Glob over touched paths (workspace:* events).' },
925
+ tagsAny: { type: 'array', items: { type: 'string' }, description: 'Match if ANY tag matches (globs ok).' },
926
+ tagsAll: { type: 'array', items: { type: 'string' }, description: 'Match only if EVERY tag matches.' },
927
+ tagsNone: { type: 'array', items: { type: 'string' }, description: 'Match only if NONE match.' },
928
+ metadataTrue: { type: 'array', items: { type: 'string' }, description: 'Match if ANY listed metadata field is truthy.' },
929
+ filter: {
930
+ type: 'object',
931
+ description: 'Content filter.',
932
+ properties: {
933
+ type: { type: 'string', enum: ['text', 'regex'] },
934
+ pattern: { type: 'string' },
935
+ },
936
+ required: ['type', 'pattern'],
937
+ },
938
+ },
939
+ },
940
+ behavior: {
941
+ type: 'string',
942
+ enum: ['always', 'defer', 'skip'],
943
+ description: 'Simple behavior: "always" (wake now) or "defer" (don\'t wake; still enters ' +
944
+ 'context). For debounce / rate-limit / sampling, use debounceMs / rateLimit / ' +
945
+ 'passiveSample below instead of this field. Exactly one behavior must be given.',
946
+ },
947
+ debounceMs: {
948
+ type: 'number',
949
+ description: 'Shorthand for { debounce: ms }: wake once after ms of quiet (100–300000).',
950
+ },
951
+ rateLimit: {
952
+ type: 'object',
953
+ description: 'Token-bucket wake: at most `tokens` wakes per window; refills one per refillIntervalMs.',
954
+ properties: {
955
+ tokens: { type: 'number', description: 'Bucket capacity (> 0).' },
956
+ refillIntervalMs: { type: 'number', description: 'Ms between token refills (> 0).' },
957
+ keyBy: { type: 'string', description: 'Metadata field to partition buckets by (e.g. "channelId").' },
958
+ },
959
+ required: ['tokens', 'refillIntervalMs'],
960
+ },
961
+ passiveSample: {
962
+ type: 'object',
963
+ description: 'Wake every Nth matching event.',
964
+ properties: {
965
+ every: { type: 'number', description: 'Fire every N matches (positive integer).' },
966
+ keyBy: { type: 'string', description: 'Metadata field for separate per-key counters.' },
967
+ },
968
+ required: ['every'],
969
+ },
970
+ position: {
971
+ type: 'string',
972
+ enum: ['append', 'prepend'],
973
+ description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
974
+ },
975
+ resets: {
976
+ type: 'array',
977
+ items: { type: 'string' },
978
+ description: 'Names of other rules whose runtime state to clear when this rule fires.',
979
+ },
980
+ },
981
+ required: ['name'],
982
+ },
983
+ },
984
+ {
985
+ name: 'wake_remove_rule',
986
+ description: 'Remove a wake rule (gate.json policy) by name at runtime. Any pending ' +
987
+ 'debounce batch for that rule is delivered first. Returns whether a rule ' +
988
+ 'was removed (false if no rule had that name).',
989
+ inputSchema: {
990
+ type: 'object',
991
+ properties: {
992
+ name: { type: 'string', description: 'The rule name to remove.' },
993
+ },
994
+ required: ['name'],
995
+ },
996
+ },
997
+ ];
998
+ /** Discoverability for event tags (MCPL RFC-001): the reserved chat:* core,
999
+ * each connected server's declared tag ontology, and gate.js status. */
1000
+ static EVENT_TAGS_TOOL = {
1001
+ name: 'event_tags',
1002
+ description: 'List the event tags available for gating: the reserved cross-platform ' +
1003
+ 'chat:* core, each connected server\'s declared tag ontology (descriptions, ' +
1004
+ 'implications, suggested treatments), and the status of your programmable ' +
1005
+ 'gate (gate.js). Use these tag names in gate.json policies (tagsAny / ' +
1006
+ 'tagsAll / tagsNone) or in gate.js.',
1007
+ inputSchema: { type: 'object' },
1008
+ };
1009
+ /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1010
+ * the agent can author rules without reading the spec. */
1011
+ static CHAT_CORE_TAGS = {
1012
+ 'chat:addressed': 'Directed at you (umbrella: dm/mention/reply)',
1013
+ 'chat:mention': 'You were explicitly @-mentioned',
1014
+ 'chat:reply': 'A reply to your own message',
1015
+ 'chat:dm': 'A direct/private message to you',
1016
+ 'chat:ambient': 'Overheard in a followed channel; not addressed',
1017
+ 'chat:broadcast': 'Channel-wide ping (@everyone / channel post)',
1018
+ 'chat:to-self': 'Acts on your own content (reaction/reply to you)',
1019
+ 'chat:from-human': 'Authored by a human',
1020
+ 'chat:from-bot': 'Authored by a bot/automation',
1021
+ 'chat:from-self': 'Your own message, echoed back',
1022
+ 'chat:from-agent': 'Authored by another persona/agent',
1023
+ 'chat:edited': 'An edit of an existing message',
1024
+ 'chat:deleted': 'A deletion',
1025
+ 'chat:reaction': 'An emoji reaction was added',
1026
+ 'chat:reaction-remove': 'A reaction was removed',
1027
+ 'chat:has-image': 'Has an image attachment',
1028
+ 'chat:has-audio': 'Has an audio attachment',
1029
+ 'chat:has-file': 'Has a file attachment',
1030
+ 'chat:has-link': 'Contains a link',
1031
+ 'chat:command': 'A slash/bot command invocation',
1032
+ 'chat:private': 'Private conversation',
1033
+ 'chat:group': 'Group (multi-party) conversation',
1034
+ 'chat:thread': 'Occurred in a thread',
1035
+ };
831
1036
  /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
832
1037
  static REFUSAL_REACTIONS = {
833
1038
  bio: '☣️',
@@ -1722,6 +1927,9 @@ export class AgentFramework {
1722
1927
  reason: 'mcpl:channel-incoming',
1723
1928
  source: event.serverId,
1724
1929
  timestamp: Date.now(),
1930
+ // Route this turn's auto-published speech back to THIS channel, not
1931
+ // the global most-recent-inbound locus (item-3 redux, trunk agents).
1932
+ channelId: event.channelId,
1725
1933
  });
1726
1934
  }
1727
1935
  }
@@ -1800,6 +2008,9 @@ export class AgentFramework {
1800
2008
  reason: 'mcpl:channel-incoming',
1801
2009
  source: event.serverId,
1802
2010
  timestamp: Date.now(),
2011
+ // A fork's home channel wins in routeSpeech regardless, but carry the
2012
+ // triggering channel too so the trunk/active path stays consistent.
2013
+ channelId: event.channelId,
1803
2014
  });
1804
2015
  }
1805
2016
  }
@@ -1961,6 +2172,15 @@ export class AgentFramework {
1961
2172
  };
1962
2173
  const id = this.addMessage('user', event.content, metadata);
1963
2174
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
2175
+ // Some push events carry a channel of origin (Discord DMs arrive here, not
2176
+ // via channels/incoming, because discord-mcpl forwards them with the channel
2177
+ // closed). Register + open that channel so routeSpeech can resolve it, and
2178
+ // thread it onto the wake request so the reply routes back to it instead of
2179
+ // the global locus (item-3 redux, DM sub-case).
2180
+ const triggerChannel = this.derivePushEventChannel(event.origin);
2181
+ if (triggerChannel && this.channelRegistry) {
2182
+ this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
2183
+ }
1964
2184
  if (event.triggerInference) {
1965
2185
  // Default broadcast excludes conversation forks (channel-driven).
1966
2186
  const targetAgents = event.targetAgents
@@ -1971,10 +2191,43 @@ export class AgentFramework {
1971
2191
  reason: 'mcpl:push-event',
1972
2192
  source: event.serverId,
1973
2193
  timestamp: Date.now(),
2194
+ channelId: triggerChannel?.channelId,
1974
2195
  });
1975
2196
  }
1976
2197
  }
1977
2198
  }
2199
+ /**
2200
+ * Derive the MCPL composite channel id (the outbound routing locus) for a
2201
+ * push event from its server-defined `origin`, if it carries one.
2202
+ *
2203
+ * Discord DMs are the motivating case: discord-mcpl forwards them via
2204
+ * push/event (the DM channel is closed), so they never pass through
2205
+ * channels/incoming and their channel is never registered — leaving the
2206
+ * agent's reply with nowhere to route (item-3 redux, DM sub-case). Prefers an
2207
+ * explicit `origin.mcplChannelId` (a surface declaring its own composite id —
2208
+ * the surface-agnostic contract); otherwise reconstructs the Discord form
2209
+ * `discord:{guildId|dm}:{channelId}` from origin fields, matching
2210
+ * discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the
2211
+ * fix works even against a discord-mcpl build that predates `mcplChannelId`.
2212
+ * Returns undefined for push events with no channel provenance (heartbeats,
2213
+ * timers), which correctly keep the global fallback.
2214
+ */
2215
+ derivePushEventChannel(origin) {
2216
+ if (!origin)
2217
+ return undefined;
2218
+ const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
2219
+ const explicit = origin.mcplChannelId;
2220
+ if (typeof explicit === 'string' && explicit) {
2221
+ return { channelId: explicit, label };
2222
+ }
2223
+ // Discord fallback: reconstruct the composite from origin parts. `guildId`
2224
+ // is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
2225
+ if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
2226
+ const guild = typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
2227
+ return { channelId: `discord:${guild}:${origin.channelId}`, label };
2228
+ }
2229
+ return undefined;
2230
+ }
1978
2231
  async processInferenceRequests() {
1979
2232
  if (this.pendingRequests.length === 0) {
1980
2233
  return;
@@ -2028,7 +2281,16 @@ export class AgentFramework {
2028
2281
  }
2029
2282
  // Start streaming inference (non-blocking — driveStream runs in background)
2030
2283
  const trigger = requests[0];
2031
- await this.startAgentStream(agent, trigger);
2284
+ // Route this turn's auto-published speech to the channel that triggered
2285
+ // it (item-3 redux). A batched wake may carry several triggering channels
2286
+ // (messages arrived in >1 channel while the agent was busy/idle) — reply
2287
+ // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
2288
+ // is the message the agent is most likely answering. The common case is
2289
+ // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
2290
+ // channelId, leaving the field undefined → global fallback. `reduce`
2291
+ // keeps the last defined channelId across the (FIFO-ordered) batch.
2292
+ const triggerChannel = requests.reduce((acc, r) => r.channelId ?? acc, undefined);
2293
+ await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
2032
2294
  }
2033
2295
  }
2034
2296
  async startAgentStream(agent, trigger, attempt = 0) {
@@ -2037,6 +2299,18 @@ export class AgentFramework {
2037
2299
  this.recordTurnCheckpoint(agent.name);
2038
2300
  this.redoStacks.delete(agent.name); // new work invalidates redo
2039
2301
  }
2302
+ // Establish this turn's outbound routing locus (item-3 redux). Set it to the
2303
+ // triggering channel for a channel/DM-triggered turn; clear it otherwise so
2304
+ // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
2305
+ // instead falls back to the global default. A given agent runs one turn at a
2306
+ // time, so a set here is only read during THIS turn's routeSpeech /
2307
+ // buildChannelContext; retries re-run with the same trigger, re-setting it.
2308
+ if (trigger?.channelId) {
2309
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
2310
+ }
2311
+ else {
2312
+ this.activeTriggerChannels.delete(agent.name);
2313
+ }
2040
2314
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
2041
2315
  this.eventGate?.onInferenceStarted(agent.name);
2042
2316
  try {
@@ -2401,21 +2675,32 @@ export class AgentFramework {
2401
2675
  .map((b) => b.name)
2402
2676
  .filter((n) => typeof n === 'string');
2403
2677
  const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
2404
- const t = allText
2405
- .map((b) => b.text)
2406
- .join('\n')
2407
- .trim();
2408
- if (silenced || !t) {
2678
+ // Split the turn's content into ordered prose segments, broken at
2679
+ // each tool_use / tool_result boundary. `response.content` holds the
2680
+ // WHOLE turn's blocks in provider order — earlier tool rounds
2681
+ // included (see the trailing-slice note above, which slices exactly
2682
+ // because the full turn is present) — so a left-to-right walk
2683
+ // reconstructs the emission order. Interleaved prose
2684
+ // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
2685
+ // separate messages IN ORDER instead of being collapsed into one
2686
+ // trailing post (item 4). The silencing rule stays turn-wide: an
2687
+ // explicit send / skip suppresses ALL auto-routed prose this turn,
2688
+ // preserving the double-post guard.
2689
+ const segments = splitProseSegments(response.content);
2690
+ if (silenced || segments.length === 0) {
2409
2691
  console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
2410
2692
  `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`);
2411
2693
  }
2412
2694
  else {
2413
- console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing trailing prose (${t.length} chars) as reply`);
2414
- try {
2415
- await this.channelRegistry.routeSpeech(agent.name, t);
2416
- }
2417
- catch (err) {
2418
- console.error('speech routing failed:', err);
2695
+ console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order`);
2696
+ // Deliver sequentially (await each) so the segments land in order.
2697
+ for (const seg of segments) {
2698
+ try {
2699
+ await this.channelRegistry.routeSpeech(agent.name, seg);
2700
+ }
2701
+ catch (err) {
2702
+ console.error('speech routing failed:', err);
2703
+ }
2419
2704
  }
2420
2705
  }
2421
2706
  }
@@ -2731,6 +3016,16 @@ export class AgentFramework {
2731
3016
  this.dispatchSleepToolCall(agentName, enrichedCall);
2732
3017
  return;
2733
3018
  }
3019
+ // Route wake-rule tools (runtime gate.json policy add/remove)
3020
+ if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
3021
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
3022
+ return;
3023
+ }
3024
+ // Route event_tags (tag/ontology discovery)
3025
+ if (enrichedCall.name === 'event_tags' && this.eventGate) {
3026
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
3027
+ return;
3028
+ }
2734
3029
  // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
2735
3030
  // stay-silent) tools — handled by the channel registry like the other
2736
3031
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
@@ -2925,7 +3220,7 @@ export class AgentFramework {
2925
3220
  console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
2926
3221
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
2927
3222
  const agent = this.agents.get(agentName);
2928
- if (agent) {
3223
+ if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
2929
3224
  try {
2930
3225
  agent.getContextManager().addMessage('user', [{
2931
3226
  type: 'text',
@@ -2986,6 +3281,21 @@ export class AgentFramework {
2986
3281
  }
2987
3282
  },
2988
3283
  shouldTriggerInference: triggerFilter,
3284
+ // Route a conversation fork's plain-text speech to its HOME channel, not
3285
+ // the process-global most-recent-inbound locus (item 3). The trunk agent
3286
+ // has no home entry, so this returns undefined and routeSpeech falls back
3287
+ // to defaultPublishChannel. `conversationAgentHomes` is the permanent
3288
+ // spawn-time binding; `channelForAgent` is the router's live binding as a
3289
+ // belt-and-suspenders fallback.
3290
+ homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName)
3291
+ ?? this.conversationRouter?.channelForAgent(agentName),
3292
+ // Route a single TRUNK agent's plain-text speech to the channel that
3293
+ // triggered its CURRENT turn (item-3 redux). connectome-host runs every
3294
+ // agent as a trunk (it never exposes conversation forks), so without this
3295
+ // a reply falls back to the process-global most-recent-inbound locus and
3296
+ // a concurrent message in another channel hijacks it. Empty for
3297
+ // heartbeat / no-trigger turns → correct global fallback.
3298
+ activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
2989
3299
  // A text-only turn whose speech couldn't be delivered must not vanish
2990
3300
  // silently: record a `[discord-send-failed]` marker in chronicle so the
2991
3301
  // agent sees, on her next turn, that her reply never reached the human.
@@ -3262,15 +3572,21 @@ export class AgentFramework {
3262
3572
  this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
3263
3573
  const startTime = Date.now();
3264
3574
  const args = (call.input && typeof call.input === 'object') ? call.input : {};
3265
- // Build state params for stateful tools (Step 8)
3575
+ // Build state params for stateful tools (Step 8).
3576
+ // Host-managed state is single-valued per server, so inject the host-managed
3577
+ // set's `state` for any call; otherwise fall back to a server-managed set's
3578
+ // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
3579
+ // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
3580
+ // that misattributes the feed's state.
3266
3581
  let stateParams;
3267
3582
  if (this.checkpointManager) {
3268
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3269
- if (fs) {
3270
- if (this.checkpointManager.isHostManaged(serverId, fs)) {
3271
- stateParams = { state: this.checkpointManager.getCurrentState(serverId, fs) };
3272
- }
3273
- else {
3583
+ const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
3584
+ if (hostFs) {
3585
+ stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
3586
+ }
3587
+ else {
3588
+ const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3589
+ if (fs) {
3274
3590
  const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
3275
3591
  if (cp)
3276
3592
  stateParams = { checkpoint: cp };
@@ -3281,10 +3597,17 @@ export class AgentFramework {
3281
3597
  .then((result) => {
3282
3598
  const durationMs = Date.now() - startTime;
3283
3599
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
3284
- // Record checkpoint from stateful tool response (Step 8)
3600
+ // Record checkpoint from stateful tool response (Step 8).
3601
+ // Attribute to the set the server tagged (result.state.featureSet) first;
3602
+ // else the host-managed set (host-managed checkpoints carry data/patch);
3603
+ // else the single stateful set. Avoids recording a feed checkpoint onto a
3604
+ // server-managed set just because it registered first.
3285
3605
  if (result.state && this.checkpointManager) {
3286
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
3287
- if (fs) {
3606
+ const tagged = result.state.featureSet;
3607
+ const fs = tagged
3608
+ ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
3609
+ ?? this.checkpointManager.getStatefulFeatureSet(serverId);
3610
+ if (fs && this.checkpointManager.isStateful(serverId, fs)) {
3288
3611
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
3289
3612
  }
3290
3613
  }
@@ -3398,6 +3721,84 @@ export class AgentFramework {
3398
3721
  });
3399
3722
  });
3400
3723
  }
3724
+ /**
3725
+ * Handle the synthesized `wake_add_rule` / `wake_remove_rule` tools: mutate
3726
+ * the hot-reloaded gate.json at runtime. Validation lives in the EventGate
3727
+ * (same path as gate.json load), so an invalid rule is surfaced as a tool
3728
+ * error and nothing is written.
3729
+ */
3730
+ dispatchWakeRuleToolCall(agentName, call) {
3731
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
3732
+ const finish = (result) => {
3733
+ this.emitTrace({
3734
+ type: result.isError ? 'tool:failed' : 'tool:completed',
3735
+ module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
3736
+ ...(result.isError ? { error: result.error } : {}),
3737
+ });
3738
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
3739
+ };
3740
+ try {
3741
+ if (call.name === 'wake_remove_rule') {
3742
+ const input = (call.input ?? {});
3743
+ if (typeof input.name !== 'string' || !input.name) {
3744
+ finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
3745
+ return;
3746
+ }
3747
+ const removed = this.removeGatePolicy(input.name);
3748
+ finish({
3749
+ success: true,
3750
+ data: { removed, name: input.name, policies: this.getGatePolicyNames() },
3751
+ });
3752
+ return;
3753
+ }
3754
+ // wake_add_rule — assemble the canonical behavior from the typed fields
3755
+ // (exactly one), then let the gate's own validator do the authoritative
3756
+ // range/shape checks.
3757
+ const input = (call.input ?? {});
3758
+ const behaviorSources = [
3759
+ input.behavior !== undefined ? 'behavior' : null,
3760
+ input.debounceMs !== undefined ? 'debounceMs' : null,
3761
+ input.rateLimit !== undefined ? 'rateLimit' : null,
3762
+ input.passiveSample !== undefined ? 'passiveSample' : null,
3763
+ ].filter((s) => s !== null);
3764
+ if (behaviorSources.length === 0) {
3765
+ finish({
3766
+ success: false,
3767
+ error: 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
3768
+ isError: true,
3769
+ });
3770
+ return;
3771
+ }
3772
+ if (behaviorSources.length > 1) {
3773
+ finish({
3774
+ success: false,
3775
+ error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
3776
+ isError: true,
3777
+ });
3778
+ return;
3779
+ }
3780
+ const behavior = input.debounceMs !== undefined ? { debounce: input.debounceMs }
3781
+ : input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
3782
+ : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
3783
+ : input.behavior;
3784
+ const position = input.position === 'prepend' ? 'prepend' : 'append';
3785
+ const rawPolicy = {
3786
+ name: input.name,
3787
+ match: input.match ?? {},
3788
+ behavior,
3789
+ ...(input.resets !== undefined ? { resets: input.resets } : {}),
3790
+ };
3791
+ const policy = this.addGatePolicy(rawPolicy, { position });
3792
+ finish({
3793
+ success: true,
3794
+ data: { added: policy.name, policies: this.getGatePolicyNames() },
3795
+ });
3796
+ }
3797
+ catch (err) {
3798
+ const msg = err instanceof Error ? err.message : String(err);
3799
+ finish({ success: false, error: msg, isError: true });
3800
+ }
3801
+ }
3401
3802
  /**
3402
3803
  * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
3403
3804
  * suppression window, optionally announces in the sticky channel, and ends
@@ -3437,6 +3838,42 @@ export class AgentFramework {
3437
3838
  // endTurn: the agent stops here and goes idle for the duration.
3438
3839
  finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
3439
3840
  }
3841
+ /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
3842
+ * server's declared tag ontology + gate.js status. */
3843
+ buildEventTagsResult() {
3844
+ const servers = {};
3845
+ for (const conn of this.mcplServerRegistry?.getAllServers() ?? []) {
3846
+ const declared = this.featureSetManager?.getDeclaredFeatureSets(conn.id) ?? {};
3847
+ const sets = {};
3848
+ for (const [name, decl] of Object.entries(declared)) {
3849
+ if (decl.tagOntology)
3850
+ sets[name] = decl.tagOntology;
3851
+ }
3852
+ if (Object.keys(sets).length > 0)
3853
+ servers[conn.id] = sets;
3854
+ }
3855
+ return {
3856
+ core: AgentFramework.CHAT_CORE_TAGS,
3857
+ servers,
3858
+ gateScript: this.eventGate?.getStatus().script ?? null,
3859
+ hint: 'Use these in gate.json policies (match.tagsAny / tagsAll / tagsNone) or ' +
3860
+ 'in gate.js. Unknown/undeclared tags are tolerated (open ontologies).',
3861
+ };
3862
+ }
3863
+ dispatchEventTagsToolCall(agentName, call) {
3864
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
3865
+ let result;
3866
+ try {
3867
+ result = { success: true, data: this.buildEventTagsResult() };
3868
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
3869
+ }
3870
+ catch (error) {
3871
+ const err = error instanceof Error ? error : new Error(String(error));
3872
+ this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
3873
+ result = { success: false, error: err.message, isError: true };
3874
+ }
3875
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
3876
+ }
3440
3877
  dispatchGateToolCall(agentName, call) {
3441
3878
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
3442
3879
  const startTime = Date.now();
@@ -3468,7 +3905,13 @@ export class AgentFramework {
3468
3905
  const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3469
3906
  return {
3470
3907
  inferenceId,
3471
- conversationId: agent.name, // Simplified; proper conversation tracking TODO
3908
+ // Conversation identity = the agent (a trunk agent IS its own conversation;
3909
+ // forks get their own agent). The turn's channel LOCUS — the "proper
3910
+ // conversation tracking" this once flagged as a TODO — is now tracked
3911
+ // per-agent in `activeTriggerChannels` and surfaced to the agent via
3912
+ // buildChannelContext (channels.defaultOutgoing) below, so the agent is
3913
+ // told the same channel its speech will route to (item-3 redux).
3914
+ conversationId: agent.name,
3472
3915
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
3473
3916
  userMessage: null, // Could extract from trigger context
3474
3917
  model: {
@@ -3477,7 +3920,7 @@ export class AgentFramework {
3477
3920
  contextWindow: 200000,
3478
3921
  capabilities: ['tools'],
3479
3922
  },
3480
- channels: this.channelRegistry?.buildChannelContext(),
3923
+ channels: this.channelRegistry?.buildChannelContext(agent.name),
3481
3924
  };
3482
3925
  }
3483
3926
  }