@animalabs/agent-framework 0.19.0 → 0.20.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 (193) 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 +1169 -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/index.d.ts +3 -0
  33. package/dist/src/mcpl/index.d.ts.map +1 -1
  34. package/dist/src/mcpl/index.js +3 -0
  35. package/dist/src/mcpl/index.js.map +1 -1
  36. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  37. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  38. package/dist/src/mcpl/push-coalescer.js +709 -0
  39. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  40. package/dist/src/mcpl/push-handler.d.ts +7 -2
  41. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  42. package/dist/src/mcpl/push-handler.js +43 -4
  43. package/dist/src/mcpl/push-handler.js.map +1 -1
  44. package/dist/src/mcpl/server-connection.d.ts +18 -0
  45. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  46. package/dist/src/mcpl/server-connection.js +44 -1
  47. package/dist/src/mcpl/server-connection.js.map +1 -1
  48. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  49. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  50. package/dist/src/mcpl/tool-classes.js +170 -0
  51. package/dist/src/mcpl/tool-classes.js.map +1 -0
  52. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  53. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  54. package/dist/src/mcpl/tool-glob.js +41 -0
  55. package/dist/src/mcpl/tool-glob.js.map +1 -0
  56. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  57. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  58. package/dist/src/mcpl/tool-lifecycle.js +592 -0
  59. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  60. package/dist/src/mcpl/transport.d.ts +12 -0
  61. package/dist/src/mcpl/transport.d.ts.map +1 -1
  62. package/dist/src/mcpl/transport.js +33 -1
  63. package/dist/src/mcpl/transport.js.map +1 -1
  64. package/dist/src/mcpl/types.d.ts +66 -1
  65. package/dist/src/mcpl/types.d.ts.map +1 -1
  66. package/dist/src/mcpl/types.js +6 -0
  67. package/dist/src/mcpl/types.js.map +1 -1
  68. package/dist/src/modules/discord/index.js +1 -1
  69. package/dist/src/modules/discord/index.js.map +1 -1
  70. package/dist/src/modules/history/index.d.ts +85 -0
  71. package/dist/src/modules/history/index.d.ts.map +1 -1
  72. package/dist/src/modules/history/index.js +1019 -24
  73. package/dist/src/modules/history/index.js.map +1 -1
  74. package/dist/src/modules/history/semantic.d.ts +202 -0
  75. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  76. package/dist/src/modules/history/semantic.js +418 -0
  77. package/dist/src/modules/history/semantic.js.map +1 -0
  78. package/dist/src/modules/index.d.ts +2 -1
  79. package/dist/src/modules/index.d.ts.map +1 -1
  80. package/dist/src/modules/index.js.map +1 -1
  81. package/dist/src/tool-result-guard.d.ts +82 -0
  82. package/dist/src/tool-result-guard.d.ts.map +1 -0
  83. package/dist/src/tool-result-guard.js +306 -0
  84. package/dist/src/tool-result-guard.js.map +1 -0
  85. package/dist/src/types/agent.d.ts +14 -0
  86. package/dist/src/types/agent.d.ts.map +1 -1
  87. package/dist/src/types/framework.d.ts +21 -0
  88. package/dist/src/types/framework.d.ts.map +1 -1
  89. package/dist/src/types/trace.d.ts +17 -0
  90. package/dist/src/types/trace.d.ts.map +1 -1
  91. package/dist/test/channel-registry-routing.test.js +59 -0
  92. package/dist/test/channel-registry-routing.test.js.map +1 -1
  93. package/dist/test/code-execution.test.js +139 -0
  94. package/dist/test/code-execution.test.js.map +1 -1
  95. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  96. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  97. package/dist/test/helpers/coalescing-fixture.js +145 -0
  98. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  99. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  100. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  101. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  102. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  103. package/dist/test/history-module-realstore.test.d.ts +2 -0
  104. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  105. package/dist/test/history-module-realstore.test.js +363 -0
  106. package/dist/test/history-module-realstore.test.js.map +1 -0
  107. package/dist/test/history-module-semantic.test.d.ts +2 -0
  108. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  109. package/dist/test/history-module-semantic.test.js +213 -0
  110. package/dist/test/history-module-semantic.test.js.map +1 -0
  111. package/dist/test/history-module-ux.test.d.ts +2 -0
  112. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  113. package/dist/test/history-module-ux.test.js +304 -0
  114. package/dist/test/history-module-ux.test.js.map +1 -0
  115. package/dist/test/history-module.test.js +25 -1
  116. package/dist/test/history-module.test.js.map +1 -1
  117. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  118. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  119. package/dist/test/history-semantic-review-findings.test.js +338 -0
  120. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  121. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  122. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  123. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  124. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  125. package/dist/test/mcpl-capability-grant.test.js +3 -1
  126. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  127. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  128. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  129. package/dist/test/mcpl-child-env.test.js +69 -0
  130. package/dist/test/mcpl-child-env.test.js.map +1 -0
  131. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  132. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  133. package/dist/test/mcpl-coalescing.test.js +1050 -0
  134. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  135. package/dist/test/mcpl-list-status.test.js +4 -0
  136. package/dist/test/mcpl-list-status.test.js.map +1 -1
  137. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  138. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  139. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  140. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  141. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  142. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  143. package/dist/test/mcpl-tool-classes.test.js +80 -0
  144. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  145. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  146. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  147. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  148. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  149. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  150. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  151. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  152. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  153. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  154. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  155. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  156. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  157. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  158. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  159. package/dist/test/silent-heartbeat.test.js +154 -0
  160. package/dist/test/silent-heartbeat.test.js.map +1 -0
  161. package/dist/test/tool-result-guard.test.d.ts +2 -0
  162. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  163. package/dist/test/tool-result-guard.test.js +1081 -0
  164. package/dist/test/tool-result-guard.test.js.map +1 -0
  165. package/dist/test/trunk-channel-routing.test.js +67 -0
  166. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  167. package/dist/tsconfig.tsbuildinfo +1 -1
  168. package/package.json +2 -2
  169. package/src/agent.ts +128 -10
  170. package/src/api/types.ts +2 -0
  171. package/src/code-execution/py-runner.ts +34 -2
  172. package/src/code-execution/tool-definition.ts +46 -0
  173. package/src/framework.ts +1201 -80
  174. package/src/index.ts +13 -0
  175. package/src/mcpl/capability-grant.ts +54 -6
  176. package/src/mcpl/channel-registry.ts +115 -7
  177. package/src/mcpl/index.ts +29 -0
  178. package/src/mcpl/push-coalescer.ts +821 -0
  179. package/src/mcpl/push-handler.ts +40 -4
  180. package/src/mcpl/server-connection.ts +48 -2
  181. package/src/mcpl/tool-classes.ts +188 -0
  182. package/src/mcpl/tool-glob.ts +36 -0
  183. package/src/mcpl/tool-lifecycle.ts +730 -0
  184. package/src/mcpl/transport.ts +35 -1
  185. package/src/mcpl/types.ts +69 -1
  186. package/src/modules/discord/index.ts +1 -1
  187. package/src/modules/history/index.ts +1089 -24
  188. package/src/modules/history/semantic.ts +473 -0
  189. package/src/modules/index.ts +2 -1
  190. package/src/tool-result-guard.ts +318 -0
  191. package/src/types/agent.ts +15 -0
  192. package/src/types/framework.ts +23 -0
  193. 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,34 @@ 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
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
5941
+ if (event.triggerInference) {
5942
+ this.pendingRequests.push({
5943
+ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
5944
+ channelId: event.channelId,
5945
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
5946
+ addressed: isAddressedMessage(event.tags, event.metadata),
5947
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
5948
+ });
5949
+ }
5950
+ return { agent: target.name, messageId: id };
5951
+ }
5952
+ if (this.conversationRouter)
5953
+ return undefined; // the trunk never holds channel traffic
5954
+ event = { ...event, targetAgents: [target.name] };
5955
+ }
5956
+ else if (this.conversationRouter) {
5957
+ return this.routeConversationIncoming(event, metadata);
5762
5958
  }
5763
5959
  // Addressed-while-closed invitation — parity with the push-event path
5764
5960
  // (channels.publish surfaces like portal-mcpl deliver mentions here).
@@ -5796,7 +5992,8 @@ export class AgentFramework {
5796
5992
  if (divert) {
5797
5993
  metadata.tuneOut = { epochId: divert.epochId };
5798
5994
  }
5799
- const id = this.addMessage('user', incomingContent, metadata);
5995
+ const placement = { agent: '' };
5996
+ const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
5800
5997
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
5801
5998
  if (event.triggerInference && !divert) {
5802
5999
  const addressed = isAddressedMessage(event.tags, event.metadata);
@@ -5822,9 +6019,11 @@ export class AgentFramework {
5822
6019
  // Addressed messages outrank ambient chatter when a batched wake
5823
6020
  // picks the turn's frozen speech locus.
5824
6021
  addressed,
6022
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
5825
6023
  });
5826
6024
  }
5827
6025
  }
6026
+ return placement.agent ? placement : undefined;
5828
6027
  }
