@animalabs/agent-framework 0.19.0 → 0.21.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 (214) hide show
  1. package/README.md +29 -0
  2. package/dist/src/agent.d.ts +22 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +128 -10
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/types.d.ts +1 -1
  7. package/dist/src/api/types.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +10 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +27 -2
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts +21 -0
  13. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  14. package/dist/src/code-execution/tool-definition.js +34 -0
  15. package/dist/src/code-execution/tool-definition.js.map +1 -1
  16. package/dist/src/framework.d.ts +153 -2
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1175 -76
  19. package/dist/src/framework.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +4 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/capability-grant.d.ts +1 -1
  25. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  26. package/dist/src/mcpl/capability-grant.js +49 -5
  27. package/dist/src/mcpl/capability-grant.js.map +1 -1
  28. package/dist/src/mcpl/channel-registry.d.ts +59 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +106 -6
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/conversation-router.d.ts +8 -6
  33. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  34. package/dist/src/mcpl/conversation-router.js +12 -6
  35. package/dist/src/mcpl/conversation-router.js.map +1 -1
  36. package/dist/src/mcpl/index.d.ts +3 -0
  37. package/dist/src/mcpl/index.d.ts.map +1 -1
  38. package/dist/src/mcpl/index.js +3 -0
  39. package/dist/src/mcpl/index.js.map +1 -1
  40. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  41. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  42. package/dist/src/mcpl/push-coalescer.js +709 -0
  43. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  44. package/dist/src/mcpl/push-handler.d.ts +7 -2
  45. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  46. package/dist/src/mcpl/push-handler.js +43 -4
  47. package/dist/src/mcpl/push-handler.js.map +1 -1
  48. package/dist/src/mcpl/references.d.ts.map +1 -1
  49. package/dist/src/mcpl/references.js +9 -4
  50. package/dist/src/mcpl/references.js.map +1 -1
  51. package/dist/src/mcpl/server-connection.d.ts +18 -0
  52. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/server-connection.js +44 -1
  54. package/dist/src/mcpl/server-connection.js.map +1 -1
  55. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  56. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  57. package/dist/src/mcpl/tool-classes.js +170 -0
  58. package/dist/src/mcpl/tool-classes.js.map +1 -0
  59. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  60. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  61. package/dist/src/mcpl/tool-glob.js +41 -0
  62. package/dist/src/mcpl/tool-glob.js.map +1 -0
  63. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  64. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  65. package/dist/src/mcpl/tool-lifecycle.js +607 -0
  66. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  67. package/dist/src/mcpl/transport.d.ts +15 -0
  68. package/dist/src/mcpl/transport.d.ts.map +1 -1
  69. package/dist/src/mcpl/transport.js +65 -1
  70. package/dist/src/mcpl/transport.js.map +1 -1
  71. package/dist/src/mcpl/types.d.ts +68 -1
  72. package/dist/src/mcpl/types.d.ts.map +1 -1
  73. package/dist/src/mcpl/types.js +6 -0
  74. package/dist/src/mcpl/types.js.map +1 -1
  75. package/dist/src/modules/discord/index.js +1 -1
  76. package/dist/src/modules/discord/index.js.map +1 -1
  77. package/dist/src/modules/history/index.d.ts +85 -0
  78. package/dist/src/modules/history/index.d.ts.map +1 -1
  79. package/dist/src/modules/history/index.js +1019 -24
  80. package/dist/src/modules/history/index.js.map +1 -1
  81. package/dist/src/modules/history/semantic.d.ts +202 -0
  82. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  83. package/dist/src/modules/history/semantic.js +418 -0
  84. package/dist/src/modules/history/semantic.js.map +1 -0
  85. package/dist/src/modules/index.d.ts +2 -1
  86. package/dist/src/modules/index.d.ts.map +1 -1
  87. package/dist/src/modules/index.js.map +1 -1
  88. package/dist/src/tool-result-guard.d.ts +82 -0
  89. package/dist/src/tool-result-guard.d.ts.map +1 -0
  90. package/dist/src/tool-result-guard.js +306 -0
  91. package/dist/src/tool-result-guard.js.map +1 -0
  92. package/dist/src/types/agent.d.ts +14 -0
  93. package/dist/src/types/agent.d.ts.map +1 -1
  94. package/dist/src/types/framework.d.ts +21 -0
  95. package/dist/src/types/framework.d.ts.map +1 -1
  96. package/dist/src/types/trace.d.ts +17 -0
  97. package/dist/src/types/trace.d.ts.map +1 -1
  98. package/dist/test/channel-registry-routing.test.js +59 -0
  99. package/dist/test/channel-registry-routing.test.js.map +1 -1
  100. package/dist/test/code-execution.test.js +139 -0
  101. package/dist/test/code-execution.test.js.map +1 -1
  102. package/dist/test/conversation-router.test.js +33 -19
  103. package/dist/test/conversation-router.test.js.map +1 -1
  104. package/dist/test/conversation-routing.test.js +89 -0
  105. package/dist/test/conversation-routing.test.js.map +1 -1
  106. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  107. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  108. package/dist/test/helpers/coalescing-fixture.js +145 -0
  109. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  110. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  111. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  112. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  113. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  114. package/dist/test/history-module-realstore.test.d.ts +2 -0
  115. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  116. package/dist/test/history-module-realstore.test.js +363 -0
  117. package/dist/test/history-module-realstore.test.js.map +1 -0
  118. package/dist/test/history-module-semantic.test.d.ts +2 -0
  119. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  120. package/dist/test/history-module-semantic.test.js +213 -0
  121. package/dist/test/history-module-semantic.test.js.map +1 -0
  122. package/dist/test/history-module-ux.test.d.ts +2 -0
  123. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  124. package/dist/test/history-module-ux.test.js +304 -0
  125. package/dist/test/history-module-ux.test.js.map +1 -0
  126. package/dist/test/history-module.test.js +25 -1
  127. package/dist/test/history-module.test.js.map +1 -1
  128. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  129. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  130. package/dist/test/history-semantic-review-findings.test.js +338 -0
  131. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  132. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  133. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  134. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  135. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  136. package/dist/test/mcpl-capability-grant.test.js +3 -1
  137. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  138. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  139. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  140. package/dist/test/mcpl-child-env.test.js +158 -0
  141. package/dist/test/mcpl-child-env.test.js.map +1 -0
  142. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  143. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  144. package/dist/test/mcpl-coalescing.test.js +1050 -0
  145. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  146. package/dist/test/mcpl-list-status.test.js +4 -0
  147. package/dist/test/mcpl-list-status.test.js.map +1 -1
  148. package/dist/test/mcpl-reference-fields.test.d.ts +2 -0
  149. package/dist/test/mcpl-reference-fields.test.d.ts.map +1 -0
  150. package/dist/test/mcpl-reference-fields.test.js +82 -0
  151. package/dist/test/mcpl-reference-fields.test.js.map +1 -0
  152. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  153. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  154. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  155. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  156. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  157. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  158. package/dist/test/mcpl-tool-classes.test.js +80 -0
  159. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  160. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  161. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  162. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  163. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  164. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  165. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  166. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  167. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  168. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  169. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  170. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  171. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  172. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  173. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  174. package/dist/test/silent-heartbeat.test.js +154 -0
  175. package/dist/test/silent-heartbeat.test.js.map +1 -0
  176. package/dist/test/tool-result-guard.test.d.ts +2 -0
  177. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  178. package/dist/test/tool-result-guard.test.js +1081 -0
  179. package/dist/test/tool-result-guard.test.js.map +1 -0
  180. package/dist/test/tools-observe-parser-conformance.test.d.ts +2 -0
  181. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +1 -0
  182. package/dist/test/tools-observe-parser-conformance.test.js +129 -0
  183. package/dist/test/tools-observe-parser-conformance.test.js.map +1 -0
  184. package/dist/test/trunk-channel-routing.test.js +67 -0
  185. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  186. package/dist/tsconfig.tsbuildinfo +1 -1
  187. package/package.json +2 -2
  188. package/src/agent.ts +128 -10
  189. package/src/api/types.ts +2 -0
  190. package/src/code-execution/py-runner.ts +34 -2
  191. package/src/code-execution/tool-definition.ts +46 -0
  192. package/src/framework.ts +1207 -80
  193. package/src/index.ts +13 -0
  194. package/src/mcpl/capability-grant.ts +54 -6
  195. package/src/mcpl/channel-registry.ts +115 -7
  196. package/src/mcpl/conversation-router.ts +12 -8
  197. package/src/mcpl/index.ts +29 -0
  198. package/src/mcpl/push-coalescer.ts +821 -0
  199. package/src/mcpl/push-handler.ts +40 -4
  200. package/src/mcpl/references.ts +11 -3
  201. package/src/mcpl/server-connection.ts +48 -2
  202. package/src/mcpl/tool-classes.ts +188 -0
  203. package/src/mcpl/tool-glob.ts +36 -0
  204. package/src/mcpl/tool-lifecycle.ts +745 -0
  205. package/src/mcpl/transport.ts +67 -1
  206. package/src/mcpl/types.ts +71 -1
  207. package/src/modules/discord/index.ts +1 -1
  208. package/src/modules/history/index.ts +1089 -24
  209. package/src/modules/history/semantic.ts +473 -0
  210. package/src/modules/index.ts +2 -1
  211. package/src/tool-result-guard.ts +318 -0
  212. package/src/types/agent.ts +15 -0
  213. package/src/types/framework.ts +23 -0
  214. package/src/types/trace.ts +17 -0
@@ -16,7 +16,10 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
16
16
  import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
17
17
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
18
18
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
19
- import { PushHandler } from './mcpl/push-handler.js';
19
+ import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
20
+ import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass } from './mcpl/tool-classes.js';
21
+ import { PushHandler, convertBlock as convertPushBlock } from './mcpl/push-handler.js';
22
+ import { PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent, coalescingSubjectKey, } from './mcpl/push-coalescer.js';
20
23
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
21
24
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
22
25
  import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
@@ -26,9 +29,9 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
26
29
  import { safeSlice } from './safe-slice.js';
27
30
  import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
28
31
  import { TOOL_IMAGE_REF_RE, ToolImageLedger, formatPreservedImageSlots, parseImagePlaceholders, sha256Hex, splitPreservingImageSlots, } from './tool-image-ledger.js';
29
- import { randomUUID } from 'node:crypto';
30
- import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
31
- import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
32
+ import { randomUUID, createHash } from 'node:crypto';
33
+ import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
34
+ import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, scriptTimeLimits, validateCodeExecutionConfig, } from './code-execution/tool-definition.js';
32
35
  import { splitProseSegments } from './prose-segments.js';
33
36
  import { cumulativeDelta } from './usage-accounting.js';
34
37
  import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
@@ -289,6 +292,10 @@ import { OperatorActionError, OperatorLog, capIds, defaultOperatorLogPath, } fro
289
292
  const FRAMEWORK_STATE_ID = 'framework/state';
290
293
  /** Snapshot slot mirroring context writes deferred while quiesced (#122). */
291
294
  const DEFERRED_WRITES_ID = 'framework/deferred-writes';
295
+ /** RFC-006 coalescing bookkeeping: receipts (retry window) + subject history. */
296
+ const COALESCING_STATE_ID = 'mcpl/coalescing';
297
+ /** Receipts written since the last snapshot flush (bridges the throttle window). */
298
+ const COALESCING_RECENT_ID = 'mcpl/coalescing-recent';
292
299
  /** Above this serialized size the deferred-write mirror is skipped (kept in
293
300
  * memory only) rather than rewriting a multi-MB snapshot per deferral. */
294
301
  const DEFERRED_WRITES_PERSIST_CAP_BYTES = 4 * 1024 * 1024;
@@ -300,7 +307,8 @@ function withDeferredWriteId(metadata, id) {
300
307
  return { ...(metadata ?? {}), deferredWriteId: id };
301
308
  }
302
309
  function isTurnContinuation(reason) {
303
- return reason === 'context_budget_restart' || reason === 'tool_results_ready';
310
+ return reason === 'context_budget_restart' || reason === 'tool_results_ready'
311
+ || reason === 'tool_result_guard_retry';
304
312
  }
305
313
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
306
314
  const INFERENCE_LOG_ID = 'framework/inference-log';
