@animalabs/agent-framework 0.29.0 → 0.31.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 (236) hide show
  1. package/README.md +71 -8
  2. package/dist/src/admission/admit.d.ts +90 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -0
  4. package/dist/src/admission/admit.js +43 -0
  5. package/dist/src/admission/admit.js.map +1 -0
  6. package/dist/src/agent.d.ts +50 -9
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +125 -23
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +10 -3
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/author-presentation.d.ts +50 -0
  14. package/dist/src/author-presentation.d.ts.map +1 -0
  15. package/dist/src/author-presentation.js +58 -0
  16. package/dist/src/author-presentation.js.map +1 -0
  17. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/render.js +7 -6
  19. package/dist/src/code-execution/isolate/render.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +5 -4
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/dendrite/index.d.ts +6 -0
  24. package/dist/src/dendrite/index.d.ts.map +1 -0
  25. package/dist/src/dendrite/index.js +3 -0
  26. package/dist/src/dendrite/index.js.map +1 -0
  27. package/dist/src/dendrite/presets.d.ts +60 -0
  28. package/dist/src/dendrite/presets.d.ts.map +1 -0
  29. package/dist/src/dendrite/presets.js +117 -0
  30. package/dist/src/dendrite/presets.js.map +1 -0
  31. package/dist/src/dendrite/registry.d.ts +260 -0
  32. package/dist/src/dendrite/registry.d.ts.map +1 -0
  33. package/dist/src/dendrite/registry.js +742 -0
  34. package/dist/src/dendrite/registry.js.map +1 -0
  35. package/dist/src/dendrite/types.d.ts +392 -0
  36. package/dist/src/dendrite/types.d.ts.map +1 -0
  37. package/dist/src/dendrite/types.js +24 -0
  38. package/dist/src/dendrite/types.js.map +1 -0
  39. package/dist/src/framework.d.ts +806 -118
  40. package/dist/src/framework.d.ts.map +1 -1
  41. package/dist/src/framework.js +4180 -1121
  42. package/dist/src/framework.js.map +1 -1
  43. package/dist/src/gate/event-gate.d.ts +36 -21
  44. package/dist/src/gate/event-gate.d.ts.map +1 -1
  45. package/dist/src/gate/event-gate.js +223 -149
  46. package/dist/src/gate/event-gate.js.map +1 -1
  47. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  48. package/dist/src/gate/recipe-reconcile.js +6 -5
  49. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  50. package/dist/src/host-presentation.d.ts +4 -0
  51. package/dist/src/host-presentation.d.ts.map +1 -1
  52. package/dist/src/host-presentation.js +12 -10
  53. package/dist/src/host-presentation.js.map +1 -1
  54. package/dist/src/index.d.ts +6 -2
  55. package/dist/src/index.d.ts.map +1 -1
  56. package/dist/src/index.js +7 -1
  57. package/dist/src/index.js.map +1 -1
  58. package/dist/src/inference-bounds.d.ts +37 -0
  59. package/dist/src/inference-bounds.d.ts.map +1 -0
  60. package/dist/src/inference-bounds.js +84 -0
  61. package/dist/src/inference-bounds.js.map +1 -0
  62. package/dist/src/line-reader.d.ts +7 -1
  63. package/dist/src/line-reader.d.ts.map +1 -1
  64. package/dist/src/line-reader.js +17 -6
  65. package/dist/src/line-reader.js.map +1 -1
  66. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  67. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  68. package/dist/src/mcpl/channel-registry.js +136 -58
  69. package/dist/src/mcpl/channel-registry.js.map +1 -1
  70. package/dist/src/mcpl/errors.d.ts +1 -0
  71. package/dist/src/mcpl/errors.d.ts.map +1 -1
  72. package/dist/src/mcpl/errors.js +3 -0
  73. package/dist/src/mcpl/errors.js.map +1 -1
  74. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  75. package/dist/src/mcpl/inbound-source.js +2 -2
  76. package/dist/src/mcpl/inference-router.d.ts +11 -1
  77. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/inference-router.js +65 -9
  79. package/dist/src/mcpl/inference-router.js.map +1 -1
  80. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  81. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  82. package/dist/src/mcpl/modern-connection.js +85 -74
  83. package/dist/src/mcpl/modern-connection.js.map +1 -1
  84. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  85. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  86. package/dist/src/mcpl/protocol-family.js +45 -3
  87. package/dist/src/mcpl/protocol-family.js.map +1 -1
  88. package/dist/src/mcpl/push-coalescer.d.ts +11 -7
  89. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  90. package/dist/src/mcpl/push-coalescer.js +21 -0
  91. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  92. package/dist/src/mcpl/references.d.ts +1 -2
  93. package/dist/src/mcpl/references.d.ts.map +1 -1
  94. package/dist/src/mcpl/references.js +3 -3
  95. package/dist/src/mcpl/references.js.map +1 -1
  96. package/dist/src/mcpl/server-connection.d.ts +14 -0
  97. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/server-connection.js +26 -22
  99. package/dist/src/mcpl/server-connection.js.map +1 -1
  100. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  101. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  102. package/dist/src/mcpl/tool-signature.js +16 -11
  103. package/dist/src/mcpl/tool-signature.js.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  105. package/dist/src/mcpl/toolsets-module.js +7 -0
  106. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  107. package/dist/src/mcpl/transport.d.ts +44 -21
  108. package/dist/src/mcpl/transport.d.ts.map +1 -1
  109. package/dist/src/mcpl/transport.js +283 -64
  110. package/dist/src/mcpl/transport.js.map +1 -1
  111. package/dist/src/mcpl/types.d.ts +17 -1
  112. package/dist/src/mcpl/types.d.ts.map +1 -1
  113. package/dist/src/mcpl/types.js.map +1 -1
  114. package/dist/src/module-registry.d.ts +32 -4
  115. package/dist/src/module-registry.d.ts.map +1 -1
  116. package/dist/src/module-registry.js +189 -25
  117. package/dist/src/module-registry.js.map +1 -1
  118. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.js +24 -16
  120. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  121. package/dist/src/modules/sender-name.d.ts +5 -4
  122. package/dist/src/modules/sender-name.d.ts.map +1 -1
  123. package/dist/src/modules/sender-name.js +7 -5
  124. package/dist/src/modules/sender-name.js.map +1 -1
  125. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  126. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  127. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  128. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  129. package/dist/src/modules/workspace/index.d.ts +6 -3
  130. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/index.js +61 -15
  132. package/dist/src/modules/workspace/index.js.map +1 -1
  133. package/dist/src/modules/workspace/observe.d.ts +9 -1
  134. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  135. package/dist/src/modules/workspace/observe.js +50 -10
  136. package/dist/src/modules/workspace/observe.js.map +1 -1
  137. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  138. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/reconcile.js +19 -8
  140. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  141. package/dist/src/modules/workspace/types.d.ts +8 -0
  142. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  143. package/dist/src/modules/workspace/types.js.map +1 -1
  144. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  145. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/watcher.js +7 -5
  147. package/dist/src/modules/workspace/watcher.js.map +1 -1
  148. package/dist/src/notice-tags.d.ts +2 -0
  149. package/dist/src/notice-tags.d.ts.map +1 -1
  150. package/dist/src/notice-tags.js +2 -0
  151. package/dist/src/notice-tags.js.map +1 -1
  152. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  153. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  154. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  155. package/dist/src/silent-heartbeat.d.ts +4 -2
  156. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  157. package/dist/src/silent-heartbeat.js +7 -4
  158. package/dist/src/silent-heartbeat.js.map +1 -1
  159. package/dist/src/stored-injections.d.ts +4 -0
  160. package/dist/src/stored-injections.d.ts.map +1 -1
  161. package/dist/src/stored-injections.js +6 -0
  162. package/dist/src/stored-injections.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +2 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -1
  165. package/dist/src/supplied-text.js +16 -7
  166. package/dist/src/supplied-text.js.map +1 -1
  167. package/dist/src/tool-result-guard.js +1 -1
  168. package/dist/src/tool-result-guard.js.map +1 -1
  169. package/dist/src/tune-out/coordinator.d.ts +368 -16
  170. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  171. package/dist/src/tune-out/coordinator.js +1224 -136
  172. package/dist/src/tune-out/coordinator.js.map +1 -1
  173. package/dist/src/tune-out/tools.d.ts +115 -7
  174. package/dist/src/tune-out/tools.d.ts.map +1 -1
  175. package/dist/src/tune-out/tools.js +55 -18
  176. package/dist/src/tune-out/tools.js.map +1 -1
  177. package/dist/src/types/agent.d.ts +27 -1
  178. package/dist/src/types/agent.d.ts.map +1 -1
  179. package/dist/src/types/framework.d.ts +153 -15
  180. package/dist/src/types/framework.d.ts.map +1 -1
  181. package/dist/src/types/module.d.ts +42 -3
  182. package/dist/src/types/module.d.ts.map +1 -1
  183. package/dist/src/types/trace.d.ts +140 -3
  184. package/dist/src/types/trace.d.ts.map +1 -1
  185. package/package.json +4 -4
  186. package/src/admission/admit.ts +113 -0
  187. package/src/agent.ts +138 -31
  188. package/src/api/mcp-server-core.ts +10 -3
  189. package/src/author-presentation.ts +81 -0
  190. package/src/code-execution/isolate/render.ts +7 -6
  191. package/src/code-execution/isolate/scripts.ts +5 -4
  192. package/src/dendrite/index.ts +32 -0
  193. package/src/dendrite/presets.ts +149 -0
  194. package/src/dendrite/registry.ts +808 -0
  195. package/src/dendrite/types.ts +397 -0
  196. package/src/framework.ts +4344 -1052
  197. package/src/gate/event-gate.ts +251 -150
  198. package/src/gate/recipe-reconcile.ts +7 -5
  199. package/src/host-presentation.ts +14 -10
  200. package/src/index.ts +16 -2
  201. package/src/inference-bounds.ts +111 -0
  202. package/src/line-reader.ts +22 -5
  203. package/src/mcpl/channel-registry.ts +170 -60
  204. package/src/mcpl/errors.ts +4 -0
  205. package/src/mcpl/inbound-source.ts +2 -2
  206. package/src/mcpl/inference-router.ts +67 -10
  207. package/src/mcpl/modern-connection.ts +83 -68
  208. package/src/mcpl/protocol-family.ts +37 -4
  209. package/src/mcpl/push-coalescer.ts +21 -7
  210. package/src/mcpl/references.ts +3 -4
  211. package/src/mcpl/server-connection.ts +30 -21
  212. package/src/mcpl/tool-signature.ts +18 -11
  213. package/src/mcpl/toolsets-module.ts +7 -0
  214. package/src/mcpl/transport.ts +261 -60
  215. package/src/mcpl/types.ts +18 -1
  216. package/src/module-registry.ts +178 -25
  217. package/src/modules/discord/discord-js-client.ts +33 -29
  218. package/src/modules/sender-name.ts +8 -5
  219. package/src/modules/workspace/disk-agreement.ts +39 -15
  220. package/src/modules/workspace/index.ts +65 -14
  221. package/src/modules/workspace/observe.ts +51 -10
  222. package/src/modules/workspace/reconcile.ts +22 -8
  223. package/src/modules/workspace/types.ts +5 -0
  224. package/src/modules/workspace/watcher.ts +8 -6
  225. package/src/notice-tags.ts +2 -0
  226. package/src/recovery/discord-awareness-outbox.ts +1 -11
  227. package/src/silent-heartbeat.ts +7 -4
  228. package/src/stored-injections.ts +7 -0
  229. package/src/supplied-text.ts +17 -6
  230. package/src/tool-result-guard.ts +1 -1
  231. package/src/tune-out/coordinator.ts +1374 -141
  232. package/src/tune-out/tools.ts +166 -13
  233. package/src/types/agent.ts +30 -1
  234. package/src/types/framework.ts +157 -15
  235. package/src/types/module.ts +42 -3
  236. package/src/types/trace.ts +154 -3