5829
6028
  /**
5830
6029
  * Route an incoming channel message through the ConversationRouter:
@@ -5905,8 +6104,10 @@ export class AgentFramework {
5905
6104
  channelId: event.channelId,
5906
6105
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
5907
6106
  addressed: isAddressedMessage(event.tags, event.metadata),
6107
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
5908
6108
  });
5909
6109
  }
6110
+ return { agent: agent.name, messageId: id };
5910
6111
  }
5911
6112
  /**
5912
6113
  * Spawn a persistent per-channel conversation agent ("fork") from the
@@ -5955,6 +6156,8 @@ export class AgentFramework {
5955
6156
  }
5956
6157
  const config = { ...templateConfig, name, strategy: undefined };
5957
6158
  const agent = new Agent(config, contextManager, this.membrane);
6159
+ this.restoreToolResultGuardSetting(agent);
6160
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
5958
6161
  this.agents.set(name, agent);
5959
6162
  this.agentConfigs.set(name, config);
5960
6163
  this.conversationAgentHomes.set(name, channelId);
@@ -6112,6 +6315,19 @@ export class AgentFramework {
6112
6315
  const label = opts.channelLabel ?? descriptor?.label;
6113
6316
  const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
6114
6317
  const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
6318
+ // The label is for reading; the prefix must be ONE token that resolves back
6319
+ // here, on this server (a DM label "DM: alice" quoted verbatim parses as
6320
+ // target "#DM:"). A registry that can name targets is authoritative: no safe
6321
+ // token means no prefix option. A stand-in without proseTargetFor gets a
6322
+ // whitespace-free guess, never a label with spaces.
6323
+ const namer = this.channelRegistry;
6324
+ const target = typeof namer.proseTargetFor === 'function'
6325
+ ? namer.proseTargetFor(opts.channelId, opts.serverId)
6326
+ : [label ? `#${label}` : undefined, opts.channelId].find((t) => !!t && !/\s/.test(t));
6327
+ const replyOption = target
6328
+ ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
6329
+ `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
6330
+ : `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
6115
6331
  const missedCount = opts.missedMessages ?? 0;
6116
6332
  const missedNote = missedCount > 0
6117
6333
  ? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
@@ -6125,8 +6341,7 @@ export class AgentFramework {
6125
6341
  `a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
6126
6342
  missedNote +
6127
6343
  `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` +
6344
+ replyOption +
6130
6345
  `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
6131
6346
  (maxBackscroll > 0
6132
6347
  ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
@@ -6145,6 +6360,484 @@ export class AgentFramework {
6145
6360
  },
6146
6361
  };
6147
6362
  }
6363
+ // ==========================================================================
6364
+ // RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
6365
+ // ==========================================================================
6366
+ /**
6367
+ * RFC-006 §3.2 binding identity: the configured server id bound to its
6368
+ * endpoint/command. Survives reconnects to the same peer; changes when the
6369
+ * id is reassigned to another endpoint (restartMcplServer with a new config).
6370
+ */
6371
+ coalescingBinding(serverId) {
6372
+ const config = this.mcplServerConfigs.get(serverId);
6373
+ return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
6374
+ }
6375
+ initializePushCoalescer() {
6376
+ const coalescer = new PushCoalescer({
6377
+ isUnread: (p) => this.isUnreadPlacement(p),
6378
+ remove: (p) => this.removePlacement(p),
6379
+ deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
6380
+ wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
6381
+ cancelWake: (subject) => this.cancelCoalescedWakes(subject),
6382
+ authorized: (occ) => this.coalescedAuthorized(occ),
6383
+ audience: (occ) => this.coalescedAudience(occ),
6384
+ render: (occ, params) => {
6385
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
6386
+ if (!server?.isConnected)
6387
+ throw new Error('server disconnected');
6388
+ return server.sendPushRender(params);
6389
+ },
6390
+ audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
6391
+ save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
6392
+ recordReceipt: (record) => {
6393
+ // Written now, not at the throttled flush: an acknowledged acceptance
6394
+ // must not lose its receipt, the subject's birth, or (deferred) the
6395
+ // accepted work itself to a crash. A failure here fails the acceptance.
6396
+ this.coalescingRecentReceipts.push(record);
6397
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
6398
+ },
6399
+ wasPublished: (subject, eventId) => {
6400
+ // Boot-time only: the occurrence's durable delivery identity
6401
+ // (subject + eventId in message metadata) in any agent's context.
6402
+ for (const agent of this.agents.values()) {
6403
+ try {
6404
+ if (agent.getContextManager().getAllMessages().some((m) => m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId))
6405
+ return true;
6406
+ }
6407
+ catch { /* a context that cannot be read cannot prove publication */ }
6408
+ }
6409
+ return false;
6410
+ },
6411
+ commit: () => this.commitCoalescingDurable(),
6412
+ saveNow: (snapshot) => {
6413
+ if (this.coalescingSaveTimer) {
6414
+ clearTimeout(this.coalescingSaveTimer);
6415
+ this.coalescingSaveTimer = null;
6416
+ }
6417
+ this.coalescingSnapshotDirty = null;
6418
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot);
6419
+ this.coalescingRecentReceipts = [];
6420
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
6421
+ },
6422
+ });
6423
+ try {
6424
+ coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID));
6425
+ const recent = this.store.getStateJson(COALESCING_RECENT_ID);
6426
+ if (Array.isArray(recent)) {
6427
+ coalescer.restoreReceipts(recent);
6428
+ // Until the next snapshot flush checkpoints the recovered state, these
6429
+ // records are its only durable form: keep them in the writable buffer
6430
+ // so the next acceptance appends to them instead of replacing them.
6431
+ this.coalescingRecentReceipts = recent;
6432
+ }
6433
+ }
6434
+ catch (err) {
6435
+ console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
6436
+ }
6437
+ this.pushCoalescer = coalescer;
6438
+ // Batches restored from a snapshot still owe the agent a wake (§14 v41).
6439
+ for (const occ of coalescer.pendingBatchOccurrences())
6440
+ void this.wakeForCoalescedBatch(occ);
6441
+ }
6442
+ /**
6443
+ * Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
6444
+ * written so far is fsynced before the caller proceeds. Group-committed —
6445
+ * all acceptances that arrive in one event-loop turn share one
6446
+ * `store.sync()` — so throughput is bounded by fsync rate per batch, not
6447
+ * per message.
6448
+ */
6449
+ commitCoalescingDurable() {
6450
+ if (!this.coalescingCommit) {
6451
+ this.coalescingCommit = new Promise((resolve, reject) => {
6452
+ setImmediate(() => {
6453
+ this.coalescingCommit = null;
6454
+ try {
6455
+ this.store.sync();
6456
+ resolve();
6457
+ }
6458
+ catch (err) {
6459
+ reject(err);
6460
+ }
6461
+ });
6462
+ });
6463
+ }
6464
+ return this.coalescingCommit;
6465
+ }
6466
+ scheduleCoalescingSnapshot(snapshot) {
6467
+ this.coalescingSnapshotDirty = snapshot;
6468
+ if (this.coalescingSaveTimer)
6469
+ return;
6470
+ const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
6471
+ timer.unref?.();
6472
+ this.coalescingSaveTimer = timer;
6473
+ }
6474
+ flushCoalescingSnapshot() {
6475
+ if (this.coalescingSaveTimer) {
6476
+ clearTimeout(this.coalescingSaveTimer);
6477
+ this.coalescingSaveTimer = null;
6478
+ }
6479
+ const snapshot = this.coalescingSnapshotDirty;
6480
+ this.coalescingSnapshotDirty = null;
6481
+ if (!snapshot)
6482
+ return;
6483
+ try {
6484
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot());
6485
+ this.coalescingRecentReceipts = [];
6486
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
6487
+ }
6488
+ catch (err) {
6489
+ console.error('[coalescing] could not persist coalescing state:', err);
6490
+ }
6491
+ }
6492
+ /** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
6493
+ admitCoalesced(work) {
6494
+ const run = this.coalescingAdmission.then(work, work);
6495
+ this.coalescingAdmission = run.then(() => undefined, () => undefined);
6496
+ return run;
6497
+ }
6498
+ handleCoalescedIncoming(serverId, message, event) {
6499
+ return this.admitCoalesced(async () => {
6500
+ if (!this.pushCoalescer)
6501
+ throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
6502
+ validateCoalesceMember(message.coalesce, 'channel');
6503
+ if (typeof message.eventId !== 'string' || !message.eventId) {
6504
+ throw new CoalesceError('eventId', 'eventId is required with coalesce');
6505
+ }
6506
+ if (typeof message.timestamp !== 'string')
6507
+ throw new CoalesceError('timestamp', 'timestamp is required');
6508
+ validateCoalescedContent(message.content);
6509
+ const c = message.coalesce;
6510
+ const result = await this.pushCoalescer.accept({
6511
+ serverId,
6512
+ binding: this.coalescingBinding(serverId),
6513
+ scope: { kind: 'channel', id: message.channelId },
6514
+ key: c.key,
6515
+ eventId: message.eventId,
6516
+ timestamp: message.timestamp,
6517
+ retract: !!c.retract,
6518
+ deferred: false,
6519
+ initial: !!c.initial,
6520
+ tags: event.tags,
6521
+ content: message.content,
6522
+ identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
6523
+ event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
6524
+ });
6525
+ return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
6526
+ });
6527
+ }
6528
+ handleCoalescedPush(serverId, params, event) {
6529
+ return this.admitCoalesced(async () => {
6530
+ if (!this.pushCoalescer)
6531
+ throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
6532
+ validateCoalesceMember(params.coalesce, 'push');
6533
+ if (typeof params.eventId !== 'string' || !params.eventId)
6534
+ throw new CoalesceError('eventId', 'eventId is required');
6535
+ if (typeof params.timestamp !== 'string')
6536
+ throw new CoalesceError('timestamp', 'timestamp is required');
6537
+ validateCoalescedContent(params.payload?.content);
6538
+ const c = params.coalesce;
6539
+ if (c.channelId)
6540
+ this.assertChannelScopedPush(serverId, c.channelId);
6541
+ const origin = params.origin ?? {};
6542
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
6543
+ return this.pushCoalescer.accept({
6544
+ serverId,
6545
+ binding: this.coalescingBinding(serverId),
6546
+ featureSet: params.featureSet,
6547
+ scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
6548
+ key: c.key,
6549
+ eventId: params.eventId,
6550
+ timestamp: params.timestamp,
6551
+ retract: !!c.retract,
6552
+ deferred: !!c.deferred,
6553
+ initial: !!c.initial,
6554
+ data: c.data,
6555
+ tags: event.tags,
6556
+ content: params.payload.content,
6557
+ ...(c.channelId ? {
6558
+ identity: {
6559
+ ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
6560
+ ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
6561
+ ...(str(origin.authorId) ? { author: { id: str(origin.authorId), name: str(origin.authorName) ?? str(origin.authorId) } } : {}),
6562
+ },
6563
+ } : {}),
6564
+ event: { lane: 'push', event },
6565
+ });
6566
+ });
6567
+ }
6568
+ /** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
6569
+ assertChannelScopedPush(serverId, channelId) {
6570
+ const server = this.mcplServerRegistry?.getServer(serverId);
6571
+ if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
6572
+ throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
6573
+ }
6574
+ if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
6575
+ throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
6576
+ }
6577
+ }
6578
+ coalescedAuthorized(occ) {
6579
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
6580
+ if (!server?.isConnected || !server.policyEstablished)
6581
+ return false;
6582
+ // §3.2: work admitted under another binding (the id since reassigned to a
6583
+ // different endpoint) never acquires the replacement peer's authority —
6584
+ // and its private notice data is never sent to that peer.
6585
+ if (occ.binding !== this.coalescingBinding(occ.serverId))
6586
+ return false;
6587
+ if (occ.event.lane === 'push') {
6588
+ if (!server.grant.has('pushEvents'))
6589
+ return false;
6590
+ try {
6591
+ this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet);
6592
+ }
6593
+ catch {
6594
+ return false;
6595
+ }
6596
+ }
6597
+ if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
6598
+ // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
6599
+ if (!server.grant.has('channels.incoming'))
6600
+ return false;
6601
+ if (occ.event.lane === 'push')
6602
+ return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
6603
+ return !!this.channelRegistry?.getDescriptor(occ.scope.id);
6604
+ }
6605
+ return true;
6606
+ }
6607
+ async coalescedAudience(occ) {
6608
+ if (occ.deliverTo)
6609
+ return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
6610
+ const explicit = occ.event.event.targetAgents;
6611
+ if (explicit)
6612
+ return explicit.filter((n) => this.agents.has(n));
6613
+ if (occ.scope.kind === 'channel' && this.conversationRouter) {
6614
+ const router = this.conversationRouter;
6615
+ const bound = router.getBinding(occ.scope.id)?.agentName;
6616
+ if (bound)
6617
+ return this.agents.has(bound) ? [bound] : [];
6618
+ // No fork yet: the same bind decision an incoming message would get. A
6619
+ // batch that qualifies spawns its fork now so it has someone to wake.
6620
+ const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
6621
+ const decision = router.route({
6622
+ channelId: occ.scope.id,
6623
+ mentioned: metadata.mentioned === true,
6624
+ kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
6625
+ });
6626
+ if (decision.kind !== 'spawn')
6627
+ return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
6628
+ try {
6629
+ await this.createConversationAgent(decision.agentName, occ.scope.id);
6630
+ router.bind(occ.scope.id, decision.agentName, decision.generation);
6631
+ this.persistConversationRouterState();
6632
+ return [decision.agentName];
6633
+ }
6634
+ catch (err) {
6635
+ console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
6636
+ return [];
6637
+ }
6638
+ }
6639
+ return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
6640
+ }
6641
+ /** Deliver through the lane's ordinary path; report where it landed. */
6642
+ async deliverCoalesced(occ, materialized, assemblingFor) {
6643
+ const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
6644
+ const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
6645
+ if (occ.event.lane === 'channel') {
6646
+ const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
6647
+ const placement = await this.handleMcplChannelIncoming(event);
6648
+ await this.dispatchProcessEventToModules(event);
6649
+ return placement;
6650
+ }
6651
+ if (occ.scope.kind === 'channel') {
6652
+ // §3.2: a channel-scoped push addresses the channel's subject namespace,
6653
+ // so it is delivered as a message OF that channel — same routing
6654
+ // (conversation forks, tune-out, closed-channel invitation), same reply
6655
+ // locus, and the subject's stable platform identity (§3.1, vector 43).
6656
+ const push = occ.event.event;
6657
+ const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
6658
+ const origin = push.origin ?? {};
6659
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
6660
+ const event = {
6661
+ type: 'mcpl:channel-incoming',
6662
+ serverId: occ.serverId,
6663
+ channelId: occ.scope.id,
6664
+ messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
6665
+ threadId: identity.threadId ?? str(origin.threadId),
6666
+ author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
6667
+ content: materialized ? materialized.map(convertPushBlock) : push.content,
6668
+ timestamp: occ.timestamp,
6669
+ metadata: origin,
6670
+ tags: push.tags,
6671
+ triggerInference: assemblingFor ? false : push.triggerInference,
6672
+ targetAgents: push.targetAgents,
6673
+ coalescingSubject,
6674
+ eventId: occ.eventId,
6675
+ ...narrowing,
6676
+ ...(assemblingFor ? { assemblingFor } : {}),
6677
+ };
6678
+ const placement = await this.handleMcplChannelIncoming(event);
6679
+ await this.dispatchProcessEventToModules(event);
6680
+ return placement;
6681
+ }
6682
+ const event = {
6683
+ ...occ.event.event,
6684
+ eventId: occ.eventId,
6685
+ timestamp: occ.timestamp,
6686
+ ...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
6687
+ coalescingSubject,
6688
+ // §5.2: the materialized occurrence is consumed by the request being
6689
+ // assembled; the wake that started it is not repeated.
6690
+ ...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
6691
+ ...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
6692
+ };
6693
+ const placement = this.handleMcplPushEvent(event);
6694
+ await this.dispatchProcessEventToModules(event);
6695
+ return placement;
6696
+ }
6697
+ async wakeForCoalescedBatch(occ) {
6698
+ const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
6699
+ this.cancelCoalescedWakes(subject);
6700
+ if (occ.event.lane !== 'push' || !occ.event.event.triggerInference)
6701
+ return;
6702
+ const origin = occ.event.event.origin;
6703
+ // The wake carries the batch's channel so the turn freezes its reply
6704
+ // locus there (the materialized delivery at assembly wakes nobody).
6705
+ const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
6706
+ const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
6707
+ for (const agentName of await this.coalescedAudience(occ)) {
6708
+ this.pendingRequests.push({
6709
+ agentName,
6710
+ reason: 'mcpl:push-event',
6711
+ source: occ.serverId,
6712
+ timestamp: Date.now(),
6713
+ channelId,
6714
+ counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
6715
+ addressed: isAddressedMessage(occ.tags, origin),
6716
+ coalescingSubject: subject,
6717
+ });
6718
+ }
6719
+ }
6720
+ /** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
6721
+ cancelCoalescedWakes(subject) {
6722
+ this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
6723
+ for (const cooldown of this.providerAccelerationCooldowns.values()) {
6724
+ cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
6725
+ }
6726
+ }
6727
+ /**
6728
+ * A placement in the deferred queue is upgraded in place once the flush has
6729
+ * stored it: the stored copy carries `metadata.deferredWriteId`, found by
6730
+ * scanning the unread tail (above the watermark) — never below it.
6731
+ */
6732
+ resolvePlacement(p) {
6733
+ if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId))
6734
+ return;
6735
+ const agent = this.agents.get(p.agent);
6736
+ if (!agent)
6737
+ return;
6738
+ const cm = agent.getContextManager();
6739
+ const watermark = agent.getConsumedWatermark();
6740
+ const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
6741
+ const all = cm.getAllMessages();
6742
+ for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
6743
+ if (all[i].metadata?.deferredWriteId === p.deferredId) {
6744
+ p.messageId = all[i].id;
6745
+ delete p.deferredId;
6746
+ return;
6747
+ }
6748
+ }
6749
+ }
6750
+ isUnreadPlacement(p) {
6751
+ this.resolvePlacement(p);
6752
+ if (p.deferredId)
6753
+ return this.deferredMessages.some((m) => m.id === p.deferredId);
6754
+ if (!p.messageId)
6755
+ return false;
6756
+ const agent = this.agents.get(p.agent);
6757
+ return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
6758
+ }
6759
+ /**
6760
+ * Unread = stored, above the agent's consumed watermark on the current
6761
+ * branch, not a body-group shard (immutable), and not folded into any
6762
+ * summary (compression is consumption, §3.3). Conservative on every doubt.
6763
+ */
6764
+ isUnreadStoredMessage(agent, messageId) {
6765
+ const cm = agent.getContextManager();
6766
+ let message;
6767
+ try {
6768
+ message = cm.getMessage(messageId);
6769
+ }
6770
+ catch {
6771
+ return false;
6772
+ }
6773
+ if (!message || message.bodyGroupId)
6774
+ return false;
6775
+ // Every agent that can read this message must not have compiled past it.
6776
+ // Residents share one message slot, so a message one of them read is
6777
+ // history for all of them (the never-rewrite rule is about the message,
6778
+ // not the agent that happened to be asked). A tune-out-diverted message
6779
+ // never enters a resident's compiled view; only the subconscious reads
6780
+ // it through its merged view — no subconscious, nobody reads it.
6781
+ let readers;
6782
+ if (message.metadata?.tuneOut) {
6783
+ const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
6784
+ if (!subconscious)
6785
+ return true;
6786
+ readers = [subconscious];
6787
+ }
6788
+ else {
6789
+ readers = this.sharedSlotAgents.has(agent)
6790
+ ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
6791
+ : [agent];
6792
+ }
6793
+ const branch = cm.currentBranch().name;
6794
+ const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
6795
+ for (const reader of readers) {
6796
+ const watermark = reader.getConsumedWatermark();
6797
+ if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence)
6798
+ return false;
6799
+ // Compression is consumption (§3.3): folded into any reader's summary.
6800
+ try {
6801
+ const rcm = reader.getContextManager();
6802
+ const strategy = rcm.getStrategy();
6803
+ for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
6804
+ const entry = strategy.getSummary?.(summary.id);
6805
+ if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId))
6806
+ return false;
6807
+ }
6808
+ }
6809
+ catch {
6810
+ return false;
6811
+ }
6812
+ }
6813
+ return true;
6814
+ }
6815
+ removePlacement(p) {
6816
+ this.resolvePlacement(p);
6817
+ if (p.deferredId) {
6818
+ const before = this.deferredMessages.length;
6819
+ this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
6820
+ if (this.deferredMessages.length === before)
6821
+ return false;
6822
+ if (this.quiesced || this.deferredWritesPersisted)
6823
+ this.persistDeferredWrites();
6824
+ return true;
6825
+ }
6826
+ if (!p.messageId)
6827
+ return false;
6828
+ const agent = this.agents.get(p.agent);
6829
+ if (!agent)
6830
+ return false;
6831
+ try {
6832
+ agent.getContextManager().removeMessage(p.messageId);
6833
+ this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
6834
+ return true;
6835
+ }
6836
+ catch (err) {
6837
+ console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
6838
+ return false;
6839
+ }
6840
+ }
6148
6841
  /**
6149
6842
  * Convert an MCPL push event to a context message.
6150
6843
  */
