@animalabs/agent-framework 0.21.1 → 0.22.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 (271) hide show
  1. package/README.md +43 -0
  2. package/dist/src/agent.d.ts +17 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +102 -42
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/py-runner.d.ts +22 -1
  7. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.js +64 -9
  9. package/dist/src/code-execution/py-runner.js.map +1 -1
  10. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  11. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  12. package/dist/src/code-execution/runtime-py.js +215 -38
  13. package/dist/src/code-execution/runtime-py.js.map +1 -1
  14. package/dist/src/framework.d.ts +250 -39
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +1602 -371
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts +21 -0
  19. package/dist/src/gate/event-gate.d.ts.map +1 -1
  20. package/dist/src/gate/event-gate.js +40 -7
  21. package/dist/src/gate/event-gate.js.map +1 -1
  22. package/dist/src/index.d.ts +5 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +26 -3
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  30. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  31. package/dist/src/mcpl/conversation-router.js +17 -0
  32. package/dist/src/mcpl/conversation-router.js.map +1 -1
  33. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  34. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  35. package/dist/src/mcpl/feature-set-manager.js +31 -7
  36. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  37. package/dist/src/mcpl/index.d.ts +2 -0
  38. package/dist/src/mcpl/index.d.ts.map +1 -1
  39. package/dist/src/mcpl/index.js +1 -0
  40. package/dist/src/mcpl/index.js.map +1 -1
  41. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  42. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  43. package/dist/src/mcpl/prose-stream-router.js +85 -22
  44. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  45. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  46. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  47. package/dist/src/mcpl/push-coalescer.js +46 -13
  48. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  49. package/dist/src/mcpl/push-handler.d.ts +5 -3
  50. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-handler.js +41 -11
  52. package/dist/src/mcpl/push-handler.js.map +1 -1
  53. package/dist/src/mcpl/server-connection.d.ts +4 -0
  54. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  55. package/dist/src/mcpl/server-connection.js +45 -8
  56. package/dist/src/mcpl/server-connection.js.map +1 -1
  57. package/dist/src/mcpl/server-registry.d.ts +5 -2
  58. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-registry.js +25 -7
  60. package/dist/src/mcpl/server-registry.js.map +1 -1
  61. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  62. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  63. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  64. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  65. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  66. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  67. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  68. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  69. package/dist/src/mcpl/types.d.ts +15 -5
  70. package/dist/src/mcpl/types.d.ts.map +1 -1
  71. package/dist/src/mcpl/types.js.map +1 -1
  72. package/dist/src/mcpl/visible-content.d.ts +39 -0
  73. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  74. package/dist/src/mcpl/visible-content.js +54 -0
  75. package/dist/src/mcpl/visible-content.js.map +1 -0
  76. package/dist/src/module-registry.d.ts +81 -3
  77. package/dist/src/module-registry.d.ts.map +1 -1
  78. package/dist/src/module-registry.js +257 -16
  79. package/dist/src/module-registry.js.map +1 -1
  80. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  81. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  82. package/dist/src/modules/workspace/branch-intent.js +83 -0
  83. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  84. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  85. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  86. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  87. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  88. package/dist/src/modules/workspace/effects.d.ts +84 -0
  89. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  90. package/dist/src/modules/workspace/effects.js +368 -0
  91. package/dist/src/modules/workspace/effects.js.map +1 -0
  92. package/dist/src/modules/workspace/index.d.ts +155 -17
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +1059 -424
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/observe.d.ts +278 -0
  97. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  98. package/dist/src/modules/workspace/observe.js +805 -0
  99. package/dist/src/modules/workspace/observe.js.map +1 -0
  100. package/dist/src/modules/workspace/reconcile.d.ts +224 -0
  101. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  102. package/dist/src/modules/workspace/reconcile.js +901 -0
  103. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  104. package/dist/src/modules/workspace/sync.d.ts +6 -59
  105. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/sync.js +6 -257
  107. package/dist/src/modules/workspace/sync.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +77 -10
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/record-journal.d.ts +127 -0
  112. package/dist/src/record-journal.d.ts.map +1 -0
  113. package/dist/src/record-journal.js +202 -0
  114. package/dist/src/record-journal.js.map +1 -0
  115. package/dist/src/shutdown.d.ts +13 -0
  116. package/dist/src/shutdown.d.ts.map +1 -0
  117. package/dist/src/shutdown.js +46 -0
  118. package/dist/src/shutdown.js.map +1 -0
  119. package/dist/src/silent-heartbeat.d.ts +74 -0
  120. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  121. package/dist/src/silent-heartbeat.js +77 -0
  122. package/dist/src/silent-heartbeat.js.map +1 -0
  123. package/dist/src/tool-presentation.d.ts +50 -0
  124. package/dist/src/tool-presentation.d.ts.map +1 -0
  125. package/dist/src/tool-presentation.js +235 -0
  126. package/dist/src/tool-presentation.js.map +1 -0
  127. package/dist/src/tool-result-guard.d.ts +125 -6
  128. package/dist/src/tool-result-guard.d.ts.map +1 -1
  129. package/dist/src/tool-result-guard.js +310 -35
  130. package/dist/src/tool-result-guard.js.map +1 -1
  131. package/dist/src/tool-result-history.d.ts +13 -0
  132. package/dist/src/tool-result-history.d.ts.map +1 -1
  133. package/dist/src/tool-result-history.js +21 -1
  134. package/dist/src/tool-result-history.js.map +1 -1
  135. package/dist/src/types/agent.d.ts +18 -2
  136. package/dist/src/types/agent.d.ts.map +1 -1
  137. package/dist/src/types/events.d.ts +4 -0
  138. package/dist/src/types/events.d.ts.map +1 -1
  139. package/dist/src/types/framework.d.ts +12 -0
  140. package/dist/src/types/framework.d.ts.map +1 -1
  141. package/dist/src/types/module.d.ts +14 -0
  142. package/dist/src/types/module.d.ts.map +1 -1
  143. package/dist/src/types/trace.d.ts +73 -0
  144. package/dist/src/types/trace.d.ts.map +1 -1
  145. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  146. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  147. package/dist/test/batched-wake-locus.test.js +166 -0
  148. package/dist/test/batched-wake-locus.test.js.map +1 -0
  149. package/dist/test/code-execution.test.js +386 -2
  150. package/dist/test/code-execution.test.js.map +1 -1
  151. package/dist/test/component-defaults.test.d.ts +2 -0
  152. package/dist/test/component-defaults.test.d.ts.map +1 -0
  153. package/dist/test/component-defaults.test.js +54 -0
  154. package/dist/test/component-defaults.test.js.map +1 -0
  155. package/dist/test/conversation-routing.test.js +57 -1
  156. package/dist/test/conversation-routing.test.js.map +1 -1
  157. package/dist/test/ephemeral-settle.test.js +3 -0
  158. package/dist/test/ephemeral-settle.test.js.map +1 -1
  159. package/dist/test/event-gate.test.js +118 -0
  160. package/dist/test/event-gate.test.js.map +1 -1
  161. package/dist/test/explicit-prose-routing.test.js +977 -0
  162. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  163. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  164. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  165. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  166. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  167. package/dist/test/mcpl-coalescing.test.js +19 -6
  168. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  169. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  170. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  171. package/dist/test/mcpl-config-diagnostics.test.js +449 -0
  172. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  173. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  174. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  175. package/dist/test/mcpl-empty-content.test.js +368 -0
  176. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  177. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  178. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  179. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  180. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  181. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  182. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  183. package/dist/test/prose-stream-router.test.js +41 -0
  184. package/dist/test/prose-stream-router.test.js.map +1 -1
  185. package/dist/test/record-journal.test.d.ts +6 -0
  186. package/dist/test/record-journal.test.d.ts.map +1 -0
  187. package/dist/test/record-journal.test.js +269 -0
  188. package/dist/test/record-journal.test.js.map +1 -0
  189. package/dist/test/save-recent-image.test.js +102 -0
  190. package/dist/test/save-recent-image.test.js.map +1 -1
  191. package/dist/test/shutdown-errors.test.d.ts +2 -0
  192. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  193. package/dist/test/shutdown-errors.test.js +662 -0
  194. package/dist/test/shutdown-errors.test.js.map +1 -0
  195. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  196. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  197. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  198. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  199. package/dist/test/silent-heartbeat.test.js +49 -38
  200. package/dist/test/silent-heartbeat.test.js.map +1 -1
  201. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  202. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  203. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  204. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  205. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  206. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  207. package/dist/test/tool-batch-hook.test.js +275 -0
  208. package/dist/test/tool-batch-hook.test.js.map +1 -0
  209. package/dist/test/tool-presentation.test.d.ts +2 -0
  210. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  211. package/dist/test/tool-presentation.test.js +268 -0
  212. package/dist/test/tool-presentation.test.js.map +1 -0
  213. package/dist/test/tool-result-guard.test.js +2330 -18
  214. package/dist/test/tool-result-guard.test.js.map +1 -1
  215. package/dist/test/tool-result-history.test.js +2 -1
  216. package/dist/test/tool-result-history.test.js.map +1 -1
  217. package/dist/test/turn-trigger-provenance.test.js +35 -0
  218. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  219. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  220. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  221. package/dist/test/unconvertible-tool-result.test.js +358 -0
  222. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  223. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  224. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  225. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  226. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  227. package/dist/test/workspace-three-way.test.d.ts +12 -0
  228. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  229. package/dist/test/workspace-three-way.test.js +3079 -0
  230. package/dist/test/workspace-three-way.test.js.map +1 -0
  231. package/dist/tsconfig.tsbuildinfo +1 -1
  232. package/package.json +2 -2
  233. package/src/agent.ts +120 -44
  234. package/src/code-execution/py-runner.ts +66 -9
  235. package/src/code-execution/runtime-py.ts +215 -38
  236. package/src/framework.ts +1661 -363
  237. package/src/gate/event-gate.ts +59 -7
  238. package/src/index.ts +11 -0
  239. package/src/mcpl/channel-registry.ts +24 -3
  240. package/src/mcpl/conversation-router.ts +34 -1
  241. package/src/mcpl/feature-set-manager.ts +36 -11
  242. package/src/mcpl/index.ts +2 -0
  243. package/src/mcpl/prose-stream-router.ts +79 -23
  244. package/src/mcpl/push-coalescer.ts +62 -13
  245. package/src/mcpl/push-handler.ts +41 -12
  246. package/src/mcpl/server-connection.ts +41 -10
  247. package/src/mcpl/server-registry.ts +24 -7
  248. package/src/mcpl/tool-lifecycle.ts +77 -2
  249. package/src/mcpl/tool-pattern-check.ts +106 -0
  250. package/src/mcpl/types.ts +15 -5
  251. package/src/mcpl/visible-content.ts +57 -0
  252. package/src/module-registry.ts +252 -16
  253. package/src/modules/workspace/branch-intent.ts +123 -0
  254. package/src/modules/workspace/disk-agreement.ts +325 -0
  255. package/src/modules/workspace/effects.ts +362 -0
  256. package/src/modules/workspace/index.ts +1058 -446
  257. package/src/modules/workspace/observe.ts +839 -0
  258. package/src/modules/workspace/reconcile.ts +1136 -0
  259. package/src/modules/workspace/sync.ts +6 -336
  260. package/src/modules/workspace/types.ts +75 -10
  261. package/src/record-journal.ts +246 -0
  262. package/src/shutdown.ts +49 -0
  263. package/src/silent-heartbeat.ts +141 -0
  264. package/src/tool-presentation.ts +188 -0
  265. package/src/tool-result-guard.ts +335 -33
  266. package/src/tool-result-history.ts +25 -1
  267. package/src/types/agent.ts +18 -2
  268. package/src/types/events.ts +4 -0
  269. package/src/types/framework.ts +12 -0
  270. package/src/types/module.ts +15 -0
  271. package/src/types/trace.ts +80 -0