@@ -566,11 +566,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
566
566
  },
567
567
  {
568
568
  name: 'channel_publish',
569
- description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it. A subagent has neither default, so it must name a channel.",
569
+ description: "Publish a message to a channel. If channelId is omitted, publishes where your plain speech goes this turn: your conversation's channel if you are bound to one; otherwise the channel of the message that woke this turn, or one you opened during it; otherwise, while a focus holds, the channel it keeps. Where none of those names a channel, nothing is posted, unless this host is set to use the channel of the latest incoming message instead. A turn with no channel of its own, such as a heartbeat's, has no default, so it names one. A subagent has no default, so it must name a channel.",
570
570
  inputSchema: {
571
571
  type: 'object' as const,
572
572
  properties: {
573
- channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, other than a reaction, reaction removal, deletion or edit, though an edit of a message you hadn't been shown can count; a message pushed to you rather than posted on a channel never sets it; required of a subagent)" },
573
+ channelId: { type: 'string', description: "ID of the channel to publish to (defaults to where your plain speech goes this turn, as the tool's description says; required of a subagent)" },
574
574
  content: { type: 'string', description: 'Text content to publish' },
575
575
  text: { type: 'string', description: 'Alias for content' },
576
576
  },
@@ -992,8 +992,35 @@ interface ChannelRegistryOptions {
992
992
  ) => Promise<ChannelIncomingMessageResult & { firstView?: true }>;
993
993
  /** Chronicle store used for durable desired channel lifecycle state. */
994
994
  store?: JsStore;
995
- /** Callback to determine whether an incoming message should trigger inference. */
995
+ /**
996
+ * The server-level veto on waking for an incoming message (a per-server
997
+ * `McplServerConfig.shouldTriggerInference`). The wake gate is NOT wired
998
+ * here: it runs in the framework's admission stage, after the hold
999
+ * decision, so a held message never forms a gate batch (issue #232 gap 3).
1000
+ */
996
1001
  shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1002
+ /**
1003
+ * Whether the most recent delivered inbound message is a fallback speech
1004
+ * locus for turns that have none of their own (`speech.locus:
1005
+ * 'latest-inbound'`). Off by default: incoming traffic never moves the
1006
+ * locus (architecture channel, 2026-10-06).
1007
+ */
1008
+ latestInboundLocus?: boolean;
1009
+ /**
1010
+ * A channel is being closed by tool: an all-except hold whose exception
1011
+ * this is ends through the cancel flow (release the backlog, record the
1012
+ * end) before the lifecycle state flips to closed. A
1013
+ * tuned-out channel's close is refused before this (its cancel ends it).
1014
+ * Returns what it ended, in a sentence the close's answer adds when the
1015
+ * close itself isn't recorded.
1016
+ */
1017
+ onToolClose?: (serverId: string, channelId: string) => string | undefined;
1018
+ /**
1019
+ * The channel unprefixed speech lands in while an all-except hold is
1020
+ * active (its exception), else null. Consulted after a fork's home and
1021
+ * the turn's own delivery, before the legacy fallback.
1022
+ */
1023
+ focusChannelResolver?: () => ChannelRef | null;
997
1024
  /**
998
1025
  * Called when a text-only turn's speech was not delivered to its
999
1026
  * conversational locus (`failed`), or may not have been (`unknown`), for
@@ -1043,7 +1070,7 @@ interface ChannelRegistryOptions {
1043
1070
  /**
1044
1071
  * Resolve a conversation fork's HOME channel from its agent name. Conversation
1045
1072
  * forks are spawned bound to a single channel (the framework tracks this in
1046
- * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their
1073
+ * the agent registry's `homeChannel` / `ConversationRouter.channelForAgent`); their
1047
1074
  * plain-text speech must route THERE. The process-global `defaultPublishChannel`
1048
1075
  * tracks only the most recent inbound message across ALL channels (reaction,
1049
1076
  * reaction-removal, edit and deletion markers aside, apart from an edit the
@@ -1120,6 +1147,10 @@ export type ChannelToolOrigin =
1120
1147
  * wake due before it, when there is one, which its confirmation names
1121
1148
  * (skipReplyNote). */
1122
1149
  earlierWake?: SkipReplyEarlierWake;
1150
+ /** Made inside one of the agent's turns (the framework's callingTurnOf):
1151
+ * a channel_publish naming no channel arrives with that turn's default
1152
+ * already applied, so it takes no other. */
1153
+ inTurn?: boolean;
1123
1154
  };
1124
1155
 
1125
1156
  /** The machine decision sources a module-origin channel_close may record.
@@ -1154,6 +1185,9 @@ export class ChannelRegistry {
1154
1185
  op?: 'start' | 'stop',
1155
1186
  ) => void;
1156
1187
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1188
+ private readonly latestInboundLocus: boolean;
1189
+ private onToolClose?: (serverId: string, channelId: string) => string | undefined;
1190
+ private focusChannelResolver?: () => ChannelRef | null;
1157
1191
  private onRouteFailure?: ChannelRegistryOptions['onRouteFailure'];
1158
1192
  private onChannelAutoOpened?: (info: {
1159
1193
  conversationId?: string;
@@ -1582,6 +1616,9 @@ export class ChannelRegistry {
1582
1616
  this.emitTraceFn = emitTraceFn;
1583
1617
  this.sendTypingFn = options?.sendTypingFn;
1584
1618
  this.shouldTriggerInference = options?.shouldTriggerInference;
1619
+ this.latestInboundLocus = options?.latestInboundLocus === true;
1620
+ this.onToolClose = options?.onToolClose;
1621
+ this.focusChannelResolver = options?.focusChannelResolver;
1585
1622
  this.onRouteFailure = options?.onRouteFailure;
1586
1623
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1587
1624
  this.homeChannelResolver = options?.homeChannelResolver;
@@ -1823,9 +1860,6 @@ export class ChannelRegistry {
1823
1860
  // The batch came on the transport open now: a close after this point
1824
1861
  // (transportClosed) leaves its traffic nothing to confirm.
1825
1862
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1826
- // And came from a server registered now: one removed since (removeServer)
1827
- // gives no message the agent replies to (markAccepted).
1828
- const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
1829
1863
 
1830
1864
  for (const message of params.messages) {
1831
1865
  if (!message || typeof message.channelId !== 'string' || !message.channelId
@@ -1931,28 +1965,19 @@ export class ChannelRegistry {
1931
1965
  continue;
1932
1966
  }
1933
1967
 
1934
- // Track default publish channel (most recent ACCEPTED incoming) —
1935
- // deliberately after §14.5 validation: a rejected message from an
1936
- // unregistered channel must not retarget outbound speech (the locus is
1937
- // exactly the authority a self-attested channel would be stealing).
1938
- // A coalesced item is "accepted" only once the coalescer admits it, so
1939
- // for those this runs after the hook (below).
1968
+ // A server sending channels/incoming is evidence that the transport
1969
+ // is open. This repairs transient status only; durable desired state
1970
+ // still changes exclusively through lifecycle operations. Nothing else
1971
+ // is decided here: whether this message is delivered, wakes anyone or
1972
+ // counts as the latest inbound is the framework's admission decision,
1973
+ // taken once the hold is known (see `noteDelivered`; issue #232 root
1974
+ // cause A). A coalesced item is "accepted" only once the coalescer
1975
+ // admits it, so for those this runs after the hook (below).
1940
1976
  const incomingEntry = this.channels.get(incomingKey)!;
1941
- const markAccepted = (firstView = false) => {
1942
- // Non-conversational markers (a reaction added or removed, an edit, a
1943
- // deletion, and a chat:system notice such as a join or the weather)
1944
- // are ABOUT a message or the place, not one the agent replies to: they
1945
- // must not retarget the default publish locus or become
1946
- // `context.incoming`'s reply anchor. An edit the coalescer delivered
1947
- // as the agent's first view counts as the message it replaced.
1948
- // A message the coalescer admits after its server was removed sets no
1949
- // default: the removal cleared that server's message, and a server
1950
- // that's gone mustn't displace a live one's default.
1951
- if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
1952
- this.defaultPublishChannel = { serverId, channelId: message.channelId };
1953
- this.defaultPublishMessageId = message.messageId;
1954
- this.defaultPublishThreadId = message.threadId;
1955
- }
1977
+ // `firstView` is the coalescer's mark that an edit was the agent's first
1978
+ // view of its message; the registry no longer records the publish locus
1979
+ // here (admission does, through noteDelivered, with the same marker rule).
1980
+ const markAccepted = (_firstView = false) => {
1956
1981
  // Where the host directs this server's lifecycle, traffic shows the
1957
1982
  // transport open only for a channel still decided open or tuned out,
1958
1983
  // and, but for one exception, only for a confirmed target: after a
@@ -1987,7 +2012,7 @@ export class ChannelRegistry {
1987
2012
  };
1988
2013
  if (!coalesced) markAccepted();
1989
2014
 
1990
- // Determine whether to trigger inference
2015
+ // The server-level veto only; the wake gate runs at admission.
1991
2016
  let triggerInference = true;
1992
2017
  if (this.shouldTriggerInference) {
1993
2018
  // The text the agent's context gets, as the push lane's filter reads
@@ -2289,16 +2314,58 @@ export class ChannelRegistry {
2289
2314
  // Accessors
2290
2315
  // ==========================================================================
2291
2316
 
2317
+ /**
2318
+ * The most recent DELIVERED inbound message (channel, message, thread).
2319
+ * Recorded by the framework after admission, so a held message never
2320
+ * becomes "the latest inbound" (issue #232 gap 2), and never for a marker
2321
+ * (a reaction, reaction removal, edit or deletion, apart from an edit the
2322
+ * coalescer delivered as the agent's first view: isMarkerTagged). Read as
2323
+ * the `incoming` reference in the channel context and, under
2324
+ * `speech.locus: 'latest-inbound'`, as the fallback speech locus. The
2325
+ * channel is on the server the message came from. A message whose server
2326
+ * no longer registers its channel sets nothing, as one the coalescer admits
2327
+ * after its server was removed: the removal cleared that server's message,
2328
+ * and a server that's gone mustn't displace a live one's anchor. That's
2329
+ * read at admission, where main's guard compared the server's removal
2330
+ * count at arrival and at acceptance (markAccepted): a message that
2331
+ * arrived before its server's removal and is admitted once the server
2332
+ * registers the channel again sets the anchor here, as a message delivered
2333
+ * on a live channel, where main's would set nothing.
2334
+ */
2335
+ noteDelivered(channel: ChannelRef, messageId: string, threadId?: string, tags?: unknown, firstView = false): void {
2336
+ // A marker is ABOUT a message, not one the agent replies to: it leaves
2337
+ // the anchor where it was (#157), and so does a chat:system notice (a
2338
+ // join, the weather), which is about the place (isMarkerTagged). An edit
2339
+ // the coalescer delivered as the agent's first view of its message counts
2340
+ // as that message.
2341
+ if (isMarkerTagged(tags, firstView)) return;
2342
+ if (!this.entryAt(channel)) return;
2343
+ this.defaultPublishChannel = { serverId: channel.serverId, channelId: channel.channelId };
2344
+ this.defaultPublishMessageId = messageId;
2345
+ this.defaultPublishThreadId = threadId;
2346
+ }
2347
+
2292
2348
  /**
2293
2349
  * Get the default publish channel ID: the channel of the most recent
2294
- * incoming message that isn't a reaction, reaction removal, edit or
2295
- * deletion marker, apart from an edit the coalescer delivers on acceptance
2296
- * as the agent's first view.
2350
+ * delivered incoming message that isn't a reaction, reaction removal, edit
2351
+ * or deletion marker, apart from an edit the coalescer delivers on
2352
+ * acceptance as the agent's first view.
2297
2353
  */
2298
2354
  getDefaultPublishChannel(): ChannelRef | null {
2299
2355
  return this.defaultPublishChannel;
2300
2356
  }
2301
2357
 
2358
+ /**
2359
+ * Public form of the tool-input channel resolution (resolveToolChannelEntry):
2360
+ * optional server, the calling turn's own channels (`own`), ambiguity is an
2361
+ * error.
2362
+ */
2363
+ resolveChannel(channelId: string, serverId?: string, own?: readonly ChannelRef[]): ChannelRef | { error: string } {
2364
+ const resolved = this.resolveToolChannelEntry(channelId, serverId, own);
2365
+ if (!resolved.entry) return { error: resolved.error ?? `Channel not found: ${channelId}` };
2366
+ return { serverId: resolved.entry.serverId, channelId: resolved.entry.descriptor.id };
2367
+ }
2368
+
2302
2369
  /**
2303
2370
  * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
2304
2371
  * declaration (channels/register, channels/changed) or an authorized open?
@@ -2439,7 +2506,7 @@ export class ChannelRegistry {
2439
2506
 
2440
2507
  /**
2441
2508
  * The message the agent is replying to, with its channel: the most recent
2442
- * inbound message that isn't a reaction, reaction removal, edit or deletion
2509
+ * delivered inbound message (noteDelivered) that isn't a reaction, reaction removal, edit or deletion
2443
2510
  * marker, apart from an edit the coalescer delivers on acceptance as the
2444
2511
  * agent's first view. Left process-global: per-channel inbound tracking
2445
2512
  * (the right messageId for a fork's own channel) is a separate concern from
@@ -2456,9 +2523,13 @@ export class ChannelRegistry {
2456
2523
  if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
2457
2524
  return undefined;
2458
2525
  }
2459
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2460
- if (!fallback || !this.defaultPublishMessageId) return undefined;
2461
- return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
2526
+ // The latest delivered inbound itself, not the speech fallback
2527
+ // (globalFallbackFor), which under `speech.locus: 'delivery'` is the
2528
+ // all-except hold's excepted channel while one is open, and none
2529
+ // otherwise.
2530
+ const anchor = agentName !== undefined && this.takesGlobalFallback?.(agentName) === false ? null : this.defaultPublishChannel;
2531
+ if (!anchor || !this.defaultPublishMessageId) return undefined;
2532
+ return { channel: anchor, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
2462
2533
  }
2463
2534
 
2464
2535
  /**
@@ -2484,16 +2555,19 @@ export class ChannelRegistry {
2484
2555
  // '' is a name an ephemeral run can have, and resolveLocus reads it too.
2485
2556
  const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
2486
2557
  const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
2487
- // An ephemeral run takes no global fallback (takesGlobalFallback): neither
2488
- // the latest inbound message's channel as its outgoing one, nor that
2489
- // message as the one it is replying to.
2490
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2558
+ // An ephemeral run takes no global fallback (takesGlobalFallback): not the
2559
+ // all-except hold's channel, not the latest inbound message's channel as
2560
+ // its outgoing one, nor that message as the one it is replying to.
2561
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
2491
2562
  // A turn that deliberately has no channel (null) takes no fallback.
2492
2563
  const outgoing = home ?? (active !== undefined ? active : fallback);
2493
2564
 
2494
2565
  const context: ChannelContext = {};
2495
2566
 
2496
- // Incoming: the message the agent is replying to (incomingFor).
2567
+ // Incoming: the message the agent is replying to (incomingFor). The
2568
+ // anchor is independent of the speech rule: under `speech.locus:
2569
+ // 'delivery'` the latest inbound is no fallback for where prose lands,
2570
+ // but it is still the message the agent is replying to.
2497
2571
  const incoming = this.incomingFor(agentName);
2498
2572
  if (incoming && incoming.channel.serverId === serverId) {
2499
2573
  context.incoming = {
@@ -3994,8 +4068,8 @@ export class ChannelRegistry {
3994
4068
  /**
3995
4069
  * How a tuned-out channel's tune-out ends, for the texts that tell an agent
3996
4070
  * channel_open doesn't change it: a tune_out cancel, by the name that
3997
- * picks the channel (nameFor), or, where no name picks it alone, its own
3998
- * end, since tune_out takes a name.
4071
+ * picks the channel (nameFor), or, where no name picks it alone, by its id
4072
+ * and server, since tune_out takes a serverId.
3999
4073
  * For channel_close, which refuses a tuned-out channel too, it adds that
4000
4074
  * the close comes after that.
4001
4075
  */
@@ -4008,12 +4082,15 @@ export class ChannelRegistry {
4008
4082
 
4009
4083
  private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
4010
4084
  const close = tool === 'channel_close';
4011
- return this.nameFor(entry) !== undefined
4012
- ? `Call tune_out with channelId ${quotedValue(this.nameFor(entry)!)} and mode "cancel" for it to resume normal ` +
4013
- `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
4014
- : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
4015
- 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
4016
- (close ? ' Once it has ended, channel_close can close it.' : '');
4085
+ // A name is written as a literal tune_out's channelId takes back
4086
+ // (quotedValue, main's #852); one no name picks alone, by its id and
4087
+ // server, since this branch's tune_out takes a serverId.
4088
+ const name = this.nameFor(entry);
4089
+ const names = name !== undefined
4090
+ ? `channelId ${quotedValue(name)}`
4091
+ : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
4092
+ return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` +
4093
+ `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`;
4017
4094
  }
4018
4095
 
4019
4096
  /**
@@ -5156,12 +5233,13 @@ export class ChannelRegistry {
5156
5233
  // Closed already, perhaps only by default: the agent's close is still
5157
5234
  // its decision, and is recorded as one (setDesiredState), so a later
5158
5235
  // registration's lift of a default leaves the channel closed.
5236
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
5159
5237
  try {
5160
5238
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5161
5239
  } catch (err) {
5162
5240
  return {
5163
5241
  success: false,
5164
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
5242
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended}` : ''}`,
5165
5243
  isError: true,
5166
5244
  };
5167
5245
  }
@@ -5204,12 +5282,18 @@ export class ChannelRegistry {
5204
5282
  const refusal = this.tunedOutRefusal('channel_close', entry);
5205
5283
  return isModuleOrigin ? { ...refusal, isError: false } : refusal;
5206
5284
  }
5285
+ // A hold around this channel — the all-except scope it is the excepted
5286
+ // channel of — ends through the cancel flow: backlog released, end
5287
+ // recorded, never by overwriting state (#232 gap 9).
5288
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
5207
5289
 
5208
- // A decision the log refused changed nothing, and the answer says so.
5290
+ // A decision the log refused changed nothing for the channel, and the
5291
+ // answer says so, and what the hook ended all the same.
5209
5292
  try {
5210
5293
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5211
5294
  } catch (err) {
5212
- return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
5295
+ const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
5296
+ return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
5213
5297
  }
5214
5298
  // This channel's typing, not every channel with its id (stopTyping's
5215
5299
  // fan-out of a bare name).
@@ -5686,9 +5770,29 @@ export class ChannelRegistry {
5686
5770
  return active !== undefined ? active : this.globalFallbackFor(conversationId);
5687
5771
  }
5688
5772
 
5689
- /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
5773
+ /**
5774
+ * The process-global fallback for this conversation, or null when it takes
5775
+ * none (takesGlobalFallback): an ephemeral run's prose is its caller's
5776
+ * result, so neither the all-except hold's channel nor the latest inbound
5777
+ * message's is ever its destination.
5778
+ */
5690
5779
  private globalFallbackFor(conversationId: string): ChannelRef | null {
5691
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
5780
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
5781
+ }
5782
+
5783
+ /**
5784
+ * What every conversation that takes the global fallback falls back to when
5785
+ * neither a home channel nor its own delivery names one: the all-except
5786
+ * hold's channel while one is open, else the latest delivered inbound
5787
+ * channel under the legacy locus rule, else none — the prose stays private
5788
+ * (architecture channel §3 carve-out, 2026-10-06).
5789
+ */
5790
+ private sharedFallback(): ChannelRef | null {
5791
+ return this.focusChannelResolver?.() ?? this.fallbackLocus();
5792
+ }
5793
+
5794
+ private fallbackLocus(): ChannelRef | null {
5795
+ return this.latestInboundLocus ? this.defaultPublishChannel : null;
5692
5796
  }
5693
5797
 
5694
5798
  /**
@@ -6140,17 +6244,20 @@ export class ChannelRegistry {
6140
6244
  if (input.channelId === '' && tool === 'channel_publish') {
6141
6245
  return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
6142
6246
  }
6143
- // Resolve channelId: default to the channel of the most recent incoming
6144
- // message that isn't a reaction, reaction removal, edit or deletion
6145
- // marker, apart from an edit the coalescer delivers on acceptance as the
6146
- // agent's first view. An ephemeral run never takes that default
6247
+ // Resolve channelId: a call made inside the agent's turn (inTurn) has
6248
+ // the turn's default applied already, where its plain speech goes, and
6249
+ // takes none here. Any other defaults to what the caller falls back to:
6250
+ // the all-except hold's channel, else the latest delivered inbound
6251
+ // channel under the legacy locus rule (a marker — reaction, edit,
6252
+ // deletion — is never that channel), which an ephemeral run never takes
6147
6253
  // (takesGlobalFallback), so it names one.
6148
6254
  // A channel named by a string means what it means to every channel tool
6149
6255
  // (entriesNamed): one channel, by its id or its server-qualified name,
6150
6256
  // and a name two servers' channels answer to is refused with them as
6151
6257
  // candidates, never sent to the first.
6152
6258
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
6153
- const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
6259
+ const inTurn = origin?.kind === 'agent' && origin.inTurn === true;
6260
+ const fallback = caller === undefined ? this.sharedFallback() : inTurn ? null : this.globalFallbackFor(caller);
6154
6261
  if (!(input.channelId ?? fallback)) {
6155
6262
  return {
6156
6263
  success: false,
@@ -6159,7 +6266,10 @@ export class ChannelRegistry {
6159
6266
  : caller !== undefined && this.takesGlobalFallback?.(caller) === false
6160
6267
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
6161
6268
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
6162
- : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6269
+ : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null
6270
+ ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
6271
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`
6272
+ : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6163
6273
  isError: true,
6164
6274
  };
6165
6275
  }
@@ -68,6 +68,10 @@ export function boundedForLog(text: string, max = 200): string {
68
68
  return points.length > max ? `${points.slice(0, max).join('')}…` : points.join('');
69
69
  }
70
70
 
71
+ export function hostStoppedWhileHandling(): JsonRpcError {
72
+ return { code: -32000, message: 'the host stopped while handling this request' };
73
+ }
74
+
71
75
  /**
72
76
  * Create a "feature set not enabled" error.
73
77
  * Returned when a server sends a message tagged with a feature set
@@ -167,8 +167,8 @@ export function conversationKey(source: InboundSource): string | undefined {
167
167
  * well-formed is dropped (the store doesn't keep it). An `undefined` array
168
168
  * element is written as `null`, as JSON writes it: the store's append
169
169
  * refuses a body holding one, so no stored copy does. So a value
170
- * hashes alike however its keys were ordered (content read back from the
171
- * store has them in a different order than it was written), and alike
170
+ * hashes alike however its keys were ordered (a store on chronicle before
171
+ * 0.5.1 hands content back with its keys sorted), and alike
172
172
  * before and after the store. editMessage writes JSON instead, so an edited
173
173
  * copy can keep what an append wouldn't; it has changed anyway.
174
174
  *
@@ -25,8 +25,10 @@ import type {
25
25
  } from './types.js';
26
26
  import type { FeatureSetManager } from './feature-set-manager.js';
27
27
  import { McplFeatureSetError } from './feature-set-manager.js';
28
+ import { hostStoppedWhileHandling } from './errors.js';
28
29
  import type { HookOrchestrator } from './hook-orchestrator.js';
29
30
  import { logValue } from './log-value.js';
31
+ import { streamBounds, wholeCallMs, withHostSetting, type InferenceBounds } from '../inference-bounds.js';
30
32
 
31
33
  // ============================================================================
32
34
  // Responder interface
@@ -41,6 +43,7 @@ interface Responder {
41
43
  /** Sends a streamed chunk on the transport the request came on, as its
42
44
  * answer is sent. */
43
45
  sendChunk?(params: InferenceChunkParams): void;
46
+ signal?: AbortSignal;
44
47
  }
