@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
package/src/framework.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } 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';
@@ -7,6 +8,7 @@ import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolRes
7
8
  import { MembraneError } from '@animalabs/membrane';
8
9
  import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
9
10
  import type { CacheWireReceipt } from './kv-unified-wire.js';
11
+ import type { SilentHeartbeatStamp } from './silent-heartbeat.js';
10
12
  import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
11
13
  import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
12
14
  import type {
@@ -59,32 +61,37 @@ import type {
59
61
  SameRoundThinkTextPolicy,
60
62
  } from './types/index.js';
61
63
  import { ProcessQueueImpl } from './queue.js';
64
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
62
65
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
63
66
  import { Agent } from './agent.js';
64
- import { ModuleRegistry, isStateExistsError } from './module-registry.js';
67
+ import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
65
68
  import { McplServerRegistry } from './mcpl/server-registry.js';
66
69
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
67
70
  import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
68
71
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
69
72
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
70
- import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
73
+ import { ToolLifecycleEmitter, parseToolObserveParams, type ScriptCallOrigin } from './mcpl/tool-lifecycle.js';
74
+ import { checkToolPattern, describeUnmatchedPattern, type PatternServer } from './mcpl/tool-pattern-check.js';
71
75
  import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass, type EffectiveToolClass, type ToolClass, type ToolClassSource } from './mcpl/tool-classes.js';
72
76
  import { PushHandler, convertBlock as convertPushBlock, type McplPushEvent } from './mcpl/push-handler.js';
73
77
  import {
74
78
  PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent,
75
- coalescingSubjectKey, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
79
+ coalescingSubjectKey, type AssemblyResult, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
76
80
  type CoalescingReceiptRecord,
77
81
  } from './mcpl/push-coalescer.js';
78
82
  import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, PushEventResult } from './mcpl/types.js';
83
+ import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './mcpl/visible-content.js';
79
84
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
80
85
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
81
86
  import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
82
87
  import { InferenceRouter } from './mcpl/inference-router.js';
83
88
  import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
84
- import { ConversationRouter } from './mcpl/conversation-router.js';
89
+ import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
85
90
  import { safeSlice } from './safe-slice.js';
86
91
  import type { WorkspaceModule } from './modules/workspace/index.js';
92
+ import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
87
93
  import {
94
+ nonErrorResultText,
88
95
  toolResultDataToHistoryString,
89
96
  truncateForHistory,
90
97
  DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
@@ -476,6 +483,97 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string):
476
483
  return { ...(metadata ?? {}), deferredWriteId: id } as MessageMetadata;
477
484
  }
478
485
 