@@ -88,6 +88,11 @@ interface PendingEvent {
88
88
  * newer ambient chatter when a batched wake picks its provenance — the same
89
89
  * rule the framework applies to the turn's speech locus. */
90
90
  addressed: boolean;
91
+ /** The REGISTERED (composite) channel id the event arrived on, resolved by
92
+ * the host at enqueue time. Differs from `channelId` for push events,
93
+ * whose `channelId` is the adapter's raw id (a bare Discord snowflake for a
94
+ * DM) — unroutable. Absent when the host could not resolve one. */
95
+ routeChannelId?: string;
91
96
  }
92
97
 
93
98
  /**
@@ -106,6 +111,15 @@ export interface WakeProvenance {
106
111
  counterparty?: string;
107
112
  /** True when the chosen event was `chat:addressed`. */
108
113
  addressed?: boolean;
114
+ /** Speech locus for the wake: the routable channel of the chosen event,
115
+ * set ONLY when that event addressed the agent (mention / reply / DM) and
116
+ * the host resolved its registered channel. A batched wake used to carry
117
+ * no locus at all, so its reply fell back to the process-global
118
+ * most-recent-inbound channel — which any ambient message elsewhere,
119
+ * arriving a second after the DM that caused the wake, could retarget
120
+ * (2026-09-30: a DM reply published into a guild channel). Ambient-only
121
+ * batches still set nothing and keep the legacy fallback. */
122
+ routeChannelId?: string;
109
123
  /** Timestamp (ms) of the chosen event, so a consumer coalescing several
110
124
  * requests can order by event recency, not by flush order. */
