@animalabs/agent-framework 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +29 -0
  2. package/dist/src/agent.d.ts +22 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +128 -10
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/types.d.ts +1 -1
  7. package/dist/src/api/types.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +10 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +27 -2
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts +21 -0
  13. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  14. package/dist/src/code-execution/tool-definition.js +34 -0
  15. package/dist/src/code-execution/tool-definition.js.map +1 -1
  16. package/dist/src/framework.d.ts +153 -2
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1175 -76
  19. package/dist/src/framework.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +4 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/capability-grant.d.ts +1 -1
  25. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  26. package/dist/src/mcpl/capability-grant.js +49 -5
  27. package/dist/src/mcpl/capability-grant.js.map +1 -1
  28. package/dist/src/mcpl/channel-registry.d.ts +59 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +106 -6
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/conversation-router.d.ts +8 -6
  33. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  34. package/dist/src/mcpl/conversation-router.js +12 -6
  35. package/dist/src/mcpl/conversation-router.js.map +1 -1
  36. package/dist/src/mcpl/index.d.ts +3 -0
  37. package/dist/src/mcpl/index.d.ts.map +1 -1
  38. package/dist/src/mcpl/index.js +3 -0
  39. package/dist/src/mcpl/index.js.map +1 -1
  40. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  41. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  42. package/dist/src/mcpl/push-coalescer.js +709 -0
  43. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  44. package/dist/src/mcpl/push-handler.d.ts +7 -2
  45. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  46. package/dist/src/mcpl/push-handler.js +43 -4
  47. package/dist/src/mcpl/push-handler.js.map +1 -1
  48. package/dist/src/mcpl/references.d.ts.map +1 -1
  49. package/dist/src/mcpl/references.js +9 -4
  50. package/dist/src/mcpl/references.js.map +1 -1
  51. package/dist/src/mcpl/server-connection.d.ts +18 -0
  52. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/server-connection.js +44 -1
  54. package/dist/src/mcpl/server-connection.js.map +1 -1
  55. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  56. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  57. package/dist/src/mcpl/tool-classes.js +170 -0
  58. package/dist/src/mcpl/tool-classes.js.map +1 -0
  59. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  60. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  61. package/dist/src/mcpl/tool-glob.js +41 -0
  62. package/dist/src/mcpl/tool-glob.js.map +1 -0
  63. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  64. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  65. package/dist/src/mcpl/tool-lifecycle.js +607 -0
  66. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  67. package/dist/src/mcpl/transport.d.ts +15 -0
  68. package/dist/src/mcpl/transport.d.ts.map +1 -1
  69. package/dist/src/mcpl/transport.js +65 -1
  70. package/dist/src/mcpl/transport.js.map +1 -1
  71. package/dist/src/mcpl/types.d.ts +68 -1
  72. package/dist/src/mcpl/types.d.ts.map +1 -1
  73. package/dist/src/mcpl/types.js +6 -0
  74. package/dist/src/mcpl/types.js.map +1 -1
  75. package/dist/src/modules/discord/index.js +1 -1
  76. package/dist/src/modules/discord/index.js.map +1 -1
  77. package/dist/src/modules/history/index.d.ts +85 -0
  78. package/dist/src/modules/history/index.d.ts.map +1 -1
  79. package/dist/src/modules/history/index.js +1019 -24
  80. package/dist/src/modules/history/index.js.map +1 -1
  81. package/dist/src/modules/history/semantic.d.ts +202 -0
  82. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  83. package/dist/src/modules/history/semantic.js +418 -0
  84. package/dist/src/modules/history/semantic.js.map +1 -0
  85. package/dist/src/modules/index.d.ts +2 -1
  86. package/dist/src/modules/index.d.ts.map +1 -1
  87. package/dist/src/modules/index.js.map +1 -1
  88. package/dist/src/tool-result-guard.d.ts +82 -0
  89. package/dist/src/tool-result-guard.d.ts.map +1 -0
  90. package/dist/src/tool-result-guard.js +306 -0
  91. package/dist/src/tool-result-guard.js.map +1 -0
  92. package/dist/src/types/agent.d.ts +14 -0
  93. package/dist/src/types/agent.d.ts.map +1 -1
  94. package/dist/src/types/framework.d.ts +21 -0
  95. package/dist/src/types/framework.d.ts.map +1 -1
  96. package/dist/src/types/trace.d.ts +17 -0
  97. package/dist/src/types/trace.d.ts.map +1 -1
  98. package/dist/test/channel-registry-routing.test.js +59 -0
  99. package/dist/test/channel-registry-routing.test.js.map +1 -1
  100. package/dist/test/code-execution.test.js +139 -0
  101. package/dist/test/code-execution.test.js.map +1 -1
  102. package/dist/test/conversation-router.test.js +33 -19
  103. package/dist/test/conversation-router.test.js.map +1 -1
  104. package/dist/test/conversation-routing.test.js +89 -0
  105. package/dist/test/conversation-routing.test.js.map +1 -1
  106. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  107. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  108. package/dist/test/helpers/coalescing-fixture.js +145 -0
  109. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  110. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  111. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  112. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  113. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  114. package/dist/test/history-module-realstore.test.d.ts +2 -0
  115. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  116. package/dist/test/history-module-realstore.test.js +363 -0
  117. package/dist/test/history-module-realstore.test.js.map +1 -0
  118. package/dist/test/history-module-semantic.test.d.ts +2 -0
  119. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  120. package/dist/test/history-module-semantic.test.js +213 -0
  121. package/dist/test/history-module-semantic.test.js.map +1 -0
  122. package/dist/test/history-module-ux.test.d.ts +2 -0
  123. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  124. package/dist/test/history-module-ux.test.js +304 -0
  125. package/dist/test/history-module-ux.test.js.map +1 -0
  126. package/dist/test/history-module.test.js +25 -1
  127. package/dist/test/history-module.test.js.map +1 -1
  128. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  129. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  130. package/dist/test/history-semantic-review-findings.test.js +338 -0
  131. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  132. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  133. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  134. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  135. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  136. package/dist/test/mcpl-capability-grant.test.js +3 -1
  137. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  138. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  139. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  140. package/dist/test/mcpl-child-env.test.js +158 -0
  141. package/dist/test/mcpl-child-env.test.js.map +1 -0
  142. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  143. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  144. package/dist/test/mcpl-coalescing.test.js +1050 -0
  145. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  146. package/dist/test/mcpl-list-status.test.js +4 -0
  147. package/dist/test/mcpl-list-status.test.js.map +1 -1
  148. package/dist/test/mcpl-reference-fields.test.d.ts +2 -0
  149. package/dist/test/mcpl-reference-fields.test.d.ts.map +1 -0
  150. package/dist/test/mcpl-reference-fields.test.js +82 -0
  151. package/dist/test/mcpl-reference-fields.test.js.map +1 -0
  152. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  153. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  154. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  155. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  156. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  157. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  158. package/dist/test/mcpl-tool-classes.test.js +80 -0
  159. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  160. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  161. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  162. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  163. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  164. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  165. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  166. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  167. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  168. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  169. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  170. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  171. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  172. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  173. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  174. package/dist/test/silent-heartbeat.test.js +154 -0
  175. package/dist/test/silent-heartbeat.test.js.map +1 -0
  176. package/dist/test/tool-result-guard.test.d.ts +2 -0
  177. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  178. package/dist/test/tool-result-guard.test.js +1081 -0
  179. package/dist/test/tool-result-guard.test.js.map +1 -0
  180. package/dist/test/tools-observe-parser-conformance.test.d.ts +2 -0
  181. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +1 -0
  182. package/dist/test/tools-observe-parser-conformance.test.js +129 -0
  183. package/dist/test/tools-observe-parser-conformance.test.js.map +1 -0
  184. package/dist/test/trunk-channel-routing.test.js +67 -0
  185. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  186. package/dist/tsconfig.tsbuildinfo +1 -1
  187. package/package.json +2 -2
  188. package/src/agent.ts +128 -10
  189. package/src/api/types.ts +2 -0
  190. package/src/code-execution/py-runner.ts +34 -2
  191. package/src/code-execution/tool-definition.ts +46 -0
  192. package/src/framework.ts +1207 -80
  193. package/src/index.ts +13 -0
  194. package/src/mcpl/capability-grant.ts +54 -6
  195. package/src/mcpl/channel-registry.ts +115 -7
  196. package/src/mcpl/conversation-router.ts +12 -8
  197. package/src/mcpl/index.ts +29 -0
  198. package/src/mcpl/push-coalescer.ts +821 -0
  199. package/src/mcpl/push-handler.ts +40 -4
  200. package/src/mcpl/references.ts +11 -3
  201. package/src/mcpl/server-connection.ts +48 -2
  202. package/src/mcpl/tool-classes.ts +188 -0
  203. package/src/mcpl/tool-glob.ts +36 -0
  204. package/src/mcpl/tool-lifecycle.ts +745 -0
  205. package/src/mcpl/transport.ts +67 -1
  206. package/src/mcpl/types.ts +71 -1
  207. package/src/modules/discord/index.ts +1 -1
  208. package/src/modules/history/index.ts +1089 -24
  209. package/src/modules/history/semantic.ts +473 -0
  210. package/src/modules/index.ts +2 -1
  211. package/src/tool-result-guard.ts +318 -0
  212. package/src/types/agent.ts +15 -0
  213. package/src/types/framework.ts +23 -0
  214. package/src/types/trace.ts +17 -0
package/src/framework.ts CHANGED
@@ -67,7 +67,15 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
67
67
  import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
68
68
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
69
69
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
70
- import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
70
+ import { ToolLifecycleEmitter, parseToolObserveParams } from './mcpl/tool-lifecycle.js';
71
+ import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass, type EffectiveToolClass, type ToolClass, type ToolClassSource } from './mcpl/tool-classes.js';
72
+ import { PushHandler, convertBlock as convertPushBlock, type McplPushEvent } from './mcpl/push-handler.js';
73
+ import {
74
+ PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent,
75
+ coalescingSubjectKey, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
76
+ type CoalescingReceiptRecord,
77
+ } from './mcpl/push-coalescer.js';
78
+ import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, PushEventResult } from './mcpl/types.js';
71
79
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
72
80
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
73
81
  import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
@@ -90,11 +98,13 @@ import {
90
98
  splitPreservingImageSlots,
91
99
  type ParsedImagePlaceholder,
92
100
  } from './tool-image-ledger.js';
93
- import { randomUUID } from 'node:crypto';
94
- import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
101
+ import { randomUUID, createHash } from 'node:crypto';
102
+ import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
95
103
  import {
96
104
  buildCodeExecutionToolDefinition,
97
105
  CODE_EXECUTION_TOOL_NAME,
106
+ scriptTimeLimits,
107
+ validateCodeExecutionConfig,
98
108
  } from './code-execution/tool-definition.js';
99
109
  import { splitProseSegments } from './prose-segments.js';
100
110
  import { cumulativeDelta } from './usage-accounting.js';
@@ -409,6 +419,35 @@ import {
409
419
  const FRAMEWORK_STATE_ID = 'framework/state';
410
420
  /** Snapshot slot mirroring context writes deferred while quiesced (#122). */
411
421
  const DEFERRED_WRITES_ID = 'framework/deferred-writes';
422
+ /** The event a coalesced occurrence delivers through — one per lane. */
423
+ interface CoalescedChannelEvent {
424
+ type: 'mcpl:channel-incoming';
425
+ serverId: string;
426
+ channelId: string;
427
+ messageId: string;
428
+ threadId?: string;
429
+ author: { id: string; name: string };
430
+ content: ContentBlock[];
431
+ timestamp: string;
432
+ metadata?: Record<string, unknown>;
433
+ tags?: string[];
434
+ triggerInference?: boolean;
435
+ targetAgents?: string[];
436
+ coalescingSubject?: string;
437
+ /** Occurrence id (RFC-006 §3.1); channel messages carry it only when coalesced. */
438
+ eventId?: string;
439
+ /** RFC-006 assembly: materialized for this agent's turn (store directly). */
440
+ assemblingFor?: string;
441
+ /** RFC-006 §3.2: deliver into this agent only (replacement / notice audience). */
442
+ deliverTo?: string;
443
+ }
444
+ type CoalescedDelivery =
445
+ | { lane: 'channel'; event: CoalescedChannelEvent }
446
+ | { lane: 'push'; event: McplPushEvent };
447
+ /** RFC-006 coalescing bookkeeping: receipts (retry window) + subject history. */
448
+ const COALESCING_STATE_ID = 'mcpl/coalescing';
449
+ /** Receipts written since the last snapshot flush (bridges the throttle window). */
450
+ const COALESCING_RECENT_ID = 'mcpl/coalescing-recent';
412
451
  /** Above this serialized size the deferred-write mirror is skipped (kept in
413
452
  * memory only) rather than rewriting a multi-MB snapshot per deferral. */
414
453
  const DEFERRED_WRITES_PERSIST_CAP_BYTES = 4 * 1024 * 1024;
@@ -438,7 +477,8 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string):
438
477
  }
439
478
 
440
479
  function isTurnContinuation(reason: string): boolean {
441
- return reason === 'context_budget_restart' || reason === 'tool_results_ready';
480
+ return reason === 'context_budget_restart' || reason === 'tool_results_ready'
481
+ || reason === 'tool_result_guard_retry';
442
482
  }
443
483
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
444
484
  const INFERENCE_LOG_ID = 'framework/inference-log';