486
+ /**
487
+ * A prose block's envelopes, as explicit mode routes them: every line whose
488
+ * text, after its leading whitespace, is `>>` and then a non-space opens a
489
+ * new envelope (first live use: Tilde 2026-07-24, one block carrying a
490
+ * letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
491
+ * parser delivered the aside literally into the first destination — exactly
492
+ * the leak class this mode exists to kill). Text before the first `>>` is
493
+ * its own unprefixed envelope. A quoted `>> arrow` (space after) stays body
494
+ * text. Blank envelopes are dropped, and an envelope's whitespace before its
495
+ * `>>` is trimmed.
496
+ */
497
+ function splitExplicitEnvelopes(rawText: string): string[] {
498
+ const envelopes: string[] = [];
499
+ let current: string[] = [];
500
+ for (const line of rawText.split('\n')) {
501
+ if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
502
+ envelopes.push(current.join('\n'));
503
+ current = [];
504
+ }
505
+ current.push(line);
506
+ }
507
+ if (current.length > 0) envelopes.push(current.join('\n'));
508
+ return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, ''));
509
+ }
510
+
511
+ /**
512
+ * An envelope of explicit prose as its round was admitted: for a `>>` target,
513
+ * the registry's answer then, which its delivery uses.
514
+ */
515
+ interface AdmittedEnvelope {
516
+ text: string;
517
+ resolved?: { channelId: string } | { error: string; candidates?: string[] };
518
+ }
519
+
520
+ /**
521
+ * Whether a stream resumes its logical turn rather than starting one: a budget
522
+ * restart, or a tool-result-guard retry. Such a stream keeps the turn's
523
+ * routing state (the sticky destination, the turn's destination) and queues
524
+ * its deliveries behind the turn's earlier ones.
525
+ */
526
+ function resumesLogicalTurn(reason: string | undefined): boolean {
527
+ return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
528
+ }
529
+
530
+ /**
531
+ * One turn's routing and delivery receipts (AgentFramework.turnRouting).
532
+ * A fresh turn gets a new object; a queued delivery keeps its own turn's.
533
+ */
534
+ interface TurnRouting {
535
+ /** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */
536
+ pin?: string;
537
+ /**
538
+ * Explicit mode: the destination the turn's delivered rounds have set,
539
+ * decided at each round boundary (roundDestination), where a stream that
540
+ * resumes the turn starts.
541
+ */
542
+ destination: string | null;
543
+ /** Hybrid: held back after a malformed or unresolved envelope, until a later `>>>` names a target. */
544
+ hybridHold: boolean;
545
+ /** What the logical turn keeps across a new stream (LogicalTurn). */
546
+ logical: LogicalTurn;
547
+ }
548
+
549
+ /**
550
+ * What every stream of one logical turn shares, from the fresh start that
551
+ * opens it until it settles (AgentFramework.openLogical): a framework retry,
552
+ * a re-inference after unstreamed tool results, a capacity-held turn's later
553
+ * compile. beginAgentTurn starts each with fresh routing, but a ` !` asked
554
+ * for in any of them stands, and the receipt at the turn's end reports every
555
+ * stream's deliveries, even ones that land after a new stream began.
556
+ */
557
+ interface LogicalTurn {
558
+ /** The turn asked for a ` !` continuation; taken once. */
559
+ continuation: boolean;
560
+ /** Successful plain-prose destinations in delivery order; deduped by the receipt. */
561
+ deliveries: string[];
562
+ /** Plain-prose segments the turn suppressed, reported by its receipt. */
563
+ suppressed: number;
564
+ }
565
+
566
+ function freshTurnRouting(): TurnRouting {
567
+ return { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } };
568
+ }
569
+
570
+ /** How a round's prose is delivered: kept private, and under which turn's routing. */
571
+ interface ProseRound {
572
+ keepPrivate?: boolean;
573
+ /** The routing state of the turn that queued the round. */
574
+ routing: TurnRouting;
575
+ }
576
+
479
577
  function isTurnContinuation(reason: string): boolean {
480
578
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
481
579
  || reason === 'tool_result_guard_retry';
@@ -765,6 +863,10 @@ class DefaultErrorPolicy implements ErrorPolicy {
765
863
 
766
864
  /** Default sync interval in milliseconds */
767
865
  const DEFAULT_SYNC_INTERVAL_MS = 1000;
866
+ /** Total time one withheld batch may spend awaiting workspace writes.
867
+ * At the deadline its unfinished write is named as such, and later
868
+ * originals stay in the audit. This bound applies to withheld spills. */
869
+ const WITHHELD_SPILL_DEADLINE_MS = 30_000;
768
870
  /** Poll pending context-strategy maintenance independently of user activity. */
769
871
  const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
770
872
  /** Bound one pass so a large backlog yields to inference and other agents. */
@@ -948,9 +1050,100 @@ function truncateReason(reason: string, max = 160): string {
948
1050
  return reason.length <= max ? reason : reason.slice(0, max) + '…';
949
1051
  }
950
1052
 
1053
+ /** The most of a thrown value's or a tool error's text that reaches a log line or a server's error answer. */
1054
+ const THROWN_TEXT_LIMIT = 500;
1055
+
1056
+ /**
1057
+ * What a thrown value says, safely and briefly: an Error's message, or any
1058
+ * other value, as a string. Every inspection and coercion of the value is
1059
+ * inside the try, so reading it can't throw: a revoked Proxy (whose
1060
+ * instanceof throws), a hostile getter, an Error whose message isn't a
1061
+ * string, or a value with no string form reads as text or as unreadable.
1062
+ * The text is bounded, since a provider or handler error can carry a whole
1063
+ * request body.
1064
+ */
1065
+ function thrownText(error: unknown): string {
1066
+ let text: string;
1067
+ try {
1068
+ text = String(error instanceof Error ? error.message : error);
1069
+ } catch {
1070
+ text = '(a value that could not be read)';
1071
+ }
1072
+ return text.length <= THROWN_TEXT_LIMIT ? text : `${text.slice(0, THROWN_TEXT_LIMIT)}… (${text.length} characters)`;
1073
+ }
1074
+
1075
+ /**
1076
+ * A failed handler's reason, never throwing: an Error's message, else "the
1077
+ * handler failed" and what it threw (nothing, for `undefined`). Telling an
1078
+ * Error apart is itself guarded, since instanceof throws on a revoked Proxy.
1079
+ */
1080
+ function handlerFailureText(error: unknown): string {
1081
+ let isError = false;
1082
+ try {
1083
+ isError = error instanceof Error;
1084
+ } catch {
1085
+ // Not an Error that can be read; thrownText says so.
1086
+ }
1087
+ if (isError) return thrownText(error);
1088
+ return error === undefined ? 'the handler failed' : `the handler failed: ${thrownText(error)}`;
1089
+ }
1090
+
1091
+ /** A server's responder, and whether the request it belongs to has been answered yet. */
1092
+ type AnswerOnce = {
1093
+ responder: { respond?: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void } | undefined;
1094
+ answered: () => boolean;
1095
+ };
1096
+
1097
+ /**
1098
+ * Wrap a server request's responder so the request is answered at most once,
1099
+ * and a later failure can tell whether the handler already answered.
1100
+ * - An answer counts once the responder returns. One that throws, such as a
1101
+ * result the transport can't serialize, leaves the request unanswered, so
1102
+ * the listener's failure answer still goes out. This assumes a responder
1103
+ * that throws has sent nothing, which holds for the connection's own: they
1104
+ * serialize before they write, and their transports' writes don't throw
1105
+ * after writing.
1106
+ * - A second answer is refused and logged: a handler that answered and then
1107
+ * threw isn't answered again, and a handler that answers after the
1108
+ * listener answered its failure doesn't send a second response.
1109
+ * - Only the methods the responder has are wrapped, so a handler that checks
1110
+ * for `respondError` still finds a responder without one.
1111
+ */
1112
+ function answerOnce<R extends { respond?: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void }>(
1113
+ responder: R | undefined,
1114
+ method: string,
1115
+ serverId: string,
1116
+ ): AnswerOnce & { responder: R | undefined } {
1117
+ if (!responder) return { responder: undefined, answered: () => false };
1118
+ let done = false;
1119
+ const once = <A extends unknown[]>(kind: string, send: (...args: A) => void) => (...args: A): void => {
1120
+ if (done) {
1121
+ console.error(`[mcpl] ${method} from ${serverId}: a second answer (${kind}) was refused; the request was already answered`);
1122
+ return;
1123
+ }
1124
+ send(...args);
1125
+ done = true;
1126
+ };
1127
+ const wrapped: { respond?: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void } = { ...responder };
1128
+ if (responder.respond) wrapped.respond = once('respond', (result: unknown) => responder.respond!(result));
1129
+ if (responder.respondError) {
1130
+ wrapped.respondError = once('respondError', (code: number, message: string, data?: unknown) => responder.respondError!(code, message, data));
1131
+ }
1132
+ return { responder: wrapped as R, answered: () => done };
1133
+ }
1134
+
951
1135
  export class AgentFramework {
1136
+ private toolPresentations = new Map<string, ToolPresentation>();
1137
+ private presentationPreviews = new WeakMap<object, PresentationSnapshot>();
1138
+ getRequestToolPresentation(request: object): PresentationSnapshot | null {
1139
+ return this.presentationPreviews.get(request) ?? null;
1140
+ }
1141
+
952
1142
  private store: JsStore;
953
1143
  private ownsStore: boolean;
1144
+ private stopPromise: Promise<void> | null = null;
1145
+ private stopCompleted = false;
1146
+ private ownedStoreClosed = false;
954
1147
  private membrane: Membrane;
955
1148
  private queue: ProcessQueueImpl;
956
1149
  private agents: Map<string, Agent> = new Map();
@@ -998,6 +1191,8 @@ export class AgentFramework {
998
1191
  private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
999
1192
  private providerHoldHook: ProviderHoldHook | undefined;
1000
1193
  private providerAdmissionClosed = false;
1194
+ /** Invalidates framework turns still assembling when stop begins. */
1195
+ private lifecycleGeneration = 0;
1001
1196
  /** Last time we reported stale (busy-requeued) inference requests, per agent. */
1002
1197
  private staleWarnAt = new Map<string, number>();
1003
1198
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -1032,26 +1227,6 @@ export class AgentFramework {
1032
1227
  * keeps unrelated channels from moving the pin. Cleared at every fresh
1033
1228
  * turn's start; budget restarts keep it. */
1034
1229
  private turnEngagedChannels: Map<string, Set<string>> = new Map();
1035
- /** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
1036
- * order (deduped at render). Feeds the `[delivered]` receipt appended at
1037
- * logical turn end: explicit sends receipt themselves via their
1038
- * tool_result, but auto-routed prose previously vanished into the router
1039
- * with no in-window record — the agent could never see where its own
1040
- * words landed (2026-07-31 misroute series; antra: minimal receipts).
1041
- * Cleared at every fresh turn's start; budget restarts keep it (same
1042
- * logical turn, receipt covers the whole turn). */
1043
- private turnProseDeliveries: Map<string, string[]> = new Map();
1044
- /** Count of plain-prose segments SUPPRESSED this turn by sticky
1045
- * explicit-send silencing. The suppression rule itself is untouched
1046
- * (antra, 2026-07-31: visibility is enough) — but it must never be a
1047
- * silent black hole: Mythos multiplexed two threads in one round (prose
1048
- * for the lane + explicit send for another channel, textbook per the
1049
- * routing doc), the silencing ate the prose reply, and his own record
1050
- * ("scene delivered to #mythos via plain speech — routing confirmed")
1051
- * believed it delivered. The receipt now reports suppression, so the
1052
- * author sees the segment's fate one turn later. Cleared each fresh
1053
- * turn; budget restarts keep it. */
1054
- private turnProseSuppressed: Map<string, number> = new Map();
1055
1230
  /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
1056
1231
  * not a reaction or a system marker) into the live stream. Tells
1057
1232
  * driveStream to clear sticky explicit-send suppression before handling
@@ -1066,14 +1241,43 @@ export class AgentFramework {
1066
1241
  // ---- Explicit prose routing (docs/explicit-prose-routing.md) ----------
1067
1242
  /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
1068
1243
  private proseClipboards: Map<string, string> = new Map();
1069
- /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
1070
- * Cleared at every non-restart turn start; survives context restarts. */
1071
- private proseTargetPins: Map<string, string> = new Map();
1072
- /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
1073
- private proseHybridSuppressed: Set<string> = new Set();
1074
- /** Agents whose current turn requested `!` continuation — re-woken when the
1075
- * turn completes instead of pausing until the next external event. */
1076
- private proseContinuations: Set<string> = new Set();
1244
+ /**
1245
+ * Each agent's routing state for its current turn (TurnRouting): the
1246
+ * sticky destination, the turn's destination for a resumed stream,
1247
+ * hybrid's hold, a `!` continuation and receipt tallies. A fresh turn starts a new object
1248
+ * instead of clearing the old one, and a stream that resumes the turn
1249
+ * (resumesLogicalTurn) keeps it. A queued delivery holds the object of the
1250
+ * turn that queued it, so prose a turn leaves queued when it ends (a
1251
+ * skip_reply ends it before its posts have run) still follows that turn's
1252
+ * destination, and its late writes can't reach the next turn.
1253
+ */
1254
+ private turnRouting: Map<string, TurnRouting> = new Map();
1255
+ /**
1256
+ * The latest link of each agent's chain of queued prose deliveries. Every
1257
+ * stream queues its own behind it, a fresh turn's included, so the agent's
1258
+ * posts go out in the order they were written, across streams and turns.
1259
+ */
1260
+ private proseSpeechChains: Map<string, Promise<void>> = new Map();
1261
+ /**
1262
+ * Each agent's open logical turn (LogicalTurn). It stays open from the
1263
+ * fresh start that opens it until the turn settles (settleAgent: it
1264
+ * completes, a tool ends it, or it fails or is abandoned), and every fresh
1265
+ * start that routes prose while it is open continues it: a framework
1266
+ * retry, the start that consumes tool results the non-streaming fallback
1267
+ * left (whichever wake the scheduler starts it with), the later compile a
1268
+ * provider-capacity hold defers a turn to. A silent start neither
1269
+ * continues nor opens one. A stream that ends without settling needs to do
1270
+ * nothing to carry its turn. Settling only detaches the turn from future
1271
+ * starts: the settling stream keeps its own object, which its remaining
1272
+ * deliveries, its receipt and its ` !` check go on using (a completion
1273
+ * settles before it awaits its chain and writes its receipt). So every
1274
+ * logical end must close inheritance once, before the next fresh start:
1275
+ * a turn left open would hand its object, late writes and all, to an
1276
+ * unrelated turn. A tool-ended turn settles before the next turn can
1277
+ * start. Only the stream that holds the open turn closes it; disposal
1278
+ * drops it, and stop() clears every agent's.
1279
+ */
1280
+ private openLogical: Map<string, LogicalTurn> = new Map();
1077
1281
  /** Consecutive bounce-wakes per agent; capped so an agent that keeps
1078
1282
  * emitting unprefixed prose can't wake-loop (notices still append). */
1079
1283
  private proseBounceStreaks: Map<string, number> = new Map();
@@ -1308,10 +1512,16 @@ export class AgentFramework {
1308
1512
  private codeExecutionConfig: import('./types/index.js').CodeExecutionConfig | null = null;
1309
1513
  private codeExecutionRunners: Map<string, PyRunner> = new Map();
1310
1514
  private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
1311
- /** Agents whose running script hit an endTurn-carrying inner result —
1312
- * deferred and applied to the final code_execution result instead of
1313
- * cancelling the stream mid-script (which would wedge the turn). */
1314
- private scriptDeferredEndTurn: Set<string> = new Set();
1515
+ /** Per agent, the foreground script now running. An endTurn-carrying inner
1516
+ * result marks the script that made the call, and is applied to that
1517
+ * script's code_execution result instead of cancelling the stream
1518
+ * mid-script (which would wedge the turn). A stopped script's late result
1519
+ * marks only that script, never the agent's next one. */
1520
+ private scriptDeferredEndTurn: Map<string, { endTurn: boolean }> = new Map();
1521
+ /** RFC-007: per agent, the origin of the foreground script its runner is
1522
+ * executing, handed to that script's inner calls (scripts run one at a
1523
+ * time per runner). Background runners capture theirs in the closure. */
1524
+ private foregroundScriptOrigins: Map<string, ScriptCallOrigin> = new Map();
1315
1525
  /** Background (daemon) scripts: model-authored watchers that outlive their
1316
1526
  * spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
1317
1527
  * provenance envelope + payload and requests inference. Keyed by script id. */
@@ -1343,6 +1553,16 @@ export class AgentFramework {
1343
1553
  /** Namespaced tool name → the server whose tools/list produced it. Prefixes
1344
1554
  * can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
1345
1555
  private mcplToolServers: Map<string, string> = new Map();
1556
+ /** Servers whose tools/list answered in the latest refresh. */
1557
+ private mcplListedServers: Set<string> = new Set();
1558
+ /** Zero-match tool-pattern diagnostics (checkToolPatterns): set at the end
1559
+ * of create(), when the tool universe is first complete. */
1560
+ private toolPatternChecksArmed = false;
1561
+ /** Patterns already reported as matching nothing — each is reported once. */
1562
+ private toolPatternsReported: Set<string> = new Set();
1563
+ /** Every (prefix → serverId) ever configured. A removed or restarting
1564
+ * server's patterns stay undecided rather than reported mid-restart. */
1565
+ private toolPatternKnownPrefixes: Map<string, string> = new Map();
1346
1566
  /** Maps serverId → McplServerConfig for prefix lookup. */
1347
1567
  private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
1348
1568
  /** Host capabilities advertised during the MCP handshake — stored so servers
@@ -1577,6 +1797,7 @@ export class AgentFramework {
1577
1797
  // Create agents
1578
1798
  for (const agentConfig of config.agents) {
1579
1799
  await framework.createAgent(agentConfig);
1800
+ if (agentConfig.toolPresentation) framework.toolPresentations.set(agentConfig.name, new ToolPresentation(agentConfig.toolPresentation));
1580
1801
  }
1581
1802
 
1582
1803
  // The subconscious resident (issue #77) registers after the residents so
@@ -1594,6 +1815,17 @@ export class AgentFramework {
1594
1815
  for (const module of config.modules) {
1595
1816
  await framework.addModule(module);
1596
1817
  }
1818
+ for (const [agentName, presentation] of framework.toolPresentations) {
1819
+ const workspace = framework.getWorkspaceModule();
1820
+ if (!workspace) throw new Error('Tool presentation requires workspace');
1821
+ const agent = framework.agents.get(agentName)!;
1822
+ for (const name of ['workspace--read', 'set_tool_visibility', 'set_tool_description']) {
1823
+ if (!agent.canUseTool(name)) throw new Error(`Tool presentation recovery requires ${name}`);
1824
+ }
1825
+ workspace.registerGeneratedTextFile(presentation.config.cataloguePath,
1826
+ () => renderCatalogue(framework.inspectToolPresentation(agentName)!), agentName);
1827
+ }
1828
+
1597
1829
 
1598
1830
  // Initialize per-channel conversation routing (if configured)
1599
1831
  if (config.conversations) {
@@ -1604,6 +1836,9 @@ export class AgentFramework {
1604
1836
  );
1605
1837
  }
1606
1838
  framework.conversationRouter = new ConversationRouter(config.conversations);
1839
+ // Deprecated feature (agent-framework#235): still wired exactly as
1840
+ // before, but named once per framework instance on stderr.
1841
+ console.warn(`[deprecated] ${CONVERSATION_ROUTING_DEPRECATION_NOTICE}`);
1607
1842
 
1608
1843
  // Generation counters persist across restarts — reusing generation 1's
1609
1844
  // agent name after a restart would reopen (and re-seed) the previous
@@ -1640,16 +1875,41 @@ export class AgentFramework {
1640
1875
  framework.pendingRequests.push({
1641
1876
  agentName, reason, source, timestamp: Date.now(),
1642
1877
  // gate-requested wakes carry where/who (EventGate wakeProvenance)
1643
- // as TELEMETRY fields — the host's stamp reads them; the turn's
1644
- // speech locus (channelId / addressed) is deliberately NOT set
1645
- // here, so a batched wake routes exactly as it did before.
1878
+ // as TELEMETRY fields — the host's stamp reads them.
1646
1879
  ...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
1647
1880
  ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
1648
1881
  ...(provenance?.at ? { wakeAt: provenance.at } : {}),
1882
+ // A batch that contains an ADDRESSED message (mention / reply /
1883
+ // DM) routes like the direct path does: the reply goes to that
1884
+ // message's channel. Without it the turn froze on the
1885
+ // process-global most-recent-inbound channel, which an ambient
1886
+ // message elsewhere could retarget between the DM's arrival and
1887
+ // the debounce firing (2026-09-30). Ambient-only batches set no
1888
+ // locus and keep the legacy fallback.
1889
+ //
1890
+ // The wake is broadcast to every agent, conversation forks
1891
+ // included, and a fork speaks only in its home channel: the
1892
+ // route applies to a fork only when it IS that home, and never to
1893
+ // another agent when a fork owns that channel. Otherwise a fork
1894
+ // bound elsewhere would show typing where it isn't replying.
1895
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
1896
+ ? { channelId: provenance.routeChannelId, addressed: true }
1897
+ : {}),
1649
1898
  });
1650
1899
  },
1651
1900
  getAgentNames: () => [...framework.agents.keys()].filter(
1652
1901
  (n) => n !== framework.subconsciousAgentName),
1902
+ // Push events reach the gate with the adapter's raw channel id; map
1903
+ // them to the registered composite id the same way ingestion does
1904
+ // (handleMcplPushEvent registers that channel on arrival, before any
1905
+ // debounce can fire).
1906
+ resolveRouteChannel: (info) => {
1907
+ if (info.eventType === 'mcpl:channel-incoming') return info.channelId || undefined;
1908
+ if (info.eventType === 'mcpl:push-event') {
1909
+ return framework.derivePushEventChannel(info.metadata)?.channelId;
1910
+ }
1911
+ return undefined;
1912
+ },
1653
1913
  });
1654
1914
  }
1655
1915
 
@@ -1832,6 +2092,12 @@ export class AgentFramework {
1832
2092
  // SIGUSR2 not available on this platform — non-fatal.
1833
2093
  }
1834
2094
 
2095
+ // Modules, agents, MCPL servers and the tune-out tool are all in place:
2096
+ // the first point at which "matches no tool" can be judged. Later tool
2097
+ // refreshes re-check patterns still undecided.
2098
+ framework.toolPatternChecksArmed = true;
2099
+ framework.checkToolPatterns();
2100
+
1835
2101
  return framework;
1836
2102
  }
1837
2103
 
@@ -1843,8 +2109,14 @@ export class AgentFramework {
1843
2109
  return;
1844
2110
  }
1845
2111
 
2112
+ this.resetCompletedStop();
2113
+
1846
2114
  this.running = true;
1847
2115
  this.providerAdmissionClosed = false;
2116
+ // A new lifecycle: a turn compiled before an earlier stop() never drives,
2117
+ // and withheld spills, closed for the generation a stop ended, are open
2118
+ // for this one.
2119
+ this.lifecycleGeneration++;
1848
2120
  this.loopPromise = this.runLoop();
1849
2121
 
1850
2122
  // Start periodic sync timer (if enabled)
@@ -1870,9 +2142,36 @@ export class AgentFramework {
1870
2142
  }
1871
2143
 
1872
2144
  /**
1873
- * Stop the event loop.
2145
+ * Stop the event loop. Failed module/MCPL cleanup can be retried with its
2146
+ * original context; storage stays open until that cleanup succeeds.
2147
+ * @param teardownTimeoutMs Optional module/MCPL wait budget. Omit to wait
2148
+ * until cleanup settles, as required by existing module/child contracts. This
2149
+ * does not bound earlier stream or event-loop quiescence, or cancel cleanup.
1874
2150
  */
1875
- async stop(): Promise<void> {
2151
+ stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
2152
+ try { validateShutdownTimeout(teardownTimeoutMs); }
2153
+ catch (error) { return Promise.reject(error); }
2154
+ if (this.stopPromise) return this.stopPromise;
2155
+ const attempt = this.stopOwned(teardownTimeoutMs);
2156
+ this.stopPromise = attempt;
2157
+ void attempt.then(() => { this.stopCompleted = true; }, () => {});
2158
+ // Failed cleanup can be retried while its original store is open. A
2159
+ // terminal storage failure after successful close must remain a failure.
2160
+ void attempt.catch(() => {
2161
+ if (!this.ownedStoreClosed && this.stopPromise === attempt) this.stopPromise = null;
2162
+ });
2163
+ return attempt;
2164
+ }
2165
+
2166
+ private resetCompletedStop(): void {
2167
+ if (this.ownedStoreClosed) throw new Error('Cannot start a new lifecycle: owned store is closed');
2168
+ if (this.stopCompleted) {
2169
+ this.stopPromise = null;
2170
+ this.stopCompleted = false;
2171
+ }
2172
+ }
2173
+
2174
+ private async stopOwned(teardownTimeoutMs: number | undefined): Promise<void> {
1876
2175
  this.pushCoalescer?.suspend();
1877
2176
  this.flushCoalescingSnapshot();
1878
2177
  this.running = false;
@@ -1880,6 +2179,10 @@ export class AgentFramework {
1880
2179
  // store is still open, rather than leaving them to a reboot replay.
1881
2180
  this.ackDeferredWrites();
1882
2181
  this.providerAdmissionClosed = true;
2182
+ this.lifecycleGeneration++;
2183
+ // The generation this stop ends; withheld spills close for it alone, so a
2184
+ // start() made before this stop finishes reopens them for its own.
2185
+ const stoppingGeneration = this.lifecycleGeneration;
1883
2186
  this.queue.close();
1884
2187
  this.tuneOutCoordinator?.stop();
1885
2188
 
@@ -1907,6 +2210,10 @@ export class AgentFramework {
1907
2210
 
1908
2211
  // A stopped host must never hang behind its own cooldown.
1909
2212
  this.cancelProviderAdmission();
2213
+ // An open logical turn doesn't survive a stop: a start after a restart
2214
+ // on this instance opens a new one rather than carrying its ` !` and
2215
+ // receipt tallies across (openLogical).
2216
+ this.openLogical.clear();
1910
2217
 
1911
2218
  // Cancel all active streams
1912
2219
  for (const agent of this.agents.values()) {
@@ -1946,20 +2253,46 @@ export class AgentFramework {
1946
2253
  this.eventGate?.dispose();
1947
2254
  this.livenessWatchdog?.stop();
1948
2255
 
2256
+ // A stopping stream's batch settles as withheld, and its stub names where
2257
+ // the original went: let those writes finish while the workspace is up.
2258
+ // Tracked here rather than per agent, since an ephemeral run or a closed
2259
+ // fork leaves the agent map while its annotation can still be running.
2260
+ while (this.guardAnnotations.size > 0) await Promise.all([...this.guardAnnotations]);
2261
+ // An annotation that starts after this point, such as one from a direct
2262
+ // Agent API call stop doesn't cancel, starts no write to the stopping
2263
+ // workspace: its stub names the audit record. A write that was past its
2264
+ // deadline isn't waited for here, but the workspace's own stop waits for
2265
+ // the mount turn it holds, bounded only by teardownTimeoutMs.
2266
+ this.guardSpillsClosedFor = stoppingGeneration;
2267
+
1949
2268
  // Stop modules and MCPL servers in parallel
1950
- const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll()];
2269
+ const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll(teardownTimeoutMs)];
1951
2270
  if (this.mcplServerRegistry) {
1952
- shutdownPromises.push(this.mcplServerRegistry.closeAll());
2271
+ shutdownPromises.push(this.mcplServerRegistry.closeAll(teardownTimeoutMs));
2272
+ }
2273
+ // Timed-out or failed teardown retains its context and may still write.
2274
+ // Sync available state best-effort, but leave its store open for a retry.
2275
+ const shutdownErrors: unknown[] = [];
2276
+ for (const result of await Promise.allSettled(shutdownPromises)) {
2277
+ if (result.status === 'rejected') shutdownErrors.push(result.reason);
1953
2278
  }
1954
- await Promise.all(shutdownPromises);
2279
+
2280
+ const teardownSucceeded = shutdownErrors.length === 0;
2281
+
2282
+ // Those later annotations write nothing, so they finish at once; let them
2283
+ // record and edit their stubs before the store closes.
2284
+ while (this.guardAnnotations.size > 0) await Promise.all([...this.guardAnnotations]);
1955
2285
 
1956
2286
  // Streams may queue storage repairs while being cancelled above. Retry
1957
- // after their teardown, while the store is still open, before final sync.
1958
- for (const agent of this.agents.values()) {
2287
+ // after their teardown, while the store is still open, before final sync:
2288
+ // every live agent's guard, and every other guard that isn't idle, whose
2289
+ // ephemeral run or closed fork has left the agent map.
2290
+ for (const [guard, name] of this.guardsWithStorageWork()) {
1959
2291
  try {
1960
- agent.toolResultGuard.flushUnrecorded();
2292
+ guard.flushUnrecorded();
1961
2293
  } catch (error) {
1962
- console.error(`[tool-result-guard] agent=${agent.name} could not finish queued storage work at stop:`, error);
2294
+ console.error(`[tool-result-guard] agent=${name} could not finish queued storage work at stop:`, error);
2295
+ shutdownErrors.push(error);
1963
2296
  }
1964
2297
  }
1965
2298
 
@@ -1968,11 +2301,23 @@ export class AgentFramework {
1968
2301
  this.store.sync();
1969
2302
  } catch (error) {
1970
2303
  console.error('Final sync error:', error);
2304
+ shutdownErrors.push(error);
1971
2305
  }
1972
2306
 
1973
- if (this.ownsStore) {
1974
- this.store.close();
2307
+ if (this.ownsStore && teardownSucceeded) {
2308
+ try {
2309
+ this.store.close();
2310
+ this.ownedStoreClosed = true;
2311
+ } catch (error) {
2312
+ // Chronicle may consume the handle before close's internal sync
2313
+ // throws. A consumed handle cannot retry the original operation.
2314
+ this.ownedStoreClosed = this.store.isClosed();
2315
+ shutdownErrors.push(error);
2316
+ }
1975
2317
  }
2318
+ if (shutdownErrors.length === 1) throw shutdownErrors[0];
2319
+ if (shutdownErrors.length > 1) throw new AggregateError(shutdownErrors,
2320
+ `Framework shutdown failed: ${shutdownErrors.map(shutdownErrorMessage).join('; ')}`);
1976
2321
  }
1977
2322
 
1978
2323
  /**
@@ -2193,6 +2538,17 @@ export class AgentFramework {
2193
2538
  }
2194
2539
 
2195
2540
  private async runQueuedMaintenance(): Promise<void> {
2541
+ // A guard whose agent has left the map gets no activation to retry its
2542
+ // queue; retry it here. It leaves busyGuards once nothing of it is left.
2543
+ const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
2544
+ for (const [guard, name] of this.busyGuards) {
2545
+ if (live.has(guard)) continue;
2546
+ try {
2547
+ guard.flushUnrecorded();
2548
+ } catch (error) {
2549
+ console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
2550
+ }
2551
+ }
2196
2552
  const queued = [...this.agents.values()].flatMap((agent) => {
2197
2553
  try {
2198
2554
  agent.toolResultGuard.flushUnrecorded();
@@ -2200,7 +2556,7 @@ export class AgentFramework {
2200
2556
  console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
2201
2557
  }
2202
2558
  const cm = agent.getContextManager();
2203
- const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
2559
+ const tools = this.compressionToolsForAgent(agent.name);
2204
2560
  cm.setToolDefinitions(tools);
2205
2561
  if (this.providerGateBlocked(agent.name)) return [];
2206
2562
  if (cm.isReady()) return [];
@@ -2284,10 +2640,74 @@ export class AgentFramework {
2284
2640
  : undefined;
2285
2641
  }
2286
2642
 
2643
+ /**
2644
+ * A server's request or notification the host couldn't handle, answered
2645
+ * with a JSON-RPC error when it is a request (a notification has no one to
2646
+ * answer, and is logged), rather than thrown out of the connection's
2647
+ * listener, where it was an unhandled rejection and the server's request
2648
+ * waited for its timeout.
2649
+ * - `refused`: the listener turned it away before its handler ran; the
2650
+ * host is stopping, and none of it was taken. Answered -32000, "the host
2651
+ * is stopping", the code the push coalescer already gives a stopping
2652
+ * host: the server can send it again to the host's next run.
2653
+ * - `failed`: the handler threw, whatever it threw, `undefined` included.
2654
+ * While the host stops (its queue closed), that's answered -32000 too,
2655
+ * with the handler's own message after the stop wording; on a live host
2656
+ * it's a fault, answered -32603 with the handler's message. The handler
2657
+ * may have admitted part of the request first (an earlier message of a
2658
+ * batch), so nothing here claims that none of it was taken.
2659
+ * A request the handler already answered before it threw isn't answered
2660
+ * again: the failure is logged.
2661
+ */
2662
+ private refuseServerItem(
2663
+ method: string,
2664
+ serverId: string,
2665
+ answer: AnswerOnce,
2666
+ outcome: { kind: 'refused' } | { kind: 'failed'; error: unknown },
2667
+ ): void {
2668
+ // Optional chaining: prototype-built harnesses leave the queue unset.
2669
+ const stopping = outcome.kind === 'refused' || !!this.queue?.isClosed;
2670
+ const what = outcome.kind === 'refused' ? '' : handlerFailureText(outcome.error);
2671
+ const reason = outcome.kind === 'refused'
2672
+ ? 'the host is stopping'
2673
+ : stopping
2674
+ ? `the host stopped while handling this request (${what})`
2675
+ : what;
2676
+ if (outcome.kind === 'failed' && answer.answered()) {
2677
+ console.error(`[mcpl] ${method} from ${serverId} failed after it was answered: ${reason}`);
2678
+ return;
2679
+ }
2680
+ console.error(outcome.kind === 'refused'
2681
+ ? `[mcpl] ${method} from ${serverId} refused: ${reason}`
2682
+ : `[mcpl] ${method} from ${serverId} failed: ${reason} (part of it may already have been admitted)`);
2683
+ try {
2684
+ answer.responder?.respondError?.(stopping ? -32000 : -32603, reason);
2685
+ } catch {
2686
+ // The connection is going too; there is no one left to tell.
2687
+ }
2688
+ }
2689
+
2287
2690
  /**
2288
2691
  * Push a process event to the queue.
2692
+ *
2693
+ * After stop() the queue is closed and this throws, so a direct caller
2694
+ * learns the framework is stopped — except for a tool's result. A call
2695
+ * still running at stop() completes later, into a callback with nothing
2696
+ * to catch the throw (an unhandled rejection), and nothing could take its
2697
+ * result anyway: the queue that would carry it to its stream is closed,
2698
+ * stop() is ending the streams, and the tool has already run. That
2699
+ * result is dropped, and the drop is logged.
2289
2700
  */
2290
2701
  pushEvent(event: ProcessEvent): void {
2702
+ if (event.type === 'tool-result' && this.queue?.isClosed) {
2703
+ // What an operator reconciling a dropped result needs (the resident
2704
+ // may run the tool again): which tool, and whether it succeeded.
2705
+ const outcome = event.result.success && !event.result.isError
2706
+ ? 'succeeded'
2707
+ : `failed${event.result.error ? `: ${thrownText(event.result.error)}` : ''}`;
2708
+ console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.toolName ? `${event.toolName}, ` : ''}${event.moduleName}) arrived after stop — dropped; the tool ${outcome}`);
2709
+ return;
2710
+ }
2291
2711
  this.queue.push(event);
2292
2712
  this.emitTrace({ type: 'process:received', processEvent: event });
2293
2713
  }
@@ -2330,6 +2750,7 @@ export class AgentFramework {
2330
2750
  * Add a module at runtime.
2331
2751
  */
2332
2752
  async addModule(module: Module): Promise<void> {
2753
+ this.resetCompletedStop();
2333
2754
  await this.moduleRegistry.addModule(module);
2334
2755
  this.emitTrace({ type: 'module:added', moduleName: module.name });
2335
2756
  }
@@ -2449,7 +2870,14 @@ export class AgentFramework {
2449
2870
  if (!mount) return null;
2450
2871
  const path = `${mount}/refs/${fileName}`;
2451
2872
  const res = await workspace.writeBinary(path, data, mimeType);
2452
- return res.success ? path : null;
2873
+ if (res.success) return path;
2874
+ // Stored, with its disk copy failed: the workspace's tools read it
2875
+ // from the tree, so the reference is fetched to that path.
2876
+ if ((res.data as { inChronicle?: boolean } | undefined)?.inChronicle === true) {
2877
+ console.error(`[refs] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${res.error ?? 'unknown error'}`);
2878
+ return path;
2879
+ }
2880
+ return null;
2453
2881
  },
2454
2882
  );
2455
2883
  }
@@ -2622,7 +3050,38 @@ export class AgentFramework {
2622
3050
  };
2623
3051
  }
2624
3052
 
2625
- private restoreToolResultGuardSetting(agent: Agent): void {
3053
+ /** Every guard's annotation still running, for stop() (agent-framework #277). */
3054
+ private readonly guardAnnotations = new Set<Promise<void>>();
3055
+ /** Each guard that isn't idle, by agent name, as the guard reports it. An
3056
+ * ephemeral run or a closed fork leaves the agent map with an annotation
3057
+ * maybe still running, or a stub edit, acceptance edit or audit write
3058
+ * queued, which maintenance and stop then retry (agent-framework #277). A
3059
+ * guard is here from the moment it has work left until the moment it has
3060
+ * none, however that comes about. */
3061
+ private readonly busyGuards = new Map<ToolResultGuard, string>();
3062
+ /** Withheld originals' writes still under way past their deadline: while
3063
+ * any is, the workspace isn't answering and no other withheld original's
3064
+ * write is started. Several can be, when writes of different agents'
3065
+ * batches stalled together. */
3066
+ private readonly stalledSpills = new Set<Promise<void>>();
3067
+ /** Withheld spills skipped since the current stall began. */
3068
+ private stallSkips = 0;
3069
+ /** See WITHHELD_SPILL_DEADLINE_MS; an instance field so tests can shorten it. */
3070
+ private withheldSpillDeadlineMs = WITHHELD_SPILL_DEADLINE_MS;
3071
+ /** The lifecycle generation whose stop() has let the running annotations
3072
+ * finish: a withheld original is then no longer written to a workspace
3073
+ * that is stopping, and its stub names the audit record. A start() after
3074
+ * that stop() has finished has a generation of its own, so the spill gate
3075
+ * is open again for an instance kept with a store it doesn't own and that
3076
+ * is still open (start() doesn't restore stopped modules or MCPL
3077
+ * connections, and refuses an owned store that stop() closed). A start()
3078
+ * made while a stop() is still draining isn't a supported order. */
3079
+ private guardSpillsClosedFor: number | null = null;
3080
+
3081
+ /** A new agent's guard: its persisted setting, the tool-results spill its
3082
+ * withheld originals are written to, and the set stop() waits on
3083
+ * (agent-framework #277). */
3084
+ private adoptToolResultGuard(agent: Agent): void {
2626
3085
  const enabled = (this.store.getStateJson(FRAMEWORK_STATE_ID) as {
2627
3086
  toolResultGuards?: Record<string, unknown>;
2628
3087
  } | null)?.toolResultGuards?.[agent.name];
@@ -2630,6 +3089,28 @@ export class AgentFramework {
2630
3089
  throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
2631
3090
  }
2632
3091
  agent.toolResultGuard.setOverride(enabled);
3092
+ const guard = agent.toolResultGuard;
3093
+ guard.setHost({
3094
+ spill: (label, text, branch, deadline) => this.spillWithheld(label, text, branch, deadline),
3095
+ deadline: () => Date.now() + this.withheldSpillDeadlineMs,
3096
+ track: (annotation) => {
3097
+ this.guardAnnotations.add(annotation);
3098
+ void annotation.finally(() => this.guardAnnotations.delete(annotation));
3099
+ },
3100
+ idle: (idle) => {
3101
+ if (idle) this.busyGuards.delete(guard);
3102
+ else this.busyGuards.set(guard, agent.name);
3103
+ },
3104
+ });
3105
+ }
3106
+
3107
+ /** Each guard that may have queued storage work, once, by agent name: the
3108
+ * live agents' and every other that isn't idle. */
3109
+ private guardsWithStorageWork(): Map<ToolResultGuard, string> {
3110
+ const guards = new Map<ToolResultGuard, string>();
3111
+ for (const agent of this.agents.values()) guards.set(agent.toolResultGuard, agent.name);
3112
+ for (const [guard, name] of this.busyGuards) if (!guards.has(guard)) guards.set(guard, name);
3113
+ return guards;
2633
3114
  }
2634
3115
 
2635
3116
  private buildThinkTool(
@@ -2715,7 +3196,7 @@ export class AgentFramework {
2715
3196
  : t);
2716
3197
  return [...SUBCONSCIOUS_TOOLS, ...basics];
2717
3198
  }
2718
- return this.getAllTools().map((tool) => {
3199
+ return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])].map((tool) => {
2719
3200
  if (tool.name === 'think') {
2720
3201
  return this.buildThinkTool(
2721
3202
  snapshot?.sameRoundThinkTextPolicy
@@ -2726,6 +3207,53 @@ export class AgentFramework {
2726
3207
  });
2727
3208
  }
2728
3209
 
3210
+ /** Available is independent of presentation visibility; execution callers keep this surface. */
3211
+ private availableToolsForPresentation(agentName: string, snapshot?: InferenceToolSnapshot) {
3212
+ const agent = this.agents.get(agentName);
3213
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
3214
+ const tools = this.getToolsForAgent(agentName, snapshot).filter(t => agent.canUseTool(t.name));
3215
+ if (agent.proseRouting === 'explicit' && agent.canUseTool(PROSE_HELP_TOOL.name)) tools.push(PROSE_HELP_TOOL);
3216
+ return tools;
3217
+ }
3218
+
3219
+ inspectToolPresentation(agentName: string, snapshot?: InferenceToolSnapshot): PresentationSnapshot | null {
3220
+ const presentation = this.toolPresentations.get(agentName);
3221
+ if (!presentation) return null;
3222
+ const tools = this.availableToolsForPresentation(agentName, snapshot);
3223
+ const sources = new Map<string, string>();
3224
+ for (const tool of this.moduleRegistry.getAllTools()) {
3225
+ sources.set(tool.name, `Module: ${tool.name.split('--')[0]}`);
3226
+ }
3227
+ // Use registered server prefixes, not a guess from a conventional mcpl-- name.
3228
+ for (const tool of tools) {
3229
+ for (const config of this.mcplServerConfigs.values()) {
3230
+ if (tool.name.startsWith((config.toolPrefix ?? `mcpl--${config.id}`) + '--')) {
3231
+ sources.set(tool.name, `MCPL server: ${config.id}`); break;
3232
+ }
3233
+ }
3234
+ if (!sources.has(tool.name)) sources.set(tool.name, 'Framework');
3235
+ }
3236
+ return presentation.resolve(tools, sources);
3237
+ }
3238
+
3239
+ private advertisedToolsForAgent(agentName: string, snapshot?: InferenceToolSnapshot) {
3240
+ return this.inspectToolPresentation(agentName, snapshot)?.advertised
3241
+ ?? this.availableToolsForPresentation(agentName, snapshot);
3242
+ }
3243
+
3244
+ /** Compression can revisit calls to hidden tools; never apply visibility here. */
3245
+ private compressionToolsForAgent(agentName: string, snapshot?: InferenceToolSnapshot) {
3246
+ return this.inspectToolPresentation(agentName, snapshot)?.available
3247
+ ?? this.availableToolsForPresentation(agentName, snapshot);
3248
+ }
3249
+
3250
+ private editToolPresentation(agentName: string, call: ToolCall): ToolResult {
3251
+ const presentation = this.toolPresentations.get(agentName);
3252
+ const agent = this.agents.get(agentName);
3253
+ if (!presentation || !agent?.canUseTool(call.name)) return {success:false,isError:true,error:'Tool presentation is not enabled for this caller'};
3254
+ return presentation.edit(call.name, call.input, this.availableToolsForPresentation(agentName));
3255
+ }
3256
+
2729
3257
  /**
2730
3258
  * The tools one agent is shown at inference: its surface (the subconscious
2731
3259
  * has its own), less what its permissions deny, plus — for explicit-mode
@@ -2737,9 +3265,7 @@ export class AgentFramework {
2737
3265
  agent: Agent,
2738
3266
  snapshot?: InferenceToolSnapshot,
2739
3267
  ): import('./types/index.js').ToolDefinition[] {
2740
- const tools = this.getToolsForAgent(agent.name, snapshot).filter((t) => agent.canUseTool(t.name));
2741
- if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
2742
- return tools;
3268
+ return this.advertisedToolsForAgent(agent.name, snapshot);
2743
3269
  }
2744
3270
 
2745
3271
  getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
@@ -3558,7 +4084,12 @@ export class AgentFramework {
3558
4084
  throw new Error(`Agent not found: ${agentName}`);
3559
4085
  }
3560
4086
 
3561
- const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
4087
+ const presentation = this.inspectToolPresentation(agentName);
4088
+ const tools = presentation?.advertised ?? this.availableToolsForPresentation(agentName);
4089
+ const capture = (request: NormalizedRequest) => {
4090
+ if (presentation) this.presentationPreviews.set(request, presentation);
4091
+ return request;
4092
+ };
3562
4093
 
3563
4094
  // An explicit budget compiles against a HYPOTHETICAL window instead of the
3564
4095
  // agent's live one. That also suppresses transition-settling in
@@ -3567,7 +4098,7 @@ export class AgentFramework {
3567
4098
  // Default: no dynamic injection gathering → fully transparent (no
3568
4099
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
3569
4100
  if (!opts?.injections) {
3570
- return agent.buildActivationRequest(tools, undefined, opts?.budget);
4101
+ return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools));
3571
4102
  }
3572
4103
 
3573
4104
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
@@ -3602,7 +4133,7 @@ export class AgentFramework {
3602
4133
  }
3603
4134
  }
3604
4135
 
3605
- return agent.buildActivationRequest(tools, injections, opts?.budget);
4136
+ return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools));
3606
4137
  }
3607
4138
 
3608
4139
  /**
@@ -4029,7 +4560,7 @@ export class AgentFramework {
4029
4560
  });
4030
4561
 
4031
4562
  const agent = new Agent(config, contextManager, this.membrane);
4032
- this.restoreToolResultGuardSetting(agent);
4563
+ this.adoptToolResultGuard(agent);
4033
4564
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
4034
4565
  this.ephemeralCandidates.set(agent, contextManager);
4035
4566
 
@@ -4083,13 +4614,22 @@ export class AgentFramework {
4083
4614
  run.lastActivity = Date.now();
4084
4615
  }
4085
4616
 
4086
- /** Settle an active ephemeral run (no-op for resident agents). The tool-call
4087
- * count comes from the run's own counter — the single source of truth,
4088
- * which unlike a per-stream local survives budget restarts. */
4617
+ /** A logical turn's end, for every agent: it closes the agent's open
4618
+ * logical turn when it is the settling stream's own (openLogical), and
4619
+ * settles an active ephemeral run. The run's tool-call count comes from
4620
+ * its own counter — the single source of truth, which unlike a
4621
+ * per-stream local survives budget restarts. */
4089
4622
  private settleAgent(
4090
4623
  agentName: string,
4091
4624
  outcome: { stopReason: AgentSettleResult['stopReason']; speech: string; error?: string },
4625
+ /** The settling stream's logical turn, when that stream still owns the
4626
+ * agent: it ends here, so the agent's next fresh start opens a new one.
4627
+ * A settle from a stream a newer one replaced closes nothing. */
4628
+ closes: LogicalTurn | undefined,
4092
4629
  ): void {
4630
+ // This identity check is the only guard against a stale settle closing
4631
+ // a newer turn (no test reaches one); keep it when editing call sites.
4632
+ if (closes && this.openLogical.get(agentName) === closes) this.openLogical.delete(agentName);
4093
4633
  const run = this.ephemeralRuns.get(agentName);
4094
4634
  if (!run) return;
4095
4635
  run.lastActivity = Date.now();
@@ -4231,6 +4771,9 @@ export class AgentFramework {
4231
4771
  // generation mismatch and leave any future owner untouched.
4232
4772
  this.activeStreams.delete(agent.name);
4233
4773
  this.pendingAssistantBlocks.delete(agent.name);
4774
+ this.turnRouting.delete(agent.name);
4775
+ this.proseSpeechChains.delete(agent.name);
4776
+ this.openLogical.delete(agent.name);
4234
4777
  // The late stream finalizer intentionally refuses name-keyed cleanup once
4235
4778
  // deregistered, so disposal must release EventGate liveness itself.
4236
4779
  this.eventGate?.onInferenceEnded(agent.name);
@@ -6335,7 +6878,7 @@ export class AgentFramework {
6335
6878
  });
6336
6879
 
6337
6880
  const agent = new Agent(config, contextManager, this.membrane);
6338
- this.restoreToolResultGuardSetting(agent);
6881
+ this.adoptToolResultGuard(agent);
6339
6882
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
6340
6883
  this.sharedSlotAgents.add(agent);
6341
6884
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
@@ -6412,7 +6955,7 @@ export class AgentFramework {
6412
6955
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
6413
6956
  };
6414
6957
  const agent = new Agent(agentConfig, contextManager, this.membrane);
6415
- this.restoreToolResultGuardSetting(agent);
6958
+ this.adoptToolResultGuard(agent);
6416
6959
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
6417
6960
  this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
6418
6961
  this.agents.set(name, agent);
@@ -6483,6 +7026,10 @@ export class AgentFramework {
6483
7026
  const scriptWaiter = this.scriptToolWaiters.get(event.callId);
6484
7027
  if (scriptWaiter) {
6485
7028
  this.scriptToolWaiters.delete(event.callId);
7029
+ // RFC-007: the inner call's terminal. Also reached when the script
7030
+ // was killed while the call ran: the result reaches no one, but the
7031
+ // call did finish.
7032
+ this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
6486
7033
  scriptWaiter(event.result);
6487
7034
  return;
6488
7035
  }
@@ -6514,6 +7061,44 @@ export class AgentFramework {
6514
7061
  result: event.result,
6515
7062
  });
6516
7063
  }
7064
+ // The round's last pending result: let modules catch up with what the
7065
+ // batch's tools did (the workspace scans `on-agent-action` mounts) before
7066
+ // anything continues the turn. Bounded and fail-open; the agent may be
7067
+ // reset or cancelled meanwhile, so its state is checked again after.
7068
+ if (
7069
+ agent && agent.state.status === 'waiting_for_tools'
7070
+ && agent.state.pending.size === 1 && agent.state.pending.has(event.callId)
7071
+ ) {
7072
+ // The call this result answers, as this round holds it: a new round
7073
+ // that reuses the call's id holds a new entry for it.
7074
+ const owed = agent.state.pending.get(event.callId);
7075
+ const failures = await this.moduleRegistry.notifyToolBatchComplete(event.agentName);
7076
+ for (const failure of failures) {
7077
+ this.emitTrace({ type: 'module:batch_hook_failed', agentName: event.agentName, module: failure.module, error: failure.error });
7078
+ }
7079
+ // The turn may have been reset, cancelled or replaced while the hook
7080
+ // ran, even back to waiting on a new batch, whose calls may reuse this
7081
+ // one's id: this result is still owed only if this agent still waits
7082
+ // on this round's call.
7083
+ const after = agent.state as AgentState;
7084
+ if (
7085
+ this.agents.get(event.agentName) !== agent
7086
+ || after.status !== 'waiting_for_tools' || after.pending.get(event.callId) !== owed
7087
+ ) {
7088
+ console.warn(
7089
+ `[framework] Dropping tool-result for agent '${event.agentName}' (callId=${event.callId}): ` +
7090
+ `its turn changed while modules caught up with the batch (status '${after.status}').`,
7091
+ );
7092
+ this.emitTrace({
7093
+ type: 'tool:result_dropped',
7094
+ agentName: event.agentName,
7095
+ callId: event.callId,
7096
+ agentStatus: (agent.state as AgentState).status,
7097
+ result: event.result,
7098
+ });
7099
+ return;
7100
+ }
7101
+ }
6517
7102
  if (agent && agent.state.status === 'waiting_for_tools') {
6518
7103
  agent.provideToolResult(event.callId, event.result);
6519
7104
 
@@ -6523,8 +7108,9 @@ export class AgentFramework {
6523
7108
  if (currentState.status === 'ready') {
6524
7109
  // Flush pending assistant blocks (tool_use + preamble text) to context
6525
7110
  const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
7111
+ const turnRowMetadata = this.silentTurnRowMetadata(agent.name);
6526
7112
  if (pendingBlocks) {
6527
- agent.addAssistantResponse(pendingBlocks);
7113
+ agent.addAssistantResponse(pendingBlocks, turnRowMetadata);
6528
7114
  this.pendingAssistantBlocks.delete(agent.name);
6529
7115
  }
6530
7116
 
@@ -6543,7 +7129,7 @@ export class AgentFramework {
6543
7129
  const membraneResults = currentState.toolResults.map(tc =>
6544
7130
  this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
6545
7131
  );
6546
- agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
7132
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata);
6547
7133
 
6548
7134
  // Flush any messages that were deferred while this turn was in
6549
7135
  // flight. Route to the PRIMARY agent — deferred messages are
@@ -6739,7 +7325,7 @@ export class AgentFramework {
6739
7325
  // agent must be wakeable the moment it goes idle. (Idempotent;
6740
7326
  // driveStream's finally is the backstop.)
6741
7327
  this.eventGate?.onInferenceEnded(agent.name);
6742
- this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
7328
+ this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)?.logical);
6743
7329
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
6744
7330
  } else if (overBudget || overPhysical) {
6745
7331
  // Context budget exceeded: break the stream, let compile() compress.
@@ -6780,6 +7366,7 @@ export class AgentFramework {
6780
7366
  timestamp: Date.now(),
6781
7367
  suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
6782
7368
  ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
7369
+ silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
6783
7370
  });
6784
7371
  } else if (currentState.stream) {
6785
7372
  // Streaming path: convert results and resume the stream.
@@ -6797,7 +7384,8 @@ export class AgentFramework {
6797
7384
  ...(midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections.length } : {}),
6798
7385
  });
6799
7386
  } else {
6800
- // Non-streaming fallback: schedule re-inference
7387
+ // Non-streaming fallback: schedule re-inference, which continues
7388
+ // this stream's logical turn (openLogical).
6801
7389
  this.pendingRequests.push({
6802
7390
  agentName: agent.name,
6803
7391
  reason: 'tool_results_ready',
@@ -6969,6 +7557,16 @@ export class AgentFramework {
6969
7557
  assemblingFor?: string;
6970
7558
  deliverTo?: string;
6971
7559
  }): Promise<CoalescingPlacement | undefined> {
7560
+ // Same backstop as the push lane: no row, no wake, no fork spawned for a
7561
+ // message with nothing visible in it.
7562
+ if (isVisiblyEmptyContent(event.content)) {
7563
+ console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
7564
+ this.emitTrace({
7565
+ type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
7566
+ channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
7567
+ });
7568
+ return undefined;
7569
+ }
6972
7570
  const metadata: Record<string, unknown> = {
6973
7571
  ...event.metadata,
6974
7572
  ...(event.eventId ? { eventId: event.eventId } : {}),
@@ -7239,7 +7837,11 @@ export class AgentFramework {
7239
7837
  // seeding again would stack another template copy on top of it.
7240
7838
  const { messages: existing } = await contextManager.compile();
7241
7839
  if (existing.length === 0) {
7242
- const { messages: compiled } = await template.getContextManager().compile();
7840
+ // The copy is the fork's own, and the template's annotation edits only
7841
+ // the template's message: seed only once each of the template's
7842
+ // annotations has finished.
7843
+ const { messages: compiled } = await template.toolResultGuard.afterAnnotations(
7844
+ () => template.getContextManager().compile());
7243
7845
  for (const msg of compiled) {
7244
7846
  const participant = msg.participant === template.name ? name : msg.participant;
7245
7847
  contextManager.addMessage(participant, msg.content);
@@ -7248,7 +7850,7 @@ export class AgentFramework {
7248
7850
 
7249
7851
  const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
7250
7852
  const agent = new Agent(config, contextManager, this.membrane);
7251
- this.restoreToolResultGuardSetting(agent);
7853
+ this.adoptToolResultGuard(agent);
7252
7854
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
7253
7855
  this.agents.set(name, agent);
7254
7856
  this.agentConfigs.set(name, config);
@@ -7283,6 +7885,9 @@ export class AgentFramework {
7283
7885
  const channelId = this.conversationAgentHomes.get(agentName);
7284
7886
  const agent = this.agents.get(agentName);
7285
7887
  this.agents.delete(agentName);
7888
+ this.turnRouting.delete(agentName);
7889
+ this.proseSpeechChains.delete(agentName);
7890
+ this.openLogical.delete(agentName);
7286
7891
  this.toolImageLedgers.delete(agentName);
7287
7892
  this.agentConfigs.delete(agentName);
7288
7893
  this.conversationAgentHomes.delete(agentName);
@@ -7611,7 +8216,7 @@ export class AgentFramework {
7611
8216
  throw new CoalesceError('eventId', 'eventId is required with coalesce');
7612
8217
  }
7613
8218
  if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
7614
- validateCoalescedContent(message.content);
8219
+ validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true });
7615
8220
  const c = message.coalesce;
7616
8221
  const result = await this.pushCoalescer.accept({
7617
8222
  serverId,
@@ -7638,7 +8243,11 @@ export class AgentFramework {
7638
8243
  validateCoalesceMember(params.coalesce, 'push');
7639
8244
  if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
7640
8245
  if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
7641
- validateCoalescedContent(params.payload?.content);
8246
+ validateCoalescedContent(params.payload?.content, undefined, {
8247
+ allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
8248
+ serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
8249
+ }),
8250
+ });
7642
8251
  const c = params.coalesce;
7643
8252
  if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId);
7644
8253
  const origin = params.origin ?? {};
@@ -7810,6 +8419,7 @@ export class AgentFramework {
7810
8419
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
7811
8420
  addressed: isAddressedMessage(occ.tags, origin),
7812
8421
  coalescingSubject: subject,
8422
+ coalescingBatch: true,
7813
8423
  });
7814
8424
  }
7815
8425
  }
@@ -7924,6 +8534,16 @@ export class AgentFramework {
7924
8534
  * Convert an MCPL push event to a context message.
7925
8535
  */
7926
8536
  private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined {
8537
+ // Backstop for the MCPL boundary's empty-content rejection, covering
8538
+ // events that never crossed it (module-emitted, coalescer deliveries):
8539
+ // content with nothing visible stores no row and queues no wake — the
8540
+ // model would wake to `[Continue]` or an older message re-presented as
8541
+ // the newest, a wake with no visible cause.
8542
+ if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
8543
+ console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
8544
+ this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
8545
+ return undefined;
8546
+ }
7927
8547
  const triggerChannel = this.derivePushEventChannel(event.origin);
7928
8548
  if (triggerChannel && this.channelRegistry) {
7929
8549
  this.channelRegistry.ensureChannelRegistered(
@@ -7978,12 +8598,7 @@ export class AgentFramework {
7978
8598
  // Accept the no-message path only for the heartbeat feature's own exact
7979
8599
  // marker with an empty payload — arbitrary MCPL servers cannot hide
7980
8600
  // content merely by setting `origin.silent`.
7981
- const silentHeartbeat =
7982
- event.serverId === 'heartbeat' &&
7983
- event.featureSet === 'heartbeat' &&
7984
- event.origin?.source === 'heartbeat' &&
7985
- event.origin?.silent === true &&
7986
- content.length === 0;
8601
+ const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
7987
8602
 
7988
8603
  const placement: CoalescingPlacement = { agent: '' };
7989
8604
  if (!silentHeartbeat) {
@@ -8014,6 +8629,7 @@ export class AgentFramework {
8014
8629
  ephemeralSystemPrompt:
8015
8630
  '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
8016
8631
  'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
8632
+ silentHeartbeat: { eventId: event.eventId, serverId: event.serverId },
8017
8633
  } : {}),
8018
8634
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
8019
8635
  });
@@ -8022,6 +8638,21 @@ export class AgentFramework {
8022
8638
  return placement.agent ? placement : undefined;
8023
8639
  }
8024
8640
 
8641
+ /**
8642
+ * Whether a batched gate wake's addressed route may become `agentName`'s
8643
+ * speech locus. The gate broadcasts to every agent, conversation forks
8644
+ * included. A fork speaks only in its home channel, and a channel bound to
8645
+ * a fork is that fork's conversation, so no other agent takes it.
8646
+ */
8647
+ private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean {
8648
+ const home = this.conversationAgentHomes.get(agentName);
8649
+ if (home !== undefined) return home === routeChannelId;
8650
+ for (const boundChannel of this.conversationAgentHomes.values()) {
8651
+ if (boundChannel === routeChannelId) return false;
8652
+ }
8653
+ return true;
8654
+ }
8655
+
8025
8656
  /**
8026
8657
  * Derive the MCPL composite channel id (the outbound routing locus) for a
8027
8658
  * push event from its server-defined `origin`, if it carries one.
@@ -8289,19 +8920,31 @@ export class AgentFramework {
8289
8920
  // and counterparty must come from the same message, or a batch of
8290
8921
  // "ambient from A, then addressed from B" would report B's channel
8291
8922
  // with A's author (review finding on the provenance change).
8923
+ //
8924
+ // "Most recent" is by the EVENT's time (wakeAt, else the request's own
8925
+ // timestamp), not queue order. A gate wake is queued when its debounce
8926
+ // flushes, which can be long after its event: an addressed message
8927
+ // held in the gate while the agent was busy lands BEHIND a newer
8928
+ // addressed message's direct wake, and queue order would hand the turn
8929
+ // to the older one. Ties keep the later-queued request, as before.
8930
+ const eventAt = (r: InferenceRequest): number => r.wakeAt ?? r.timestamp;
8292
8931
  let ambientReq: InferenceRequest | undefined;
8293
8932
  let addressedReq: InferenceRequest | undefined;
8294
8933
  for (const r of requests) {
8295
8934
  if (!r.channelId) continue;
8296
- ambientReq = r;
8297
- if (r.addressed) addressedReq = r;
8935
+ if (!ambientReq || eventAt(r) >= eventAt(ambientReq)) ambientReq = r;
8936
+ if (r.addressed && (!addressedReq || eventAt(r) >= eventAt(addressedReq))) addressedReq = r;
8298
8937
  }
8299
8938
  const channelReq = addressedReq ?? ambientReq;
8300
- // Telemetry provenance (who/where woke the agent): from the routing
8301
- // winner when it carries one; else from the most recent request that
8302
- // does (gate wakes name a counterparty without setting a locus),
8303
- // ordered by the event's own time (wakeAt) rather than flush order.
8304
- let provReq: InferenceRequest | undefined = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined;
8939
+ // Telemetry provenance (who/where woke the agent). An ADDRESSED winner
8940
+ // is its own provenance, even when it names no author (a push event
8941
+ // can route without an author id): borrowing another request's author
8942
+ // would credit the turn to someone who did not address the agent.
8943
+ // Otherwise from the routing winner when it carries one; else from the
8944
+ // most recent request that does (ambient gate wakes name a counterparty
8945
+ // without setting a locus), ordered by the event's own time.
8946
+ let provReq: InferenceRequest | undefined = addressedReq
8947
+ ?? (channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined);
8305
8948
  if (!provReq) {
8306
8949
  for (const r of requests) {
8307
8950
  if (!r.counterparty && !r.wakeChannelId) continue;
@@ -8311,10 +8954,19 @@ export class AgentFramework {
8311
8954
  // A silent tick never suppresses a genuine coalesced wake. If any
8312
8955
  // ordinary request shares this batch, the ordinary turn semantics win.
8313
8956
  const silentOnly = requests.every((r) => r.suppressProse === true);
8957
+ // RFC-006 §5: when every cause is a deferred batch, the turn's content
8958
+ // exists only once assembly renders it. Recorded for the whole batch
8959
+ // (not inherited from requests[0]), so a requeued trigger stays honest.
8960
+ const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
8314
8961
  await this.startAgentStream(agent, {
8315
8962
  ...trigger,
8963
+ coalescingBatch: batchOnly || undefined,
8964
+ coalescingBatchSubjects: batchOnly
8965
+ ? [...new Set(requests.flatMap((r) => r.coalescingBatchSubjects ?? [r.coalescingSubject!]))]
8966
+ : undefined,
8316
8967
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
8317
8968
  ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
8969
+ silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
8318
8970
  channelId: channelReq?.channelId,
8319
8971
  addressed: addressedReq !== undefined,
8320
8972
  // A context-budget restart continues the same logical turn: it keeps
@@ -8328,24 +8980,36 @@ export class AgentFramework {
8328
8980
  }
8329
8981
  }
8330
8982
 
8331
- /** Record prose segments suppressed by explicit-send silencing. */
8332
- private recordProseSuppression(agentName: string, count: number): void {
8983
+ /**
8984
+ * Metadata for rows the agent's current turn stores: a silent heartbeat
8985
+ * tick stamps each of them (assistant responses, tool_result rows) so they
8986
+ * stay identifiable after the turn — request builds key the tick separator
8987
+ * on it, and a later retention policy can find or collapse them. The stamp
8988
+ * names the agent: residents share one message slot and a broadcast tick
8989
+ * reaches each of them with the same eventId. Undefined for ordinary
8990
+ * turns, whose rows are stored exactly as before.
8991
+ */
8992
+ private silentTurnRowMetadata(agentName: string): MessageMetadata | undefined {
8993
+ const tick = this.activeTurnTriggers.get(agentName)?.silentHeartbeat;
8994
+ const stamp: SilentHeartbeatStamp | undefined = tick
8995
+ ? { eventId: tick.eventId, serverId: tick.serverId, agentName }
8996
+ : undefined;
8997
+ return stamp ? { silentHeartbeat: stamp } : undefined;
8998
+ }
8999
+
9000
+ /** Record plain-prose segments this turn suppressed. */
9001
+ private recordProseSuppression(routing: TurnRouting, count: number): void {
8333
9002
  if (count <= 0) return;
8334
- this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
9003
+ routing.logical.suppressed += count;
8335
9004
  }
8336
9005
 
8337
9006
  /** Record a successful plain-prose delivery for this turn's receipt. */
8338
9007
  private recordProseDelivery(
8339
- agentName: string,
9008
+ routing: TurnRouting,
8340
9009
  outcome: { delivered: boolean; channelId: string } | null | undefined,
8341
9010
  ): void {
8342
9011
  if (!outcome?.delivered) return;
8343
- let list = this.turnProseDeliveries.get(agentName);
8344
- if (!list) {
8345
- list = [];
8346
- this.turnProseDeliveries.set(agentName, list);
8347
- }
8348
- list.push(outcome.channelId);
9012
+ routing.logical.deliveries.push(outcome.channelId);
8349
9013
  }
8350
9014
 
8351
9015
  /**
@@ -8361,12 +9025,12 @@ export class AgentFramework {
8361
9025
  * delivered nothing. Failures are already marked separately
8362
9026
  * ([discord-send-failed]); this is the success half.
8363
9027
  */
8364
- private appendProseDeliveryReceipt(agent: Agent): void {
8365
- const list = this.turnProseDeliveries.get(agent.name);
8366
- const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
8367
- if ((!list || list.length === 0) && suppressed === 0) return;
8368
- this.turnProseDeliveries.delete(agent.name);
8369
- this.turnProseSuppressed.delete(agent.name);
9028
+ private appendProseDeliveryReceipt(agent: Agent, routing: TurnRouting): void {
9029
+ const list = routing.logical.deliveries;
9030
+ const suppressed = routing.logical.suppressed;
9031
+ if (list.length === 0 && suppressed === 0) return;
9032
+ routing.logical.deliveries = [];
9033
+ routing.logical.suppressed = 0;
8370
9034
  const seen = new Set<string>();
8371
9035
  const shown: string[] = [];
8372
9036
  for (const id of list ?? []) {
@@ -8494,6 +9158,47 @@ export class AgentFramework {
8494
9158
  }
8495
9159
  }
8496
9160
 
9161
+ /**
9162
+ * A `!` continuation the turn asked for (`>>#x !`, `>>>#x !`): start another
9163
+ * turn now instead of pausing until the next external event. It reads the
9164
+ * logical turn of the stream that asked (shared with a retry or a
9165
+ * re-inference that continues it), once its deliveries have run, when the
9166
+ * turn completes or a tool ends it. A stream that ends in an error asks for
9167
+ * none itself, but a framework retry that follows it carries the request;
9168
+ * a turn abandoned by a quiesce asks for none.
9169
+ */
9170
+ private requestProseContinuation(agent: Agent, routing: TurnRouting): void {
9171
+ // The flag may be shared with an earlier stream of the same logical
9172
+ // turn (LogicalTurn); its deliveries are on the agent's chain ahead of
9173
+ // this stream's, so by the time this check runs, they have run.
9174
+ if (!routing.logical.continuation) return;
9175
+ routing.logical.continuation = false;
9176
+ // An explicit turn's check can run after its stream has returned. A
9177
+ // stopped framework, or an agent no longer registered under its name (a
9178
+ // disposed fork or ephemeral run), takes no new turn from it.
9179
+ if (this.providerAdmissionClosed || this.agents.get(agent.name) !== agent) {
9180
+ console.error(`[prose] ${agent.name}: '!' continuation dropped — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
9181
+ return;
9182
+ }
9183
+ console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
9184
+ this.pendingRequests.push({
9185
+ agentName: agent.name,
9186
+ reason: 'prose-continuation',
9187
+ source: 'framework',
9188
+ timestamp: Date.now(),
9189
+ });
9190
+ }
9191
+
9192
+ /** The agent's routing state for its current turn, made when it has none. */
9193
+ private routingOf(agentName: string): TurnRouting {
9194
+ let routing = this.turnRouting.get(agentName);
9195
+ if (!routing) {
9196
+ routing = freshTurnRouting();
9197
+ this.turnRouting.set(agentName, routing);
9198
+ }
9199
+ return routing;
9200
+ }
9201
+
8497
9202
  /**
8498
9203
  * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md).
8499
9204
  * Every prose segment of an `explicit`-mode agent flows through here:
@@ -8502,32 +9207,58 @@ export class AgentFramework {
8502
9207
  * nothing is ever sent to a destination the model did not name (directly
8503
9208
  * or via the turn's sticky target).
8504
9209
  */
8505
- private async deliverProse(agent: Agent, rawText: string): Promise<void> {
8506
- // Multi-envelope: a single prose block may address SEVERAL destinations —
8507
- // every line beginning with `>>` opens a new envelope, routed
8508
- // independently (first live use: Tilde 2026-07-24, one block carrying a
8509
- // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope
8510
- // parser delivered the aside literally into the first destination —
8511
- // exactly the leak class this mode exists to kill). Text before the
8512
- // first `>>` is its own unprefixed envelope (sticky/bounce as usual).
8513
- const lines = rawText.split('\n');
8514
- const envelopes: string[] = [];
8515
- let current: string[] = [];
8516
- for (const line of lines) {
8517
- // Only a REAL prefix line opens an envelope: `>>` immediately followed
8518
- // by a target token. A quoted `>> arrow` (space after) stays body text.
8519
- if (/^>>\S/.test(line.trimStart()) && current.length > 0) {
8520
- envelopes.push(current.join('\n'));
8521
- current = [];
9210
+ private async deliverProse(agent: Agent, rawText: string, round?: { keepPrivate?: boolean }): Promise<void> {
9211
+ // The direct entry delivers under the live turn's routing.
9212
+ await this.deliverAdmittedProse(agent, this.admitProse(rawText), {
9213
+ keepPrivate: round?.keepPrivate,
9214
+ routing: this.routingOf(agent.name),
9215
+ });
9216
+ }
9217
+
9218
+ /**
9219
+ * Explicit prose's envelopes, each `>>` target resolved now, once. A live
9220
+ * round is admitted at its boundary: its turn's destination is decided from
9221
+ * these resolutions (roundDestination), and its queued delivery posts to
9222
+ * them, however the registry's names change before it runs.
9223
+ */
9224
+ private admitProse(rawText: string): AdmittedEnvelope[] {
9225
+ return splitExplicitEnvelopes(rawText).map((text) => {
9226
+ const parsed = parseProsePrefix(text);
9227
+ if (parsed.kind !== 'target') return { text };
9228
+ try {
9229
+ return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!) };
9230
+ } catch (err) {
9231
+ // Admission runs in the stream's event loop, so a resolver that
9232
+ // throws makes the envelope bounce rather than stop the round.
9233
+ return { text, resolved: { error: `the destination couldn't be resolved: ${err instanceof Error ? err.message : String(err)}` } };
8522
9234
  }
8523
- current.push(line);
9235
+ });
9236
+ }
9237
+
9238
+ private async deliverAdmittedProse(
9239
+ agent: Agent,
9240
+ envelopes: AdmittedEnvelope[],
9241
+ round: ProseRound,
9242
+ ): Promise<void> {
9243
+ // Multi-envelope: a single prose block may address SEVERAL destinations,
9244
+ // each envelope routed independently (sticky/bounce as usual).
9245
+ for (const envelope of envelopes) {
9246
+ await this.deliverProseEnvelope(agent, envelope, round);
8524
9247
  }
8525
- if (current.length > 0) envelopes.push(current.join('\n'));
9248
+ }
8526
9249
 
9250
+ /**
9251
+ * Where a delivered round leaves its turn's sticky destination, as the
9252
+ * explicit gateway sets it once the round's envelopes run: each `>>`
9253
+ * envelope whose target resolved becomes the destination. One that didn't
9254
+ * resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text.
9255
+ */
9256
+ private roundDestination(current: string | null, envelopes: AdmittedEnvelope[]): string | null {
9257
+ let destination = current;
8527
9258
  for (const envelope of envelopes) {
8528
- if (!envelope.trim()) continue;
8529
- await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
9259
+ if (envelope.resolved && 'channelId' in envelope.resolved) destination = envelope.resolved.channelId;
8530
9260
  }
9261
+ return destination;
8531
9262
  }
8532
9263
 
8533
9264
  /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
@@ -8536,7 +9267,9 @@ export class AgentFramework {
8536
9267
  rawText: string,
8537
9268
  locus: string | null,
8538
9269
  allowLocus: boolean,
9270
+ turnRouting: TurnRouting,
8539
9271
  ): Promise<void> {
9272
+ const routing = turnRouting;
8540
9273
  const lines = rawText.split('\n');
8541
9274
  const envelopes: string[] = [];
8542
9275
  let current: string[] = [];
@@ -8554,77 +9287,85 @@ export class AgentFramework {
8554
9287
  const normalized = envelope.replace(/^\s+(?=>>>)/, '');
8555
9288
  const attempted = normalized.startsWith('>>>');
8556
9289
  const parsed = parseHybridProsePrefix(normalized);
8557
- if (parsed.continueTurn) this.proseContinuations.add(agent.name);
9290
+ if (parsed.continueTurn) routing.logical.continuation = true;
8558
9291
  if (parsed.kind === 'private') {
8559
- this.proseTargetPins.delete(agent.name);
8560
- this.proseHybridSuppressed.add(agent.name);
9292
+ delete routing.pin;
9293
+ routing.hybridHold = true;
8561
9294
  console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
8562
9295
  continue;
8563
9296
  }
8564
9297
  if (parsed.kind === 'target') {
8565
9298
  const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!);
8566
9299
  if ('error' in resolved) {
8567
- this.proseTargetPins.delete(agent.name);
8568
- this.proseHybridSuppressed.add(agent.name);
9300
+ delete routing.pin;
9301
+ routing.hybridHold = true;
8569
9302
  this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
8570
9303
  continue;
8571
9304
  }
8572
9305
  let body = parsed.body;
8573
9306
  const usedClipboard = body.includes('{{unsent}}');
8574
9307
  if (usedClipboard) body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
8575
- this.proseTargetPins.set(agent.name, resolved.channelId);
8576
- this.proseHybridSuppressed.delete(agent.name);
9308
+ routing.pin = resolved.channelId;
9309
+ routing.hybridHold = false;
8577
9310
  if (!body.trim()) {
8578
9311
  console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
8579
9312
  continue;
8580
9313
  }
8581
9314
  try {
8582
9315
  const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId);
8583
- this.recordProseDelivery(agent.name, outcome);
9316
+ this.recordProseDelivery(routing, outcome);
8584
9317
  if (outcome?.delivered) {
8585
9318
  this.proseBounceStreaks.delete(agent.name);
8586
9319
  if (usedClipboard) this.proseClipboards.delete(agent.name);
8587
9320
  } else {
8588
- this.proseTargetPins.delete(agent.name);
8589
- this.proseHybridSuppressed.add(agent.name);
9321
+ delete routing.pin;
9322
+ routing.hybridHold = true;
8590
9323
  this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
8591
9324
  }
8592
9325
  } catch (err) {
8593
- this.proseTargetPins.delete(agent.name);
8594
- this.proseHybridSuppressed.add(agent.name);
9326
+ delete routing.pin;
9327
+ routing.hybridHold = true;
8595
9328
  this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
8596
9329
  }
8597
9330
  continue;
8598
9331
  }
8599
9332
  if (attempted) {
8600
- this.proseTargetPins.delete(agent.name);
8601
- this.proseHybridSuppressed.add(agent.name);
9333
+ delete routing.pin;
9334
+ routing.hybridHold = true;
8602
9335
  this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
8603
9336
  continue;
8604
9337
  }
8605
- if (this.proseHybridSuppressed.has(agent.name)) {
8606
- this.recordProseSuppression(agent.name, 1);
9338
+ if (routing.hybridHold) {
9339
+ this.recordProseSuppression(routing, 1);
8607
9340
  continue;
8608
9341
  }
8609
- const sticky = this.proseTargetPins.get(agent.name);
9342
+ const sticky = routing.pin;
8610
9343
  if (!allowLocus && !sticky) {
8611
- this.recordProseSuppression(agent.name, 1);
9344
+ this.recordProseSuppression(routing, 1);
8612
9345
  continue;
8613
9346
  }
8614
9347
  const target = sticky ?? locus;
8615
9348
  try {
8616
9349
  const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
8617
- this.recordProseDelivery(agent.name, outcome);
9350
+ this.recordProseDelivery(routing, outcome);
8618
9351
  } catch (err) {
8619
9352
  console.error('hybrid locus delivery failed:', err);
8620
9353
  }
8621
9354
  }
8622
9355
  }
8623
9356
 
8624
- private async deliverProseEnvelope(agent: Agent, rawText: string): Promise<void> {
9357
+ private async deliverProseEnvelope(
9358
+ agent: Agent,
9359
+ envelope: AdmittedEnvelope,
9360
+ round: ProseRound,
9361
+ ): Promise<void> {
8625
9362
  const name = agent.name;
9363
+ const routing = round.routing;
9364
+ const rawText = envelope.text;
8626
9365
  const parsed = parseProsePrefix(rawText);
8627
- if (parsed.continueTurn) this.proseContinuations.add(name);
9366
+ // A private round (one that called skip_reply) asks for no publication
9367
+ // or continuation, even when delivery runs after that turn has ended.
9368
+ if (parsed.continueTurn && !round.keepPrivate) routing.logical.continuation = true;
8628
9369
 
8629
9370
  if (parsed.kind === 'private') {
8630
9371
  // The text already lives in the assistant message (window/chronicle);
@@ -8635,20 +9376,35 @@ export class AgentFramework {
8635
9376
  return;
8636
9377
  }
8637
9378
 
9379
+ if (round.keepPrivate) {
9380
+ // skip_reply's promise: "Any plain text you wrote this turn stays
9381
+ // private and is NOT posted". Every envelope of the round stays in the
9382
+ // context, as a >>skip_reply envelope does: it isn't sent, doesn't
9383
+ // bounce to the clipboard and doesn't move the destination.
9384
+ console.error(
9385
+ `[prose] ${name}: ${parsed.kind === 'target' ? `>>${parsed.target}` : 'unprefixed'} — ` +
9386
+ `${parsed.body.length} chars kept in context (skip_reply round)`,
9387
+ );
9388
+ return;
9389
+ }
9390
+
8638
9391
  let targetChannel: string;
8639
9392
  let body: string;
8640
9393
  if (parsed.kind === 'target') {
8641
- const res = this.channelRegistry!.resolveProseTarget(parsed.target!);
9394
+ // Resolved once, when the round was admitted (admitProse), never again
9395
+ // here after the wait: a `>>` envelope without its resolution is a bug.
9396
+ const res = envelope.resolved;
9397
+ if (!res) throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`);
8642
9398
  if ('error' in res) {
8643
9399
  this.bounceProse(agent, parsed.body, res.error, res.candidates);
8644
9400
  return;
8645
9401
  }
8646
9402
  targetChannel = res.channelId;
8647
9403
  // Sticky within the turn: later unprefixed segments follow this target.
8648
- this.proseTargetPins.set(name, targetChannel);
9404
+ routing.pin = targetChannel;
8649
9405
  body = parsed.body;
8650
9406
  } else {
8651
- const sticky = this.proseTargetPins.get(name);
9407
+ const sticky = routing.pin;
8652
9408
  if (!sticky) {
8653
9409
  this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn');
8654
9410
  return;
@@ -8879,8 +9635,8 @@ export class AgentFramework {
8879
9635
  turnToken: number,
8880
9636
  ownsProviderGate: boolean,
8881
9637
  ): Promise<boolean> {
8882
- const continuingTurn = trigger?.reason === 'context_budget_restart'
8883
- || trigger?.reason === 'tool_result_guard_retry';
9638
+ const lifecycle = this.lifecycleGeneration;
9639
+ const continuingTurn = resumesLogicalTurn(trigger?.reason);
8884
9640
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
8885
9641
  // Flush messages deferred during the PREVIOUS turn — before the
8886
9642
  // checkpoint, the locus announcement, and the compile — so a turn started
@@ -8928,14 +9684,48 @@ export class AgentFramework {
8928
9684
  // boundary — after the deferred flush (same window: turn alive, compile
8929
9685
  // not yet run) and before the checkpoint (they are the turn's inputs).
8930
9686
  // Not on a context-budget restart: that continues the same logical turn.
9687
+ let assembly: AssemblyResult | undefined;
8931
9688
  if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
8932
9689
  try {
8933
- await this.pushCoalescer.assemble(agent.name);
9690
+ assembly = await this.pushCoalescer.assemble(agent.name);
8934
9691
  } catch (err) {
8935
9692
  console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
8936
9693
  }
8937
9694
  }
8938
9695
 
9696
+ // A turn whose only causes were deferred batches, all of which rendered
9697
+ // nothing (empty or blank render, §5.2; cancelled; revoked), has nothing
9698
+ // new to show: running it would wake the model to `[Continue]` or an
9699
+ // older message, an uncaused wake. Stop before the checkpoint, locus,
9700
+ // typing and compile. Any batch this assembly materialized keeps the
9701
+ // turn, including one that was not among its causes (a batch whose wake
9702
+ // the freeze withdrew, or one that never qualified for a wake). A cause
9703
+ // this assembly did not settle (rendered elsewhere, not yet frozen) keeps
9704
+ // the turn, as before.
9705
+ const batchSubjects = trigger?.coalescingBatchSubjects;
9706
+ if (assembly && batchSubjects?.length && assembly.materialized.size === 0
9707
+ && batchSubjects.every((s) => assembly.settled.has(s))) {
9708
+ console.error(`[coalescing] ${agent.name}: deferred render produced nothing; turn not started (no wake cause left)`);
9709
+ this.emitTrace({ type: 'mcpl:coalescing', kind: 'turn-withdrawn', agentName: agent.name, subjects: batchSubjects });
9710
+ // Release the turn and land anything deferred while assembly awaited
9711
+ // the render, as a torn-down turn would.
9712
+ if (this.activeTurnTokens.get(agent.name) === turnToken) {
9713
+ this.activeTurnTokens.delete(agent.name);
9714
+ this.activeTurnTriggers.delete(agent.name);
9715
+ }
9716
+ if (!this.activeTurnTokens.has(agent.name)
9717
+ && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
9718
+ for (const msg of this.drainDeferredFor(agent.name)) {
9719
+ this.addMessage(msg.participant, msg.content, msg.metadata, {
9720
+ deferredWriteId: msg.id,
9721
+ ...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
9722
+ });
9723
+ }
9724
+ this.ackDeferredWrites();
9725
+ }
9726
+ return false;
9727
+ }
9728
+
8939
9729
  // Record turn checkpoint before inference (only on first attempt, not retries)
8940
9730
  if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
8941
9731
  this.recordTurnCheckpoint(agent.name);
@@ -8990,20 +9780,30 @@ export class AgentFramework {
8990
9780
  // A framework retry is a new physical stream in the same logical turn.
8991
9781
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
8992
9782
  }
8993
- // Fresh turn: forget the previous turn's explicit-send engagements and
8994
- // prose deliveries — both are strictly turn-scoped (a restart continues
8995
- // the same logical turn and keeps them).
9783
+ // Fresh turn: forget the previous turn's explicit-send engagements.
9784
+ // A restart continues the same logical turn and keeps them.
8996
9785
  this.turnEngagedChannels.delete(agent.name);
8997
- this.turnProseDeliveries.delete(agent.name);
8998
- this.turnProseSuppressed.delete(agent.name);
8999
- this.proseHybridSuppressed.delete(agent.name);
9000
- if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
9786
+ // A new routing state for the new turn, in every mode: the sticky
9787
+ // destination, the turn's destination, hybrid's hold and the receipt
9788
+ // tallies start empty. The previous turn's object isn't cleared: prose
9789
+ // it left queued still reads and writes its own.
9790
+ const fresh = freshTurnRouting();
9791
+ // A start while the agent's logical turn is open continues it: it
9792
+ // shares the earlier stream's ` !` and receipt tallies, which a
9793
+ // delivery still queued there can write after this stream begins
9794
+ // (openLogical). Otherwise this start opens one. A silent start does
9795
+ // neither: it routes no prose, so it keeps a logical turn of its own,
9796
+ // and an open turn waits for the next start that routes prose.
9797
+ if (!trigger?.suppressProse) {
9798
+ const open = this.openLogical.get(agent.name);
9799
+ if (open) fresh.logical = open;
9800
+ else this.openLogical.set(agent.name, fresh.logical);
9801
+ }
9802
+ this.turnRouting.set(agent.name, fresh);
9001
9803
  if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
9002
9804
  // Explicit and disabled prose routing have no inferred locus.
9003
9805
  // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
9004
9806
  // prose target at all. Neither mode freezes or announces a locus.
9005
- this.proseTargetPins.delete(agent.name);
9006
- this.proseContinuations.delete(agent.name);
9007
9807
  this.midTurnInputSignals.delete(agent.name);
9008
9808
  this.turnLocusPins.delete(agent.name);
9009
9809
  } else {
@@ -9020,9 +9820,16 @@ export class AgentFramework {
9020
9820
  // opened/pinned a channel mid-turn.
9021
9821
  if (trigger?.suppressProse) {
9022
9822
  this.turnLocusPins.delete(agent.name);
9023
- this.proseTargetPins.delete(agent.name);
9024
- this.proseContinuations.delete(agent.name);
9823
+ // Silent: no routing state. A continuing silent start keeps its turn's
9824
+ // logical turn; a fresh one keeps the one of its own it was given.
9825
+ const silent = freshTurnRouting();
9826
+ const logical = this.turnRouting.get(agent.name)?.logical;
9827
+ if (logical) silent.logical = logical;
9828
+ this.turnRouting.set(agent.name, silent);
9025
9829
  }
9830
+ // This start's own logical turn: a failure before its stream exists
9831
+ // settles it, and no other start's.
9832
+ const startLogical = this.turnRouting.get(agent.name)?.logical;
9026
9833
 
9027
9834
  this.touchEphemeralRun(agent.name, true);
9028
9835
  this.emitTrace({
@@ -9057,7 +9864,11 @@ export class AgentFramework {
9057
9864
 
9058
9865
  try {
9059
9866
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
9060
- const tools = this.agentToolSurface(agent, requestSnapshot);
9867
+ // Capture both surfaces together before context hooks can refresh tools.
9868
+ const presentation = this.inspectToolPresentation(agent.name, requestSnapshot);
9869
+ const compressionTools = presentation?.available
9870
+ ?? structuredClone(this.availableToolsForPresentation(agent.name, requestSnapshot));
9871
+ const tools = presentation?.advertised ?? compressionTools;
9061
9872
 
9062
9873
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
9063
9874
  // Both produce ContextInjection[] that get merged before inference.
@@ -9105,9 +9916,10 @@ export class AgentFramework {
9105
9916
  injections = injections ? [...injections, silentInjection] : [silentInjection];
9106
9917
  }
9107
9918
 
9108
- // An ephemeral watchdog may dispose this Agent while hooks/compile await.
9109
- // Do not create a provider stream after its generation lost ownership.
9110
- if (this.agents.get(agent.name) !== agent) {
9919
+ // An ephemeral watchdog may dispose this Agent while hooks/compile await,
9920
+ // and stop() may close provider admission. Do not create a provider
9921
+ // stream after its generation lost ownership, or once admission closed.
9922
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
9111
9923
  this.channelRegistry?.stopTyping();
9112
9924
  this.eventGate?.onInferenceEnded(agent.name);
9113
9925
  return false;
@@ -9129,10 +9941,28 @@ export class AgentFramework {
9129
9941
  request: compiledRequest,
9130
9942
  takeKvSubmission,
9131
9943
  drainKvSubmissionIds,
9132
- } = await agent.startStreamWithInjections(tools, injections);
9133
- if (this.agents.get(agent.name) !== agent) {
9944
+ } = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools, {
9945
+ silentHeartbeat: trigger?.silentHeartbeat,
9946
+ });
9947
+ // The compile can wait for annotations (each batch has one spill
9948
+ // deadline), so check ownership, admission, and the lifecycle again.
9949
+ // A stop followed by start must not revive this earlier turn.
9950
+ // Membrane's yielding stream starts its request when it is first
9951
+ // iterated, so cancelling it here, before driveStream, sends nothing.
9952
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
9953
+ console.error(`[turn] ${agent.name}: a turn compiled while ${
9954
+ this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
9955
+ : this.providerAdmissionClosed ? 'the framework was stopping'
9956
+ : 'the framework was stopped and started again'} is dropped before it sends anything`);
9134
9957
  stream.cancel();
9135
9958
  agent.cancelStream();
9959
+ if (this.agents.get(agent.name) === agent) {
9960
+ // The compile took the agent's stream id, so a frame that started
9961
+ // this one (a guard or budget restart) no longer owns the entries
9962
+ // it left, and its teardown leaves them to this frame.
9963
+ this.activeStreams.delete(agent.name);
9964
+ this.pendingAssistantBlocks.delete(agent.name);
9965
+ }
9136
9966
  this.channelRegistry?.stopTyping();
9137
9967
  this.eventGate?.onInferenceEnded(agent.name);
9138
9968
  return false;
@@ -9195,7 +10025,7 @@ export class AgentFramework {
9195
10025
  stopReason: 'exhausted',
9196
10026
  speech: '',
9197
10027
  error: err.message,
9198
- });
10028
+ }, startLogical);
9199
10029
  this.emitTrace({
9200
10030
  type: 'inference:exhausted',
9201
10031
  agentName: agent.name,
@@ -9259,6 +10089,9 @@ export class AgentFramework {
9259
10089
  // from the map here: the turn-alive busy check in processInferenceRequests
9260
10090
  // means no successor turn can have replaced it while we compiled.
9261
10091
  const myTurnToken = this.activeTurnTokens.get(agent.name);
10092
+ // The turn-alive guard also keeps this turn's routing current at entry.
10093
+ // Retain it for queued deliveries and receipts after a tool ends the turn.
10094
+ const streamRouting = this.routingOf(agent.name);
9262
10095
  let hadToolCalls = false;
9263
10096
  let generationLost = false;
9264
10097
 
@@ -9280,14 +10113,26 @@ export class AgentFramework {
9280
10113
  // awaits the chain before routing trailing prose, so in-channel ordering
9281
10114
  // is preserved end-to-end. Each link catches its own error: one failed
9282
10115
  // post must not silence the rest of the turn.
9283
- let turnSpeechChain: Promise<void> = Promise.resolve();
10116
+ //
10117
+ // Every stream queues its deliveries behind the agent's latest link, so
10118
+ // the agent's posts go out in the order they were written, across a
10119
+ // turn's streams (a budget restart, a tool-result-guard retry) and across
10120
+ // turns. A turn that a tool ends (skip_reply) settles before its queued
10121
+ // prose has posted; that prose keeps its own turn's routing state
10122
+ // (turnRouting), and the next turn's posts wait behind it.
10123
+ const resumesTurn = resumesLogicalTurn(trigger?.reason);
10124
+ let turnSpeechChain: Promise<void> = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
10125
+ this.proseSpeechChains.set(agent.name, turnSpeechChain);
10126
+ const chainSpeech = (link: () => Promise<void>, failure: string): void => {
10127
+ turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err));
10128
+ // A stream another has replaced doesn't move the turn's latest link.
10129
+ if (agent.streamId === myStreamId) this.proseSpeechChains.set(agent.name, turnSpeechChain);
10130
+ };
9284
10131
  const enqueueSpeech = (text: string, locus: string | null): void => {
9285
- turnSpeechChain = turnSpeechChain
9286
- .then(async () => {
9287
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
9288
- this.recordProseDelivery(agent.name, outcome);
9289
- })
9290
- .catch((err) => console.error('mid-turn speech routing failed:', err));
10132
+ chainSpeech(async () => {
10133
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
10134
+ this.recordProseDelivery(streamRouting, outcome);
10135
+ }, 'mid-turn speech routing failed:');
9291
10136
  };
9292
10137
 
9293
10138
  // Sticky explicit-send suppression: prose after send_message stays quiet
@@ -9366,7 +10211,16 @@ export class AgentFramework {
9366
10211
  !agent.toolWrapperProseGuard
9367
10212
  ? new ProseStreamRouter({
9368
10213
  mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
9369
- initialTarget: typingChannel,
10214
+ // Explicit text has no destination until a `>>` prefix names one,
10215
+ // so a fresh turn's stream starts suppressed. One that resumes the
10216
+ // turn (a tool-result-guard retry, a budget restart) starts at the
10217
+ // destination the turn's delivered rounds have set: a private
10218
+ // round's prefixes don't count, and the gateway's queued
10219
+ // deliveries needn't have run. The trigger channel is only where
10220
+ // explicit mode's typing indicator shows.
10221
+ initialTarget: turnProseRouting === 'explicit'
10222
+ ? (resumesTurn ? streamRouting.destination : null)
10223
+ : typingChannel,
9370
10224
  resolve: (spec) => {
9371
10225
  const r = this.channelRegistry!.resolveProseTarget(spec);
9372
10226
  return 'channelId' in r ? r.channelId : null;
@@ -9388,6 +10242,8 @@ export class AgentFramework {
9388
10242
  // other trace consumers voice it). Hold both; release in order on a clean
9389
10243
  // round (tool-calls / non-guard completion), discard on a guard refusal.
9390
10244
  let guardHeld: Array<{ trace: Parameters<AgentFramework['emitTrace']>[0]; text?: string }> = [];
10245
+ // The text block the stream's last text came from, within this round.
10246
+ let lastTextBlock: number | undefined;
9391
10247
  const releaseGuardHeld = (): void => {
9392
10248
  const held = guardHeld;
9393
10249
  guardHeld = [];
@@ -9432,7 +10288,17 @@ export class AgentFramework {
9432
10288
  // turn's prose is bound for, without re-deriving routing.
9433
10289
  channelId: typingChannel ?? undefined,
9434
10290
  };
9435
- const text = event.meta.type === 'text' ? event.content : undefined;
10291
+ // Delivery joins a round's text blocks with a newline
10292
+ // (splitProseSegments), so a later block starts a new line for
10293
+ // the stream too.
10294
+ let text = event.meta.type === 'text' ? event.content : undefined;
10295
+ if (text !== undefined) {
10296
+ const block = event.meta.blockIndex;
10297
+ if (block !== undefined) {
10298
+ if (lastTextBlock !== undefined && block !== lastTextBlock) text = '\n' + text;
10299
+ lastTextBlock = block;
10300
+ }
10301
+ }
9436
10302
  if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
9437
10303
  guardHeld.push({ trace, text });
9438
10304
  } else {
@@ -9465,6 +10331,11 @@ export class AgentFramework {
9465
10331
  outgoingIndex = 0;
9466
10332
  discardGuardHeld();
9467
10333
  proseStream?.reset();
10334
+ lastTextBlock = undefined;
10335
+ // Explicit mode's retried attempt starts where the turn's
10336
+ // delivered rounds have set the destination, not at the stream's
10337
+ // seed: rounds this stream delivered before the refusal stand.
10338
+ if (turnProseRouting === 'explicit') proseStream?.retarget(streamRouting.destination);
9468
10339
  this.emitTrace({
9469
10340
  type: 'inference:tokens',
9470
10341
  agentName: agent.name,
@@ -9494,6 +10365,10 @@ export class AgentFramework {
9494
10365
  // preceding results before storing/dispatching this new round.
9495
10366
  agent.toolResultGuard.accept();
9496
10367
  releaseGuardHeld();
10368
+ // The round's text ends here: delivery parses each round's text
10369
+ // on its own, so the next round's text starts a new line.
10370
+ if (proseStream) emitOutgoing(proseStream.endSegment());
10371
+ lastTextBlock = undefined;
9497
10372
  adoptInjectedRound();
9498
10373
  hadToolCalls = true;
9499
10374
  this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
@@ -9602,39 +10477,58 @@ export class AgentFramework {
9602
10477
  console.error(
9603
10478
  `[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`,
9604
10479
  );
9605
- this.recordProseSuppression(agent.name, roundSegments.length);
10480
+ this.recordProseSuppression(streamRouting, roundSegments.length);
9606
10481
  } else if (turnProseRouting === 'hybrid') {
9607
10482
  if (turnSilenced) {
9608
- this.recordProseSuppression(agent.name, roundSegments.length);
10483
+ this.recordProseSuppression(streamRouting, roundSegments.length);
9609
10484
  } else if (!hasSameRoundPrivateThink) {
9610
10485
  const locus = resolveTurnLocus();
10486
+ // Queued under this turn's routing, whenever it posts.
10487
+ const routing = streamRouting;
9611
10488
  for (const seg of roundSegments) {
9612
- turnSpeechChain = turnSpeechChain
9613
- .then(() => this.deliverHybridProse(agent, seg, locus, true))
9614
- .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
10489
+ chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true, routing), 'mid-turn hybrid prose delivery failed:');
9615
10490
  }
9616
10491
  }
9617
10492
  } else if (turnProseRouting === 'explicit') {
9618
- // Explicit mode: every segment through the prose gateway.
9619
- // Silencing does not apply — unprefixed prose bounces and
9620
- // prefixed prose is deliberate; think-privacy still holds.
10493
+ // Explicit mode: the segments go through the prose
10494
+ // gateway, unless a private think in the round holds them
10495
+ // back. Send silencing does not apply: unprefixed prose
10496
+ // bounces, and prefixed prose is deliberate. skip_reply
10497
+ // does: its round's envelopes all stay in the context
10498
+ // (deliverProseEnvelope's keepPrivate), as the tool
10499
+ // promises, and skip_reply then ends the turn at this
10500
+ // round's tool results.
9621
10501
  if (!hasSameRoundPrivateThink) {
10502
+ const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply');
10503
+ // The round is admitted now: its targets are resolved
10504
+ // once, for the turn's destination and for its queued
10505
+ // delivery alike.
10506
+ const admitted = roundSegments.map((seg) => this.admitProse(seg));
10507
+ // Queued under this turn's routing, whenever it posts.
10508
+ const routing = streamRouting;
10509
+ if (!keepPrivate) {
10510
+ routing.destination = this.roundDestination(routing.destination, admitted.flat());
10511
+ }
9622
10512
  console.error(
9623
- `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`,
10513
+ `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`,
9624
10514
  );
9625
- for (const seg of roundSegments) {
9626
- turnSpeechChain = turnSpeechChain
9627
- .then(() => this.deliverProse(agent, seg))
9628
- .catch((err) => console.error('mid-turn prose delivery failed:', err));
10515
+ for (const envelopes of admitted) {
10516
+ chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate, routing }), 'mid-turn prose delivery failed:');
9629
10517
  }
9630
10518
  }
10519
+ // The stream's next round starts where delivery sends
10520
+ // unprefixed text: the turn's destination. The stream
10521
+ // followed this round's prefixes as it was written, but a
10522
+ // private round's, a >>skip_reply envelope's or a target
10523
+ // that didn't resolve leave the destination as it was.
10524
+ proseStream?.retarget(streamRouting.destination);
9631
10525
  } else if (turnSilenced) {
9632
10526
  console.error(
9633
10527
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
9634
10528
  );
9635
10529
  // Visible in the turn-end receipt — silencing must never
9636
10530
  // be a silent black hole (n=8: the flying-scene reply).
9637
- this.recordProseSuppression(agent.name, roundSegments.length);
10531
+ this.recordProseSuppression(streamRouting, roundSegments.length);
9638
10532
  } else if (hasSameRoundPrivateThink) {
9639
10533
  console.error(
9640
10534
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
@@ -9676,6 +10570,7 @@ export class AgentFramework {
9676
10570
  // persisted as assistant speech, including a terminal refusal
9677
10571
  // after the single recovery retry.
9678
10572
  proseStream?.reset();
10573
+ if (turnProseRouting === 'explicit') proseStream?.retarget(streamRouting.destination);
9679
10574
  if (withheld) {
9680
10575
  const usage = response.details?.usage ?? response.usage;
9681
10576
  const tokenUsage = usage ? {
@@ -9758,7 +10653,7 @@ export class AgentFramework {
9758
10653
  // Flush pending assistant blocks
9759
10654
  const pending = this.pendingAssistantBlocks.get(agent.name);
9760
10655
  if (pending) {
9761
- agent.addAssistantResponse(pending);
10656
+ agent.addAssistantResponse(pending, this.silentTurnRowMetadata(agent.name));
9762
10657
  this.pendingAssistantBlocks.delete(agent.name);
9763
10658
  }
9764
10659
  // Store tool results — same bounded spill policy as the
@@ -9773,7 +10668,8 @@ export class AgentFramework {
9773
10668
  cap,
9774
10669
  );
9775
10670
  agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
9776
- this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
10671
+ this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults,
10672
+ this.silentTurnRowMetadata(agent.name));
9777
10673
  // The response is already complete: this batch is never
9778
10674
  // submitted in this turn, so it cannot be refused. Admit it.
9779
10675
  agent.toolResultGuard.settleTurnEnded();
@@ -9817,16 +10713,17 @@ export class AgentFramework {
9817
10713
  system: true,
9818
10714
  kind: 'tool-wrapper-prose-contained',
9819
10715
  toolName: guardedWrapperTool,
10716
+ ...this.silentTurnRowMetadata(agent.name),
9820
10717
  });
9821
10718
  console.error(
9822
10719
  `[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`,
9823
10720
  );
9824
10721
  } else if (lastToolIdx >= 0) {
9825
10722
  if (terminalContent.length > 0) {
9826
- agent.addAssistantResponse(terminalContent);
10723
+ agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
9827
10724
  }
9828
10725
  } else {
9829
- agent.addAssistantResponse(terminalContent);
10726
+ agent.addAssistantResponse(terminalContent, this.silentTurnRowMetadata(agent.name));
9830
10727
  }
9831
10728
 
9832
10729
  // Bind the cooldown receipt to this fresh, successful request —
@@ -9854,6 +10751,20 @@ export class AgentFramework {
9854
10751
  const speechContent = hadToolCalls ? [] : allText;
9855
10752
  const thoughts = hadToolCalls ? allText : [];
9856
10753
 
10754
+ // Explicit text is admitted as its round ends, before anything
10755
+ // below waits (a module's speech callback, the turn's earlier
10756
+ // deliveries): its targets resolve now, as a live round's do.
10757
+ const finalSpeechText = speechContent.map((b) => b.text).join('\n').trim();
10758
+ const finalSegments = hadToolCalls && allText.length > 0
10759
+ ? splitProseSegments(liveProseRouting ? terminalContent : response.content)
10760
+ : [];
10761
+ const admitFinal = turnProseRouting === 'explicit' && !!this.channelRegistry;
10762
+ // The final text is delivered under this turn's routing, as its
10763
+ // queued rounds are.
10764
+ const finalRouting = streamRouting;
10765
+ const admittedSpeech = admitFinal && finalSpeechText ? this.admitProse(finalSpeechText) : null;
10766
+ const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : [];
10767
+
9857
10768
  const du = response.details?.usage;
9858
10769
  const tokenUsage = du
9859
10770
  ? {
@@ -9888,7 +10799,7 @@ export class AgentFramework {
9888
10799
  .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
9889
10800
  .map((block) => block.text)
9890
10801
  .join('\n'),
9891
- });
10802
+ }, streamRouting.logical);
9892
10803
 
9893
10804
  this.emitTrace({
9894
10805
  type: 'inference:completed',
@@ -10066,33 +10977,35 @@ export class AgentFramework {
10066
10977
  // they are never routed here — which is precisely how the `think`
10067
10978
  // tool (and any explicit send tool) yields a silent turn.
10068
10979
  if (speechContent.length > 0 && this.channelRegistry) {
10069
- const speechText = speechContent
10070
- .map((b) => (b as ContentBlock & { type: 'text' }).text)
10071
- .join('\n')
10072
- .trim();
10980
+ const speechText = finalSpeechText;
10073
10981
  if (speechText) {
10982
+ // After the agent's earlier deliveries: every stream's chain
10983
+ // starts from the agent's latest link, so this waits for the
10984
+ // turn's earlier rounds and for an earlier turn's posts still
10985
+ // queued.
10986
+ await turnSpeechChain;
10074
10987
  if (turnProseRouting === 'disabled') {
10075
10988
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
10076
- this.recordProseSuppression(agent.name, 1);
10989
+ this.recordProseSuppression(streamRouting, 1);
10077
10990
  } else if (turnSilenced && turnProseRouting !== 'explicit') {
10078
10991
  // Only reachable on a guard recovery that carried the
10079
10992
  // same-turn send suppression, or a suppressProse trigger
10080
10993
  // (a fresh text-only turn starts unsilenced). Explicit
10081
10994
  // mode is exempt, as mid-turn.
10082
10995
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
10083
- this.recordProseSuppression(agent.name, 1);
10996
+ this.recordProseSuppression(streamRouting, 1);
10084
10997
  } else if (turnProseRouting === 'hybrid') {
10085
10998
  const locus = resolveTurnLocus();
10086
10999
  console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
10087
11000
  try {
10088
- await this.deliverHybridProse(agent, speechText, locus, true);
11001
+ await this.deliverHybridProse(agent, speechText, locus, true, finalRouting);
10089
11002
  } catch (err) {
10090
11003
  console.error('text-only hybrid prose delivery failed:', err);
10091
11004
  }
10092
11005
  } else if (turnProseRouting === 'explicit') {
10093
11006
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
10094
11007
  try {
10095
- await this.deliverProse(agent, speechText);
11008
+ await this.deliverAdmittedProse(agent, admittedSpeech!, { routing: finalRouting });
10096
11009
  } catch (err) {
10097
11010
  console.error('text-only prose delivery failed:', err);
10098
11011
  }
@@ -10109,7 +11022,7 @@ export class AgentFramework {
10109
11022
  );
10110
11023
  try {
10111
11024
  const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
10112
- this.recordProseDelivery(agent.name, outcome);
11025
+ this.recordProseDelivery(streamRouting, outcome);
10113
11026
  } catch (err) {
10114
11027
  console.error('speech routing failed:', err);
10115
11028
  }
@@ -10138,7 +11051,7 @@ export class AgentFramework {
10138
11051
  ? turnSilenced
10139
11052
  : turnSilenced || toolNames.some(isSilencingTool);
10140
11053
 
10141
- const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
11054
+ const segments = finalSegments;
10142
11055
 
10143
11056
  // Preserve in-channel ordering: everything enqueued live must
10144
11057
  // land before the trailing prose. Awaited even when silenced —
@@ -10150,16 +11063,16 @@ export class AgentFramework {
10150
11063
  console.error(
10151
11064
  `[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`,
10152
11065
  );
10153
- this.recordProseSuppression(agent.name, segments.length);
11066
+ this.recordProseSuppression(streamRouting, segments.length);
10154
11067
  }
10155
11068
  } else if (turnProseRouting === 'hybrid') {
10156
11069
  if (silenced && segments.length > 0) {
10157
- this.recordProseSuppression(agent.name, segments.length);
11070
+ this.recordProseSuppression(streamRouting, segments.length);
10158
11071
  } else if (segments.length > 0) {
10159
11072
  const locus = resolveTurnLocus();
10160
11073
  for (const seg of segments) {
10161
11074
  try {
10162
- await this.deliverHybridProse(agent, seg, locus, true);
11075
+ await this.deliverHybridProse(agent, seg, locus, true, finalRouting);
10163
11076
  } catch (err) {
10164
11077
  console.error('trailing hybrid prose delivery failed:', err);
10165
11078
  }
@@ -10170,9 +11083,9 @@ export class AgentFramework {
10170
11083
  console.error(
10171
11084
  `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
10172
11085
  );
10173
- for (const seg of segments) {
11086
+ for (const envelopes of admittedSegments) {
10174
11087
  try {
10175
- await this.deliverProse(agent, seg);
11088
+ await this.deliverAdmittedProse(agent, envelopes, { routing: finalRouting });
10176
11089
  } catch (err) {
10177
11090
  console.error('trailing prose delivery failed:', err);
10178
11091
  }
@@ -10184,7 +11097,7 @@ export class AgentFramework {
10184
11097
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
10185
11098
  );
10186
11099
  if (silenced && segments.length > 0) {
10187
- this.recordProseSuppression(agent.name, segments.length);
11100
+ this.recordProseSuppression(streamRouting, segments.length);
10188
11101
  }
10189
11102
  } else {
10190
11103
  // Reuse the locus pinned at the turn's first live-routed
@@ -10202,7 +11115,7 @@ export class AgentFramework {
10202
11115
  for (const seg of segments) {
10203
11116
  try {
10204
11117
  const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
10205
- this.recordProseDelivery(agent.name, outcome);
11118
+ this.recordProseDelivery(streamRouting, outcome);
10206
11119
  } catch (err) {
10207
11120
  console.error('speech routing failed:', err);
10208
11121
  }
@@ -10214,28 +11127,21 @@ export class AgentFramework {
10214
11127
  // doesn't depend on the status field, so it correctly runs after.
10215
11128
 
10216
11129
  // Logical turn end (normal completion): drop the `[delivered]`
10217
- // receipt. All deliveries are settled — the live chain was
10218
- // awaited before trailing dispatch, and trailing/text-only
10219
- // segments were awaited in-loop. Locus mode only; explicit-mode
10220
- // envelopes acknowledge themselves through the prose gateway.
11130
+ // receipt, once every delivery has settled. The text-only and
11131
+ // trailing paths awaited the chain when there was final text; a
11132
+ // turn whose last response is empty awaits it here, so the
11133
+ // receipt and the continuation see its last rounds' deliveries.
11134
+ // Locus and hybrid mode; explicit-mode envelopes acknowledge
11135
+ // themselves through the prose gateway.
11136
+ await turnSpeechChain;
10221
11137
  if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
10222
- this.appendProseDeliveryReceipt(agent);
11138
+ this.appendProseDeliveryReceipt(agent, streamRouting);
10223
11139
  }
10224
11140
 
10225
- // Explicit-prose `!` continuation: a prose segment this turn asked
10226
- // to keep going (`>>#x !` / `>>private !`) — start another turn
10227
- // now instead of pausing until the next external event. This gives
10228
- // prose the same continuation ability tool rounds have (wanted for
11141
+ // A `!` continuation this turn asked for. This gives prose the
11142
+ // same continuation ability tool rounds have (wanted for
10229
11143
  // robotics-style loops where an end-of-turn pause is harmful).
10230
- if (this.proseContinuations.delete(agent.name)) {
10231
- console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`);
10232
- this.pendingRequests.push({
10233
- agentName: agent.name,
10234
- reason: 'prose-continuation',
10235
- source: 'framework',
10236
- timestamp: Date.now(),
10237
- });
10238
- }
11144
+ this.requestProseContinuation(agent, finalRouting);
10239
11145
 
10240
11146
  break;
10241
11147
  }
@@ -10287,7 +11193,7 @@ export class AgentFramework {
10287
11193
  stopReason: 'exhausted',
10288
11194
  speech: '',
10289
11195
  error: 'Turn abandoned by operator quiesce',
10290
- });
11196
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
10291
11197
  this.emitTrace({
10292
11198
  type: 'inference:aborted',
10293
11199
  agentName: agent.name,
@@ -10316,7 +11222,7 @@ export class AgentFramework {
10316
11222
  stopReason: 'exhausted',
10317
11223
  speech: '',
10318
11224
  error: err.message,
10319
- });
11225
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
10320
11226
  this.emitTrace({
10321
11227
  type: 'inference:exhausted',
10322
11228
  agentName: agent.name,
@@ -10370,7 +11276,7 @@ export class AgentFramework {
10370
11276
  stopReason: 'exhausted',
10371
11277
  speech: '',
10372
11278
  error: 'Turn abandoned by operator quiesce',
10373
- });
11279
+ }, streamRouting.logical);
10374
11280
  }
10375
11281
  this.emitTrace({
10376
11282
  type: 'inference:aborted',
@@ -10383,13 +11289,22 @@ export class AgentFramework {
10383
11289
  // endTurn IS a logical turn end — earlier rounds may have
10384
11290
  // live-routed prose (narrate → skip_reply is a real shape),
10385
11291
  // so settle the delivery chain and drop the receipt. A
10386
- // budget restart continues the same logical turn: the
10387
- // deliveries map persists (cleared only at fresh turn
10388
- // start) and the continuation stream's end writes ONE
10389
- // receipt for the whole turn.
11292
+ // budget restart keeps the same turn's routing and tallies;
11293
+ // the continuation stream's end writes one receipt for it.
10390
11294
  if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
10391
11295
  await turnSpeechChain;
10392
- this.appendProseDeliveryReceipt(agent);
11296
+ this.appendProseDeliveryReceipt(agent, streamRouting);
11297
+ }
11298
+ // A `!` an earlier round asked for still starts its
11299
+ // continuation when a tool ends the turn, once the turn's
11300
+ // deliveries have run. Explicit mode doesn't hold the turn
11301
+ // for them, so it checks at the end of its chain.
11302
+ if (cancelKind === 'turn_ended') {
11303
+ if (turnProseRouting === 'explicit') {
11304
+ void turnSpeechChain.then(() => this.requestProseContinuation(agent, streamRouting));
11305
+ } else {
11306
+ this.requestProseContinuation(agent, streamRouting);
11307
+ }
10393
11308
  }
10394
11309
  return;
10395
11310
  }
@@ -10405,7 +11320,7 @@ export class AgentFramework {
10405
11320
  stopReason: 'exhausted',
10406
11321
  speech: '',
10407
11322
  error: `Stream aborted: ${reason}`,
10408
- });
11323
+ }, streamRouting.logical);
10409
11324
  this.emitTrace({
10410
11325
  type: 'inference:exhausted',
10411
11326
  agentName: agent.name,
@@ -10551,7 +11466,7 @@ export class AgentFramework {
10551
11466
  stopReason: 'exhausted',
10552
11467
  speech: '',
10553
11468
  error: err.message,
10554
- });
11469
+ }, streamRouting.logical);
10555
11470
  this.emitTrace({
10556
11471
  type: 'inference:exhausted',
10557
11472
  agentName: agent.name,
@@ -10815,13 +11730,15 @@ export class AgentFramework {
10815
11730
  let ownedToEnd = false;
10816
11731
  try {
10817
11732
  const started = Date.now();
10818
- const result = await this.executeToolCall({
10819
- id: toolUseId,
10820
- name: toolName,
10821
- input,
10822
- callerAgentName: agentName,
10823
- });
11733
+ const call = { id: toolUseId, name: toolName, input, callerAgentName: agentName };
11734
+ const returned = await this.executeToolCall(call);
10824
11735
  const durationMs = Date.now() - started;
11736
+ // The pair is stored as a model's call would be, so the result stored,
11737
+ // traced and logged is its checked form. The caller gets the tool's
11738
+ // own result back, as an in-process caller does, unless nothing
11739
+ // usable could be stored: then it gets the error result stored.
11740
+ const checked = checkedToolResult(call, returned);
11741
+ const result = checked.result;
10825
11742
 
10826
11743
  // Store the pair through the same shapes the ordinary path uses. Build
10827
11744
  // the result blocks BEFORE storing the tool_use: the spill path awaits.
@@ -10869,7 +11786,7 @@ export class AgentFramework {
10869
11786
  `[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
10870
11787
  `in ${durationMs}ms (${toolUseId})${ownedToEnd ? '' : ' — deferred behind a live turn'}`,
10871
11788
  );
10872
- return { toolUseId, result };
11789
+ return { toolUseId, result: checked.usable ? returned : result };
10873
11790
  } finally {
10874
11791
  if (this.activeTurnTokens.get(agentName) === turnToken) this.activeTurnTokens.delete(agentName);
10875
11792
  // Messages deferred while we held the token land now, after the pair
@@ -10893,6 +11810,7 @@ export class AgentFramework {
10893
11810
  }
10894
11811
 
10895
11812
  private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
11813
+ if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
10896
11814
  // Client-side programmatic tool calling for promise-based callers
10897
11815
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
10898
11816
  // gets its own interpreter state.
@@ -10931,10 +11849,11 @@ export class AgentFramework {
10931
11849
  // ephemerals, ModuleContext.callTool) reach utilities the same way the
10932
11850
  // model does.
10933
11851
  if (call.name === 'utils') {
10934
- return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
11852
+ return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true });
10935
11853
  }
10936
11854
 
10937
- // Module tools
11855
+ // Module tools. The caller uses the result in process, so it gets the
11856
+ // module's own result, as the module returned it.
10938
11857
  return this.moduleRegistry.handleToolCall(call);
10939
11858
  }
10940
11859
 
@@ -11013,6 +11932,7 @@ export class AgentFramework {
11013
11932
  this.pushEvent({
11014
11933
  type: 'tool-result',
11015
11934
  callId: call.id,
11935
+ toolName: call.name,
11016
11936
  agentName,
11017
11937
  moduleName: 'code_execution',
11018
11938
  result,
@@ -11031,6 +11951,11 @@ export class AgentFramework {
11031
11951
  * results reach the running script and never the model context.
11032
11952
  */
11033
11953
  private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
11954
+ // RFC-007: what the script's own tool calls are attributed to. Read
11955
+ // before the first await: it is only known while this call is being
11956
+ // dispatched. Undefined off the model dispatch path (ephemeral callers),
11957
+ // whose code_execution call is not reported either.
11958
+ const origin = this.toolLifecycleEmitter?.scriptOrigin(agentName, call.id);
11034
11959
  const input = (call.input ?? {}) as {
11035
11960
  code?: unknown;
11036
11961
  background?: unknown;
@@ -11106,7 +12031,7 @@ export class AgentFramework {
11106
12031
  );
11107
12032
 
11108
12033
  if (input.background === true) {
11109
- const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
12034
+ const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs, origin);
11110
12035
  if (capNote && started.success && started.data && typeof started.data === 'object') {
11111
12036
  (started.data as Record<string, unknown>).time_limit_note = capNote;
11112
12037
  }
@@ -11114,9 +12039,23 @@ export class AgentFramework {
11114
12039
  }
11115
12040
 
11116
12041
  const runner = this.getOrCreateScriptRunner(agentName);
11117
- this.scriptDeferredEndTurn.delete(agentName);
11118
- const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
11119
- const endTurn = this.scriptDeferredEndTurn.delete(agentName);
12042
+ // A busy runner refuses this exec; the running script keeps its mark and
12043
+ // its origin.
12044
+ const ownsRunner = !runner.busy;
12045
+ const script = { endTurn: false };
12046
+ if (ownsRunner) this.scriptDeferredEndTurn.set(agentName, script);
12047
+ const ownsOrigin = origin !== undefined && ownsRunner;
12048
+ if (ownsOrigin) this.foregroundScriptOrigins.set(agentName, origin);
12049
+ let exec: import('./code-execution/py-runner.js').ExecResult;
12050
+ try {
12051
+ exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
12052
+ } finally {
12053
+ if (this.scriptDeferredEndTurn.get(agentName) === script) this.scriptDeferredEndTurn.delete(agentName);
12054
+ if (ownsOrigin && this.foregroundScriptOrigins.get(agentName) === origin) {
12055
+ this.foregroundScriptOrigins.delete(agentName);
12056
+ }
12057
+ }
12058
+ const endTurn = script.endTurn;
11120
12059
 
11121
12060
  return {
11122
12061
  success: true,
@@ -11148,6 +12087,7 @@ export class AgentFramework {
11148
12087
  code: string,
11149
12088
  injected: import('./code-execution/py-runner.js').InjectedTool[],
11150
12089
  timeLimitMs?: number,
12090
+ origin?: ScriptCallOrigin,
11151
12091
  ): ToolResult {
11152
12092
  // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
11153
12093
  // would outlive its owner and its wake would land in the primary
@@ -11197,7 +12137,7 @@ export class AgentFramework {
11197
12137
  scriptTimeoutMs: cfg?.scriptTimeoutMs,
11198
12138
  idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
11199
12139
  label: `${agentName}:${scriptId}`,
11200
- onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
12140
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args, origin),
11201
12141
  });
11202
12142
 
11203
12143
  const record: BackgroundScriptRecord = {
@@ -11416,7 +12356,7 @@ export class AgentFramework {
11416
12356
  // stays reachable by provenance after the bytes leave the wire.
11417
12357
  const raw = tc.result.isError
11418
12358
  ? tc.result.error ?? 'Unknown error'
11419
- : toolResultDataToHistoryString(tc.result.data, undefined, {
12359
+ : nonErrorResultText(tc.result, undefined, {
11420
12360
  imageRef: (blockIndex, image) => ledger.retain({
11421
12361
  toolCallId: tc.id,
11422
12362
  toolName: tc.name,
@@ -11439,6 +12379,133 @@ export class AgentFramework {
11439
12379
  return { blocks, spilled };
11440
12380
  }
11441
12381
 
12382
+ /** Where a tool result labelled `label` is written: `<first writable
12383
+ * mount>/tool-results/<label>.txt`, or null when there is no writable
12384
+ * workspace. */
12385
+ private toolResultFilePlace(label: string): { workspace: WorkspaceModule; path: string; safeLabel: string } | null {
12386
+ const workspace = this.getWorkspaceModule();
12387
+ const mountName = workspace ? this.firstWritableMountName(workspace) : null;
12388
+ if (!workspace || !mountName) return null;
12389
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
12390
+ return { workspace, path: `${mountName}/tool-results/${safeLabel}.txt`, safeLabel };
12391
+ }
12392
+
12393
+ /**
12394
+ * A withheld original uses the remaining time in its batch's deadline.
12395
+ * A write still under way at that deadline is reported as unfinished and
12396
+ * goes on; the guard stops waiting for it, though stop() still waits for
12397
+ * the workspace's turn it holds (bounded only by a teardown timeout). From
12398
+ * then until every
12399
+ * overdue write settles, new withheld spills are workspace_unanswered.
12400
+ * Writes from other batches can start before the first deadline fires.
12401
+ * An exhausted batch starts no more writes even if none is overdue.
12402
+ * The inline cap's spills use writeToolResultFile without this deadline.
12403
+ */
12404
+ private async spillWithheld(label: string, text: string, branch: string,
12405
+ deadline = Date.now() + this.withheldSpillDeadlineMs): Promise<WithheldSpill> {
12406
+ // A batch with no finite deadline (a host whose deadline() threw) gets
12407
+ // this write's own, rather than a timer Node would cut to 1 ms.
12408
+ if (!Number.isFinite(deadline)) deadline = Date.now() + this.withheldSpillDeadlineMs;
12409
+ if (this.guardSpillsClosedFor === this.lifecycleGeneration) return null;
12410
+ const place = this.toolResultFilePlace(label);
12411
+ if (!place) return null;
12412
+ if (this.stalledSpills.size > 0) {
12413
+ // Said once per stall, with the count when it clears, so a stalled
12414
+ // workspace doesn't write a line for every result it can't take.
12415
+ if (this.stallSkips++ === 0) {
12416
+ 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})`);
12417
+ }
12418
+ return { skipped: 'workspace_unanswered' };
12419
+ }
12420
+ const remaining = deadline - Date.now();
12421
+ // The guard says once per batch that it used its deadline.
12422
+ if (remaining <= 0) return { skipped: 'batch_deadline' };
12423
+ const write = this.writeToolResultFile(label, text, branch);
12424
+ let timer: ReturnType<typeof setTimeout> | undefined;
12425
+ const late = new Promise<'late'>((resolve) => {
12426
+ timer = setTimeout(() => {
12427
+ // Marked as the deadline passes, before anything else runs, so no
12428
+ // spill that starts after it finds the workspace answering.
12429
+ const stalled: Promise<void> = write.then(() => {}, () => {}).finally(() => {
12430
+ this.stalledSpills.delete(stalled);
12431
+ if (this.stalledSpills.size === 0 && this.stallSkips > 0) {
12432
+ console.error(`[spill] the workspace answered again; ${this.stallSkips} withheld original(s) were skipped as workspace_unanswered meanwhile`);
12433
+ this.stallSkips = 0;
12434
+ }
12435
+ });
12436
+ this.stalledSpills.add(stalled);
12437
+ console.error(`[spill] ${place.path} still being written at the withheld batch deadline`);
12438
+ resolve('late');
12439
+ }, remaining);
12440
+ timer.unref?.();
12441
+ });
12442
+ try {
12443
+ const written = await Promise.race([write, late]);
12444
+ if (written !== 'late') {
12445
+ if (!written) return null;
12446
+ if (written.branchLeft) return { skipped: 'branch_changed' };
12447
+ return { path: written.path, ...(written.error !== undefined ? { error: written.error } : {}) };
12448
+ }
12449
+ return { path: place.path, unfinished: true };
12450
+ } finally {
12451
+ clearTimeout(timer);
12452
+ }
12453
+ }
12454
+
12455
+ /**
12456
+ * Write one tool result's text to its tool-results file (see
12457
+ * toolResultFilePlace): the one place both the inline cap's spill and the
12458
+ * guard's withheld originals go. null when there is no writable workspace.
12459
+ * A failed write is reported, traced and logged here, never thrown. With a
12460
+ * branch, the file lands on that branch or isn't written (see writeBinary).
12461
+ */
12462
+ private async writeToolResultFile(
12463
+ label: string,
12464
+ content: string,
12465
+ branch?: string,
12466
+ ): Promise<{ path: string; error?: string; branchLeft?: true } | null> {
12467
+ const place = this.toolResultFilePlace(label);
12468
+ if (!place) return null;
12469
+ const { workspace, path, safeLabel } = place;
12470
+ let failure: string;
12471
+ try {
12472
+ const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain', { branch });
12473
+ if (result.success) return { path };
12474
+ // The file is in the workspace's Chronicle tree, where its tools read
12475
+ // it, and the rest of the write failed (its copy on disk, or something
12476
+ // after the entry was set): it is written for every reader the stub or
12477
+ // the truncation note sends there. The workspace emits
12478
+ // workspace:materialize-failed only for a refused push, so the failure
12479
+ // is traced here too.
12480
+ if ((result.data as { inChronicle?: boolean } | undefined)?.inChronicle === true) {
12481
+ const error = (result.error ?? 'the rest of the write failed').slice(0, 200);
12482
+ this.emitTrace({ type: 'tool:spill_failed', label: safeLabel, path, contentLength: content.length, error });
12483
+ console.error(`[spill] ${path} is in the workspace's Chronicle tree, but the rest of its write failed: ${error}`);
12484
+ return { path };
12485
+ }
12486
+ // Declined, not failed: the store left the branch the file is for, so
12487
+ // it isn't written anywhere, as the caller asked.
12488
+ if ((result.data as { branchLeft?: boolean } | undefined)?.branchLeft === true) {
12489
+ return { path, error: result.error ?? 'the workspace had left the branch this file is for', branchLeft: true };
12490
+ }
12491
+ failure = result.error ?? 'write refused';
12492
+ } catch (err) {
12493
+ failure = err instanceof Error ? err.message : String(err);
12494
+ }
12495
+ failure = failure.slice(0, 200);
12496
+ this.emitTrace({
12497
+ type: 'tool:spill_failed',
12498
+ label: safeLabel,
12499
+ path,
12500
+ contentLength: content.length,
12501
+ error: failure,
12502
+ });
12503
+ console.error(
12504
+ `[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
12505
+ );
12506
+ return { path, error: failure };
12507
+ }
12508
+
11442
12509
  private async spillOrTruncate(
11443
12510
  content: string,
11444
12511
  cap: number | undefined,
@@ -11453,52 +12520,32 @@ export class AgentFramework {
11453
12520
  const { head, tail } = splitPreservingImageSlots(content, cap);
11454
12521
  const kept = formatPreservedImageSlots(tail);
11455
12522
 
11456
- const workspace = this.getWorkspaceModule();
11457
- const mountName = workspace ? this.firstWritableMountName(workspace) : null;
11458
- if (workspace && mountName) {
11459
- const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
11460
- const path = `${mountName}/tool-results/${safeLabel}.txt`;
11461
- let failure: string;
11462
- try {
11463
- const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
11464
- if (result.success) {
11465
- // JSON serialization can expand each read-back code unit to six
11466
- // chars (\\u0000). Leave room for the page's metadata and a possible
11467
- // extra code unit at a surrogate-pair boundary.
11468
- const pageOverhead = JSON.stringify(path).length + 256;
11469
- const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
11470
- return {
11471
- text: head
11472
- + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
11473
- + `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
11474
- + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
11475
- + 'agent_settings update tool_result_inline_max_chars.]'
11476
- + kept,
11477
- filePath: path,
11478
- };
11479
- }
11480
- failure = result.error ?? 'write refused';
11481
- } catch (err) {
11482
- failure = err instanceof Error ? err.message : String(err);
11483
- }
12523
+ const written = await this.writeToolResultFile(label, content);
12524
+ if (written && written.error === undefined) {
12525
+ const path = written.path;
12526
+ // JSON serialization can expand each read-back code unit to six
12527
+ // chars (\\u0000). Leave room for the page's metadata and a possible
12528
+ // extra code unit at a surrogate-pair boundary.
12529
+ const pageOverhead = JSON.stringify(path).length + 256;
12530
+ const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
12531
+ return {
12532
+ text: head
12533
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
12534
+ + `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
12535
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
12536
+ + 'agent_settings update tool_result_inline_max_chars.]'
12537
+ + kept,
12538
+ filePath: path,
12539
+ };
12540
+ }
12541
+ if (written) {
11484
12542
  // A workspace was there and the write FAILED — say that, loudly and
11485
12543
  // distinctly. Telling the agent "no writable workspace" would teach
11486
12544
  // them their residence lacks a capability it actually has.
11487
- failure = failure.slice(0, 200);
11488
- this.emitTrace({
11489
- type: 'tool:spill_failed',
11490
- label: safeLabel,
11491
- path,
11492
- contentLength: content.length,
11493
- error: failure,
11494
- });
11495
- console.error(
11496
- `[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
11497
- );
11498
12545
  return {
11499
12546
  text: head
11500
- + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
11501
- + ` (${failure}); content over the cap was not retained]`
12547
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
12548
+ + ` (${written.error}); content over the cap was not retained]`
11502
12549
  + kept,
11503
12550
  filePath: null,
11504
12551
  };
@@ -11521,7 +12568,13 @@ export class AgentFramework {
11521
12568
  scriptTimeoutMs: cfg?.scriptTimeoutMs,
11522
12569
  idleReclaimMs: cfg?.idleReclaimMs,
11523
12570
  label: agentName,
11524
- onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
12571
+ // The script that made the call is the one running when it arrives.
12572
+ onToolCall: (toolName, args) =>
12573
+ this.handleScriptToolCall(
12574
+ agentName, toolName, args,
12575
+ this.foregroundScriptOrigins.get(agentName),
12576
+ this.scriptDeferredEndTurn.get(agentName),
12577
+ ),
11525
12578
  });
11526
12579
  this.codeExecutionRunners.set(agentName, runner);
11527
12580
  }
@@ -11541,21 +12594,24 @@ export class AgentFramework {
11541
12594
  agentName: string,
11542
12595
  toolName: string,
11543
12596
  args: Record<string, unknown>,
12597
+ origin?: ScriptCallOrigin,
12598
+ /** The foreground script that made the call; none for a background script. */
12599
+ script?: { endTurn: boolean },
11544
12600
  ): Promise<string> {
11545
12601
  if (toolName === CODE_EXECUTION_TOOL_NAME) {
11546
12602
  return 'Error: code_execution cannot be called from within a script';
11547
12603
  }
11548
- const result = await this.dispatchScriptToolCall(agentName, toolName, args);
11549
- if (result.endTurn) {
12604
+ const result = await this.dispatchScriptToolCall(agentName, toolName, args, origin);
12605
+ if (result.endTurn && script) {
11550
12606
  // Deferred: applied to the final code_execution result (see
11551
12607
  // scriptDeferredEndTurn) — ending the turn mid-script would cancel the
11552
12608
  // stream while the script still runs and wedge the tool round.
11553
- this.scriptDeferredEndTurn.add(agentName);
12609
+ script.endTurn = true;
11554
12610
  }
11555
12611
  if (result.isError) {
11556
12612
  return `Error: ${result.error ?? 'tool call failed'}`;
11557
12613
  }
11558
- if (result.data === undefined) return '';
12614
+ if (result.data === undefined || result.data === null) return nonErrorResultText(result, 5_000_000);
11559
12615
  // RFC-005 "eagerly lazy": a script receiving a result is the strongest
11560
12616
  // available signal that the bytes are wanted (scripts process, they do
11561
12617
  // not browse) — materialize every referenced payload now, so the stub
@@ -11582,11 +12638,16 @@ export class AgentFramework {
11582
12638
  * resolve with its result. The waiter intercept in handleProcessEvent
11583
12639
  * (keyed by the pytc- call ID) routes the tool-result event here instead
11584
12640
  * of into the agent's pending tool round.
12641
+ *
12642
+ * RFC-007: with an `origin`, observers see the call as they see a model
12643
+ * call (register, dispatch with its refusal sites, open; the terminal at
12644
+ * the waiter intercept), under the inner tool's own name and class.
11585
12645
  */
11586
12646
  private dispatchScriptToolCall(
11587
12647
  agentName: string,
11588
12648
  toolName: string,
11589
12649
  args: Record<string, unknown>,
12650
+ origin?: ScriptCallOrigin,
11590
12651
  ): Promise<ToolResult> {
11591
12652
  return new Promise<ToolResult>((resolve) => {
11592
12653
  const callId = `pytc-${randomUUID()}`;
@@ -11596,6 +12657,9 @@ export class AgentFramework {
11596
12657
  const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
11597
12658
  const safety = setTimeout(() => {
11598
12659
  if (this.scriptToolWaiters.delete(callId)) {
12660
+ // RFC-007: no result reached the host — the call failed.
12661
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
12662
+ this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
11599
12663
  resolve({
11600
12664
  success: false,
11601
12665
  error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
@@ -11610,15 +12674,28 @@ export class AgentFramework {
11610
12674
  resolve(result);
11611
12675
  });
11612
12676
 
12677
+ const call = { id: callId, name: toolName, input: args };
12678
+ if (origin) this.toolLifecycleEmitter?.registerScriptCall(agentName, origin, call);
11613
12679
  try {
11614
- this.dispatchToolCall(agentName, { id: callId, name: toolName, input: args });
12680
+ this.dispatchToolCall(agentName, call);
11615
12681
  } catch (error) {
12682
+ // Dispatch threw. Not a refusal (refusal sites call refuse() and
12683
+ // answer): the tool may have done work first (sleep sets the gate,
12684
+ // then formats its reply), so the call started and the host got no
12685
+ // result — `failed`, as for a dispatch that rejects.
12686
+ if (origin) {
12687
+ this.toolLifecycleEmitter?.open(agentName, callId);
12688
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, callId);
12689
+ this.toolLifecycleEmitter?.onResult(agentName, callId, undefined);
12690
+ }
11616
12691
  if (this.scriptToolWaiters.delete(callId)) {
11617
12692
  clearTimeout(safety);
11618
12693
  const err = error instanceof Error ? error : new Error(String(error));
11619
12694
  resolve({ success: false, error: err.message, isError: true });
11620
12695
  }
12696
+ return;
11621
12697
  }
12698
+ if (origin) this.toolLifecycleEmitter?.open(agentName, callId);
11622
12699
  });
11623
12700
  }
11624
12701
 
@@ -11967,6 +13044,12 @@ export class AgentFramework {
11967
13044
  private dispatchToolCall(agentName: string, call: ToolCall): void {
11968
13045
  // Enrich call with caller identity so modules can resolve the calling agent
11969
13046
  const enrichedCall: ToolCall = { ...call, callerAgentName: agentName };
13047
+ if (isPresentationTool(call.name)) {
13048
+ const result = this.editToolPresentation(agentName, enrichedCall);
13049
+ this.pushEvent({type:'tool-result',callId:call.id,toolName:call.name,agentName,moduleName:'tool-presentation',result});
13050
+ return;
13051
+ }
13052
+
11970
13053
 
11971
13054
  // Route MCPL tool calls to the appropriate server via prefix map
11972
13055
  const mcplMatch = this.resolveMcplTool(enrichedCall.name);
@@ -12025,10 +13108,16 @@ export class AgentFramework {
12025
13108
  ) {
12026
13109
  void this.tuneOutCoordinator
12027
13110
  .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>)
13111
+ // A rejection is answered with a failed result, as a framework tool's
13112
+ // dispatch failure is (dispatchUtilsToolCall), never left unhandled.
13113
+ .catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true }))
12028
13114
  .then((result) => {
12029
- this.queue.push({
13115
+ // Through pushEvent, like every other tool's result, so one that
13116
+ // arrives after stop() is dropped rather than rejected unhandled.
13117
+ this.pushEvent({
12030
13118
  type: 'tool-result',
12031
13119
  callId: enrichedCall.id,
13120
+ toolName: enrichedCall.name,
12032
13121
  agentName,
12033
13122
  moduleName: 'tune-out',
12034
13123
  result,
@@ -12099,6 +13188,7 @@ export class AgentFramework {
12099
13188
  this.pushEvent({
12100
13189
  type: 'tool-result',
12101
13190
  callId: enrichedCall.id,
13191
+ toolName: enrichedCall.name,
12102
13192
  agentName,
12103
13193
  moduleName: 'framework',
12104
13194
  result: { success: true, data: PROSE_ROUTING_HELP },
@@ -12134,7 +13224,7 @@ export class AgentFramework {
12134
13224
  const startTime = Date.now();
12135
13225
 
12136
13226
  this.moduleRegistry
12137
- .handleToolCall(call)
13227
+ .storableToolCall({ ...call, callerAgentName: agentName })
12138
13228
  .then((result) => {
12139
13229
  const durationMs = Date.now() - startTime;
12140
13230
  this.emitTrace({
@@ -12148,6 +13238,7 @@ export class AgentFramework {
12148
13238
  this.pushEvent({
12149
13239
  type: 'tool-result',
12150
13240
  callId: call.id,
13241
+ toolName: call.name,
12151
13242
  agentName,
12152
13243
  moduleName,
12153
13244
  result,
@@ -12169,6 +13260,7 @@ export class AgentFramework {
12169
13260
  this.pushEvent({
12170
13261
  type: 'tool-result',
12171
13262
  callId: call.id,
13263
+ toolName: call.name,
12172
13264
  agentName,
12173
13265
  moduleName,
12174
13266
  result: {
@@ -12821,7 +13913,9 @@ export class AgentFramework {
12821
13913
  inferenceRouting?: import('./mcpl/types.js').InferenceRoutingPolicy,
12822
13914
  ): Promise<void> {
12823
13915
  this.mcplServerRegistry = new McplServerRegistry();
12824
- this.featureSetManager = new FeatureSetManager();
13916
+ this.featureSetManager = new FeatureSetManager(
13917
+ (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
13918
+ );
12825
13919
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
12826
13920
  this.toolLifecycleEmitter = new ToolLifecycleEmitter({
12827
13921
  observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
@@ -13457,6 +14551,22 @@ export class AgentFramework {
13457
14551
  private manifestRefreshState: Map<string, { inFlight: boolean; queued: boolean; lastCompletedAt: number }> = new Map();
13458
14552
  private static readonly MANIFEST_FETCH_FLOOR_MS = 5_000;
13459
14553
 
14554
+ /**
14555
+ * The `mcpl:policy-refused` fields from a refusal receipt, as the host
14556
+ * acts on them: the receipt is server-supplied, so `fallback` is the
14557
+ * branch actually taken (anything but `close` is treated as `mcp-only`)
14558
+ * and a non-string `reason` is dropped.
14559
+ */
14560
+ private static policyRefusalFields(receipt: { fallback?: unknown; reason?: unknown }): {
14561
+ fallback: 'close' | 'mcp-only';
14562
+ reason: string | null;
14563
+ } {
14564
+ return {
14565
+ fallback: receipt.fallback === 'close' ? 'close' : 'mcp-only',
14566
+ reason: typeof receipt.reason === 'string' ? receipt.reason : null,
14567
+ };
14568
+ }
14569
+
13460
14570
  /**
13461
14571
  * §17.5 host processing: fetch the complete manifest, validate exactly as
13462
14572
  * at initialize, diff, apply §6.7 consequences (removals eagerly even
@@ -13572,6 +14682,12 @@ export class AgentFramework {
13572
14682
  if (receipt && receipt.accepted === false) {
13573
14683
  const fallback = receipt.fallback ?? 'mcp-only';
13574
14684
  console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
14685
+ this.emitTrace({
14686
+ type: 'mcpl:policy-refused',
14687
+ serverId: connection.id,
14688
+ phase: 'manifest-change',
14689
+ ...AgentFramework.policyRefusalFields(receipt),
14690
+ });
13575
14691
  if (fallback === 'close') {
13576
14692
  await connection.close();
13577
14693
  return;
@@ -13684,6 +14800,12 @@ export class AgentFramework {
13684
14800
  console.error(
13685
14801
  `[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`,
13686
14802
  );
14803
+ this.emitTrace({
14804
+ type: 'mcpl:policy-refused',
14805
+ serverId: config.id,
14806
+ phase: 'initial',
14807
+ ...AgentFramework.policyRefusalFields(receipt),
14808
+ });
13687
14809
  if (fallback === 'close') {
13688
14810
  await connection.close();
13689
14811
  return;
@@ -13949,9 +15071,20 @@ export class AgentFramework {
13949
15071
  params: PushEventParams,
13950
15072
  responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
13951
15073
  ) => {
13952
- const barrier = this.discordAwarenessBarrier;
13953
- if (barrier) await barrier.promise;
13954
- await this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
15074
+ const answer = answerOnce(responder, 'push/event', connection.id);
15075
+ try {
15076
+ const barrier = this.discordAwarenessBarrier;
15077
+ if (barrier) await barrier.promise;
15078
+ // Checked here, after the barrier and before anything records the
15079
+ // push as accepted: a stopped host refuses it.
15080
+ if (this.queue?.isClosed) {
15081
+ this.refuseServerItem('push/event', connection.id, answer, { kind: 'refused' });
15082
+ return;
15083
+ }
15084
+ await this.pushHandler?.handlePushEvent(connection.id, params, answer.responder as never);
15085
+ } catch (error) {
15086
+ this.refuseServerItem('push/event', connection.id, answer, { kind: 'failed', error });
15087
+ }
13955
15088
  });
13956
15089
 
13957
15090
  // Handle server-initiated inference requests (Step 6)
@@ -13961,26 +15094,50 @@ export class AgentFramework {
13961
15094
  ) => {
13962
15095
  // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
13963
15096
  if (this.pushCoalescer?.isRendering(connection.id)) {
13964
- responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
15097
+ try {
15098
+ responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
15099
+ } catch {
15100
+ // The connection is going; there is no one left to tell.
15101
+ }
13965
15102
  return;
13966
15103
  }
13967
15104
  if (this.inferenceRouter && responder) {
13968
- const barrier = this.discordAwarenessBarrier;
13969
- if (barrier) await barrier.promise;
13970
- await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
13971
- respond: responder.respond,
13972
- respondError: responder.respondError,
13973
- requestId: responder.id,
13974
- });
15105
+ const answer = answerOnce(responder, 'inference/request', connection.id);
15106
+ try {
15107
+ const barrier = this.discordAwarenessBarrier;
15108
+ if (barrier) await barrier.promise;
15109
+ // A stopping host starts no inference for a server: refused, like a
15110
+ // push, before the router runs.
15111
+ if (this.queue?.isClosed) {
15112
+ this.refuseServerItem('inference/request', connection.id, answer, { kind: 'refused' });
15113
+ return;
15114
+ }
15115
+ await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
15116
+ respond: answer.responder!.respond,
15117
+ respondError: answer.responder!.respondError,
15118
+ requestId: responder.id,
15119
+ });
15120
+ } catch (error) {
15121
+ this.refuseServerItem('inference/request', connection.id, answer, { kind: 'failed', error });
15122
+ }
13975
15123
  }
13976
15124
  });
13977
15125
 
13978
15126
  // Handle channel registration (Step 7)
13979
15127
  connection.on('channels-register', async (
13980
15128
  params: ChannelsRegisterParams,
13981
- responder?: { respond: (result: unknown) => void },
15129
+ responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void },
13982
15130
  ) => {
13983
- await this.channelRegistry?.handleRegister(connection.id, params, responder as never);
15131
+ const answer = answerOnce(responder, 'channels/register', connection.id);
15132
+ try {
15133
+ if (this.queue?.isClosed) {
15134
+ this.refuseServerItem('channels/register', connection.id, answer, { kind: 'refused' });
15135
+ return;
15136
+ }
15137
+ await this.channelRegistry?.handleRegister(connection.id, params, answer.responder as never);
15138
+ } catch (error) {
15139
+ this.refuseServerItem('channels/register', connection.id, answer, { kind: 'failed', error });
15140
+ }
13984
15141
  });
13985
15142
 
13986
15143
  // Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
@@ -13989,9 +15146,18 @@ export class AgentFramework {
13989
15146
  // filtering plus a diagnostic trace.
13990
15147
  connection.on('channels-changed', async (
13991
15148
  params: ChannelsChangedParams,
13992
- responder?: { respond: (result: unknown) => void },
15149
+ responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void },
13993
15150
  ) => {
13994
- await this.channelRegistry?.handleChanged(connection.id, params, responder as never);
15151
+ const answer = answerOnce(responder, 'channels/changed', connection.id);
15152
+ try {
15153
+ if (this.queue?.isClosed) {
15154
+ this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'refused' });
15155
+ return;
15156
+ }
15157
+ await this.channelRegistry?.handleChanged(connection.id, params, answer.responder as never);
15158
+ } catch (error) {
15159
+ this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'failed', error });
15160
+ }
13995
15161
  });
13996
15162
 
13997
15163
  // §6.6/§12: requests that previously had no handler and hung forever
@@ -14050,11 +15216,20 @@ export class AgentFramework {
14050
15216
  // Handle incoming channel messages (Step 7)
14051
15217
  connection.on('channels-incoming', async (
14052
15218
  params: ChannelsIncomingParams,
14053
- responder?: { respond: (result: unknown) => void },
15219
+ responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void },
14054
15220
  ) => {
14055
- const barrier = this.discordAwarenessBarrier;
14056
- if (barrier) await barrier.promise;
14057
- await this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
15221
+ const answer = answerOnce(responder, 'channels/incoming', connection.id);
15222
+ try {
15223
+ const barrier = this.discordAwarenessBarrier;
15224
+ if (barrier) await barrier.promise;
15225
+ if (this.queue?.isClosed) {
15226
+ this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'refused' });
15227
+ return;
15228
+ }
15229
+ await this.channelRegistry?.handleIncoming(connection.id, params, answer.responder as never);
15230
+ } catch (error) {
15231
+ this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'failed', error });
15232
+ }
14058
15233
  });
14059
15234
 
14060
15235
  // Handle host-level admin commands from a surface (e.g. Discord /undo)
@@ -14063,14 +15238,20 @@ export class AgentFramework {
14063
15238
  responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
14064
15239
  ) => {
14065
15240
  if (!responder) return;
15241
+ const answer = answerOnce(responder, 'host/command', connection.id);
14066
15242
  try {
14067
15243
  const barrier = this.discordAwarenessBarrier;
14068
15244
  if (barrier) await barrier.promise;
15245
+ // A stopping host runs no host command: refused like the requests
15246
+ // above, before its handler runs.
15247
+ if (this.queue?.isClosed) {
15248
+ this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
15249
+ return;
15250
+ }
14069
15251
  const result = await this.handleHostCommand(connection.id, params ?? {});
14070
- responder.respond(result);
15252
+ answer.responder!.respond(result);
14071
15253
  } catch (error) {
14072
- const err = error instanceof Error ? error : new Error(String(error));
14073
- responder.respondError(-32603, err.message);
15254
+ this.refuseServerItem('host/command', connection.id, answer, { kind: 'failed', error });
14074
15255
  }
14075
15256
  });
14076
15257
 
@@ -14243,12 +15424,14 @@ export class AgentFramework {
14243
15424
  const toolFeatureSets = new Map<string, string>();
14244
15425
  const toolClasses = new Map<string, ToolClass[]>();
14245
15426
  const toolServers = new Map<string, string>();
15427
+ const listedServers = new Set<string>();
14246
15428
 
14247
15429
  for (const server of this.mcplServerRegistry.getAllServers()) {
14248
15430
  const config = this.mcplServerConfigs.get(server.id);
14249
15431
  const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
14250
15432
  try {
14251
15433
  const result = await server.sendToolsList();
15434
+ listedServers.add(server.id);
14252
15435
  for (const tool of result.tools) {
14253
15436
  if (!isToolAllowed(tool.name, config)) continue;
14254
15437
  const namespacedName = `${prefix}--${tool.name}`;
@@ -14278,7 +15461,12 @@ export class AgentFramework {
14278
15461
  }
14279
15462
  }
14280
15463
  } catch {
14281
- // Server may not support tools/list — skip silently
15464
+ // Server may not support tools/list — skip silently. A live server
15465
+ // whose handshake advertised no MCP `tools` has, in effect, listed
15466
+ // nothing; count it as listed so tool-pattern checks do not wait on
15467
+ // it forever. (A server that advertised tools but failed to list
15468
+ // them stays unknown.)
15469
+ if (server.isConnected && !server.mcpToolsAdvertised) listedServers.add(server.id);
14282
15470
  }
14283
15471
  }
14284
15472
 
@@ -14286,6 +15474,91 @@ export class AgentFramework {
14286
15474
  this.mcplToolFeatureSets = toolFeatureSets;
14287
15475
  this.mcplToolClasses = toolClasses;
14288
15476
  this.mcplToolServers = toolServers;
15477
+ this.mcplListedServers = listedServers;
15478
+ if (this.toolPatternChecksArmed) this.checkToolPatterns();
15479
+ }
15480
+
15481
+ /**
15482
+ * Report operator tool-name patterns that match no model-facing tool:
15483
+ * `toolClassOverrides` keys and each server's `toolLifecycle.observe.tools`
15484
+ * / `toolLifecycle.inputs.tools`. These match `<toolPrefix>--<tool>`, and
15485
+ * the default prefix is `mcpl--<serverId>`, so a pattern written against
15486
+ * the bare server id is valid config that silently does nothing.
15487
+ *
15488
+ * Each pattern is reported at most once (console line + a
15489
+ * `mcpl:tool-pattern-unmatched` trace). A pattern that could still name
15490
+ * tools of a server whose listing is not in (not connected yet,
15491
+ * reconnecting, restarting) stays undecided until that listing arrives.
15492
+ * Diagnostic only: never throws into the refresh that called it.
15493
+ */
15494
+ private checkToolPatterns(): void {
15495
+ try {
15496
+ const entries: Array<{ setting: string; serverId?: string; pattern: string }> = [];
15497
+ for (const [pattern] of this.toolClassOverrides ?? []) {
15498
+ entries.push({ setting: 'toolClassOverrides', pattern });
15499
+ }
15500
+ const configs = [...(this.mcplServerConfigs?.values() ?? [])];
15501
+ for (const config of configs) {
15502
+ const lifecycle = config.toolLifecycle as Record<string, { tools?: unknown } | undefined> | undefined;
15503
+ for (const key of ['observe', 'inputs'] as const) {
15504
+ const tools = lifecycle?.[key]?.tools;
15505
+ if (!Array.isArray(tools)) continue;
15506
+ for (const pattern of tools) {
15507
+ if (typeof pattern === 'string') {
15508
+ entries.push({ setting: `toolLifecycle.${key}.tools`, serverId: config.id, pattern });
15509
+ }
15510
+ }
15511
+ }
15512
+ }
15513
+ const reported = (this.toolPatternsReported ??= new Set());
15514
+ const keyOf = (e: { setting: string; serverId?: string; pattern: string }) =>
15515
+ `${e.serverId ?? ''}\u0000${e.setting}\u0000${e.pattern}`;
15516
+ const pending = entries.filter((e) => !reported.has(keyOf(e)));
15517
+ if (pending.length === 0) return;
15518
+
15519
+ const known = (this.toolPatternKnownPrefixes ??= new Map());
15520
+ for (const c of configs) known.set(c.toolPrefix ?? `mcpl--${c.id}`, c.id);
15521
+ const servers: PatternServer[] = [...known].map(([prefix, id]) => {
15522
+ const current = this.mcplServerConfigs?.get(id);
15523
+ return {
15524
+ id,
15525
+ prefix,
15526
+ listed: !!current
15527
+ && (current.toolPrefix ?? `mcpl--${id}`) === prefix
15528
+ && (this.mcplListedServers?.has(id) ?? false),
15529
+ };
15530
+ });
15531
+ const names = this.modelFacingToolNames();
15532
+
15533
+ for (const entry of pending) {
15534
+ const verdict = checkToolPattern(entry.pattern, names, servers);
15535
+ if (verdict.kind !== 'unmatched') continue;
15536
+ reported.add(keyOf(entry));
15537
+ const where = entry.serverId ? `${entry.serverId}: ${entry.setting}` : entry.setting;
15538
+ console.error(describeUnmatchedPattern(where, entry.pattern, verdict));
15539
+ this.emitTrace({
15540
+ type: 'mcpl:tool-pattern-unmatched',
15541
+ setting: entry.setting,
15542
+ ...(entry.serverId ? { serverId: entry.serverId } : {}),
15543
+ pattern: entry.pattern,
15544
+ ...(verdict.suggestion ? { suggestion: verdict.suggestion } : {}),
15545
+ hint: verdict.hint,
15546
+ });
15547
+ }
15548
+ } catch (error) {
15549
+ console.error('[mcpl] tool pattern check failed:', error instanceof Error ? error.message : error);
15550
+ }
15551
+ }
15552
+
15553
+ /** Every tool name the framework offers to anyone: the shared board plus
15554
+ * each agent's own surface, before presentation visibility (a hidden tool
15555
+ * is still callable, so classes and lifecycle still apply to it). */
15556
+ private modelFacingToolNames(): Set<string> {
15557
+ const names = new Set(this.getAllTools().map((t) => t.name));
15558
+ for (const agent of this.agents.values()) {
15559
+ for (const tool of this.availableToolsForPresentation(agent.name)) names.add(tool.name);
15560
+ }
15561
+ return names;
14289
15562
  }
14290
15563
 
14291
15564
  /**
@@ -14356,6 +15629,7 @@ export class AgentFramework {
14356
15629
  this.pushEvent({
14357
15630
  type: 'tool-result',
14358
15631
  callId: call.id,
15632
+ toolName: call.name,
14359
15633
  agentName,
14360
15634
  moduleName: `mcpl:${serverId}`,
14361
15635
  result: { success: false, error: `MCPL server not found: ${serverId}`, isError: true },
@@ -14370,6 +15644,7 @@ export class AgentFramework {
14370
15644
  this.pushEvent({
14371
15645
  type: 'tool-result',
14372
15646
  callId: call.id,
15647
+ toolName: call.name,
14373
15648
  agentName,
14374
15649
  moduleName: `mcpl:${serverId}`,
14375
15650
  result: {
@@ -14529,6 +15804,7 @@ export class AgentFramework {
14529
15804
  this.pushEvent({
14530
15805
  type: 'tool-result',
14531
15806
  callId: call.id,
15807
+ toolName: call.name,
14532
15808
  agentName,
14533
15809
  moduleName: `mcpl:${serverId}`,
14534
15810
  result: {
@@ -14548,6 +15824,7 @@ export class AgentFramework {
14548
15824
  this.pushEvent({
14549
15825
  type: 'tool-result',
14550
15826
  callId: call.id,
15827
+ toolName: call.name,
14551
15828
  agentName,
14552
15829
  moduleName: `mcpl:${serverId}`,
14553
15830
  result: { success: false, error: err.message, isError: true },
@@ -14579,6 +15856,7 @@ export class AgentFramework {
14579
15856
  this.pushEvent({
14580
15857
  type: 'tool-result',
14581
15858
  callId: call.id,
15859
+ toolName: call.name,
14582
15860
  agentName,
14583
15861
  moduleName: 'channels',
14584
15862
  result: { success: false, error, isError: true },
@@ -14647,6 +15925,7 @@ export class AgentFramework {
14647
15925
  this.pushEvent({
14648
15926
  type: 'tool-result',
14649
15927
  callId: call.id,
15928
+ toolName: call.name,
14650
15929
  agentName,
14651
15930
  moduleName: 'channels',
14652
15931
  result,
@@ -14659,6 +15938,7 @@ export class AgentFramework {
14659
15938
  this.pushEvent({
14660
15939
  type: 'tool-result',
14661
15940
  callId: call.id,
15941
+ toolName: call.name,
14662
15942
  agentName,
14663
15943
  moduleName: 'channels',
14664
15944
  result: { success: false, error: err.message, isError: true },
@@ -14680,7 +15960,7 @@ export class AgentFramework {
14680
15960
  module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
14681
15961
  ...(result.isError ? { error: result.error } : {}),
14682
15962
  });
14683
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
15963
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
14684
15964
  };
14685
15965
 
14686
15966
  try {
@@ -14850,9 +16130,10 @@ export class AgentFramework {
14850
16130
  }
14851
16131
  : { success: false, error: r.error, isError: false };
14852
16132
  }
14853
- this.queue.push({
16133
+ this.pushEvent({
14854
16134
  type: 'tool-result',
14855
16135
  callId: call.id,
16136
+ toolName: call.name,
14856
16137
  agentName,
14857
16138
  moduleName: 'tune-out',
14858
16139
  result,
@@ -14866,7 +16147,7 @@ export class AgentFramework {
14866
16147
 
14867
16148
  const finish = (result: ToolResult) => {
14868
16149
  this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
14869
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
16150
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
14870
16151
  };
14871
16152
 
14872
16153
  if (call.name === 'wake') {
@@ -14900,7 +16181,7 @@ export class AgentFramework {
14900
16181
  } else if (agent?.proseRouting === 'explicit') {
14901
16182
  // Explicit mode: announce to the turn's sticky prose target if the
14902
16183
  // model has set one; otherwise stay quiet — never guess a channel.
14903
- const target = this.proseTargetPins.get(agentName);
16184
+ const target = this.turnRouting.get(agentName)?.pin;
14904
16185
  if (target) {
14905
16186
  this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
14906
16187
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
@@ -14969,7 +16250,7 @@ export class AgentFramework {
14969
16250
  ...(result.isError ? { error: result.error } : {}),
14970
16251
  });
14971
16252
  this.pushEvent({
14972
- type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
16253
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace',
14973
16254
  result: { ...result, isError: result.isError ?? false },
14974
16255
  });
14975
16256
  })();
@@ -14986,7 +16267,7 @@ export class AgentFramework {
14986
16267
  durationMs: 0,
14987
16268
  ...(result.isError ? { error: result.error } : {}),
14988
16269
  });
14989
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
16270
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
14990
16271
  };
14991
16272
  void (async (): Promise<void> => {
14992
16273
  try {
@@ -15011,7 +16292,11 @@ export class AgentFramework {
15011
16292
  typeof workspace.getTools === 'function' &&
15012
16293
  workspace.getTools().some((tool) => tool.name === 'read_image')
15013
16294
  ) {
15014
- const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
16295
+ // The workspace module's own handler, called directly: its result
16296
+ // gets the same check a module's result gets in the registry. The
16297
+ // agent's name is for the check's log line, not the handler.
16298
+ const moduleCall = { ...call, name: 'read_image' };
16299
+ const result = storableToolResult({ ...moduleCall, callerAgentName: agentName }, await workspace.handleToolCall(moduleCall));
15015
16300
  if (result.success && Array.isArray(result.data)) {
15016
16301
  const image = result.data.find(
15017
16302
  (block): block is { type: 'image'; data: string; mimeType: string } =>
@@ -15056,11 +16341,7 @@ export class AgentFramework {
15056
16341
  ],
15057
16342
  });
15058
16343
  } catch (error) {
15059
- finish({
15060
- success: false,
15061
- error: error instanceof Error ? error.message : String(error),
15062
- isError: true,
15063
- });
16344
+ finish(thrownResult(error));
15064
16345
  }
15065
16346
  })();
15066
16347
  }
@@ -15125,7 +16406,7 @@ export class AgentFramework {
15125
16406
  durationMs: 0,
15126
16407
  ...(result.isError ? { error: result.error } : {}),
15127
16408
  });
15128
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
16409
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
15129
16410
  };
15130
16411
  type Candidate =
15131
16412
  | { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number }
@@ -15421,21 +16702,35 @@ export class AgentFramework {
15421
16702
  for (const image of found) {
15422
16703
  const destination = pathFor(image.rangeOffset);
15423
16704
  const bytes = Buffer.from(image.data, 'base64');
15424
- const result = await workspace.writeBinary(destination, bytes, image.mediaType);
15425
- if (!result.success) {
15426
- finish({
15427
- success: false,
15428
- error:
15429
- `save_recent_image: failed writing "${destination}": ${result.error}` +
15430
- (savedFiles.length > 0
15431
- ? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
15432
- : ''),
15433
- isError: true,
15434
- });
16705
+ const failed = (why: unknown): void => finish({
16706
+ success: false,
16707
+ error:
16708
+ `save_recent_image: failed writing "${destination}": ${why instanceof Error ? why.message : String(why)}` +
16709
+ (savedFiles.length > 0
16710
+ ? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
16711
+ : ''),
16712
+ isError: true,
16713
+ });
16714
+ let result: Awaited<ReturnType<typeof workspace.writeBinary>>;
16715
+ try {
16716
+ result = await workspace.writeBinary(destination, bytes, image.mediaType);
16717
+ } catch (error) {
16718
+ // Thrown before its tree entry was set; the images before it
16719
+ // are saved, and the failure says so, as a returned one does.
16720
+ failed(error);
16721
+ return;
16722
+ }
16723
+ const inChronicle = (result.data as { inChronicle?: boolean } | undefined)?.inChronicle === true;
16724
+ if (!result.success && !inChronicle) {
16725
+ failed(result.error);
15435
16726
  return;
15436
16727
  }
15437
16728
  savedFiles.push({
15438
16729
  ...(result.data as Record<string, unknown>),
16730
+ path: destination,
16731
+ size: bytes.byteLength,
16732
+ mimeType: image.mediaType,
16733
+ ...(!result.success ? { diskCopyError: result.error ?? 'disk copy failed' } : {}),
15439
16734
  imageIndex: index + image.rangeOffset,
15440
16735
  ...image.receipt,
15441
16736
  });
@@ -15463,7 +16758,7 @@ export class AgentFramework {
15463
16758
  * (Module.getUtilities). Shared by the model-facing dispatcher and the
15464
16759
  * programmatic path (executeToolCall), so code_execution scripts and
15465
16760
  * ephemerals reach utilities too. */
15466
- private async handleUtilsToolCall(agentName: string, call: ToolCall): Promise<ToolResult> {
16761
+ private async handleUtilsToolCall(agentName: string, call: ToolCall, options: { inProcess?: boolean } = {}): Promise<ToolResult> {
15467
16762
  const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown };
15468
16763
  const utilities = this.moduleRegistry.getAllUtilities();
15469
16764
  const available = (): string =>
@@ -15498,13 +16793,12 @@ export class AgentFramework {
15498
16793
  };
15499
16794
  }
15500
16795
  // Same dispatch as a first-class tool call — the module cannot tell
15501
- // which surface the call came through.
15502
- return this.moduleRegistry.handleToolCall({
15503
- id: call.id,
15504
- name: def.name,
15505
- input: args,
15506
- callerAgentName: agentName,
15507
- });
16796
+ // which surface the call came through. An in-process caller gets the
16797
+ // module's own result; the model's path takes its checked form.
16798
+ const utilityCall = { id: call.id, name: def.name, input: args, callerAgentName: agentName };
16799
+ return options.inProcess
16800
+ ? this.moduleRegistry.handleToolCall(utilityCall)
16801
+ : this.moduleRegistry.storableToolCall(utilityCall);
15508
16802
  }
15509
16803
  default:
15510
16804
  return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
@@ -15529,6 +16823,7 @@ export class AgentFramework {
15529
16823
  this.pushEvent({
15530
16824
  type: 'tool-result',
15531
16825
  callId: call.id,
16826
+ toolName: call.name,
15532
16827
  agentName,
15533
16828
  moduleName: 'framework',
15534
16829
  result,
@@ -15702,6 +16997,7 @@ export class AgentFramework {
15702
16997
  this.pushEvent({
15703
16998
  type: 'tool-result',
15704
16999
  callId: call.id,
17000
+ toolName: call.name,
15705
17001
  agentName,
15706
17002
  moduleName: 'agent',
15707
17003
  result,
@@ -15742,7 +17038,7 @@ export class AgentFramework {
15742
17038
  this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
15743
17039
  result = { success: false, error: err.message, isError: true };
15744
17040
  }
15745
- this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
17041
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
15746
17042
  }
15747
17043
 
15748
17044
  private dispatchGateToolCall(agentName: string, call: ToolCall): void {
@@ -15756,6 +17052,7 @@ export class AgentFramework {
15756
17052
  this.pushEvent({
15757
17053
  type: 'tool-result',
15758
17054
  callId: call.id,
17055
+ toolName: call.name,
15759
17056
  agentName,
15760
17057
  moduleName: 'gate',
15761
17058
  result,
@@ -15768,6 +17065,7 @@ export class AgentFramework {
15768
17065
  this.pushEvent({
15769
17066
  type: 'tool-result',
15770
17067
  callId: call.id,
17068
+ toolName: call.name,
15771
17069
  agentName,
15772
17070
  moduleName: 'gate',
15773
17071
  result: { success: false, error: err.message, isError: true },