45
48
 
46
49
  // ============================================================================
@@ -186,6 +189,7 @@ export class InferenceRouter {
186
189
  * server with no policy, and per server and feature set with one, so a
187
190
  * server that keeps asking doesn't flood the operator's log. */
188
191
  private loggedRefusals = new Set<string>();
192
+ private running = new Set<{ serverId: string; stop: AbortController; answered: Promise<void> }>();
189
193
 
190
194
  constructor(
191
195
  membrane: Membrane,
@@ -193,6 +197,7 @@ export class InferenceRouter {
193
197
  featureSetManager: FeatureSetManager,
194
198
  routingPolicy: InferenceRoutingPolicy | null,
195
199
  emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
200
+ private readonly inferenceBounds: () => InferenceBounds | null = () => null,
196
201
  ) {
197
202
  this.membrane = membrane;
198
203
  this.hookOrchestrator = hookOrchestrator;
@@ -216,6 +221,31 @@ export class InferenceRouter {
216
221
  serverId: string,
217
222
  params: McplInferenceRequestParams,
218
223
  responder: Responder,
224
+ ): Promise<void> {
225
+ const stop = new AbortController();
226
+ let ended!: () => void;
227
+ const entry = { serverId, stop, answered: new Promise<void>((resolve) => { ended = resolve; }) };
228
+ this.running.add(entry);
229
+ try {
230
+ await this.answer(serverId, params, responder, stop.signal);
231
+ } finally {
232
+ this.running.delete(entry);
233
+ ended();
234
+ }
235
+ }
236
+
237
+ stopAll(): Array<{ label: string; promise: Promise<void> }> {
238
+ return [...this.running].map(({ serverId, stop, answered }) => {
239
+ stop.abort();
240
+ return { label: `inference/request:${serverId}`, promise: answered };
241
+ });
242
+ }
243
+
244
+ private async answer(
245
+ serverId: string,
246
+ params: McplInferenceRequestParams,
247
+ responder: Responder,
248
+ stopped: AbortSignal,
219
249
  ): Promise<void> {
220
250
  // 1. Validate feature set
221
251
  try {
@@ -295,6 +325,24 @@ export class InferenceRouter {
295
325
  return;
296
326
  }
297
327
 
328
+ const signal = responder.signal ? AbortSignal.any([stopped, responder.signal]) : stopped;
329
+ let stopAnswered = false;
330
+ const answerStop = () => {
331
+ if (responder.signal?.aborted) return;
332
+ stopAnswered = true;
333
+ const answer = hostStoppedWhileHandling();
334
+ this.emitTraceFn({ type: 'mcpl:inference_fail', serverId, model, error: answer.message, streamed });
335
+ // A throw from an abort listener would be uncaught.
336
+ try {
337
+ responder.respondError(answer.code, answer.message);
338
+ } catch {}
339
+ };
340
+ stopped.addEventListener('abort', answerStop, { once: true });
341
+ const settled = (): boolean => {
342
+ stopped.removeEventListener('abort', answerStop);
343
+ return stopAnswered;
344
+ };
345
+
298
346
  // Emit trace: inference start
299
347
  this.emitTraceFn({
300
348
  type: 'mcpl:inference_start',
@@ -319,12 +367,14 @@ export class InferenceRouter {
319
367
  },
320
368
  };
321
369
 
370
+ if (stopAnswered) return;
322
371
  try {
323
372
  // 5. Run inference. Both modes end in membrane's response, and one
324
373
  // builder makes the result from it.
325
374
  const response = chunkRequestId != null
326
- ? await this.streamResponse(request, chunkRequestId, sendChunk!)
327
- : await this.membrane.complete(request);
375
+ ? await this.streamResponse(request, chunkRequestId, sendChunk!, signal)
376
+ : await this.membrane.complete(request, { ...this.wholeCallBound(request), signal });
377
+ if (settled()) return;
328
378
 
329
379
  // 6. Answer
330
380
  const result = resultFrom(response, model);
@@ -344,22 +394,23 @@ export class InferenceRouter {
344
394
 
345
395
  responder.respond(result);
346
396
  } catch (err) {
397
+ if (settled()) return;
347
398
  // Every ending that isn't a completion lands here, in either mode:
348
399
  // membrane's error, a stream that stopped early, a tool call, or a
349
400
  // stop FinishReason can't name. The answer is the JSON-RPC error, never
350
401
  // a result carrying what arrived.
402
+ const transportClosed = responder.signal?.aborted === true;
351
403
  this.emitTraceFn({
352
404
  type: 'mcpl:inference_fail',
353
405
  serverId,
354
406
  model,
355
- error: err instanceof Error ? err.message : String(err),
407
+ error: transportClosed ? 'the transport the request came on closed' : err instanceof Error ? err.message : String(err),
356
408
  streamed,
357
409
  });
358
410
 
359
- responder.respondError(
360
- -32603,
361
- err instanceof Error ? err.message : 'Inference failed',
362
- );
411
+ if (!transportClosed) {
412
+ responder.respondError(-32603, err instanceof Error ? err.message : 'Inference failed');
413
+ }
363
414
  }
364
415
  }
365
416
 
@@ -407,6 +458,11 @@ export class InferenceRouter {
407
458
  // Private: Streaming
408
459
  // ==========================================================================
409
460
 
461
+ private wholeCallBound(request: NormalizedRequest): { progressTimeoutMs?: number } {
462
+ const bounds = this.inferenceBounds();
463
+ return bounds === null ? {} : { progressTimeoutMs: wholeCallMs(bounds, request.config.maxTokens) };
464
+ }
465
+
410
466
  /**
411
467
  * Run the inference as a stream, sending each visible text delta to the
412
468
  * server as an inference/chunk, and return membrane's response once the
@@ -424,8 +480,9 @@ export class InferenceRouter {
424
480
  request: NormalizedRequest,
425
481
  requestId: string | number,
426
482
  sendChunk: (params: InferenceChunkParams) => void,
483
+ signal: AbortSignal,
427
484
  ): Promise<NormalizedResponse> {
428
- const stream = this.membrane.streamYielding(request);
485
+ const stream = this.membrane.streamYielding(request, { ...streamBounds(this.inferenceBounds(), request.config.maxTokens), signal });
429
486
  let index = 0;
430
487
 
431
488
  for await (const event of stream) {
@@ -437,7 +494,7 @@ export class InferenceRouter {
437
494
  // XML tool mode the chunks can miss text the content holds: prose
438
495
  // naming a tag the stream parser opens a block for, or a thinking
439
496
  // block cut off before its close tag.
440
- if (event.meta.visible) {
497
+ if (event.meta.visible && !signal.aborted) {
441
498
  sendChunk({ requestId, index: index++, delta: event.content });
442
499
  }
443
500
  break;
@@ -448,7 +505,7 @@ export class InferenceRouter {
448
505
  case 'error':
449
506
  throw event.error;
450
507
  case 'aborted':
451
- throw new Error(`The inference stopped before it finished (${event.reason})`);
508
+ throw new Error(`The inference stopped before it finished (${event.reason}${event.message ? `: ${event.reason === 'timeout' ? withHostSetting(event.message) : event.message}` : ''})`);
452
509
  }
453
510
  }
454
511
  throw new Error('The inference stream ended without a result');