@@ -6160,6 +6853,7 @@ export class AgentFramework {
6160
6853
  eventId: event.eventId,
6161
6854
  triggered: event.triggerInference ?? false,
6162
6855
  ...(event.tags ? { tags: event.tags } : {}),
6856
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6163
6857
  };
6164
6858
  // `origin.channelId` is the server-internal raw id (a bare Discord
6165
6859
  // snowflake) — unroutable as a locus and unresolvable by the agent. Store
@@ -6189,8 +6883,23 @@ export class AgentFramework {
6189
6883
  Object.assign(metadata, invitation.metadataPatch);
6190
6884
  }
6191
6885
  }
6192
- const id = this.addMessage('user', content, metadata);
6193
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
6886
+ // Silent heartbeat ticks are control-plane wakes, not autobiography.
6887
+ // Accept the no-message path only for the heartbeat feature's own exact
6888
+ // marker with an empty payload — arbitrary MCPL servers cannot hide
6889
+ // content merely by setting `origin.silent`.
6890
+ const silentHeartbeat = event.serverId === 'heartbeat' &&
6891
+ event.featureSet === 'heartbeat' &&
6892
+ event.origin?.source === 'heartbeat' &&
6893
+ event.origin?.silent === true &&
6894
+ content.length === 0;
6895
+ const placement = { agent: '' };
6896
+ if (!silentHeartbeat) {
6897
+ const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
6898
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
6899
+ }
6900
+ else {
6901
+ console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
6902
+ }
6194
6903
  if (event.triggerInference) {
6195
6904
  // Default broadcast excludes conversation forks (channel-driven).
6196
6905
  const targetAgents = event.targetAgents
@@ -6206,9 +6915,16 @@ export class AgentFramework {
6206
6915
  // they must outrank ambient chatter in a batched wake's locus
6207
6916
  // selection just like their channels/incoming counterparts.
6208
6917
  addressed: isAddressedMessage(event.tags, event.origin),
6918
+ ...(silentHeartbeat ? {
6919
+ suppressProse: true,
6920
+ ephemeralSystemPrompt: '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
6921
+ 'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
6922
+ } : {}),
6923
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6209
6924
  });