@@ -1212,7 +1252,36 @@ export class AgentFramework {
1212
1252
  private mcplServerRegistry: McplServerRegistry | null = null;
1213
1253
  private featureSetManager: FeatureSetManager | null = null;
1214
1254
  private hookOrchestrator: HookOrchestrator | null = null;
1255
+ /** MCPL RFC-007: `tools/lifecycle` to observing servers. Created with the
1256
+ * MCPL subsystem; inert (no tracking) while no connection holds
1257
+ * `toolLifecycle.observe`. */
1258
+ private toolLifecycleEmitter: ToolLifecycleEmitter | null = null;
1259
+ /** MCPL RFC-008 class sources, normalized: operator overrides, then host
1260
+ * knowledge (embedding host's table ahead of the built-in one). */
1261
+ private toolClassOverrides: Array<[string, ToolClass[]]> = [];
1262
+ private hostToolClasses: Array<[string, ToolClass[]]> = normalizeClassTable(
1263
+ BUILTIN_TOOL_CLASSES as Record<string, readonly string[]>,
1264
+ 'BUILTIN_TOOL_CLASSES',
1265
+ );
1215
1266
  private pushHandler: PushHandler | null = null;
1267
+ /** RFC-006 coalescing (see mcpl/push-coalescer.ts for the design). */
1268
+ private pushCoalescer: PushCoalescer<CoalescedDelivery> | null = null;
1269
+ /** Both lanes are admitted in arrival order: a create, edit and delete of
1270
+ * one subject must not overtake one another while routing awaits. */
1271
+ private coalescingAdmission: Promise<unknown> = Promise.resolve();
1272
+ private coalescingSaveTimer: ReturnType<typeof setTimeout> | null = null;
1273
+ private coalescingSnapshotDirty: (() => CoalescingSnapshot) | null = null;
1274
+ private coalescingRecentReceipts: CoalescingReceiptRecord[] = [];
1275
+ /**
1276
+ * Agents that read the residents' shared un-namespaced message slot: every
1277
+ * ordinary resident (their context managers are not isolated) and the
1278
+ * subconscious (isolated, but merging that slot read-only). A message in
1279
+ * that slot is unread only if NONE of them has compiled past it. Forks and
1280
+ * ephemeral agents have isolated slots and are their own only readers.
1281
+ */
1282
+ private readonly sharedSlotAgents = new Set<Agent>();
1283
+ /** Group commit: one fsync per event-loop turn, shared by every waiter. */
1284
+ private coalescingCommit: Promise<void> | null = null;
1216
1285
  private inferenceRouter: InferenceRouter | null = null;
1217
1286
  private channelRegistry: ChannelRegistry | null = null;
1218
1287
  private checkpointManager: CheckpointManager | null = null;
@@ -1265,10 +1334,15 @@ export class AgentFramework {
1265
1334
  private mcplTools: import('./types/index.js').ToolDefinition[] = [];
1266
1335
  /** Namespaced tool name → stateful feature-set attribution from tools/list. */
1267
1336
  private mcplToolFeatureSets: Map<string, string> = new Map();
1337
+ /** Namespaced tool name → the server's RFC-008 `_meta["mcpl/class"]`. */
1338
+ private mcplToolClasses: Map<string, ToolClass[]> = new Map();
1268
1339
  private mcplToolRefreshInFlight = false;
1269
1340
  private mcplToolRefreshPending = false;
1270
1341
  /** Maps tool prefix → serverId for dispatch routing. */
1271
1342
  private mcplPrefixMap: Map<string, string> = new Map();
1343
+ /** Namespaced tool name → the server whose tools/list produced it. Prefixes
1344
+ * can nest (`foo`, `foo--bar`), so a prefix match alone can name two. */
1345
+ private mcplToolServers: Map<string, string> = new Map();
1272
1346
  /** Maps serverId → McplServerConfig for prefix lookup. */
1273
1347
  private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
1274
1348
  /** Host capabilities advertised during the MCP handshake — stored so servers
@@ -1303,6 +1377,8 @@ export class AgentFramework {
1303
1377
 
1304
1378
  // Session-level token usage tracking (always-on)
1305
1379
  private usageTracker: UsageTracker;
1380
+ /** Explicit-send suppression carried into a tool-result-guard retry. */
1381
+ private guardRetryTurnSilenced = new Map<string, boolean>();
1306
1382
  /** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
1307
1383
  private readonly timeZone: string;
1308
1384
 
@@ -1364,6 +1440,9 @@ export class AgentFramework {
1364
1440
  * Create and start the framework.
1365
1441
  */
1366
1442
  static async create(config: FrameworkConfig): Promise<AgentFramework> {
1443
+ // Before anything opens or starts: a refused config must leave nothing behind.
1444
+ if (config.codeExecution?.enabled) validateCodeExecutionConfig(config.codeExecution);
1445
+
1367
1446
  // Create or use existing store
1368
1447
  let store: JsStore;
1369
1448
  let ownsStore: boolean;
@@ -1389,6 +1468,13 @@ export class AgentFramework {
1389
1468
  } catch {
1390
1469
  // Already registered
1391
1470
  }
1471
+ for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
1472
+ try {
1473
+ store.registerState({ id, strategy: 'snapshot' });
1474
+ } catch {
1475
+ // Already registered
1476
+ }
1477
+ }
1392
1478
 
1393
1479
  try {
1394
1480
  store.registerState({
@@ -1588,6 +1674,12 @@ export class AgentFramework {
1588
1674
  // retained when enabled — everything downstream gates on the field.
1589
1675
  framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
1590
1676
 
1677
+ framework.toolClassOverrides = normalizeClassTable(config.toolClassOverrides, 'toolClassOverrides');
1678
+ framework.hostToolClasses = [
1679
+ ...normalizeClassTable(config.hostToolClasses, 'hostToolClasses'),
1680
+ ...normalizeClassTable(BUILTIN_TOOL_CLASSES as Record<string, readonly string[]>, 'BUILTIN_TOOL_CLASSES'),
1681
+ ];
1682
+
1591
1683
  if (config.toolResultInlineMaxChars !== undefined) {
1592
1684
  const cap = config.toolResultInlineMaxChars;
1593
1685
  if (!Number.isFinite(cap) || cap < 1000) {
@@ -1781,6 +1873,8 @@ export class AgentFramework {
1781
1873
  * Stop the event loop.
1782
1874
  */
1783
1875
  async stop(): Promise<void> {
1876
+ this.pushCoalescer?.suspend();
1877
+ this.flushCoalescingSnapshot();
1784
1878
  this.running = false;
1785
1879
  // Flushed-but-unsynced deferred writes: sync and ack now, while the
1786
1880
  // store is still open, rather than leaving them to a reboot replay.
@@ -1859,6 +1953,16 @@ export class AgentFramework {
1859
1953
  }
1860
1954
  await Promise.all(shutdownPromises);
1861
1955
 
1956
+ // Streams may queue storage repairs while being cancelled above. Retry
1957
+ // after their teardown, while the store is still open, before final sync.
1958
+ for (const agent of this.agents.values()) {
1959
+ try {
1960
+ agent.toolResultGuard.flushUnrecorded();
1961
+ } catch (error) {
1962
+ console.error(`[tool-result-guard] agent=${agent.name} could not finish queued storage work at stop:`, error);
1963
+ }
1964
+ }
1965
+
1862
1966
  // Final sync before closing
1863
1967
  try {
1864
1968
  this.store.sync();
@@ -2090,6 +2194,11 @@ export class AgentFramework {
2090
2194
 
2091
2195
  private async runQueuedMaintenance(): Promise<void> {
2092
2196
  const queued = [...this.agents.values()].flatMap((agent) => {
2197
+ try {
2198
+ agent.toolResultGuard.flushUnrecorded();
2199
+ } catch (error) {
2200
+ console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
2201
+ }
2093
2202
  const cm = agent.getContextManager();
2094
2203
  const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
2095
2204
  cm.setToolDefinitions(tools);
@@ -2398,6 +2507,11 @@ export class AgentFramework {
2398
2507
  ext.keys.forEach((k) => taken.add(k));
2399
2508
  result.set('_framework', ext);
2400
2509
  }
2510
+ {
2511
+ const ext = this.toolResultGuardSettingsExtension();
2512
+ ext.keys.forEach((key) => taken.add(key));
2513
+ result.set('_toolResultGuard', ext);
2514
+ }
2401
2515
  for (const module of this.moduleRegistry.getAllModules()) {
2402
2516
  const ext = module.getAgentSettingsExtension?.();
2403
2517
  if (!ext) continue;
@@ -2468,6 +2582,56 @@ export class AgentFramework {
2468
2582
  };
2469
2583
  }
2470
2584
 
2585
+ private toolResultGuardSettingsExtension(): AgentSettingsExtension {
2586
+ const get = (name: string): Record<string, unknown> => {
2587
+ const guard = this.agents.get(name)?.toolResultGuard;
2588
+ return {
2589
+ tool_result_guard: guard?.enabled ?? false,
2590
+ tool_result_guard_source: guard?.settingOverride !== undefined ? 'runtime_override' : 'recipe_default',
2591
+ };
2592
+ };
2593
+ const set = (name: string, enabled: boolean | undefined): Record<string, unknown> => {
2594
+ const agent = this.agents.get(name);
2595
+ if (!agent) throw new Error(`Unknown agent: ${name}`);
2596
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
2597
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
2598
+ const values = { ...((state.toolResultGuards as Record<string, boolean> | undefined) ?? {}) };
2599
+ if (enabled === undefined) delete values[name];
2600
+ else values[name] = enabled;
2601
+ state.toolResultGuards = values;
2602
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
2603
+ agent.toolResultGuard.setOverride(enabled);
2604
+ return get(name);
2605
+ };
2606
+ return {
2607
+ properties: {
2608
+ tool_result_guard: {
2609
+ type: 'boolean',
2610
+ description: 'Enable the tool result guard. It can withhold a batch of tool output and retry inference once. ' +
2611
+ 'Tools are not re-executed. Persists across turns and restarts until explicitly changed. ' +
2612
+ 'Disabling affects future results only; previously withheld results stay withheld.',
2613
+ },
2614
+ },
2615
+ keys: ['tool_result_guard'],
2616
+ get,
2617
+ update: (name, patch) => {
2618
+ if (typeof patch.tool_result_guard !== 'boolean') throw new Error('tool_result_guard must be a boolean');
2619
+ return set(name, patch.tool_result_guard);
2620
+ },
2621
+ reset: (name) => set(name, undefined),
2622
+ };
2623
+ }
2624
+
2625
+ private restoreToolResultGuardSetting(agent: Agent): void {
2626
+ const enabled = (this.store.getStateJson(FRAMEWORK_STATE_ID) as {
2627
+ toolResultGuards?: Record<string, unknown>;
2628
+ } | null)?.toolResultGuards?.[agent.name];
2629
+ if (enabled !== undefined && typeof enabled !== 'boolean') {
2630
+ throw new Error(`Invalid persisted tool_result_guard for ${agent.name}: expected a boolean`);
2631
+ }
2632
+ agent.toolResultGuard.setOverride(enabled);
2633
+ }
2634
+
2471
2635
  private buildThinkTool(
2472
2636
  policy: SameRoundThinkTextPolicy,
2473
2637
  ): import('./types/index.js').ToolDefinition {
@@ -2562,6 +2726,22 @@ export class AgentFramework {
2562
2726
  });
2563
2727
  }
2564
2728
 
2729
+ /**
2730
+ * The tools one agent is shown at inference: its surface (the subconscious
2731
+ * has its own), less what its permissions deny, plus — for explicit-mode
2732
+ * agents — the on-demand routing reference (teach-by-bounce: the grammar
2733
+ * is never injected, only served when asked). Inference and the RFC-008
2734
+ * listing both use this, so the listing cannot drift from the model's view.
2735
+ */
2736
+ private agentToolSurface(
2737
+ agent: Agent,
2738
+ snapshot?: InferenceToolSnapshot,
2739
+ ): import('./types/index.js').ToolDefinition[] {
2740
+ const tools = this.getToolsForAgent(agent.name, snapshot).filter((t) => agent.canUseTool(t.name));
2741
+ if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
2742
+ return tools;
2743
+ }
2744
+
2565
2745
  getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
2566
2746
  const agent = this.agents.get(agentName);
2567
2747
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
@@ -3849,6 +4029,8 @@ export class AgentFramework {
3849
4029
  });
3850
4030
 
3851
4031
  const agent = new Agent(config, contextManager, this.membrane);
4032
+ this.restoreToolResultGuardSetting(agent);
4033
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
3852
4034
  this.ephemeralCandidates.set(agent, contextManager);
3853
4035
 
3854
4036
  const cleanup = () => {
@@ -6153,6 +6335,9 @@ export class AgentFramework {
6153
6335
  });
6154
6336
 
6155
6337
  const agent = new Agent(config, contextManager, this.membrane);
6338
+ this.restoreToolResultGuardSetting(agent);
6339
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
6340
+ this.sharedSlotAgents.add(agent);
6156
6341
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
6157
6342
  if (restoredSettings) {
6158
6343
  agent.restoreRuntimeSettings(
@@ -6227,6 +6412,9 @@ export class AgentFramework {
6227
6412
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
6228
6413
  };
6229
6414
  const agent = new Agent(agentConfig, contextManager, this.membrane);
6415
+ this.restoreToolResultGuardSetting(agent);
6416
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
6417
+ this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
6230
6418
  this.agents.set(name, agent);
6231
6419
  this.agentConfigs.set(name, agentConfig);
6232
6420
  this.subconsciousAgentName = name;
@@ -6299,6 +6487,10 @@ export class AgentFramework {
6299
6487
  return;
6300
6488
  }
6301
6489
 
6490
+ // RFC-007: the call's terminal (completed / failed) — the result
6491
+ // itself is never sent, only whether it was an error.
6492
+ this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
6493
+
6302
6494
  const agent = this.agents.get(event.agentName);
6303
6495
  if (agent) {
6304
6496
  this.touchEphemeralRun(event.agentName, true);
@@ -6348,7 +6540,10 @@ export class AgentFramework {
6348
6540
  // divergence breaks the compile prefix).
6349
6541
  const { blocks: toolResultContent, spilled } =
6350
6542
  await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
6351
- agent.getContextManager().addMessage('user', toolResultContent);
6543
+ const membraneResults = currentState.toolResults.map(tc =>
6544
+ this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
6545
+ );
6546
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults);
6352
6547
 
6353
6548
  // Flush any messages that were deferred while this turn was in
6354
6549
  // flight. Route to the PRIMARY agent — deferred messages are
@@ -6531,6 +6726,9 @@ export class AgentFramework {
6531
6726
 
6532
6727
  if (shouldEndTurn) {
6533
6728
  // endTurn: messages already stored above, cancel stream, reset to idle.
6729
+ // The batch is never submitted, so nothing can refuse it: admit
6730
+ // it now instead of leaving it pending across the idle gap.
6731
+ agent.toolResultGuard.settleTurnEnded();
6534
6732
  if (currentState.stream) {
6535
6733
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
6536
6734
  currentState.stream.cancel();
@@ -6580,17 +6778,16 @@ export class AgentFramework {
6580
6778
  reason: 'context_budget_restart',
6581
6779
  source: 'framework',
6582
6780
  timestamp: Date.now(),
6781
+ suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
6782
+ ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt,
6583
6783
  });
6584
6784
  } else if (currentState.stream) {
6585
6785
  // Streaming path: convert results and resume the stream.
6586
6786
  // Mid-turn messages collected above ride along as injected user
6587
6787
  // messages (membrane ≥0.5.72) — appended after the tool_result
6588
6788
  // envelope so the next round of THIS turn hears them.
6589
- const membraneResults = currentState.toolResults.map(tc =>
6590
- this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
6591
- );
6592
6789
  currentState.stream.provideToolResults(
6593
- membraneResults,
6790
+ agent.toolResultGuard.submissionResults(membraneResults),
6594
6791
  midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined,
6595
6792
  );
6596
6793
  agent.setStreaming(currentState.stream);
@@ -6632,6 +6829,17 @@ export class AgentFramework {
6632
6829
  this.handleMcplPushEvent(event as unknown as McplPushEvent);
6633
6830
  }
6634
6831
 
6832
+ await this.dispatchProcessEventToModules(event, startTime);
6833
+ }
6834
+
6835
+ /**
6836
+ * The module half of handleProcessEvent: every module's onProcess, their
6837
+ * responses, the tool-call dispatch, the completion trace and the process
6838
+ * log. Shared with RFC-006 coalesced deliveries, which bypass the queue
6839
+ * (the coalescer needs the placement synchronously) but must still reach
6840
+ * modules exactly like the ordinary deliveries do.
6841
+ */
6842
+ private async dispatchProcessEventToModules(event: ProcessEvent, startTime = Date.now()): Promise<void> {
6635
6843
  // Dispatch to all modules, tracking responses with module names
6636
6844
  const responses: ModuleProcessResponse[] = [];
6637
6845
  for (const module of this.moduleRegistry.getAllModules()) {
@@ -6755,9 +6963,16 @@ export class AgentFramework {
6755
6963
  tags?: string[];
6756
6964
  triggerInference?: boolean;
6757
6965
  targetAgents?: string[];
6758
- }): Promise<void> {
6966
+ /** RFC-006: set when the coalescer delivers; rides the wake it queues. */
6967
+ coalescingSubject?: string;
6968
+ eventId?: string;
6969
+ assemblingFor?: string;
6970
+ deliverTo?: string;
6971
+ }): Promise<CoalescingPlacement | undefined> {
6759
6972
  const metadata: Record<string, unknown> = {
6760
6973
  ...event.metadata,
6974
+ ...(event.eventId ? { eventId: event.eventId } : {}),
6975
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6761
6976
  channelId: event.channelId,
6762
6977
  messageId: event.messageId,
6763
6978
  author: event.author,
@@ -6770,9 +6985,36 @@ export class AgentFramework {
6770
6985
  // Per-channel conversation routing: messages go to the channel's fork
6771
6986
  // agent (spawned from the template on first qualifying message), never
6772
6987
  // to the primary conversation.
6773
- if (this.conversationRouter) {
6774
- await this.routeConversationIncoming(event, metadata);
6775
- return;
6988
+ // RFC-006 §3.2: a replacement or deletion notice follows the delivery it
6989
+ // corrects — into the agent that holds (or read) the prior occurrence,
6990
+ // never a context fresh routing would pick. Gone agent → delivered nowhere.
6991
+ if (event.deliverTo) {
6992
+ const target = this.agents.get(event.deliverTo);
6993
+ if (!target) return undefined;
6994
+ if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
6995
+ metadata.triggered = event.triggerInference ?? false;
6996
+ const id = target.getContextManager().addMessage('user', event.content, metadata);
6997
+ // A correction may target an older engagement. Refresh only the
6998
+ // binding whose agent actually received the message.
6999
+ if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
7000
+ this.conversationRouter.touch(event.channelId);
7001
+ }
7002
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
7003
+ if (event.triggerInference) {
7004
+ this.pendingRequests.push({
7005
+ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
7006
+ channelId: event.channelId,
7007
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
7008
+ addressed: isAddressedMessage(event.tags, event.metadata),
7009
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
7010
+ });
7011
+ }
7012
+ return { agent: target.name, messageId: id };
7013
+ }
7014
+ if (this.conversationRouter) return undefined; // the trunk never holds channel traffic
7015
+ event = { ...event, targetAgents: [target.name] };
7016
+ } else if (this.conversationRouter) {
7017
+ return this.routeConversationIncoming(event, metadata);
6776
7018
  }
6777
7019
 
6778
7020
  // Addressed-while-closed invitation — parity with the push-event path
@@ -6819,7 +7061,8 @@ export class AgentFramework {
6819
7061
  metadata.tuneOut = { epochId: divert.epochId };
6820
7062
  }
6821
7063
 
6822
- const id = this.addMessage('user', incomingContent, metadata);
7064
+ const placement: CoalescingPlacement = { agent: '' };
7065
+ const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
6823
7066
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
6824
7067
 
6825
7068
  if (event.triggerInference && !divert) {
@@ -6845,9 +7088,11 @@ export class AgentFramework {
6845
7088
  // Addressed messages outrank ambient chatter when a batched wake
6846
7089
  // picks the turn's frozen speech locus.
6847
7090
  addressed,
7091
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6848
7092
  });
6849
7093
  }
6850
7094
  }
7095
+ return placement.agent ? placement : undefined;
6851
7096
  }
6852
7097
 
6853
7098
  /**
@@ -6866,9 +7111,10 @@ export class AgentFramework {
6866
7111
  metadata?: Record<string, unknown>;
6867
7112
  tags?: string[];
6868
7113
  triggerInference?: boolean;
7114
+ coalescingSubject?: string;
6869
7115
  },
6870
7116
  messageMetadata: Record<string, unknown>,
6871
- ): Promise<void> {
7117
+ ): Promise<CoalescingPlacement | undefined> {
6872
7118
  const router = this.conversationRouter!;
6873
7119
  const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
6874
7120
 
@@ -6932,6 +7178,7 @@ export class AgentFramework {
6932
7178
  messageMetadata.triggered = trigger;
6933
7179
 
6934
7180
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
7181
+ router.touch(event.channelId);
6935
7182
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
6936
7183
 
6937
7184
  if (trigger) {
@@ -6945,8 +7192,10 @@ export class AgentFramework {
6945
7192
  channelId: event.channelId,
6946
7193
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
6947
7194
  addressed: isAddressedMessage(event.tags, event.metadata),
7195
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6948
7196
  });
6949
7197
  }
7198
+ return { agent: agent.name, messageId: id };
6950
7199
  }
6951
7200
 
6952
7201
  /**
@@ -6999,6 +7248,8 @@ export class AgentFramework {
6999
7248
 
7000
7249
  const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
7001
7250
  const agent = new Agent(config, contextManager, this.membrane);
7251
+ this.restoreToolResultGuardSetting(agent);
7252
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
7002
7253
  this.agents.set(name, agent);
7003
7254
  this.agentConfigs.set(name, config);
7004
7255
  this.conversationAgentHomes.set(name, channelId);
@@ -7177,6 +7428,19 @@ export class AgentFramework {
7177
7428
  const label = opts.channelLabel ?? descriptor?.label;
7178
7429
  const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
7179
7430
  const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
7431
+ // The label is for reading; the prefix must be ONE token that resolves back
7432
+ // here, on this server (a DM label "DM: alice" quoted verbatim parses as
7433
+ // target "#DM:"). A registry that can name targets is authoritative: no safe
7434
+ // token means no prefix option. A stand-in without proseTargetFor gets a
7435
+ // whitespace-free guess, never a label with spaces.
7436
+ const namer = this.channelRegistry as { proseTargetFor?: (id: string, serverId?: string) => string | undefined };
7437
+ const target = typeof namer.proseTargetFor === 'function'
7438
+ ? namer.proseTargetFor(opts.channelId, opts.serverId)
7439
+ : [label ? `#${label}` : undefined, opts.channelId].find((t): t is string => !!t && !/\s/.test(t));
7440
+ const replyOption = target
7441
+ ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
7442
+ `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
7443
+ : `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
7180
7444
  const missedCount = opts.missedMessages ?? 0;
7181
7445
  const missedNote =
7182
7446
  missedCount > 0
@@ -7192,8 +7456,7 @@ export class AgentFramework {
7192
7456
  `a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
7193
7457
  missedNote +
7194
7458
  `Your options:\n` +
7195
- `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
7196
- `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
7459
+ replyOption +
7197
7460
  `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
7198
7461
  (maxBackscroll > 0
7199
7462
  ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
@@ -7213,10 +7476,454 @@ export class AgentFramework {
7213
7476
  };
7214
7477
  }
7215
7478
 
7479
+ // ==========================================================================
7480
+ // RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
7481
+ // ==========================================================================
7482
+
7483
+ /**
7484
+ * RFC-006 §3.2 binding identity: the configured server id bound to its
7485
+ * endpoint/command. Survives reconnects to the same peer; changes when the
7486
+ * id is reassigned to another endpoint (restartMcplServer with a new config).
7487
+ */
7488
+ private coalescingBinding(serverId: string): string {
7489
+ const config = this.mcplServerConfigs.get(serverId);
7490
+ return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
7491
+ }
7492
+
7493
+ private initializePushCoalescer(): void {
7494
+ const coalescer = new PushCoalescer<CoalescedDelivery>({
7495
+ isUnread: (p) => this.isUnreadPlacement(p),
7496
+ remove: (p) => this.removePlacement(p),
7497
+ deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
7498
+ wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
7499
+ cancelWake: (subject) => this.cancelCoalescedWakes(subject),
7500
+ authorized: (occ) => this.coalescedAuthorized(occ),
7501
+ audience: (occ) => this.coalescedAudience(occ),
7502
+ render: (occ, params) => {
7503
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
7504
+ if (!server?.isConnected) throw new Error('server disconnected');
7505
+ return server.sendPushRender(params);
7506
+ },
7507
+ audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
7508
+ save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
7509
+ recordReceipt: (record) => {
7510
+ // Written now, not at the throttled flush: an acknowledged acceptance
7511
+ // must not lose its receipt, the subject's birth, or (deferred) the
7512
+ // accepted work itself to a crash. A failure here fails the acceptance.
7513
+ this.coalescingRecentReceipts.push(record);
7514
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
7515
+ },
7516
+ wasPublished: (subject, eventId) => {
7517
+ // Boot-time only: the occurrence's durable delivery identity
7518
+ // (subject + eventId in message metadata) in any agent's context.
7519
+ for (const agent of this.agents.values()) {
7520
+ try {
7521
+ if (agent.getContextManager().getAllMessages().some((m) =>
7522
+ m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId)) return true;
7523
+ } catch { /* a context that cannot be read cannot prove publication */ }
7524
+ }
7525
+ return false;
7526
+ },
7527
+ commit: () => this.commitCoalescingDurable(),
7528
+ saveNow: (snapshot) => {
7529
+ if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
7530
+ this.coalescingSnapshotDirty = null;
7531
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot);
7532
+ this.coalescingRecentReceipts = [];
7533
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
7534
+ },
7535
+ });
7536
+ try {
7537
+ coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID) as CoalescingSnapshot | null);
7538
+ const recent = this.store.getStateJson(COALESCING_RECENT_ID) as CoalescingReceiptRecord[] | null;
7539
+ if (Array.isArray(recent)) {
7540
+ coalescer.restoreReceipts(recent);
7541
+ // Until the next snapshot flush checkpoints the recovered state, these
7542
+ // records are its only durable form: keep them in the writable buffer
7543
+ // so the next acceptance appends to them instead of replacing them.
7544
+ this.coalescingRecentReceipts = recent;
7545
+ }
7546
+ } catch (err) {
7547
+ console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
7548
+ }
7549
+ this.pushCoalescer = coalescer;
7550
+ // Batches restored from a snapshot still owe the agent a wake (§14 v41).
7551
+ for (const occ of coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ);
7552
+ }
7553
+
7554
+ /**
7555
+ * Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
7556
+ * written so far is fsynced before the caller proceeds. Group-committed —
7557
+ * all acceptances that arrive in one event-loop turn share one
7558
+ * `store.sync()` — so throughput is bounded by fsync rate per batch, not
7559
+ * per message.
7560
+ */
7561
+ private commitCoalescingDurable(): Promise<void> {
7562
+ if (!this.coalescingCommit) {
7563
+ this.coalescingCommit = new Promise<void>((resolve, reject) => {
7564
+ setImmediate(() => {
7565
+ this.coalescingCommit = null;
7566
+ try { this.store.sync(); resolve(); } catch (err) { reject(err); }
7567
+ });
7568
+ });
7569
+ }
7570
+ return this.coalescingCommit;
7571
+ }
7572
+
7573
+ private scheduleCoalescingSnapshot(snapshot: () => CoalescingSnapshot): void {
7574
+ this.coalescingSnapshotDirty = snapshot;
7575
+ if (this.coalescingSaveTimer) return;
7576
+ const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
7577
+ timer.unref?.();
7578
+ this.coalescingSaveTimer = timer;
7579
+ }
7580
+
7581
+ private flushCoalescingSnapshot(): void {
7582
+ if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
7583
+ const snapshot = this.coalescingSnapshotDirty;
7584
+ this.coalescingSnapshotDirty = null;
7585
+ if (!snapshot) return;
7586
+ try {
7587
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot());
7588
+ this.coalescingRecentReceipts = [];
7589
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
7590
+ } catch (err) {
7591
+ console.error('[coalescing] could not persist coalescing state:', err);
7592
+ }
7593
+ }
7594
+
7595
+ /** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
7596
+ private admitCoalesced<T>(work: () => Promise<T>): Promise<T> {
7597
+ const run = this.coalescingAdmission.then(work, work);
7598
+ this.coalescingAdmission = run.then(() => undefined, () => undefined);
7599
+ return run;
7600
+ }
7601
+
7602
+ private handleCoalescedIncoming(
7603
+ serverId: string,
7604
+ message: ChannelIncomingMessage,
7605
+ event: CoalescedChannelEvent,
7606
+ ): Promise<ChannelIncomingMessageResult> {
7607
+ return this.admitCoalesced(async () => {
7608
+ if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
7609
+ validateCoalesceMember(message.coalesce, 'channel');
7610
+ if (typeof message.eventId !== 'string' || !message.eventId) {
7611
+ throw new CoalesceError('eventId', 'eventId is required with coalesce');
7612
+ }
7613
+ if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
7614
+ validateCoalescedContent(message.content);
7615
+ const c = message.coalesce;
7616
+ const result = await this.pushCoalescer.accept({
7617
+ serverId,
7618
+ binding: this.coalescingBinding(serverId),
7619
+ scope: { kind: 'channel', id: message.channelId },
7620
+ key: c.key,
7621
+ eventId: message.eventId,
7622
+ timestamp: message.timestamp,
7623
+ retract: !!c.retract,
7624
+ deferred: false,
7625
+ initial: !!c.initial,
7626
+ tags: event.tags,
7627
+ content: message.content,
7628
+ identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
7629
+ event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
7630
+ });
7631
+ return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
7632
+ });
7633
+ }
7634
+
7635
+ private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise<PushEventResult> {
7636
+ return this.admitCoalesced(async () => {
7637
+ if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
7638
+ validateCoalesceMember(params.coalesce, 'push');
7639
+ if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
7640
+ if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
7641
+ validateCoalescedContent(params.payload?.content);
7642
+ const c = params.coalesce;
7643
+ if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId);
7644
+ const origin = params.origin ?? {};
7645
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
7646
+ return this.pushCoalescer.accept({
7647
+ serverId,
7648
+ binding: this.coalescingBinding(serverId),
7649
+ featureSet: params.featureSet,
7650
+ scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
7651
+ key: c.key,
7652
+ eventId: params.eventId,
7653
+ timestamp: params.timestamp,
7654
+ retract: !!c.retract,
7655
+ deferred: !!c.deferred,
7656
+ initial: !!c.initial,
7657
+ data: c.data,
7658
+ tags: event.tags,
7659
+ content: params.payload.content,
7660
+ ...(c.channelId ? {
7661
+ identity: {
7662
+ ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
7663
+ ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
7664
+ ...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}),
7665
+ },
7666
+ } : {}),
7667
+ event: { lane: 'push', event },
7668
+ });
7669
+ });
7670
+ }
7671
+
7672
+ /** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
7673
+ private assertChannelScopedPush(serverId: string, channelId: string): void {
7674
+ const server = this.mcplServerRegistry?.getServer(serverId);
7675
+ if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
7676
+ throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
7677
+ }
7678
+ if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
7679
+ throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
7680
+ }
7681
+ }
7682
+
7683
+ private coalescedAuthorized(occ: CoalescedOccurrence<CoalescedDelivery>): boolean {
7684
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
7685
+ if (!server?.isConnected || !server.policyEstablished) return false;
7686
+ // §3.2: work admitted under another binding (the id since reassigned to a
7687
+ // different endpoint) never acquires the replacement peer's authority —
7688
+ // and its private notice data is never sent to that peer.
7689
+ if (occ.binding !== this.coalescingBinding(occ.serverId)) return false;
7690
+ if (occ.event.lane === 'push') {
7691
+ if (!server.grant.has('pushEvents')) return false;
7692
+ try { this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet); } catch { return false; }
7693
+ }
7694
+ if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
7695
+ // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
7696
+ if (!server.grant.has('channels.incoming')) return false;
7697
+ if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
7698
+ return !!this.channelRegistry?.getDescriptor(occ.scope.id);
7699
+ }
7700
+ return true;
7701
+ }
7702
+
7703
+ private async coalescedAudience(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<string[]> {
7704
+ if (occ.deliverTo) return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
7705
+ const explicit = occ.event.event.targetAgents;
7706
+ if (explicit) return explicit.filter((n) => this.agents.has(n));
7707
+ if (occ.scope.kind === 'channel' && this.conversationRouter) {
7708
+ const router = this.conversationRouter;
7709
+ const bound = router.getBinding(occ.scope.id)?.agentName;
7710
+ if (bound) return this.agents.has(bound) ? [bound] : [];
7711
+ // No fork yet: the same bind decision an incoming message would get. A
7712
+ // batch that qualifies spawns its fork now so it has someone to wake.
7713
+ const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
7714
+ const decision = router.route({
7715
+ channelId: occ.scope.id,
7716
+ mentioned: metadata.mentioned === true,
7717
+ kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
7718
+ });
7719
+ if (decision.kind !== 'spawn') return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
7720
+ try {
7721
+ await this.createConversationAgent(decision.agentName, occ.scope.id);
7722
+ router.bind(occ.scope.id, decision.agentName, decision.generation);
7723
+ this.persistConversationRouterState();
7724
+ return [decision.agentName];
7725
+ } catch (err) {
7726
+ console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
7727
+ return [];
7728
+ }
7729
+ }
7730
+ return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
7731
+ }
7732
+
7733
+ /** Deliver through the lane's ordinary path; report where it landed. */
7734
+ private async deliverCoalesced(
7735
+ occ: CoalescedOccurrence<CoalescedDelivery>,
7736
+ materialized?: McplContentBlock[],
7737
+ assemblingFor?: string,
7738
+ ): Promise<CoalescingPlacement | undefined> {
7739
+ const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
7740
+ const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
7741
+ if (occ.event.lane === 'channel') {
7742
+ const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
7743
+ const placement = await this.handleMcplChannelIncoming(event);
7744
+ await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
7745
+ return placement;
7746
+ }
7747
+ if (occ.scope.kind === 'channel') {
7748
+ // §3.2: a channel-scoped push addresses the channel's subject namespace,
7749
+ // so it is delivered as a message OF that channel — same routing
7750
+ // (conversation forks, tune-out, closed-channel invitation), same reply
7751
+ // locus, and the subject's stable platform identity (§3.1, vector 43).
7752
+ const push = occ.event.event;
7753
+ const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
7754
+ const origin = push.origin ?? {};
7755
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
7756
+ const event: CoalescedChannelEvent = {
7757
+ type: 'mcpl:channel-incoming',
7758
+ serverId: occ.serverId,
7759
+ channelId: occ.scope.id,
7760
+ messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
7761
+ threadId: identity.threadId ?? str(origin.threadId),
7762
+ author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
7763
+ content: materialized ? materialized.map(convertPushBlock) : push.content,
7764
+ timestamp: occ.timestamp,
7765
+ metadata: origin,
7766
+ tags: push.tags,
7767
+ triggerInference: assemblingFor ? false : push.triggerInference,
7768
+ targetAgents: push.targetAgents,
7769
+ coalescingSubject,
7770
+ eventId: occ.eventId,
7771
+ ...narrowing,
7772
+ ...(assemblingFor ? { assemblingFor } : {}),
7773
+ };
7774
+ const placement = await this.handleMcplChannelIncoming(event);
7775
+ await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
7776
+ return placement;
7777
+ }
7778
+ const event: McplPushEvent = {
7779
+ ...occ.event.event,
7780
+ eventId: occ.eventId,
7781
+ timestamp: occ.timestamp,
7782
+ ...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
7783
+ coalescingSubject,
7784
+ // §5.2: the materialized occurrence is consumed by the request being
7785
+ // assembled; the wake that started it is not repeated.
7786
+ ...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
7787
+ ...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
7788
+ };
7789
+ const placement = this.handleMcplPushEvent(event);
7790
+ await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
7791
+ return placement;
7792
+ }
7793
+
7794
+ private async wakeForCoalescedBatch(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<void> {
7795
+ const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
7796
+ this.cancelCoalescedWakes(subject);
7797
+ if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return;
7798
+ const origin = occ.event.event.origin;
7799
+ // The wake carries the batch's channel so the turn freezes its reply
7800
+ // locus there (the materialized delivery at assembly wakes nobody).
7801
+ const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
7802
+ const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
7803
+ for (const agentName of await this.coalescedAudience(occ)) {
7804
+ this.pendingRequests.push({
7805
+ agentName,
7806
+ reason: 'mcpl:push-event',
7807
+ source: occ.serverId,
7808
+ timestamp: Date.now(),
7809
+ channelId,
7810
+ counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
7811
+ addressed: isAddressedMessage(occ.tags, origin),
7812
+ coalescingSubject: subject,
7813
+ });
7814
+ }
7815
+ }
7816
+
7817
+ /** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
7818
+ private cancelCoalescedWakes(subject: string): void {
7819
+ this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
7820
+ for (const cooldown of this.providerAccelerationCooldowns.values()) {
7821
+ cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
7822
+ }
7823
+ }
7824
+
7825
+ /**
7826
+ * A placement in the deferred queue is upgraded in place once the flush has
7827
+ * stored it: the stored copy carries `metadata.deferredWriteId`, found by
7828
+ * scanning the unread tail (above the watermark) — never below it.
7829
+ */
7830
+ private resolvePlacement(p: CoalescingPlacement): void {
7831
+ if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId)) return;
7832
+ const agent = this.agents.get(p.agent);
7833
+ if (!agent) return;
7834
+ const cm = agent.getContextManager();
7835
+ const watermark = agent.getConsumedWatermark();
7836
+ const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
7837
+ const all = cm.getAllMessages();
7838
+ for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
7839
+ if (all[i].metadata?.deferredWriteId === p.deferredId) {
7840
+ p.messageId = all[i].id;
7841
+ delete p.deferredId;
7842
+ return;
7843
+ }
7844
+ }
7845
+ }
7846
+
7847
+ private isUnreadPlacement(p: CoalescingPlacement): boolean {
7848
+ this.resolvePlacement(p);
7849
+ if (p.deferredId) return this.deferredMessages.some((m) => m.id === p.deferredId);
7850
+ if (!p.messageId) return false;
7851
+ const agent = this.agents.get(p.agent);
7852
+ return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
7853
+ }
7854
+
7855
+ /**
7856
+ * Unread = stored, above the agent's consumed watermark on the current
7857
+ * branch, not a body-group shard (immutable), and not folded into any
7858
+ * summary (compression is consumption, §3.3). Conservative on every doubt.
7859
+ */
7860
+ private isUnreadStoredMessage(agent: Agent, messageId: MessageId): boolean {
7861
+ const cm = agent.getContextManager();
7862
+ let message: ReturnType<ContextManager['getMessage']>;
7863
+ try { message = cm.getMessage(messageId); } catch { return false; }
7864
+ if (!message || message.bodyGroupId) return false;
7865
+ // Every agent that can read this message must not have compiled past it.
7866
+ // Residents share one message slot, so a message one of them read is
7867
+ // history for all of them (the never-rewrite rule is about the message,
7868
+ // not the agent that happened to be asked). A tune-out-diverted message
7869
+ // never enters a resident's compiled view; only the subconscious reads
7870
+ // it through its merged view — no subconscious, nobody reads it.
7871
+ let readers: Agent[];
7872
+ if ((message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut) {
7873
+ const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
7874
+ if (!subconscious) return true;
7875
+ readers = [subconscious];
7876
+ } else {
7877
+ readers = this.sharedSlotAgents.has(agent)
7878
+ ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
7879
+ : [agent];
7880
+ }
7881
+ const branch = cm.currentBranch().name;
7882
+ const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
7883
+ for (const reader of readers) {
7884
+ const watermark = reader.getConsumedWatermark();
7885
+ if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence) return false;
7886
+ // Compression is consumption (§3.3): folded into any reader's summary.
7887
+ try {
7888
+ const rcm = reader.getContextManager();
7889
+ const strategy = rcm.getStrategy() as { getSummary?: (id: string) => { sourceIds?: string[]; sourceLevel?: number } | null };
7890
+ for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
7891
+ const entry = strategy.getSummary?.(summary.id);
7892
+ if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId)) return false;
7893
+ }
7894
+ } catch {
7895
+ return false;
7896
+ }
7897
+ }
7898
+ return true;
7899
+ }
7900
+
7901
+ private removePlacement(p: CoalescingPlacement): boolean {
7902
+ this.resolvePlacement(p);
7903
+ if (p.deferredId) {
7904
+ const before = this.deferredMessages.length;
7905
+ this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
7906
+ if (this.deferredMessages.length === before) return false;
7907
+ if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
7908
+ return true;
7909
+ }
7910
+ if (!p.messageId) return false;
7911
+ const agent = this.agents.get(p.agent);
7912
+ if (!agent) return false;
7913
+ try {
7914
+ agent.getContextManager().removeMessage(p.messageId);
7915
+ this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
7916
+ return true;
7917
+ } catch (err) {
7918
+ console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
7919
+ return false;
7920
+ }
7921
+ }
7922
+
7216
7923
  /**
7217
7924
  * Convert an MCPL push event to a context message.
7218
7925
  */
7219
- private handleMcplPushEvent(event: McplPushEvent): void {
7926
+ private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined {
7220
7927
  const triggerChannel = this.derivePushEventChannel(event.origin);
7221
7928
  if (triggerChannel && this.channelRegistry) {
7222
7929
  this.channelRegistry.ensureChannelRegistered(
@@ -7234,6 +7941,7 @@ export class AgentFramework {
7234
7941
  eventId: event.eventId,
7235
7942
  triggered: event.triggerInference ?? false,
7236
7943
  ...(event.tags ? { tags: event.tags } : {}),
7944
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
7237
7945
  };
7238
7946
  // `origin.channelId` is the server-internal raw id (a bare Discord
7239
7947
  // snowflake) — unroutable as a locus and unresolvable by the agent. Store
@@ -7266,8 +7974,24 @@ export class AgentFramework {
7266
7974
  }
7267
7975
  }
7268
7976
 
7269
- const id = this.addMessage('user', content, metadata);
7270
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
7977
+ // Silent heartbeat ticks are control-plane wakes, not autobiography.
7978
+ // Accept the no-message path only for the heartbeat feature's own exact
7979
+ // marker with an empty payload — arbitrary MCPL servers cannot hide
7980
+ // content merely by setting `origin.silent`.
7981
+ const silentHeartbeat =
7982
+ event.serverId === 'heartbeat' &&
7983
+ event.featureSet === 'heartbeat' &&
7984
+ event.origin?.source === 'heartbeat' &&
7985
+ event.origin?.silent === true &&
7986
+ content.length === 0;
7987
+
7988
+ const placement: CoalescingPlacement = { agent: '' };
7989
+ if (!silentHeartbeat) {
7990
+ const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
7991
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
7992
+ } else {
7993
+ console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
7994
+ }
7271
7995
 
7272
7996
  if (event.triggerInference) {
7273
7997
  // Default broadcast excludes conversation forks (channel-driven).
@@ -7285,9 +8009,17 @@ export class AgentFramework {
7285
8009
  // they must outrank ambient chatter in a batched wake's locus
7286
8010
  // selection just like their channels/incoming counterparts.
7287
8011
  addressed: isAddressedMessage(event.tags, event.origin),
8012
+ ...(silentHeartbeat ? {
8013
+ suppressProse: true,
8014
+ ephemeralSystemPrompt:
8015
+ '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
8016
+ 'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
8017
+ } : {}),
8018
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
7288
8019
  });
7289
8020
  }
7290
8021
  }
8022
+ return placement.agent ? placement : undefined;
7291
8023
  }
7292
8024
 
7293
8025
  /**
@@ -7445,7 +8177,9 @@ export class AgentFramework {
7445
8177
  // route the resumed turn into the wrong channel.
7446
8178
  const newestByKey = new Map<string, InferenceRequest>();
7447
8179
  for (const request of parked) {
7448
- const key = `${request.reason}|${request.addressed ? 'a' : 'n'}`;
8180
+ // A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
8181
+ // must not absorb an unrelated wake that would vanish with it.
8182
+ const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
7449
8183
  const prev = newestByKey.get(key);
7450
8184
  if (!prev || request.timestamp >= prev.timestamp) {
7451
8185
  newestByKey.set(key, request);
@@ -7574,8 +8308,13 @@ export class AgentFramework {
7574
8308
  if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
7575
8309
  }
7576
8310
  }
8311
+ // A silent tick never suppresses a genuine coalesced wake. If any
8312
+ // ordinary request shares this batch, the ordinary turn semantics win.
8313
+ const silentOnly = requests.every((r) => r.suppressProse === true);
7577
8314
  await this.startAgentStream(agent, {
7578
8315
  ...trigger,
8316
+ suppressProse: silentOnly ? trigger?.suppressProse : undefined,
8317
+ ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
7579
8318
  channelId: channelReq?.channelId,
7580
8319
  addressed: addressedReq !== undefined,
7581
8320
  // A context-budget restart continues the same logical turn: it keeps
@@ -8140,6 +8879,9 @@ export class AgentFramework {
8140
8879
  turnToken: number,
8141
8880
  ownsProviderGate: boolean,
8142
8881
  ): Promise<boolean> {
8882
+ const continuingTurn = trigger?.reason === 'context_budget_restart'
8883
+ || trigger?.reason === 'tool_result_guard_retry';
8884
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
8143
8885
  // Flush messages deferred during the PREVIOUS turn — before the
8144
8886
  // checkpoint, the locus announcement, and the compile — so a turn started
8145
8887
  // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
@@ -8156,7 +8898,7 @@ export class AgentFramework {
8156
8898
  // products — undoing the turn must not destroy them.
8157
8899
  if (
8158
8900
  attempt === 0 &&
8159
- trigger?.reason !== 'context_budget_restart' &&
8901
+ !continuingTurn &&
8160
8902
  this.deferredMessages.length > 0
8161
8903
  ) {
8162
8904
  {
@@ -8182,8 +8924,20 @@ export class AgentFramework {
8182
8924
  }
8183
8925
  }
8184
8926
 
8927
+ // RFC-006 §5: deferred batches for this agent render now, at the assembly
8928
+ // boundary — after the deferred flush (same window: turn alive, compile
8929
+ // not yet run) and before the checkpoint (they are the turn's inputs).
8930
+ // Not on a context-budget restart: that continues the same logical turn.
8931
+ if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
8932
+ try {
8933
+ await this.pushCoalescer.assemble(agent.name);
8934
+ } catch (err) {
8935
+ console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
8936
+ }
8937
+ }
8938
+
8185
8939
  // Record turn checkpoint before inference (only on first attempt, not retries)
8186
- if (attempt === 0) {
8940
+ if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
8187
8941
  this.recordTurnCheckpoint(agent.name);
8188
8942
  this.redoStacks.delete(agent.name); // new work invalidates redo
8189
8943
  }
@@ -8201,7 +8955,7 @@ export class AgentFramework {
8201
8955
  // after the restart fell through to the hours-old defaultPublishChannel.
8202
8956
  // Keep the turn's trigger channel across restarts; only real new turns
8203
8957
  // reset it.
8204
- if (trigger?.reason !== 'context_budget_restart') {
8958
+ if (!continuingTurn) {
8205
8959
  if (trigger?.channelId) {
8206
8960
  this.activeTriggerChannels.set(agent.name, trigger.channelId);
8207
8961
  } else {
@@ -8221,12 +8975,12 @@ export class AgentFramework {
8221
8975
  // BEFORE this turn compiles, so the agent always knows where its voice
8222
8976
  // goes (announce-on-change only — no per-turn chatter, append-only for
8223
8977
  // KV stability).
8224
- if (trigger?.reason === 'context_budget_restart') {
8978
+ if (continuingTurn) {
8225
8979
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
8226
8980
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
8227
8981
  }
8228
8982
 
8229
- if (trigger?.reason !== 'context_budget_restart') {
8983
+ if (!continuingTurn) {
8230
8984
  if (attempt === 0) this.maybePrimeProseMode(agent);
8231
8985
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
8232
8986
  if (attempt === 0) {
@@ -8243,8 +8997,8 @@ export class AgentFramework {
8243
8997
  this.turnProseDeliveries.delete(agent.name);
8244
8998
  this.turnProseSuppressed.delete(agent.name);
8245
8999
  this.proseHybridSuppressed.delete(agent.name);
8246
- if (agent.proseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
8247
- if (agent.proseRouting === 'explicit' || agent.proseRouting === 'disabled') {
9000
+ if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
9001
+ if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
8248
9002
  // Explicit and disabled prose routing have no inferred locus.
8249
9003
  // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
8250
9004
  // prose target at all. Neither mode freezes or announces a locus.
@@ -8261,6 +9015,15 @@ export class AgentFramework {
8261
9015
  }
8262
9016
  }
8263
9017
 
9018
+ // A context-budget restart normally preserves the turn locus, but a
9019
+ // silent control-plane wake must remain locusless even if an explicit tool
9020
+ // opened/pinned a channel mid-turn.
9021
+ if (trigger?.suppressProse) {
9022
+ this.turnLocusPins.delete(agent.name);
9023
+ this.proseTargetPins.delete(agent.name);
9024
+ this.proseContinuations.delete(agent.name);
9025
+ }
9026
+
8264
9027
  this.touchEphemeralRun(agent.name, true);
8265
9028
  this.emitTrace({
8266
9029
  type: 'inference:started',
@@ -8268,10 +9031,11 @@ export class AgentFramework {
8268
9031
  // The turn-frozen locus was pinned just above (kept across a
8269
9032
  // context-budget restart, which skips the re-pin but re-emits this).
8270
9033
  channelId: this.turnLocusPins.get(agent.name),
9034
+ ...(trigger?.suppressProse ? { silent: true } : {}),
8271
9035
  });
8272
9036
  // A budget restart continues the same logical inference window. The
8273
9037
  // predecessor keeps EventGate liveness until its successor terminates.
8274
- if (trigger?.reason !== 'context_budget_restart') {
9038
+ if (!continuingTurn) {
8275
9039
  this.eventGate?.onInferenceStarted(agent.name);
8276
9040
  }
8277
9041
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
@@ -8286,18 +9050,14 @@ export class AgentFramework {
8286
9050
  // idempotent per channel, and owns the 7s refresh); the catch below stops
8287
9051
  // it on the no-driveStream failure paths (e.g. a compile refusal).
8288
9052
  const earlyTypingChannel =
8289
- agent.proseRouting === 'explicit'
9053
+ turnProseRouting === 'explicit'
8290
9054
  ? trigger?.channelId ?? null
8291
9055
  : this.turnLocusPins.get(agent.name) ?? null;
8292
9056
  if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
8293
9057
 
8294
9058
  try {
8295
9059
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
8296
- const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
8297
- const tools = allTools.filter((t) => agent.canUseTool(t.name));
8298
- // Explicit-mode agents get the on-demand routing reference (teach-by-
8299
- // bounce: the grammar is never injected, only served when asked).
8300
- if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL);
9060
+ const tools = this.agentToolSurface(agent, requestSnapshot);
8301
9061
 
8302
9062
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
8303
9063
  // Both produce ContextInjection[] that get merged before inference.
@@ -8336,6 +9096,15 @@ export class AgentFramework {
8336
9096
  }
8337
9097
  }
8338
9098
 
9099
+ if (trigger?.ephemeralSystemPrompt) {
9100
+ const silentInjection: ContextInjection = {
9101
+ namespace: 'framework:silent-heartbeat',
9102
+ position: 'system',
9103
+ content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
9104
+ };
9105
+ injections = injections ? [...injections, silentInjection] : [silentInjection];
9106
+ }
9107
+
8339
9108
  // An ephemeral watchdog may dispose this Agent while hooks/compile await.
8340
9109
  // Do not create a provider stream after its generation lost ownership.
8341
9110
  if (this.agents.get(agent.name) !== agent) {
@@ -8466,6 +9235,7 @@ export class AgentFramework {
8466
9235
  drainKvSubmissionIds?: () => string[],
8467
9236
  knownToolNames: ReadonlySet<string> = new Set(),
8468
9237
  ): Promise<void> {
9238
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
8469
9239
  const startTime = Date.now();
8470
9240
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
8471
9241
  const myStreamId = agent.streamId;
@@ -8523,7 +9293,10 @@ export class AgentFramework {
8523
9293
  // Sticky explicit-send suppression: prose after send_message stays quiet
8524
9294
  // to prevent a redundant "sent it" postscript. Fresh injected input
8525
9295
  // clears it, because the following prose is a reply to a new message.
8526
- let turnSilenced = false;
9296
+ let turnSilenced = trigger?.suppressProse === true
9297
+ || (trigger?.reason === 'tool_result_guard_retry'
9298
+ && this.guardRetryTurnSilenced.get(agent.name) === true);
9299
+ this.guardRetryTurnSilenced.delete(agent.name);
8527
9300
 
8528
9301
  // Live routing is only trusted when the membrane provides verbatim
8529
9302
  // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
@@ -8546,9 +9319,9 @@ export class AgentFramework {
8546
9319
  // sent here", which is true regardless of where the reply goes.
8547
9320
  // Heartbeat/no-trigger explicit turns show no indicator.
8548
9321
  const typingChannel =
8549
- agent.proseRouting === 'disabled'
9322
+ turnProseRouting === 'disabled'
8550
9323
  ? null
8551
- : agent.proseRouting === 'explicit'
9324
+ : turnProseRouting === 'explicit'
8552
9325
  ? trigger?.channelId ?? null
8553
9326
  : resolveTurnLocus();
8554
9327
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
@@ -8566,6 +9339,9 @@ export class AgentFramework {
8566
9339
  // id makes the discarded attempt's chunk buffer orphaned (never
8567
9340
  // finalized) instead of being appended to — see the 'retrying' case.
8568
9341
  let outgoingInferenceId = newOutgoingInferenceId();
9342
+ // RFC-007: every inference id THIS stream mints, so the stream's end
9343
+ // aborts only its own open tool calls — never a successor's.
9344
+ const lifecycleInferenceIds = new Set<string>([outgoingInferenceId]);
8569
9345
  let outgoingIndex = 0;
8570
9346
 
8571
9347
  // §10.5 inference/lifecycle: `started` now, exactly one terminal in the
@@ -8586,10 +9362,10 @@ export class AgentFramework {
8586
9362
  // turns until completion: otherwise speculative outgoing chunks could
8587
9363
  // expose the wrapper before the fail-closed classifier runs.
8588
9364
  const proseStream = this.channelRegistry &&
8589
- agent.proseRouting !== 'disabled' &&
9365
+ turnProseRouting !== 'disabled' &&
8590
9366
  !agent.toolWrapperProseGuard
8591
9367
  ? new ProseStreamRouter({
8592
- mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
9368
+ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
8593
9369
  initialTarget: typingChannel,
8594
9370
  resolve: (spec) => {
8595
9371
  const r = this.channelRegistry!.resolveProseTarget(spec);
@@ -8605,6 +9381,23 @@ export class AgentFramework {
8605
9381
  }
8606
9382
  };
8607
9383
 
9384
+ // Tool-result-guard publication boundary. While a SUBMITTED batch is
9385
+ // pending (or a guard recovery is running), a refusal can still abandon
9386
+ // this physical round, so its streamed text must not reach any surface
9387
+ // yet — neither the prose router nor the inference:tokens trace (TTS and
9388
+ // other trace consumers voice it). Hold both; release in order on a clean
9389
+ // round (tool-calls / non-guard completion), discard on a guard refusal.
9390
+ let guardHeld: Array<{ trace: Parameters<AgentFramework['emitTrace']>[0]; text?: string }> = [];
9391
+ const releaseGuardHeld = (): void => {
9392
+ const held = guardHeld;
9393
+ guardHeld = [];
9394
+ for (const item of held) {
9395
+ this.emitTrace(item.trace);
9396
+ if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text));
9397
+ }
9398
+ };
9399
+ const discardGuardHeld = (): void => { guardHeld = []; };
9400
+
8608
9401
  const adoptInjectedRound = (): void => {
8609
9402
  if (!this.midTurnInputSignals.has(agent.name)) return;
8610
9403
  this.midTurnInputSignals.delete(agent.name);
@@ -8612,7 +9405,7 @@ export class AgentFramework {
8612
9405
  // A new conversational message, not merely a tool result, means any
8613
9406
  // earlier explicit delivery has completed its conversational job.
8614
9407
  // Routing is NOT touched: the turn locus stays frozen.
8615
- turnSilenced = false;
9408
+ turnSilenced = trigger?.suppressProse === true;
8616
9409
  };
8617
9410
 
8618
9411
  try {
@@ -8627,8 +9420,8 @@ export class AgentFramework {
8627
9420
  }
8628
9421
  this.touchEphemeralRun(agent.name, true);
8629
9422
  switch (event.type) {
8630
- case 'tokens':
8631
- this.emitTrace({
9423
+ case 'tokens': {
9424
+ const trace: Parameters<AgentFramework['emitTrace']>[0] = {
8632
9425
  type: 'inference:tokens',
8633
9426
  agentName: agent.name,
8634
9427
  content: event.content,
@@ -8638,11 +9431,16 @@ export class AgentFramework {
8638
9431
  // lets trace consumers tag every chunk with the channel this
8639
9432
  // turn's prose is bound for, without re-deriving routing.
8640
9433
  channelId: typingChannel ?? undefined,
8641
- });
8642
- if (proseStream && event.meta.type === 'text') {
8643
- emitOutgoing(proseStream.feed(event.content));
9434
+ };
9435
+ const text = event.meta.type === 'text' ? event.content : undefined;
9436
+ if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
9437
+ guardHeld.push({ trace, text });
9438
+ } else {
9439
+ this.emitTrace(trace);
9440
+ if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text));
8644
9441
  }
8645
9442
  break;
9443
+ }
8646
9444
 
8647
9445
  case 'retrying': {
8648
9446
  // Membrane is re-issuing after a content-policy refusal. Per the
@@ -8663,7 +9461,9 @@ export class AgentFramework {
8663
9461
  ' — discarding the refused attempt',
8664
9462
  );
8665
9463
  outgoingInferenceId = newOutgoingInferenceId();
9464
+ lifecycleInferenceIds.add(outgoingInferenceId);
8666
9465
  outgoingIndex = 0;
9466
+ discardGuardHeld();
8667
9467
  proseStream?.reset();
8668
9468
  this.emitTrace({
8669
9469
  type: 'inference:tokens',
@@ -8690,6 +9490,10 @@ export class AgentFramework {
8690
9490
  }
8691
9491
 
8692
9492
  case 'tool-calls': {
9493
+ // A tool-call response is a clean physical round: admit its
9494
+ // preceding results before storing/dispatching this new round.
9495
+ agent.toolResultGuard.accept();
9496
+ releaseGuardHeld();
8693
9497
  adoptInjectedRound();
8694
9498
  hadToolCalls = true;
8695
9499
  this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
@@ -8761,7 +9565,14 @@ export class AgentFramework {
8761
9565
  agent.enterWaitingForTools(event.calls, stream);
8762
9566
 
8763
9567
  for (const call of event.calls) {
9568
+ // RFC-007: register at the one dispatch point for model-issued
9569
+ // calls, keyed by (agent, call id) with this inference's id;
9570
+ // `started` goes out once dispatch returns. Refusal sites inside
9571
+ // dispatch run synchronously and call refuse() first, so a call
9572
+ // the host refuses produces no events.
9573
+ this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
8764
9574
  this.dispatchToolCall(agent.name, call);
9575
+ this.toolLifecycleEmitter?.open(agent.name, call.id);
8765
9576
  }
8766
9577
 
8767
9578
  // Speak-while-acting: route THIS round's prose to the locus NOW
@@ -8787,12 +9598,12 @@ export class AgentFramework {
8787
9598
  liveProseRouting = true;
8788
9599
  const roundSegments = splitProseSegments(assistantBlocks);
8789
9600
  if (roundSegments.length > 0) {
8790
- if (agent.proseRouting === 'disabled') {
9601
+ if (turnProseRouting === 'disabled') {
8791
9602
  console.error(
8792
9603
  `[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`,
8793
9604
  );
8794
9605
  this.recordProseSuppression(agent.name, roundSegments.length);
8795
- } else if (agent.proseRouting === 'hybrid') {
9606
+ } else if (turnProseRouting === 'hybrid') {
8796
9607
  if (turnSilenced) {
8797
9608
  this.recordProseSuppression(agent.name, roundSegments.length);
8798
9609
  } else if (!hasSameRoundPrivateThink) {
@@ -8803,7 +9614,7 @@ export class AgentFramework {
8803
9614
  .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
8804
9615
  }
8805
9616
  }
8806
- } else if (agent.proseRouting === 'explicit') {
9617
+ } else if (turnProseRouting === 'explicit') {
8807
9618
  // Explicit mode: every segment through the prose gateway.
8808
9619
  // Silencing does not apply — unprefixed prose bounces and
8809
9620
  // prefixed prose is deliberate; think-privacy still holds.
@@ -8847,7 +9658,86 @@ export class AgentFramework {
8847
9658
  case 'complete': {
8848
9659
  adoptInjectedRound();
8849
9660
  const durationMs = Date.now() - startTime;
8850
- const response = event.response;
9661
+ let response = event.response;
9662
+ const guardCategory = (response.raw?.response as {
9663
+ stop_details?: { category?: string };
9664
+ } | undefined)?.stop_details?.category ?? 'unknown';
9665
+ // A staged batch the strategy omitted from the request cannot
9666
+ // have caused this refusal: settle it and fall through to the
9667
+ // ordinary refusal handling (rewind/reaction) below.
9668
+ if (response.stopReason === 'refusal') agent.toolResultGuard.settleUnsubmitted(guardCategory);
9669
+ const guardRefusal = response.stopReason === 'refusal'
9670
+ && (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
9671
+ if (guardRefusal) {
9672
+ discardGuardHeld();
9673
+ const category = guardCategory;
9674
+ const withheld = agent.toolResultGuard.withhold(category);
9675
+ // Nothing from the abandoned physical attempt is published or
9676
+ // persisted as assistant speech, including a terminal refusal
9677
+ // after the single recovery retry.
9678
+ proseStream?.reset();
9679
+ if (withheld) {
9680
+ const usage = response.details?.usage ?? response.usage;
9681
+ const tokenUsage = usage ? {
9682
+ input: usage.inputTokens, output: usage.outputTokens,
9683
+ cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
9684
+ } : undefined;
9685
+ this.noteRefusal(agent.name, category, tokenUsage);
9686
+ // The abandoned stream was billed (membrane usage here is
9687
+ // cumulative across this physical tool loop): count it before
9688
+ // the retry opens a fresh stream with its own usage.
9689
+ const abandonedUsage = response.details?.usage;
9690
+ if (abandonedUsage) {
9691
+ this.usageTracker.onInferenceCompleted(agent.name, {
9692
+ inputTokens: abandonedUsage.inputTokens,
9693
+ outputTokens: abandonedUsage.outputTokens,
9694
+ cacheCreationTokens: abandonedUsage.cacheCreationTokens,
9695
+ cacheReadTokens: abandonedUsage.cacheReadTokens,
9696
+ }, abandonedUsage.estimatedCost
9697
+ ? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
9698
+ : undefined);
9699
+ this.persistUsageState();
9700
+ }
9701
+ this.logInference({
9702
+ timestamp: startTime, agentName: agent.name, requestId,
9703
+ success: false, error: 'Tool output withheld by the guard',
9704
+ request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
9705
+ });
9706
+ console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
9707
+ this.emitTrace({
9708
+ type: 'inference:stream_restarted', agentName: agent.name,
9709
+ reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
9710
+ budget: agent.maxStreamTokens,
9711
+ });
9712
+ await turnSpeechChain;
9713
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
9714
+ generationLost = true;
9715
+ lifecyclePhase = 'aborted';
9716
+ return;
9717
+ }
9718
+ agent.reset();
9719
+ preserveEventGateForSuccessor = true;
9720
+ lifecyclePhase = 'aborted';
9721
+ // Carry same-turn explicit-send suppression into the retry:
9722
+ // a text-only recovery after a successful send must not post
9723
+ // a postscript to a message already delivered.
9724
+ if (turnSilenced) this.guardRetryTurnSilenced.set(agent.name, true);
9725
+ // Restart inference inside this logical turn. No tool is
9726
+ // executed again and no settle/checkpoint/locus reset occurs.
9727
+ await this.startAgentStream(agent, {
9728
+ ...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
9729
+ source: trigger?.source ?? 'framework', timestamp: Date.now(),
9730
+ }, attempt);
9731
+ return;
9732
+ }
9733
+ const lastResult = response.content.reduce((last, block, index) =>
9734
+ block.type === 'tool_result' ? index : last, -1);
9735
+ response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
9736
+ agent.toolResultGuard.recovering = false;
9737
+ } else if (response.stopReason !== 'refusal') {
9738
+ agent.toolResultGuard.accept();
9739
+ }
9740
+ if (!guardRefusal) releaseGuardHeld();
8851
9741
 
8852
9742
  // If the agent is still waiting_for_tools when 'complete' fires
8853
9743
  // (shouldn't happen after incomplete-tool-call fix, but guard anyway),
@@ -8876,12 +9766,17 @@ export class AgentFramework {
8876
9766
  // one door a giant blob still walks through).
8877
9767
  const readyState = agent.state as AgentState;
8878
9768
  if (readyState.status === 'ready') {
8879
- const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
9769
+ const cap = this.resolveToolResultInlineCap(agent).cap;
9770
+ const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(
8880
9771
  agent.name,
8881
9772
  readyState.toolResults,
8882
- this.resolveToolResultInlineCap(agent).cap,
9773
+ cap,
8883
9774
  );
8884
- agent.getContextManager().addMessage('user', toolResultContent);
9775
+ agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
9776
+ this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
9777
+ // The response is already complete: this batch is never
9778
+ // submitted in this turn, so it cannot be refused. Admit it.
9779
+ agent.toolResultGuard.settleTurnEnded();
8885
9780
  }
8886
9781
  }
8887
9782
 
@@ -9097,7 +9992,7 @@ export class AgentFramework {
9097
9992
  },
9098
9993
  () => this.finishUnstick(agent.name, false, category),
9099
9994
  );
9100
- } else if (rh?.autoRewind) {
9995
+ } else if (rh?.autoRewind && !guardRefusal) {
9101
9996
  const cap = Math.max(1, rh.maxRewinds ?? 3);
9102
9997
  const used = this.refusalRewinds.get(agent.name) ?? 0;
9103
9998
  doRewind(
@@ -9145,7 +10040,7 @@ export class AgentFramework {
9145
10040
  }
9146
10041
 
9147
10042
  // Dispatch speech (and thoughts if any)
9148
- if (speechContent.length > 0 || thoughts.length > 0) {
10043
+ if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
9149
10044
  const speechContext: SpeechContext = {
9150
10045
  turnComplete: true,
9151
10046
  trigger: trigger ?? {
@@ -9176,10 +10071,17 @@ export class AgentFramework {
9176
10071
  .join('\n')
9177
10072
  .trim();
9178
10073
  if (speechText) {
9179
- if (agent.proseRouting === 'disabled') {
10074
+ if (turnProseRouting === 'disabled') {
9180
10075
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
9181
10076
  this.recordProseSuppression(agent.name, 1);
9182
- } else if (agent.proseRouting === 'hybrid') {
10077
+ } else if (turnSilenced && turnProseRouting !== 'explicit') {
10078
+ // Only reachable on a guard recovery that carried the
10079
+ // same-turn send suppression, or a suppressProse trigger
10080
+ // (a fresh text-only turn starts unsilenced). Explicit
10081
+ // mode is exempt, as mid-turn.
10082
+ console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
10083
+ this.recordProseSuppression(agent.name, 1);
10084
+ } else if (turnProseRouting === 'hybrid') {
9183
10085
  const locus = resolveTurnLocus();
9184
10086
  console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
9185
10087
  try {
@@ -9187,7 +10089,7 @@ export class AgentFramework {
9187
10089
  } catch (err) {
9188
10090
  console.error('text-only hybrid prose delivery failed:', err);
9189
10091
  }
9190
- } else if (agent.proseRouting === 'explicit') {
10092
+ } else if (turnProseRouting === 'explicit') {
9191
10093
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
9192
10094
  try {
9193
10095
  await this.deliverProse(agent, speechText);
@@ -9243,14 +10145,14 @@ export class AgentFramework {
9243
10145
  // the chain may still be flushing earlier rounds' posts.
9244
10146
  await turnSpeechChain;
9245
10147
 
9246
- if (agent.proseRouting === 'disabled') {
10148
+ if (turnProseRouting === 'disabled') {
9247
10149
  if (segments.length > 0) {
9248
10150
  console.error(
9249
10151
  `[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`,
9250
10152
  );
9251
10153
  this.recordProseSuppression(agent.name, segments.length);
9252
10154
  }
9253
- } else if (agent.proseRouting === 'hybrid') {
10155
+ } else if (turnProseRouting === 'hybrid') {
9254
10156
  if (silenced && segments.length > 0) {
9255
10157
  this.recordProseSuppression(agent.name, segments.length);
9256
10158
  } else if (segments.length > 0) {
@@ -9263,7 +10165,7 @@ export class AgentFramework {
9263
10165
  }
9264
10166
  }
9265
10167
  }
9266
- } else if (agent.proseRouting === 'explicit') {
10168
+ } else if (turnProseRouting === 'explicit') {
9267
10169
  if (segments.length > 0) {
9268
10170
  console.error(
9269
10171
  `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
@@ -9316,7 +10218,7 @@ export class AgentFramework {
9316
10218
  // awaited before trailing dispatch, and trailing/text-only
9317
10219
  // segments were awaited in-loop. Locus mode only; explicit-mode
9318
10220
  // envelopes acknowledge themselves through the prose gateway.
9319
- if (agent.proseRouting !== 'explicit') {
10221
+ if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
9320
10222
  this.appendProseDeliveryReceipt(agent);
9321
10223
  }
9322
10224
 
@@ -9485,7 +10387,7 @@ export class AgentFramework {
9485
10387
  // deliveries map persists (cleared only at fresh turn
9486
10388
  // start) and the continuation stream's end writes ONE
9487
10389
  // receipt for the whole turn.
9488
- if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
10390
+ if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
9489
10391
  await turnSpeechChain;
9490
10392
  this.appendProseDeliveryReceipt(agent);
9491
10393
  }
@@ -9705,6 +10607,13 @@ export class AgentFramework {
9705
10607
  // completed; catch → failed; framework-cancel → aborted). A host
9706
10608
  // crash emits nothing — that is the documented best-effort limit, and
9707
10609
  // why consumers keep their safety timeout.
10610
+ //
10611
+ // RFC-007 §7.3: a tool call still open when its turn's stream ends was
10612
+ // cancelled before a result — its terminal is `aborted`. (A normal turn
10613
+ // has none: the stream waits for every result before continuing.)
10614
+ // Scoped to this stream's own inference ids: a budget-restart successor
10615
+ // may already be running for the same agent.
10616
+ this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
9708
10617
  this.hookOrchestrator?.emitLifecycle({
9709
10618
  inferenceId: outgoingInferenceId,
9710
10619
  conversationId: agent.name,
@@ -9719,6 +10628,22 @@ export class AgentFramework {
9719
10628
  // (typing still stops, compression still runs — matching the observed
9720
10629
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
9721
10630
  if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
10631
+ // A frame that ends without handing off to a successor (abort,
10632
+ // exhausted errors) must not leave its batch pending: a later turn
10633
+ // would resubmit the originals and claim its own refusal. Successor
10634
+ // frames (error-policy retry, budget/guard restart) bump streamId or
10635
+ // set preserveEventGateForSuccessor and keep the batch.
10636
+ if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
10637
+ // abandon() clears the batch before its audit append, so a failed
10638
+ // write cannot re-arm it; it must not abort the rest of teardown
10639
+ // (gate release, turn token, provider admission) either.
10640
+ try {
10641
+ agent.toolResultGuard.abandon('aborted');
10642
+ } catch (error) {
10643
+ console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
10644
+ }
10645
+ }
10646
+ agent.toolResultGuard.recovering = false;
9722
10647
  this.eventGate?.onInferenceEnded(agent.name);
9723
10648
  }
9724
10649
  if (!generationLost && ownsPhysicalStream) {
@@ -10061,6 +10986,7 @@ export class AgentFramework {
10061
10986
  const startTime = Date.now();
10062
10987
  this.runCodeExecution(agentName, call)
10063
10988
  .catch((error): ToolResult => {
10989
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
10064
10990
  // runCodeExecution is designed not to reject; this is the last-resort
10065
10991
  // guard so a bug here can never strand the agent in waiting_for_tools.
10066
10992
  const err = error instanceof Error ? error : new Error(String(error));
@@ -10110,6 +11036,7 @@ export class AgentFramework {
10110
11036
  background?: unknown;
10111
11037
  action?: unknown;
10112
11038
  script_id?: unknown;
11039
+ time_limit_ms?: unknown;
10113
11040
  };
10114
11041
 
10115
11042
  // Management surface: the agent's own daemon fleet is inspectable and
@@ -10158,6 +11085,18 @@ export class AgentFramework {
10158
11085
  };
10159
11086
  }
10160
11087
 
11088
+ // A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
11089
+ if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
11090
+ return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
11091
+ }
11092
+ const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
11093
+ const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
11094
+ const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
11095
+ const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
11096
+ const capNote = requestedMs !== undefined && requestedMs > ceilingMs
11097
+ ? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
11098
+ : undefined;
11099
+
10161
11100
  const agent = this.agents.get(agentName);
10162
11101
  const surface = agent
10163
11102
  ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
@@ -10167,17 +11106,26 @@ export class AgentFramework {
10167
11106
  );
10168
11107
 
10169
11108
  if (input.background === true) {
10170
- return this.startBackgroundScript(agentName, input.code, injected);
11109
+ const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
11110
+ if (capNote && started.success && started.data && typeof started.data === 'object') {
11111
+ (started.data as Record<string, unknown>).time_limit_note = capNote;
11112
+ }
11113
+ return started;
10171
11114
  }
10172
11115
 
10173
11116
  const runner = this.getOrCreateScriptRunner(agentName);
10174
11117
  this.scriptDeferredEndTurn.delete(agentName);
10175
- const exec = await runner.exec(input.code, injected);
11118
+ const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
10176
11119
  const endTurn = this.scriptDeferredEndTurn.delete(agentName);
10177
11120
 
10178
11121
  return {
10179
11122
  success: true,
10180
- data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
11123
+ data: {
11124
+ stdout: exec.stdout,
11125
+ stderr: exec.stderr,
11126
+ return_code: exec.returnCode,
11127
+ ...(capNote ? { time_limit_note: capNote } : {}),
11128
+ },
10181
11129
  isError: false,
10182
11130
  ...(endTurn ? { endTurn: true } : {}),
10183
11131
  };
@@ -10199,6 +11147,7 @@ export class AgentFramework {
10199
11147
  agentName: string,
10200
11148
  code: string,
10201
11149
  injected: import('./code-execution/py-runner.js').InjectedTool[],
11150
+ timeLimitMs?: number,
10202
11151
  ): ToolResult {
10203
11152
  // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
10204
11153
  // would outlive its owner and its wake would land in the primary
@@ -10224,7 +11173,8 @@ export class AgentFramework {
10224
11173
  }
10225
11174
 
10226
11175
  const scriptId = `bg-${++this.backgroundScriptCounter}`;
10227
- const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
11176
+ // The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
11177
+ const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
10228
11178
 
10229
11179
  // Journal: a file under the agent's first read-write workspace mount so
10230
11180
  // their existing read/grep/shell tools work on it. Python appends
@@ -10290,6 +11240,7 @@ export class AgentFramework {
10290
11240
  script_id: scriptId,
10291
11241
  log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
10292
11242
  lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
11243
+ lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
10293
11244
  note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
10294
11245
  },
10295
11246
  };
@@ -10926,17 +11877,91 @@ export class AgentFramework {
10926
11877
  this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
10927
11878
  }
10928
11879
 
11880
+ /** Forget one server's RFC-008 class declarations (see the close handler). */
11881
+ private dropMcplToolClasses(serverId: string): void {
11882
+ // Tolerate partially-constructed frameworks (tests build them with
11883
+ // Object.create): nothing cached means nothing to forget.
11884
+ if (!this.mcplToolClasses?.size) return;
11885
+ const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
11886
+ for (const name of [...this.mcplToolClasses.keys()]) {
11887
+ if (name.startsWith(`${prefix}--`)) this.mcplToolClasses.delete(name);
11888
+ }
11889
+ }
11890
+
11891
+ /**
11892
+ * RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
11893
+ * its effective class, and for MCPL tools the providing connection and
11894
+ * the server's own tool name. Host knowledge (built-in table, embedding
11895
+ * host's table) applies only to tools the host implements; an MCPL tool's
11896
+ * class comes from an operator override or its server's declaration.
11897
+ */
11898
+ private describeToolForLifecycle(tool: string): { class: ToolClass[]; serverId?: string; serverTool?: string } {
11899
+ const mcpl = this.resolveMcplTool(tool);
11900
+ const { classes } = this.effectiveToolClass(tool, mcpl !== null);
11901
+ if (mcpl) return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
11902
+ return { class: classes };
11903
+ }
11904
+
10929
11905
  /**
10930
- * Find the MCPL server for a tool call by checking against the prefix map.
10931
- * Returns [serverId, prefix] if found, null otherwise.
11906
+ * RFC-008 §5.1: a tool's effective class and the source that decided it.
11907
+ * Operator overrides first; then host knowledge, for tools the host
11908
+ * implements (never for MCPL tools); then an MCPL tool's own declaration.
11909
+ * Tolerates partially-constructed frameworks (tests use Object.create).
11910
+ */
11911
+ private effectiveToolClass(tool: string, isMcpl: boolean): EffectiveToolClass {
11912
+ return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
11913
+ overrides: this.toolClassOverrides ?? [],
11914
+ host: isMcpl ? [] : (this.hostToolClasses ?? []),
11915
+ });
11916
+ }
11917
+
11918
+ /**
11919
+ * RFC-008 §6: tools with their effective class and where that class came
11920
+ * from (`override`, `host`, `server`, or `none` for an unclassed tool,
11921
+ * which policy treats as most restrictive). For operators: a surprising
11922
+ * class should be visible before it matters.
11923
+ *
11924
+ * With `agentName`, exactly the tools that agent is shown. Without, every
11925
+ * tool the framework offers to anyone: the shared board plus each agent's
11926
+ * own (the subconscious's surface, `prose_help`), each listed once.
11927
+ */
11928
+ listToolClasses(agentName?: string): Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> {
11929
+ let tools: import('./types/index.js').ToolDefinition[];
11930
+ if (agentName !== undefined) {
11931
+ const agent = this.agents.get(agentName);
11932
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
11933
+ tools = this.agentToolSurface(agent);
11934
+ } else {
11935
+ tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
11936
+ }
11937
+ const seen = new Set<string>();
11938
+ const listed: Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> = [];
11939
+ for (const t of tools) {
11940
+ if (seen.has(t.name)) continue;
11941
+ seen.add(t.name);
11942
+ const mcpl = this.resolveMcplTool(t.name);
11943
+ const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
11944
+ listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
11945
+ }
11946
+ return listed;
11947
+ }
11948
+
11949
+ /**
11950
+ * Find the MCPL server for a tool: [serverId, prefix], or null.
11951
+ * Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
11952
+ * both. The server whose tools/list produced the name decides; a name no
11953
+ * live server listed (stale, or invented by the model) goes to the longest
11954
+ * matching prefix. Never prefix-map insertion order.
10932
11955
  */
10933
11956
  private resolveMcplTool(toolName: string): [string, string] | null {
10934
- for (const [prefix, serverId] of this.mcplPrefixMap) {
10935
- if (toolName.startsWith(prefix + '--')) {
10936
- return [serverId, prefix];
10937
- }
11957
+ const listedBy = this.mcplToolServers?.get(toolName);
11958
+ let best: [string, string] | null = null;
11959
+ for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
11960
+ if (!toolName.startsWith(prefix + '--')) continue;
11961
+ if (serverId === listedBy) return [serverId, prefix];
11962
+ if (!best || prefix.length > best[1].length) best = [serverId, prefix];
10938
11963
  }
10939
- return null;
11964
+ return best;
10940
11965
  }
10941
11966
 
10942
11967
  private dispatchToolCall(agentName: string, call: ToolCall): void {
@@ -11130,6 +12155,8 @@ export class AgentFramework {
11130
12155
  })
11131
12156
  .catch((error) => {
11132
12157
  const err = error instanceof Error ? error : new Error(String(error));
12158
+ // RFC-007: the module threw — the host could not obtain a result.
12159
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
11133
12160
  this.emitTrace({
11134
12161
  type: 'tool:failed',
11135
12162
  module: moduleName,
@@ -11174,6 +12201,14 @@ export class AgentFramework {
11174
12201
  * minting a second, independently replayable one.
11175
12202
  */
11176
12203
  deferredWriteId?: string;
12204
+ /** Out-param: where the message landed (RFC-006 needs to find it again). */
12205
+ placement?: { agent?: string; messageId?: MessageId; deferredId?: string };
12206
+ /**
12207
+ * RFC-006 assembly: the named agent's turn is alive but its compile has
12208
+ * not run yet (the same window the turn-start deferred flush writes
12209
+ * in). A message for THAT agent is stored directly instead of deferred.
12210
+ */
12211
+ bypassDeferralFor?: string;
11177
12212
  }
11178
12213
  ): MessageId {
11179
12214
  // Route to the named agent, else the primary (not ephemeral subagents).
@@ -11223,6 +12258,7 @@ export class AgentFramework {
11223
12258
  : this.pendingAssistantBlocks.size > 0;
11224
12259
  if (
11225
12260
  !hasToolResult &&
12261
+ opts?.bypassDeferralFor !== agent.name &&
11226
12262
  (this.quiesced ||
11227
12263
  midToolCycle ||
11228
12264
  this.activeTurnTokens.has(agent.name) ||
@@ -11239,14 +12275,17 @@ export class AgentFramework {
11239
12275
  }
11240
12276
  this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
11241
12277
  if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
12278
+ if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.deferredId = id; }
11242
12279
  return '' as MessageId; // Deferred — flushed at the target's next boundary
11243
12280
  }
11244
12281
 
11245
- return agent.getContextManager().addMessage(
12282
+ const stored = agent.getContextManager().addMessage(
11246
12283
  participant,
11247
12284
  content,
11248
12285
  opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata,
11249
12286
  );
12287
+ if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
12288
+ return stored;
11250
12289
  }
11251
12290
 
11252
12291
  /**
@@ -11784,6 +12823,11 @@ export class AgentFramework {
11784
12823
  this.mcplServerRegistry = new McplServerRegistry();
11785
12824
  this.featureSetManager = new FeatureSetManager();
11786
12825
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
12826
+ this.toolLifecycleEmitter = new ToolLifecycleEmitter({
12827
+ observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
12828
+ configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
12829
+ describe: (tool) => this.describeToolForLifecycle(tool),
12830
+ });
11787
12831
 
11788
12832
  // Build prefix map and store configs for tool routing
11789
12833
  for (const config of serverConfigs) {
@@ -11798,11 +12842,13 @@ export class AgentFramework {
11798
12842
  ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
11799
12843
 
11800
12844
  // Push events handler (Step 6)
12845
+ this.initializePushCoalescer();
11801
12846
  this.pushHandler = new PushHandler(
11802
12847
  this.featureSetManager,
11803
12848
  (event) => this.pushEvent(event as unknown as ProcessEvent),
11804
12849
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
11805
12850
  triggerFilter,
12851
+ (serverId, params, event) => this.handleCoalescedPush(serverId, params, event),
11806
12852
  );
11807
12853
 
11808
12854
  // Server-initiated inference router (Step 6)
@@ -11832,6 +12878,8 @@ export class AgentFramework {
11832
12878
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
11833
12879
  {
11834
12880
  store: this.store,
12881
+ handleCoalescedIncoming: (serverId, message, event) =>
12882
+ this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent),
11835
12883
  sendTypingFn: (serverId, channelId, metadata, op) => {
11836
12884
  const server = this.mcplServerRegistry!.getServer(serverId);
11837
12885
  if (server) {
@@ -11906,11 +12954,17 @@ export class AgentFramework {
11906
12954
  // implemented for years — are declared (AUDIT-001/issue #76 item 12).
11907
12955
  this.mcplHostCapabilities = {
11908
12956
  version: '0.5',
12957
+ // RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
12958
+ // channel-scoped pushes, one-hour retry window.
12959
+ eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
11909
12960
  pushEvents: true,
11910
12961
  contextHooks: {
11911
12962
  beforeInference: true,
11912
12963
  },
11913
12964
  inferenceLifecycle: true,
12965
+ // RFC-007: tools/lifecycle (metadata, and requested argument fields
12966
+ // under their own grant) plus the tools/observe filter.
12967
+ toolLifecycle: { observe: true, inputs: true },
11914
12968
  // modelInfo is NOT advertised: §12.2's result requires all four of
11915
12969
  // {id, vendor, contextWindow, capabilities} and the host has no
11916
12970
  // truthful source for contextWindow/capabilities (grep: none exists).
@@ -12792,6 +13846,20 @@ export class AgentFramework {
12792
13846
  deniedCapabilities: string[];
12793
13847
  /** Host-owned authority; deliberately separate from the portable grant. */
12794
13848
  allowHostCommands: boolean;
13849
+ /**
13850
+ * RFC-007 §10: the server's current tools/observe filter — its own
13851
+ * statement of which calls and argument fields it uses, shown next to
13852
+ * the grant so an operator can narrow toolLifecycle.inputs to match. It
13853
+ * confers nothing. Null = no filter (granted calls, metadata only).
13854
+ */
13855
+ toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
13856
+ /**
13857
+ * RFC-008 §6: each of this server's tools with its effective class and
13858
+ * the source that decided it — `override` (operator), `server` (its own
13859
+ * `_meta["mcpl/class"]`), or `none` (unclassed: observers never see its
13860
+ * arguments). Shown next to the grant so a surprising class is visible.
13861
+ */
13862
+ toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
12795
13863
  /**
12796
13864
  * Per-transport §17 facts about the last manifest this host fetched and
12797
13865
  * acted on. The revision is server-authored and equality-only; these are
@@ -12810,6 +13878,8 @@ export class AgentFramework {
12810
13878
  toolPrefix: string; toolCount: number; policyEstablished: boolean;
12811
13879
  effectiveGrant: string[]; maskedCapabilities: string[];
12812
13880
  deniedCapabilities: string[]; allowHostCommands: boolean;
13881
+ toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
13882
+ toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
12813
13883
  manifestState: {
12814
13884
  lastValidatedRevision: string | null;
12815
13885
  lastFetchedAt: number | null;
@@ -12821,17 +13891,28 @@ export class AgentFramework {
12821
13891
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
12822
13892
  const connection = this.mcplServerRegistry?.getServer(id) ?? null;
12823
13893
  const connected = connection?.isConnected ?? false;
13894
+ // Attribute through the dispatch resolver: a bare prefix match would
13895
+ // also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
13896
+ const ownTools = (this.mcplTools ?? []).flatMap((t) => {
13897
+ const hit = this.resolveMcplTool(t.name);
13898
+ return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
13899
+ });
12824
13900
  result.push({
12825
13901
  id,
12826
13902
  connected,
12827
13903
  retrying: !connected && (connection?.willReconnect ?? false),
12828
13904
  toolPrefix: prefix,
12829
- toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
13905
+ toolCount: ownTools.length,
12830
13906
  policyEstablished: connection?.policyEstablished ?? false,
12831
13907
  effectiveGrant: connection?.grant.effectiveList() ?? [],
12832
13908
  maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
12833
13909
  deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
12834
13910
  allowHostCommands: config.allowHostCommands === true,
13911
+ toolObserveFilter: connection?.toolObserveFilter ?? null,
13912
+ toolClasses: ownTools.map(({ tool, serverTool }) => {
13913
+ const { classes, source } = this.effectiveToolClass(tool, true);
13914
+ return { tool, serverTool, class: classes, source };
13915
+ }),
12835
13916
  manifestState: connection
12836
13917
  ? { ...connection.manifestState }
12837
13918
  : { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
@@ -12870,7 +13951,7 @@ export class AgentFramework {
12870
13951
  ) => {
12871
13952
  const barrier = this.discordAwarenessBarrier;
12872
13953
  if (barrier) await barrier.promise;
12873
- this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
13954
+ await this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
12874
13955
  });
12875
13956
 
12876
13957
  // Handle server-initiated inference requests (Step 6)
@@ -12878,6 +13959,11 @@ export class AgentFramework {
12878
13959
  params: McplInferenceRequestParams,
12879
13960
  responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
12880
13961
  ) => {
13962
+ // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
13963
+ if (this.pushCoalescer?.isRendering(connection.id)) {
13964
+ responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
13965
+ return;
13966
+ }
12881
13967
  if (this.inferenceRouter && responder) {
12882
13968
  const barrier = this.discordAwarenessBarrier;
12883
13969
  if (barrier) await barrier.promise;
@@ -12928,6 +14014,23 @@ export class AgentFramework {
12928
14014
  });
12929
14015
  });
12930
14016
 
14017
+ // RFC-007 §6: the server's filter over tools/lifecycle. Admission already
14018
+ // required toolLifecycle.observe (-32002 otherwise). Validation failures
14019
+ // leave the previous filter in force (§6.6).
14020
+ connection.on('tools-observe', (
14021
+ params: unknown,
14022
+ responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
14023
+ ) => {
14024
+ const parsed = parseToolObserveParams(params);
14025
+ if (!parsed.ok) {
14026
+ if (responder?.respondError) responder.respondError(-32602, parsed.message, parsed.data);
14027
+ else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
14028
+ return;
14029
+ }
14030
+ connection.toolObserveFilter = parsed.rules;
14031
+ responder?.respond({});
14032
+ });
14033
+
12931
14034
  connection.on('model-info', (
12932
14035
  _params: unknown,
12933
14036
  responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
@@ -12951,7 +14054,7 @@ export class AgentFramework {
12951
14054
  ) => {
12952
14055
  const barrier = this.discordAwarenessBarrier;
12953
14056
  if (barrier) await barrier.promise;
12954
- this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
14057
+ await this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
12955
14058
  });
12956
14059
 
12957
14060
  // Handle host-level admin commands from a surface (e.g. Discord /undo)
@@ -13114,6 +14217,10 @@ export class AgentFramework {
13114
14217
  // disconnectMcplServer, which deletes the trees explicitly.
13115
14218
  connection.on('close', (code?: number | null, signal?: string | null) => {
13116
14219
  this.featureSetManager?.removeServer(connection.id);
14220
+ // RFC-008: a closed provider's class hints are stale — on reconnect it
14221
+ // may class its tools differently. Until the next tools/list its tools
14222
+ // are unclassed, the restrictive answer (RFC-007 §4.3).
14223
+ this.dropMcplToolClasses(connection.id);
13117
14224
  this.emitTrace({
13118
14225
  type: 'mcpl:server-closed',
13119
14226
  serverId: connection.id,
@@ -13134,6 +14241,8 @@ export class AgentFramework {
13134
14241
 
13135
14242
  const tools: import('./types/index.js').ToolDefinition[] = [];
13136
14243
  const toolFeatureSets = new Map<string, string>();
14244
+ const toolClasses = new Map<string, ToolClass[]>();
14245
+ const toolServers = new Map<string, string>();
13137
14246
 
13138
14247
  for (const server of this.mcplServerRegistry.getAllServers()) {
13139
14248
  const config = this.mcplServerConfigs.get(server.id);
@@ -13143,10 +14252,15 @@ export class AgentFramework {
13143
14252
  for (const tool of result.tools) {
13144
14253
  if (!isToolAllowed(tool.name, config)) continue;
13145
14254
  const namespacedName = `${prefix}--${tool.name}`;
14255
+ toolServers.set(namespacedName, server.id);
13146
14256
  const attributedTool = tool as typeof tool & {
13147
14257
  featureSet?: unknown;
13148
- _meta?: { featureSet?: unknown };
14258
+ _meta?: { featureSet?: unknown; [key: string]: unknown };
13149
14259
  };
14260
+ // RFC-008: the server's class hint for this tool. Never shown to
14261
+ // the model (the definition below is unchanged by it).
14262
+ const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
14263
+ if (declaredClasses) toolClasses.set(namespacedName, declaredClasses);
13150
14264
  const featureSet = typeof attributedTool.featureSet === 'string'
13151
14265
  ? attributedTool.featureSet
13152
14266
  : typeof attributedTool._meta?.featureSet === 'string'
@@ -13170,6 +14284,8 @@ export class AgentFramework {
13170
14284
 
13171
14285
  this.mcplTools = tools;
13172
14286
  this.mcplToolFeatureSets = toolFeatureSets;
14287
+ this.mcplToolClasses = toolClasses;
14288
+ this.mcplToolServers = toolServers;
13173
14289
  }
13174
14290
 
13175
14291
  /**
@@ -13235,6 +14351,8 @@ export class AgentFramework {
13235
14351
  const server = this.mcplServerRegistry!.getServer(serverId);
13236
14352
 
13237
14353
  if (!server) {
14354
+ // RFC-007: refused before executing — no lifecycle events.
14355
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
13238
14356
  this.pushEvent({
13239
14357
  type: 'tool-result',
13240
14358
  callId: call.id,
@@ -13247,6 +14365,7 @@ export class AgentFramework {
13247
14365
 
13248
14366
  const config = this.mcplServerConfigs.get(serverId);
13249
14367
  if (!isToolAllowed(toolName, config)) {
14368
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
13250
14369
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
13251
14370
  this.pushEvent({
13252
14371
  type: 'tool-result',
@@ -13423,6 +14542,8 @@ export class AgentFramework {
13423
14542
  .catch((error) => {
13424
14543
  const err = error instanceof Error ? error : new Error(String(error));
13425
14544
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
14545
+ // RFC-007: transport error, closed connection or timeout — no result.
14546
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
13426
14547
 
13427
14548
  this.pushEvent({
13428
14549
  type: 'tool-result',
@@ -13449,6 +14570,8 @@ export class AgentFramework {
13449
14570
  const home = this.conversationAgentHomes.get(agentName);
13450
14571
  if (home) {
13451
14572
  const reject = (error: string): void => {
14573
+ // RFC-007: the guard refuses before the tool runs — no lifecycle events.
14574
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
13452
14575
  this.emitTrace({
13453
14576
  type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
13454
14577
  error: `conversation agent ${agentName}: ${error}`,
@@ -13532,6 +14655,7 @@ export class AgentFramework {
13532
14655
  .catch((error) => {
13533
14656
  const err = error instanceof Error ? error : new Error(String(error));
13534
14657
  this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
14658
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
13535
14659
  this.pushEvent({
13536
14660
  type: 'tool-result',
13537
14661
  callId: call.id,
@@ -13770,7 +14894,8 @@ export class AgentFramework {
13770
14894
  if (announce && this.channelRegistry) {
13771
14895
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
13772
14896
  const agent = this.agents.get(agentName);
13773
- if (agent?.proseRouting === 'disabled') {
14897
+ const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
14898
+ if (silentTurn || agent?.proseRouting === 'disabled') {
13774
14899
  console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
13775
14900
  } else if (agent?.proseRouting === 'explicit') {
13776
14901
  // Explicit mode: announce to the turn's sticky prose target if the
@@ -14614,6 +15739,7 @@ export class AgentFramework {
14614
15739
  } catch (error) {
14615
15740
  const err = error instanceof Error ? error : new Error(String(error));
14616
15741
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
15742
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
14617
15743
  result = { success: false, error: err.message, isError: true };
14618
15744
  }
14619
15745
  this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
@@ -14638,6 +15764,7 @@ export class AgentFramework {
14638
15764
  .catch((error) => {
14639
15765
  const err = error instanceof Error ? error : new Error(String(error));
14640
15766
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
15767
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
14641
15768
  this.pushEvent({
14642
15769
  type: 'tool-result',
14643
15770
  callId: call.id,