111
125
  at?: number;
@@ -190,7 +204,14 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
190
204
  let newest: PendingEvent | undefined;
191
205
  let newestAddressed: PendingEvent | undefined;
192
206
  for (const e of events) {
193
- if (!e.channelId && !e.authorId) continue;
207
+ // A resolved route counts as naming a channel only for an ADDRESSED
208
+ // event: a push whose origin declares only `mcplChannelId` (no raw
209
+ // channel id, no author) is still a routable addressed message, and
210
+ // skipping it would leave its wake on the most-recent-inbound fallback.
211
+ // An ambient one stays out: the route is never reported for it, so as
212
+ // the pick it would carry no telemetry at all and hide an earlier event
213
+ // that names its channel and author.
214
+ if (!e.channelId && !e.authorId && !(e.addressed && e.routeChannelId)) continue;
194
215
  if (!newest || e.timestamp >= newest.timestamp) newest = e;
195
216
  if (e.addressed && (!newestAddressed || e.timestamp >= newestAddressed.timestamp)) newestAddressed = e;
196
217
  }
@@ -204,10 +225,20 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
204
225
  ...(channelId ? { channelId } : {}),
205
226
  ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
206
227
  ...(pick.addressed ? { addressed: true } : {}),
228
+ ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}),
207
229
  at: pick.timestamp,
208
230
  };
209
231
  }
210
232
 
233
+ /**
234
+ * Default route-channel resolution when the host supplies none: a
235
+ * channel-incoming event's channel id is already the registered composite id;
236
+ * a push event's is the adapter's raw id, which only the host can map.
237
+ */
238
+ function defaultRouteChannel(info: GateEventInfo): string | undefined {
239
+ return info.eventType === 'mcpl:channel-incoming' && info.channelId ? info.channelId : undefined;
240
+ }
241
+
211
242
  /** Compact, log-friendly serialization of a GateBehavior. */