6210
6925
  }
6211
6926
  }
6927
+ return placement.agent ? placement : undefined;
6212
6928
  }
6213
6929
  /**
6214
6930
  * Derive the MCPL composite channel id (the outbound routing locus) for a
@@ -6353,7 +7069,9 @@ export class AgentFramework {
6353
7069
  // route the resumed turn into the wrong channel.
6354
7070
  const newestByKey = new Map();
6355
7071
  for (const request of parked) {
6356
- const key = `${request.reason}|${request.addressed ? 'a' : 'n'}`;
7072
+ // A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
7073
+ // must not absorb an unrelated wake that would vanish with it.
7074
+ const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
6357
7075
  const prev = newestByKey.get(key);
6358
7076
  if (!prev || request.timestamp >= prev.timestamp) {
6359
7077
  newestByKey.set(key, request);
@@ -6479,8 +7197,13 @@ export class AgentFramework {
6479
7197
  provReq = r;
6480
7198
  }
6481
7199
  }
7200
+ // A silent tick never suppresses a genuine coalesced wake. If any
7201
+ // ordinary request shares this batch, the ordinary turn semantics win.
7202
+ const silentOnly = requests.every((r) => r.suppressProse === true);
6482
7203
  await this.startAgentStream(agent, {
6483
7204
  ...trigger,
7205
+ suppressProse: silentOnly ? trigger?.suppressProse : undefined,
7206
+ ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
6484
7207
  channelId: channelReq?.channelId,
6485
7208
  addressed: addressedReq !== undefined,
6486
7209
  // A context-budget restart continues the same logical turn: it keeps
@@ -7008,6 +7731,9 @@ export class AgentFramework {
7008
7731
  * driveStream (whose finally then owns clearing it).
7009
7732
  */
7010
7733
  async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
7734
+ const continuingTurn = trigger?.reason === 'context_budget_restart'
7735
+ || trigger?.reason === 'tool_result_guard_retry';
7736
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
7011
7737
  // Flush messages deferred during the PREVIOUS turn — before the
7012
7738
  // checkpoint, the locus announcement, and the compile — so a turn started
7013
7739
  // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
