@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
@@ -1,3 +1,4 @@
1
+ import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue } from "./tool-presentation.js";
1
2
  import { dirname, join } from 'node:path';
2
3
  import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceRegistry, referenceStubOrNull } from './mcpl/references.js';
3
4
  import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js';
@@ -8,26 +9,29 @@ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverB
8
9
  import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
9
10
  import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
10
11
  import { ProcessQueueImpl } from './queue.js';
12
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
11
13
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
12
14
  import { Agent } from './agent.js';
13
- import { ModuleRegistry, isStateExistsError } from './module-registry.js';
15
+ import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
14
16
  import { McplServerRegistry } from './mcpl/server-registry.js';
15
17
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
16
18
  import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
17
19
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
18
20
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
19
21
  import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
22
+ import { checkToolPattern, describeUnmatchedPattern } from './mcpl/tool-pattern-check.js';
20
23
  import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass } from './mcpl/tool-classes.js';
21
24
  import { PushHandler, convertBlock as convertPushBlock } from './mcpl/push-handler.js';
22
25
  import { PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent, coalescingSubjectKey, } from './mcpl/push-coalescer.js';
26
+ import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './mcpl/visible-content.js';
23
27
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
24
28
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
25
29
  import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
26
30
  import { InferenceRouter } from './mcpl/inference-router.js';
27
31
  import { ChannelRegistry } from './mcpl/channel-registry.js';
28
- import { ConversationRouter } from './mcpl/conversation-router.js';
32
+ import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
29
33
  import { safeSlice } from './safe-slice.js';