212
243
  function formatBehavior(b: import('./types.js').GateBehavior): string {
213
244
  if (typeof b === 'string') return b;
@@ -587,6 +618,7 @@ export class EventGate {
587
618
  ) => unknown;
588
619
  private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance) => void;
589
620
  private getAgentNamesFn: () => string[];
621
+ private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined;
590
622
  /** Clock injection — keeps the new rate_limit / passive_sample paths
591
623
  * testable without monkey-patching Date.now globally. */
592
624
  private now: () => number;
@@ -608,6 +640,10 @@ export class EventGate {
608
640
  now?: () => number;
609
641
  /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
610
642
  scriptTimeoutMs?: number;
643
+ /** Map an event to the registered channel it arrived on (the host knows
644
+ * how push-event origins compose into channel ids). Default: a
645
+ * channel-incoming event's own channel id; nothing for push events. */
646
+ resolveRouteChannel?: (info: GateEventInfo) => string | undefined;
611
647
  }) {
612
648
  this.configPath = opts.configPath;
613
649
  this.privilegedUsersPath = opts.privilegedUsersPath;
@@ -615,6 +651,7 @@ export class EventGate {
615
651
  this.addMessageFn = opts.addMessage;
616
652
  this.requestInferenceFn = opts.requestInference;
617
653
  this.getAgentNamesFn = opts.getAgentNames;
654
+ this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
618
655
  this.now = opts.now ?? (() => Date.now());
619
656
  this.loadPrivileged();
620
657
 
@@ -1432,6 +1469,15 @@ export class EventGate {
1432
1469
  // Debounce
1433
1470
  // =========================================================================
1434
1471
 
1472
+ /** Registered channel for an event; a resolver fault never breaks gating. */
1473
+ private routeChannelFor(info: GateEventInfo): string | undefined {
1474
+ try {
1475
+ return this.resolveRouteChannelFn(info) || undefined;
1476
+ } catch {
1477
+ return undefined;
1478
+ }
1479
+ }
1480
+
1435
1481
  private handleDebounce(policy: GatePolicy, info: GateEventInfo): void {
1436
1482
  const debounceMs = (policy.behavior as { debounce: number }).debounce;
1437
1483
 
@@ -1451,6 +1497,7 @@ export class EventGate {
1451
1497
  authorId: this.extractAuthorId(info.metadata) ?? undefined,
1452
1498
  serverId: info.serverId || undefined,
1453
1499
  addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
1500
+ routeChannelId: this.routeChannelFor(info),
1454
1501
  };
1455
1502
 
1456
1503
  const existing = this.debounceTimers.get(policy.name);
@@ -1514,7 +1561,10 @@ export class EventGate {
1514
1561
  if (referenced.length > 0) {
1515
1562
  const byChannel = new Map<string, { count: number; oldest: number; label?: string }>();
1516
1563
  for (const e of referenced) {
1517
- const key = e.channelId ?? '(unknown channel)';
1564
+ // The registered id when known, so this line names the channel the
1565
+ // same way routing notices and send tools do (a push event's own
1566
+ // channelId is the adapter's raw id).
1567
+ const key = e.routeChannelId ?? e.channelId ?? '(unknown channel)';
1518
1568
  const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp, label: e.channelLabel };
1519
1569
  entry.count += 1;
1520
1570
  entry.oldest = Math.min(entry.oldest, e.timestamp);
@@ -1541,11 +1591,13 @@ export class EventGate {
1541
1591
  policies: policyNames,
1542
1592
  });
1543
1593
 
1544
- // Provenance of the batched wake (telemetry: who/where woke the agent):
1545
- // the newest ADDRESSED event first, else the newest event naming a
1546
- // channel or an author; channel + author + addressed from that ONE
1547
- // event. It does not set the turn's speech locus — the framework keeps
1548
- // its own routing rule for that.
1594
+ // Provenance of the batched wake (who/where woke the agent): the newest
1595
+ // ADDRESSED event first, else the newest event naming a channel or an
1596
+ // author; channel + author + addressed from that ONE event. When that
1597
+ // event addressed the agent and its registered channel is known,
1598
+ // `routeChannelId` names the turn's speech locus too — the reply goes
1599
+ // back to whoever addressed the agent, not to whichever channel last saw
1600
+ // any traffic. Ambient-only batches carry telemetry only.
1549
1601
  const provenance = wakeProvenance(events);
1550
1602
  for (const agentName of this.getAgentNamesFn()) {
1551
1603
  this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance);
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export { Agent } from './agent.js';
8
8
  export type { StartStreamResult } from './agent.js';
9
9
  export { ProcessQueueImpl } from './queue.js';
10
10
  export { ModuleRegistry } from './module-registry.js';
11
+ export { ShutdownTimeoutError, DEFAULT_SHUTDOWN_TIMEOUT_MS } from './shutdown.js';
11
12
  export { formatZonedDateTime, formatZonedTime, isValidTimeZone, resolveTimeZone } from './timezone.js';
12
13
  export { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
13
14
 
@@ -107,7 +108,9 @@ export type {
107
108
  } from './mcpl/index.js';
108
109
 
109
110
  // Per-channel conversation routing
111
+ /** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */
110
112
  export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js';
113
+ /** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */
111
114
  export type {
112
115
  ConversationRouterConfig,
113
116
  ConversationBinding,
@@ -146,3 +149,11 @@ export type {
146
149
  OfflineRecoveryBranchOptions,
147
150
  OfflineRecoveryBranchResult,
148
151
  } from './recovery/offline-branch.js';
152
+
153
+ export * from "./tool-presentation.js";
154
+ export {
155
+ SILENT_HEARTBEAT_SEPARATOR,
156
+ silentHeartbeatOf,
157
+ type SilentHeartbeatStamp,
158
+ type SilentHeartbeatTick,
159
+ } from './silent-heartbeat.js';
@@ -37,7 +37,8 @@ import type { McplServerRegistry } from './server-registry.js';
37
37
  import type { FeatureSetManager } from './feature-set-manager.js';
38
38
  import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
39
39
  import { expandCoreTags } from './tags.js';
40
- import { validateCoalescedContent } from './push-coalescer.js';
40
+ import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
41
+ import { isVisiblyEmptyContent } from './visible-content.js';
41
42
  import { CapabilityGrant } from './capability-grant.js';
42
43
 
43
44
  // ============================================================================
@@ -915,18 +916,38 @@ export class ChannelRegistry {
915
916
  // ACCEPTED from here down: semantic processing only for admitted
916
917
  // messages. §16.3 core-tag closure, then content conversion.
917
918
  const coalesced = message.coalesce !== undefined && !!this.handleCoalescedIncoming;
919
+ // Only a coalescing retraction may be empty (pure withdrawal, RFC-006
920
+ // §6; the coalescer appends nothing for it). Any other message that
921
+ // shows the model nothing would wake it with no visible cause.
922
+ const emptyAllowed = coalesced && (message.coalesce as { retract?: unknown } | null)?.retract === true;
923
+ const rejectEmpty = () => {
924
+ console.error(`[channel-incoming-rejected] server=${serverId} channel=${message.channelId} messageId=${message.messageId} reason=empty-content`);
925
+ this.emitTraceFn({
926
+ type: 'mcpl:channel-incoming-rejected',
927
+ serverId,
928
+ channelId: message.channelId,
929
+ messageId: message.messageId,
930
+ reason: 'empty-content',
931
+ });
932
+ results.push({ messageId: message.messageId, accepted: false, reason: 'empty_content' });
933
+ };
918
934
  if (coalesced) {
919
935
  // RFC-006 §13: malformed content on a coalesced item is that item's
920
936
  // failure, not the batch's — check the shape before converting.
921
937
  try {
922
- validateCoalescedContent(message.content);
938
+ validateCoalescedContent(message.content, undefined, { allowEmpty: emptyAllowed });
923
939
  } catch (error) {
924
- results.push({ messageId: message.messageId, accepted: false, reason: 'coalesce_invalid' });
940
+ if (error instanceof EmptyContentError) rejectEmpty();
941
+ else results.push({ messageId: message.messageId, accepted: false, reason: 'coalesce_invalid' });
925
942
  continue;
926
943
  }
927
944
  }
928
945
  if (message.tags) message.tags = expandCoreTags(message.tags);
929
946
  const convertedContent: ContentBlock[] = message.content.map(convertBlock);
947
+ if (!emptyAllowed && isVisiblyEmptyContent(convertedContent)) {
948
+ rejectEmpty();
949
+ continue;
950
+ }
930
951
 
931
952
  // Track default publish channel (most recent ACCEPTED incoming) —
932
953
  // deliberately after §14.5 validation: a rejected message from an
@@ -27,6 +27,11 @@
27
27
  * a restarted host must NOT reuse generation 1's agent name, or it would
28
28
  * reopen the previous engagement's Chronicle namespace and re-seed the
29
29
  * template context on top of the old history.
30
+ *
31
+ * DEPRECATED (anima-research/agent-framework#235): per-channel conversation
32
+ * routing is being retired. Behavior is unchanged for now; the framework
33
+ * logs one `[deprecated]` line when it is configured. Removal is a
34
+ * follow-up.
30
35
  */
31
36
 
32
37
  import type { ContextStrategy } from '@animalabs/context-manager';
@@ -49,6 +54,20 @@ export type TriggerRule = 'always' | 'mention';
49
54
  * inferring on every message of a four-human group DM is a firehose). */
50
55
  export type ChannelKind = 'dm' | 'groupDm' | 'channel';
51
56
 
57
+ /** Logged once by the framework when `FrameworkConfig.conversations` is set. */
58
+ export const CONVERSATION_ROUTING_DEPRECATION_NOTICE =
59
+ 'Per-channel conversation routing (FrameworkConfig.conversations) is deprecated and will ' +
60
+ 'be removed in a future release. Its \'mention\' bind/trigger rule reads ' +
61
+ 'metadata.mentioned, which not every channel server sets (discord-mcpl does not), so on ' +
62
+ 'such channels an @-mention neither binds a fork nor triggers a bound one. Routing still ' +
63
+ 'works for now. See anima-research/agent-framework#235.';
64
+
65
+ /**
66
+ * @deprecated Per-channel conversation routing is deprecated and will be
67
+ * removed (anima-research/agent-framework#235). Its 'mention' bind/trigger
68
+ * rule reads `metadata.mentioned`, which not every channel server sets.
69
+ * Behavior is unchanged for now.
70
+ */
52
71
  export interface ConversationRouterConfig {
53
72
  /** Agent whose context seeds new forks (the "trunk"/warm checkpoint). */
54
73
  templateAgent: string;
@@ -61,7 +80,15 @@ export interface ConversationRouterConfig {
61
80
  * Defaults: dm 'always', groupDm 'mention', channel 'mention'. */
62
81
  trigger?: { dm?: TriggerRule; groupDm?: TriggerRule; channel?: TriggerRule };
63
82
 
64
- /** Idle time before a binding expires and the fork is closed. Default 12h. */
83
+ /**
84
+ * Idle time before a binding expires and the fork is closed. Default 12h.
85
+ *
86
+ * Expiry is checked at most about once a minute, so the sweep usually
87
+ * notices an expired binding up to ~60s after the TTL elapses. That is the
88
+ * usual extra delay, not a deadline for closing the fork: the closure turn
89
+ * can be delayed further (e.g. while the host is quiesced, it stays queued
90
+ * until resume).
91
+ */
65
92
  idleTtlMs?: number;
66
93
 
67
94
  /** Final system-initiated user message sent to a fork on expiry. */
@@ -116,6 +143,12 @@ export const DEFAULT_CLOSURE_PROMPT =
116
143
  // ConversationRouter
117
144
  // ============================================================================
118
145
 
146
+ /**
147
+ * @deprecated Per-channel conversation routing is deprecated and will be
148
+ * removed (anima-research/agent-framework#235). Behavior is unchanged for
149
+ * now; the framework logs one `[deprecated]` line when it constructs a
150
+ * router from `FrameworkConfig.conversations`.
151
+ */
119
152
  export class ConversationRouter {
120
153
  private config: ConversationRouterConfig;
121
154
 
@@ -128,6 +128,16 @@ export class FeatureSetManager {
128
128
  /** Per-server state, keyed by server ID. */
129
129
  private servers = new Map<string, ServerState>();
130
130
 
131
+ /**
132
+ * @param emitTrace Receives an `mcpl:feature-set-disabled` trace beside
133
+ * each §6.4 disablement's console line, so hosts that keep per-server
134
+ * logs from the trace bus see a set that config enabled but derivation
135
+ * turned off. Omit for scratch derivations (nothing is installed).
136
+ */
137
+ constructor(
138
+ private readonly emitTrace?: (event: { type: string; [key: string]: unknown }) => void,
139
+ ) {}
140
+
131
141
  /**
132
142
  * Initialize a server's feature set state from its capabilities and host config.
133
143
  *
@@ -201,28 +211,43 @@ export class FeatureSetManager {
201
211
  // resolution so it can only remove: nothing a declaration or a config
202
212
  // says can supply a capability the grant lacks.
203
213
  if (grant) {
214
+ // Named only when the operator listed it: with enabledFeatureSets
215
+ // omitted every declared set is selected, which is no surprise.
216
+ const explicitSelection = config?.enabledFeatureSets !== undefined;
217
+ const disable = (
218
+ name: string,
219
+ reason: 'invalid_uses' | 'missing_capabilities',
220
+ detail: string,
221
+ extra: { unrecognized?: string[]; missing?: string[] },
222
+ ): void => {
223
+ const selectedByConfig = state.enabled.delete(name);
224
+ const note = explicitSelection && selectedByConfig ? ' — overrides enabledFeatureSets' : '';
225
+ console.error(`[mcpl] ${serverId}/${name} disabled: ${detail}${note}`);
226
+ this.emitTrace?.({
227
+ type: 'mcpl:feature-set-disabled',
228
+ serverId,
229
+ featureSet: name,
230
+ reason,
231
+ selectedByConfig,
232
+ ...extra,
233
+ });
234
+ };
204
235
  for (const [name, decl] of Object.entries(declared)) {
205
236
  const uses = (decl as { uses?: unknown }).uses;
206
237
  if (!Array.isArray(uses) || uses.length === 0) {
207
- if (state.enabled.delete(name) || true) {
208
- console.error(`[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — uses absent or empty)`);
209
- }
238
+ disable(name, 'invalid_uses', 'invalid_uses (§6.4 — uses absent or empty)', { unrecognized: [] });
210
239
  continue;
211
240
  }
212
241
  const unknown = uses.filter((u) => typeof u !== 'string' || !isKnownCapabilityPath(u));
213
242
  if (unknown.length > 0) {
214
- state.enabled.delete(name);
215
- console.error(
216
- `[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — unrecognized: ${unknown.join(', ')})`,
217
- );
243
+ disable(name, 'invalid_uses', `invalid_uses (§6.4 — unrecognized: ${unknown.join(', ')})`, {
244
+ unrecognized: unknown.map((u) => (typeof u === 'string' ? u : JSON.stringify(u))),
245
+ });
218
246
  continue;
219
247
  }
220
248
  const missing = (uses as string[]).filter((u) => !grant.has(u));
221
249
  if (missing.length > 0) {
222
- state.enabled.delete(name);
223
- console.error(
224
- `[mcpl] ${serverId}/${name} disabled: missing capabilities ${missing.join(', ')} (§6.4)`,
225
- );
250
+ disable(name, 'missing_capabilities', `missing capabilities ${missing.join(', ')} (§6.4)`, { missing });
226
251
  }
227
252
  }
228
253
  }
package/src/mcpl/index.ts CHANGED
@@ -158,7 +158,9 @@ export { InferenceRouter } from './inference-router.js';
158
158
  export { ChannelRegistry } from './channel-registry.js';
159
159
 
160
160
  // Per-channel conversation routing (fork-per-channel agents)
161
+ /** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */
161
162
  export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './conversation-router.js';
163
+ /** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */
162
164
  export type {
163
165
  ConversationRouterConfig,
164
166
  ConversationBinding,
@@ -19,8 +19,13 @@
19
19
  * emits NOTHING — the delivery path will bounce/retain it; a stream consumer
20
20
  * must never see text that delivery would refuse to send.
21
21
  *
22
- * Two modes, mirroring proseRouting:
23
- * - explicit: parse `>>` prefixes; initial target null (suppressed).
22
+ * Three modes, mirroring proseRouting:
23
+ * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed;
24
+ * one that resumes a turn starts at the turn's destination, and
25
+ * each round's end returns the stream to it (retarget).
26
+ * - hybrid: parse `>>>` envelopes, with the frozen turn locus as the
27
+ * fallback. An envelope with no target, or `>>>skip_reply`,
28
+ * holds back what follows until a later `>>>` names a target.
24
29
  * - locus: NO prefix parsing (locus mode's `>>` lines are plain text);
25
30
  * everything streams to the frozen turn locus.
26
31
  */
@@ -30,7 +35,10 @@ export interface RoutedDelta {
30
35
  delta: string;
31
36
  }
32
37
 
33
- type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body';
38
+ type State = 'line-start' | 'indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body';
39
+
40
+ /** Whitespace as the delivery path reads it (`trimStart()`, `\s`, `\S`). */
41
+ const isSpace = (ch: string): boolean => /\s/.test(ch);
34
42
 
35
43
  export class ProseStreamRouter {
36
44
  private target: string | null;
@@ -44,7 +52,9 @@ export class ProseStreamRouter {
44
52
  private opts: {
45
53
  /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */
46
54
  mode: 'explicit' | 'hybrid' | 'locus';
47
- /** Initial destination: the frozen turn locus (locus mode) or null. */
55
+ /** Initial destination: the frozen turn locus (locus and hybrid mode); in
56
+ * explicit mode, null on a fresh turn, or the turn's destination on a
57
+ * stream that resumes one. */
48
58
  initialTarget: string | null;
49
59
  /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */
50
60
  resolve: (spec: string) => string | null;
@@ -59,6 +69,17 @@ export class ProseStreamRouter {
59
69
  return this.accumulated;
60
70
  }
61
71
 
72
+ /**
73
+ * Point the stream at a destination decided outside it. Explicit mode does
74
+ * this at each round's end, with the turn's destination: a private round's
75
+ * prefixes, a `>>skip_reply` envelope and a target that didn't resolve all
76
+ * leave that destination as it was, though the stream followed them while
77
+ * the round was written.
78
+ */
79
+ retarget(target: string | null): void {
80
+ this.target = target;
81
+ }
82
+
62
83
  /**
63
84
  * Drop everything routed so far and return to the constructed state.
64
85
  *
@@ -82,23 +103,38 @@ export class ProseStreamRouter {
82
103
  return coalesce(out);
83
104
  }
84
105
 
85
- /** End of stream: flush any held line-start text (it can no longer become a
86
- * complete prefix; an unterminated `>>tok` at EOF is routing metadata with
87
- * no body — dropped, matching delivery which would deliver empty body). */
88
- finish(): RoutedDelta[] {
106
+ /**
107
+ * The end of a segment: one round's text, which delivery parses on its
108
+ * own. The pending line's decision is made now. Held line-start text can
109
+ * no longer become a prefix, so it is body. A prefix whose target is
110
+ * written but has no body takes effect, as delivery reads `>>#chan` with
111
+ * nothing after it. The next segment starts a new line.
112
+ */
113
+ endSegment(): RoutedDelta[] {
89
114
  const out: RoutedDelta[] = [];
90
- if (this.state === 'hybrid-indent') this.emit(this.held, out);
115
+ if (this.state === 'indent') this.emit(this.held, out);
91
116
  if (this.state === 'maybe-prefix') {
92
- const full = this.opts.mode === 'hybrid' ? '>>>' : '>>';
93
- const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
94
- if (arrows !== full) this.emit(this.held, out);
117
+ // Hybrid's `>>>` with nothing after it is an envelope with no target:
118
+ // delivery bounces it and holds back what follows. Anything else held
119
+ // is text to delivery, explicit's bare `>>` included.
120
+ const arrows = this.held.trimStart();
121
+ if (this.opts.mode === 'hybrid' && arrows === '>>>') this.target = null;
122
+ else this.emit(this.held, out);
95
123
  }
96
- // prefix-token / after-token at EOF: prefix without body — nothing to emit.
124
+ // Hybrid's `>>>` and a gap with nothing after it: no target either.
125
+ if (this.state === 'prefix-gap') this.target = null;
126
+ if (this.state === 'prefix-token') this.applyTarget();
97
127
  this.held = '';
98
- this.state = 'line-start';
128
+ this.token = '';
129
+ if (this.opts.mode !== 'locus') this.state = 'line-start';
99
130
  return coalesce(out);
100
131
  }
101
132
 
133
+ /** End of stream: the last segment ends. */
134
+ finish(): RoutedDelta[] {
135
+ return this.endSegment();
136
+ }
137
+
102
138
  private step(ch: string, out: RoutedDelta[]): void {
103
139
  switch (this.state) {
104
140
  case 'body':
@@ -111,8 +147,11 @@ export class ProseStreamRouter {
111
147
  return;
112
148
 
113
149
  case 'line-start':
114
- if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
115
- this.state = 'hybrid-indent';
150
+ // Indentation before a prefix is held until the line's decision: the
151
+ // delivery path reads an indented `>>` (explicit) or `>>>` (hybrid)
152
+ // line as a prefix too, after any leading whitespace.
153
+ if (ch !== '\n' && isSpace(ch)) {
154
+ this.state = 'indent';
116
155
  this.held = ch;
117
156
  return;
118
157
  }
@@ -121,8 +160,8 @@ export class ProseStreamRouter {
121
160
  this.step(ch, out);
122
161
  return;
123
162
 
124
- case 'hybrid-indent':
125
- if (ch === ' ' || ch === '\t') { this.held += ch; return; }
163
+ case 'indent':
164
+ if (ch !== '\n' && isSpace(ch)) { this.held += ch; return; }
126
165
  if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; }
127
166
  this.emit(this.held, out);
128
167
  this.held = '';
@@ -132,7 +171,7 @@ export class ProseStreamRouter {
132
171
 
133
172
  case 'maybe-prefix': {
134
173
  const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>';
135
- const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
174
+ const arrows = this.held.trimStart();
136
175
  if (ch === '>' && arrows.length < prefix.length) {
137
176
  this.held += '>';
138
177
  return;
@@ -143,11 +182,22 @@ export class ProseStreamRouter {
143
182
  this.state = 'prefix-gap';
144
183
  return;
145
184
  }
146
- if (ch === '\n' || ch === ' ' || ch === '\t') {
147
- if (this.opts.mode === 'explicit') this.emit(this.held, out);
185
+ if (isSpace(ch)) {
186
+ if (this.opts.mode === 'explicit') {
187
+ // `>>` and then whitespace is a quoted arrow: body text, since
188
+ // delivery's prefix needs a target right after the arrows.
189
+ this.emit(this.held, out);
190
+ } else {
191
+ // `>>>` and then whitespace other than a space or tab (a
192
+ // newline included) is an envelope with no target. Delivery
193
+ // bounces it and holds back what follows until a later `>>>`
194
+ // names a target.
195
+ this.target = null;
196
+ }
197
+ // The whitespace itself is body, a newline included.
148
198
  this.held = '';
149
- this.state = ch === '\n' ? 'line-start' : 'body';
150
- if (ch !== '\n') this.step(ch, out);
199
+ this.state = 'body';
200
+ this.step(ch, out);
151
201
  return;
152
202
  }
153
203
  this.state = 'prefix-token';
@@ -165,6 +215,9 @@ export class ProseStreamRouter {
165
215
  case 'prefix-gap':
166
216
  if (ch === ' ' || ch === '\t') return;
167
217
  if (ch === '\n') { this.target = null; this.state = 'line-start'; return; }
218
+ // Other whitespace can't start hybrid's target (`[ \t]*\S+`): an
219
+ // envelope with no target, as above.
220
+ if (isSpace(ch)) { this.target = null; this.state = 'body'; return; }
168
221
  this.state = 'prefix-token';
169
222
  this.token = ch;
170
223
  return;
@@ -172,6 +225,9 @@ export class ProseStreamRouter {
172
225
  case 'prefix-token':
173
226
  if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; }
174
227
  if (ch === '\n') { this.applyTarget(); this.state = 'line-start'; return; }
228
+ // Any other whitespace ends the token too (`\S+`), and starts the
229
+ // body: the grammar's gap after a token is spaces and tabs only.
230
+ if (isSpace(ch)) { this.applyTarget(); this.state = 'body'; this.step(ch, out); return; }
175
231
  this.token += ch;
176
232
  return;
177
233