@@ -7023,7 +7749,7 @@ export class AgentFramework {
7023
7749
  // Before recordTurnCheckpoint: these are the turn's inputs, not its
7024
7750
  // products — undoing the turn must not destroy them.
7025
7751
  if (attempt === 0 &&
7026
- trigger?.reason !== 'context_budget_restart' &&
7752
+ !continuingTurn &&
7027
7753
  this.deferredMessages.length > 0) {
7028
7754
  {
7029
7755
  const deferred = this.drainDeferredFor(agent.name);
@@ -7043,8 +7769,20 @@ export class AgentFramework {
7043
7769
  this.ackDeferredWrites();
7044
7770
  }
7045
7771
  }
7772
+ // RFC-006 §5: deferred batches for this agent render now, at the assembly
7773
+ // boundary — after the deferred flush (same window: turn alive, compile
7774
+ // not yet run) and before the checkpoint (they are the turn's inputs).
7775
+ // Not on a context-budget restart: that continues the same logical turn.
7776
+ if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
7777
+ try {
7778
+ await this.pushCoalescer.assemble(agent.name);
7779
+ }
7780
+ catch (err) {
7781
+ console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
7782
+ }
7783
+ }
7046
7784
  // Record turn checkpoint before inference (only on first attempt, not retries)
7047
- if (attempt === 0) {
7785
+ if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
7048
7786
  this.recordTurnCheckpoint(agent.name);
7049
7787
  this.redoStacks.delete(agent.name); // new work invalidates redo
7050
7788
  }
@@ -7061,7 +7799,7 @@ export class AgentFramework {
7061
7799
  // after the restart fell through to the hours-old defaultPublishChannel.
7062
7800
  // Keep the turn's trigger channel across restarts; only real new turns
7063
7801
  // reset it.
7064
- if (trigger?.reason !== 'context_budget_restart') {
7802
+ if (!continuingTurn) {
7065
7803
  if (trigger?.channelId) {
7066
7804
  this.activeTriggerChannels.set(agent.name, trigger.channelId);
7067
7805
  }
@@ -7081,11 +7819,11 @@ export class AgentFramework {
7081
7819
  // BEFORE this turn compiles, so the agent always knows where its voice
7082
7820
  // goes (announce-on-change only — no per-turn chatter, append-only for
7083
7821
  // KV stability).
7084
- if (trigger?.reason === 'context_budget_restart') {
7822
+ if (continuingTurn) {
7085
7823
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
7086
7824
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
7087
7825
  }
7088
- if (trigger?.reason !== 'context_budget_restart') {
7826
+ if (!continuingTurn) {
7089
7827
  if (attempt === 0)
7090
7828
  this.maybePrimeProseMode(agent);
7091
7829
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
@@ -7104,9 +7842,9 @@ export class AgentFramework {
7104
7842
  this.turnProseDeliveries.delete(agent.name);
7105
7843
  this.turnProseSuppressed.delete(agent.name);
7106
7844
  this.proseHybridSuppressed.delete(agent.name);
7107
- if (agent.proseRouting === 'hybrid')
7845
+ if (turnProseRouting === 'hybrid')
7108
7846
  this.proseTargetPins.delete(agent.name);
7109
- if (agent.proseRouting === 'explicit' || agent.proseRouting === 'disabled') {
7847
+ if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
7110
7848
  // Explicit and disabled prose routing have no inferred locus.
7111
7849
  // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
7112
7850
  // prose target at all. Neither mode freezes or announces a locus.
@@ -7126,6 +7864,14 @@ export class AgentFramework {
7126
7864
  this.announceLocusIfChanged(agent.name, locus);
7127
7865
  }
7128
7866
  }
7867
+ // A context-budget restart normally preserves the turn locus, but a
7868
+ // silent control-plane wake must remain locusless even if an explicit tool
7869
+ // opened/pinned a channel mid-turn.
7870
+ if (trigger?.suppressProse) {
7871
+ this.turnLocusPins.delete(agent.name);
7872
+ this.proseTargetPins.delete(agent.name);
7873
+ this.proseContinuations.delete(agent.name);
7874
+ }
7129
7875
  this.touchEphemeralRun(agent.name, true);
7130
7876
  this.emitTrace({
7131
7877
  type: 'inference:started',
@@ -7133,10 +7879,11 @@ export class AgentFramework {
7133
7879
  // The turn-frozen locus was pinned just above (kept across a
7134
7880
  // context-budget restart, which skips the re-pin but re-emits this).
7135
7881
  channelId: this.turnLocusPins.get(agent.name),
7882
+ ...(trigger?.suppressProse ? { silent: true } : {}),
7136
7883
  });
7137
7884
  // A budget restart continues the same logical inference window. The
7138
7885
  // predecessor keeps EventGate liveness until its successor terminates.
7139
- if (trigger?.reason !== 'context_budget_restart') {
7886
+ if (!continuingTurn) {
7140
7887
  this.eventGate?.onInferenceStarted(agent.name);
7141
7888
  }
7142
7889
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
@@ -7149,19 +7896,14 @@ export class AgentFramework {
7149
7896
  // Same channel choice as driveStream's own startTyping (which stays, is
7150
7897
  // idempotent per channel, and owns the 7s refresh); the catch below stops
7151
7898
  // it on the no-driveStream failure paths (e.g. a compile refusal).
7152
- const earlyTypingChannel = agent.proseRouting === 'explicit'
7899
+ const earlyTypingChannel = turnProseRouting === 'explicit'
7153
7900
  ? trigger?.channelId ?? null
7154
7901
  : this.turnLocusPins.get(agent.name) ?? null;
7155
7902
  if (earlyTypingChannel)
7156
7903
  this.channelRegistry?.startTyping(earlyTypingChannel);
7157
7904
  try {
7158
7905
  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);
7906
+ const tools = this.agentToolSurface(agent, requestSnapshot);
7165
7907
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
7166
7908
  // Both produce ContextInjection[] that get merged before inference.
7167
7909
  let injections;
@@ -7192,6 +7934,14 @@ export class AgentFramework {
7192
7934
  console.error('beforeInference hook error:', error);
7193
7935
  }
7194
7936
  }
7937
+ if (trigger?.ephemeralSystemPrompt) {
7938
+ const silentInjection = {
7939
+ namespace: 'framework:silent-heartbeat',
7940
+ position: 'system',
7941
+ content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
7942
+ };
7943
+ injections = injections ? [...injections, silentInjection] : [silentInjection];
7944
+ }
7195
7945
  // An ephemeral watchdog may dispose this Agent while hooks/compile await.
7196
7946
  // Do not create a provider stream after its generation lost ownership.
7197
7947
  if (this.agents.get(agent.name) !== agent) {
@@ -7291,6 +8041,7 @@ export class AgentFramework {
7291
8041
  }
7292
8042
  }
7293
8043
  async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
8044
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
7294
8045
  const startTime = Date.now();
7295
8046
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
7296
8047
  const myStreamId = agent.streamId;
@@ -7344,7 +8095,10 @@ export class AgentFramework {
7344
8095
  // Sticky explicit-send suppression: prose after send_message stays quiet
7345
8096
  // to prevent a redundant "sent it" postscript. Fresh injected input
7346
8097
  // clears it, because the following prose is a reply to a new message.
7347
- let turnSilenced = false;
8098
+ let turnSilenced = trigger?.suppressProse === true
8099
+ || (trigger?.reason === 'tool_result_guard_retry'
8100
+ && this.guardRetryTurnSilenced.get(agent.name) === true);
8101
+ this.guardRetryTurnSilenced.delete(agent.name);
7348
8102
  // Live routing is only trusted when the membrane provides verbatim
7349
8103
  // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
7350
8104
  // The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
@@ -7364,9 +8118,9 @@ export class AgentFramework {
7364
8118
  // this doesn't violate never-guess: it says "attending to what you
7365
8119
  // sent here", which is true regardless of where the reply goes.
7366
8120
  // Heartbeat/no-trigger explicit turns show no indicator.
7367
- const typingChannel = agent.proseRouting === 'disabled'
8121
+ const typingChannel = turnProseRouting === 'disabled'
7368
8122
  ? null
7369
- : agent.proseRouting === 'explicit'
8123
+ : turnProseRouting === 'explicit'
7370
8124
  ? trigger?.channelId ?? null
7371
8125
  : resolveTurnLocus();
7372
8126
  if (typingChannel)
@@ -7383,6 +8137,9 @@ export class AgentFramework {
7383
8137
  // id makes the discarded attempt's chunk buffer orphaned (never
7384
8138
  // finalized) instead of being appended to — see the 'retrying' case.
7385
8139
  let outgoingInferenceId = newOutgoingInferenceId();
8140
+ // RFC-007: every inference id THIS stream mints, so the stream's end
8141
+ // aborts only its own open tool calls — never a successor's.
8142
+ const lifecycleInferenceIds = new Set([outgoingInferenceId]);
7386
8143
  let outgoingIndex = 0;
7387
8144
  // §10.5 inference/lifecycle: `started` now, exactly one terminal in the
7388
8145
  // finally below (the one block every exit path shares). Which terminal
@@ -7402,10 +8159,10 @@ export class AgentFramework {
7402
8159
  // turns until completion: otherwise speculative outgoing chunks could
7403
8160
  // expose the wrapper before the fail-closed classifier runs.
7404
8161
  const proseStream = this.channelRegistry &&
7405
- agent.proseRouting !== 'disabled' &&
8162
+ turnProseRouting !== 'disabled' &&
7406
8163
  !agent.toolWrapperProseGuard
7407
8164
  ? new ProseStreamRouter({
7408
- mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
8165
+ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
7409
8166
  initialTarget: typingChannel,
7410
8167
  resolve: (spec) => {
7411
8168
  const r = this.channelRegistry.resolveProseTarget(spec);
@@ -7418,6 +8175,23 @@ export class AgentFramework {
7418
8175
  this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
7419
8176
  }
7420
8177
  };
8178
+ // Tool-result-guard publication boundary. While a SUBMITTED batch is
8179
+ // pending (or a guard recovery is running), a refusal can still abandon
8180
+ // this physical round, so its streamed text must not reach any surface
8181
+ // yet — neither the prose router nor the inference:tokens trace (TTS and
8182
+ // other trace consumers voice it). Hold both; release in order on a clean
8183
+ // round (tool-calls / non-guard completion), discard on a guard refusal.
8184
+ let guardHeld = [];
8185
+ const releaseGuardHeld = () => {
8186
+ const held = guardHeld;
8187
+ guardHeld = [];
8188
+ for (const item of held) {
8189
+ this.emitTrace(item.trace);
8190
+ if (proseStream && item.text !== undefined)
8191
+ emitOutgoing(proseStream.feed(item.text));
8192
+ }
8193
+ };
8194
+ const discardGuardHeld = () => { guardHeld = []; };
7421
8195
  const adoptInjectedRound = () => {
7422
8196
  if (!this.midTurnInputSignals.has(agent.name))
7423
8197
  return;
@@ -7425,7 +8199,7 @@ export class AgentFramework {
7425
8199
  // A new conversational message, not merely a tool result, means any
7426
8200
  // earlier explicit delivery has completed its conversational job.
7427
8201
  // Routing is NOT touched: the turn locus stays frozen.
7428
- turnSilenced = false;
8202
+ turnSilenced = trigger?.suppressProse === true;
7429
8203
  };
7430
8204
  try {
7431
8205
  for await (const event of stream) {
@@ -7439,8 +8213,8 @@ export class AgentFramework {
7439
8213
  }
7440
8214
  this.touchEphemeralRun(agent.name, true);
7441
8215
  switch (event.type) {
7442
- case 'tokens':
7443
- this.emitTrace({
8216
+ case 'tokens': {
8217
+ const trace = {
7444
8218
  type: 'inference:tokens',
7445
8219
  agentName: agent.name,
7446
8220
  content: event.content,
@@ -7450,11 +8224,18 @@ export class AgentFramework {
7450
8224
  // lets trace consumers tag every chunk with the channel this
7451
8225
  // turn's prose is bound for, without re-deriving routing.
7452
8226
  channelId: typingChannel ?? undefined,
7453
- });
7454
- if (proseStream && event.meta.type === 'text') {
7455
- emitOutgoing(proseStream.feed(event.content));
8227
+ };
8228
+ const text = event.meta.type === 'text' ? event.content : undefined;
8229
+ if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
8230
+ guardHeld.push({ trace, text });
8231
+ }
8232
+ else {
8233
+ this.emitTrace(trace);
8234
+ if (proseStream && text !== undefined)
8235
+ emitOutgoing(proseStream.feed(text));
7456
8236
  }
7457
8237
  break;
8238
+ }
7458
8239
  case 'retrying': {
7459
8240
  // Membrane is re-issuing after a content-policy refusal. Per the
7460
8241
  // RetryingEvent contract we must DISCARD everything this call has
@@ -7472,7 +8253,9 @@ export class AgentFramework {
7472
8253
  (event.category ? ` category=${event.category}` : '') +
7473
8254
  ' — discarding the refused attempt');
7474
8255
  outgoingInferenceId = newOutgoingInferenceId();
8256
+ lifecycleInferenceIds.add(outgoingInferenceId);
7475
8257
  outgoingIndex = 0;
8258
+ discardGuardHeld();
7476
8259
  proseStream?.reset();
7477
8260
  this.emitTrace({
7478
8261
  type: 'inference:tokens',
@@ -7497,6 +8280,10 @@ export class AgentFramework {
7497
8280
  break;
7498
8281
  }
7499
8282
  case 'tool-calls': {
8283
+ // A tool-call response is a clean physical round: admit its
8284
+ // preceding results before storing/dispatching this new round.
8285
+ agent.toolResultGuard.accept();
8286
+ releaseGuardHeld();
7500
8287
  adoptInjectedRound();
7501
8288
  hadToolCalls = true;
7502
8289
  this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
@@ -7562,7 +8349,14 @@ export class AgentFramework {
7562
8349
  // So all calls here have valid input — {} is legitimate for parameterless tools.
7563
8350
  agent.enterWaitingForTools(event.calls, stream);
7564
8351
  for (const call of event.calls) {
8352
+ // RFC-007: register at the one dispatch point for model-issued
8353
+ // calls, keyed by (agent, call id) with this inference's id;
8354
+ // `started` goes out once dispatch returns. Refusal sites inside
8355
+ // dispatch run synchronously and call refuse() first, so a call
8356
+ // the host refuses produces no events.
8357
+ this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
7565
8358
  this.dispatchToolCall(agent.name, call);
8359
+ this.toolLifecycleEmitter?.open(agent.name, call.id);
7566
8360
  }
7567
8361
  // Speak-while-acting: route THIS round's prose to the locus NOW
7568
8362
  // instead of batching every segment to the end of the turn. Long
@@ -7586,11 +8380,11 @@ export class AgentFramework {
7586
8380
  liveProseRouting = true;
7587
8381
  const roundSegments = splitProseSegments(assistantBlocks);
7588
8382
  if (roundSegments.length > 0) {
7589
- if (agent.proseRouting === 'disabled') {
8383
+ if (turnProseRouting === 'disabled') {
7590
8384
  console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
7591
8385
  this.recordProseSuppression(agent.name, roundSegments.length);
7592
8386
  }
7593
- else if (agent.proseRouting === 'hybrid') {
8387
+ else if (turnProseRouting === 'hybrid') {
7594
8388
  if (turnSilenced) {
7595
8389
  this.recordProseSuppression(agent.name, roundSegments.length);
7596
8390
  }
@@ -7603,7 +8397,7 @@ export class AgentFramework {
7603
8397
  }
7604
8398
  }
7605
8399
  }
7606
- else if (agent.proseRouting === 'explicit') {
8400
+ else if (turnProseRouting === 'explicit') {
7607
8401
  // Explicit mode: every segment through the prose gateway.
7608
8402
  // Silencing does not apply — unprefixed prose bounces and
7609
8403
  // prefixed prose is deliberate; think-privacy still holds.
@@ -7641,7 +8435,87 @@ export class AgentFramework {
7641
8435
  case 'complete': {
7642
8436
  adoptInjectedRound();
7643
8437
  const durationMs = Date.now() - startTime;
7644
- const response = event.response;
8438
+ let response = event.response;
8439
+ const guardCategory = response.raw?.response?.stop_details?.category ?? 'unknown';
8440
+ // A staged batch the strategy omitted from the request cannot
8441
+ // have caused this refusal: settle it and fall through to the
8442
+ // ordinary refusal handling (rewind/reaction) below.
8443
+ if (response.stopReason === 'refusal')
8444
+ agent.toolResultGuard.settleUnsubmitted(guardCategory);
8445
+ const guardRefusal = response.stopReason === 'refusal'
8446
+ && (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
8447
+ if (guardRefusal) {
8448
+ discardGuardHeld();
8449
+ const category = guardCategory;
8450
+ const withheld = agent.toolResultGuard.withhold(category);
8451
+ // Nothing from the abandoned physical attempt is published or
8452
+ // persisted as assistant speech, including a terminal refusal
8453
+ // after the single recovery retry.
8454
+ proseStream?.reset();
8455
+ if (withheld) {
8456
+ const usage = response.details?.usage ?? response.usage;
8457
+ const tokenUsage = usage ? {
8458
+ input: usage.inputTokens, output: usage.outputTokens,
8459
+ cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
8460
+ } : undefined;
8461
+ this.noteRefusal(agent.name, category, tokenUsage);
8462
+ // The abandoned stream was billed (membrane usage here is
8463
+ // cumulative across this physical tool loop): count it before
8464
+ // the retry opens a fresh stream with its own usage.
8465
+ const abandonedUsage = response.details?.usage;
8466
+ if (abandonedUsage) {
8467
+ this.usageTracker.onInferenceCompleted(agent.name, {
8468
+ inputTokens: abandonedUsage.inputTokens,
8469
+ outputTokens: abandonedUsage.outputTokens,
8470
+ cacheCreationTokens: abandonedUsage.cacheCreationTokens,
8471
+ cacheReadTokens: abandonedUsage.cacheReadTokens,
8472
+ }, abandonedUsage.estimatedCost
8473
+ ? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
8474
+ : undefined);
8475
+ this.persistUsageState();
8476
+ }
8477
+ this.logInference({
8478
+ timestamp: startTime, agentName: agent.name, requestId,
8479
+ success: false, error: 'Tool output withheld by the guard',
8480
+ request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
8481
+ });
8482
+ console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
8483
+ this.emitTrace({
8484
+ type: 'inference:stream_restarted', agentName: agent.name,
8485
+ reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
8486
+ budget: agent.maxStreamTokens,
8487
+ });
8488
+ await turnSpeechChain;
8489
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
8490
+ generationLost = true;
8491
+ lifecyclePhase = 'aborted';
8492
+ return;
8493
+ }
8494
+ agent.reset();
8495
+ preserveEventGateForSuccessor = true;
8496
+ lifecyclePhase = 'aborted';
8497
+ // Carry same-turn explicit-send suppression into the retry:
8498
+ // a text-only recovery after a successful send must not post
8499
+ // a postscript to a message already delivered.
8500
+ if (turnSilenced)
8501
+ this.guardRetryTurnSilenced.set(agent.name, true);
8502
+ // Restart inference inside this logical turn. No tool is
8503
+ // executed again and no settle/checkpoint/locus reset occurs.
8504
+ await this.startAgentStream(agent, {
8505
+ ...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
8506
+ source: trigger?.source ?? 'framework', timestamp: Date.now(),
8507
+ }, attempt);
8508
+ return;
8509
+ }
8510
+ const lastResult = response.content.reduce((last, block, index) => block.type === 'tool_result' ? index : last, -1);
8511
+ response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
8512
+ agent.toolResultGuard.recovering = false;
8513
+ }
8514
+ else if (response.stopReason !== 'refusal') {
8515
+ agent.toolResultGuard.accept();
8516
+ }
8517
+ if (!guardRefusal)
8518
+ releaseGuardHeld();
7645
8519
  // If the agent is still waiting_for_tools when 'complete' fires
7646
8520
  // (shouldn't happen after incomplete-tool-call fix, but guard anyway),
7647
8521
  // flush pending blocks + inject error results for any remaining pending tools.
@@ -7667,8 +8541,12 @@ export class AgentFramework {
7667
8541
  // one door a giant blob still walks through).
7668
8542
  const readyState = agent.state;
7669
8543
  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);
8544
+ const cap = this.resolveToolResultInlineCap(agent).cap;
8545
+ const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap);
8546
+ agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
8547
+ // The response is already complete: this batch is never
8548
+ // submitted in this turn, so it cannot be refused. Admit it.
8549
+ agent.toolResultGuard.settleTurnEnded();
7672
8550
  }
7673
8551
  }
7674
8552
  // Add assistant response to context.
@@ -7855,7 +8733,7 @@ export class AgentFramework {
7855
8733
  `(remaining ${forced.remaining})`);
7856
8734
  }, () => this.finishUnstick(agent.name, false, category));
7857
8735
  }
7858
- else if (rh?.autoRewind) {
8736
+ else if (rh?.autoRewind && !guardRefusal) {
7859
8737
  const cap = Math.max(1, rh.maxRewinds ?? 3);
7860
8738
  const used = this.refusalRewinds.get(agent.name) ?? 0;
7861
8739
  doRewind(used < cap, (count) => {
@@ -7894,7 +8772,7 @@ export class AgentFramework {
7894
8772
  this.refusalStreak.delete(agent.name);
7895
8773
  }
7896
8774
  // Dispatch speech (and thoughts if any)
7897
- if (speechContent.length > 0 || thoughts.length > 0) {
8775
+ if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
7898
8776
  const speechContext = {
7899
8777
  turnComplete: true,
7900
8778
  trigger: trigger ?? {
@@ -7920,11 +8798,19 @@ export class AgentFramework {
7920
8798
  .join('\n')
7921
8799
  .trim();
7922
8800
  if (speechText) {
7923
- if (agent.proseRouting === 'disabled') {
8801
+ if (turnProseRouting === 'disabled') {
7924
8802
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
7925
8803
  this.recordProseSuppression(agent.name, 1);
7926
8804
  }
7927
- else if (agent.proseRouting === 'hybrid') {
8805
+ else if (turnSilenced && turnProseRouting !== 'explicit') {
8806
+ // Only reachable on a guard recovery that carried the
8807
+ // same-turn send suppression, or a suppressProse trigger
8808
+ // (a fresh text-only turn starts unsilenced). Explicit
8809
+ // mode is exempt, as mid-turn.
8810
+ console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
8811
+ this.recordProseSuppression(agent.name, 1);
8812
+ }
8813
+ else if (turnProseRouting === 'hybrid') {
7928
8814
  const locus = resolveTurnLocus();
7929
8815
  console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
7930
8816
  try {
@@ -7934,7 +8820,7 @@ export class AgentFramework {
7934
8820
  console.error('text-only hybrid prose delivery failed:', err);
7935
8821
  }
7936
8822
  }
7937
- else if (agent.proseRouting === 'explicit') {
8823
+ else if (turnProseRouting === 'explicit') {
7938
8824
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
7939
8825
  try {
7940
8826
  await this.deliverProse(agent, speechText);
@@ -7989,13 +8875,13 @@ export class AgentFramework {
7989
8875
  // land before the trailing prose. Awaited even when silenced —
7990
8876
  // the chain may still be flushing earlier rounds' posts.
7991
8877
  await turnSpeechChain;
7992
- if (agent.proseRouting === 'disabled') {
8878
+ if (turnProseRouting === 'disabled') {
7993
8879
  if (segments.length > 0) {
7994
8880
  console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
7995
8881
  this.recordProseSuppression(agent.name, segments.length);
7996
8882
  }
7997
8883
  }
7998
- else if (agent.proseRouting === 'hybrid') {
8884
+ else if (turnProseRouting === 'hybrid') {
7999
8885
  if (silenced && segments.length > 0) {
8000
8886
  this.recordProseSuppression(agent.name, segments.length);
8001
8887
  }
@@ -8011,7 +8897,7 @@ export class AgentFramework {
8011
8897
  }
8012
8898
  }
8013
8899
  }
8014
- else if (agent.proseRouting === 'explicit') {
8900
+ else if (turnProseRouting === 'explicit') {
8015
8901
  if (segments.length > 0) {
8016
8902
  console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
8017
8903
  for (const seg of segments) {
@@ -8061,7 +8947,7 @@ export class AgentFramework {
8061
8947
  // awaited before trailing dispatch, and trailing/text-only
8062
8948
  // segments were awaited in-loop. Locus mode only; explicit-mode
8063
8949
  // envelopes acknowledge themselves through the prose gateway.
8064
- if (agent.proseRouting !== 'explicit') {
8950
+ if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
8065
8951
  this.appendProseDeliveryReceipt(agent);
8066
8952
  }
8067
8953
  // Explicit-prose `!` continuation: a prose segment this turn asked
@@ -8225,7 +9111,7 @@ export class AgentFramework {
8225
9111
  // deliveries map persists (cleared only at fresh turn
8226
9112
  // start) and the continuation stream's end writes ONE
8227
9113
  // receipt for the whole turn.
8228
- if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
9114
+ if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
8229
9115
  await turnSpeechChain;
8230
9116
  this.appendProseDeliveryReceipt(agent);
8231
9117
  }
@@ -8430,6 +9316,13 @@ export class AgentFramework {
8430
9316
  // completed; catch → failed; framework-cancel → aborted). A host
8431
9317
  // crash emits nothing — that is the documented best-effort limit, and
8432
9318
  // why consumers keep their safety timeout.
9319
+ //
9320
+ // RFC-007 §7.3: a tool call still open when its turn's stream ends was
9321
+ // cancelled before a result — its terminal is `aborted`. (A normal turn
9322
+ // has none: the stream waits for every result before continuing.)
9323
+ // Scoped to this stream's own inference ids: a budget-restart successor
9324
+ // may already be running for the same agent.
9325
+ this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
8433
9326
  this.hookOrchestrator?.emitLifecycle({
8434
9327
  inferenceId: outgoingInferenceId,
8435
9328
  conversationId: agent.name,
@@ -8444,6 +9337,23 @@ export class AgentFramework {
8444
9337
  // (typing still stops, compression still runs — matching the observed
8445
9338
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
8446
9339
  if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
9340
+ // A frame that ends without handing off to a successor (abort,
9341
+ // exhausted errors) must not leave its batch pending: a later turn
9342
+ // would resubmit the originals and claim its own refusal. Successor
9343
+ // frames (error-policy retry, budget/guard restart) bump streamId or
9344
+ // set preserveEventGateForSuccessor and keep the batch.
9345
+ if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
9346
+ // abandon() clears the batch before its audit append, so a failed
9347
+ // write cannot re-arm it; it must not abort the rest of teardown
9348
+ // (gate release, turn token, provider admission) either.
9349
+ try {
9350
+ agent.toolResultGuard.abandon('aborted');
9351
+ }
9352
+ catch (error) {
9353
+ console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
9354
+ }
9355
+ }
9356
+ agent.toolResultGuard.recovering = false;
8447
9357
  this.eventGate?.onInferenceEnded(agent.name);
8448
9358
  }
8449
9359
  if (!generationLost && ownsPhysicalStream) {
@@ -8756,6 +9666,7 @@ export class AgentFramework {
8756
9666
  const startTime = Date.now();
8757
9667
  this.runCodeExecution(agentName, call)
8758
9668
  .catch((error) => {
9669
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
8759
9670
  // runCodeExecution is designed not to reject; this is the last-resort
8760
9671
  // guard so a bug here can never strand the agent in waiting_for_tools.
8761
9672
  const err = error instanceof Error ? error : new Error(String(error));
@@ -8845,21 +9756,41 @@ export class AgentFramework {
8845
9756
  isError: true,
8846
9757
  };
8847
9758
  }
9759
+ // A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
9760
+ if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
9761
+ return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
9762
+ }
9763
+ const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
9764
+ const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
9765
+ const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
9766
+ const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
9767
+ const capNote = requestedMs !== undefined && requestedMs > ceilingMs
9768
+ ? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
9769
+ : undefined;
8848
9770
  const agent = this.agents.get(agentName);
8849
9771
  const surface = agent
8850
9772
  ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
8851
9773
  : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
8852
9774
  const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
8853
9775
  if (input.background === true) {
8854
- return this.startBackgroundScript(agentName, input.code, injected);
9776
+ const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
9777
+ if (capNote && started.success && started.data && typeof started.data === 'object') {
9778
+ started.data.time_limit_note = capNote;
9779
+ }
9780
+ return started;
8855
9781
  }
8856
9782
  const runner = this.getOrCreateScriptRunner(agentName);
8857
9783
  this.scriptDeferredEndTurn.delete(agentName);
8858
- const exec = await runner.exec(input.code, injected);
9784
+ const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
8859
9785
  const endTurn = this.scriptDeferredEndTurn.delete(agentName);
8860
9786
  return {
8861
9787
  success: true,
8862
- data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
9788
+ data: {
9789
+ stdout: exec.stdout,
9790
+ stderr: exec.stderr,
9791
+ return_code: exec.returnCode,
9792
+ ...(capNote ? { time_limit_note: capNote } : {}),
9793
+ },
8863
9794
  isError: false,
8864
9795
  ...(endTurn ? { endTurn: true } : {}),
8865
9796
  };
@@ -8875,7 +9806,7 @@ export class AgentFramework {
8875
9806
  * limitation is stated in the tool description so agents can calibrate
8876
9807
  * how much to rely on an armed wake.
8877
9808
  */
8878
- startBackgroundScript(agentName, code, injected) {
9809
+ startBackgroundScript(agentName, code, injected, timeLimitMs) {
8879
9810
  // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
8880
9811
  // would outlive its owner and its wake would land in the primary
8881
9812
  // conversation anyway — refuse loudly instead of surprising anyone.
@@ -8899,7 +9830,8 @@ export class AgentFramework {
8899
9830
  };
8900
9831
  }
8901
9832
  const scriptId = `bg-${++this.backgroundScriptCounter}`;
8902
- const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
9833
+ // The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
9834
+ const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
8903
9835
  // Journal: a file under the agent's first read-write workspace mount so
8904
9836
  // their existing read/grep/shell tools work on it. Python appends
8905
9837
  // directly to the materialized path; watch-mode mounts sync it back to
@@ -8960,6 +9892,7 @@ export class AgentFramework {
8960
9892
  script_id: scriptId,
8961
9893
  log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
8962
9894
  lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
9895
+ lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
8963
9896
  note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
8964
9897
  },
8965
9898
  };
@@ -9532,17 +10465,96 @@ export class AgentFramework {
9532
10465
  // Append one record per event — same rationale as logInference.
9533
10466
  this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
9534
10467
  }
10468
+ /** Forget one server's RFC-008 class declarations (see the close handler). */
10469
+ dropMcplToolClasses(serverId) {
10470
+ // Tolerate partially-constructed frameworks (tests build them with
10471
+ // Object.create): nothing cached means nothing to forget.
10472
+ if (!this.mcplToolClasses?.size)
10473
+ return;
10474
+ const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
10475
+ for (const name of [...this.mcplToolClasses.keys()]) {
10476
+ if (name.startsWith(`${prefix}--`))
10477
+ this.mcplToolClasses.delete(name);
10478
+ }
10479
+ }
9535
10480
  /**
9536
- * Find the MCPL server for a tool call by checking against the prefix map.
9537
- * Returns [serverId, prefix] if found, null otherwise.
10481
+ * RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
10482
+ * its effective class, and for MCPL tools the providing connection and
10483
+ * the server's own tool name. Host knowledge (built-in table, embedding
10484
+ * host's table) applies only to tools the host implements; an MCPL tool's
10485
+ * class comes from an operator override or its server's declaration.
10486
+ */
10487
+ describeToolForLifecycle(tool) {
10488
+ const mcpl = this.resolveMcplTool(tool);
10489
+ const { classes } = this.effectiveToolClass(tool, mcpl !== null);
10490
+ if (mcpl)
10491
+ return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
10492
+ return { class: classes };
10493
+ }
10494
+ /**
10495
+ * RFC-008 §5.1: a tool's effective class and the source that decided it.
10496
+ * Operator overrides first; then host knowledge, for tools the host
10497
+ * implements (never for MCPL tools); then an MCPL tool's own declaration.
10498
+ * Tolerates partially-constructed frameworks (tests use Object.create).
10499
+ */
10500
+ effectiveToolClass(tool, isMcpl) {
10501
+ return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
10502
+ overrides: this.toolClassOverrides ?? [],
10503
+ host: isMcpl ? [] : (this.hostToolClasses ?? []),
10504
+ });
10505
+ }
10506
+ /**
10507
+ * RFC-008 §6: tools with their effective class and where that class came
10508
+ * from (`override`, `host`, `server`, or `none` for an unclassed tool,
10509
+ * which policy treats as most restrictive). For operators: a surprising
10510
+ * class should be visible before it matters.
10511
+ *
10512
+ * With `agentName`, exactly the tools that agent is shown. Without, every
10513
+ * tool the framework offers to anyone: the shared board plus each agent's
10514
+ * own (the subconscious's surface, `prose_help`), each listed once.
10515
+ */
10516
+ listToolClasses(agentName) {
10517
+ let tools;
10518
+ if (agentName !== undefined) {
10519
+ const agent = this.agents.get(agentName);
10520
+ if (!agent)
10521
+ throw new Error(`Unknown agent: ${agentName}`);
10522
+ tools = this.agentToolSurface(agent);
10523
+ }
10524
+ else {
10525
+ tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
10526
+ }
10527
+ const seen = new Set();
10528
+ const listed = [];
10529
+ for (const t of tools) {
10530
+ if (seen.has(t.name))
10531
+ continue;
10532
+ seen.add(t.name);
10533
+ const mcpl = this.resolveMcplTool(t.name);
10534
+ const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
10535
+ listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
10536
+ }
10537
+ return listed;
10538
+ }
10539
+ /**
10540
+ * Find the MCPL server for a tool: [serverId, prefix], or null.
10541
+ * Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
10542
+ * both. The server whose tools/list produced the name decides; a name no
10543
+ * live server listed (stale, or invented by the model) goes to the longest
10544
+ * matching prefix. Never prefix-map insertion order.
9538
10545
  */
9539
10546
  resolveMcplTool(toolName) {
9540
- for (const [prefix, serverId] of this.mcplPrefixMap) {
9541
- if (toolName.startsWith(prefix + '--')) {
10547
+ const listedBy = this.mcplToolServers?.get(toolName);
10548
+ let best = null;
10549
+ for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
10550
+ if (!toolName.startsWith(prefix + '--'))
10551
+ continue;
10552
+ if (serverId === listedBy)
9542
10553
  return [serverId, prefix];
9543
- }
10554
+ if (!best || prefix.length > best[1].length)
10555
+ best = [serverId, prefix];
9544
10556
  }
9545
- return null;
10557
+ return best;
9546
10558
  }
9547
10559
  dispatchToolCall(agentName, call) {
9548
10560
  // Enrich call with caller identity so modules can resolve the calling agent
@@ -9712,6 +10724,8 @@ export class AgentFramework {
9712
10724
  })
9713
10725
  .catch((error) => {
9714
10726
  const err = error instanceof Error ? error : new Error(String(error));
10727
+ // RFC-007: the module threw — the host could not obtain a result.
10728
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
9715
10729
  this.emitTrace({
9716
10730
  type: 'tool:failed',
9717
10731
  module: moduleName,
@@ -9777,6 +10791,7 @@ export class AgentFramework {
9777
10791
  ? this.pendingAssistantBlocks.has(agent.name)
9778
10792
  : this.pendingAssistantBlocks.size > 0;
9779
10793
  if (!hasToolResult &&
10794
+ opts?.bypassDeferralFor !== agent.name &&
9780
10795
  (this.quiesced ||
9781
10796
  midToolCycle ||
9782
10797
  this.activeTurnTokens.has(agent.name) ||
@@ -9793,9 +10808,18 @@ export class AgentFramework {
9793
10808
  this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
9794
10809
  if (this.quiesced || this.deferredWritesPersisted)
9795
10810
  this.persistDeferredWrites();
10811
+ if (opts?.placement) {
10812
+ opts.placement.agent = agent.name;
10813
+ opts.placement.deferredId = id;
10814
+ }
9796
10815
  return ''; // Deferred — flushed at the target's next boundary
9797
10816
  }
9798
- return agent.getContextManager().addMessage(participant, content, opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata);
10817
+ const stored = agent.getContextManager().addMessage(participant, content, opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata);
10818
+ if (opts?.placement) {
10819
+ opts.placement.agent = agent.name;
10820
+ opts.placement.messageId = stored;
10821
+ }
10822
+ return stored;
9799
10823
  }
9800
10824
  /**
9801
10825
  * Splice out the deferred messages addressed to `agentName` (explicitly
@@ -10239,6 +11263,11 @@ export class AgentFramework {
10239
11263
  this.mcplServerRegistry = new McplServerRegistry();
10240
11264
  this.featureSetManager = new FeatureSetManager();
10241
11265
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
11266
+ this.toolLifecycleEmitter = new ToolLifecycleEmitter({
11267
+ observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
11268
+ configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
11269
+ describe: (tool) => this.describeToolForLifecycle(tool),
11270
+ });
10242
11271
  // Build prefix map and store configs for tool routing
10243
11272
  for (const config of serverConfigs) {
10244
11273
  const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
@@ -10250,7 +11279,8 @@ export class AgentFramework {
10250
11279
  const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
10251
11280
  ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
10252
11281
  // Push events handler (Step 6)
10253
- this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter);
11282
+ this.initializePushCoalescer();
11283
+ this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event));
10254
11284
  // Server-initiated inference router (Step 6)
10255
11285
  this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), (serverId, params) => {
10256
11286
  const server = this.mcplServerRegistry.getServer(serverId);
@@ -10261,6 +11291,7 @@ export class AgentFramework {
10261
11291
  // Channel registry (Step 7)
10262
11292
  this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
10263
11293
  store: this.store,
11294
+ handleCoalescedIncoming: (serverId, message, event) => this.handleCoalescedIncoming(serverId, message, event),
10264
11295
  sendTypingFn: (serverId, channelId, metadata, op) => {
10265
11296
  const server = this.mcplServerRegistry.getServer(serverId);
10266
11297
  if (server) {
@@ -10329,11 +11360,17 @@ export class AgentFramework {
10329
11360
  // implemented for years — are declared (AUDIT-001/issue #76 item 12).
10330
11361
  this.mcplHostCapabilities = {
10331
11362
  version: '0.5',
11363
+ // RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
11364
+ // channel-scoped pushes, one-hour retry window.
11365
+ eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
10332
11366
  pushEvents: true,
10333
11367
  contextHooks: {
10334
11368
  beforeInference: true,
10335
11369
  },
10336
11370
  inferenceLifecycle: true,
11371
+ // RFC-007: tools/lifecycle (metadata, and requested argument fields
11372
+ // under their own grant) plus the tools/observe filter.
11373
+ toolLifecycle: { observe: true, inputs: true },
10337
11374
  // modelInfo is NOT advertised: §12.2's result requires all four of
10338
11375
  // {id, vendor, contextWindow, capabilities} and the host has no
10339
11376
  // truthful source for contextWindow/capabilities (grep: none exists).
@@ -11066,17 +12103,28 @@ export class AgentFramework {
11066
12103
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
11067
12104
  const connection = this.mcplServerRegistry?.getServer(id) ?? null;
11068
12105
  const connected = connection?.isConnected ?? false;
12106
+ // Attribute through the dispatch resolver: a bare prefix match would
12107
+ // also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
12108
+ const ownTools = (this.mcplTools ?? []).flatMap((t) => {
12109
+ const hit = this.resolveMcplTool(t.name);
12110
+ return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
12111
+ });
11069
12112
  result.push({
11070
12113
  id,
11071
12114
  connected,
11072
12115
  retrying: !connected && (connection?.willReconnect ?? false),
11073
12116
  toolPrefix: prefix,
11074
- toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
12117
+ toolCount: ownTools.length,
11075
12118
  policyEstablished: connection?.policyEstablished ?? false,
11076
12119
  effectiveGrant: connection?.grant.effectiveList() ?? [],
11077
12120
  maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
11078
12121
  deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
11079
12122
  allowHostCommands: config.allowHostCommands === true,
12123
+ toolObserveFilter: connection?.toolObserveFilter ?? null,
12124
+ toolClasses: ownTools.map(({ tool, serverTool }) => {
12125
+ const { classes, source } = this.effectiveToolClass(tool, true);
12126
+ return { tool, serverTool, class: classes, source };
12127
+ }),
11080
12128
  manifestState: connection
11081
12129
  ? { ...connection.manifestState }
11082
12130
  : { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
@@ -11109,10 +12157,15 @@ export class AgentFramework {
11109
12157
  const barrier = this.discordAwarenessBarrier;
11110
12158
  if (barrier)
11111
12159
  await barrier.promise;
11112
- this.pushHandler?.handlePushEvent(connection.id, params, responder);
12160
+ await this.pushHandler?.handlePushEvent(connection.id, params, responder);
11113
12161
  });
11114
12162
  // Handle server-initiated inference requests (Step 6)
11115
12163
  connection.on('inference-request', async (params, responder) => {
12164
+ // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
12165
+ if (this.pushCoalescer?.isRendering(connection.id)) {
12166
+ responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
12167
+ return;
12168
+ }
11116
12169
  if (this.inferenceRouter && responder) {
11117
12170
  const barrier = this.discordAwarenessBarrier;
11118
12171
  if (barrier)
@@ -11150,6 +12203,21 @@ export class AgentFramework {
11150
12203
  channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
11151
12204
  });
11152
12205
  });
12206
+ // RFC-007 §6: the server's filter over tools/lifecycle. Admission already
12207
+ // required toolLifecycle.observe (-32002 otherwise). Validation failures
12208
+ // leave the previous filter in force (§6.6).
12209
+ connection.on('tools-observe', (params, responder) => {
12210
+ const parsed = parseToolObserveParams(params);
12211
+ if (!parsed.ok) {
12212
+ if (responder?.respondError)
12213
+ responder.respondError(-32602, parsed.message, parsed.data);
12214
+ else
12215
+ console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
12216
+ return;
12217
+ }
12218
+ connection.toolObserveFilter = parsed.rules;
12219
+ responder?.respond({});
12220
+ });
11153
12221
  connection.on('model-info', (_params, responder) => {
11154
12222
  // §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
11155
12223
  // in the DIRECT result shape, and numeric contextWindow cannot express
@@ -11164,7 +12232,7 @@ export class AgentFramework {
11164
12232
  const barrier = this.discordAwarenessBarrier;
11165
12233
  if (barrier)
11166
12234
  await barrier.promise;
11167
- this.channelRegistry?.handleIncoming(connection.id, params, responder);
12235
+ await this.channelRegistry?.handleIncoming(connection.id, params, responder);
11168
12236
  });
11169
12237
  // Handle host-level admin commands from a surface (e.g. Discord /undo)
11170
12238
  connection.on('host-command', async (params, responder) => {
@@ -11300,6 +12368,10 @@ export class AgentFramework {
11300
12368
  // disconnectMcplServer, which deletes the trees explicitly.
11301
12369
  connection.on('close', (code, signal) => {
11302
12370
  this.featureSetManager?.removeServer(connection.id);
12371
+ // RFC-008: a closed provider's class hints are stale — on reconnect it
12372
+ // may class its tools differently. Until the next tools/list its tools
12373
+ // are unclassed, the restrictive answer (RFC-007 §4.3).
12374
+ this.dropMcplToolClasses(connection.id);
11303
12375
  this.emitTrace({
11304
12376
  type: 'mcpl:server-closed',
11305
12377
  serverId: connection.id,
@@ -11319,6 +12391,8 @@ export class AgentFramework {
11319
12391
  return;
11320
12392
  const tools = [];
11321
12393
  const toolFeatureSets = new Map();
12394
+ const toolClasses = new Map();
12395
+ const toolServers = new Map();
11322
12396
  for (const server of this.mcplServerRegistry.getAllServers()) {
11323
12397
  const config = this.mcplServerConfigs.get(server.id);
11324
12398
  const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
@@ -11328,7 +12402,13 @@ export class AgentFramework {
11328
12402
  if (!isToolAllowed(tool.name, config))
11329
12403
  continue;
11330
12404
  const namespacedName = `${prefix}--${tool.name}`;
12405
+ toolServers.set(namespacedName, server.id);
11331
12406
  const attributedTool = tool;
12407
+ // RFC-008: the server's class hint for this tool. Never shown to
12408
+ // the model (the definition below is unchanged by it).
12409
+ const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
12410
+ if (declaredClasses)
12411
+ toolClasses.set(namespacedName, declaredClasses);
11332
12412
  const featureSet = typeof attributedTool.featureSet === 'string'
11333
12413
  ? attributedTool.featureSet
11334
12414
  : typeof attributedTool._meta?.featureSet === 'string'
@@ -11352,6 +12432,8 @@ export class AgentFramework {
11352
12432
  }
11353
12433
  this.mcplTools = tools;
11354
12434
  this.mcplToolFeatureSets = toolFeatureSets;
12435
+ this.mcplToolClasses = toolClasses;
12436
+ this.mcplToolServers = toolServers;
11355
12437
  }
11356
12438
  /**
11357
12439
  * Handle a tools/list_changed notification with collapse logic.
@@ -11409,6 +12491,8 @@ export class AgentFramework {
11409
12491
  const toolName = call.name.slice(prefix.length + 2); // strip "{prefix}--"
11410
12492
  const server = this.mcplServerRegistry.getServer(serverId);
11411
12493
  if (!server) {
12494
+ // RFC-007: refused before executing — no lifecycle events.
12495
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
11412
12496
  this.pushEvent({
11413
12497
  type: 'tool-result',
11414
12498
  callId: call.id,
@@ -11420,6 +12504,7 @@ export class AgentFramework {
11420
12504
  }
11421
12505
  const config = this.mcplServerConfigs.get(serverId);
11422
12506
  if (!isToolAllowed(toolName, config)) {
12507
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
11423
12508
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
11424
12509
  this.pushEvent({
11425
12510
  type: 'tool-result',
@@ -11574,6 +12659,8 @@ export class AgentFramework {
11574
12659
  .catch((error) => {
11575
12660
  const err = error instanceof Error ? error : new Error(String(error));
11576
12661
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
12662
+ // RFC-007: transport error, closed connection or timeout — no result.
12663
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
11577
12664
  this.pushEvent({
11578
12665
  type: 'tool-result',
11579
12666
  callId: call.id,
@@ -11598,6 +12685,8 @@ export class AgentFramework {
11598
12685
  const home = this.conversationAgentHomes.get(agentName);
11599
12686
  if (home) {
11600
12687
  const reject = (error) => {
12688
+ // RFC-007: the guard refuses before the tool runs — no lifecycle events.
12689
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
11601
12690
  this.emitTrace({
11602
12691
  type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
11603
12692
  error: `conversation agent ${agentName}: ${error}`,
@@ -11676,6 +12765,7 @@ export class AgentFramework {
11676
12765
  .catch((error) => {
11677
12766
  const err = error instanceof Error ? error : new Error(String(error));
11678
12767
  this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
12768
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
11679
12769
  this.pushEvent({
11680
12770
  type: 'tool-result',
11681
12771
  callId: call.id,
@@ -11894,7 +12984,8 @@ export class AgentFramework {
11894
12984
  if (announce && this.channelRegistry) {
11895
12985
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
11896
12986
  const agent = this.agents.get(agentName);
11897
- if (agent?.proseRouting === 'disabled') {
12987
+ const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
12988
+ if (silentTurn || agent?.proseRouting === 'disabled') {
11898
12989
  console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
11899
12990
  }
11900
12991
  else if (agent?.proseRouting === 'explicit') {
@@ -12669,6 +13760,7 @@ export class AgentFramework {
12669
13760
  catch (error) {
12670
13761
  const err = error instanceof Error ? error : new Error(String(error));
12671
13762
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
13763
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
12672
13764
  result = { success: false, error: err.message, isError: true };
12673
13765
  }
12674
13766
  this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
@@ -12691,6 +13783,7 @@ export class AgentFramework {
12691
13783
  .catch((error) => {
12692
13784
  const err = error instanceof Error ? error : new Error(String(error));
12693
13785
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
13786
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
12694
13787
  this.pushEvent({
12695
13788
  type: 'tool-result',
12696
13789
  callId: call.id,