30
- import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
34
+ import { nonErrorResultText, toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
31
35
  import { TOOL_IMAGE_REF_RE, ToolImageLedger, formatPreservedImageSlots, parseImagePlaceholders, sha256Hex, splitPreservingImageSlots, } from './tool-image-ledger.js';
32
36
  import { randomUUID, createHash } from 'node:crypto';
33
37
  import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
@@ -306,6 +310,43 @@ const bySeq = (a, b) => a.seq - b.seq;
306
310
  function withDeferredWriteId(metadata, id) {
307
311
  return { ...(metadata ?? {}), deferredWriteId: id };
308
312
  }
313
+ /**
314
+ * A prose block's envelopes, as explicit mode routes them: every line whose
315
+ * text, after its leading whitespace, is `>>` and then a non-space opens a
316
+ * new envelope (first live use: Tilde 2026-07-24, one block carrying a
317
+ * letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
318
+ * parser delivered the aside literally into the first destination — exactly
319
+ * the leak class this mode exists to kill). Text before the first `>>` is
320
+ * its own unprefixed envelope. A quoted `>> arrow` (space after) stays body
321
+ * text. Blank envelopes are dropped, and an envelope's whitespace before its
322
+ * `>>` is trimmed.
323
+ */
324
+ function splitExplicitEnvelopes(rawText) {
325
+ const envelopes = [];
326
+ let current = [];
327
+ for (const line of rawText.split('\n')) {
328
+ if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
329
+ envelopes.push(current.join('\n'));
330
+ current = [];
331
+ }
332
+ current.push(line);
333
+ }
334
+ if (current.length > 0)
335
+ envelopes.push(current.join('\n'));
336
+ return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, ''));
337
+ }
338
+ /**
339
+ * Whether a stream resumes its logical turn rather than starting one: a budget
340
+ * restart, or a tool-result-guard retry. Such a stream keeps the turn's
341
+ * routing state (the sticky destination, the turn's destination) and queues
342
+ * its deliveries behind the turn's earlier ones.
343
+ */
344
+ function resumesLogicalTurn(reason) {
345
+ return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
346
+ }
347
+ function freshTurnRouting() {
348
+ return { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } };
349
+ }
309
350
  function isTurnContinuation(reason) {
310
351
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
311
352
  || reason === 'tool_result_guard_retry';
@@ -463,6 +504,10 @@ class DefaultErrorPolicy {
463
504
  }
464
505
  /** Default sync interval in milliseconds */
465
506
  const DEFAULT_SYNC_INTERVAL_MS = 1000;
507
+ /** Total time one withheld batch may spend awaiting workspace writes.
508
+ * At the deadline its unfinished write is named as such, and later
509
+ * originals stay in the audit. This bound applies to withheld spills. */
510
+ const WITHHELD_SPILL_DEADLINE_MS = 30_000;
466
511
  /** Poll pending context-strategy maintenance independently of user activity. */
467
512
  const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
468
513
  /** Bound one pass so a large backlog yields to inference and other agents. */
@@ -513,9 +558,90 @@ function extractGateFields(event) {
513
558
  function truncateReason(reason, max = 160) {
514
559
  return reason.length <= max ? reason : reason.slice(0, max) + '…';
515
560
  }
561
+ /** The most of a thrown value's or a tool error's text that reaches a log line or a server's error answer. */
562
+ const THROWN_TEXT_LIMIT = 500;
563
+ /**
564
+ * What a thrown value says, safely and briefly: an Error's message, or any
565
+ * other value, as a string. Every inspection and coercion of the value is
566
+ * inside the try, so reading it can't throw: a revoked Proxy (whose
567
+ * instanceof throws), a hostile getter, an Error whose message isn't a
568
+ * string, or a value with no string form reads as text or as unreadable.
569
+ * The text is bounded, since a provider or handler error can carry a whole
570
+ * request body.
571
+ */
572
+ function thrownText(error) {
573
+ let text;
574
+ try {
575
+ text = String(error instanceof Error ? error.message : error);
576
+ }
577
+ catch {
578
+ text = '(a value that could not be read)';
579
+ }
580
+ return text.length <= THROWN_TEXT_LIMIT ? text : `${text.slice(0, THROWN_TEXT_LIMIT)}… (${text.length} characters)`;
581
+ }
582
+ /**
583
+ * A failed handler's reason, never throwing: an Error's message, else "the
584
+ * handler failed" and what it threw (nothing, for `undefined`). Telling an
585
+ * Error apart is itself guarded, since instanceof throws on a revoked Proxy.
586
+ */
587
+ function handlerFailureText(error) {
588
+ let isError = false;
589
+ try {
590
+ isError = error instanceof Error;
591
+ }
592
+ catch {
593
+ // Not an Error that can be read; thrownText says so.
594
+ }
595
+ if (isError)
596
+ return thrownText(error);
597
+ return error === undefined ? 'the handler failed' : `the handler failed: ${thrownText(error)}`;
598
+ }
599
+ /**
600
+ * Wrap a server request's responder so the request is answered at most once,
601
+ * and a later failure can tell whether the handler already answered.
602
+ * - An answer counts once the responder returns. One that throws, such as a
603
+ * result the transport can't serialize, leaves the request unanswered, so
604
+ * the listener's failure answer still goes out. This assumes a responder
605
+ * that throws has sent nothing, which holds for the connection's own: they
606
+ * serialize before they write, and their transports' writes don't throw
607
+ * after writing.
608
+ * - A second answer is refused and logged: a handler that answered and then
609
+ * threw isn't answered again, and a handler that answers after the
610
+ * listener answered its failure doesn't send a second response.
611
+ * - Only the methods the responder has are wrapped, so a handler that checks
612
+ * for `respondError` still finds a responder without one.
613
+ */
614
+ function answerOnce(responder, method, serverId) {
615
+ if (!responder)
616
+ return { responder: undefined, answered: () => false };
617
+ let done = false;
618
+ const once = (kind, send) => (...args) => {
619
+ if (done) {
620
+ console.error(`[mcpl] ${method} from ${serverId}: a second answer (${kind}) was refused; the request was already answered`);
621
+ return;
622
+ }
623
+ send(...args);
624
+ done = true;
625
+ };
626
+ const wrapped = { ...responder };
627
+ if (responder.respond)
628
+ wrapped.respond = once('respond', (result) => responder.respond(result));
629
+ if (responder.respondError) {
630
+ wrapped.respondError = once('respondError', (code, message, data) => responder.respondError(code, message, data));
631
+ }
632
+ return { responder: wrapped, answered: () => done };
633
+ }
516
634
  export class AgentFramework {
635
+ toolPresentations = new Map();
636
+ presentationPreviews = new WeakMap();
637
+ getRequestToolPresentation(request) {
638
+ return this.presentationPreviews.get(request) ?? null;
639
+ }
517
640
  store;
518
641
  ownsStore;
642
+ stopPromise = null;
643
+ stopCompleted = false;
644
+ ownedStoreClosed = false;
519
645
  membrane;
520
646
  queue;
521
647
  agents = new Map();
@@ -563,6 +689,8 @@ export class AgentFramework {
563
689
  providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
564
690
  providerHoldHook;
565
691
  providerAdmissionClosed = false;
692
+ /** Invalidates framework turns still assembling when stop begins. */
693
+ lifecycleGeneration = 0;
566
694
  /** Last time we reported stale (busy-requeued) inference requests, per agent. */
567
695
  staleWarnAt = new Map();
568
696
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -596,26 +724,6 @@ export class AgentFramework {
596
724
  * keeps unrelated channels from moving the pin. Cleared at every fresh
597
725
  * turn's start; budget restarts keep it. */
598
726
  turnEngagedChannels = new Map();
599
- /** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
600
- * order (deduped at render). Feeds the `[delivered]` receipt appended at
601
- * logical turn end: explicit sends receipt themselves via their
602
- * tool_result, but auto-routed prose previously vanished into the router
603
- * with no in-window record — the agent could never see where its own
604
- * words landed (2026-07-31 misroute series; antra: minimal receipts).
605
- * Cleared at every fresh turn's start; budget restarts keep it (same
606
- * logical turn, receipt covers the whole turn). */
607
- turnProseDeliveries = new Map();
608
- /** Count of plain-prose segments SUPPRESSED this turn by sticky
609
- * explicit-send silencing. The suppression rule itself is untouched
610
- * (antra, 2026-07-31: visibility is enough) — but it must never be a
611
- * silent black hole: Mythos multiplexed two threads in one round (prose
612
- * for the lane + explicit send for another channel, textbook per the
613
- * routing doc), the silencing ate the prose reply, and his own record
614
- * ("scene delivered to #mythos via plain speech — routing confirmed")
615
- * believed it delivered. The receipt now reports suppression, so the
616
- * author sees the segment's fate one turn later. Cleared each fresh
617
- * turn; budget restarts keep it. */
618
- turnProseSuppressed = new Map();
619
727
  /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
620
728
  * not a reaction or a system marker) into the live stream. Tells
621
729
  * driveStream to clear sticky explicit-send suppression before handling
@@ -629,14 +737,43 @@ export class AgentFramework {
629
737
  // ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
630
738
  /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
631
739
  proseClipboards = new Map();
632
- /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
633
- * Cleared at every non-restart turn start; survives context restarts. */
634
- proseTargetPins = new Map();
635
- /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
636
- proseHybridSuppressed = new Set();
637
- /** Agents whose current turn requested `!` continuation — re-woken when the
638
- * turn completes instead of pausing until the next external event. */
639
- proseContinuations = new Set();
740
+ /**
741
+ * Each agent's routing state for its current turn (TurnRouting): the
742
+ * sticky destination, the turn's destination for a resumed stream,
743
+ * hybrid's hold, a `!` continuation and receipt tallies. A fresh turn starts a new object
744
+ * instead of clearing the old one, and a stream that resumes the turn
745
+ * (resumesLogicalTurn) keeps it. A queued delivery holds the object of the
746
+ * turn that queued it, so prose a turn leaves queued when it ends (a
747
+ * skip_reply ends it before its posts have run) still follows that turn's
748
+ * destination, and its late writes can't reach the next turn.
749
+ */
750
+ turnRouting = new Map();
751
+ /**
752
+ * The latest link of each agent's chain of queued prose deliveries. Every
753
+ * stream queues its own behind it, a fresh turn's included, so the agent's
754
+ * posts go out in the order they were written, across streams and turns.
755
+ */
756
+ proseSpeechChains = new Map();
757
+ /**
758
+ * Each agent's open logical turn (LogicalTurn). It stays open from the
759
+ * fresh start that opens it until the turn settles (settleAgent: it
760
+ * completes, a tool ends it, or it fails or is abandoned), and every fresh
761
+ * start that routes prose while it is open continues it: a framework
762
+ * retry, the start that consumes tool results the non-streaming fallback
763
+ * left (whichever wake the scheduler starts it with), the later compile a
764
+ * provider-capacity hold defers a turn to. A silent start neither
765
+ * continues nor opens one. A stream that ends without settling needs to do
766
+ * nothing to carry its turn. Settling only detaches the turn from future
767
+ * starts: the settling stream keeps its own object, which its remaining
768
+ * deliveries, its receipt and its ` !` check go on using (a completion
769
+ * settles before it awaits its chain and writes its receipt). So every
770
+ * logical end must close inheritance once, before the next fresh start:
771
+ * a turn left open would hand its object, late writes and all, to an
772
+ * unrelated turn. A tool-ended turn settles before the next turn can
773
+ * start. Only the stream that holds the open turn closes it; disposal
774
+ * drops it, and stop() clears every agent's.
775
+ */
776
+ openLogical = new Map();
640
777
  /** Consecutive bounce-wakes per agent; capped so an agent that keeps
641
778
  * emitting unprefixed prose can't wake-loop (notices still append). */
642
779
  proseBounceStreaks = new Map();
@@ -842,10 +979,16 @@ export class AgentFramework {
842
979
  codeExecutionConfig = null;
843
980
  codeExecutionRunners = new Map();
844
981
  scriptToolWaiters = new Map();
845
- /** Agents whose running script hit an endTurn-carrying inner result —
846
- * deferred and applied to the final code_execution result instead of
847
- * cancelling the stream mid-script (which would wedge the turn). */
848
- scriptDeferredEndTurn = new Set();
982
+ /** Per agent, the foreground script now running. An endTurn-carrying inner
983
+ * result marks the script that made the call, and is applied to that
984
+ * script's code_execution result instead of cancelling the stream
985
+ * mid-script (which would wedge the turn). A stopped script's late result
986
+ * marks only that script, never the agent's next one. */
987
+ scriptDeferredEndTurn = new Map();
988
+ /** RFC-007: per agent, the origin of the foreground script its runner is
989
+ * executing, handed to that script's inner calls (scripts run one at a
990
+ * time per runner). Background runners capture theirs in the closure. */
991
+ foregroundScriptOrigins = new Map();
849
992
  /** Background (daemon) scripts: model-authored watchers that outlive their
850
993
  * spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
851
994
  * provenance envelope + payload and requests inference. Keyed by script id. */
@@ -876,6 +1019,16 @@ export class AgentFramework {
876
1019
  /** Namespaced tool name → the server whose tools/list produced it. Prefixes
877
1020
  * can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
878
1021
  mcplToolServers = new Map();
1022
+ /** Servers whose tools/list answered in the latest refresh. */
1023
+ mcplListedServers = new Set();
1024
+ /** Zero-match tool-pattern diagnostics (checkToolPatterns): set at the end
1025
+ * of create(), when the tool universe is first complete. */
1026
+ toolPatternChecksArmed = false;
1027
+ /** Patterns already reported as matching nothing — each is reported once. */
1028
+ toolPatternsReported = new Set();
1029
+ /** Every (prefix → serverId) ever configured. A removed or restarting
1030
+ * server's patterns stay undecided rather than reported mid-restart. */
1031
+ toolPatternKnownPrefixes = new Map();
879
1032
  /** Maps serverId → McplServerConfig for prefix lookup. */
880
1033
  mcplServerConfigs = new Map();
881
1034
  /** Host capabilities advertised during the MCP handshake — stored so servers
@@ -1066,6 +1219,8 @@ export class AgentFramework {
1066
1219
  // Create agents
1067
1220
  for (const agentConfig of config.agents) {
1068
1221
  await framework.createAgent(agentConfig);
1222
+ if (agentConfig.toolPresentation)
1223
+ framework.toolPresentations.set(agentConfig.name, new ToolPresentation(agentConfig.toolPresentation));
1069
1224
  }
1070
1225
  // The subconscious resident (issue #77) registers after the residents so
1071
1226
  // it can never become primary; it is excluded from every broadcast
@@ -1080,6 +1235,17 @@ export class AgentFramework {
1080
1235
  for (const module of config.modules) {
1081
1236
  await framework.addModule(module);
1082
1237
  }
1238
+ for (const [agentName, presentation] of framework.toolPresentations) {
1239
+ const workspace = framework.getWorkspaceModule();
1240
+ if (!workspace)
1241
+ throw new Error('Tool presentation requires workspace');
1242
+ const agent = framework.agents.get(agentName);
1243
+ for (const name of ['workspace--read', 'set_tool_visibility', 'set_tool_description']) {
1244
+ if (!agent.canUseTool(name))
1245
+ throw new Error(`Tool presentation recovery requires ${name}`);
1246
+ }
1247
+ workspace.registerGeneratedTextFile(presentation.config.cataloguePath, () => renderCatalogue(framework.inspectToolPresentation(agentName)), agentName);
1248
+ }
1083
1249
  // Initialize per-channel conversation routing (if configured)
1084
1250
  if (config.conversations) {
1085
1251
  if (!framework.agents.has(config.conversations.templateAgent)) {
@@ -1087,6 +1253,9 @@ export class AgentFramework {
1087
1253
  `is not a configured agent`);
1088
1254
  }
1089
1255
  framework.conversationRouter = new ConversationRouter(config.conversations);
1256
+ // Deprecated feature (agent-framework#235): still wired exactly as
1257
+ // before, but named once per framework instance on stderr.
1258
+ console.warn(`[deprecated] ${CONVERSATION_ROUTING_DEPRECATION_NOTICE}`);
1090
1259
  // Generation counters persist across restarts — reusing generation 1's
1091
1260
  // agent name after a restart would reopen (and re-seed) the previous
1092
1261
  // engagement's Chronicle namespace.
@@ -1122,15 +1291,41 @@ export class AgentFramework {
1122
1291
  framework.pendingRequests.push({
1123
1292
  agentName, reason, source, timestamp: Date.now(),
1124
1293
  // gate-requested wakes carry where/who (EventGate wakeProvenance)
1125
- // as TELEMETRY fields — the host's stamp reads them; the turn's
1126
- // speech locus (channelId / addressed) is deliberately NOT set
1127
- // here, so a batched wake routes exactly as it did before.
1294
+ // as TELEMETRY fields — the host's stamp reads them.
1128
1295
  ...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
1129
1296
  ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
1130
1297
  ...(provenance?.at ? { wakeAt: provenance.at } : {}),
1298
+ // A batch that contains an ADDRESSED message (mention / reply /
1299
+ // DM) routes like the direct path does: the reply goes to that
1300
+ // message's channel. Without it the turn froze on the
1301
+ // process-global most-recent-inbound channel, which an ambient
1302
+ // message elsewhere could retarget between the DM's arrival and
1303
+ // the debounce firing (2026-09-30). Ambient-only batches set no
1304
+ // locus and keep the legacy fallback.
1305
+ //
1306
+ // The wake is broadcast to every agent, conversation forks
1307
+ // included, and a fork speaks only in its home channel: the
1308
+ // route applies to a fork only when it IS that home, and never to
1309
+ // another agent when a fork owns that channel. Otherwise a fork
1310
+ // bound elsewhere would show typing where it isn't replying.
1311
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
1312
+ ? { channelId: provenance.routeChannelId, addressed: true }
1313
+ : {}),
1131
1314
  });
1132
1315
  },
1133
1316
  getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
1317
+ // Push events reach the gate with the adapter's raw channel id; map
1318
+ // them to the registered composite id the same way ingestion does
1319
+ // (handleMcplPushEvent registers that channel on arrival, before any
1320
+ // debounce can fire).
1321
+ resolveRouteChannel: (info) => {
1322
+ if (info.eventType === 'mcpl:channel-incoming')
1323
+ return info.channelId || undefined;
1324
+ if (info.eventType === 'mcpl:push-event') {
1325
+ return framework.derivePushEventChannel(info.metadata)?.channelId;
1326
+ }
1327
+ return undefined;
1328
+ },
1134
1329
  });
1135
1330
  }
1136
1331
  // Liveness watchdog: fail hard if the main thread wedges (opt-in).
@@ -1284,6 +1479,11 @@ export class AgentFramework {
1284
1479
  catch {
1285
1480
  // SIGUSR2 not available on this platform — non-fatal.
1286
1481
  }
1482
+ // Modules, agents, MCPL servers and the tune-out tool are all in place:
1483
+ // the first point at which "matches no tool" can be judged. Later tool
1484
+ // refreshes re-check patterns still undecided.
1485
+ framework.toolPatternChecksArmed = true;
1486
+ framework.checkToolPatterns();
1287
1487
  return framework;
1288
1488
  }
1289
1489
  /**
@@ -1293,8 +1493,13 @@ export class AgentFramework {
1293
1493
  if (this.running) {
1294
1494
  return;
1295
1495
  }
1496
+ this.resetCompletedStop();
1296
1497
  this.running = true;
1297
1498
  this.providerAdmissionClosed = false;
1499
+ // A new lifecycle: a turn compiled before an earlier stop() never drives,
1500
+ // and withheld spills, closed for the generation a stop ended, are open
1501
+ // for this one.
1502
+ this.lifecycleGeneration++;
1298
1503
  this.loopPromise = this.runLoop();
1299
1504
  // Start periodic sync timer (if enabled)
1300
1505
  if (this.syncIntervalMs > 0) {
@@ -1318,9 +1523,41 @@ export class AgentFramework {
1318
1523
  }
1319
1524
  }
1320
1525
  /**
1321
- * Stop the event loop.
1526
+ * Stop the event loop. Failed module/MCPL cleanup can be retried with its
1527
+ * original context; storage stays open until that cleanup succeeds.
1528
+ * @param teardownTimeoutMs Optional module/MCPL wait budget. Omit to wait
1529
+ * until cleanup settles, as required by existing module/child contracts. This
1530
+ * does not bound earlier stream or event-loop quiescence, or cancel cleanup.
1322
1531
  */
1323
- async stop() {
1532
+ stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
1533
+ try {
1534
+ validateShutdownTimeout(teardownTimeoutMs);
1535
+ }
1536
+ catch (error) {
1537
+ return Promise.reject(error);
1538
+ }
1539
+ if (this.stopPromise)
1540
+ return this.stopPromise;
1541
+ const attempt = this.stopOwned(teardownTimeoutMs);
1542
+ this.stopPromise = attempt;
1543
+ void attempt.then(() => { this.stopCompleted = true; }, () => { });
1544
+ // Failed cleanup can be retried while its original store is open. A
1545
+ // terminal storage failure after successful close must remain a failure.
1546
+ void attempt.catch(() => {
1547
+ if (!this.ownedStoreClosed && this.stopPromise === attempt)
1548
+ this.stopPromise = null;
1549
+ });
1550
+ return attempt;
1551
+ }
1552
+ resetCompletedStop() {
1553
+ if (this.ownedStoreClosed)
1554
+ throw new Error('Cannot start a new lifecycle: owned store is closed');
1555
+ if (this.stopCompleted) {
1556
+ this.stopPromise = null;
1557
+ this.stopCompleted = false;
1558
+ }
1559
+ }
1560
+ async stopOwned(teardownTimeoutMs) {
1324
1561
  this.pushCoalescer?.suspend();
1325
1562
  this.flushCoalescingSnapshot();
1326
1563
  this.running = false;
@@ -1328,6 +1565,10 @@ export class AgentFramework {
1328
1565
  // store is still open, rather than leaving them to a reboot replay.
1329
1566
  this.ackDeferredWrites();
1330
1567
  this.providerAdmissionClosed = true;
1568
+ this.lifecycleGeneration++;
1569
+ // The generation this stop ends; withheld spills close for it alone, so a
1570
+ // start() made before this stop finishes reopens them for its own.
1571
+ const stoppingGeneration = this.lifecycleGeneration;
1331
1572
  this.queue.close();
1332
1573
  this.tuneOutCoordinator?.stop();
1333
1574
  // Kill running code_execution scripts before cancelling streams: a
@@ -1353,6 +1594,10 @@ export class AgentFramework {
1353
1594
  this.toolImageLedgers.clear();
1354
1595
  // A stopped host must never hang behind its own cooldown.
1355
1596
  this.cancelProviderAdmission();
1597
+ // An open logical turn doesn't survive a stop: a start after a restart
1598
+ // on this instance opens a new one rather than carrying its ` !` and
1599
+ // receipt tallies across (openLogical).
1600
+ this.openLogical.clear();
1356
1601
  // Cancel all active streams
1357
1602
  for (const agent of this.agents.values()) {
1358
1603
  if (agent.state.status === 'streaming' ||
@@ -1384,20 +1629,46 @@ export class AgentFramework {
1384
1629
  // Dispose EventGate (clear debounce timers)
1385
1630
  this.eventGate?.dispose();
1386
1631
  this.livenessWatchdog?.stop();
1632
+ // A stopping stream's batch settles as withheld, and its stub names where
1633
+ // the original went: let those writes finish while the workspace is up.
1634
+ // Tracked here rather than per agent, since an ephemeral run or a closed
1635
+ // fork leaves the agent map while its annotation can still be running.
1636
+ while (this.guardAnnotations.size > 0)
1637
+ await Promise.all([...this.guardAnnotations]);
1638
+ // An annotation that starts after this point, such as one from a direct
1639
+ // Agent API call stop doesn't cancel, starts no write to the stopping
1640
+ // workspace: its stub names the audit record. A write that was past its
1641
+ // deadline isn't waited for here, but the workspace's own stop waits for
1642
+ // the mount turn it holds, bounded only by teardownTimeoutMs.
1643
+ this.guardSpillsClosedFor = stoppingGeneration;
1387
1644
  // Stop modules and MCPL servers in parallel
1388
- const shutdownPromises = [this.moduleRegistry.stopAll()];
1645
+ const shutdownPromises = [this.moduleRegistry.stopAll(teardownTimeoutMs)];
1389
1646
  if (this.mcplServerRegistry) {
1390
- shutdownPromises.push(this.mcplServerRegistry.closeAll());
1391
- }
1392
- await Promise.all(shutdownPromises);
1647
+ shutdownPromises.push(this.mcplServerRegistry.closeAll(teardownTimeoutMs));
1648
+ }
1649
+ // Timed-out or failed teardown retains its context and may still write.
1650
+ // Sync available state best-effort, but leave its store open for a retry.
1651
+ const shutdownErrors = [];
1652
+ for (const result of await Promise.allSettled(shutdownPromises)) {
1653
+ if (result.status === 'rejected')
1654
+ shutdownErrors.push(result.reason);
1655
+ }
1656
+ const teardownSucceeded = shutdownErrors.length === 0;
1657
+ // Those later annotations write nothing, so they finish at once; let them
1658
+ // record and edit their stubs before the store closes.
1659
+ while (this.guardAnnotations.size > 0)
1660
+ await Promise.all([...this.guardAnnotations]);
1393
1661
  // Streams may queue storage repairs while being cancelled above. Retry
1394
- // after their teardown, while the store is still open, before final sync.
1395
- for (const agent of this.agents.values()) {
1662
+ // after their teardown, while the store is still open, before final sync:
1663
+ // every live agent's guard, and every other guard that isn't idle, whose
1664
+ // ephemeral run or closed fork has left the agent map.
1665
+ for (const [guard, name] of this.guardsWithStorageWork()) {
1396
1666
  try {
1397
- agent.toolResultGuard.flushUnrecorded();
1667
+ guard.flushUnrecorded();
1398
1668
  }
1399
1669
  catch (error) {
1400
- console.error(`[tool-result-guard] agent=${agent.name} could not finish queued storage work at stop:`, error);
1670
+ console.error(`[tool-result-guard] agent=${name} could not finish queued storage work at stop:`, error);
1671
+ shutdownErrors.push(error);
1401
1672
  }
1402
1673
  }
1403
1674
  // Final sync before closing
@@ -1406,10 +1677,24 @@ export class AgentFramework {
1406
1677
  }
1407
1678
  catch (error) {
1408
1679
  console.error('Final sync error:', error);
1680
+ shutdownErrors.push(error);
1409
1681
  }
1410
- if (this.ownsStore) {
1411
- this.store.close();
1682
+ if (this.ownsStore && teardownSucceeded) {
1683
+ try {
1684
+ this.store.close();
1685
+ this.ownedStoreClosed = true;
1686
+ }
1687
+ catch (error) {
1688
+ // Chronicle may consume the handle before close's internal sync
1689
+ // throws. A consumed handle cannot retry the original operation.
1690
+ this.ownedStoreClosed = this.store.isClosed();
1691
+ shutdownErrors.push(error);
1692
+ }
1412
1693
  }
1694
+ if (shutdownErrors.length === 1)
1695
+ throw shutdownErrors[0];
1696
+ if (shutdownErrors.length > 1)
1697
+ throw new AggregateError(shutdownErrors, `Framework shutdown failed: ${shutdownErrors.map(shutdownErrorMessage).join('; ')}`);
1413
1698
  }
1414
1699
  /**
1415
1700
  * Advance queued ContextManager work without requiring a new message.
@@ -1660,6 +1945,19 @@ export class AgentFramework {
1660
1945
  });
1661
1946
  }
1662
1947
  async runQueuedMaintenance() {
1948
+ // A guard whose agent has left the map gets no activation to retry its
1949
+ // queue; retry it here. It leaves busyGuards once nothing of it is left.
1950
+ const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
1951
+ for (const [guard, name] of this.busyGuards) {
1952
+ if (live.has(guard))
1953
+ continue;
1954
+ try {
1955
+ guard.flushUnrecorded();
1956
+ }
1957
+ catch (error) {
1958
+ console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
1959
+ }
1960
+ }
1663
1961
  const queued = [...this.agents.values()].flatMap((agent) => {
1664
1962
  try {
1665
1963
  agent.toolResultGuard.flushUnrecorded();
@@ -1668,7 +1966,7 @@ export class AgentFramework {
1668
1966
  console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
1669
1967
  }
1670
1968
  const cm = agent.getContextManager();
1671
- const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
1969
+ const tools = this.compressionToolsForAgent(agent.name);
1672
1970
  cm.setToolDefinitions(tools);
1673
1971
  if (this.providerGateBlocked(agent.name))
1674
1972
  return [];
@@ -1747,10 +2045,69 @@ export class AgentFramework {
1747
2045
  ? progress
1748
2046
  : undefined;
1749
2047
  }
2048
+ /**
2049
+ * A server's request or notification the host couldn't handle, answered
2050
+ * with a JSON-RPC error when it is a request (a notification has no one to
2051
+ * answer, and is logged), rather than thrown out of the connection's
2052
+ * listener, where it was an unhandled rejection and the server's request
2053
+ * waited for its timeout.
2054
+ * - `refused`: the listener turned it away before its handler ran; the
2055
+ * host is stopping, and none of it was taken. Answered -32000, "the host
2056
+ * is stopping", the code the push coalescer already gives a stopping
2057
+ * host: the server can send it again to the host's next run.
2058
+ * - `failed`: the handler threw, whatever it threw, `undefined` included.
2059
+ * While the host stops (its queue closed), that's answered -32000 too,
2060
+ * with the handler's own message after the stop wording; on a live host
2061
+ * it's a fault, answered -32603 with the handler's message. The handler
2062
+ * may have admitted part of the request first (an earlier message of a
2063
+ * batch), so nothing here claims that none of it was taken.
2064
+ * A request the handler already answered before it threw isn't answered
2065
+ * again: the failure is logged.
2066
+ */
2067
+ refuseServerItem(method, serverId, answer, outcome) {
2068
+ // Optional chaining: prototype-built harnesses leave the queue unset.
2069
+ const stopping = outcome.kind === 'refused' || !!this.queue?.isClosed;
2070
+ const what = outcome.kind === 'refused' ? '' : handlerFailureText(outcome.error);
2071
+ const reason = outcome.kind === 'refused'
2072
+ ? 'the host is stopping'
2073
+ : stopping
2074
+ ? `the host stopped while handling this request (${what})`
2075
+ : what;
2076
+ if (outcome.kind === 'failed' && answer.answered()) {
2077
+ console.error(`[mcpl] ${method} from ${serverId} failed after it was answered: ${reason}`);
2078
+ return;
2079
+ }
2080
+ console.error(outcome.kind === 'refused'
2081
+ ? `[mcpl] ${method} from ${serverId} refused: ${reason}`
2082
+ : `[mcpl] ${method} from ${serverId} failed: ${reason} (part of it may already have been admitted)`);
2083
+ try {
2084
+ answer.responder?.respondError?.(stopping ? -32000 : -32603, reason);
2085
+ }
2086
+ catch {
2087
+ // The connection is going too; there is no one left to tell.
2088
+ }
2089
+ }
1750
2090
  /**
1751
2091
  * Push a process event to the queue.
2092
+ *
2093
+ * After stop() the queue is closed and this throws, so a direct caller
2094
+ * learns the framework is stopped — except for a tool's result. A call
2095
+ * still running at stop() completes later, into a callback with nothing
2096
+ * to catch the throw (an unhandled rejection), and nothing could take its
2097
+ * result anyway: the queue that would carry it to its stream is closed,
2098
+ * stop() is ending the streams, and the tool has already run. That
2099
+ * result is dropped, and the drop is logged.
1752
2100
  */
1753
2101
  pushEvent(event) {
2102
+ if (event.type === 'tool-result' && this.queue?.isClosed) {
2103
+ // What an operator reconciling a dropped result needs (the resident
2104
+ // may run the tool again): which tool, and whether it succeeded.
2105
+ const outcome = event.result.success && !event.result.isError
2106
+ ? 'succeeded'
2107
+ : `failed${event.result.error ? `: ${thrownText(event.result.error)}` : ''}`;
2108
+ console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.toolName ? `${event.toolName}, ` : ''}${event.moduleName}) arrived after stop — dropped; the tool ${outcome}`);
2109
+ return;
2110
+ }
1754
2111
  this.queue.push(event);
1755
2112
  this.emitTrace({ type: 'process:received', processEvent: event });
1756
2113
  }
@@ -1790,6 +2147,7 @@ export class AgentFramework {
1790
2147
  * Add a module at runtime.
1791
2148
  */
1792
2149
  async addModule(module) {
2150
+ this.resetCompletedStop();
1793
2151
  await this.moduleRegistry.addModule(module);
1794
2152
  this.emitTrace({ type: 'module:added', moduleName: module.name });
1795
2153
  }
@@ -1901,7 +2259,15 @@ export class AgentFramework {
1901
2259
  return null;
1902
2260
  const path = `${mount}/refs/${fileName}`;
1903
2261
  const res = await workspace.writeBinary(path, data, mimeType);
1904
- return res.success ? path : null;
2262
+ if (res.success)
2263
+ return path;
2264
+ // Stored, with its disk copy failed: the workspace's tools read it
2265
+ // from the tree, so the reference is fetched to that path.
2266
+ if (res.data?.inChronicle === true) {
2267
+ console.error(`[refs] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${res.error ?? 'unknown error'}`);
2268
+ return path;
2269
+ }
2270
+ return null;
1905
2271
  });
1906
2272
  }
1907
2273
  return this.referenceFetcherInstance;
@@ -2071,12 +2437,68 @@ export class AgentFramework {
2071
2437
  reset: (name) => set(name, undefined),
2072
2438
  };
2073
2439
  }
2074
- restoreToolResultGuardSetting(agent) {
2440
+ /** Every guard's annotation still running, for stop() (agent-framework #277). */
2441
+ guardAnnotations = new Set();
2442
+ /** Each guard that isn't idle, by agent name, as the guard reports it. An
2443
+ * ephemeral run or a closed fork leaves the agent map with an annotation
2444
+ * maybe still running, or a stub edit, acceptance edit or audit write
2445
+ * queued, which maintenance and stop then retry (agent-framework #277). A
2446
+ * guard is here from the moment it has work left until the moment it has
2447
+ * none, however that comes about. */
2448
+ busyGuards = new Map();
2449
+ /** Withheld originals' writes still under way past their deadline: while
2450
+ * any is, the workspace isn't answering and no other withheld original's
2451
+ * write is started. Several can be, when writes of different agents'
2452
+ * batches stalled together. */
2453
+ stalledSpills = new Set();
2454
+ /** Withheld spills skipped since the current stall began. */
2455
+ stallSkips = 0;
2456
+ /** See WITHHELD_SPILL_DEADLINE_MS; an instance field so tests can shorten it. */
2457
+ withheldSpillDeadlineMs = WITHHELD_SPILL_DEADLINE_MS;
2458
+ /** The lifecycle generation whose stop() has let the running annotations
2459
+ * finish: a withheld original is then no longer written to a workspace
2460
+ * that is stopping, and its stub names the audit record. A start() after
2461
+ * that stop() has finished has a generation of its own, so the spill gate
2462
+ * is open again for an instance kept with a store it doesn't own and that
2463
+ * is still open (start() doesn't restore stopped modules or MCPL
2464
+ * connections, and refuses an owned store that stop() closed). A start()
2465
+ * made while a stop() is still draining isn't a supported order. */
2466
+ guardSpillsClosedFor = null;
2467
+ /** A new agent's guard: its persisted setting, the tool-results spill its
2468
+ * withheld originals are written to, and the set stop() waits on
2469
+ * (agent-framework #277). */
2470
+ adoptToolResultGuard(agent) {
2075
2471
  const enabled = this.store.getStateJson(FRAMEWORK_STATE_ID)?.toolResultGuards?.[agent.name];
2076
2472
  if (enabled !== undefined && typeof enabled !== 'boolean') {
2077
2473
  throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
2078
2474
  }
2079
2475
  agent.toolResultGuard.setOverride(enabled);
2476
+ const guard = agent.toolResultGuard;
2477
+ guard.setHost({
2478
+ spill: (label, text, branch, deadline) => this.spillWithheld(label, text, branch, deadline),
2479
+ deadline: () => Date.now() + this.withheldSpillDeadlineMs,
2480
+ track: (annotation) => {
2481
+ this.guardAnnotations.add(annotation);
2482
+ void annotation.finally(() => this.guardAnnotations.delete(annotation));
2483
+ },
2484
+ idle: (idle) => {
2485
+ if (idle)
2486
+ this.busyGuards.delete(guard);
2487
+ else
2488
+ this.busyGuards.set(guard, agent.name);
2489
+ },
2490
+ });
2491
+ }
2492
+ /** Each guard that may have queued storage work, once, by agent name: the
2493
+ * live agents' and every other that isn't idle. */
2494
+ guardsWithStorageWork() {
2495
+ const guards = new Map();
2496
+ for (const agent of this.agents.values())
2497
+ guards.set(agent.toolResultGuard, agent.name);
2498
+ for (const [guard, name] of this.busyGuards)
2499
+ if (!guards.has(guard))
2500
+ guards.set(guard, name);
2501
+ return guards;
2080
2502
  }
2081
2503
  buildThinkTool(policy) {
2082
2504
  return {
@@ -2146,7 +2568,7 @@ export class AgentFramework {
2146
2568
  : t);
2147
2569
  return [...SUBCONSCIOUS_TOOLS, ...basics];
2148
2570
  }
2149
- return this.getAllTools().map((tool) => {
2571
+ return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName).config.cataloguePath) : [])].map((tool) => {
2150
2572
  if (tool.name === 'think') {
2151
2573
  return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
2152
2574
  ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy);
@@ -2154,6 +2576,54 @@ export class AgentFramework {
2154
2576
  return tool;
2155
2577
  });
2156
2578
  }
2579
+ /** Available is independent of presentation visibility; execution callers keep this surface. */
2580
+ availableToolsForPresentation(agentName, snapshot) {
2581
+ const agent = this.agents.get(agentName);
2582
+ if (!agent)
2583
+ throw new Error(`Unknown agent: ${agentName}`);
2584
+ const tools = this.getToolsForAgent(agentName, snapshot).filter(t => agent.canUseTool(t.name));
2585
+ if (agent.proseRouting === 'explicit' && agent.canUseTool(PROSE_HELP_TOOL.name))
2586
+ tools.push(PROSE_HELP_TOOL);
2587
+ return tools;
2588
+ }
2589
+ inspectToolPresentation(agentName, snapshot) {
2590
+ const presentation = this.toolPresentations.get(agentName);
2591
+ if (!presentation)
2592
+ return null;
2593
+ const tools = this.availableToolsForPresentation(agentName, snapshot);
2594
+ const sources = new Map();
2595
+ for (const tool of this.moduleRegistry.getAllTools()) {
2596
+ sources.set(tool.name, `Module: ${tool.name.split('--')[0]}`);
2597
+ }
2598
+ // Use registered server prefixes, not a guess from a conventional mcpl-- name.
2599
+ for (const tool of tools) {
2600
+ for (const config of this.mcplServerConfigs.values()) {
2601
+ if (tool.name.startsWith((config.toolPrefix ?? `mcpl--${config.id}`) + '--')) {
2602
+ sources.set(tool.name, `MCPL server: ${config.id}`);
2603
+ break;
2604
+ }
2605
+ }
2606
+ if (!sources.has(tool.name))
2607
+ sources.set(tool.name, 'Framework');
2608
+ }
2609
+ return presentation.resolve(tools, sources);
2610
+ }
2611
+ advertisedToolsForAgent(agentName, snapshot) {
2612
+ return this.inspectToolPresentation(agentName, snapshot)?.advertised
2613
+ ?? this.availableToolsForPresentation(agentName, snapshot);
2614
+ }
2615
+ /** Compression can revisit calls to hidden tools; never apply visibility here. */
2616
+ compressionToolsForAgent(agentName, snapshot) {
2617
+ return this.inspectToolPresentation(agentName, snapshot)?.available
2618
+ ?? this.availableToolsForPresentation(agentName, snapshot);
2619
+ }
2620
+ editToolPresentation(agentName, call) {
2621
+ const presentation = this.toolPresentations.get(agentName);
2622
+ const agent = this.agents.get(agentName);
2623
+ if (!presentation || !agent?.canUseTool(call.name))
2624
+ return { success: false, isError: true, error: 'Tool presentation is not enabled for this caller' };
2625
+ return presentation.edit(call.name, call.input, this.availableToolsForPresentation(agentName));
2626
+ }
2157
2627
  /**
2158
2628
  * The tools one agent is shown at inference: its surface (the subconscious
2159
2629
  * has its own), less what its permissions deny, plus — for explicit-mode
@@ -2162,10 +2632,7 @@ export class AgentFramework {
2162
2632
  * listing both use this, so the listing cannot drift from the model's view.
2163
2633
  */
2164
2634
  agentToolSurface(agent, snapshot) {
2165
- const tools = this.getToolsForAgent(agent.name, snapshot).filter((t) => agent.canUseTool(t.name));
2166
- if (agent.proseRouting === 'explicit')
2167
- tools.push(PROSE_HELP_TOOL);
2168
- return tools;
2635
+ return this.advertisedToolsForAgent(agent.name, snapshot);
2169
2636
  }
2170
2637
  getAgentRuntimeSettings(agentName) {
2171
2638
  const agent = this.agents.get(agentName);
@@ -2926,7 +3393,13 @@ export class AgentFramework {
2926
3393
  if (!agent) {
2927
3394
  throw new Error(`Agent not found: ${agentName}`);
2928
3395
  }
2929
- const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
3396
+ const presentation = this.inspectToolPresentation(agentName);
3397
+ const tools = presentation?.advertised ?? this.availableToolsForPresentation(agentName);
3398
+ const capture = (request) => {
3399
+ if (presentation)
3400
+ this.presentationPreviews.set(request, presentation);
3401
+ return request;
3402
+ };
2930
3403
  // An explicit budget compiles against a HYPOTHETICAL window instead of the
2931
3404
  // agent's live one. That also suppresses transition-settling in
2932
3405
  // compileWithInjections (which only settles when no budget is passed), so
@@ -2934,7 +3407,7 @@ export class AgentFramework {
2934
3407
  // Default: no dynamic injection gathering → fully transparent (no
2935
3408
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
2936
3409
  if (!opts?.injections) {
2937
- return agent.buildActivationRequest(tools, undefined, opts?.budget);
3410
+ return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools));
2938
3411
  }
2939
3412
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
2940
3413
  // NOT transparent — see the doc comment above.
@@ -2964,7 +3437,7 @@ export class AgentFramework {
2964
3437
  console.error('beforeInference hook error (preview):', error);
2965
3438
  }
2966
3439
  }
2967
- return agent.buildActivationRequest(tools, injections, opts?.budget);
3440
+ return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools));
2968
3441
  }
2969
3442
  /**
2970
3443
  * Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
@@ -3341,7 +3814,7 @@ export class AgentFramework {
3341
3814
  debugLogContext: !!process.env.DEBUG_CONTEXT,
3342
3815
  });
3343
3816
  const agent = new Agent(config, contextManager, this.membrane);
3344
- this.restoreToolResultGuardSetting(agent);
3817
+ this.adoptToolResultGuard(agent);
3345
3818
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
3346
3819
  this.ephemeralCandidates.set(agent, contextManager);
3347
3820
  const cleanup = () => {
@@ -3393,10 +3866,20 @@ export class AgentFramework {
3393
3866
  run.toolCallsCount += count;
3394
3867
  run.lastActivity = Date.now();
3395
3868
  }
3396
- /** Settle an active ephemeral run (no-op for resident agents). The tool-call
3397
- * count comes from the run's own counter — the single source of truth,
3398
- * which unlike a per-stream local survives budget restarts. */
3399
- settleAgent(agentName, outcome) {
3869
+ /** A logical turn's end, for every agent: it closes the agent's open
3870
+ * logical turn when it is the settling stream's own (openLogical), and
3871
+ * settles an active ephemeral run. The run's tool-call count comes from
3872
+ * its own counter — the single source of truth, which unlike a
3873
+ * per-stream local survives budget restarts. */
3874
+ settleAgent(agentName, outcome,
3875
+ /** The settling stream's logical turn, when that stream still owns the
3876
+ * agent: it ends here, so the agent's next fresh start opens a new one.
3877
+ * A settle from a stream a newer one replaced closes nothing. */
3878
+ closes) {
3879
+ // This identity check is the only guard against a stale settle closing
3880
+ // a newer turn (no test reaches one); keep it when editing call sites.
3881
+ if (closes && this.openLogical.get(agentName) === closes)
3882
+ this.openLogical.delete(agentName);
3400
3883
  const run = this.ephemeralRuns.get(agentName);
3401
3884
  if (!run)
3402
3885
  return;
@@ -3525,6 +4008,9 @@ export class AgentFramework {
3525
4008
  // generation mismatch and leave any future owner untouched.
3526
4009
  this.activeStreams.delete(agent.name);
3527
4010
  this.pendingAssistantBlocks.delete(agent.name);
4011
+ this.turnRouting.delete(agent.name);
4012
+ this.proseSpeechChains.delete(agent.name);
4013
+ this.openLogical.delete(agent.name);
3528
4014
  // The late stream finalizer intentionally refuses name-keyed cleanup once
3529
4015
  // deregistered, so disposal must release EventGate liveness itself.
3530
4016
  this.eventGate?.onInferenceEnded(agent.name);
@@ -5361,7 +5847,7 @@ export class AgentFramework {
5361
5847
  viewFilter: (message) => !message.metadata?.tuneOut,
5362
5848
  });
5363
5849
  const agent = new Agent(config, contextManager, this.membrane);
5364
- this.restoreToolResultGuardSetting(agent);
5850
+ this.adoptToolResultGuard(agent);
5365
5851
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
5366
5852
  this.sharedSlotAgents.add(agent);
5367
5853
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
@@ -5431,7 +5917,7 @@ export class AgentFramework {
5431
5917
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
5432
5918
  };
5433
5919
  const agent = new Agent(agentConfig, contextManager, this.membrane);
5434
- this.restoreToolResultGuardSetting(agent);
5920
+ this.adoptToolResultGuard(agent);
5435
5921
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
5436
5922
  this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
5437
5923
  this.agents.set(name, agent);
@@ -5496,6 +5982,10 @@ export class AgentFramework {
5496
5982
  const scriptWaiter = this.scriptToolWaiters.get(event.callId);
5497
5983
  if (scriptWaiter) {
5498
5984
  this.scriptToolWaiters.delete(event.callId);
5985
+ // RFC-007: the inner call's terminal. Also reached when the script
5986
+ // was killed while the call ran: the result reaches no one, but the
5987
+ // call did finish.
5988
+ this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
5499
5989
  scriptWaiter(event.result);
5500
5990
  return;
5501
5991
  }
@@ -5522,6 +6012,38 @@ export class AgentFramework {
5522
6012
  result: event.result,
5523
6013
  });
5524
6014
  }
6015
+ // The round's last pending result: let modules catch up with what the
6016
+ // batch's tools did (the workspace scans `on-agent-action` mounts) before
6017
+ // anything continues the turn. Bounded and fail-open; the agent may be
6018
+ // reset or cancelled meanwhile, so its state is checked again after.
6019
+ if (agent && agent.state.status === 'waiting_for_tools'
6020
+ && agent.state.pending.size === 1 && agent.state.pending.has(event.callId)) {
6021
+ // The call this result answers, as this round holds it: a new round
6022
+ // that reuses the call's id holds a new entry for it.
6023
+ const owed = agent.state.pending.get(event.callId);
6024
+ const failures = await this.moduleRegistry.notifyToolBatchComplete(event.agentName);
6025
+ for (const failure of failures) {
6026
+ this.emitTrace({ type: 'module:batch_hook_failed', agentName: event.agentName, module: failure.module, error: failure.error });
6027
+ }
6028
+ // The turn may have been reset, cancelled or replaced while the hook
6029
+ // ran, even back to waiting on a new batch, whose calls may reuse this
6030
+ // one's id: this result is still owed only if this agent still waits
6031
+ // on this round's call.
6032
+ const after = agent.state;
6033
+ if (this.agents.get(event.agentName) !== agent
6034
+ || after.status !== 'waiting_for_tools' || after.pending.get(event.callId) !== owed) {
6035
+ console.warn(`[framework] Dropping tool-result for agent '${event.agentName}' (callId=${event.callId}): ` +
6036
+ `its turn changed while modules caught up with the batch (status '${after.status}').`);
6037
+ this.emitTrace({
6038
+ type: 'tool:result_dropped',
6039
+ agentName: event.agentName,
6040
+ callId: event.callId,
6041
+ agentStatus: agent.state.status,
6042
+ result: event.result,
6043
+ });
6044
+ return;
6045
+ }
6046
+ }
5525
6047
  if (agent && agent.state.status === 'waiting_for_tools') {
5526
6048
  agent.provideToolResult(event.callId, event.result);
5527
6049
  // Check if agent is now ready (state may have changed after provideToolResult)
@@ -5530,8 +6052,9 @@ export class AgentFramework {
5530
6052
  if (currentState.status === 'ready') {
5531
6053
  // Flush pending assistant blocks (tool_use + preamble text) to context
5532
6054
  const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
6055
+ const turnRowMetadata = this.silentTurnRowMetadata(agent.name);
5533
6056
  if (pendingBlocks) {
5534
- agent.addAssistantResponse(pendingBlocks);
6057
+ agent.addAssistantResponse(pendingBlocks, turnRowMetadata);
5535
6058
  this.pendingAssistantBlocks.delete(agent.name);
5536
6059
  }
5537
6060
  // Effective inline cap: agent_settings override → durable config →
@@ -5545,7 +6068,7 @@ export class AgentFramework {
5545
6068
  // divergence breaks the compile prefix).
5546
6069
  const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
5547
6070
  const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
5548
- agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
6071
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata);
5549
6072
  // Flush any messages that were deferred while this turn was in
5550
6073
  // flight. Route to the PRIMARY agent — deferred messages are
5551
6074
  // framework-level (module messages, push events, subagent return
@@ -5730,7 +6253,7 @@ export class AgentFramework {
5730
6253
  // agent must be wakeable the moment it goes idle. (Idempotent;
5731
6254
  // driveStream's finally is the backstop.)
5732
6255
  this.eventGate?.onInferenceEnded(agent.name);
5733
- this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
6256
+ this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)?.logical);
5734
6257
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
5735
6258
  }
5736
6259
  else if (overBudget || overPhysical) {
@@ -5772,6 +6295,7 @@ export class AgentFramework {
5772
6295
  timestamp: Date.now(),
5773
6296
  suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
5774
6297
  ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
6298
+ silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
5775
6299
  });
5776
6300
  }
5777
6301
  else if (currentState.stream) {
@@ -5788,7 +6312,8 @@ export class AgentFramework {
5788
6312
  });
5789
6313
  }
5790
6314
  else {
5791
- // Non-streaming fallback: schedule re-inference
6315
+ // Non-streaming fallback: schedule re-inference, which continues
6316
+ // this stream's logical turn (openLogical).
5792
6317
  this.pendingRequests.push({
5793
6318
  agentName: agent.name,
5794
6319
  reason: 'tool_results_ready',
@@ -5910,6 +6435,16 @@ export class AgentFramework {
5910
6435
  * This replaces the old MCPLModule.onProcess() message conversion.
5911
6436
  */
5912
6437
  async handleMcplChannelIncoming(event) {
6438
+ // Same backstop as the push lane: no row, no wake, no fork spawned for a
6439
+ // message with nothing visible in it.
6440
+ if (isVisiblyEmptyContent(event.content)) {
6441
+ console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
6442
+ this.emitTrace({
6443
+ type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
6444
+ channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
6445
+ });
6446
+ return undefined;
6447
+ }
5913
6448
  const metadata = {
5914
6449
  ...event.metadata,
5915
6450
  ...(event.eventId ? { eventId: event.eventId } : {}),
@@ -6154,7 +6689,10 @@ export class AgentFramework {
6154
6689
  // seeding again would stack another template copy on top of it.
6155
6690
  const { messages: existing } = await contextManager.compile();
6156
6691
  if (existing.length === 0) {
6157
- const { messages: compiled } = await template.getContextManager().compile();
6692
+ // The copy is the fork's own, and the template's annotation edits only
6693
+ // the template's message: seed only once each of the template's
6694
+ // annotations has finished.
6695
+ const { messages: compiled } = await template.toolResultGuard.afterAnnotations(() => template.getContextManager().compile());
6158
6696
  for (const msg of compiled) {
6159
6697
  const participant = msg.participant === template.name ? name : msg.participant;
6160
6698
  contextManager.addMessage(participant, msg.content);
@@ -6162,7 +6700,7 @@ export class AgentFramework {
6162
6700
  }
6163
6701
  const config = { ...templateConfig, name, strategy: undefined };
6164
6702
  const agent = new Agent(config, contextManager, this.membrane);
6165
- this.restoreToolResultGuardSetting(agent);
6703
+ this.adoptToolResultGuard(agent);
6166
6704
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
6167
6705
  this.agents.set(name, agent);
6168
6706
  this.agentConfigs.set(name, config);
@@ -6198,6 +6736,9 @@ export class AgentFramework {
6198
6736
  const channelId = this.conversationAgentHomes.get(agentName);
6199
6737
  const agent = this.agents.get(agentName);
6200
6738
  this.agents.delete(agentName);
6739
+ this.turnRouting.delete(agentName);
6740
+ this.proseSpeechChains.delete(agentName);
6741
+ this.openLogical.delete(agentName);
6201
6742
  this.toolImageLedgers.delete(agentName);
6202
6743
  this.agentConfigs.delete(agentName);
6203
6744
  this.conversationAgentHomes.delete(agentName);
@@ -6511,7 +7052,7 @@ export class AgentFramework {
6511
7052
  }
6512
7053
  if (typeof message.timestamp !== 'string')
6513
7054
  throw new CoalesceError('timestamp', 'timestamp is required');
6514
- validateCoalescedContent(message.content);
7055
+ validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true });
6515
7056
  const c = message.coalesce;
6516
7057
  const result = await this.pushCoalescer.accept({
6517
7058
  serverId,
@@ -6540,7 +7081,11 @@ export class AgentFramework {
6540
7081
  throw new CoalesceError('eventId', 'eventId is required');
6541
7082
  if (typeof params.timestamp !== 'string')
6542
7083
  throw new CoalesceError('timestamp', 'timestamp is required');
6543
- validateCoalescedContent(params.payload?.content);
7084
+ validateCoalescedContent(params.payload?.content, undefined, {
7085
+ allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
7086
+ serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
7087
+ }),
7088
+ });
6544
7089
  const c = params.coalesce;
6545
7090
  if (c.channelId)
6546
7091
  this.assertChannelScopedPush(serverId, c.channelId);
@@ -6720,6 +7265,7 @@ export class AgentFramework {
6720
7265
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
6721
7266
  addressed: isAddressedMessage(occ.tags, origin),
6722
7267
  coalescingSubject: subject,
7268
+ coalescingBatch: true,
6723
7269
  });
6724
7270
  }
6725
7271
  }
@@ -6848,6 +7394,16 @@ export class AgentFramework {
6848
7394
  * Convert an MCPL push event to a context message.
6849
7395
  */
6850
7396
  handleMcplPushEvent(event) {
7397
+ // Backstop for the MCPL boundary's empty-content rejection, covering
7398
+ // events that never crossed it (module-emitted, coalescer deliveries):
7399
+ // content with nothing visible stores no row and queues no wake — the
7400
+ // model would wake to `[Continue]` or an older message re-presented as
7401
+ // the newest, a wake with no visible cause.
7402
+ if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
7403
+ console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
7404
+ this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
7405
+ return undefined;
7406
+ }
6851
7407
  const triggerChannel = this.derivePushEventChannel(event.origin);
6852
7408
  if (triggerChannel && this.channelRegistry) {
6853
7409
  this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label, triggerChannel.metadata);
@@ -6893,11 +7449,7 @@ export class AgentFramework {
6893
7449
  // Accept the no-message path only for the heartbeat feature's own exact
6894
7450
  // marker with an empty payload — arbitrary MCPL servers cannot hide
6895
7451
  // content merely by setting `origin.silent`.
6896
- const silentHeartbeat = event.serverId === 'heartbeat' &&
6897
- event.featureSet === 'heartbeat' &&
6898
- event.origin?.source === 'heartbeat' &&
6899
- event.origin?.silent === true &&
6900
- content.length === 0;
7452
+ const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
6901
7453
  const placement = { agent: '' };
6902
7454
  if (!silentHeartbeat) {
6903
7455
  const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
@@ -6925,6 +7477,7 @@ export class AgentFramework {
6925
7477
  suppressProse: true,
6926
7478
  ephemeralSystemPrompt: '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
6927
7479
  'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
7480
+ silentHeartbeat: { eventId: event.eventId, serverId: event.serverId },
6928
7481
  } : {}),
6929
7482
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6930
7483
  });
@@ -6932,6 +7485,22 @@ export class AgentFramework {
6932
7485
  }
6933
7486
  return placement.agent ? placement : undefined;
6934
7487
  }
7488
+ /**
7489
+ * Whether a batched gate wake's addressed route may become `agentName`'s
7490
+ * speech locus. The gate broadcasts to every agent, conversation forks
7491
+ * included. A fork speaks only in its home channel, and a channel bound to
7492
+ * a fork is that fork's conversation, so no other agent takes it.
7493
+ */
7494
+ mayTakeGateRoute(agentName, routeChannelId) {
7495
+ const home = this.conversationAgentHomes.get(agentName);
7496
+ if (home !== undefined)
7497
+ return home === routeChannelId;
7498
+ for (const boundChannel of this.conversationAgentHomes.values()) {
7499
+ if (boundChannel === routeChannelId)
7500
+ return false;
7501
+ }
7502
+ return true;
7503
+ }
6935
7504
  /**
6936
7505
  * Derive the MCPL composite channel id (the outbound routing locus) for a
6937
7506
  * push event from its server-defined `origin`, if it carries one.
@@ -7180,21 +7749,34 @@ export class AgentFramework {
7180
7749
  // and counterparty must come from the same message, or a batch of
7181
7750
  // "ambient from A, then addressed from B" would report B's channel
7182
7751
  // with A's author (review finding on the provenance change).
7752
+ //
7753
+ // "Most recent" is by the EVENT's time (wakeAt, else the request's own
7754
+ // timestamp), not queue order. A gate wake is queued when its debounce
7755
+ // flushes, which can be long after its event: an addressed message
7756
+ // held in the gate while the agent was busy lands BEHIND a newer
7757
+ // addressed message's direct wake, and queue order would hand the turn
7758
+ // to the older one. Ties keep the later-queued request, as before.
7759
+ const eventAt = (r) => r.wakeAt ?? r.timestamp;
7183
7760
  let ambientReq;
7184
7761
  let addressedReq;
7185
7762
  for (const r of requests) {
7186
7763
  if (!r.channelId)
7187
7764
  continue;
7188
- ambientReq = r;
7189
- if (r.addressed)
7765
+ if (!ambientReq || eventAt(r) >= eventAt(ambientReq))
7766
+ ambientReq = r;
7767
+ if (r.addressed && (!addressedReq || eventAt(r) >= eventAt(addressedReq)))
7190
7768
  addressedReq = r;
7191
7769
  }
7192
7770
  const channelReq = addressedReq ?? ambientReq;
7193
- // Telemetry provenance (who/where woke the agent): from the routing
7194
- // winner when it carries one; else from the most recent request that
7195
- // does (gate wakes name a counterparty without setting a locus),
7196
- // ordered by the event's own time (wakeAt) rather than flush order.
7197
- let provReq = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined;
7771
+ // Telemetry provenance (who/where woke the agent). An ADDRESSED winner
7772
+ // is its own provenance, even when it names no author (a push event
7773
+ // can route without an author id): borrowing another request's author
7774
+ // would credit the turn to someone who did not address the agent.
7775
+ // Otherwise from the routing winner when it carries one; else from the
7776
+ // most recent request that does (ambient gate wakes name a counterparty
7777
+ // without setting a locus), ordered by the event's own time.
7778
+ let provReq = addressedReq
7779
+ ?? (channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined);
7198
7780
  if (!provReq) {
7199
7781
  for (const r of requests) {
7200
7782
  if (!r.counterparty && !r.wakeChannelId)
@@ -7206,10 +7788,19 @@ export class AgentFramework {
7206
7788
  // A silent tick never suppresses a genuine coalesced wake. If any
7207
7789
  // ordinary request shares this batch, the ordinary turn semantics win.
7208
7790
  const silentOnly = requests.every((r) => r.suppressProse === true);
7791
+ // RFC-006 §5: when every cause is a deferred batch, the turn's content
7792
+ // exists only once assembly renders it. Recorded for the whole batch
7793
+ // (not inherited from requests[0]), so a requeued trigger stays honest.
7794
+ const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
7209
7795
  await this.startAgentStream(agent, {
7210
7796
  ...trigger,
7797
+ coalescingBatch: batchOnly || undefined,
7798
+ coalescingBatchSubjects: batchOnly
7799
+ ? [...new Set(requests.flatMap((r) => r.coalescingBatchSubjects ?? [r.coalescingSubject]))]
7800
+ : undefined,
7211
7801
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
7212
7802
  ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
7803
+ silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
7213
7804
  channelId: channelReq?.channelId,
7214
7805
  addressed: addressedReq !== undefined,
7215
7806
  // A context-budget restart continues the same logical turn: it keeps
@@ -7222,22 +7813,33 @@ export class AgentFramework {
7222
7813
  });
7223
7814
  }
7224
7815
  }
7225
- /** Record prose segments suppressed by explicit-send silencing. */
7226
- recordProseSuppression(agentName, count) {
7816
+ /**
7817
+ * Metadata for rows the agent's current turn stores: a silent heartbeat
7818
+ * tick stamps each of them (assistant responses, tool_result rows) so they
7819
+ * stay identifiable after the turn — request builds key the tick separator
7820
+ * on it, and a later retention policy can find or collapse them. The stamp
7821
+ * names the agent: residents share one message slot and a broadcast tick
7822
+ * reaches each of them with the same eventId. Undefined for ordinary
7823
+ * turns, whose rows are stored exactly as before.
7824
+ */
7825
+ silentTurnRowMetadata(agentName) {
7826
+ const tick = this.activeTurnTriggers.get(agentName)?.silentHeartbeat;
7827
+ const stamp = tick
7828
+ ? { eventId: tick.eventId, serverId: tick.serverId, agentName }
7829
+ : undefined;
7830
+ return stamp ? { silentHeartbeat: stamp } : undefined;
7831
+ }
7832
+ /** Record plain-prose segments this turn suppressed. */
7833
+ recordProseSuppression(routing, count) {
7227
7834
  if (count <= 0)
7228
7835
  return;
7229
- this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
7836
+ routing.logical.suppressed += count;
7230
7837
  }
7231
7838
  /** Record a successful plain-prose delivery for this turn's receipt. */
7232
- recordProseDelivery(agentName, outcome) {
7839
+ recordProseDelivery(routing, outcome) {
7233
7840
  if (!outcome?.delivered)
7234
7841
  return;
7235
- let list = this.turnProseDeliveries.get(agentName);
7236
- if (!list) {
7237
- list = [];
7238
- this.turnProseDeliveries.set(agentName, list);
7239
- }
7240
- list.push(outcome.channelId);
7842
+ routing.logical.deliveries.push(outcome.channelId);
7241
7843
  }
7242
7844
  /**
7243
7845
  * Append the turn's `[delivered]` receipt: one compact system message
@@ -7252,13 +7854,13 @@ export class AgentFramework {
7252
7854
  * delivered nothing. Failures are already marked separately
7253
7855
  * ([discord-send-failed]); this is the success half.
7254
7856
  */
7255
- appendProseDeliveryReceipt(agent) {
7256
- const list = this.turnProseDeliveries.get(agent.name);
7257
- const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
7258
- if ((!list || list.length === 0) && suppressed === 0)
7857
+ appendProseDeliveryReceipt(agent, routing) {
7858
+ const list = routing.logical.deliveries;
7859
+ const suppressed = routing.logical.suppressed;
7860
+ if (list.length === 0 && suppressed === 0)
7259
7861
  return;
7260
- this.turnProseDeliveries.delete(agent.name);
7261
- this.turnProseSuppressed.delete(agent.name);
7862
+ routing.logical.deliveries = [];
7863
+ routing.logical.suppressed = 0;
7262
7864
  const seen = new Set();
7263
7865
  const shown = [];
7264
7866
  for (const id of list ?? []) {
@@ -7367,6 +7969,46 @@ export class AgentFramework {
7367
7969
  console.error('recordChannelAutoOpenNotice failed:', err);
7368
7970
  }
7369
7971
  }
7972
+ /**
7973
+ * A `!` continuation the turn asked for (`>>#x !`, `>>>#x !`): start another
7974
+ * turn now instead of pausing until the next external event. It reads the
7975
+ * logical turn of the stream that asked (shared with a retry or a
7976
+ * re-inference that continues it), once its deliveries have run, when the
7977
+ * turn completes or a tool ends it. A stream that ends in an error asks for
7978
+ * none itself, but a framework retry that follows it carries the request;
7979
+ * a turn abandoned by a quiesce asks for none.
7980
+ */
7981
+ requestProseContinuation(agent, routing) {
7982
+ // The flag may be shared with an earlier stream of the same logical
7983
+ // turn (LogicalTurn); its deliveries are on the agent's chain ahead of
7984
+ // this stream's, so by the time this check runs, they have run.
7985
+ if (!routing.logical.continuation)
7986
+ return;
7987
+ routing.logical.continuation = false;
7988
+ // An explicit turn's check can run after its stream has returned. A
7989
+ // stopped framework, or an agent no longer registered under its name (a
7990
+ // disposed fork or ephemeral run), takes no new turn from it.
7991
+ if (this.providerAdmissionClosed || this.agents.get(agent.name) !== agent) {
7992
+ console.error(`[prose] ${agent.name}: '!' continuation dropped — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
7993
+ return;
7994
+ }
7995
+ console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
7996
+ this.pendingRequests.push({
7997
+ agentName: agent.name,
7998
+ reason: 'prose-continuation',
7999
+ source: 'framework',
8000
+ timestamp: Date.now(),
8001
+ });
8002
+ }
8003
+ /** The agent's routing state for its current turn, made when it has none. */
8004
+ routingOf(agentName) {
8005
+ let routing = this.turnRouting.get(agentName);
8006
+ if (!routing) {
8007
+ routing = freshTurnRouting();
8008
+ this.turnRouting.set(agentName, routing);
8009
+ }
8010
+ return routing;
8011
+ }
7370
8012
  /**
7371
8013
  * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
7372
8014
  * Every prose segment of an `explicit`-mode agent flows through here:
@@ -7375,36 +8017,58 @@ export class AgentFramework {
7375
8017
  * nothing is ever sent to a destination the model did not name (directly
7376
8018
  * or via the turn's sticky target).
7377
8019
  */
7378
- async deliverProse(agent, rawText) {
7379
- // Multi-envelope: a single prose block may address SEVERAL destinations —
7380
- // every line beginning with `>>` opens a new envelope, routed
7381
- // independently (first live use: Tilde 2026-07-24, one block carrying a
7382
- // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
7383
- // parser delivered the aside literally into the first destination —
7384
- // exactly the leak class this mode exists to kill). Text before the
7385
- // first `>>` is its own unprefixed envelope (sticky/bounce as usual).
7386
- const lines = rawText.split('\n');
7387
- const envelopes = [];
7388
- let current = [];
7389
- for (const line of lines) {
7390
- // Only a REAL prefix line opens an envelope: `>>` immediately followed
7391
- // by a target token. A quoted `>> arrow` (space after) stays body text.
7392
- if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
7393
- envelopes.push(current.join('\n'));
7394
- current = [];
8020
+ async deliverProse(agent, rawText, round) {
8021
+ // The direct entry delivers under the live turn's routing.
8022
+ await this.deliverAdmittedProse(agent, this.admitProse(rawText), {
8023
+ keepPrivate: round?.keepPrivate,
8024
+ routing: this.routingOf(agent.name),
8025
+ });
8026
+ }
8027
+ /**
8028
+ * Explicit prose's envelopes, each `>>` target resolved now, once. A live
8029
+ * round is admitted at its boundary: its turn's destination is decided from
8030
+ * these resolutions (roundDestination), and its queued delivery posts to
8031
+ * them, however the registry's names change before it runs.
8032
+ */
8033
+ admitProse(rawText) {
8034
+ return splitExplicitEnvelopes(rawText).map((text) => {
8035
+ const parsed = parseProsePrefix(text);
8036
+ if (parsed.kind !== 'target')
8037
+ return { text };
8038
+ try {
8039
+ return { text, resolved: this.channelRegistry.resolveProseTarget(parsed.target) };
7395
8040
  }
7396
- current.push(line);
8041
+ catch (err) {
8042
+ // Admission runs in the stream's event loop, so a resolver that
8043
+ // throws makes the envelope bounce rather than stop the round.
8044
+ return { text, resolved: { error: `the destination couldn't be resolved: ${err instanceof Error ? err.message : String(err)}` } };
8045
+ }
8046
+ });
8047
+ }
8048
+ async deliverAdmittedProse(agent, envelopes, round) {
8049
+ // Multi-envelope: a single prose block may address SEVERAL destinations,
8050
+ // each envelope routed independently (sticky/bounce as usual).
8051
+ for (const envelope of envelopes) {
8052
+ await this.deliverProseEnvelope(agent, envelope, round);
7397
8053
  }
7398
- if (current.length > 0)
7399
- envelopes.push(current.join('\n'));
8054
+ }
8055
+ /**
8056
+ * Where a delivered round leaves its turn's sticky destination, as the
8057
+ * explicit gateway sets it once the round's envelopes run: each `>>`
8058
+ * envelope whose target resolved becomes the destination. One that didn't
8059
+ * resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text.
8060
+ */
8061
+ roundDestination(current, envelopes) {
8062
+ let destination = current;
7400
8063
  for (const envelope of envelopes) {
7401
- if (!envelope.trim())
7402
- continue;
7403
- await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
8064
+ if (envelope.resolved && 'channelId' in envelope.resolved)
8065
+ destination = envelope.resolved.channelId;
7404
8066
  }
8067
+ return destination;
7405
8068
  }
7406
8069
  /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
7407
- async deliverHybridProse(agent, rawText, locus, allowLocus) {
8070
+ async deliverHybridProse(agent, rawText, locus, allowLocus, turnRouting) {
8071
+ const routing = turnRouting;
7408
8072
  const lines = rawText.split('\n');
7409
8073
  const envelopes = [];
7410
8074
  let current = [];
@@ -7424,18 +8088,18 @@ export class AgentFramework {
7424
8088
  const attempted = normalized.startsWith('>>>');
7425
8089
  const parsed = parseHybridProsePrefix(normalized);
7426
8090
  if (parsed.continueTurn)
7427
- this.proseContinuations.add(agent.name);
8091
+ routing.logical.continuation = true;
7428
8092
  if (parsed.kind === 'private') {
7429
- this.proseTargetPins.delete(agent.name);
7430
- this.proseHybridSuppressed.add(agent.name);
8093
+ delete routing.pin;
8094
+ routing.hybridHold = true;
7431
8095
  console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
7432
8096
  continue;
7433
8097
  }
7434
8098
  if (parsed.kind === 'target') {
7435
8099
  const resolved = this.channelRegistry.resolveProseTarget(parsed.target);
7436
8100
  if ('error' in resolved) {
7437
- this.proseTargetPins.delete(agent.name);
7438
- this.proseHybridSuppressed.add(agent.name);
8101
+ delete routing.pin;
8102
+ routing.hybridHold = true;
7439
8103
  this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
7440
8104
  continue;
7441
8105
  }
@@ -7443,63 +8107,67 @@ export class AgentFramework {
7443
8107
  const usedClipboard = body.includes('{{unsent}}');
7444
8108
  if (usedClipboard)
7445
8109
  body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
7446
- this.proseTargetPins.set(agent.name, resolved.channelId);
7447
- this.proseHybridSuppressed.delete(agent.name);
8110
+ routing.pin = resolved.channelId;
8111
+ routing.hybridHold = false;
7448
8112
  if (!body.trim()) {
7449
8113
  console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
7450
8114
  continue;
7451
8115
  }
7452
8116
  try {
7453
8117
  const outcome = await this.channelRegistry.routeSpeech(agent.name, body, resolved.channelId);
7454
- this.recordProseDelivery(agent.name, outcome);
8118
+ this.recordProseDelivery(routing, outcome);
7455
8119
  if (outcome?.delivered) {
7456
8120
  this.proseBounceStreaks.delete(agent.name);
7457
8121
  if (usedClipboard)
7458
8122
  this.proseClipboards.delete(agent.name);
7459
8123
  }
7460
8124
  else {
7461
- this.proseTargetPins.delete(agent.name);
7462
- this.proseHybridSuppressed.add(agent.name);
8125
+ delete routing.pin;
8126
+ routing.hybridHold = true;
7463
8127
  this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
7464
8128
  }
7465
8129
  }
7466
8130
  catch (err) {
7467
- this.proseTargetPins.delete(agent.name);
7468
- this.proseHybridSuppressed.add(agent.name);
8131
+ delete routing.pin;
8132
+ routing.hybridHold = true;
7469
8133
  this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
7470
8134
  }
7471
8135
  continue;
7472
8136
  }
7473
8137
  if (attempted) {
7474
- this.proseTargetPins.delete(agent.name);
7475
- this.proseHybridSuppressed.add(agent.name);
8138
+ delete routing.pin;
8139
+ routing.hybridHold = true;
7476
8140
  this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
7477
8141
  continue;
7478
8142
  }
7479
- if (this.proseHybridSuppressed.has(agent.name)) {
7480
- this.recordProseSuppression(agent.name, 1);
8143
+ if (routing.hybridHold) {
8144
+ this.recordProseSuppression(routing, 1);
7481
8145
  continue;
7482
8146
  }
7483
- const sticky = this.proseTargetPins.get(agent.name);
8147
+ const sticky = routing.pin;
7484
8148
  if (!allowLocus && !sticky) {
7485
- this.recordProseSuppression(agent.name, 1);
8149
+ this.recordProseSuppression(routing, 1);
7486
8150
  continue;
7487
8151
  }
7488
8152
  const target = sticky ?? locus;
7489
8153
  try {
7490
8154
  const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
7491
- this.recordProseDelivery(agent.name, outcome);
8155
+ this.recordProseDelivery(routing, outcome);
7492
8156
  }
7493
8157
  catch (err) {
7494
8158
  console.error('hybrid locus delivery failed:', err);
7495
8159
  }
7496
8160
  }
7497
8161
  }
7498
- async deliverProseEnvelope(agent, rawText) {
8162
+ async deliverProseEnvelope(agent, envelope, round) {
7499
8163
  const name = agent.name;
8164
+ const routing = round.routing;
8165
+ const rawText = envelope.text;
7500
8166
  const parsed = parseProsePrefix(rawText);
7501
- if (parsed.continueTurn)
7502
- this.proseContinuations.add(name);
8167
+ // A private round (one that called skip_reply) asks for no publication
8168
+ // or continuation, even when delivery runs after that turn has ended.
8169
+ if (parsed.continueTurn && !round.keepPrivate)
8170
+ routing.logical.continuation = true;
7503
8171
  if (parsed.kind === 'private') {
7504
8172
  // The text already lives in the assistant message (window/chronicle);
7505
8173
  // "delivery" is deliberately a no-op. Consumes the retained text if the
@@ -7509,21 +8177,34 @@ export class AgentFramework {
7509
8177
  console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
7510
8178
  return;
7511
8179
  }
8180
+ if (round.keepPrivate) {
8181
+ // skip_reply's promise: "Any plain text you wrote this turn stays
8182
+ // private and is NOT posted". Every envelope of the round stays in the
8183
+ // context, as a >>skip_reply envelope does: it isn't sent, doesn't
8184
+ // bounce to the clipboard and doesn't move the destination.
8185
+ console.error(`[prose] ${name}: ${parsed.kind === 'target' ? `>>${parsed.target}` : 'unprefixed'} — ` +
8186
+ `${parsed.body.length} chars kept in context (skip_reply round)`);
8187
+ return;
8188
+ }
7512
8189
  let targetChannel;
7513
8190
  let body;
7514
8191
  if (parsed.kind === 'target') {
7515
- const res = this.channelRegistry.resolveProseTarget(parsed.target);
8192
+ // Resolved once, when the round was admitted (admitProse), never again
8193
+ // here after the wait: a `>>` envelope without its resolution is a bug.
8194
+ const res = envelope.resolved;
8195
+ if (!res)
8196
+ throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`);
7516
8197
  if ('error' in res) {
7517
8198
  this.bounceProse(agent, parsed.body, res.error, res.candidates);
7518
8199
  return;
7519
8200
  }
7520
8201
  targetChannel = res.channelId;
7521
8202
  // Sticky within the turn: later unprefixed segments follow this target.
7522
- this.proseTargetPins.set(name, targetChannel);
8203
+ routing.pin = targetChannel;
7523
8204
  body = parsed.body;
7524
8205
  }
7525
8206
  else {
7526
- const sticky = this.proseTargetPins.get(name);
8207
+ const sticky = routing.pin;
7527
8208
  if (!sticky) {
7528
8209
  this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
7529
8210
  return;
@@ -7737,8 +8418,8 @@ export class AgentFramework {
7737
8418
  * driveStream (whose finally then owns clearing it).
7738
8419
  */
7739
8420
  async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
7740
- const continuingTurn = trigger?.reason === 'context_budget_restart'
7741
- || trigger?.reason === 'tool_result_guard_retry';
8421
+ const lifecycle = this.lifecycleGeneration;
8422
+ const continuingTurn = resumesLogicalTurn(trigger?.reason);
7742
8423
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
7743
8424
  // Flush messages deferred during the PREVIOUS turn — before the
7744
8425
  // checkpoint, the locus announcement, and the compile — so a turn started
@@ -7779,14 +8460,47 @@ export class AgentFramework {
7779
8460
  // boundary — after the deferred flush (same window: turn alive, compile
7780
8461
  // not yet run) and before the checkpoint (they are the turn's inputs).
7781
8462
  // Not on a context-budget restart: that continues the same logical turn.
8463
+ let assembly;
7782
8464
  if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
7783
8465
  try {
7784
- await this.pushCoalescer.assemble(agent.name);
8466
+ assembly = await this.pushCoalescer.assemble(agent.name);
7785
8467
  }
7786
8468
  catch (err) {
7787
8469
  console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
7788
8470
  }
7789
8471
  }
8472
+ // A turn whose only causes were deferred batches, all of which rendered
8473
+ // nothing (empty or blank render, §5.2; cancelled; revoked), has nothing
8474
+ // new to show: running it would wake the model to `[Continue]` or an
8475
+ // older message, an uncaused wake. Stop before the checkpoint, locus,
8476
+ // typing and compile. Any batch this assembly materialized keeps the
8477
+ // turn, including one that was not among its causes (a batch whose wake
8478
+ // the freeze withdrew, or one that never qualified for a wake). A cause
8479
+ // this assembly did not settle (rendered elsewhere, not yet frozen) keeps
8480
+ // the turn, as before.
8481
+ const batchSubjects = trigger?.coalescingBatchSubjects;
8482
+ if (assembly && batchSubjects?.length && assembly.materialized.size === 0
8483
+ && batchSubjects.every((s) => assembly.settled.has(s))) {
8484
+ console.error(`[coalescing] ${agent.name}: deferred render produced nothing; turn not started (no wake cause left)`);
8485
+ this.emitTrace({ type: 'mcpl:coalescing', kind: 'turn-withdrawn', agentName: agent.name, subjects: batchSubjects });
8486
+ // Release the turn and land anything deferred while assembly awaited
8487
+ // the render, as a torn-down turn would.
8488
+ if (this.activeTurnTokens.get(agent.name) === turnToken) {
8489
+ this.activeTurnTokens.delete(agent.name);
8490
+ this.activeTurnTriggers.delete(agent.name);
8491
+ }
8492
+ if (!this.activeTurnTokens.has(agent.name)
8493
+ && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
8494
+ for (const msg of this.drainDeferredFor(agent.name)) {
8495
+ this.addMessage(msg.participant, msg.content, msg.metadata, {
8496
+ deferredWriteId: msg.id,
8497
+ ...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
8498
+ });
8499
+ }
8500
+ this.ackDeferredWrites();
8501
+ }
8502
+ return false;
8503
+ }
7790
8504
  // Record turn checkpoint before inference (only on first attempt, not retries)
7791
8505
  if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
7792
8506
  this.recordTurnCheckpoint(agent.name);
@@ -7841,21 +8555,32 @@ export class AgentFramework {
7841
8555
  // A framework retry is a new physical stream in the same logical turn.
7842
8556
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
7843
8557
  }
7844
- // Fresh turn: forget the previous turn's explicit-send engagements and
7845
- // prose deliveries — both are strictly turn-scoped (a restart continues
7846
- // the same logical turn and keeps them).
8558
+ // Fresh turn: forget the previous turn's explicit-send engagements.
8559
+ // A restart continues the same logical turn and keeps them.
7847
8560
  this.turnEngagedChannels.delete(agent.name);
7848
- this.turnProseDeliveries.delete(agent.name);
7849
- this.turnProseSuppressed.delete(agent.name);
7850
- this.proseHybridSuppressed.delete(agent.name);
7851
- if (turnProseRouting === 'hybrid')
7852
- this.proseTargetPins.delete(agent.name);
8561
+ // A new routing state for the new turn, in every mode: the sticky
8562
+ // destination, the turn's destination, hybrid's hold and the receipt
8563
+ // tallies start empty. The previous turn's object isn't cleared: prose
8564
+ // it left queued still reads and writes its own.
8565
+ const fresh = freshTurnRouting();
8566
+ // A start while the agent's logical turn is open continues it: it
8567
+ // shares the earlier stream's ` !` and receipt tallies, which a
8568
+ // delivery still queued there can write after this stream begins
8569
+ // (openLogical). Otherwise this start opens one. A silent start does
8570
+ // neither: it routes no prose, so it keeps a logical turn of its own,
8571
+ // and an open turn waits for the next start that routes prose.
8572
+ if (!trigger?.suppressProse) {
8573
+ const open = this.openLogical.get(agent.name);
8574
+ if (open)
8575
+ fresh.logical = open;
8576
+ else
8577
+ this.openLogical.set(agent.name, fresh.logical);
8578
+ }
8579
+ this.turnRouting.set(agent.name, fresh);
7853
8580
  if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
7854
8581
  // Explicit and disabled prose routing have no inferred locus.
7855
8582
  // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
7856
8583
  // prose target at all. Neither mode freezes or announces a locus.
7857
- this.proseTargetPins.delete(agent.name);
7858
- this.proseContinuations.delete(agent.name);
7859
8584
  this.midTurnInputSignals.delete(agent.name);
7860
8585
  this.turnLocusPins.delete(agent.name);
7861
8586
  }
@@ -7875,9 +8600,17 @@ export class AgentFramework {
7875
8600
  // opened/pinned a channel mid-turn.
7876
8601
  if (trigger?.suppressProse) {
7877
8602
  this.turnLocusPins.delete(agent.name);
7878
- this.proseTargetPins.delete(agent.name);
7879
- this.proseContinuations.delete(agent.name);
7880
- }
8603
+ // Silent: no routing state. A continuing silent start keeps its turn's
8604
+ // logical turn; a fresh one keeps the one of its own it was given.
8605
+ const silent = freshTurnRouting();
8606
+ const logical = this.turnRouting.get(agent.name)?.logical;
8607
+ if (logical)
8608
+ silent.logical = logical;
8609
+ this.turnRouting.set(agent.name, silent);
8610
+ }
8611
+ // This start's own logical turn: a failure before its stream exists
8612
+ // settles it, and no other start's.
8613
+ const startLogical = this.turnRouting.get(agent.name)?.logical;
7881
8614
  this.touchEphemeralRun(agent.name, true);
7882
8615
  this.emitTrace({
7883
8616
  type: 'inference:started',
@@ -7909,7 +8642,11 @@ export class AgentFramework {
7909
8642
  this.channelRegistry?.startTyping(earlyTypingChannel);
7910
8643
  try {
7911
8644
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
7912
- const tools = this.agentToolSurface(agent, requestSnapshot);
8645
+ // Capture both surfaces together before context hooks can refresh tools.
8646
+ const presentation = this.inspectToolPresentation(agent.name, requestSnapshot);
8647
+ const compressionTools = presentation?.available
8648
+ ?? structuredClone(this.availableToolsForPresentation(agent.name, requestSnapshot));
8649
+ const tools = presentation?.advertised ?? compressionTools;
7913
8650
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
7914
8651
  // Both produce ContextInjection[] that get merged before inference.
7915
8652
  let injections;
@@ -7948,9 +8685,10 @@ export class AgentFramework {
7948
8685
  };
7949
8686
  injections = injections ? [...injections, silentInjection] : [silentInjection];
7950
8687
  }
7951
- // An ephemeral watchdog may dispose this Agent while hooks/compile await.
7952
- // Do not create a provider stream after its generation lost ownership.
7953
- if (this.agents.get(agent.name) !== agent) {
8688
+ // An ephemeral watchdog may dispose this Agent while hooks/compile await,
8689
+ // and stop() may close provider admission. Do not create a provider
8690
+ // stream after its generation lost ownership, or once admission closed.
8691
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
7954
8692
  this.channelRegistry?.stopTyping();
7955
8693
  this.eventGate?.onInferenceEnded(agent.name);
7956
8694
  return false;
@@ -7965,10 +8703,27 @@ export class AgentFramework {
7965
8703
  injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
7966
8704
  }
7967
8705
  }
7968
- const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections);
7969
- if (this.agents.get(agent.name) !== agent) {
8706
+ const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools, {
8707
+ silentHeartbeat: trigger?.silentHeartbeat,
8708
+ });
8709
+ // The compile can wait for annotations (each batch has one spill
8710
+ // deadline), so check ownership, admission, and the lifecycle again.
8711
+ // A stop followed by start must not revive this earlier turn.
8712
+ // Membrane's yielding stream starts its request when it is first
8713
+ // iterated, so cancelling it here, before driveStream, sends nothing.
8714
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
8715
+ console.error(`[turn] ${agent.name}: a turn compiled while ${this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
8716
+ : this.providerAdmissionClosed ? 'the framework was stopping'
8717
+ : 'the framework was stopped and started again'} is dropped before it sends anything`);
7970
8718
  stream.cancel();
7971
8719
  agent.cancelStream();
8720
+ if (this.agents.get(agent.name) === agent) {
8721
+ // The compile took the agent's stream id, so a frame that started
8722
+ // this one (a guard or budget restart) no longer owns the entries
8723
+ // it left, and its teardown leaves them to this frame.
8724
+ this.activeStreams.delete(agent.name);
8725
+ this.pendingAssistantBlocks.delete(agent.name);
8726
+ }
7972
8727
  this.channelRegistry?.stopTyping();
7973
8728
  this.eventGate?.onInferenceEnded(agent.name);
7974
8729
  return false;
@@ -8019,7 +8774,7 @@ export class AgentFramework {
8019
8774
  stopReason: 'exhausted',
8020
8775
  speech: '',
8021
8776
  error: err.message,
8022
- });
8777
+ }, startLogical);
8023
8778
  this.emitTrace({
8024
8779
  type: 'inference:exhausted',
8025
8780
  agentName: agent.name,
@@ -8071,6 +8826,9 @@ export class AgentFramework {
8071
8826
  // from the map here: the turn-alive busy check in processInferenceRequests
8072
8827
  // means no successor turn can have replaced it while we compiled.
8073
8828
  const myTurnToken = this.activeTurnTokens.get(agent.name);
8829
+ // The turn-alive guard also keeps this turn's routing current at entry.
8830
+ // Retain it for queued deliveries and receipts after a tool ends the turn.
8831
+ const streamRouting = this.routingOf(agent.name);
8074
8832
  let hadToolCalls = false;
8075
8833
  let generationLost = false;
8076
8834
  // ---- Present-while-acting turn state ---------------------------------
@@ -8089,14 +8847,27 @@ export class AgentFramework {
8089
8847
  // awaits the chain before routing trailing prose, so in-channel ordering
8090
8848
  // is preserved end-to-end. Each link catches its own error: one failed
8091
8849
  // post must not silence the rest of the turn.
8092
- let turnSpeechChain = Promise.resolve();
8850
+ //
8851
+ // Every stream queues its deliveries behind the agent's latest link, so
8852
+ // the agent's posts go out in the order they were written, across a
8853
+ // turn's streams (a budget restart, a tool-result-guard retry) and across
8854
+ // turns. A turn that a tool ends (skip_reply) settles before its queued
8855
+ // prose has posted; that prose keeps its own turn's routing state
8856
+ // (turnRouting), and the next turn's posts wait behind it.
8857
+ const resumesTurn = resumesLogicalTurn(trigger?.reason);
8858
+ let turnSpeechChain = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
8859
+ this.proseSpeechChains.set(agent.name, turnSpeechChain);
8860
+ const chainSpeech = (link, failure) => {
8861
+ turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err));
8862
+ // A stream another has replaced doesn't move the turn's latest link.
8863
+ if (agent.streamId === myStreamId)
8864
+ this.proseSpeechChains.set(agent.name, turnSpeechChain);
8865
+ };
8093
8866
  const enqueueSpeech = (text, locus) => {
8094
- turnSpeechChain = turnSpeechChain
8095
- .then(async () => {
8867
+ chainSpeech(async () => {
8096
8868
  const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
8097
- this.recordProseDelivery(agent.name, outcome);
8098
- })
8099
- .catch((err) => console.error('mid-turn speech routing failed:', err));
8869
+ this.recordProseDelivery(streamRouting, outcome);
8870
+ }, 'mid-turn speech routing failed:');
8100
8871
  };
8101
8872
  // Sticky explicit-send suppression: prose after send_message stays quiet
8102
8873
  // to prevent a redundant "sent it" postscript. Fresh injected input
@@ -8169,7 +8940,16 @@ export class AgentFramework {
8169
8940
  !agent.toolWrapperProseGuard
8170
8941
  ? new ProseStreamRouter({
8171
8942
  mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
8172
- initialTarget: typingChannel,
8943
+ // Explicit text has no destination until a `>>` prefix names one,
8944
+ // so a fresh turn's stream starts suppressed. One that resumes the
8945
+ // turn (a tool-result-guard retry, a budget restart) starts at the
8946
+ // destination the turn's delivered rounds have set: a private
8947
+ // round's prefixes don't count, and the gateway's queued
8948
+ // deliveries needn't have run. The trigger channel is only where
8949
+ // explicit mode's typing indicator shows.
8950
+ initialTarget: turnProseRouting === 'explicit'
8951
+ ? (resumesTurn ? streamRouting.destination : null)
8952
+ : typingChannel,
8173
8953
  resolve: (spec) => {
8174
8954
  const r = this.channelRegistry.resolveProseTarget(spec);
8175
8955
  return 'channelId' in r ? r.channelId : null;
@@ -8188,6 +8968,8 @@ export class AgentFramework {
8188
8968
  // other trace consumers voice it). Hold both; release in order on a clean
8189
8969
  // round (tool-calls / non-guard completion), discard on a guard refusal.
8190
8970
  let guardHeld = [];
8971
+ // The text block the stream's last text came from, within this round.
8972
+ let lastTextBlock;
8191
8973
  const releaseGuardHeld = () => {
8192
8974
  const held = guardHeld;
8193
8975
  guardHeld = [];
@@ -8231,7 +9013,18 @@ export class AgentFramework {
8231
9013
  // turn's prose is bound for, without re-deriving routing.
8232
9014
  channelId: typingChannel ?? undefined,
8233
9015
  };
8234
- const text = event.meta.type === 'text' ? event.content : undefined;
9016
+ // Delivery joins a round's text blocks with a newline
9017
+ // (splitProseSegments), so a later block starts a new line for
9018
+ // the stream too.
9019
+ let text = event.meta.type === 'text' ? event.content : undefined;
9020
+ if (text !== undefined) {
9021
+ const block = event.meta.blockIndex;
9022
+ if (block !== undefined) {
9023
+ if (lastTextBlock !== undefined && block !== lastTextBlock)
9024
+ text = '\n' + text;
9025
+ lastTextBlock = block;
9026
+ }
9027
+ }
8235
9028
  if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
8236
9029
  guardHeld.push({ trace, text });
8237
9030
  }
@@ -8263,6 +9056,12 @@ export class AgentFramework {
8263
9056
  outgoingIndex = 0;
8264
9057
  discardGuardHeld();
8265
9058
  proseStream?.reset();
9059
+ lastTextBlock = undefined;
9060
+ // Explicit mode's retried attempt starts where the turn's
9061
+ // delivered rounds have set the destination, not at the stream's
9062
+ // seed: rounds this stream delivered before the refusal stand.
9063
+ if (turnProseRouting === 'explicit')
9064
+ proseStream?.retarget(streamRouting.destination);
8266
9065
  this.emitTrace({
8267
9066
  type: 'inference:tokens',
8268
9067
  agentName: agent.name,
@@ -8290,6 +9089,11 @@ export class AgentFramework {
8290
9089
  // preceding results before storing/dispatching this new round.
8291
9090
  agent.toolResultGuard.accept();
8292
9091
  releaseGuardHeld();
9092
+ // The round's text ends here: delivery parses each round's text
9093
+ // on its own, so the next round's text starts a new line.
9094
+ if (proseStream)
9095
+ emitOutgoing(proseStream.endSegment());
9096
+ lastTextBlock = undefined;
8293
9097
  adoptInjectedRound();
8294
9098
  hadToolCalls = true;
8295
9099
  this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
@@ -8388,39 +9192,58 @@ export class AgentFramework {
8388
9192
  if (roundSegments.length > 0) {
8389
9193
  if (turnProseRouting === 'disabled') {
8390
9194
  console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
8391
- this.recordProseSuppression(agent.name, roundSegments.length);
9195
+ this.recordProseSuppression(streamRouting, roundSegments.length);
8392
9196
  }
8393
9197
  else if (turnProseRouting === 'hybrid') {
8394
9198
  if (turnSilenced) {
8395
- this.recordProseSuppression(agent.name, roundSegments.length);
9199
+ this.recordProseSuppression(streamRouting, roundSegments.length);
8396
9200
  }
8397
9201
  else if (!hasSameRoundPrivateThink) {
8398
9202
  const locus = resolveTurnLocus();
9203
+ // Queued under this turn's routing, whenever it posts.
9204
+ const routing = streamRouting;
8399
9205
  for (const seg of roundSegments) {
8400
- turnSpeechChain = turnSpeechChain
8401
- .then(() => this.deliverHybridProse(agent, seg, locus, true))
8402
- .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
9206
+ chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true, routing), 'mid-turn hybrid prose delivery failed:');
8403
9207
  }
8404
9208
  }
8405
9209
  }
8406
9210
  else if (turnProseRouting === 'explicit') {
8407
- // Explicit mode: every segment through the prose gateway.
8408
- // Silencing does not apply — unprefixed prose bounces and
8409
- // prefixed prose is deliberate; think-privacy still holds.
9211
+ // Explicit mode: the segments go through the prose
9212
+ // gateway, unless a private think in the round holds them
9213
+ // back. Send silencing does not apply: unprefixed prose
9214
+ // bounces, and prefixed prose is deliberate. skip_reply
9215
+ // does: its round's envelopes all stay in the context
9216
+ // (deliverProseEnvelope's keepPrivate), as the tool
9217
+ // promises, and skip_reply then ends the turn at this
9218
+ // round's tool results.
8410
9219
  if (!hasSameRoundPrivateThink) {
8411
- console.error(`[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`);
8412
- for (const seg of roundSegments) {
8413
- turnSpeechChain = turnSpeechChain
8414
- .then(() => this.deliverProse(agent, seg))
8415
- .catch((err) => console.error('mid-turn prose delivery failed:', err));
9220
+ const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply');
9221
+ // The round is admitted now: its targets are resolved
9222
+ // once, for the turn's destination and for its queued
9223
+ // delivery alike.
9224
+ const admitted = roundSegments.map((seg) => this.admitProse(seg));
9225
+ // Queued under this turn's routing, whenever it posts.
9226
+ const routing = streamRouting;
9227
+ if (!keepPrivate) {
9228
+ routing.destination = this.roundDestination(routing.destination, admitted.flat());
9229
+ }
9230
+ console.error(`[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`);
9231
+ for (const envelopes of admitted) {
9232
+ chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate, routing }), 'mid-turn prose delivery failed:');
8416
9233
  }
8417
9234
  }
9235
+ // The stream's next round starts where delivery sends
9236
+ // unprefixed text: the turn's destination. The stream
9237
+ // followed this round's prefixes as it was written, but a
9238
+ // private round's, a >>skip_reply envelope's or a target
9239
+ // that didn't resolve leave the destination as it was.
9240
+ proseStream?.retarget(streamRouting.destination);
8418
9241
  }
8419
9242
  else if (turnSilenced) {
8420
9243
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
8421
9244
  // Visible in the turn-end receipt — silencing must never
8422
9245
  // be a silent black hole (n=8: the flying-scene reply).
8423
- this.recordProseSuppression(agent.name, roundSegments.length);
9246
+ this.recordProseSuppression(streamRouting, roundSegments.length);
8424
9247
  }
8425
9248
  else if (hasSameRoundPrivateThink) {
8426
9249
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
@@ -8458,6 +9281,8 @@ export class AgentFramework {
8458
9281
  // persisted as assistant speech, including a terminal refusal
8459
9282
  // after the single recovery retry.
8460
9283
  proseStream?.reset();
9284
+ if (turnProseRouting === 'explicit')
9285
+ proseStream?.retarget(streamRouting.destination);
8461
9286
  if (withheld) {
8462
9287
  const usage = response.details?.usage ?? response.usage;
8463
9288
  const tokenUsage = usage ? {
@@ -8539,7 +9364,7 @@ export class AgentFramework {
8539
9364
  // Flush pending assistant blocks
8540
9365
  const pending = this.pendingAssistantBlocks.get(agent.name);
8541
9366
  if (pending) {
8542
- agent.addAssistantResponse(pending);
9367
+ agent.addAssistantResponse(pending, this.silentTurnRowMetadata(agent.name));
8543
9368
  this.pendingAssistantBlocks.delete(agent.name);
8544
9369
  }
8545
9370
  // Store tool results — same bounded spill policy as the
@@ -8549,7 +9374,7 @@ export class AgentFramework {
8549
9374
  if (readyState.status === 'ready') {
8550
9375
  const cap = this.resolveToolResultInlineCap(agent).cap;
8551
9376
  const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap);
8552
- agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
9377
+ agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults, this.silentTurnRowMetadata(agent.name));
8553
9378
  // The response is already complete: this batch is never
8554
9379
  // submitted in this turn, so it cannot be refused. Admit it.
8555
9380
  agent.toolResultGuard.settleTurnEnded();
@@ -8585,16 +9410,17 @@ export class AgentFramework {
8585
9410
  system: true,
8586
9411
  kind: 'tool-wrapper-prose-contained',
8587
9412
  toolName: guardedWrapperTool,
9413
+ ...this.silentTurnRowMetadata(agent.name),
8588
9414
  });
8589
9415
  console.error(`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`);
8590
9416
  }
8591
9417
  else if (lastToolIdx >= 0) {
8592
9418
  if (terminalContent.length > 0) {
8593
- agent.addAssistantResponse(terminalContent);
9419
+ agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
8594
9420
  }
8595
9421
  }
8596
9422
  else {
8597
- agent.addAssistantResponse(terminalContent);
9423
+ agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
8598
9424
  }
8599
9425
  // Bind the cooldown receipt to this fresh, successful request —
8600
9426
  // never to the frozen request that received the 429.
@@ -8616,6 +9442,19 @@ export class AgentFramework {
8616
9442
  : response.content.filter(isTextBlock);
8617
9443
  const speechContent = hadToolCalls ? [] : allText;
8618
9444
  const thoughts = hadToolCalls ? allText : [];
9445
+ // Explicit text is admitted as its round ends, before anything
9446
+ // below waits (a module's speech callback, the turn's earlier
9447
+ // deliveries): its targets resolve now, as a live round's do.
9448
+ const finalSpeechText = speechContent.map((b) => b.text).join('\n').trim();
9449
+ const finalSegments = hadToolCalls && allText.length > 0
9450
+ ? splitProseSegments(liveProseRouting ? terminalContent : response.content)
9451
+ : [];
9452
+ const admitFinal = turnProseRouting === 'explicit' && !!this.channelRegistry;
9453
+ // The final text is delivered under this turn's routing, as its
9454
+ // queued rounds are.
9455
+ const finalRouting = streamRouting;
9456
+ const admittedSpeech = admitFinal && finalSpeechText ? this.admitProse(finalSpeechText) : null;
9457
+ const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : [];
8619
9458
  const du = response.details?.usage;
8620
9459
  const tokenUsage = du
8621
9460
  ? {
@@ -8649,7 +9488,7 @@ export class AgentFramework {
8649
9488
  .filter((block) => block.type === 'text')
8650
9489
  .map((block) => block.text)
8651
9490
  .join('\n'),
8652
- });
9491
+ }, streamRouting.logical);
8653
9492
  this.emitTrace({
8654
9493
  type: 'inference:completed',
8655
9494
  agentName: agent.name,
@@ -8799,14 +9638,16 @@ export class AgentFramework {
8799
9638
  // they are never routed here — which is precisely how the `think`
8800
9639
  // tool (and any explicit send tool) yields a silent turn.
8801
9640
  if (speechContent.length > 0 && this.channelRegistry) {
8802
- const speechText = speechContent
8803
- .map((b) => b.text)
8804
- .join('\n')
8805
- .trim();
9641
+ const speechText = finalSpeechText;
8806
9642
  if (speechText) {
9643
+ // After the agent's earlier deliveries: every stream's chain
9644
+ // starts from the agent's latest link, so this waits for the
9645
+ // turn's earlier rounds and for an earlier turn's posts still
9646
+ // queued.
9647
+ await turnSpeechChain;
8807
9648
  if (turnProseRouting === 'disabled') {
8808
9649
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
8809
- this.recordProseSuppression(agent.name, 1);
9650
+ this.recordProseSuppression(streamRouting, 1);
8810
9651
  }
8811
9652
  else if (turnSilenced && turnProseRouting !== 'explicit') {
8812
9653
  // Only reachable on a guard recovery that carried the
@@ -8814,13 +9655,13 @@ export class AgentFramework {
8814
9655
  // (a fresh text-only turn starts unsilenced). Explicit
8815
9656
  // mode is exempt, as mid-turn.
8816
9657
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
8817
- this.recordProseSuppression(agent.name, 1);
9658
+ this.recordProseSuppression(streamRouting, 1);
8818
9659
  }
8819
9660
  else if (turnProseRouting === 'hybrid') {
8820
9661
  const locus = resolveTurnLocus();
8821
9662
  console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
8822
9663
  try {
8823
- await this.deliverHybridProse(agent, speechText, locus, true);
9664
+ await this.deliverHybridProse(agent, speechText, locus, true, finalRouting);
8824
9665
  }
8825
9666
  catch (err) {
8826
9667
  console.error('text-only hybrid prose delivery failed:', err);
@@ -8829,7 +9670,7 @@ export class AgentFramework {
8829
9670
  else if (turnProseRouting === 'explicit') {
8830
9671
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
8831
9672
  try {
8832
- await this.deliverProse(agent, speechText);
9673
+ await this.deliverAdmittedProse(agent, admittedSpeech, { routing: finalRouting });
8833
9674
  }
8834
9675
  catch (err) {
8835
9676
  console.error('text-only prose delivery failed:', err);
@@ -8846,7 +9687,7 @@ export class AgentFramework {
8846
9687
  console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
8847
9688
  try {
8848
9689
  const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
8849
- this.recordProseDelivery(agent.name, outcome);
9690
+ this.recordProseDelivery(streamRouting, outcome);
8850
9691
  }
8851
9692
  catch (err) {
8852
9693
  console.error('speech routing failed:', err);
@@ -8876,7 +9717,7 @@ export class AgentFramework {
8876
9717
  const silenced = liveProseRouting
8877
9718
  ? turnSilenced
8878
9719
  : turnSilenced || toolNames.some(isSilencingTool);
8879
- const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
9720
+ const segments = finalSegments;
8880
9721
  // Preserve in-channel ordering: everything enqueued live must
8881
9722
  // land before the trailing prose. Awaited even when silenced —
8882
9723
  // the chain may still be flushing earlier rounds' posts.
@@ -8884,18 +9725,18 @@ export class AgentFramework {
8884
9725
  if (turnProseRouting === 'disabled') {
8885
9726
  if (segments.length > 0) {
8886
9727
  console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
8887
- this.recordProseSuppression(agent.name, segments.length);
9728
+ this.recordProseSuppression(streamRouting, segments.length);
8888
9729
  }
8889
9730
  }
8890
9731
  else if (turnProseRouting === 'hybrid') {
8891
9732
  if (silenced && segments.length > 0) {
8892
- this.recordProseSuppression(agent.name, segments.length);
9733
+ this.recordProseSuppression(streamRouting, segments.length);
8893
9734
  }
8894
9735
  else if (segments.length > 0) {
8895
9736
  const locus = resolveTurnLocus();
8896
9737
  for (const seg of segments) {
8897
9738
  try {
8898
- await this.deliverHybridProse(agent, seg, locus, true);
9739
+ await this.deliverHybridProse(agent, seg, locus, true, finalRouting);
8899
9740
  }
8900
9741
  catch (err) {
8901
9742
  console.error('trailing hybrid prose delivery failed:', err);
@@ -8906,9 +9747,9 @@ export class AgentFramework {
8906
9747
  else if (turnProseRouting === 'explicit') {
8907
9748
  if (segments.length > 0) {
8908
9749
  console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
8909
- for (const seg of segments) {
9750
+ for (const envelopes of admittedSegments) {
8910
9751
  try {
8911
- await this.deliverProse(agent, seg);
9752
+ await this.deliverAdmittedProse(agent, envelopes, { routing: finalRouting });
8912
9753
  }
8913
9754
  catch (err) {
8914
9755
  console.error('trailing prose delivery failed:', err);
@@ -8920,7 +9761,7 @@ export class AgentFramework {
8920
9761
  console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
8921
9762
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
8922
9763
  if (silenced && segments.length > 0) {
8923
- this.recordProseSuppression(agent.name, segments.length);
9764
+ this.recordProseSuppression(streamRouting, segments.length);
8924
9765
  }
8925
9766
  }
8926
9767
  else {
@@ -8937,7 +9778,7 @@ export class AgentFramework {
8937
9778
  for (const seg of segments) {
8938
9779
  try {
8939
9780
  const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
8940
- this.recordProseDelivery(agent.name, outcome);
9781
+ this.recordProseDelivery(streamRouting, outcome);
8941
9782
  }
8942
9783
  catch (err) {
8943
9784
  console.error('speech routing failed:', err);
@@ -8949,27 +9790,20 @@ export class AgentFramework {
8949
9790
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
8950
9791
  // doesn't depend on the status field, so it correctly runs after.
8951
9792
  // Logical turn end (normal completion): drop the `[delivered]`
8952
- // receipt. All deliveries are settled — the live chain was
8953
- // awaited before trailing dispatch, and trailing/text-only
8954
- // segments were awaited in-loop. Locus mode only; explicit-mode
8955
- // envelopes acknowledge themselves through the prose gateway.
9793
+ // receipt, once every delivery has settled. The text-only and
9794
+ // trailing paths awaited the chain when there was final text; a
9795
+ // turn whose last response is empty awaits it here, so the
9796
+ // receipt and the continuation see its last rounds' deliveries.
9797
+ // Locus and hybrid mode; explicit-mode envelopes acknowledge
9798
+ // themselves through the prose gateway.
9799
+ await turnSpeechChain;
8956
9800
  if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
8957
- this.appendProseDeliveryReceipt(agent);
9801
+ this.appendProseDeliveryReceipt(agent, streamRouting);
8958
9802
  }
8959
- // Explicit-prose `!` continuation: a prose segment this turn asked
8960
- // to keep going (`>>#x !` / `>>private !`) — start another turn
8961
- // now instead of pausing until the next external event. This gives
8962
- // prose the same continuation ability tool rounds have (wanted for
9803
+ // A `!` continuation this turn asked for. This gives prose the
9804
+ // same continuation ability tool rounds have (wanted for
8963
9805
  // robotics-style loops where an end-of-turn pause is harmful).
8964
- if (this.proseContinuations.delete(agent.name)) {
8965
- console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
8966
- this.pendingRequests.push({
8967
- agentName: agent.name,
8968
- reason: 'prose-continuation',
8969
- source: 'framework',
8970
- timestamp: Date.now(),
8971
- });
8972
- }
9806
+ this.requestProseContinuation(agent, finalRouting);
8973
9807
  break;
8974
9808
  }
8975
9809
  case 'error': {
@@ -9016,7 +9850,7 @@ export class AgentFramework {
9016
9850
  stopReason: 'exhausted',
9017
9851
  speech: '',
9018
9852
  error: 'Turn abandoned by operator quiesce',
9019
- });
9853
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
9020
9854
  this.emitTrace({
9021
9855
  type: 'inference:aborted',
9022
9856
  agentName: agent.name,
@@ -9045,7 +9879,7 @@ export class AgentFramework {
9045
9879
  stopReason: 'exhausted',
9046
9880
  speech: '',
9047
9881
  error: err.message,
9048
- });
9882
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
9049
9883
  this.emitTrace({
9050
9884
  type: 'inference:exhausted',
9051
9885
  agentName: agent.name,
@@ -9100,7 +9934,7 @@ export class AgentFramework {
9100
9934
  stopReason: 'exhausted',
9101
9935
  speech: '',
9102
9936
  error: 'Turn abandoned by operator quiesce',
9103
- });
9937
+ }, streamRouting.logical);
9104
9938
  }
9105
9939
  this.emitTrace({
9106
9940
  type: 'inference:aborted',
@@ -9113,13 +9947,23 @@ export class AgentFramework {
9113
9947
  // endTurn IS a logical turn end — earlier rounds may have
9114
9948
  // live-routed prose (narrate → skip_reply is a real shape),
9115
9949
  // so settle the delivery chain and drop the receipt. A
9116
- // budget restart continues the same logical turn: the
9117
- // deliveries map persists (cleared only at fresh turn
9118
- // start) and the continuation stream's end writes ONE
9119
- // receipt for the whole turn.
9950
+ // budget restart keeps the same turn's routing and tallies;
9951
+ // the continuation stream's end writes one receipt for it.
9120
9952
  if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
9121
9953
  await turnSpeechChain;
9122
- this.appendProseDeliveryReceipt(agent);
9954
+ this.appendProseDeliveryReceipt(agent, streamRouting);
9955
+ }
9956
+ // A `!` an earlier round asked for still starts its
9957
+ // continuation when a tool ends the turn, once the turn's
9958
+ // deliveries have run. Explicit mode doesn't hold the turn
9959
+ // for them, so it checks at the end of its chain.
9960
+ if (cancelKind === 'turn_ended') {
9961
+ if (turnProseRouting === 'explicit') {
9962
+ void turnSpeechChain.then(() => this.requestProseContinuation(agent, streamRouting));
9963
+ }
9964
+ else {
9965
+ this.requestProseContinuation(agent, streamRouting);
9966
+ }
9123
9967
  }
9124
9968
  return;
9125
9969
  }
@@ -9135,7 +9979,7 @@ export class AgentFramework {
9135
9979
  stopReason: 'exhausted',
9136
9980
  speech: '',
9137
9981
  error: `Stream aborted: ${reason}`,
9138
- });
9982
+ }, streamRouting.logical);
9139
9983
  this.emitTrace({
9140
9984
  type: 'inference:exhausted',
9141
9985
  agentName: agent.name,
@@ -9268,7 +10112,7 @@ export class AgentFramework {
9268
10112
  stopReason: 'exhausted',
9269
10113
  speech: '',
9270
10114
  error: err.message,
9271
- });
10115
+ }, streamRouting.logical);
9272
10116
  this.emitTrace({
9273
10117
  type: 'inference:exhausted',
9274
10118
  agentName: agent.name,
@@ -9516,13 +10360,15 @@ export class AgentFramework {
9516
10360
  let ownedToEnd = false;
9517
10361
  try {
9518
10362
  const started = Date.now();
9519
- const result = await this.executeToolCall({
9520
- id: toolUseId,
9521
- name: toolName,
9522
- input,
9523
- callerAgentName: agentName,
9524
- });
10363
+ const call = { id: toolUseId, name: toolName, input, callerAgentName: agentName };
10364
+ const returned = await this.executeToolCall(call);
9525
10365
  const durationMs = Date.now() - started;
10366
+ // The pair is stored as a model's call would be, so the result stored,
10367
+ // traced and logged is its checked form. The caller gets the tool's
10368
+ // own result back, as an in-process caller does, unless nothing
10369
+ // usable could be stored: then it gets the error result stored.
10370
+ const checked = checkedToolResult(call, returned);
10371
+ const result = checked.result;
9526
10372
  // Store the pair through the same shapes the ordinary path uses. Build
9527
10373
  // the result blocks BEFORE storing the tool_use: the spill path awaits.
9528
10374
  // A message arriving during either await is deferred by the turn
@@ -9560,7 +10406,7 @@ export class AgentFramework {
9560
10406
  });
9561
10407
  console.log(`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
9562
10408
  `in ${durationMs}ms (${toolUseId})${ownedToEnd ? '' : ' — deferred behind a live turn'}`);
9563
- return { toolUseId, result };
10409
+ return { toolUseId, result: checked.usable ? returned : result };
9564
10410
  }
9565
10411
  finally {
9566
10412
  if (this.activeTurnTokens.get(agentName) === turnToken)
@@ -9585,6 +10431,8 @@ export class AgentFramework {
9585
10431
  }
9586
10432
  }
9587
10433
  async executeToolCallFrom(call, origin) {
10434
+ if (isPresentationTool(call.name))
10435
+ return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
9588
10436
  // Client-side programmatic tool calling for promise-based callers
9589
10437
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
9590
10438
  // gets its own interpreter state.
@@ -9620,9 +10468,10 @@ export class AgentFramework {
9620
10468
  // ephemerals, ModuleContext.callTool) reach utilities the same way the
9621
10469
  // model does.
9622
10470
  if (call.name === 'utils') {
9623
- return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
10471
+ return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true });
9624
10472
  }
9625
- // Module tools
10473
+ // Module tools. The caller uses the result in process, so it gets the
10474
+ // module's own result, as the module returned it.
9626
10475
  return this.moduleRegistry.handleToolCall(call);
9627
10476
  }
9628
10477
  async executeMcplToolCall(call, config) {
@@ -9700,6 +10549,7 @@ export class AgentFramework {
9700
10549
  this.pushEvent({
9701
10550
  type: 'tool-result',
9702
10551
  callId: call.id,
10552
+ toolName: call.name,
9703
10553
  agentName,
9704
10554
  moduleName: 'code_execution',
9705
10555
  result,
@@ -9717,6 +10567,11 @@ export class AgentFramework {
9717
10567
  * results reach the running script and never the model context.
9718
10568
  */
9719
10569
  async runCodeExecution(agentName, call) {
10570
+ // RFC-007: what the script's own tool calls are attributed to. Read
10571
+ // before the first await: it is only known while this call is being
10572
+ // dispatched. Undefined off the model dispatch path (ephemeral callers),
10573
+ // whose code_execution call is not reported either.
10574
+ const origin = this.toolLifecycleEmitter?.scriptOrigin(agentName, call.id);
9720
10575
  const input = (call.input ?? {});
9721
10576
  // Management surface: the agent's own daemon fleet is inspectable and
9722
10577
  // killable through the same tool that spawns it.
@@ -9779,16 +10634,34 @@ export class AgentFramework {
9779
10634
  : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
9780
10635
  const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
9781
10636
  if (input.background === true) {
9782
- const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
10637
+ const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs, origin);
9783
10638
  if (capNote && started.success && started.data && typeof started.data === 'object') {
9784
10639
  started.data.time_limit_note = capNote;
9785
10640
  }
9786
10641
  return started;
9787
10642
  }
9788
10643
  const runner = this.getOrCreateScriptRunner(agentName);
9789
- this.scriptDeferredEndTurn.delete(agentName);
9790
- const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
9791
- const endTurn = this.scriptDeferredEndTurn.delete(agentName);
10644
+ // A busy runner refuses this exec; the running script keeps its mark and
10645
+ // its origin.
10646
+ const ownsRunner = !runner.busy;
10647
+ const script = { endTurn: false };
10648
+ if (ownsRunner)
10649
+ this.scriptDeferredEndTurn.set(agentName, script);
10650
+ const ownsOrigin = origin !== undefined && ownsRunner;
10651
+ if (ownsOrigin)
10652
+ this.foregroundScriptOrigins.set(agentName, origin);
10653
+ let exec;
10654
+ try {
10655
+ exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
10656
+ }
10657
+ finally {
10658
+ if (this.scriptDeferredEndTurn.get(agentName) === script)
10659
+ this.scriptDeferredEndTurn.delete(agentName);
10660
+ if (ownsOrigin && this.foregroundScriptOrigins.get(agentName) === origin) {
10661
+ this.foregroundScriptOrigins.delete(agentName);
10662
+ }
10663
+ }
10664
+ const endTurn = script.endTurn;
9792
10665
  return {
9793
10666
  success: true,
9794
10667
  data: {
@@ -9812,7 +10685,7 @@ export class AgentFramework {
9812
10685
  * limitation is stated in the tool description so agents can calibrate
9813
10686
  * how much to rely on an armed wake.
9814
10687
  */
9815
- startBackgroundScript(agentName, code, injected, timeLimitMs) {
10688
+ startBackgroundScript(agentName, code, injected, timeLimitMs, origin) {
9816
10689
  // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
9817
10690
  // would outlive its owner and its wake would land in the primary
9818
10691
  // conversation anyway — refuse loudly instead of surprising anyone.
@@ -9858,7 +10731,7 @@ export class AgentFramework {
9858
10731
  scriptTimeoutMs: cfg?.scriptTimeoutMs,
9859
10732
  idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
9860
10733
  label: `${agentName}:${scriptId}`,
9861
- onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
10734
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args, origin),
9862
10735
  });
9863
10736
  const record = {
9864
10737
  id: scriptId,
@@ -10047,7 +10920,7 @@ export class AgentFramework {
10047
10920
  // stays reachable by provenance after the bytes leave the wire.
10048
10921
  const raw = tc.result.isError
10049
10922
  ? tc.result.error ?? 'Unknown error'
10050
- : toolResultDataToHistoryString(tc.result.data, undefined, {
10923
+ : nonErrorResultText(tc.result, undefined, {
10051
10924
  imageRef: (blockIndex, image) => ledger.retain({
10052
10925
  toolCallId: tc.id,
10053
10926
  toolName: tc.name,
@@ -10069,6 +10942,134 @@ export class AgentFramework {
10069
10942
  }));
10070
10943
  return { blocks, spilled };
10071
10944
  }
10945
+ /** Where a tool result labelled `label` is written: `<first writable
10946
+ * mount>/tool-results/<label>.txt`, or null when there is no writable
10947
+ * workspace. */
10948
+ toolResultFilePlace(label) {
10949
+ const workspace = this.getWorkspaceModule();
10950
+ const mountName = workspace ? this.firstWritableMountName(workspace) : null;
10951
+ if (!workspace || !mountName)
10952
+ return null;
10953
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
10954
+ return { workspace, path: `${mountName}/tool-results/${safeLabel}.txt`, safeLabel };
10955
+ }
10956
+ /**
10957
+ * A withheld original uses the remaining time in its batch's deadline.
10958
+ * A write still under way at that deadline is reported as unfinished and
10959
+ * goes on; the guard stops waiting for it, though stop() still waits for
10960
+ * the workspace's turn it holds (bounded only by a teardown timeout). From
10961
+ * then until every
10962
+ * overdue write settles, new withheld spills are workspace_unanswered.
10963
+ * Writes from other batches can start before the first deadline fires.
10964
+ * An exhausted batch starts no more writes even if none is overdue.
10965
+ * The inline cap's spills use writeToolResultFile without this deadline.
10966
+ */
10967
+ async spillWithheld(label, text, branch, deadline = Date.now() + this.withheldSpillDeadlineMs) {
10968
+ // A batch with no finite deadline (a host whose deadline() threw) gets
10969
+ // this write's own, rather than a timer Node would cut to 1 ms.
10970
+ if (!Number.isFinite(deadline))
10971
+ deadline = Date.now() + this.withheldSpillDeadlineMs;
10972
+ if (this.guardSpillsClosedFor === this.lifecycleGeneration)
10973
+ return null;
10974
+ const place = this.toolResultFilePlace(label);
10975
+ if (!place)
10976
+ return null;
10977
+ if (this.stalledSpills.size > 0) {
10978
+ // Said once per stall, with the count when it clears, so a stalled
10979
+ // workspace doesn't write a line for every result it can't take.
10980
+ if (this.stallSkips++ === 0) {
10981
+ console.error(`[spill] withheld originals are skipped as workspace_unanswered while ${this.stalledSpills.size} write(s) past their deadline are under way (first: ${place.path})`);
10982
+ }
10983
+ return { skipped: 'workspace_unanswered' };
10984
+ }
10985
+ const remaining = deadline - Date.now();
10986
+ // The guard says once per batch that it used its deadline.
10987
+ if (remaining <= 0)
10988
+ return { skipped: 'batch_deadline' };
10989
+ const write = this.writeToolResultFile(label, text, branch);
10990
+ let timer;
10991
+ const late = new Promise((resolve) => {
10992
+ timer = setTimeout(() => {
10993
+ // Marked as the deadline passes, before anything else runs, so no
10994
+ // spill that starts after it finds the workspace answering.
10995
+ const stalled = write.then(() => { }, () => { }).finally(() => {
10996
+ this.stalledSpills.delete(stalled);
10997
+ if (this.stalledSpills.size === 0 && this.stallSkips > 0) {
10998
+ console.error(`[spill] the workspace answered again; ${this.stallSkips} withheld original(s) were skipped as workspace_unanswered meanwhile`);
10999
+ this.stallSkips = 0;
11000
+ }
11001
+ });
11002
+ this.stalledSpills.add(stalled);
11003
+ console.error(`[spill] ${place.path} still being written at the withheld batch deadline`);
11004
+ resolve('late');
11005
+ }, remaining);
11006
+ timer.unref?.();
11007
+ });
11008
+ try {
11009
+ const written = await Promise.race([write, late]);
11010
+ if (written !== 'late') {
11011
+ if (!written)
11012
+ return null;
11013
+ if (written.branchLeft)
11014
+ return { skipped: 'branch_changed' };
11015
+ return { path: written.path, ...(written.error !== undefined ? { error: written.error } : {}) };
11016
+ }
11017
+ return { path: place.path, unfinished: true };
11018
+ }
11019
+ finally {
11020
+ clearTimeout(timer);
11021
+ }
11022
+ }
11023
+ /**
11024
+ * Write one tool result's text to its tool-results file (see
11025
+ * toolResultFilePlace): the one place both the inline cap's spill and the
11026
+ * guard's withheld originals go. null when there is no writable workspace.
11027
+ * A failed write is reported, traced and logged here, never thrown. With a
11028
+ * branch, the file lands on that branch or isn't written (see writeBinary).
11029
+ */
11030
+ async writeToolResultFile(label, content, branch) {
11031
+ const place = this.toolResultFilePlace(label);
11032
+ if (!place)
11033
+ return null;
11034
+ const { workspace, path, safeLabel } = place;
11035
+ let failure;
11036
+ try {
11037
+ const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain', { branch });
11038
+ if (result.success)
11039
+ return { path };
11040
+ // The file is in the workspace's Chronicle tree, where its tools read
11041
+ // it, and the rest of the write failed (its copy on disk, or something
11042
+ // after the entry was set): it is written for every reader the stub or
11043
+ // the truncation note sends there. The workspace emits
11044
+ // workspace:materialize-failed only for a refused push, so the failure
11045
+ // is traced here too.
11046
+ if (result.data?.inChronicle === true) {
11047
+ const error = (result.error ?? 'the rest of the write failed').slice(0, 200);
11048
+ this.emitTrace({ type: 'tool:spill_failed', label: safeLabel, path, contentLength: content.length, error });
11049
+ console.error(`[spill] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${error}`);
11050
+ return { path };
11051
+ }
11052
+ // Declined, not failed: the store left the branch the file is for, so
11053
+ // it isn't written anywhere, as the caller asked.
11054
+ if (result.data?.branchLeft === true) {
11055
+ return { path, error: result.error ?? 'the workspace had left the branch this file is for', branchLeft: true };
11056
+ }
11057
+ failure = result.error ?? 'write refused';
11058
+ }
11059
+ catch (err) {
11060
+ failure = err instanceof Error ? err.message : String(err);
11061
+ }
11062
+ failure = failure.slice(0, 200);
11063
+ this.emitTrace({
11064
+ type: 'tool:spill_failed',
11065
+ label: safeLabel,
11066
+ path,
11067
+ contentLength: content.length,
11068
+ error: failure,
11069
+ });
11070
+ console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
11071
+ return { path, error: failure };
11072
+ }
10072
11073
  async spillOrTruncate(content, cap, label) {
10073
11074
  if (!cap || content.length <= cap)
10074
11075
  return { text: content, filePath: null };
@@ -10079,51 +11080,32 @@ export class AgentFramework {
10079
11080
  // slots at or past the cut, re-appended after the notice.
10080
11081
  const { head, tail } = splitPreservingImageSlots(content, cap);
10081
11082
  const kept = formatPreservedImageSlots(tail);
10082
- const workspace = this.getWorkspaceModule();
10083
- const mountName = workspace ? this.firstWritableMountName(workspace) : null;
10084
- if (workspace && mountName) {
10085
- const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
10086
- const path = `${mountName}/tool-results/${safeLabel}.txt`;
10087
- let failure;
10088
- try {
10089
- const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
10090
- if (result.success) {
10091
- // JSON serialization can expand each read-back code unit to six
10092
- // chars (\\u0000). Leave room for the page's metadata and a possible
10093
- // extra code unit at a surrogate-pair boundary.
10094
- const pageOverhead = JSON.stringify(path).length + 256;
10095
- const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
10096
- return {
10097
- text: head
10098
- + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
10099
- + `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
10100
- + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
10101
- + 'agent_settings update tool_result_inline_max_chars.]'
10102
- + kept,
10103
- filePath: path,
10104
- };
10105
- }
10106
- failure = result.error ?? 'write refused';
10107
- }
10108
- catch (err) {
10109
- failure = err instanceof Error ? err.message : String(err);
10110
- }
11083
+ const written = await this.writeToolResultFile(label, content);
11084
+ if (written && written.error === undefined) {
11085
+ const path = written.path;
11086
+ // JSON serialization can expand each read-back code unit to six
11087
+ // chars (\\u0000). Leave room for the page's metadata and a possible
11088
+ // extra code unit at a surrogate-pair boundary.
11089
+ const pageOverhead = JSON.stringify(path).length + 256;
11090
+ const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
11091
+ return {
11092
+ text: head
11093
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
11094
+ + `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
11095
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
11096
+ + 'agent_settings update tool_result_inline_max_chars.]'
11097
+ + kept,
11098
+ filePath: path,
11099
+ };
11100
+ }
11101
+ if (written) {
10111
11102
  // A workspace was there and the write FAILED — say that, loudly and
10112
11103
  // distinctly. Telling the agent "no writable workspace" would teach
10113
11104
  // them their residence lacks a capability it actually has.
10114
- failure = failure.slice(0, 200);
10115
- this.emitTrace({
10116
- type: 'tool:spill_failed',
10117
- label: safeLabel,
10118
- path,
10119
- contentLength: content.length,
10120
- error: failure,
10121
- });
10122
- console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
10123
11105
  return {
10124
11106
  text: head
10125
- + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
10126
- + ` (${failure}); content over the cap was not retained]`
11107
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
11108
+ + ` (${written.error}); content over the cap was not retained]`
10127
11109
  + kept,
10128
11110
  filePath: null,
10129
11111
  };
@@ -10145,7 +11127,8 @@ export class AgentFramework {
10145
11127
  scriptTimeoutMs: cfg?.scriptTimeoutMs,
10146
11128
  idleReclaimMs: cfg?.idleReclaimMs,
10147
11129
  label: agentName,
10148
- onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
11130
+ // The script that made the call is the one running when it arrives.
11131
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args, this.foregroundScriptOrigins.get(agentName), this.scriptDeferredEndTurn.get(agentName)),
10149
11132
  });
10150
11133
  this.codeExecutionRunners.set(agentName, runner);
10151
11134
  }
@@ -10160,22 +11143,24 @@ export class AgentFramework {
10160
11143
  * placeholders): programmatic tool results are text-only by contract, and
10161
11144
  * a script variable holding megabytes of base64 helps nobody.
10162
11145
  */
10163
- async handleScriptToolCall(agentName, toolName, args) {
11146
+ async handleScriptToolCall(agentName, toolName, args, origin,
11147
+ /** The foreground script that made the call; none for a background script. */
11148
+ script) {
10164
11149
  if (toolName === CODE_EXECUTION_TOOL_NAME) {
10165
11150
  return 'Error: code_execution cannot be called from within a script';
10166
11151
  }
10167
- const result = await this.dispatchScriptToolCall(agentName, toolName, args);
10168
- if (result.endTurn) {
11152
+ const result = await this.dispatchScriptToolCall(agentName, toolName, args, origin);
11153
+ if (result.endTurn && script) {
10169
11154
  // Deferred: applied to the final code_execution result (see
10170
11155
  // scriptDeferredEndTurn) — ending the turn mid-script would cancel the
10171
11156
  // stream while the script still runs and wedge the tool round.
10172
- this.scriptDeferredEndTurn.add(agentName);
11157
+ script.endTurn = true;
10173
11158
  }
10174
11159
  if (result.isError) {
10175
11160
  return `Error: ${result.error ?? 'tool call failed'}`;
10176
11161
  }
10177
- if (result.data === undefined)
10178
- return '';
11162
+ if (result.data === undefined || result.data === null)
11163
+ return nonErrorResultText(result, 5_000_000);
10179
11164
  // RFC-005 "eagerly lazy": a script receiving a result is the strongest
10180
11165
  // available signal that the bytes are wanted (scripts process, they do
10181
11166
  // not browse) — materialize every referenced payload now, so the stub
@@ -10203,8 +11188,12 @@ export class AgentFramework {
10203
11188
  * resolve with its result. The waiter intercept in handleProcessEvent
10204
11189
  * (keyed by the pytc- call ID) routes the tool-result event here instead
10205
11190
  * of into the agent's pending tool round.
11191
+ *
11192
+ * RFC-007: with an `origin`, observers see the call as they see a model
11193
+ * call (register, dispatch with its refusal sites, open; the terminal at
11194
+ * the waiter intercept), under the inner tool's own name and class.
10206
11195
  */
10207
- dispatchScriptToolCall(agentName, toolName, args) {
11196
+ dispatchScriptToolCall(agentName, toolName, args, origin) {
10208
11197
  return new Promise((resolve) => {
10209
11198
  const callId = `pytc-${randomUUID()}`;
10210
11199
  // Host-side leak guard: the python runtime already raises TimeoutError
@@ -10213,6 +11202,9 @@ export class AgentFramework {
10213
11202
  const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
10214
11203
  const safety = setTimeout(() => {
10215
11204
  if (this.scriptToolWaiters.delete(callId)) {
11205
+ // RFC-007: no result reached the host — the call failed.
11206
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
11207
+ this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
10216
11208
  resolve({
10217
11209
  success: false,
10218
11210
  error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
@@ -10225,16 +11217,31 @@ export class AgentFramework {
10225
11217
  clearTimeout(safety);
10226
11218
  resolve(result);
10227
11219
  });
11220
+ const call = { id: callId, name: toolName, input: args };
11221
+ if (origin)
11222
+ this.toolLifecycleEmitter?.registerScriptCall(agentName, origin, call);
10228
11223
  try {
10229
- this.dispatchToolCall(agentName, { id: callId, name: toolName, input: args });
11224
+ this.dispatchToolCall(agentName, call);
10230
11225
  }
10231
11226
  catch (error) {
11227
+ // Dispatch threw. Not a refusal (refusal sites call refuse() and
11228
+ // answer): the tool may have done work first (sleep sets the gate,
11229
+ // then formats its reply), so the call started and the host got no
11230
+ // result — `failed`, as for a dispatch that rejects.
11231
+ if (origin) {
11232
+ this.toolLifecycleEmitter?.open(agentName, callId);
11233
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
11234
+ this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
11235
+ }
10232
11236
  if (this.scriptToolWaiters.delete(callId)) {
10233
11237
  clearTimeout(safety);
10234
11238
  const err = error instanceof Error ? error : new Error(String(error));
10235
11239
  resolve({ success: false, error: err.message, isError: true });
10236
11240
  }
11241
+ return;
10237
11242
  }
11243
+ if (origin)
11244
+ this.toolLifecycleEmitter?.open(agentName, callId);
10238
11245
  });
10239
11246
  }
10240
11247
  /** Abort a running script when the agent's turn dies underneath it. */
@@ -10565,6 +11572,11 @@ export class AgentFramework {
10565
11572
  dispatchToolCall(agentName, call) {
10566
11573
  // Enrich call with caller identity so modules can resolve the calling agent
10567
11574
  const enrichedCall = { ...call, callerAgentName: agentName };
11575
+ if (isPresentationTool(call.name)) {
11576
+ const result = this.editToolPresentation(agentName, enrichedCall);
11577
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tool-presentation', result });
11578
+ return;
11579
+ }
10568
11580
  // Route MCPL tool calls to the appropriate server via prefix map
10569
11581
  const mcplMatch = this.resolveMcplTool(enrichedCall.name);
10570
11582
  if (mcplMatch && this.mcplServerRegistry) {
@@ -10612,10 +11624,16 @@ export class AgentFramework {
10612
11624
  SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
10613
11625
  void this.tuneOutCoordinator
10614
11626
  .handleSubconsciousTool(enrichedCall.name, enrichedCall.input)
11627
+ // A rejection is answered with a failed result, as a framework tool's
11628
+ // dispatch failure is (dispatchUtilsToolCall), never left unhandled.
11629
+ .catch((err) => ({ success: false, error: thrownText(err), isError: true }))
10615
11630
  .then((result) => {
10616
- this.queue.push({
11631
+ // Through pushEvent, like every other tool's result, so one that
11632
+ // arrives after stop() is dropped rather than rejected unhandled.
11633
+ this.pushEvent({
10617
11634
  type: 'tool-result',
10618
11635
  callId: enrichedCall.id,
11636
+ toolName: enrichedCall.name,
10619
11637
  agentName,
10620
11638
  moduleName: 'tune-out',
10621
11639
  result,
@@ -10681,6 +11699,7 @@ export class AgentFramework {
10681
11699
  this.pushEvent({
10682
11700
  type: 'tool-result',
10683
11701
  callId: enrichedCall.id,
11702
+ toolName: enrichedCall.name,
10684
11703
  agentName,
10685
11704
  moduleName: 'framework',
10686
11705
  result: { success: true, data: PROSE_ROUTING_HELP },
@@ -10710,7 +11729,7 @@ export class AgentFramework {
10710
11729
  });
10711
11730
  const startTime = Date.now();
10712
11731
  this.moduleRegistry
10713
- .handleToolCall(call)
11732
+ .storableToolCall({ ...call, callerAgentName: agentName })
10714
11733
  .then((result) => {
10715
11734
  const durationMs = Date.now() - startTime;
10716
11735
  this.emitTrace({
@@ -10723,6 +11742,7 @@ export class AgentFramework {
10723
11742
  this.pushEvent({
10724
11743
  type: 'tool-result',
10725
11744
  callId: call.id,
11745
+ toolName: call.name,
10726
11746
  agentName,
10727
11747
  moduleName,
10728
11748
  result,
@@ -10743,6 +11763,7 @@ export class AgentFramework {
10743
11763
  this.pushEvent({
10744
11764
  type: 'tool-result',
10745
11765
  callId: call.id,
11766
+ toolName: call.name,
10746
11767
  agentName,
10747
11768
  moduleName,
10748
11769
  result: {
@@ -11267,7 +12288,7 @@ export class AgentFramework {
11267
12288
  */
11268
12289
  async initializeMcpl(serverConfigs, inferenceRouting) {
11269
12290
  this.mcplServerRegistry = new McplServerRegistry();
11270
- this.featureSetManager = new FeatureSetManager();
12291
+ this.featureSetManager = new FeatureSetManager((event) => this.emitTrace(event));
11271
12292
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
11272
12293
  this.toolLifecycleEmitter = new ToolLifecycleEmitter({
11273
12294
  observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
@@ -11782,6 +12803,18 @@ export class AgentFramework {
11782
12803
  * coalescing. */
11783
12804
  manifestRefreshState = new Map();
11784
12805
  static MANIFEST_FETCH_FLOOR_MS = 5_000;
12806
+ /**
12807
+ * The `mcpl:policy-refused` fields from a refusal receipt, as the host
12808
+ * acts on them: the receipt is server-supplied, so `fallback` is the
12809
+ * branch actually taken (anything but `close` is treated as `mcp-only`)
12810
+ * and a non-string `reason` is dropped.
12811
+ */
12812
+ static policyRefusalFields(receipt) {
12813
+ return {
12814
+ fallback: receipt.fallback === 'close' ? 'close' : 'mcp-only',
12815
+ reason: typeof receipt.reason === 'string' ? receipt.reason : null,
12816
+ };
12817
+ }
11785
12818
  /**
11786
12819
  * §17.5 host processing: fetch the complete manifest, validate exactly as
11787
12820
  * at initialize, diff, apply §6.7 consequences (removals eagerly even
@@ -11885,6 +12918,12 @@ export class AgentFramework {
11885
12918
  if (receipt && receipt.accepted === false) {
11886
12919
  const fallback = receipt.fallback ?? 'mcp-only';
11887
12920
  console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
12921
+ this.emitTrace({
12922
+ type: 'mcpl:policy-refused',
12923
+ serverId: connection.id,
12924
+ phase: 'manifest-change',
12925
+ ...AgentFramework.policyRefusalFields(receipt),
12926
+ });
11888
12927
  if (fallback === 'close') {
11889
12928
  await connection.close();
11890
12929
  return;
@@ -11982,6 +13021,12 @@ export class AgentFramework {
11982
13021
  if (receipt && receipt.accepted === false) {
11983
13022
  const fallback = receipt.fallback ?? 'mcp-only';
11984
13023
  console.error(`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`);
13024
+ this.emitTrace({
13025
+ type: 'mcpl:policy-refused',
13026
+ serverId: config.id,
13027
+ phase: 'initial',
13028
+ ...AgentFramework.policyRefusalFields(receipt),
13029
+ });
11985
13030
  if (fallback === 'close') {
11986
13031
  await connection.close();
11987
13032
  return;
@@ -12160,39 +13205,88 @@ export class AgentFramework {
12160
13205
  // framework; the ScopeManager and config `scopes` wiring went with it.
12161
13206
  // Handle push events (Step 6)
12162
13207
  connection.on('push-event', async (params, responder) => {
12163
- const barrier = this.discordAwarenessBarrier;
12164
- if (barrier)
12165
- await barrier.promise;
12166
- await this.pushHandler?.handlePushEvent(connection.id, params, responder);
13208
+ const answer = answerOnce(responder, 'push/event', connection.id);
13209
+ try {
13210
+ const barrier = this.discordAwarenessBarrier;
13211
+ if (barrier)
13212
+ await barrier.promise;
13213
+ // Checked here, after the barrier and before anything records the
13214
+ // push as accepted: a stopped host refuses it.
13215
+ if (this.queue?.isClosed) {
13216
+ this.refuseServerItem('push/event', connection.id, answer, { kind: 'refused' });
13217
+ return;
13218
+ }
13219
+ await this.pushHandler?.handlePushEvent(connection.id, params, answer.responder);
13220
+ }
13221
+ catch (error) {
13222
+ this.refuseServerItem('push/event', connection.id, answer, { kind: 'failed', error });
13223
+ }
12167
13224
  });
12168
13225
  // Handle server-initiated inference requests (Step 6)
12169
13226
  connection.on('inference-request', async (params, responder) => {
12170
13227
  // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
12171
13228
  if (this.pushCoalescer?.isRendering(connection.id)) {
12172
- responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
13229
+ try {
13230
+ responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
13231
+ }
13232
+ catch {
13233
+ // The connection is going; there is no one left to tell.
13234
+ }
12173
13235
  return;
12174
13236
  }
12175
13237
  if (this.inferenceRouter && responder) {
12176
- const barrier = this.discordAwarenessBarrier;
12177
- if (barrier)
12178
- await barrier.promise;
12179
- await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
12180
- respond: responder.respond,
12181
- respondError: responder.respondError,
12182
- requestId: responder.id,
12183
- });
13238
+ const answer = answerOnce(responder, 'inference/request', connection.id);
13239
+ try {
13240
+ const barrier = this.discordAwarenessBarrier;
13241
+ if (barrier)
13242
+ await barrier.promise;
13243
+ // A stopping host starts no inference for a server: refused, like a
13244
+ // push, before the router runs.
13245
+ if (this.queue?.isClosed) {
13246
+ this.refuseServerItem('inference/request', connection.id, answer, { kind: 'refused' });
13247
+ return;
13248
+ }
13249
+ await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
13250
+ respond: answer.responder.respond,
13251
+ respondError: answer.responder.respondError,
13252
+ requestId: responder.id,
13253
+ });
13254
+ }
13255
+ catch (error) {
13256
+ this.refuseServerItem('inference/request', connection.id, answer, { kind: 'failed', error });
13257
+ }
12184
13258
  }
12185
13259
  });
12186
13260
  // Handle channel registration (Step 7)
12187
13261
  connection.on('channels-register', async (params, responder) => {
12188
- await this.channelRegistry?.handleRegister(connection.id, params, responder);
13262
+ const answer = answerOnce(responder, 'channels/register', connection.id);
13263
+ try {
13264
+ if (this.queue?.isClosed) {
13265
+ this.refuseServerItem('channels/register', connection.id, answer, { kind: 'refused' });
13266
+ return;
13267
+ }
13268
+ await this.channelRegistry?.handleRegister(connection.id, params, answer.responder);
13269
+ }
13270
+ catch (error) {
13271
+ this.refuseServerItem('channels/register', connection.id, answer, { kind: 'failed', error });
13272
+ }
12189
13273
  });
12190
13274
  // Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
12191
13275
  // present for the Request form and carries itemized added-descriptor
12192
13276
  // results; absent for Notifications, where rejection is itemwise
12193
13277
  // filtering plus a diagnostic trace.
12194
13278
  connection.on('channels-changed', async (params, responder) => {
12195
- await this.channelRegistry?.handleChanged(connection.id, params, responder);
13279
+ const answer = answerOnce(responder, 'channels/changed', connection.id);
13280
+ try {
13281
+ if (this.queue?.isClosed) {
13282
+ this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'refused' });
13283
+ return;
13284
+ }
13285
+ await this.channelRegistry?.handleChanged(connection.id, params, answer.responder);
13286
+ }
13287
+ catch (error) {
13288
+ this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'failed', error });
13289
+ }
12196
13290
  });
12197
13291
  // §6.6/§12: requests that previously had no handler and hung forever
12198
13292
  // (AUDIT-001 item 4). The connection-level grant gate has already run.
@@ -12235,25 +13329,41 @@ export class AgentFramework {
12235
13329
  });
12236
13330
  // Handle incoming channel messages (Step 7)
12237
13331
  connection.on('channels-incoming', async (params, responder) => {
12238
- const barrier = this.discordAwarenessBarrier;
12239
- if (barrier)
12240
- await barrier.promise;
12241
- await this.channelRegistry?.handleIncoming(connection.id, params, responder);
13332
+ const answer = answerOnce(responder, 'channels/incoming', connection.id);
13333
+ try {
13334
+ const barrier = this.discordAwarenessBarrier;
13335
+ if (barrier)
13336
+ await barrier.promise;
13337
+ if (this.queue?.isClosed) {
13338
+ this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'refused' });
13339
+ return;
13340
+ }
13341
+ await this.channelRegistry?.handleIncoming(connection.id, params, answer.responder);
13342
+ }
13343
+ catch (error) {
13344
+ this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'failed', error });
13345
+ }
12242
13346
  });
12243
13347
  // Handle host-level admin commands from a surface (e.g. Discord /undo)
12244
13348
  connection.on('host-command', async (params, responder) => {
12245
13349
  if (!responder)
12246
13350
  return;
13351
+ const answer = answerOnce(responder, 'host/command', connection.id);
12247
13352
  try {
12248
13353
  const barrier = this.discordAwarenessBarrier;
12249
13354
  if (barrier)
12250
13355
  await barrier.promise;
13356
+ // A stopping host runs no host command: refused like the requests
13357
+ // above, before its handler runs.
13358
+ if (this.queue?.isClosed) {
13359
+ this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
13360
+ return;
13361
+ }
12251
13362
  const result = await this.handleHostCommand(connection.id, params ?? {});
12252
- responder.respond(result);
13363
+ answer.responder.respond(result);
12253
13364
  }
12254
13365
  catch (error) {
12255
- const err = error instanceof Error ? error : new Error(String(error));
12256
- responder.respondError(-32603, err.message);
13366
+ this.refuseServerItem('host/command', connection.id, answer, { kind: 'failed', error });
12257
13367
  }
12258
13368
  });
12259
13369
  // Handle dynamic tool list changes (notifications/tools/list_changed)
@@ -12399,11 +13509,13 @@ export class AgentFramework {
12399
13509
  const toolFeatureSets = new Map();
12400
13510
  const toolClasses = new Map();
12401
13511
  const toolServers = new Map();
13512
+ const listedServers = new Set();
12402
13513
  for (const server of this.mcplServerRegistry.getAllServers()) {
12403
13514
  const config = this.mcplServerConfigs.get(server.id);
12404
13515
  const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
12405
13516
  try {
12406
13517
  const result = await server.sendToolsList();
13518
+ listedServers.add(server.id);
12407
13519
  for (const tool of result.tools) {
12408
13520
  if (!isToolAllowed(tool.name, config))
12409
13521
  continue;
@@ -12433,13 +13545,106 @@ export class AgentFramework {
12433
13545
  }
12434
13546
  }
12435
13547
  catch {
12436
- // Server may not support tools/list — skip silently
13548
+ // Server may not support tools/list — skip silently. A live server
13549
+ // whose handshake advertised no MCP `tools` has, in effect, listed
13550
+ // nothing; count it as listed so tool-pattern checks do not wait on
13551
+ // it forever. (A server that advertised tools but failed to list
13552
+ // them stays unknown.)
13553
+ if (server.isConnected && !server.mcpToolsAdvertised)
13554
+ listedServers.add(server.id);
12437
13555
  }
12438
13556
  }
12439
13557
  this.mcplTools = tools;
12440
13558
  this.mcplToolFeatureSets = toolFeatureSets;
12441
13559
  this.mcplToolClasses = toolClasses;
12442
13560
  this.mcplToolServers = toolServers;
13561
+ this.mcplListedServers = listedServers;
13562
+ if (this.toolPatternChecksArmed)
13563
+ this.checkToolPatterns();
13564
+ }
13565
+ /**
13566
+ * Report operator tool-name patterns that match no model-facing tool:
13567
+ * `toolClassOverrides` keys and each server's `toolLifecycle.observe.tools`
13568
+ * / `toolLifecycle.inputs.tools`. These match `<toolPrefix>--<tool>`, and
13569
+ * the default prefix is `mcpl--<serverId>`, so a pattern written against
13570
+ * the bare server id is valid config that silently does nothing.
13571
+ *
13572
+ * Each pattern is reported at most once (console line + a
13573
+ * `mcpl:tool-pattern-unmatched` trace). A pattern that could still name
13574
+ * tools of a server whose listing is not in (not connected yet,
13575
+ * reconnecting, restarting) stays undecided until that listing arrives.
13576
+ * Diagnostic only: never throws into the refresh that called it.
13577
+ */
13578
+ checkToolPatterns() {
13579
+ try {
13580
+ const entries = [];
13581
+ for (const [pattern] of this.toolClassOverrides ?? []) {
13582
+ entries.push({ setting: 'toolClassOverrides', pattern });
13583
+ }
13584
+ const configs = [...(this.mcplServerConfigs?.values() ?? [])];
13585
+ for (const config of configs) {
13586
+ const lifecycle = config.toolLifecycle;
13587
+ for (const key of ['observe', 'inputs']) {
13588
+ const tools = lifecycle?.[key]?.tools;
13589
+ if (!Array.isArray(tools))
13590
+ continue;
13591
+ for (const pattern of tools) {
13592
+ if (typeof pattern === 'string') {
13593
+ entries.push({ setting: `toolLifecycle.${key}.tools`, serverId: config.id, pattern });
13594
+ }
13595
+ }
13596
+ }
13597
+ }
13598
+ const reported = (this.toolPatternsReported ??= new Set());
13599
+ const keyOf = (e) => `${e.serverId ?? ''}\u0000${e.setting}\u0000${e.pattern}`;
13600
+ const pending = entries.filter((e) => !reported.has(keyOf(e)));
13601
+ if (pending.length === 0)
13602
+ return;
13603
+ const known = (this.toolPatternKnownPrefixes ??= new Map());
13604
+ for (const c of configs)
13605
+ known.set(c.toolPrefix ?? `mcpl--${c.id}`, c.id);
13606
+ const servers = [...known].map(([prefix, id]) => {
13607
+ const current = this.mcplServerConfigs?.get(id);
13608
+ return {
13609
+ id,
13610
+ prefix,
13611
+ listed: !!current
13612
+ && (current.toolPrefix ?? `mcpl--${id}`) === prefix
13613
+ && (this.mcplListedServers?.has(id) ?? false),
13614
+ };
13615
+ });
13616
+ const names = this.modelFacingToolNames();
13617
+ for (const entry of pending) {
13618
+ const verdict = checkToolPattern(entry.pattern, names, servers);
13619
+ if (verdict.kind !== 'unmatched')
13620
+ continue;
13621
+ reported.add(keyOf(entry));
13622
+ const where = entry.serverId ? `${entry.serverId}: ${entry.setting}` : entry.setting;
13623
+ console.error(describeUnmatchedPattern(where, entry.pattern, verdict));
13624
+ this.emitTrace({
13625
+ type: 'mcpl:tool-pattern-unmatched',
13626
+ setting: entry.setting,
13627
+ ...(entry.serverId ? { serverId: entry.serverId } : {}),
13628
+ pattern: entry.pattern,
13629
+ ...(verdict.suggestion ? { suggestion: verdict.suggestion } : {}),
13630
+ hint: verdict.hint,
13631
+ });
13632
+ }
13633
+ }
13634
+ catch (error) {
13635
+ console.error('[mcpl] tool pattern check failed:', error instanceof Error ? error.message : error);
13636
+ }
13637
+ }
13638
+ /** Every tool name the framework offers to anyone: the shared board plus
13639
+ * each agent's own surface, before presentation visibility (a hidden tool
13640
+ * is still callable, so classes and lifecycle still apply to it). */
13641
+ modelFacingToolNames() {
13642
+ const names = new Set(this.getAllTools().map((t) => t.name));
13643
+ for (const agent of this.agents.values()) {
13644
+ for (const tool of this.availableToolsForPresentation(agent.name))
13645
+ names.add(tool.name);
13646
+ }
13647
+ return names;
12443
13648
  }
12444
13649
  /**
12445
13650
  * Handle a tools/list_changed notification with collapse logic.
@@ -12502,6 +13707,7 @@ export class AgentFramework {
12502
13707
  this.pushEvent({
12503
13708
  type: 'tool-result',
12504
13709
  callId: call.id,
13710
+ toolName: call.name,
12505
13711
  agentName,
12506
13712
  moduleName: `mcpl:${serverId}`,
12507
13713
  result: { success: false, error: `MCPL server not found: ${serverId}`, isError: true },
@@ -12515,6 +13721,7 @@ export class AgentFramework {
12515
13721
  this.pushEvent({
12516
13722
  type: 'tool-result',
12517
13723
  callId: call.id,
13724
+ toolName: call.name,
12518
13725
  agentName,
12519
13726
  moduleName: `mcpl:${serverId}`,
12520
13727
  result: {
@@ -12652,6 +13859,7 @@ export class AgentFramework {
12652
13859
  this.pushEvent({
12653
13860
  type: 'tool-result',
12654
13861
  callId: call.id,
13862
+ toolName: call.name,
12655
13863
  agentName,
12656
13864
  moduleName: `mcpl:${serverId}`,
12657
13865
  result: {
@@ -12670,6 +13878,7 @@ export class AgentFramework {
12670
13878
  this.pushEvent({
12671
13879
  type: 'tool-result',
12672
13880
  callId: call.id,
13881
+ toolName: call.name,
12673
13882
  agentName,
12674
13883
  moduleName: `mcpl:${serverId}`,
12675
13884
  result: { success: false, error: err.message, isError: true },
@@ -12700,6 +13909,7 @@ export class AgentFramework {
12700
13909
  this.pushEvent({
12701
13910
  type: 'tool-result',
12702
13911
  callId: call.id,
13912
+ toolName: call.name,
12703
13913
  agentName,
12704
13914
  moduleName: 'channels',
12705
13915
  result: { success: false, error, isError: true },
@@ -12763,6 +13973,7 @@ export class AgentFramework {
12763
13973
  this.pushEvent({
12764
13974
  type: 'tool-result',
12765
13975
  callId: call.id,
13976
+ toolName: call.name,
12766
13977
  agentName,
12767
13978
  moduleName: 'channels',
12768
13979
  result,
@@ -12775,6 +13986,7 @@ export class AgentFramework {
12775
13986
  this.pushEvent({
12776
13987
  type: 'tool-result',
12777
13988
  callId: call.id,
13989
+ toolName: call.name,
12778
13990
  agentName,
12779
13991
  moduleName: 'channels',
12780
13992
  result: { success: false, error: err.message, isError: true },
@@ -12795,7 +14007,7 @@ export class AgentFramework {
12795
14007
  module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
12796
14008
  ...(result.isError ? { error: result.error } : {}),
12797
14009
  });
12798
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
14010
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
12799
14011
  };
12800
14012
  try {
12801
14013
  if (call.name === 'wake_remove_rule') {
@@ -12953,9 +14165,10 @@ export class AgentFramework {
12953
14165
  }
12954
14166
  : { success: false, error: r.error, isError: false };
12955
14167
  }
12956
- this.queue.push({
14168
+ this.pushEvent({
12957
14169
  type: 'tool-result',
12958
14170
  callId: call.id,
14171
+ toolName: call.name,
12959
14172
  agentName,
12960
14173
  moduleName: 'tune-out',
12961
14174
  result,
@@ -12967,7 +14180,7 @@ export class AgentFramework {
12967
14180
  const input = (call.input ?? {});
12968
14181
  const finish = (result) => {
12969
14182
  this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
12970
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
14183
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
12971
14184
  };
12972
14185
  if (call.name === 'wake') {
12973
14186
  const was = gate.clearSleep();
@@ -12997,7 +14210,7 @@ export class AgentFramework {
12997
14210
  else if (agent?.proseRouting === 'explicit') {
12998
14211
  // Explicit mode: announce to the turn's sticky prose target if the
12999
14212
  // model has set one; otherwise stay quiet — never guess a channel.
13000
- const target = this.proseTargetPins.get(agentName);
14213
+ const target = this.turnRouting.get(agentName)?.pin;
13001
14214
  if (target) {
13002
14215
  this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
13003
14216
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
@@ -13067,7 +14280,7 @@ export class AgentFramework {
13067
14280
  ...(result.isError ? { error: result.error } : {}),
13068
14281
  });
13069
14282
  this.pushEvent({
13070
- type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
14283
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace',
13071
14284
  result: { ...result, isError: result.isError ?? false },
13072
14285
  });
13073
14286
  })();
@@ -13083,7 +14296,7 @@ export class AgentFramework {
13083
14296
  durationMs: 0,
13084
14297
  ...(result.isError ? { error: result.error } : {}),
13085
14298
  });
13086
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
14299
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
13087
14300
  };
13088
14301
  void (async () => {
13089
14302
  try {
@@ -13106,7 +14319,11 @@ export class AgentFramework {
13106
14319
  if (typeof workspace.handleToolCall === 'function' &&
13107
14320
  typeof workspace.getTools === 'function' &&
13108
14321
  workspace.getTools().some((tool) => tool.name === 'read_image')) {
13109
- const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
14322
+ // The workspace module's own handler, called directly: its result
14323
+ // gets the same check a module's result gets in the registry. The
14324
+ // agent's name is for the check's log line, not the handler.
14325
+ const moduleCall = { ...call, name: 'read_image' };
14326
+ const result = storableToolResult({ ...moduleCall, callerAgentName: agentName }, await workspace.handleToolCall(moduleCall));
13110
14327
  if (result.success && Array.isArray(result.data)) {
13111
14328
  const image = result.data.find((block) => typeof block === 'object' && block !== null &&
13112
14329
  block.type === 'image' &&
@@ -13144,11 +14361,7 @@ export class AgentFramework {
13144
14361
  });
13145
14362
  }
13146
14363
  catch (error) {
13147
- finish({
13148
- success: false,
13149
- error: error instanceof Error ? error.message : String(error),
13150
- isError: true,
13151
- });
14364
+ finish(thrownResult(error));
13152
14365
  }
13153
14366
  })();
13154
14367
  }
@@ -13212,7 +14425,7 @@ export class AgentFramework {
13212
14425
  durationMs: 0,
13213
14426
  ...(result.isError ? { error: result.error } : {}),
13214
14427
  });
13215
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
14428
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
13216
14429
  };
13217
14430
  void (async () => {
13218
14431
  try {
@@ -13453,20 +14666,35 @@ export class AgentFramework {
13453
14666
  for (const image of found) {
13454
14667
  const destination = pathFor(image.rangeOffset);
13455
14668
  const bytes = Buffer.from(image.data, 'base64');
13456
- const result = await workspace.writeBinary(destination, bytes, image.mediaType);
13457
- if (!result.success) {
13458
- finish({
13459
- success: false,
13460
- error: `save_recent_image: failed writing "${destination}": ${result.error}` +
13461
- (savedFiles.length > 0
13462
- ? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
13463
- : ''),
13464
- isError: true,
13465
- });
14669
+ const failed = (why) => finish({
14670
+ success: false,
14671
+ error: `save_recent_image: failed writing "${destination}": ${why instanceof Error ? why.message : String(why)}` +
14672
+ (savedFiles.length > 0
14673
+ ? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
14674
+ : ''),
14675
+ isError: true,
14676
+ });
14677
+ let result;
14678
+ try {
14679
+ result = await workspace.writeBinary(destination, bytes, image.mediaType);
14680
+ }
14681
+ catch (error) {
14682
+ // Thrown before its tree entry was set; the images before it
14683
+ // are saved, and the failure says so, as a returned one does.
14684
+ failed(error);
14685
+ return;
14686
+ }
14687
+ const inChronicle = result.data?.inChronicle === true;
14688
+ if (!result.success && !inChronicle) {
14689
+ failed(result.error);
13466
14690
  return;
13467
14691
  }
13468
14692
  savedFiles.push({
13469
14693
  ...result.data,
14694
+ path: destination,
14695
+ size: bytes.byteLength,
14696
+ mimeType: image.mediaType,
14697
+ ...(!result.success ? { diskCopyError: result.error ?? 'disk copy failed' } : {}),
13470
14698
  imageIndex: index + image.rangeOffset,
13471
14699
  ...image.receipt,
13472
14700
  });
@@ -13494,7 +14722,7 @@ export class AgentFramework {
13494
14722
  * (Module.getUtilities). Shared by the model-facing dispatcher and the
13495
14723
  * programmatic path (executeToolCall), so code_execution scripts and
13496
14724
  * ephemerals reach utilities too. */
13497
- async handleUtilsToolCall(agentName, call) {
14725
+ async handleUtilsToolCall(agentName, call, options = {}) {
13498
14726
  const input = (call.input ?? {});
13499
14727
  const utilities = this.moduleRegistry.getAllUtilities();
13500
14728
  const available = () => utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
@@ -13528,13 +14756,12 @@ export class AgentFramework {
13528
14756
  };
13529
14757
  }
13530
14758
  // Same dispatch as a first-class tool call — the module cannot tell
13531
- // which surface the call came through.
13532
- return this.moduleRegistry.handleToolCall({
13533
- id: call.id,
13534
- name: def.name,
13535
- input: args,
13536
- callerAgentName: agentName,
13537
- });
14759
+ // which surface the call came through. An in-process caller gets the
14760
+ // module's own result; the model's path takes its checked form.
14761
+ const utilityCall = { id: call.id, name: def.name, input: args, callerAgentName: agentName };
14762
+ return options.inProcess
14763
+ ? this.moduleRegistry.handleToolCall(utilityCall)
14764
+ : this.moduleRegistry.storableToolCall(utilityCall);
13538
14765
  }
13539
14766
  default:
13540
14767
  return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
@@ -13559,6 +14786,7 @@ export class AgentFramework {
13559
14786
  this.pushEvent({
13560
14787
  type: 'tool-result',
13561
14788
  callId: call.id,
14789
+ toolName: call.name,
13562
14790
  agentName,
13563
14791
  moduleName: 'framework',
13564
14792
  result,
@@ -13729,6 +14957,7 @@ export class AgentFramework {
13729
14957
  this.pushEvent({
13730
14958
  type: 'tool-result',
13731
14959
  callId: call.id,
14960
+ toolName: call.name,
13732
14961
  agentName,
13733
14962
  moduleName: 'agent',
13734
14963
  result,
@@ -13769,7 +14998,7 @@ export class AgentFramework {
13769
14998
  this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
13770
14999
  result = { success: false, error: err.message, isError: true };
13771
15000
  }
13772
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
15001
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
13773
15002
  }
13774
15003
  dispatchGateToolCall(agentName, call) {
13775
15004
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
@@ -13781,6 +15010,7 @@ export class AgentFramework {
13781
15010
  this.pushEvent({
13782
15011
  type: 'tool-result',
13783
15012
  callId: call.id,
15013
+ toolName: call.name,
13784
15014
  agentName,
13785
15015
  moduleName: 'gate',
13786
15016
  result,
@@ -13793,6 +15023,7 @@ export class AgentFramework {
13793
15023
  this.pushEvent({
13794
15024
  type: 'tool-result',
13795
15025
  callId: call.id,
15026
+ toolName: call.name,
13796
15027
  agentName,
13797
15028
  moduleName: 'gate',
13798
15029
  result: { success: false, error: err.message, isError: true },