@@ -782,7 +790,33 @@ export class AgentFramework {
782
790
  mcplServerRegistry = null;
783
791
  featureSetManager = null;
784
792
  hookOrchestrator = null;
793
+ /** MCPL RFC-007: `tools/lifecycle` to observing servers. Created with the
794
+ * MCPL subsystem; inert (no tracking) while no connection holds
795
+ * `toolLifecycle.observe`. */
796
+ toolLifecycleEmitter = null;
797
+ /** MCPL RFC-008 class sources, normalized: operator overrides, then host
798
+ * knowledge (embedding host's table ahead of the built-in one). */
799
+ toolClassOverrides = [];
800
+ hostToolClasses = normalizeClassTable(BUILTIN_TOOL_CLASSES, 'BUILTIN_TOOL_CLASSES');
785
801
  pushHandler = null;
802
+ /** RFC-006 coalescing (see mcpl/push-coalescer.ts for the design). */
803
+ pushCoalescer = null;
804
+ /** Both lanes are admitted in arrival order: a create, edit and delete of
805
+ * one subject must not overtake one another while routing awaits. */
806
+ coalescingAdmission = Promise.resolve();
807
+ coalescingSaveTimer = null;
808
+ coalescingSnapshotDirty = null;
809
+ coalescingRecentReceipts = [];
810
+ /**
811
+ * Agents that read the residents' shared un-namespaced message slot: every
812
+ * ordinary resident (their context managers are not isolated) and the
813
+ * subconscious (isolated, but merging that slot read-only). A message in
814
+ * that slot is unread only if NONE of them has compiled past it. Forks and
815
+ * ephemeral agents have isolated slots and are their own only readers.
816
+ */
817
+ sharedSlotAgents = new Set();
818
+ /** Group commit: one fsync per event-loop turn, shared by every waiter. */
819
+ coalescingCommit = null;
786
820
  inferenceRouter = null;
787
821
  channelRegistry = null;
788
822
  checkpointManager = null;
@@ -833,10 +867,15 @@ export class AgentFramework {
833
867
  mcplTools = [];
834
868
  /** Namespaced tool name → stateful feature-set attribution from tools/list. */
835
869
  mcplToolFeatureSets = new Map();
870
+ /** Namespaced tool name → the server's RFC-008 `_meta["mcpl/class"]`. */
871
+ mcplToolClasses = new Map();
836
872
  mcplToolRefreshInFlight = false;
837
873
  mcplToolRefreshPending = false;
838
874
  /** Maps tool prefix → serverId for dispatch routing. */
839
875
  mcplPrefixMap = new Map();
876
+ /** Namespaced tool name → the server whose tools/list produced it. Prefixes
877
+ * can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
878
+ mcplToolServers = new Map();
840
879
  /** Maps serverId → McplServerConfig for prefix lookup. */
841
880
  mcplServerConfigs = new Map();
842
881
  /** Host capabilities advertised during the MCP handshake — stored so servers
@@ -869,6 +908,8 @@ export class AgentFramework {
869
908
  eventGate = null;
870
909
  // Session-level token usage tracking (always-on)
871
910
  usageTracker;
911
+ /** Explicit-send suppression carried into a tool-result-guard retry. */
912
+ guardRetryTurnSilenced = new Map();
872
913
  /** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
873
914
  timeZone;
874
915
  constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone, discordAwarenessOutbox, discordAwarenessEmoji, discordAwarenessDeadlineMs, operatorLog = new OperatorLog(undefined)) {
@@ -912,6 +953,9 @@ export class AgentFramework {
912
953
  * Create and start the framework.
913
954
  */
914
955
  static async create(config) {
956
+ // Before anything opens or starts: a refused config must leave nothing behind.
957
+ if (config.codeExecution?.enabled)
958
+ validateCodeExecutionConfig(config.codeExecution);
915
959
  // Create or use existing store
916
960
  let store;
917
961
  let ownsStore;
@@ -939,6 +983,14 @@ export class AgentFramework {
939
983
  catch {
940
984
  // Already registered
941
985
  }
986
+ for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
987
+ try {
988
+ store.registerState({ id, strategy: 'snapshot' });
989
+ }
990
+ catch {
991
+ // Already registered
992
+ }
993
+ }
942
994
  try {
943
995
  store.registerState({
944
996
  id: INFERENCE_LOG_ID,
@@ -1099,6 +1151,11 @@ export class AgentFramework {
1099
1151
  // Client-side programmatic tool calling (code_execution). Config is only
1100
1152
  // retained when enabled — everything downstream gates on the field.
1101
1153
  framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
1154
+ framework.toolClassOverrides = normalizeClassTable(config.toolClassOverrides, 'toolClassOverrides');
1155
+ framework.hostToolClasses = [
1156
+ ...normalizeClassTable(config.hostToolClasses, 'hostToolClasses'),
1157
+ ...normalizeClassTable(BUILTIN_TOOL_CLASSES, 'BUILTIN_TOOL_CLASSES'),
1158
+ ];
1102
1159
  if (config.toolResultInlineMaxChars !== undefined) {
1103
1160
  const cap = config.toolResultInlineMaxChars;
1104
1161
  if (!Number.isFinite(cap) || cap < 1000) {
@@ -1264,6 +1321,8 @@ export class AgentFramework {
1264
1321
  * Stop the event loop.
1265
1322
  */
1266
1323
  async stop() {
1324
+ this.pushCoalescer?.suspend();
1325
+ this.flushCoalescingSnapshot();
1267
1326
  this.running = false;
1268
1327
  // Flushed-but-unsynced deferred writes: sync and ack now, while the
1269
1328
  // store is still open, rather than leaving them to a reboot replay.
@@ -1331,6 +1390,16 @@ export class AgentFramework {
1331
1390
  shutdownPromises.push(this.mcplServerRegistry.closeAll());
1332
1391
  }
1333
1392
  await Promise.all(shutdownPromises);
1393
+ // Streams may queue storage repairs while being cancelled above. Retry
1394
+ // after their teardown, while the store is still open, before final sync.
1395
+ for (const agent of this.agents.values()) {
1396
+ try {
1397
+ agent.toolResultGuard.flushUnrecorded();
1398
+ }
1399
+ catch (error) {
1400
+ console.error(`[tool-result-guard] agent=${agent.name} could not finish queued storage work at stop:`, error);
1401
+ }
1402
+ }
1334
1403
  // Final sync before closing
1335
1404
  try {
1336
1405
  this.store.sync();
@@ -1592,6 +1661,12 @@ export class AgentFramework {
1592
1661
  }
1593
1662
  async runQueuedMaintenance() {
1594
1663
  const queued = [...this.agents.values()].flatMap((agent) => {
1664
+ try {
1665
+ agent.toolResultGuard.flushUnrecorded();
1666
+ }
1667
+ catch (error) {
1668
+ console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
1669
+ }
1595
1670
  const cm = agent.getContextManager();
1596
1671
  const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
1597
1672
  cm.setToolDefinitions(tools);
@@ -1884,6 +1959,11 @@ export class AgentFramework {
1884
1959
  ext.keys.forEach((k) => taken.add(k));
1885
1960
  result.set('_framework', ext);
1886
1961
  }
1962
+ {
1963
+ const ext = this.toolResultGuardSettingsExtension();
1964
+ ext.keys.forEach((key) => taken.add(key));
1965
+ result.set('_toolResultGuard', ext);
1966
+ }
1887
1967
  for (const module of this.moduleRegistry.getAllModules()) {
1888
1968
  const ext = module.getAgentSettingsExtension?.();
1889
1969
  if (!ext)
@@ -1948,6 +2028,56 @@ export class AgentFramework {
1948
2028
  sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
1949
2029
  };
1950
2030
  }
2031
+ toolResultGuardSettingsExtension() {
2032
+ const get = (name) => {
2033
+ const guard = this.agents.get(name)?.toolResultGuard;
2034
+ return {
2035
+ tool_result_guard: guard?.enabled ?? false,
2036
+ tool_result_guard_source: guard?.settingOverride !== undefined ? 'runtime_override' : 'recipe_default',
2037
+ };
2038
+ };
2039
+ const set = (name, enabled) => {
2040
+ const agent = this.agents.get(name);
2041
+ if (!agent)
2042
+ throw new Error(`Unknown agent: ${name}`);
2043
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
2044
+ const state = (data && typeof data === 'object' ? data : {});
2045
+ const values = { ...(state.toolResultGuards ?? {}) };
2046
+ if (enabled === undefined)
2047
+ delete values[name];
2048
+ else
2049
+ values[name] = enabled;
2050
+ state.toolResultGuards = values;
2051
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
2052
+ agent.toolResultGuard.setOverride(enabled);
2053
+ return get(name);
2054
+ };
2055
+ return {
2056
+ properties: {
2057
+ tool_result_guard: {
2058
+ type: 'boolean',
2059
+ description: 'Enable the tool result guard. It can withhold a batch of tool output and retry inference once. ' +
2060
+ 'Tools are not re-executed. Persists across turns and restarts until explicitly changed. ' +
2061
+ 'Disabling affects future results only; previously withheld results stay withheld.',
2062
+ },
2063
+ },
2064
+ keys: ['tool_result_guard'],
2065
+ get,
2066
+ update: (name, patch) => {
2067
+ if (typeof patch.tool_result_guard !== 'boolean')
2068
+ throw new Error('tool_result_guard must be a boolean');
2069
+ return set(name, patch.tool_result_guard);
2070
+ },
2071
+ reset: (name) => set(name, undefined),
2072
+ };
2073
+ }
2074
+ restoreToolResultGuardSetting(agent) {
2075
+ const enabled = this.store.getStateJson(FRAMEWORK_STATE_ID)?.toolResultGuards?.[agent.name];
2076
+ if (enabled !== undefined && typeof enabled !== 'boolean') {
2077
+ throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
2078
+ }
2079
+ agent.toolResultGuard.setOverride(enabled);
2080
+ }
1951
2081
  buildThinkTool(policy) {
1952
2082
  return {
1953
2083
  name: 'think',
@@ -2024,6 +2154,19 @@ export class AgentFramework {
2024
2154
  return tool;
2025
2155
  });
2026
2156
  }
2157
+ /**
2158
+ * The tools one agent is shown at inference: its surface (the subconscious
2159
+ * has its own), less what its permissions deny, plus — for explicit-mode
2160
+ * agents — the on-demand routing reference (teach-by-bounce: the grammar
2161
+ * is never injected, only served when asked). Inference and the RFC-008
2162
+ * listing both use this, so the listing cannot drift from the model's view.
2163
+ */
2164
+ agentToolSurface(agent, snapshot) {
2165
+ const tools = this.getToolsForAgent(agent.name, snapshot).filter((t) => agent.canUseTool(t.name));
2166
+ if (agent.proseRouting === 'explicit')
2167
+ tools.push(PROSE_HELP_TOOL);
2168
+ return tools;
2169
+ }
2027
2170
  getAgentRuntimeSettings(agentName) {
2028
2171
  const agent = this.agents.get(agentName);
2029
2172
  if (!agent)
@@ -3198,6 +3341,8 @@ export class AgentFramework {
3198
3341
  debugLogContext: !!process.env.DEBUG_CONTEXT,
3199
3342
  });
3200
3343
  const agent = new Agent(config, contextManager, this.membrane);
3344
+ this.restoreToolResultGuardSetting(agent);
3345
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
3201
3346
  this.ephemeralCandidates.set(agent, contextManager);
3202
3347
  const cleanup = () => {
3203
3348
  // Don't close the store — it's shared. Just release the CM.
@@ -5216,6 +5361,9 @@ export class AgentFramework {
5216
5361
  viewFilter: (message) => !message.metadata?.tuneOut,
5217
5362
  });
5218
5363
  const agent = new Agent(config, contextManager, this.membrane);
5364
+ this.restoreToolResultGuardSetting(agent);
5365
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
5366
+ this.sharedSlotAgents.add(agent);
5219
5367
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
5220
5368
  if (restoredSettings) {
5221
5369
  agent.restoreRuntimeSettings(this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
@@ -5283,6 +5431,9 @@ export class AgentFramework {
5283
5431
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
5284
5432
  };
5285
5433
  const agent = new Agent(agentConfig, contextManager, this.membrane);
5434
+ this.restoreToolResultGuardSetting(agent);
5435
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
5436
+ this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
5286
5437
  this.agents.set(name, agent);
5287
5438
  this.agentConfigs.set(name, agentConfig);
5288
5439
  this.subconsciousAgentName = name;
@@ -5348,6 +5499,9 @@ export class AgentFramework {
5348
5499
  scriptWaiter(event.result);
5349
5500
  return;
5350
5501
  }
5502
+ // RFC-007: the call's terminal (completed / failed) — the result
5503
+ // itself is never sent, only whether it was an error.
5504
+ this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
5351
5505
  const agent = this.agents.get(event.agentName);
5352
5506
  if (agent) {
5353
5507
  this.touchEphemeralRun(event.agentName, true);
@@ -5390,7 +5544,8 @@ export class AgentFramework {
5390
5544
  // byte-matched (the window stores what the membrane sends;
5391
5545
  // divergence breaks the compile prefix).
5392
5546
  const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
5393
- agent.getContextManager().addMessage('user', toolResultContent);
5547
+ const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
5548
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
5394
5549
  // Flush any messages that were deferred while this turn was in
5395
5550
  // flight. Route to the PRIMARY agent — deferred messages are
5396
5551
  // framework-level (module messages, push events, subagent return
@@ -5562,6 +5717,9 @@ export class AgentFramework {
5562
5717
  }
5563
5718
  if (shouldEndTurn) {
5564
5719
  // endTurn: messages already stored above, cancel stream, reset to idle.
5720
+ // The batch is never submitted, so nothing can refuse it: admit
5721
+ // it now instead of leaving it pending across the idle gap.
5722
+ agent.toolResultGuard.settleTurnEnded();
5565
5723
  if (currentState.stream) {
5566
5724
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
5567
5725
  currentState.stream.cancel();
@@ -5612,6 +5770,8 @@ export class AgentFramework {
5612
5770
  reason: 'context_budget_restart',
5613
5771
  source: 'framework',
5614
5772
  timestamp: Date.now(),
5773
+ suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
5774
+ ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
5615
5775
  });
5616
5776
  }
5617
5777
  else if (currentState.stream) {
@@ -5619,8 +5779,7 @@ export class AgentFramework {
5619
5779
  // Mid-turn messages collected above ride along as injected user
5620
5780
  // messages (membrane ≥0.5.72) — appended after the tool_result
5621
5781
  // envelope so the next round of THIS turn hears them.
5622
- const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
5623
- currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
5782
+ currentState.stream.provideToolResults(agent.toolResultGuard.submissionResults(membraneResults), midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
5624
5783
  agent.setStreaming(currentState.stream);
5625
5784
  this.emitTrace({
5626
5785
  type: 'inference:stream_resumed',
@@ -5649,6 +5808,16 @@ export class AgentFramework {
5649
5808
  else if (event.type === 'mcpl:push-event') {
5650
5809
  this.handleMcplPushEvent(event);
5651
5810
  }
5811
+ await this.dispatchProcessEventToModules(event, startTime);
5812
+ }
5813
+ /**
5814
+ * The module half of handleProcessEvent: every module's onProcess, their
5815
+ * responses, the tool-call dispatch, the completion trace and the process
5816
+ * log. Shared with RFC-006 coalesced deliveries, which bypass the queue
5817
+ * (the coalescer needs the placement synchronously) but must still reach
5818
+ * modules exactly like the ordinary deliveries do.
5819
+ */
5820
+ async dispatchProcessEventToModules(event, startTime = Date.now()) {
5652
5821
  // Dispatch to all modules, tracking responses with module names
5653
5822
  const responses = [];
5654
5823
  for (const module of this.moduleRegistry.getAllModules()) {
@@ -5743,6 +5912,8 @@ export class AgentFramework {
5743
5912
  async handleMcplChannelIncoming(event) {
5744
5913
  const metadata = {
5745
5914
  ...event.metadata,
5915
+ ...(event.eventId ? { eventId: event.eventId } : {}),
5916
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
5746
5917
  channelId: event.channelId,
5747
5918
  messageId: event.messageId,
5748
5919
  author: event.author,
@@ -5756,9 +5927,39 @@ export class AgentFramework {
5756
5927
  // Per-channel conversation routing: messages go to the channel's fork
5757
5928
  // agent (spawned from the template on first qualifying message), never
5758
5929
  // to the primary conversation.
5759
- if (this.conversationRouter) {
5760
- await this.routeConversationIncoming(event, metadata);
5761
- return;
5930
+ // RFC-006 §3.2: a replacement or deletion notice follows the delivery it
5931
+ // corrects — into the agent that holds (or read) the prior occurrence,
5932
+ // never a context fresh routing would pick. Gone agent → delivered nowhere.
5933
+ if (event.deliverTo) {
5934
+ const target = this.agents.get(event.deliverTo);
5935
+ if (!target)
5936
+ return undefined;
5937
+ if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
5938
+ metadata.triggered = event.triggerInference ?? false;
5939
+ const id = target.getContextManager().addMessage('user', event.content, metadata);
5940
+ // A correction may target an older engagement. Refresh only the
5941
+ // binding whose agent actually received the message.
5942
+ if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
5943
+ this.conversationRouter.touch(event.channelId);
5944
+ }
5945
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
5946
+ if (event.triggerInference) {
5947
+ this.pendingRequests.push({
5948
+ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
5949
+ channelId: event.channelId,
5950
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
5951
+ addressed: isAddressedMessage(event.tags, event.metadata),
5952
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
5953
+ });
5954
+ }
5955
+ return { agent: target.name, messageId: id };
5956
+ }
5957
+ if (this.conversationRouter)
5958
+ return undefined; // the trunk never holds channel traffic
5959
+ event = { ...event, targetAgents: [target.name] };
5960
+ }
5961
+ else if (this.conversationRouter) {
5962
+ return this.routeConversationIncoming(event, metadata);
5762
5963
  }
5763
5964
  // Addressed-while-closed invitation — parity with the push-event path
5764
5965
  // (channels.publish surfaces like portal-mcpl deliver mentions here).
@@ -5796,7 +5997,8 @@ export class AgentFramework {
5796
5997
  if (divert) {
5797
5998
  metadata.tuneOut = { epochId: divert.epochId };
5798
5999
  }
5799
- const id = this.addMessage('user', incomingContent, metadata);
6000
+ const placement = { agent: '' };
6001
+ const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
5800
6002
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
5801
6003
  if (event.triggerInference && !divert) {
5802
6004
  const addressed = isAddressedMessage(event.tags, event.metadata);
@@ -5822,9 +6024,11 @@ export class AgentFramework {
5822
6024
  // Addressed messages outrank ambient chatter when a batched wake
5823
6025
  // picks the turn's frozen speech locus.
5824
6026
  addressed,
6027
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
5825
6028
  });
5826
6029
  }
5827
6030
  }
6031
+ return placement.agent ? placement : undefined;
5828
6032
  }
5829
6033
  /**
5830
6034
  * Route an incoming channel message through the ConversationRouter:
@@ -5893,6 +6097,7 @@ export class AgentFramework {
5893
6097
  const trigger = decision.trigger && event.triggerInference !== false;
5894
6098
  messageMetadata.triggered = trigger;
5895
6099
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
6100
+ router.touch(event.channelId);
5896
6101
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
5897
6102
  if (trigger) {
5898
6103
  this.pendingRequests.push({
@@ -5905,8 +6110,10 @@ export class AgentFramework {
5905
6110
  channelId: event.channelId,
5906
6111
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
5907
6112
  addressed: isAddressedMessage(event.tags, event.metadata),
6113
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
5908
6114
  });
5909
6115
  }
6116
+ return { agent: agent.name, messageId: id };
5910
6117
  }
5911
6118
  /**
5912
6119
  * Spawn a persistent per-channel conversation agent ("fork") from the
@@ -5955,6 +6162,8 @@ export class AgentFramework {
5955
6162
  }
5956
6163
  const config = { ...templateConfig, name, strategy: undefined };
5957
6164
  const agent = new Agent(config, contextManager, this.membrane);
6165
+ this.restoreToolResultGuardSetting(agent);
6166
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
5958
6167
  this.agents.set(name, agent);
5959
6168
  this.agentConfigs.set(name, config);
5960
6169
  this.conversationAgentHomes.set(name, channelId);
@@ -6112,6 +6321,19 @@ export class AgentFramework {
6112
6321
  const label = opts.channelLabel ?? descriptor?.label;
6113
6322
  const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
6114
6323
  const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
6324
+ // The label is for reading; the prefix must be ONE token that resolves back
6325
+ // here, on this server (a DM label "DM: alice" quoted verbatim parses as
6326
+ // target "#DM:"). A registry that can name targets is authoritative: no safe
6327
+ // token means no prefix option. A stand-in without proseTargetFor gets a
6328
+ // whitespace-free guess, never a label with spaces.
6329
+ const namer = this.channelRegistry;
6330
+ const target = typeof namer.proseTargetFor === 'function'
6331
+ ? namer.proseTargetFor(opts.channelId, opts.serverId)
6332
+ : [label ? `#${label}` : undefined, opts.channelId].find((t) => !!t && !/\s/.test(t));
6333
+ const replyOption = target
6334
+ ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
6335
+ `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
6336
+ : `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
6115
6337
  const missedCount = opts.missedMessages ?? 0;
6116
6338
  const missedNote = missedCount > 0
6117
6339
  ? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
@@ -6125,8 +6347,7 @@ export class AgentFramework {
6125
6347
  `a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
6126
6348
  missedNote +
6127
6349
  `Your options:\n` +
6128
- `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
6129
- `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
6350
+ replyOption +
6130
6351
  `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
6131
6352
  (maxBackscroll > 0
6132
6353
  ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
@@ -6145,6 +6366,484 @@ export class AgentFramework {
6145
6366
  },
6146
6367
  };
6147
6368
  }
6369
+ // ==========================================================================
6370
+ // RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
6371
+ // ==========================================================================
6372
+ /**
6373
+ * RFC-006 §3.2 binding identity: the configured server id bound to its
6374
+ * endpoint/command. Survives reconnects to the same peer; changes when the
6375
+ * id is reassigned to another endpoint (restartMcplServer with a new config).
6376
+ */
6377
+ coalescingBinding(serverId) {
6378
+ const config = this.mcplServerConfigs.get(serverId);
6379
+ return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
6380
+ }
6381
+ initializePushCoalescer() {
6382
+ const coalescer = new PushCoalescer({
6383
+ isUnread: (p) => this.isUnreadPlacement(p),
6384
+ remove: (p) => this.removePlacement(p),
6385
+ deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
6386
+ wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
6387
+ cancelWake: (subject) => this.cancelCoalescedWakes(subject),
6388
+ authorized: (occ) => this.coalescedAuthorized(occ),
6389
+ audience: (occ) => this.coalescedAudience(occ),
6390
+ render: (occ, params) => {
6391
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
6392
+ if (!server?.isConnected)
6393
+ throw new Error('server disconnected');
6394
+ return server.sendPushRender(params);
6395
+ },
6396
+ audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
6397
+ save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
6398
+ recordReceipt: (record) => {
6399
+ // Written now, not at the throttled flush: an acknowledged acceptance
6400
+ // must not lose its receipt, the subject's birth, or (deferred) the
6401
+ // accepted work itself to a crash. A failure here fails the acceptance.
6402
+ this.coalescingRecentReceipts.push(record);
6403
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
6404
+ },
6405
+ wasPublished: (subject, eventId) => {
6406
+ // Boot-time only: the occurrence's durable delivery identity
6407
+ // (subject + eventId in message metadata) in any agent's context.
6408
+ for (const agent of this.agents.values()) {
6409
+ try {
6410
+ if (agent.getContextManager().getAllMessages().some((m) => m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId))
6411
+ return true;
6412
+ }
6413
+ catch { /* a context that cannot be read cannot prove publication */ }
6414
+ }
6415
+ return false;
6416
+ },
6417
+ commit: () => this.commitCoalescingDurable(),
6418
+ saveNow: (snapshot) => {
6419
+ if (this.coalescingSaveTimer) {
6420
+ clearTimeout(this.coalescingSaveTimer);
6421
+ this.coalescingSaveTimer = null;
6422
+ }
6423
+ this.coalescingSnapshotDirty = null;
6424
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot);
6425
+ this.coalescingRecentReceipts = [];
6426
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
6427
+ },
6428
+ });
6429
+ try {
6430
+ coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID));
6431
+ const recent = this.store.getStateJson(COALESCING_RECENT_ID);
6432
+ if (Array.isArray(recent)) {
6433
+ coalescer.restoreReceipts(recent);
6434
+ // Until the next snapshot flush checkpoints the recovered state, these
6435
+ // records are its only durable form: keep them in the writable buffer
6436
+ // so the next acceptance appends to them instead of replacing them.
6437
+ this.coalescingRecentReceipts = recent;
6438
+ }
6439
+ }
6440
+ catch (err) {
6441
+ console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
6442
+ }
6443
+ this.pushCoalescer = coalescer;
6444
+ // Batches restored from a snapshot still owe the agent a wake (§14 v41).
6445
+ for (const occ of coalescer.pendingBatchOccurrences())
6446
+ void this.wakeForCoalescedBatch(occ);
6447
+ }
6448
+ /**
6449
+ * Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
6450
+ * written so far is fsynced before the caller proceeds. Group-committed —
6451
+ * all acceptances that arrive in one event-loop turn share one
6452
+ * `store.sync()` — so throughput is bounded by fsync rate per batch, not
6453
+ * per message.
6454
+ */
6455
+ commitCoalescingDurable() {
6456
+ if (!this.coalescingCommit) {
6457
+ this.coalescingCommit = new Promise((resolve, reject) => {
6458
+ setImmediate(() => {
6459
+ this.coalescingCommit = null;
6460
+ try {
6461
+ this.store.sync();
6462
+ resolve();
6463
+ }
6464
+ catch (err) {
6465
+ reject(err);
6466
+ }
6467
+ });
6468
+ });
6469
+ }
6470
+ return this.coalescingCommit;
6471
+ }
6472
+ scheduleCoalescingSnapshot(snapshot) {
6473
+ this.coalescingSnapshotDirty = snapshot;
6474
+ if (this.coalescingSaveTimer)
6475
+ return;
6476
+ const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
6477
+ timer.unref?.();
6478
+ this.coalescingSaveTimer = timer;
6479
+ }
6480
+ flushCoalescingSnapshot() {
6481
+ if (this.coalescingSaveTimer) {
6482
+ clearTimeout(this.coalescingSaveTimer);
6483
+ this.coalescingSaveTimer = null;
6484
+ }
6485
+ const snapshot = this.coalescingSnapshotDirty;
6486
+ this.coalescingSnapshotDirty = null;
6487
+ if (!snapshot)
6488
+ return;
6489
+ try {
6490
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot());
6491
+ this.coalescingRecentReceipts = [];
6492
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
6493
+ }
6494
+ catch (err) {
6495
+ console.error('[coalescing] could not persist coalescing state:', err);
6496
+ }
6497
+ }
6498
+ /** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
6499
+ admitCoalesced(work) {
6500
+ const run = this.coalescingAdmission.then(work, work);
6501
+ this.coalescingAdmission = run.then(() => undefined, () => undefined);
6502
+ return run;
6503
+ }
6504
+ handleCoalescedIncoming(serverId, message, event) {
6505
+ return this.admitCoalesced(async () => {
6506
+ if (!this.pushCoalescer)
6507
+ throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
6508
+ validateCoalesceMember(message.coalesce, 'channel');
6509
+ if (typeof message.eventId !== 'string' || !message.eventId) {
6510
+ throw new CoalesceError('eventId', 'eventId is required with coalesce');
6511
+ }
6512
+ if (typeof message.timestamp !== 'string')
6513
+ throw new CoalesceError('timestamp', 'timestamp is required');
6514
+ validateCoalescedContent(message.content);
6515
+ const c = message.coalesce;
6516
+ const result = await this.pushCoalescer.accept({
6517
+ serverId,
6518
+ binding: this.coalescingBinding(serverId),
6519
+ scope: { kind: 'channel', id: message.channelId },
6520
+ key: c.key,
6521
+ eventId: message.eventId,
6522
+ timestamp: message.timestamp,
6523
+ retract: !!c.retract,
6524
+ deferred: false,
6525
+ initial: !!c.initial,
6526
+ tags: event.tags,
6527
+ content: message.content,
6528
+ identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
6529
+ event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
6530
+ });
6531
+ return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
6532
+ });
6533
+ }
6534
+ handleCoalescedPush(serverId, params, event) {
6535
+ return this.admitCoalesced(async () => {
6536
+ if (!this.pushCoalescer)
6537
+ throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
6538
+ validateCoalesceMember(params.coalesce, 'push');
6539
+ if (typeof params.eventId !== 'string' || !params.eventId)
6540
+ throw new CoalesceError('eventId', 'eventId is required');
6541
+ if (typeof params.timestamp !== 'string')
6542
+ throw new CoalesceError('timestamp', 'timestamp is required');
6543
+ validateCoalescedContent(params.payload?.content);
6544
+ const c = params.coalesce;
6545
+ if (c.channelId)
6546
+ this.assertChannelScopedPush(serverId, c.channelId);
6547
+ const origin = params.origin ?? {};
6548
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
6549
+ return this.pushCoalescer.accept({
6550
+ serverId,
6551
+ binding: this.coalescingBinding(serverId),
6552
+ featureSet: params.featureSet,
6553
+ scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
6554
+ key: c.key,
6555
+ eventId: params.eventId,
6556
+ timestamp: params.timestamp,
6557
+ retract: !!c.retract,
6558
+ deferred: !!c.deferred,
6559
+ initial: !!c.initial,
6560
+ data: c.data,
6561
+ tags: event.tags,
6562
+ content: params.payload.content,
6563
+ ...(c.channelId ? {
6564
+ identity: {
6565
+ ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
6566
+ ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
6567
+ ...(str(origin.authorId) ? { author: { id: str(origin.authorId), name: str(origin.authorName) ?? str(origin.authorId) } } : {}),
6568
+ },
6569
+ } : {}),
6570
+ event: { lane: 'push', event },
6571
+ });
6572
+ });
6573
+ }
6574
+ /** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
6575
+ assertChannelScopedPush(serverId, channelId) {
6576
+ const server = this.mcplServerRegistry?.getServer(serverId);
6577
+ if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
6578
+ throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
6579
+ }
6580
+ if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
6581
+ throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
6582
+ }
6583
+ }
6584
+ coalescedAuthorized(occ) {
6585
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
6586
+ if (!server?.isConnected || !server.policyEstablished)
6587
+ return false;
6588
+ // §3.2: work admitted under another binding (the id since reassigned to a
6589
+ // different endpoint) never acquires the replacement peer's authority —
6590
+ // and its private notice data is never sent to that peer.
6591
+ if (occ.binding !== this.coalescingBinding(occ.serverId))
6592
+ return false;
6593
+ if (occ.event.lane === 'push') {
6594
+ if (!server.grant.has('pushEvents'))
6595
+ return false;
6596
+ try {
6597
+ this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet);
6598
+ }
6599
+ catch {
6600
+ return false;
6601
+ }
6602
+ }
6603
+ if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
6604
+ // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
6605
+ if (!server.grant.has('channels.incoming'))
6606
+ return false;
6607
+ if (occ.event.lane === 'push')
6608
+ return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
6609
+ return !!this.channelRegistry?.getDescriptor(occ.scope.id);
6610
+ }
6611
+ return true;
6612
+ }
6613
+ async coalescedAudience(occ) {
6614
+ if (occ.deliverTo)
6615
+ return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
6616
+ const explicit = occ.event.event.targetAgents;
6617
+ if (explicit)
6618
+ return explicit.filter((n) => this.agents.has(n));
6619
+ if (occ.scope.kind === 'channel' && this.conversationRouter) {
6620
+ const router = this.conversationRouter;
6621
+ const bound = router.getBinding(occ.scope.id)?.agentName;
6622
+ if (bound)
6623
+ return this.agents.has(bound) ? [bound] : [];
6624
+ // No fork yet: the same bind decision an incoming message would get. A
6625
+ // batch that qualifies spawns its fork now so it has someone to wake.
6626
+ const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
6627
+ const decision = router.route({
6628
+ channelId: occ.scope.id,
6629
+ mentioned: metadata.mentioned === true,
6630
+ kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
6631
+ });
6632
+ if (decision.kind !== 'spawn')
6633
+ return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
6634
+ try {
6635
+ await this.createConversationAgent(decision.agentName, occ.scope.id);
6636
+ router.bind(occ.scope.id, decision.agentName, decision.generation);
6637
+ this.persistConversationRouterState();
6638
+ return [decision.agentName];
6639
+ }
6640
+ catch (err) {
6641
+ console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
6642
+ return [];
6643
+ }
6644
+ }
6645
+ return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
6646
+ }
6647
+ /** Deliver through the lane's ordinary path; report where it landed. */
6648
+ async deliverCoalesced(occ, materialized, assemblingFor) {
6649
+ const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
6650
+ const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
6651
+ if (occ.event.lane === 'channel') {
6652
+ const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
6653
+ const placement = await this.handleMcplChannelIncoming(event);
6654
+ await this.dispatchProcessEventToModules(event);
6655
+ return placement;
6656
+ }
6657
+ if (occ.scope.kind === 'channel') {
6658
+ // §3.2: a channel-scoped push addresses the channel's subject namespace,
6659
+ // so it is delivered as a message OF that channel — same routing
6660
+ // (conversation forks, tune-out, closed-channel invitation), same reply
6661
+ // locus, and the subject's stable platform identity (§3.1, vector 43).
6662
+ const push = occ.event.event;
6663
+ const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
6664
+ const origin = push.origin ?? {};
6665
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
6666
+ const event = {
6667
+ type: 'mcpl:channel-incoming',
6668
+ serverId: occ.serverId,
6669
+ channelId: occ.scope.id,
6670
+ messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
6671
+ threadId: identity.threadId ?? str(origin.threadId),
6672
+ author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
6673
+ content: materialized ? materialized.map(convertPushBlock) : push.content,
6674
+ timestamp: occ.timestamp,
6675
+ metadata: origin,
6676
+ tags: push.tags,
6677
+ triggerInference: assemblingFor ? false : push.triggerInference,
6678
+ targetAgents: push.targetAgents,
6679
+ coalescingSubject,
6680
+ eventId: occ.eventId,
6681
+ ...narrowing,
6682
+ ...(assemblingFor ? { assemblingFor } : {}),
6683
+ };
6684
+ const placement = await this.handleMcplChannelIncoming(event);
6685
+ await this.dispatchProcessEventToModules(event);
6686
+ return placement;
6687
+ }
6688
+ const event = {
6689
+ ...occ.event.event,
6690
+ eventId: occ.eventId,
6691
+ timestamp: occ.timestamp,
6692
+ ...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
6693
+ coalescingSubject,
6694
+ // §5.2: the materialized occurrence is consumed by the request being
6695
+ // assembled; the wake that started it is not repeated.
6696
+ ...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
6697
+ ...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
6698
+ };
6699
+ const placement = this.handleMcplPushEvent(event);
6700
+ await this.dispatchProcessEventToModules(event);
6701
+ return placement;
6702
+ }
6703
+ async wakeForCoalescedBatch(occ) {
6704
+ const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
6705
+ this.cancelCoalescedWakes(subject);
6706
+ if (occ.event.lane !== 'push' || !occ.event.event.triggerInference)
6707
+ return;
6708
+ const origin = occ.event.event.origin;
6709
+ // The wake carries the batch's channel so the turn freezes its reply
6710
+ // locus there (the materialized delivery at assembly wakes nobody).
6711
+ const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
6712
+ const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
6713
+ for (const agentName of await this.coalescedAudience(occ)) {
6714
+ this.pendingRequests.push({
6715
+ agentName,
6716
+ reason: 'mcpl:push-event',
6717
+ source: occ.serverId,
6718
+ timestamp: Date.now(),
6719
+ channelId,
6720
+ counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
6721
+ addressed: isAddressedMessage(occ.tags, origin),
6722
+ coalescingSubject: subject,
6723
+ });
6724
+ }
6725
+ }
6726
+ /** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
6727
+ cancelCoalescedWakes(subject) {
6728
+ this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
6729
+ for (const cooldown of this.providerAccelerationCooldowns.values()) {
6730
+ cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
6731
+ }
6732
+ }
6733
+ /**
6734
+ * A placement in the deferred queue is upgraded in place once the flush has
6735
+ * stored it: the stored copy carries `metadata.deferredWriteId`, found by
6736
+ * scanning the unread tail (above the watermark) — never below it.
6737
+ */
6738
+ resolvePlacement(p) {
6739
+ if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId))
6740
+ return;
6741
+ const agent = this.agents.get(p.agent);
6742
+ if (!agent)
6743
+ return;
6744
+ const cm = agent.getContextManager();
6745
+ const watermark = agent.getConsumedWatermark();
6746
+ const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
6747
+ const all = cm.getAllMessages();
6748
+ for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
6749
+ if (all[i].metadata?.deferredWriteId === p.deferredId) {
6750
+ p.messageId = all[i].id;
6751
+ delete p.deferredId;
6752
+ return;
6753
+ }
6754
+ }
6755
+ }
6756
+ isUnreadPlacement(p) {
6757
+ this.resolvePlacement(p);
6758
+ if (p.deferredId)
6759
+ return this.deferredMessages.some((m) => m.id === p.deferredId);
6760
+ if (!p.messageId)
6761
+ return false;
6762
+ const agent = this.agents.get(p.agent);
6763
+ return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
6764
+ }
6765
+ /**
6766
+ * Unread = stored, above the agent's consumed watermark on the current
6767
+ * branch, not a body-group shard (immutable), and not folded into any
6768
+ * summary (compression is consumption, §3.3). Conservative on every doubt.
6769
+ */
6770
+ isUnreadStoredMessage(agent, messageId) {
6771
+ const cm = agent.getContextManager();
6772
+ let message;
6773
+ try {
6774
+ message = cm.getMessage(messageId);
6775
+ }
6776
+ catch {
6777
+ return false;
6778
+ }
6779
+ if (!message || message.bodyGroupId)
6780
+ return false;
6781
+ // Every agent that can read this message must not have compiled past it.
6782
+ // Residents share one message slot, so a message one of them read is
6783
+ // history for all of them (the never-rewrite rule is about the message,
6784
+ // not the agent that happened to be asked). A tune-out-diverted message
6785
+ // never enters a resident's compiled view; only the subconscious reads
6786
+ // it through its merged view — no subconscious, nobody reads it.
6787
+ let readers;
6788
+ if (message.metadata?.tuneOut) {
6789
+ const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
6790
+ if (!subconscious)
6791
+ return true;
6792
+ readers = [subconscious];
6793
+ }
6794
+ else {
6795
+ readers = this.sharedSlotAgents.has(agent)
6796
+ ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
6797
+ : [agent];
6798
+ }
6799
+ const branch = cm.currentBranch().name;
6800
+ const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
6801
+ for (const reader of readers) {
6802
+ const watermark = reader.getConsumedWatermark();
6803
+ if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence)
6804
+ return false;
6805
+ // Compression is consumption (§3.3): folded into any reader's summary.
6806
+ try {
6807
+ const rcm = reader.getContextManager();
6808
+ const strategy = rcm.getStrategy();
6809
+ for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
6810
+ const entry = strategy.getSummary?.(summary.id);
6811
+ if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId))
6812
+ return false;
6813
+ }
6814
+ }
6815
+ catch {
6816
+ return false;
6817
+ }
6818
+ }
6819
+ return true;
6820
+ }
6821
+ removePlacement(p) {
6822
+ this.resolvePlacement(p);
6823
+ if (p.deferredId) {
6824
+ const before = this.deferredMessages.length;
6825
+ this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
6826
+ if (this.deferredMessages.length === before)
6827
+ return false;
6828
+ if (this.quiesced || this.deferredWritesPersisted)
6829
+ this.persistDeferredWrites();
6830
+ return true;
6831
+ }
6832
+ if (!p.messageId)
6833
+ return false;
6834
+ const agent = this.agents.get(p.agent);
6835
+ if (!agent)
6836
+ return false;
6837
+ try {
6838
+ agent.getContextManager().removeMessage(p.messageId);
6839
+ this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
6840
+ return true;
6841
+ }
6842
+ catch (err) {
6843
+ console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
6844
+ return false;
6845
+ }
6846
+ }
6148
6847
  /**
6149
6848
  * Convert an MCPL push event to a context message.
6150
6849
  */
@@ -6160,6 +6859,7 @@ export class AgentFramework {
6160
6859
  eventId: event.eventId,
6161
6860
  triggered: event.triggerInference ?? false,
6162
6861
  ...(event.tags ? { tags: event.tags } : {}),
6862
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6163
6863
  };
6164
6864
  // `origin.channelId` is the server-internal raw id (a bare Discord
6165
6865
  // snowflake) — unroutable as a locus and unresolvable by the agent. Store
@@ -6189,8 +6889,23 @@ export class AgentFramework {
6189
6889
  Object.assign(metadata, invitation.metadataPatch);
6190
6890
  }
6191
6891
  }
6192
- const id = this.addMessage('user', content, metadata);
6193
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
6892
+ // Silent heartbeat ticks are control-plane wakes, not autobiography.
6893
+ // Accept the no-message path only for the heartbeat feature's own exact
6894
+ // marker with an empty payload — arbitrary MCPL servers cannot hide
6895
+ // content merely by setting `origin.silent`.
6896
+ const silentHeartbeat = event.serverId === 'heartbeat' &&
6897
+ event.featureSet === 'heartbeat' &&
6898
+ event.origin?.source === 'heartbeat' &&
6899
+ event.origin?.silent === true &&
6900
+ content.length === 0;
6901
+ const placement = { agent: '' };
6902
+ if (!silentHeartbeat) {
6903
+ const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
6904
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
6905
+ }
6906
+ else {
6907
+ console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
6908
+ }
6194
6909
  if (event.triggerInference) {
6195
6910
  // Default broadcast excludes conversation forks (channel-driven).
6196
6911
  const targetAgents = event.targetAgents
@@ -6206,9 +6921,16 @@ export class AgentFramework {
6206
6921
  // they must outrank ambient chatter in a batched wake's locus
6207
6922
  // selection just like their channels/incoming counterparts.
6208
6923
  addressed: isAddressedMessage(event.tags, event.origin),
6924
+ ...(silentHeartbeat ? {
6925
+ suppressProse: true,
6926
+ ephemeralSystemPrompt: '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
6927
+ 'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
6928
+ } : {}),
6929
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6209
6930
  });
6210
6931
  }
6211
6932
  }
6933
+ return placement.agent ? placement : undefined;
6212
6934
  }
6213
6935
  /**
6214
6936
  * Derive the MCPL composite channel id (the outbound routing locus) for a
@@ -6353,7 +7075,9 @@ export class AgentFramework {
6353
7075
  // route the resumed turn into the wrong channel.
6354
7076
  const newestByKey = new Map();
6355
7077
  for (const request of parked) {
6356
- const key = `${request.reason}|${request.addressed ? 'a' : 'n'}`;
7078
+ // A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
7079
+ // must not absorb an unrelated wake that would vanish with it.
7080
+ const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
6357
7081
  const prev = newestByKey.get(key);
6358
7082
  if (!prev || request.timestamp >= prev.timestamp) {
6359
7083
  newestByKey.set(key, request);
@@ -6479,8 +7203,13 @@ export class AgentFramework {
6479
7203
  provReq = r;
6480
7204
  }
6481
7205
  }
7206
+ // A silent tick never suppresses a genuine coalesced wake. If any
7207
+ // ordinary request shares this batch, the ordinary turn semantics win.
7208
+ const silentOnly = requests.every((r) => r.suppressProse === true);
6482
7209
  await this.startAgentStream(agent, {
6483
7210
  ...trigger,
7211
+ suppressProse: silentOnly ? trigger?.suppressProse : undefined,
7212
+ ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
6484
7213
  channelId: channelReq?.channelId,
6485
7214
  addressed: addressedReq !== undefined,
6486
7215
  // A context-budget restart continues the same logical turn: it keeps
@@ -7008,6 +7737,9 @@ export class AgentFramework {
7008
7737
  * driveStream (whose finally then owns clearing it).
7009
7738
  */
7010
7739
  async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
7740
+ const continuingTurn = trigger?.reason === 'context_budget_restart'
7741
+ || trigger?.reason === 'tool_result_guard_retry';
7742
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
7011
7743
  // Flush messages deferred during the PREVIOUS turn — before the
7012
7744
  // checkpoint, the locus announcement, and the compile — so a turn started
7013
7745
  // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
@@ -7023,7 +7755,7 @@ export class AgentFramework {
7023
7755
  // Before recordTurnCheckpoint: these are the turn's inputs, not its
7024
7756
  // products — undoing the turn must not destroy them.
7025
7757
  if (attempt === 0 &&
7026
- trigger?.reason !== 'context_budget_restart' &&
7758
+ !continuingTurn &&
7027
7759
  this.deferredMessages.length > 0) {
7028
7760
  {
7029
7761
  const deferred = this.drainDeferredFor(agent.name);
@@ -7043,8 +7775,20 @@ export class AgentFramework {
7043
7775
  this.ackDeferredWrites();
7044
7776
  }
7045
7777
  }
7778
+ // RFC-006 §5: deferred batches for this agent render now, at the assembly
7779
+ // boundary — after the deferred flush (same window: turn alive, compile
7780
+ // not yet run) and before the checkpoint (they are the turn's inputs).
7781
+ // Not on a context-budget restart: that continues the same logical turn.
7782
+ if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
7783
+ try {
7784
+ await this.pushCoalescer.assemble(agent.name);
7785
+ }
7786
+ catch (err) {
7787
+ console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
7788
+ }
7789
+ }
7046
7790
  // Record turn checkpoint before inference (only on first attempt, not retries)
7047
- if (attempt === 0) {
7791
+ if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
7048
7792
  this.recordTurnCheckpoint(agent.name);
7049
7793
  this.redoStacks.delete(agent.name); // new work invalidates redo
7050
7794
  }
@@ -7061,7 +7805,7 @@ export class AgentFramework {
7061
7805
  // after the restart fell through to the hours-old defaultPublishChannel.
7062
7806
  // Keep the turn's trigger channel across restarts; only real new turns
7063
7807
  // reset it.
7064
- if (trigger?.reason !== 'context_budget_restart') {
7808
+ if (!continuingTurn) {
7065
7809
  if (trigger?.channelId) {
7066
7810
  this.activeTriggerChannels.set(agent.name, trigger.channelId);
7067
7811
  }
@@ -7081,11 +7825,11 @@ export class AgentFramework {
7081
7825
  // BEFORE this turn compiles, so the agent always knows where its voice
7082
7826
  // goes (announce-on-change only — no per-turn chatter, append-only for
7083
7827
  // KV stability).
7084
- if (trigger?.reason === 'context_budget_restart') {
7828
+ if (continuingTurn) {
7085
7829
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
7086
7830
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
7087
7831
  }
7088
- if (trigger?.reason !== 'context_budget_restart') {
7832
+ if (!continuingTurn) {
7089
7833
  if (attempt === 0)
7090
7834
  this.maybePrimeProseMode(agent);
7091
7835
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
@@ -7104,9 +7848,9 @@ export class AgentFramework {
7104
7848
  this.turnProseDeliveries.delete(agent.name);
7105
7849
  this.turnProseSuppressed.delete(agent.name);
7106
7850
  this.proseHybridSuppressed.delete(agent.name);
7107
- if (agent.proseRouting === 'hybrid')
7851
+ if (turnProseRouting === 'hybrid')
7108
7852
  this.proseTargetPins.delete(agent.name);
7109
- if (agent.proseRouting === 'explicit' || agent.proseRouting === 'disabled') {
7853
+ if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
7110
7854
  // Explicit and disabled prose routing have no inferred locus.
7111
7855
  // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
7112
7856
  // prose target at all. Neither mode freezes or announces a locus.
@@ -7126,6 +7870,14 @@ export class AgentFramework {
7126
7870
  this.announceLocusIfChanged(agent.name, locus);
7127
7871
  }
7128
7872
  }
7873
+ // A context-budget restart normally preserves the turn locus, but a
7874
+ // silent control-plane wake must remain locusless even if an explicit tool
7875
+ // opened/pinned a channel mid-turn.
7876
+ if (trigger?.suppressProse) {
7877
+ this.turnLocusPins.delete(agent.name);
7878
+ this.proseTargetPins.delete(agent.name);
7879
+ this.proseContinuations.delete(agent.name);
7880
+ }
7129
7881
  this.touchEphemeralRun(agent.name, true);
7130
7882
  this.emitTrace({
7131
7883
  type: 'inference:started',
@@ -7133,10 +7885,11 @@ export class AgentFramework {
7133
7885
  // The turn-frozen locus was pinned just above (kept across a
7134
7886
  // context-budget restart, which skips the re-pin but re-emits this).
7135
7887
  channelId: this.turnLocusPins.get(agent.name),
7888
+ ...(trigger?.suppressProse ? { silent: true } : {}),
7136
7889
  });
7137
7890
  // A budget restart continues the same logical inference window. The
7138
7891
  // predecessor keeps EventGate liveness until its successor terminates.
7139
- if (trigger?.reason !== 'context_budget_restart') {
7892
+ if (!continuingTurn) {
7140
7893
  this.eventGate?.onInferenceStarted(agent.name);
7141
7894
  }
7142
7895
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
@@ -7149,19 +7902,14 @@ export class AgentFramework {
7149
7902
  // Same channel choice as driveStream's own startTyping (which stays, is
7150
7903
  // idempotent per channel, and owns the 7s refresh); the catch below stops
7151
7904
  // it on the no-driveStream failure paths (e.g. a compile refusal).
7152
- const earlyTypingChannel = agent.proseRouting === 'explicit'
7905
+ const earlyTypingChannel = turnProseRouting === 'explicit'
7153
7906
  ? trigger?.channelId ?? null
7154
7907
  : this.turnLocusPins.get(agent.name) ?? null;
7155
7908
  if (earlyTypingChannel)
7156
7909
  this.channelRegistry?.startTyping(earlyTypingChannel);
7157
7910
  try {
7158
7911
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
7159
- const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
7160
- const tools = allTools.filter((t) => agent.canUseTool(t.name));
7161
- // Explicit-mode agents get the on-demand routing reference (teach-by-
7162
- // bounce: the grammar is never injected, only served when asked).
7163
- if (agent.proseRouting === 'explicit')
7164
- tools.push(PROSE_HELP_TOOL);
7912
+ const tools = this.agentToolSurface(agent, requestSnapshot);
7165
7913
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
7166
7914
  // Both produce ContextInjection[] that get merged before inference.
7167
7915
  let injections;
@@ -7192,6 +7940,14 @@ export class AgentFramework {
7192
7940
  console.error('beforeInference hook error:', error);
7193
7941
  }
7194
7942
  }
7943
+ if (trigger?.ephemeralSystemPrompt) {
7944
+ const silentInjection = {
7945
+ namespace: 'framework:silent-heartbeat',
7946
+ position: 'system',
7947
+ content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
7948
+ };
7949
+ injections = injections ? [...injections, silentInjection] : [silentInjection];
7950
+ }
7195
7951
  // An ephemeral watchdog may dispose this Agent while hooks/compile await.
7196
7952
  // Do not create a provider stream after its generation lost ownership.
7197
7953
  if (this.agents.get(agent.name) !== agent) {
@@ -7291,6 +8047,7 @@ export class AgentFramework {
7291
8047
  }
7292
8048
  }
7293
8049
  async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
8050
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
7294
8051
  const startTime = Date.now();
7295
8052
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
7296
8053
  const myStreamId = agent.streamId;
@@ -7344,7 +8101,10 @@ export class AgentFramework {
7344
8101
  // Sticky explicit-send suppression: prose after send_message stays quiet
7345
8102
  // to prevent a redundant "sent it" postscript. Fresh injected input
7346
8103
  // clears it, because the following prose is a reply to a new message.
7347
- let turnSilenced = false;
8104
+ let turnSilenced = trigger?.suppressProse === true
8105
+ || (trigger?.reason === 'tool_result_guard_retry'
8106
+ && this.guardRetryTurnSilenced.get(agent.name) === true);
8107
+ this.guardRetryTurnSilenced.delete(agent.name);
7348
8108
  // Live routing is only trusted when the membrane provides verbatim
7349
8109
  // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
7350
8110
  // The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
@@ -7364,9 +8124,9 @@ export class AgentFramework {
7364
8124
  // this doesn't violate never-guess: it says "attending to what you
7365
8125
  // sent here", which is true regardless of where the reply goes.
7366
8126
  // Heartbeat/no-trigger explicit turns show no indicator.
7367
- const typingChannel = agent.proseRouting === 'disabled'
8127
+ const typingChannel = turnProseRouting === 'disabled'
7368
8128
  ? null
7369
- : agent.proseRouting === 'explicit'
8129
+ : turnProseRouting === 'explicit'
7370
8130
  ? trigger?.channelId ?? null
7371
8131
  : resolveTurnLocus();
7372
8132
  if (typingChannel)
@@ -7383,6 +8143,9 @@ export class AgentFramework {
7383
8143
  // id makes the discarded attempt's chunk buffer orphaned (never
7384
8144
  // finalized) instead of being appended to — see the 'retrying' case.
7385
8145
  let outgoingInferenceId = newOutgoingInferenceId();
8146
+ // RFC-007: every inference id THIS stream mints, so the stream's end
8147
+ // aborts only its own open tool calls — never a successor's.
8148
+ const lifecycleInferenceIds = new Set([outgoingInferenceId]);
7386
8149
  let outgoingIndex = 0;
7387
8150
  // §10.5 inference/lifecycle: `started` now, exactly one terminal in the
7388
8151
  // finally below (the one block every exit path shares). Which terminal
@@ -7402,10 +8165,10 @@ export class AgentFramework {
7402
8165
  // turns until completion: otherwise speculative outgoing chunks could
7403
8166
  // expose the wrapper before the fail-closed classifier runs.
7404
8167
  const proseStream = this.channelRegistry &&
7405
- agent.proseRouting !== 'disabled' &&
8168
+ turnProseRouting !== 'disabled' &&
7406
8169
  !agent.toolWrapperProseGuard
7407
8170
  ? new ProseStreamRouter({
7408
- mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
8171
+ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
7409
8172
  initialTarget: typingChannel,
7410
8173
  resolve: (spec) => {
7411
8174
  const r = this.channelRegistry.resolveProseTarget(spec);
@@ -7418,6 +8181,23 @@ export class AgentFramework {
7418
8181
  this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
7419
8182
  }
7420
8183
  };
8184
+ // Tool-result-guard publication boundary. While a SUBMITTED batch is
8185
+ // pending (or a guard recovery is running), a refusal can still abandon
8186
+ // this physical round, so its streamed text must not reach any surface
8187
+ // yet — neither the prose router nor the inference:tokens trace (TTS and
8188
+ // other trace consumers voice it). Hold both; release in order on a clean
8189
+ // round (tool-calls / non-guard completion), discard on a guard refusal.
8190
+ let guardHeld = [];
8191
+ const releaseGuardHeld = () => {
8192
+ const held = guardHeld;
8193
+ guardHeld = [];
8194
+ for (const item of held) {
8195
+ this.emitTrace(item.trace);
8196
+ if (proseStream && item.text !== undefined)
8197
+ emitOutgoing(proseStream.feed(item.text));
8198
+ }
8199
+ };
8200
+ const discardGuardHeld = () => { guardHeld = []; };
7421
8201
  const adoptInjectedRound = () => {
7422
8202
  if (!this.midTurnInputSignals.has(agent.name))
7423
8203
  return;
@@ -7425,7 +8205,7 @@ export class AgentFramework {
7425
8205
  // A new conversational message, not merely a tool result, means any
7426
8206
  // earlier explicit delivery has completed its conversational job.
7427
8207
  // Routing is NOT touched: the turn locus stays frozen.
7428
- turnSilenced = false;
8208
+ turnSilenced = trigger?.suppressProse === true;
7429
8209
  };
7430
8210
  try {
7431
8211
  for await (const event of stream) {
@@ -7439,8 +8219,8 @@ export class AgentFramework {
7439
8219
  }
7440
8220
  this.touchEphemeralRun(agent.name, true);
7441
8221
  switch (event.type) {
7442
- case 'tokens':
7443
- this.emitTrace({
8222
+ case 'tokens': {
8223
+ const trace = {
7444
8224
  type: 'inference:tokens',
7445
8225
  agentName: agent.name,
7446
8226
  content: event.content,
@@ -7450,11 +8230,18 @@ export class AgentFramework {
7450
8230
  // lets trace consumers tag every chunk with the channel this
7451
8231
  // turn's prose is bound for, without re-deriving routing.
7452
8232
  channelId: typingChannel ?? undefined,
7453
- });
7454
- if (proseStream && event.meta.type === 'text') {
7455
- emitOutgoing(proseStream.feed(event.content));
8233
+ };
8234
+ const text = event.meta.type === 'text' ? event.content : undefined;
8235
+ if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
8236
+ guardHeld.push({ trace, text });
8237
+ }
8238
+ else {
8239
+ this.emitTrace(trace);
8240
+ if (proseStream && text !== undefined)
8241
+ emitOutgoing(proseStream.feed(text));
7456
8242
  }
7457
8243
  break;
8244
+ }
7458
8245
  case 'retrying': {
7459
8246
  // Membrane is re-issuing after a content-policy refusal. Per the
7460
8247
  // RetryingEvent contract we must DISCARD everything this call has
@@ -7472,7 +8259,9 @@ export class AgentFramework {
7472
8259
  (event.category ? ` category=${event.category}` : '') +
7473
8260
  ' — discarding the refused attempt');
7474
8261
  outgoingInferenceId = newOutgoingInferenceId();
8262
+ lifecycleInferenceIds.add(outgoingInferenceId);
7475
8263
  outgoingIndex = 0;
8264
+ discardGuardHeld();
7476
8265
  proseStream?.reset();
7477
8266
  this.emitTrace({
7478
8267
  type: 'inference:tokens',
@@ -7497,6 +8286,10 @@ export class AgentFramework {
7497
8286
  break;
7498
8287
  }
7499
8288
  case 'tool-calls': {
8289
+ // A tool-call response is a clean physical round: admit its
8290
+ // preceding results before storing/dispatching this new round.
8291
+ agent.toolResultGuard.accept();
8292
+ releaseGuardHeld();
7500
8293
  adoptInjectedRound();
7501
8294
  hadToolCalls = true;
7502
8295
  this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
@@ -7562,7 +8355,14 @@ export class AgentFramework {
7562
8355
  // So all calls here have valid input — {} is legitimate for parameterless tools.
7563
8356
  agent.enterWaitingForTools(event.calls, stream);
7564
8357
  for (const call of event.calls) {
8358
+ // RFC-007: register at the one dispatch point for model-issued
8359
+ // calls, keyed by (agent, call id) with this inference's id;
8360
+ // `started` goes out once dispatch returns. Refusal sites inside
8361
+ // dispatch run synchronously and call refuse() first, so a call
8362
+ // the host refuses produces no events.
8363
+ this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
7565
8364
  this.dispatchToolCall(agent.name, call);
8365
+ this.toolLifecycleEmitter?.open(agent.name, call.id);
7566
8366
  }
7567
8367
  // Speak-while-acting: route THIS round's prose to the locus NOW
7568
8368
  // instead of batching every segment to the end of the turn. Long
@@ -7586,11 +8386,11 @@ export class AgentFramework {
7586
8386
  liveProseRouting = true;
7587
8387
  const roundSegments = splitProseSegments(assistantBlocks);
7588
8388
  if (roundSegments.length > 0) {
7589
- if (agent.proseRouting === 'disabled') {
8389
+ if (turnProseRouting === 'disabled') {
7590
8390
  console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
7591
8391
  this.recordProseSuppression(agent.name, roundSegments.length);
7592
8392
  }
7593
- else if (agent.proseRouting === 'hybrid') {
8393
+ else if (turnProseRouting === 'hybrid') {
7594
8394
  if (turnSilenced) {
7595
8395
  this.recordProseSuppression(agent.name, roundSegments.length);
7596
8396
  }
@@ -7603,7 +8403,7 @@ export class AgentFramework {
7603
8403
  }
7604
8404
  }
7605
8405
  }
7606
- else if (agent.proseRouting === 'explicit') {
8406
+ else if (turnProseRouting === 'explicit') {
7607
8407
  // Explicit mode: every segment through the prose gateway.
7608
8408
  // Silencing does not apply — unprefixed prose bounces and
7609
8409
  // prefixed prose is deliberate; think-privacy still holds.
@@ -7641,7 +8441,87 @@ export class AgentFramework {
7641
8441
  case 'complete': {
7642
8442
  adoptInjectedRound();
7643
8443
  const durationMs = Date.now() - startTime;
7644
- const response = event.response;
8444
+ let response = event.response;
8445
+ const guardCategory = response.raw?.response?.stop_details?.category ?? 'unknown';
8446
+ // A staged batch the strategy omitted from the request cannot
8447
+ // have caused this refusal: settle it and fall through to the
8448
+ // ordinary refusal handling (rewind/reaction) below.
8449
+ if (response.stopReason === 'refusal')
8450
+ agent.toolResultGuard.settleUnsubmitted(guardCategory);
8451
+ const guardRefusal = response.stopReason === 'refusal'
8452
+ && (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
8453
+ if (guardRefusal) {
8454
+ discardGuardHeld();
8455
+ const category = guardCategory;
8456
+ const withheld = agent.toolResultGuard.withhold(category);
8457
+ // Nothing from the abandoned physical attempt is published or
8458
+ // persisted as assistant speech, including a terminal refusal
8459
+ // after the single recovery retry.
8460
+ proseStream?.reset();
8461
+ if (withheld) {
8462
+ const usage = response.details?.usage ?? response.usage;
8463
+ const tokenUsage = usage ? {
8464
+ input: usage.inputTokens, output: usage.outputTokens,
8465
+ cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
8466
+ } : undefined;
8467
+ this.noteRefusal(agent.name, category, tokenUsage);
8468
+ // The abandoned stream was billed (membrane usage here is
8469
+ // cumulative across this physical tool loop): count it before
8470
+ // the retry opens a fresh stream with its own usage.
8471
+ const abandonedUsage = response.details?.usage;
8472
+ if (abandonedUsage) {
8473
+ this.usageTracker.onInferenceCompleted(agent.name, {
8474
+ inputTokens: abandonedUsage.inputTokens,
8475
+ outputTokens: abandonedUsage.outputTokens,
8476
+ cacheCreationTokens: abandonedUsage.cacheCreationTokens,
8477
+ cacheReadTokens: abandonedUsage.cacheReadTokens,
8478
+ }, abandonedUsage.estimatedCost
8479
+ ? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
8480
+ : undefined);
8481
+ this.persistUsageState();
8482
+ }
8483
+ this.logInference({
8484
+ timestamp: startTime, agentName: agent.name, requestId,
8485
+ success: false, error: 'Tool output withheld by the guard',
8486
+ request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
8487
+ });
8488
+ console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
8489
+ this.emitTrace({
8490
+ type: 'inference:stream_restarted', agentName: agent.name,
8491
+ reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
8492
+ budget: agent.maxStreamTokens,
8493
+ });
8494
+ await turnSpeechChain;
8495
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
8496
+ generationLost = true;
8497
+ lifecyclePhase = 'aborted';
8498
+ return;
8499
+ }
8500
+ agent.reset();
8501
+ preserveEventGateForSuccessor = true;
8502
+ lifecyclePhase = 'aborted';
8503
+ // Carry same-turn explicit-send suppression into the retry:
8504
+ // a text-only recovery after a successful send must not post
8505
+ // a postscript to a message already delivered.
8506
+ if (turnSilenced)
8507
+ this.guardRetryTurnSilenced.set(agent.name, true);
8508
+ // Restart inference inside this logical turn. No tool is
8509
+ // executed again and no settle/checkpoint/locus reset occurs.
8510
+ await this.startAgentStream(agent, {
8511
+ ...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
8512
+ source: trigger?.source ?? 'framework', timestamp: Date.now(),
8513
+ }, attempt);
8514
+ return;
8515
+ }
8516
+ const lastResult = response.content.reduce((last, block, index) => block.type === 'tool_result' ? index : last, -1);
8517
+ response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
8518
+ agent.toolResultGuard.recovering = false;
8519
+ }
8520
+ else if (response.stopReason !== 'refusal') {
8521
+ agent.toolResultGuard.accept();
8522
+ }
8523
+ if (!guardRefusal)
8524
+ releaseGuardHeld();
7645
8525
  // If the agent is still waiting_for_tools when 'complete' fires
7646
8526
  // (shouldn't happen after incomplete-tool-call fix, but guard anyway),
7647
8527
  // flush pending blocks + inject error results for any remaining pending tools.
@@ -7667,8 +8547,12 @@ export class AgentFramework {
7667
8547
  // one door a giant blob still walks through).
7668
8548
  const readyState = agent.state;
7669
8549
  if (readyState.status === 'ready') {
7670
- const { blocks: toolResultContent } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
7671
- agent.getContextManager().addMessage('user', toolResultContent);
8550
+ const cap = this.resolveToolResultInlineCap(agent).cap;
8551
+ const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap);
8552
+ agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
8553
+ // The response is already complete: this batch is never
8554
+ // submitted in this turn, so it cannot be refused. Admit it.
8555
+ agent.toolResultGuard.settleTurnEnded();
7672
8556
  }
7673
8557
  }
7674
8558
  // Add assistant response to context.
@@ -7855,7 +8739,7 @@ export class AgentFramework {
7855
8739
  `(remaining ${forced.remaining})`);
7856
8740
  }, () => this.finishUnstick(agent.name, false, category));
7857
8741
  }
7858
- else if (rh?.autoRewind) {
8742
+ else if (rh?.autoRewind && !guardRefusal) {
7859
8743
  const cap = Math.max(1, rh.maxRewinds ?? 3);
7860
8744
  const used = this.refusalRewinds.get(agent.name) ?? 0;
7861
8745
  doRewind(used < cap, (count) => {
@@ -7894,7 +8778,7 @@ export class AgentFramework {
7894
8778
  this.refusalStreak.delete(agent.name);
7895
8779
  }
7896
8780
  // Dispatch speech (and thoughts if any)
7897
- if (speechContent.length > 0 || thoughts.length > 0) {
8781
+ if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
7898
8782
  const speechContext = {
7899
8783
  turnComplete: true,
7900
8784
  trigger: trigger ?? {
@@ -7920,11 +8804,19 @@ export class AgentFramework {
7920
8804
  .join('\n')
7921
8805
  .trim();
7922
8806
  if (speechText) {
7923
- if (agent.proseRouting === 'disabled') {
8807
+ if (turnProseRouting === 'disabled') {
7924
8808
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
7925
8809
  this.recordProseSuppression(agent.name, 1);
7926
8810
  }
7927
- else if (agent.proseRouting === 'hybrid') {
8811
+ else if (turnSilenced && turnProseRouting !== 'explicit') {
8812
+ // Only reachable on a guard recovery that carried the
8813
+ // same-turn send suppression, or a suppressProse trigger
8814
+ // (a fresh text-only turn starts unsilenced). Explicit
8815
+ // mode is exempt, as mid-turn.
8816
+ console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
8817
+ this.recordProseSuppression(agent.name, 1);
8818
+ }
8819
+ else if (turnProseRouting === 'hybrid') {
7928
8820
  const locus = resolveTurnLocus();
7929
8821
  console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
7930
8822
  try {
@@ -7934,7 +8826,7 @@ export class AgentFramework {
7934
8826
  console.error('text-only hybrid prose delivery failed:', err);
7935
8827
  }
7936
8828
  }
7937
- else if (agent.proseRouting === 'explicit') {
8829
+ else if (turnProseRouting === 'explicit') {
7938
8830
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
7939
8831
  try {
7940
8832
  await this.deliverProse(agent, speechText);
@@ -7989,13 +8881,13 @@ export class AgentFramework {
7989
8881
  // land before the trailing prose. Awaited even when silenced —
7990
8882
  // the chain may still be flushing earlier rounds' posts.
7991
8883
  await turnSpeechChain;
7992
- if (agent.proseRouting === 'disabled') {
8884
+ if (turnProseRouting === 'disabled') {
7993
8885
  if (segments.length > 0) {
7994
8886
  console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
7995
8887
  this.recordProseSuppression(agent.name, segments.length);
7996
8888
  }
7997
8889
  }
7998
- else if (agent.proseRouting === 'hybrid') {
8890
+ else if (turnProseRouting === 'hybrid') {
7999
8891
  if (silenced && segments.length > 0) {
8000
8892
  this.recordProseSuppression(agent.name, segments.length);
8001
8893
  }
@@ -8011,7 +8903,7 @@ export class AgentFramework {
8011
8903
  }
8012
8904
  }
8013
8905
  }
8014
- else if (agent.proseRouting === 'explicit') {
8906
+ else if (turnProseRouting === 'explicit') {
8015
8907
  if (segments.length > 0) {
8016
8908
  console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
8017
8909
  for (const seg of segments) {
@@ -8061,7 +8953,7 @@ export class AgentFramework {
8061
8953
  // awaited before trailing dispatch, and trailing/text-only
8062
8954
  // segments were awaited in-loop. Locus mode only; explicit-mode
8063
8955
  // envelopes acknowledge themselves through the prose gateway.
8064
- if (agent.proseRouting !== 'explicit') {
8956
+ if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
8065
8957
  this.appendProseDeliveryReceipt(agent);
8066
8958
  }
8067
8959
  // Explicit-prose `!` continuation: a prose segment this turn asked
@@ -8225,7 +9117,7 @@ export class AgentFramework {
8225
9117
  // deliveries map persists (cleared only at fresh turn
8226
9118
  // start) and the continuation stream's end writes ONE
8227
9119
  // receipt for the whole turn.
8228
- if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
9120
+ if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
8229
9121
  await turnSpeechChain;
8230
9122
  this.appendProseDeliveryReceipt(agent);
8231
9123
  }
@@ -8430,6 +9322,13 @@ export class AgentFramework {
8430
9322
  // completed; catch → failed; framework-cancel → aborted). A host
8431
9323
  // crash emits nothing — that is the documented best-effort limit, and
8432
9324
  // why consumers keep their safety timeout.
9325
+ //
9326
+ // RFC-007 §7.3: a tool call still open when its turn's stream ends was
9327
+ // cancelled before a result — its terminal is `aborted`. (A normal turn
9328
+ // has none: the stream waits for every result before continuing.)
9329
+ // Scoped to this stream's own inference ids: a budget-restart successor
9330
+ // may already be running for the same agent.
9331
+ this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
8433
9332
  this.hookOrchestrator?.emitLifecycle({
8434
9333
  inferenceId: outgoingInferenceId,
8435
9334
  conversationId: agent.name,
@@ -8444,6 +9343,23 @@ export class AgentFramework {
8444
9343
  // (typing still stops, compression still runs — matching the observed
8445
9344
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
8446
9345
  if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
9346
+ // A frame that ends without handing off to a successor (abort,
9347
+ // exhausted errors) must not leave its batch pending: a later turn
9348
+ // would resubmit the originals and claim its own refusal. Successor
9349
+ // frames (error-policy retry, budget/guard restart) bump streamId or
9350
+ // set preserveEventGateForSuccessor and keep the batch.
9351
+ if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
9352
+ // abandon() clears the batch before its audit append, so a failed
9353
+ // write cannot re-arm it; it must not abort the rest of teardown
9354
+ // (gate release, turn token, provider admission) either.
9355
+ try {
9356
+ agent.toolResultGuard.abandon('aborted');
9357
+ }
9358
+ catch (error) {
9359
+ console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
9360
+ }
9361
+ }
9362
+ agent.toolResultGuard.recovering = false;
8447
9363
  this.eventGate?.onInferenceEnded(agent.name);
8448
9364
  }
8449
9365
  if (!generationLost && ownsPhysicalStream) {
@@ -8756,6 +9672,7 @@ export class AgentFramework {
8756
9672
  const startTime = Date.now();
8757
9673
  this.runCodeExecution(agentName, call)
8758
9674
  .catch((error) => {
9675
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
8759
9676
  // runCodeExecution is designed not to reject; this is the last-resort
8760
9677
  // guard so a bug here can never strand the agent in waiting_for_tools.
8761
9678
  const err = error instanceof Error ? error : new Error(String(error));
@@ -8845,21 +9762,41 @@ export class AgentFramework {
8845
9762
  isError: true,
8846
9763
  };
8847
9764
  }
9765
+ // A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
9766
+ if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
9767
+ return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
9768
+ }
9769
+ const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
9770
+ const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
9771
+ const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
9772
+ const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
9773
+ const capNote = requestedMs !== undefined && requestedMs > ceilingMs
9774
+ ? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
9775
+ : undefined;
8848
9776
  const agent = this.agents.get(agentName);
8849
9777
  const surface = agent
8850
9778
  ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
8851
9779
  : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
8852
9780
  const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
8853
9781
  if (input.background === true) {
8854
- return this.startBackgroundScript(agentName, input.code, injected);
9782
+ const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
9783
+ if (capNote && started.success && started.data && typeof started.data === 'object') {
9784
+ started.data.time_limit_note = capNote;
9785
+ }
9786
+ return started;
8855
9787
  }
8856
9788
  const runner = this.getOrCreateScriptRunner(agentName);
8857
9789
  this.scriptDeferredEndTurn.delete(agentName);
8858
- const exec = await runner.exec(input.code, injected);
9790
+ const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
8859
9791
  const endTurn = this.scriptDeferredEndTurn.delete(agentName);
8860
9792
  return {
8861
9793
  success: true,
8862
- data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
9794
+ data: {
9795
+ stdout: exec.stdout,
9796
+ stderr: exec.stderr,
9797
+ return_code: exec.returnCode,
9798
+ ...(capNote ? { time_limit_note: capNote } : {}),
9799
+ },
8863
9800
  isError: false,
8864
9801
  ...(endTurn ? { endTurn: true } : {}),
8865
9802
  };
@@ -8875,7 +9812,7 @@ export class AgentFramework {
8875
9812
  * limitation is stated in the tool description so agents can calibrate
8876
9813
  * how much to rely on an armed wake.
8877
9814
  */
8878
- startBackgroundScript(agentName, code, injected) {
9815
+ startBackgroundScript(agentName, code, injected, timeLimitMs) {
8879
9816
  // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
8880
9817
  // would outlive its owner and its wake would land in the primary
8881
9818
  // conversation anyway — refuse loudly instead of surprising anyone.
@@ -8899,7 +9836,8 @@ export class AgentFramework {
8899
9836
  };
8900
9837
  }
8901
9838
  const scriptId = `bg-${++this.backgroundScriptCounter}`;
8902
- const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
9839
+ // The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
9840
+ const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
8903
9841
  // Journal: a file under the agent's first read-write workspace mount so
8904
9842
  // their existing read/grep/shell tools work on it. Python appends
8905
9843
  // directly to the materialized path; watch-mode mounts sync it back to
@@ -8960,6 +9898,7 @@ export class AgentFramework {
8960
9898
  script_id: scriptId,
8961
9899
  log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
8962
9900
  lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
9901
+ lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
8963
9902
  note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
8964
9903
  },
8965
9904
  };
@@ -9532,17 +10471,96 @@ export class AgentFramework {
9532
10471
  // Append one record per event — same rationale as logInference.
9533
10472
  this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
9534
10473
  }
10474
+ /** Forget one server's RFC-008 class declarations (see the close handler). */
10475
+ dropMcplToolClasses(serverId) {
10476
+ // Tolerate partially-constructed frameworks (tests build them with
10477
+ // Object.create): nothing cached means nothing to forget.
10478
+ if (!this.mcplToolClasses?.size)
10479
+ return;
10480
+ const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
10481
+ for (const name of [...this.mcplToolClasses.keys()]) {
10482
+ if (name.startsWith(`${prefix}--`))
10483
+ this.mcplToolClasses.delete(name);
10484
+ }
10485
+ }
9535
10486
  /**
9536
- * Find the MCPL server for a tool call by checking against the prefix map.
9537
- * Returns [serverId, prefix] if found, null otherwise.
10487
+ * RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
10488
+ * its effective class, and for MCPL tools the providing connection and
10489
+ * the server's own tool name. Host knowledge (built-in table, embedding
10490
+ * host's table) applies only to tools the host implements; an MCPL tool's
10491
+ * class comes from an operator override or its server's declaration.
10492
+ */
10493
+ describeToolForLifecycle(tool) {
10494
+ const mcpl = this.resolveMcplTool(tool);
10495
+ const { classes } = this.effectiveToolClass(tool, mcpl !== null);
10496
+ if (mcpl)
10497
+ return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
10498
+ return { class: classes };
10499
+ }
10500
+ /**
10501
+ * RFC-008 §5.1: a tool's effective class and the source that decided it.
10502
+ * Operator overrides first; then host knowledge, for tools the host
10503
+ * implements (never for MCPL tools); then an MCPL tool's own declaration.
10504
+ * Tolerates partially-constructed frameworks (tests use Object.create).
10505
+ */
10506
+ effectiveToolClass(tool, isMcpl) {
10507
+ return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
10508
+ overrides: this.toolClassOverrides ?? [],
10509
+ host: isMcpl ? [] : (this.hostToolClasses ?? []),
10510
+ });
10511
+ }
10512
+ /**
10513
+ * RFC-008 §6: tools with their effective class and where that class came
10514
+ * from (`override`, `host`, `server`, or `none` for an unclassed tool,
10515
+ * which policy treats as most restrictive). For operators: a surprising
10516
+ * class should be visible before it matters.
10517
+ *
10518
+ * With `agentName`, exactly the tools that agent is shown. Without, every
10519
+ * tool the framework offers to anyone: the shared board plus each agent's
10520
+ * own (the subconscious's surface, `prose_help`), each listed once.
10521
+ */
10522
+ listToolClasses(agentName) {
10523
+ let tools;
10524
+ if (agentName !== undefined) {
10525
+ const agent = this.agents.get(agentName);
10526
+ if (!agent)
10527
+ throw new Error(`Unknown agent: ${agentName}`);
10528
+ tools = this.agentToolSurface(agent);
10529
+ }
10530
+ else {
10531
+ tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
10532
+ }
10533
+ const seen = new Set();
10534
+ const listed = [];
10535
+ for (const t of tools) {
10536
+ if (seen.has(t.name))
10537
+ continue;
10538
+ seen.add(t.name);
10539
+ const mcpl = this.resolveMcplTool(t.name);
10540
+ const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
10541
+ listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
10542
+ }
10543
+ return listed;
10544
+ }
10545
+ /**
10546
+ * Find the MCPL server for a tool: [serverId, prefix], or null.
10547
+ * Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
10548
+ * both. The server whose tools/list produced the name decides; a name no
10549
+ * live server listed (stale, or invented by the model) goes to the longest
10550
+ * matching prefix. Never prefix-map insertion order.
9538
10551
  */
9539
10552
  resolveMcplTool(toolName) {
9540
- for (const [prefix, serverId] of this.mcplPrefixMap) {
9541
- if (toolName.startsWith(prefix + '--')) {
10553
+ const listedBy = this.mcplToolServers?.get(toolName);
10554
+ let best = null;
10555
+ for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
10556
+ if (!toolName.startsWith(prefix + '--'))
10557
+ continue;
10558
+ if (serverId === listedBy)
9542
10559
  return [serverId, prefix];
9543
- }
10560
+ if (!best || prefix.length > best[1].length)
10561
+ best = [serverId, prefix];
9544
10562
  }
9545
- return null;
10563
+ return best;
9546
10564
  }
9547
10565
  dispatchToolCall(agentName, call) {
9548
10566
  // Enrich call with caller identity so modules can resolve the calling agent
@@ -9712,6 +10730,8 @@ export class AgentFramework {
9712
10730
  })
9713
10731
  .catch((error) => {
9714
10732
  const err = error instanceof Error ? error : new Error(String(error));
10733
+ // RFC-007: the module threw — the host could not obtain a result.
10734
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
9715
10735
  this.emitTrace({
9716
10736
  type: 'tool:failed',
9717
10737
  module: moduleName,
@@ -9777,6 +10797,7 @@ export class AgentFramework {
9777
10797
  ? this.pendingAssistantBlocks.has(agent.name)
9778
10798
  : this.pendingAssistantBlocks.size > 0;
9779
10799
  if (!hasToolResult &&
10800
+ opts?.bypassDeferralFor !== agent.name &&
9780
10801
  (this.quiesced ||
9781
10802
  midToolCycle ||
9782
10803
  this.activeTurnTokens.has(agent.name) ||
@@ -9793,9 +10814,18 @@ export class AgentFramework {
9793
10814
  this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
9794
10815
  if (this.quiesced || this.deferredWritesPersisted)
9795
10816
  this.persistDeferredWrites();
10817
+ if (opts?.placement) {
10818
+ opts.placement.agent = agent.name;
10819
+ opts.placement.deferredId = id;
10820
+ }
9796
10821
  return ''; // Deferred — flushed at the target's next boundary
9797
10822
  }
9798
- return agent.getContextManager().addMessage(participant, content, opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata);
10823
+ const stored = agent.getContextManager().addMessage(participant, content, opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata);
10824
+ if (opts?.placement) {
10825
+ opts.placement.agent = agent.name;
10826
+ opts.placement.messageId = stored;
10827
+ }
10828
+ return stored;
9799
10829
  }
9800
10830
  /**
9801
10831
  * Splice out the deferred messages addressed to `agentName` (explicitly
@@ -10239,6 +11269,11 @@ export class AgentFramework {
10239
11269
  this.mcplServerRegistry = new McplServerRegistry();
10240
11270
  this.featureSetManager = new FeatureSetManager();
10241
11271
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
11272
+ this.toolLifecycleEmitter = new ToolLifecycleEmitter({
11273
+ observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
11274
+ configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
11275
+ describe: (tool) => this.describeToolForLifecycle(tool),
11276
+ });
10242
11277
  // Build prefix map and store configs for tool routing
10243
11278
  for (const config of serverConfigs) {
10244
11279
  const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
@@ -10250,7 +11285,8 @@ export class AgentFramework {
10250
11285
  const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
10251
11286
  ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
10252
11287
  // Push events handler (Step 6)
10253
- this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter);
11288
+ this.initializePushCoalescer();
11289
+ this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event));
10254
11290
  // Server-initiated inference router (Step 6)
10255
11291
  this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), (serverId, params) => {
10256
11292
  const server = this.mcplServerRegistry.getServer(serverId);
@@ -10261,6 +11297,7 @@ export class AgentFramework {
10261
11297
  // Channel registry (Step 7)
10262
11298
  this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
10263
11299
  store: this.store,
11300
+ handleCoalescedIncoming: (serverId, message, event) => this.handleCoalescedIncoming(serverId, message, event),
10264
11301
  sendTypingFn: (serverId, channelId, metadata, op) => {
10265
11302
  const server = this.mcplServerRegistry.getServer(serverId);
10266
11303
  if (server) {
@@ -10329,11 +11366,17 @@ export class AgentFramework {
10329
11366
  // implemented for years — are declared (AUDIT-001/issue #76 item 12).
10330
11367
  this.mcplHostCapabilities = {
10331
11368
  version: '0.5',
11369
+ // RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
11370
+ // channel-scoped pushes, one-hour retry window.
11371
+ eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
10332
11372
  pushEvents: true,
10333
11373
  contextHooks: {
10334
11374
  beforeInference: true,
10335
11375
  },
10336
11376
  inferenceLifecycle: true,
11377
+ // RFC-007: tools/lifecycle (metadata, and requested argument fields
11378
+ // under their own grant) plus the tools/observe filter.
11379
+ toolLifecycle: { observe: true, inputs: true },
10337
11380
  // modelInfo is NOT advertised: §12.2's result requires all four of
10338
11381
  // {id, vendor, contextWindow, capabilities} and the host has no
10339
11382
  // truthful source for contextWindow/capabilities (grep: none exists).
@@ -11066,17 +12109,28 @@ export class AgentFramework {
11066
12109
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
11067
12110
  const connection = this.mcplServerRegistry?.getServer(id) ?? null;
11068
12111
  const connected = connection?.isConnected ?? false;
12112
+ // Attribute through the dispatch resolver: a bare prefix match would
12113
+ // also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
12114
+ const ownTools = (this.mcplTools ?? []).flatMap((t) => {
12115
+ const hit = this.resolveMcplTool(t.name);
12116
+ return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
12117
+ });
11069
12118
  result.push({
11070
12119
  id,
11071
12120
  connected,
11072
12121
  retrying: !connected && (connection?.willReconnect ?? false),
11073
12122
  toolPrefix: prefix,
11074
- toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
12123
+ toolCount: ownTools.length,
11075
12124
  policyEstablished: connection?.policyEstablished ?? false,
11076
12125
  effectiveGrant: connection?.grant.effectiveList() ?? [],
11077
12126
  maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
11078
12127
  deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
11079
12128
  allowHostCommands: config.allowHostCommands === true,
12129
+ toolObserveFilter: connection?.toolObserveFilter ?? null,
12130
+ toolClasses: ownTools.map(({ tool, serverTool }) => {
12131
+ const { classes, source } = this.effectiveToolClass(tool, true);
12132
+ return { tool, serverTool, class: classes, source };
12133
+ }),
11080
12134
  manifestState: connection
11081
12135
  ? { ...connection.manifestState }
11082
12136
  : { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
@@ -11109,10 +12163,15 @@ export class AgentFramework {
11109
12163
  const barrier = this.discordAwarenessBarrier;
11110
12164
  if (barrier)
11111
12165
  await barrier.promise;
11112
- this.pushHandler?.handlePushEvent(connection.id, params, responder);
12166
+ await this.pushHandler?.handlePushEvent(connection.id, params, responder);
11113
12167
  });
11114
12168
  // Handle server-initiated inference requests (Step 6)
11115
12169
  connection.on('inference-request', async (params, responder) => {
12170
+ // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
12171
+ if (this.pushCoalescer?.isRendering(connection.id)) {
12172
+ responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
12173
+ return;
12174
+ }
11116
12175
  if (this.inferenceRouter && responder) {
11117
12176
  const barrier = this.discordAwarenessBarrier;
11118
12177
  if (barrier)
@@ -11150,6 +12209,21 @@ export class AgentFramework {
11150
12209
  channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
11151
12210
  });
11152
12211
  });
12212
+ // RFC-007 §6: the server's filter over tools/lifecycle. Admission already
12213
+ // required toolLifecycle.observe (-32002 otherwise). Validation failures
12214
+ // leave the previous filter in force (§6.6).
12215
+ connection.on('tools-observe', (params, responder) => {
12216
+ const parsed = parseToolObserveParams(params);
12217
+ if (!parsed.ok) {
12218
+ if (responder?.respondError)
12219
+ responder.respondError(-32602, parsed.message, parsed.data);
12220
+ else
12221
+ console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
12222
+ return;
12223
+ }
12224
+ connection.toolObserveFilter = parsed.rules;
12225
+ responder?.respond({});
12226
+ });
11153
12227
  connection.on('model-info', (_params, responder) => {
11154
12228
  // §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
11155
12229
  // in the DIRECT result shape, and numeric contextWindow cannot express
@@ -11164,7 +12238,7 @@ export class AgentFramework {
11164
12238
  const barrier = this.discordAwarenessBarrier;
11165
12239
  if (barrier)
11166
12240
  await barrier.promise;
11167
- this.channelRegistry?.handleIncoming(connection.id, params, responder);
12241
+ await this.channelRegistry?.handleIncoming(connection.id, params, responder);
11168
12242
  });
11169
12243
  // Handle host-level admin commands from a surface (e.g. Discord /undo)
11170
12244
  connection.on('host-command', async (params, responder) => {
@@ -11300,6 +12374,10 @@ export class AgentFramework {
11300
12374
  // disconnectMcplServer, which deletes the trees explicitly.
11301
12375
  connection.on('close', (code, signal) => {
11302
12376
  this.featureSetManager?.removeServer(connection.id);
12377
+ // RFC-008: a closed provider's class hints are stale — on reconnect it
12378
+ // may class its tools differently. Until the next tools/list its tools
12379
+ // are unclassed, the restrictive answer (RFC-007 §4.3).
12380
+ this.dropMcplToolClasses(connection.id);
11303
12381
  this.emitTrace({
11304
12382
  type: 'mcpl:server-closed',
11305
12383
  serverId: connection.id,
@@ -11319,6 +12397,8 @@ export class AgentFramework {
11319
12397
  return;
11320
12398
  const tools = [];
11321
12399
  const toolFeatureSets = new Map();
12400
+ const toolClasses = new Map();
12401
+ const toolServers = new Map();
11322
12402
  for (const server of this.mcplServerRegistry.getAllServers()) {
11323
12403
  const config = this.mcplServerConfigs.get(server.id);
11324
12404
  const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
@@ -11328,7 +12408,13 @@ export class AgentFramework {
11328
12408
  if (!isToolAllowed(tool.name, config))
11329
12409
  continue;
11330
12410
  const namespacedName = `${prefix}--${tool.name}`;
12411
+ toolServers.set(namespacedName, server.id);
11331
12412
  const attributedTool = tool;
12413
+ // RFC-008: the server's class hint for this tool. Never shown to
12414
+ // the model (the definition below is unchanged by it).
12415
+ const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
12416
+ if (declaredClasses)
12417
+ toolClasses.set(namespacedName, declaredClasses);
11332
12418
  const featureSet = typeof attributedTool.featureSet === 'string'
11333
12419
  ? attributedTool.featureSet
11334
12420
  : typeof attributedTool._meta?.featureSet === 'string'
@@ -11352,6 +12438,8 @@ export class AgentFramework {
11352
12438
  }
11353
12439
  this.mcplTools = tools;
11354
12440
  this.mcplToolFeatureSets = toolFeatureSets;
12441
+ this.mcplToolClasses = toolClasses;
12442
+ this.mcplToolServers = toolServers;
11355
12443
  }
11356
12444
  /**
11357
12445
  * Handle a tools/list_changed notification with collapse logic.
@@ -11409,6 +12497,8 @@ export class AgentFramework {
11409
12497
  const toolName = call.name.slice(prefix.length + 2); // strip "{prefix}--"
11410
12498
  const server = this.mcplServerRegistry.getServer(serverId);
11411
12499
  if (!server) {
12500
+ // RFC-007: refused before executing — no lifecycle events.
12501
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
11412
12502
  this.pushEvent({
11413
12503
  type: 'tool-result',
11414
12504
  callId: call.id,
@@ -11420,6 +12510,7 @@ export class AgentFramework {
11420
12510
  }
11421
12511
  const config = this.mcplServerConfigs.get(serverId);
11422
12512
  if (!isToolAllowed(toolName, config)) {
12513
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
11423
12514
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
11424
12515
  this.pushEvent({
11425
12516
  type: 'tool-result',
@@ -11574,6 +12665,8 @@ export class AgentFramework {
11574
12665
  .catch((error) => {
11575
12666
  const err = error instanceof Error ? error : new Error(String(error));
11576
12667
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
12668
+ // RFC-007: transport error, closed connection or timeout — no result.
12669
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
11577
12670
  this.pushEvent({
11578
12671
  type: 'tool-result',
11579
12672
  callId: call.id,
@@ -11598,6 +12691,8 @@ export class AgentFramework {
11598
12691
  const home = this.conversationAgentHomes.get(agentName);
11599
12692
  if (home) {
11600
12693
  const reject = (error) => {
12694
+ // RFC-007: the guard refuses before the tool runs — no lifecycle events.
12695
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
11601
12696
  this.emitTrace({
11602
12697
  type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
11603
12698
  error: `conversation agent ${agentName}: ${error}`,
@@ -11676,6 +12771,7 @@ export class AgentFramework {
11676
12771
  .catch((error) => {
11677
12772
  const err = error instanceof Error ? error : new Error(String(error));
11678
12773
  this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
12774
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
11679
12775
  this.pushEvent({
11680
12776
  type: 'tool-result',
11681
12777
  callId: call.id,
@@ -11894,7 +12990,8 @@ export class AgentFramework {
11894
12990
  if (announce && this.channelRegistry) {
11895
12991
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
11896
12992
  const agent = this.agents.get(agentName);
11897
- if (agent?.proseRouting === 'disabled') {
12993
+ const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
12994
+ if (silentTurn || agent?.proseRouting === 'disabled') {
11898
12995
  console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
11899
12996
  }
11900
12997
  else if (agent?.proseRouting === 'explicit') {
@@ -12669,6 +13766,7 @@ export class AgentFramework {
12669
13766
  catch (error) {
12670
13767
  const err = error instanceof Error ? error : new Error(String(error));
12671
13768
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
13769
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
12672
13770
  result = { success: false, error: err.message, isError: true };
12673
13771
  }
12674
13772
  this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
@@ -12691,6 +13789,7 @@ export class AgentFramework {
12691
13789
  .catch((error) => {
12692
13790
  const err = error instanceof Error ? error : new Error(String(error));
12693
13791
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
13792
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
12694
13793
  this.pushEvent({
12695
13794
  type: 'tool-result',
12696
13795
  callId: call.id,