@animalabs/agent-framework 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) 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 +161 -7
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1183 -87
  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 +74 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +156 -10
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/index.d.ts +3 -0
  33. package/dist/src/mcpl/index.d.ts.map +1 -1
  34. package/dist/src/mcpl/index.js +3 -0
  35. package/dist/src/mcpl/index.js.map +1 -1
  36. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  37. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  38. package/dist/src/mcpl/push-coalescer.js +709 -0
  39. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  40. package/dist/src/mcpl/push-handler.d.ts +7 -2
  41. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  42. package/dist/src/mcpl/push-handler.js +43 -4
  43. package/dist/src/mcpl/push-handler.js.map +1 -1
  44. package/dist/src/mcpl/server-connection.d.ts +18 -0
  45. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  46. package/dist/src/mcpl/server-connection.js +44 -1
  47. package/dist/src/mcpl/server-connection.js.map +1 -1
  48. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  49. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  50. package/dist/src/mcpl/tool-classes.js +170 -0
  51. package/dist/src/mcpl/tool-classes.js.map +1 -0
  52. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  53. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  54. package/dist/src/mcpl/tool-glob.js +41 -0
  55. package/dist/src/mcpl/tool-glob.js.map +1 -0
  56. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  57. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  58. package/dist/src/mcpl/tool-lifecycle.js +592 -0
  59. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  60. package/dist/src/mcpl/transport.d.ts +12 -0
  61. package/dist/src/mcpl/transport.d.ts.map +1 -1
  62. package/dist/src/mcpl/transport.js +33 -1
  63. package/dist/src/mcpl/transport.js.map +1 -1
  64. package/dist/src/mcpl/types.d.ts +66 -1
  65. package/dist/src/mcpl/types.d.ts.map +1 -1
  66. package/dist/src/mcpl/types.js +6 -0
  67. package/dist/src/mcpl/types.js.map +1 -1
  68. package/dist/src/modules/discord/index.js +1 -1
  69. package/dist/src/modules/discord/index.js.map +1 -1
  70. package/dist/src/modules/history/index.d.ts +85 -0
  71. package/dist/src/modules/history/index.d.ts.map +1 -1
  72. package/dist/src/modules/history/index.js +1019 -24
  73. package/dist/src/modules/history/index.js.map +1 -1
  74. package/dist/src/modules/history/semantic.d.ts +202 -0
  75. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  76. package/dist/src/modules/history/semantic.js +418 -0
  77. package/dist/src/modules/history/semantic.js.map +1 -0
  78. package/dist/src/modules/index.d.ts +2 -1
  79. package/dist/src/modules/index.d.ts.map +1 -1
  80. package/dist/src/modules/index.js.map +1 -1
  81. package/dist/src/tool-result-guard.d.ts +82 -0
  82. package/dist/src/tool-result-guard.d.ts.map +1 -0
  83. package/dist/src/tool-result-guard.js +306 -0
  84. package/dist/src/tool-result-guard.js.map +1 -0
  85. package/dist/src/types/agent.d.ts +14 -0
  86. package/dist/src/types/agent.d.ts.map +1 -1
  87. package/dist/src/types/framework.d.ts +21 -0
  88. package/dist/src/types/framework.d.ts.map +1 -1
  89. package/dist/src/types/trace.d.ts +17 -0
  90. package/dist/src/types/trace.d.ts.map +1 -1
  91. package/dist/test/channel-registry-routing.test.js +59 -0
  92. package/dist/test/channel-registry-routing.test.js.map +1 -1
  93. package/dist/test/channels-changed-respond-first.test.d.ts +2 -0
  94. package/dist/test/channels-changed-respond-first.test.d.ts.map +1 -0
  95. package/dist/test/channels-changed-respond-first.test.js +74 -0
  96. package/dist/test/channels-changed-respond-first.test.js.map +1 -0
  97. package/dist/test/code-execution.test.js +139 -0
  98. package/dist/test/code-execution.test.js.map +1 -1
  99. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  100. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  101. package/dist/test/helpers/coalescing-fixture.js +145 -0
  102. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  103. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  104. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  105. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  106. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  107. package/dist/test/history-module-realstore.test.d.ts +2 -0
  108. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  109. package/dist/test/history-module-realstore.test.js +363 -0
  110. package/dist/test/history-module-realstore.test.js.map +1 -0
  111. package/dist/test/history-module-semantic.test.d.ts +2 -0
  112. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  113. package/dist/test/history-module-semantic.test.js +213 -0
  114. package/dist/test/history-module-semantic.test.js.map +1 -0
  115. package/dist/test/history-module-ux.test.d.ts +2 -0
  116. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  117. package/dist/test/history-module-ux.test.js +304 -0
  118. package/dist/test/history-module-ux.test.js.map +1 -0
  119. package/dist/test/history-module.test.js +25 -1
  120. package/dist/test/history-module.test.js.map +1 -1
  121. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  122. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  123. package/dist/test/history-semantic-review-findings.test.js +338 -0
  124. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  125. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  126. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  127. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  128. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  129. package/dist/test/journal-tool.test.d.ts +2 -0
  130. package/dist/test/journal-tool.test.d.ts.map +1 -0
  131. package/dist/test/journal-tool.test.js +33 -0
  132. package/dist/test/journal-tool.test.js.map +1 -0
  133. package/dist/test/mcpl-capability-grant.test.js +3 -1
  134. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  135. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  136. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  137. package/dist/test/mcpl-child-env.test.js +69 -0
  138. package/dist/test/mcpl-child-env.test.js.map +1 -0
  139. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  140. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  141. package/dist/test/mcpl-coalescing.test.js +1050 -0
  142. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  143. package/dist/test/mcpl-list-status.test.js +4 -0
  144. package/dist/test/mcpl-list-status.test.js.map +1 -1
  145. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  146. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  147. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  148. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  149. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  150. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  151. package/dist/test/mcpl-tool-classes.test.js +80 -0
  152. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  153. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  154. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  155. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  156. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  157. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  158. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  159. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  160. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  161. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  162. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  163. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  164. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  165. package/dist/test/overbudget-drain-breaker.test.js +44 -4
  166. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  167. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  168. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  169. package/dist/test/silent-heartbeat.test.js +154 -0
  170. package/dist/test/silent-heartbeat.test.js.map +1 -0
  171. package/dist/test/tool-result-guard.test.d.ts +2 -0
  172. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  173. package/dist/test/tool-result-guard.test.js +1081 -0
  174. package/dist/test/tool-result-guard.test.js.map +1 -0
  175. package/dist/test/trunk-channel-routing.test.js +67 -0
  176. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  177. package/dist/tsconfig.tsbuildinfo +1 -1
  178. package/package.json +2 -2
  179. package/src/agent.ts +128 -10
  180. package/src/api/types.ts +2 -0
  181. package/src/code-execution/py-runner.ts +34 -2
  182. package/src/code-execution/tool-definition.ts +46 -0
  183. package/src/framework.ts +1215 -91
  184. package/src/index.ts +13 -0
  185. package/src/mcpl/capability-grant.ts +54 -6
  186. package/src/mcpl/channel-registry.ts +170 -10
  187. package/src/mcpl/index.ts +29 -0
  188. package/src/mcpl/push-coalescer.ts +821 -0
  189. package/src/mcpl/push-handler.ts +40 -4
  190. package/src/mcpl/server-connection.ts +48 -2
  191. package/src/mcpl/tool-classes.ts +188 -0
  192. package/src/mcpl/tool-glob.ts +36 -0
  193. package/src/mcpl/tool-lifecycle.ts +730 -0
  194. package/src/mcpl/transport.ts +35 -1
  195. package/src/mcpl/types.ts +69 -1
  196. package/src/modules/discord/index.ts +1 -1
  197. package/src/modules/history/index.ts +1089 -24
  198. package/src/modules/history/semantic.ts +473 -0
  199. package/src/modules/index.ts +2 -1
  200. package/src/tool-result-guard.ts +318 -0
  201. package/src/types/agent.ts +15 -0
  202. package/src/types/framework.ts +23 -0
  203. package/src/types/trace.ts +17 -0
package/src/framework.ts CHANGED
@@ -5,7 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF
5
5
  import { JsStore } from '@animalabs/chronicle';
6
6
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
7
7
  import { MembraneError } from '@animalabs/membrane';
8
- import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
8
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
9
9
  import type { CacheWireReceipt } from './kv-unified-wire.js';
10
10
  import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
11
11
  import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
@@ -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,31 @@ 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
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
6998
+ if (event.triggerInference) {
6999
+ this.pendingRequests.push({
7000
+ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
7001
+ channelId: event.channelId,
7002
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
7003
+ addressed: isAddressedMessage(event.tags, event.metadata),
7004
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
7005
+ });
7006
+ }
7007
+ return { agent: target.name, messageId: id };
7008
+ }
7009
+ if (this.conversationRouter) return undefined; // the trunk never holds channel traffic
7010
+ event = { ...event, targetAgents: [target.name] };
7011
+ } else if (this.conversationRouter) {
7012
+ return this.routeConversationIncoming(event, metadata);
6776
7013
  }
6777
7014
 
6778
7015
  // Addressed-while-closed invitation — parity with the push-event path
@@ -6819,7 +7056,8 @@ export class AgentFramework {
6819
7056
  metadata.tuneOut = { epochId: divert.epochId };
6820
7057
  }
6821
7058
 
6822
- const id = this.addMessage('user', incomingContent, metadata);
7059
+ const placement: CoalescingPlacement = { agent: '' };
7060
+ const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor });
6823
7061
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
6824
7062
 
6825
7063
  if (event.triggerInference && !divert) {
@@ -6845,9 +7083,11 @@ export class AgentFramework {
6845
7083
  // Addressed messages outrank ambient chatter when a batched wake
6846
7084
  // picks the turn's frozen speech locus.
6847
7085
  addressed,
7086
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6848
7087
  });
6849
7088
  }
6850
7089
  }
7090
+ return placement.agent ? placement : undefined;
6851
7091
  }
6852
7092
 
6853
7093
  /**
@@ -6866,9 +7106,10 @@ export class AgentFramework {
6866
7106
  metadata?: Record<string, unknown>;
6867
7107
  tags?: string[];
6868
7108
  triggerInference?: boolean;
7109
+ coalescingSubject?: string;
6869
7110
  },
6870
7111
  messageMetadata: Record<string, unknown>,
6871
- ): Promise<void> {
7112
+ ): Promise<CoalescingPlacement | undefined> {
6872
7113
  const router = this.conversationRouter!;
6873
7114
  const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
6874
7115
 
@@ -6945,8 +7186,10 @@ export class AgentFramework {
6945
7186
  channelId: event.channelId,
6946
7187
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
6947
7188
  addressed: isAddressedMessage(event.tags, event.metadata),
7189
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
6948
7190
  });
6949
7191
  }
7192
+ return { agent: agent.name, messageId: id };
6950
7193
  }
6951
7194
 
6952
7195
  /**
@@ -6999,6 +7242,8 @@ export class AgentFramework {
6999
7242
 
7000
7243
  const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
7001
7244
  const agent = new Agent(config, contextManager, this.membrane);
7245
+ this.restoreToolResultGuardSetting(agent);
7246
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
7002
7247
  this.agents.set(name, agent);
7003
7248
  this.agentConfigs.set(name, config);
7004
7249
  this.conversationAgentHomes.set(name, channelId);
@@ -7177,6 +7422,19 @@ export class AgentFramework {
7177
7422
  const label = opts.channelLabel ?? descriptor?.label;
7178
7423
  const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
7179
7424
  const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
7425
+ // The label is for reading; the prefix must be ONE token that resolves back
7426
+ // here, on this server (a DM label "DM: alice" quoted verbatim parses as
7427
+ // target "#DM:"). A registry that can name targets is authoritative: no safe
7428
+ // token means no prefix option. A stand-in without proseTargetFor gets a
7429
+ // whitespace-free guess, never a label with spaces.
7430
+ const namer = this.channelRegistry as { proseTargetFor?: (id: string, serverId?: string) => string | undefined };
7431
+ const target = typeof namer.proseTargetFor === 'function'
7432
+ ? namer.proseTargetFor(opts.channelId, opts.serverId)
7433
+ : [label ? `#${label}` : undefined, opts.channelId].find((t): t is string => !!t && !/\s/.test(t));
7434
+ const replyOption = target
7435
+ ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` +
7436
+ `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`
7437
+ : `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`;
7180
7438
  const missedCount = opts.missedMessages ?? 0;
7181
7439
  const missedNote =
7182
7440
  missedCount > 0
@@ -7192,8 +7450,7 @@ export class AgentFramework {
7192
7450
  `a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
7193
7451
  missedNote +
7194
7452
  `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` +
7453
+ replyOption +
7197
7454
  `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
7198
7455
  (maxBackscroll > 0
7199
7456
  ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
@@ -7213,10 +7470,454 @@ export class AgentFramework {
7213
7470
  };
7214
7471
  }
7215
7472
 
7473
+ // ==========================================================================
7474
+ // RFC-006 event coalescing (see mcpl/push-coalescer.ts for the design)
7475
+ // ==========================================================================
7476
+
7477
+ /**
7478
+ * RFC-006 §3.2 binding identity: the configured server id bound to its
7479
+ * endpoint/command. Survives reconnects to the same peer; changes when the
7480
+ * id is reassigned to another endpoint (restartMcplServer with a new config).
7481
+ */
7482
+ private coalescingBinding(serverId: string): string {
7483
+ const config = this.mcplServerConfigs.get(serverId);
7484
+ return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16);
7485
+ }
7486
+
7487
+ private initializePushCoalescer(): void {
7488
+ const coalescer = new PushCoalescer<CoalescedDelivery>({
7489
+ isUnread: (p) => this.isUnreadPlacement(p),
7490
+ remove: (p) => this.removePlacement(p),
7491
+ deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor),
7492
+ wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ),
7493
+ cancelWake: (subject) => this.cancelCoalescedWakes(subject),
7494
+ authorized: (occ) => this.coalescedAuthorized(occ),
7495
+ audience: (occ) => this.coalescedAudience(occ),
7496
+ render: (occ, params) => {
7497
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
7498
+ if (!server?.isConnected) throw new Error('server disconnected');
7499
+ return server.sendPushRender(params);
7500
+ },
7501
+ audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
7502
+ save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
7503
+ recordReceipt: (record) => {
7504
+ // Written now, not at the throttled flush: an acknowledged acceptance
7505
+ // must not lose its receipt, the subject's birth, or (deferred) the
7506
+ // accepted work itself to a crash. A failure here fails the acceptance.
7507
+ this.coalescingRecentReceipts.push(record);
7508
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
7509
+ },
7510
+ wasPublished: (subject, eventId) => {
7511
+ // Boot-time only: the occurrence's durable delivery identity
7512
+ // (subject + eventId in message metadata) in any agent's context.
7513
+ for (const agent of this.agents.values()) {
7514
+ try {
7515
+ if (agent.getContextManager().getAllMessages().some((m) =>
7516
+ m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId)) return true;
7517
+ } catch { /* a context that cannot be read cannot prove publication */ }
7518
+ }
7519
+ return false;
7520
+ },
7521
+ commit: () => this.commitCoalescingDurable(),
7522
+ saveNow: (snapshot) => {
7523
+ if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
7524
+ this.coalescingSnapshotDirty = null;
7525
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot);
7526
+ this.coalescingRecentReceipts = [];
7527
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
7528
+ },
7529
+ });
7530
+ try {
7531
+ coalescer.restore(this.store.getStateJson(COALESCING_STATE_ID) as CoalescingSnapshot | null);
7532
+ const recent = this.store.getStateJson(COALESCING_RECENT_ID) as CoalescingReceiptRecord[] | null;
7533
+ if (Array.isArray(recent)) {
7534
+ coalescer.restoreReceipts(recent);
7535
+ // Until the next snapshot flush checkpoints the recovered state, these
7536
+ // records are its only durable form: keep them in the writable buffer
7537
+ // so the next acceptance appends to them instead of replacing them.
7538
+ this.coalescingRecentReceipts = recent;
7539
+ }
7540
+ } catch (err) {
7541
+ console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err);
7542
+ }
7543
+ this.pushCoalescer = coalescer;
7544
+ // Batches restored from a snapshot still owe the agent a wake (§14 v41).
7545
+ for (const occ of coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ);
7546
+ }
7547
+
7548
+ /**
7549
+ * Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
7550
+ * written so far is fsynced before the caller proceeds. Group-committed —
7551
+ * all acceptances that arrive in one event-loop turn share one
7552
+ * `store.sync()` — so throughput is bounded by fsync rate per batch, not
7553
+ * per message.
7554
+ */
7555
+ private commitCoalescingDurable(): Promise<void> {
7556
+ if (!this.coalescingCommit) {
7557
+ this.coalescingCommit = new Promise<void>((resolve, reject) => {
7558
+ setImmediate(() => {
7559
+ this.coalescingCommit = null;
7560
+ try { this.store.sync(); resolve(); } catch (err) { reject(err); }
7561
+ });
7562
+ });
7563
+ }
7564
+ return this.coalescingCommit;
7565
+ }
7566
+
7567
+ private scheduleCoalescingSnapshot(snapshot: () => CoalescingSnapshot): void {
7568
+ this.coalescingSnapshotDirty = snapshot;
7569
+ if (this.coalescingSaveTimer) return;
7570
+ const timer = setTimeout(() => this.flushCoalescingSnapshot(), 2_000);
7571
+ timer.unref?.();
7572
+ this.coalescingSaveTimer = timer;
7573
+ }
7574
+
7575
+ private flushCoalescingSnapshot(): void {
7576
+ if (this.coalescingSaveTimer) { clearTimeout(this.coalescingSaveTimer); this.coalescingSaveTimer = null; }
7577
+ const snapshot = this.coalescingSnapshotDirty;
7578
+ this.coalescingSnapshotDirty = null;
7579
+ if (!snapshot) return;
7580
+ try {
7581
+ this.store.setStateJson(COALESCING_STATE_ID, snapshot());
7582
+ this.coalescingRecentReceipts = [];
7583
+ this.store.setStateJson(COALESCING_RECENT_ID, []);
7584
+ } catch (err) {
7585
+ console.error('[coalescing] could not persist coalescing state:', err);
7586
+ }
7587
+ }
7588
+
7589
+ /** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
7590
+ private admitCoalesced<T>(work: () => Promise<T>): Promise<T> {
7591
+ const run = this.coalescingAdmission.then(work, work);
7592
+ this.coalescingAdmission = run.then(() => undefined, () => undefined);
7593
+ return run;
7594
+ }
7595
+
7596
+ private handleCoalescedIncoming(
7597
+ serverId: string,
7598
+ message: ChannelIncomingMessage,
7599
+ event: CoalescedChannelEvent,
7600
+ ): Promise<ChannelIncomingMessageResult> {
7601
+ return this.admitCoalesced(async () => {
7602
+ if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
7603
+ validateCoalesceMember(message.coalesce, 'channel');
7604
+ if (typeof message.eventId !== 'string' || !message.eventId) {
7605
+ throw new CoalesceError('eventId', 'eventId is required with coalesce');
7606
+ }
7607
+ if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
7608
+ validateCoalescedContent(message.content);
7609
+ const c = message.coalesce;
7610
+ const result = await this.pushCoalescer.accept({
7611
+ serverId,
7612
+ binding: this.coalescingBinding(serverId),
7613
+ scope: { kind: 'channel', id: message.channelId },
7614
+ key: c.key,
7615
+ eventId: message.eventId,
7616
+ timestamp: message.timestamp,
7617
+ retract: !!c.retract,
7618
+ deferred: false,
7619
+ initial: !!c.initial,
7620
+ tags: event.tags,
7621
+ content: message.content,
7622
+ identity: { messageId: message.messageId, author: message.author, threadId: message.threadId },
7623
+ event: { lane: 'channel', event: { ...event, eventId: message.eventId } },
7624
+ });
7625
+ return { messageId: message.messageId, accepted: true, coalesce: result.coalesce };
7626
+ });
7627
+ }
7628
+
7629
+ private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise<PushEventResult> {
7630
+ return this.admitCoalesced(async () => {
7631
+ if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000);
7632
+ validateCoalesceMember(params.coalesce, 'push');
7633
+ if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
7634
+ if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
7635
+ validateCoalescedContent(params.payload?.content);
7636
+ const c = params.coalesce;
7637
+ if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId);
7638
+ const origin = params.origin ?? {};
7639
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
7640
+ return this.pushCoalescer.accept({
7641
+ serverId,
7642
+ binding: this.coalescingBinding(serverId),
7643
+ featureSet: params.featureSet,
7644
+ scope: c.channelId ? { kind: 'channel', id: c.channelId } : { kind: 'featureSet', id: params.featureSet },
7645
+ key: c.key,
7646
+ eventId: params.eventId,
7647
+ timestamp: params.timestamp,
7648
+ retract: !!c.retract,
7649
+ deferred: !!c.deferred,
7650
+ initial: !!c.initial,
7651
+ data: c.data,
7652
+ tags: event.tags,
7653
+ content: params.payload.content,
7654
+ ...(c.channelId ? {
7655
+ identity: {
7656
+ ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}),
7657
+ ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}),
7658
+ ...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}),
7659
+ },
7660
+ } : {}),
7661
+ event: { lane: 'push', event },
7662
+ });
7663
+ });
7664
+ }
7665
+
7666
+ /** §3.2: a channel-scoped push needs what a channels/incoming message needs. */
7667
+ private assertChannelScopedPush(serverId: string, channelId: string): void {
7668
+ const server = this.mcplServerRegistry?.getServer(serverId);
7669
+ if (!server?.policyEstablished || !server.grant.has('channels.incoming')) {
7670
+ throw new CoalesceError('coalesce.channelId', 'Channel not permitted', -32017);
7671
+ }
7672
+ if (!this.channelRegistry?.isDeclaredChannel(serverId, channelId)) {
7673
+ throw new CoalesceError('coalesce.channelId', 'Unknown channel', -32023);
7674
+ }
7675
+ }
7676
+
7677
+ private coalescedAuthorized(occ: CoalescedOccurrence<CoalescedDelivery>): boolean {
7678
+ const server = this.mcplServerRegistry?.getServer(occ.serverId);
7679
+ if (!server?.isConnected || !server.policyEstablished) return false;
7680
+ // §3.2: work admitted under another binding (the id since reassigned to a
7681
+ // different endpoint) never acquires the replacement peer's authority —
7682
+ // and its private notice data is never sent to that peer.
7683
+ if (occ.binding !== this.coalescingBinding(occ.serverId)) return false;
7684
+ if (occ.event.lane === 'push') {
7685
+ if (!server.grant.has('pushEvents')) return false;
7686
+ try { this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet); } catch { return false; }
7687
+ }
7688
+ if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') {
7689
+ // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane.
7690
+ if (!server.grant.has('channels.incoming')) return false;
7691
+ if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false;
7692
+ return !!this.channelRegistry?.getDescriptor(occ.scope.id);
7693
+ }
7694
+ return true;
7695
+ }
7696
+
7697
+ private async coalescedAudience(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<string[]> {
7698
+ if (occ.deliverTo) return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : [];
7699
+ const explicit = occ.event.event.targetAgents;
7700
+ if (explicit) return explicit.filter((n) => this.agents.has(n));
7701
+ if (occ.scope.kind === 'channel' && this.conversationRouter) {
7702
+ const router = this.conversationRouter;
7703
+ const bound = router.getBinding(occ.scope.id)?.agentName;
7704
+ if (bound) return this.agents.has(bound) ? [bound] : [];
7705
+ // No fork yet: the same bind decision an incoming message would get. A
7706
+ // batch that qualifies spawns its fork now so it has someone to wake.
7707
+ const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {};
7708
+ const decision = router.route({
7709
+ channelId: occ.scope.id,
7710
+ mentioned: metadata.mentioned === true,
7711
+ kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata),
7712
+ });
7713
+ if (decision.kind !== 'spawn') return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []);
7714
+ try {
7715
+ await this.createConversationAgent(decision.agentName, occ.scope.id);
7716
+ router.bind(occ.scope.id, decision.agentName, decision.generation);
7717
+ this.persistConversationRouterState();
7718
+ return [decision.agentName];
7719
+ } catch (err) {
7720
+ console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
7721
+ return [];
7722
+ }
7723
+ }
7724
+ return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
7725
+ }
7726
+
7727
+ /** Deliver through the lane's ordinary path; report where it landed. */
7728
+ private async deliverCoalesced(
7729
+ occ: CoalescedOccurrence<CoalescedDelivery>,
7730
+ materialized?: McplContentBlock[],
7731
+ assemblingFor?: string,
7732
+ ): Promise<CoalescingPlacement | undefined> {
7733
+ const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
7734
+ const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {};
7735
+ if (occ.event.lane === 'channel') {
7736
+ const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) };
7737
+ const placement = await this.handleMcplChannelIncoming(event);
7738
+ await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
7739
+ return placement;
7740
+ }
7741
+ if (occ.scope.kind === 'channel') {
7742
+ // §3.2: a channel-scoped push addresses the channel's subject namespace,
7743
+ // so it is delivered as a message OF that channel — same routing
7744
+ // (conversation forks, tune-out, closed-channel invitation), same reply
7745
+ // locus, and the subject's stable platform identity (§3.1, vector 43).
7746
+ const push = occ.event.event;
7747
+ const identity = { ...this.pushCoalescer?.identity(coalescingSubject), ...occ.identity };
7748
+ const origin = push.origin ?? {};
7749
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
7750
+ const event: CoalescedChannelEvent = {
7751
+ type: 'mcpl:channel-incoming',
7752
+ serverId: occ.serverId,
7753
+ channelId: occ.scope.id,
7754
+ messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
7755
+ threadId: identity.threadId ?? str(origin.threadId),
7756
+ author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
7757
+ content: materialized ? materialized.map(convertPushBlock) : push.content,
7758
+ timestamp: occ.timestamp,
7759
+ metadata: origin,
7760
+ tags: push.tags,
7761
+ triggerInference: assemblingFor ? false : push.triggerInference,
7762
+ targetAgents: push.targetAgents,
7763
+ coalescingSubject,
7764
+ eventId: occ.eventId,
7765
+ ...narrowing,
7766
+ ...(assemblingFor ? { assemblingFor } : {}),
7767
+ };
7768
+ const placement = await this.handleMcplChannelIncoming(event);
7769
+ await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
7770
+ return placement;
7771
+ }
7772
+ const event: McplPushEvent = {
7773
+ ...occ.event.event,
7774
+ eventId: occ.eventId,
7775
+ timestamp: occ.timestamp,
7776
+ ...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
7777
+ coalescingSubject,
7778
+ // §5.2: the materialized occurrence is consumed by the request being
7779
+ // assembled; the wake that started it is not repeated.
7780
+ ...(assemblingFor ? { assemblingFor, triggerInference: false } : {}),
7781
+ ...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}),
7782
+ };
7783
+ const placement = this.handleMcplPushEvent(event);
7784
+ await this.dispatchProcessEventToModules(event as unknown as ProcessEvent);
7785
+ return placement;
7786
+ }
7787
+
7788
+ private async wakeForCoalescedBatch(occ: CoalescedOccurrence<CoalescedDelivery>): Promise<void> {
7789
+ const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
7790
+ this.cancelCoalescedWakes(subject);
7791
+ if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return;
7792
+ const origin = occ.event.event.origin;
7793
+ // The wake carries the batch's channel so the turn freezes its reply
7794
+ // locus there (the materialized delivery at assembly wakes nobody).
7795
+ const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
7796
+ const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
7797
+ for (const agentName of await this.coalescedAudience(occ)) {
7798
+ this.pendingRequests.push({
7799
+ agentName,
7800
+ reason: 'mcpl:push-event',
7801
+ source: occ.serverId,
7802
+ timestamp: Date.now(),
7803
+ channelId,
7804
+ counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
7805
+ addressed: isAddressedMessage(occ.tags, origin),
7806
+ coalescingSubject: subject,
7807
+ });
7808
+ }
7809
+ }
7810
+
7811
+ /** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
7812
+ private cancelCoalescedWakes(subject: string): void {
7813
+ this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
7814
+ for (const cooldown of this.providerAccelerationCooldowns.values()) {
7815
+ cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
7816
+ }
7817
+ }
7818
+
7819
+ /**
7820
+ * A placement in the deferred queue is upgraded in place once the flush has
7821
+ * stored it: the stored copy carries `metadata.deferredWriteId`, found by
7822
+ * scanning the unread tail (above the watermark) — never below it.
7823
+ */
7824
+ private resolvePlacement(p: CoalescingPlacement): void {
7825
+ if (!p.deferredId || this.deferredMessages.some((m) => m.id === p.deferredId)) return;
7826
+ const agent = this.agents.get(p.agent);
7827
+ if (!agent) return;
7828
+ const cm = agent.getContextManager();
7829
+ const watermark = agent.getConsumedWatermark();
7830
+ const floor = watermark && watermark.branch === cm.currentBranch().name ? watermark.sequence : Number.POSITIVE_INFINITY;
7831
+ const all = cm.getAllMessages();
7832
+ for (let i = all.length - 1; i >= 0 && all[i].sequence > floor; i--) {
7833
+ if (all[i].metadata?.deferredWriteId === p.deferredId) {
7834
+ p.messageId = all[i].id;
7835
+ delete p.deferredId;
7836
+ return;
7837
+ }
7838
+ }
7839
+ }
7840
+
7841
+ private isUnreadPlacement(p: CoalescingPlacement): boolean {
7842
+ this.resolvePlacement(p);
7843
+ if (p.deferredId) return this.deferredMessages.some((m) => m.id === p.deferredId);
7844
+ if (!p.messageId) return false;
7845
+ const agent = this.agents.get(p.agent);
7846
+ return !!agent && this.isUnreadStoredMessage(agent, p.messageId);
7847
+ }
7848
+
7849
+ /**
7850
+ * Unread = stored, above the agent's consumed watermark on the current
7851
+ * branch, not a body-group shard (immutable), and not folded into any
7852
+ * summary (compression is consumption, §3.3). Conservative on every doubt.
7853
+ */
7854
+ private isUnreadStoredMessage(agent: Agent, messageId: MessageId): boolean {
7855
+ const cm = agent.getContextManager();
7856
+ let message: ReturnType<ContextManager['getMessage']>;
7857
+ try { message = cm.getMessage(messageId); } catch { return false; }
7858
+ if (!message || message.bodyGroupId) return false;
7859
+ // Every agent that can read this message must not have compiled past it.
7860
+ // Residents share one message slot, so a message one of them read is
7861
+ // history for all of them (the never-rewrite rule is about the message,
7862
+ // not the agent that happened to be asked). A tune-out-diverted message
7863
+ // never enters a resident's compiled view; only the subconscious reads
7864
+ // it through its merged view — no subconscious, nobody reads it.
7865
+ let readers: Agent[];
7866
+ if ((message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut) {
7867
+ const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
7868
+ if (!subconscious) return true;
7869
+ readers = [subconscious];
7870
+ } else {
7871
+ readers = this.sharedSlotAgents.has(agent)
7872
+ ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
7873
+ : [agent];
7874
+ }
7875
+ const branch = cm.currentBranch().name;
7876
+ const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
7877
+ for (const reader of readers) {
7878
+ const watermark = reader.getConsumedWatermark();
7879
+ if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence) return false;
7880
+ // Compression is consumption (§3.3): folded into any reader's summary.
7881
+ try {
7882
+ const rcm = reader.getContextManager();
7883
+ const strategy = rcm.getStrategy() as { getSummary?: (id: string) => { sourceIds?: string[]; sourceLevel?: number } | null };
7884
+ for (const summary of rcm.getSummariesInRange({ fromMs: ts, toMs: ts })) {
7885
+ const entry = strategy.getSummary?.(summary.id);
7886
+ if (!entry || (entry.sourceLevel ?? 0) > 0 || entry.sourceIds?.includes(messageId)) return false;
7887
+ }
7888
+ } catch {
7889
+ return false;
7890
+ }
7891
+ }
7892
+ return true;
7893
+ }
7894
+
7895
+ private removePlacement(p: CoalescingPlacement): boolean {
7896
+ this.resolvePlacement(p);
7897
+ if (p.deferredId) {
7898
+ const before = this.deferredMessages.length;
7899
+ this.deferredMessages = this.deferredMessages.filter((m) => m.id !== p.deferredId);
7900
+ if (this.deferredMessages.length === before) return false;
7901
+ if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
7902
+ return true;
7903
+ }
7904
+ if (!p.messageId) return false;
7905
+ const agent = this.agents.get(p.agent);
7906
+ if (!agent) return false;
7907
+ try {
7908
+ agent.getContextManager().removeMessage(p.messageId);
7909
+ this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
7910
+ return true;
7911
+ } catch (err) {
7912
+ console.error(`[coalescing] could not remove unread message ${p.messageId} for ${agent.name}:`, err);
7913
+ return false;
7914
+ }
7915
+ }
7916
+
7216
7917
  /**
7217
7918
  * Convert an MCPL push event to a context message.
7218
7919
  */
7219
- private handleMcplPushEvent(event: McplPushEvent): void {
7920
+ private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined {
7220
7921
  const triggerChannel = this.derivePushEventChannel(event.origin);
7221
7922
  if (triggerChannel && this.channelRegistry) {
7222
7923
  this.channelRegistry.ensureChannelRegistered(
@@ -7234,6 +7935,7 @@ export class AgentFramework {
7234
7935
  eventId: event.eventId,
7235
7936
  triggered: event.triggerInference ?? false,
7236
7937
  ...(event.tags ? { tags: event.tags } : {}),
7938
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
7237
7939
  };
7238
7940
  // `origin.channelId` is the server-internal raw id (a bare Discord
7239
7941
  // snowflake) — unroutable as a locus and unresolvable by the agent. Store
@@ -7266,8 +7968,24 @@ export class AgentFramework {
7266
7968
  }
7267
7969
  }
7268
7970
 
7269
- const id = this.addMessage('user', content, metadata);
7270
- this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
7971
+ // Silent heartbeat ticks are control-plane wakes, not autobiography.
7972
+ // Accept the no-message path only for the heartbeat feature's own exact
7973
+ // marker with an empty payload — arbitrary MCPL servers cannot hide
7974
+ // content merely by setting `origin.silent`.
7975
+ const silentHeartbeat =
7976
+ event.serverId === 'heartbeat' &&
7977
+ event.featureSet === 'heartbeat' &&
7978
+ event.origin?.source === 'heartbeat' &&
7979
+ event.origin?.silent === true &&
7980
+ content.length === 0;
7981
+
7982
+ const placement: CoalescingPlacement = { agent: '' };
7983
+ if (!silentHeartbeat) {
7984
+ const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
7985
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
7986
+ } else {
7987
+ console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
7988
+ }
7271
7989
 
7272
7990
  if (event.triggerInference) {
7273
7991
  // Default broadcast excludes conversation forks (channel-driven).
@@ -7285,9 +8003,17 @@ export class AgentFramework {
7285
8003
  // they must outrank ambient chatter in a batched wake's locus
7286
8004
  // selection just like their channels/incoming counterparts.
7287
8005
  addressed: isAddressedMessage(event.tags, event.origin),
8006
+ ...(silentHeartbeat ? {
8007
+ suppressProse: true,
8008
+ ephemeralSystemPrompt:
8009
+ '[silent heartbeat] Scheduled private self-check. Review pending matters privately. ' +
8010
+ 'Do not narrate or publish plain prose; use an explicit send tool only if you deliberately choose to contact someone.',
8011
+ } : {}),
8012
+ ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
7288
8013
  });
7289
8014
  }
7290
8015
  }
8016
+ return placement.agent ? placement : undefined;
7291
8017
  }
7292
8018
 
7293
8019
  /**
@@ -7445,7 +8171,9 @@ export class AgentFramework {
7445
8171
  // route the resumed turn into the wrong channel.
7446
8172
  const newestByKey = new Map<string, InferenceRequest>();
7447
8173
  for (const request of parked) {
7448
- const key = `${request.reason}|${request.addressed ? 'a' : 'n'}`;
8174
+ // A coalesced wake is withdrawable by its subject (RFC-006 §4.2); it
8175
+ // must not absorb an unrelated wake that would vanish with it.
8176
+ const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`;
7449
8177
  const prev = newestByKey.get(key);
7450
8178
  if (!prev || request.timestamp >= prev.timestamp) {
7451
8179
  newestByKey.set(key, request);
@@ -7574,8 +8302,13 @@ export class AgentFramework {
7574
8302
  if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
7575
8303
  }
7576
8304
  }
8305
+ // A silent tick never suppresses a genuine coalesced wake. If any
8306
+ // ordinary request shares this batch, the ordinary turn semantics win.
8307
+ const silentOnly = requests.every((r) => r.suppressProse === true);
7577
8308
  await this.startAgentStream(agent, {
7578
8309
  ...trigger,
8310
+ suppressProse: silentOnly ? trigger?.suppressProse : undefined,
8311
+ ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
7579
8312
  channelId: channelReq?.channelId,
7580
8313
  addressed: addressedReq !== undefined,
7581
8314
  // A context-budget restart continues the same logical turn: it keeps
@@ -8140,6 +8873,9 @@ export class AgentFramework {
8140
8873
  turnToken: number,
8141
8874
  ownsProviderGate: boolean,
8142
8875
  ): Promise<boolean> {
8876
+ const continuingTurn = trigger?.reason === 'context_budget_restart'
8877
+ || trigger?.reason === 'tool_result_guard_retry';
8878
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
8143
8879
  // Flush messages deferred during the PREVIOUS turn — before the
8144
8880
  // checkpoint, the locus announcement, and the compile — so a turn started
8145
8881
  // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
@@ -8156,7 +8892,7 @@ export class AgentFramework {
8156
8892
  // products — undoing the turn must not destroy them.
8157
8893
  if (
8158
8894
  attempt === 0 &&
8159
- trigger?.reason !== 'context_budget_restart' &&
8895
+ !continuingTurn &&
8160
8896
  this.deferredMessages.length > 0
8161
8897
  ) {
8162
8898
  {
@@ -8182,8 +8918,20 @@ export class AgentFramework {
8182
8918
  }
8183
8919
  }
8184
8920
 
8921
+ // RFC-006 §5: deferred batches for this agent render now, at the assembly
8922
+ // boundary — after the deferred flush (same window: turn alive, compile
8923
+ // not yet run) and before the checkpoint (they are the turn's inputs).
8924
+ // Not on a context-budget restart: that continues the same logical turn.
8925
+ if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
8926
+ try {
8927
+ await this.pushCoalescer.assemble(agent.name);
8928
+ } catch (err) {
8929
+ console.error(`[coalescing] assembly for ${agent.name} failed:`, err);
8930
+ }
8931
+ }
8932
+
8185
8933
  // Record turn checkpoint before inference (only on first attempt, not retries)
8186
- if (attempt === 0) {
8934
+ if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
8187
8935
  this.recordTurnCheckpoint(agent.name);
8188
8936
  this.redoStacks.delete(agent.name); // new work invalidates redo
8189
8937
  }
@@ -8201,7 +8949,7 @@ export class AgentFramework {
8201
8949
  // after the restart fell through to the hours-old defaultPublishChannel.
8202
8950
  // Keep the turn's trigger channel across restarts; only real new turns
8203
8951
  // reset it.
8204
- if (trigger?.reason !== 'context_budget_restart') {
8952
+ if (!continuingTurn) {
8205
8953
  if (trigger?.channelId) {
8206
8954
  this.activeTriggerChannels.set(agent.name, trigger.channelId);
8207
8955
  } else {
@@ -8221,12 +8969,12 @@ export class AgentFramework {
8221
8969
  // BEFORE this turn compiles, so the agent always knows where its voice
8222
8970
  // goes (announce-on-change only — no per-turn chatter, append-only for
8223
8971
  // KV stability).
8224
- if (trigger?.reason === 'context_budget_restart') {
8972
+ if (continuingTurn) {
8225
8973
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
8226
8974
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
8227
8975
  }
8228
8976
 
8229
- if (trigger?.reason !== 'context_budget_restart') {
8977
+ if (!continuingTurn) {
8230
8978
  if (attempt === 0) this.maybePrimeProseMode(agent);
8231
8979
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
8232
8980
  if (attempt === 0) {
@@ -8243,8 +8991,8 @@ export class AgentFramework {
8243
8991
  this.turnProseDeliveries.delete(agent.name);
8244
8992
  this.turnProseSuppressed.delete(agent.name);
8245
8993
  this.proseHybridSuppressed.delete(agent.name);
8246
- if (agent.proseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
8247
- if (agent.proseRouting === 'explicit' || agent.proseRouting === 'disabled') {
8994
+ if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
8995
+ if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
8248
8996
  // Explicit and disabled prose routing have no inferred locus.
8249
8997
  // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
8250
8998
  // prose target at all. Neither mode freezes or announces a locus.
@@ -8261,6 +9009,15 @@ export class AgentFramework {
8261
9009
  }
8262
9010
  }
8263
9011
 
9012
+ // A context-budget restart normally preserves the turn locus, but a
9013
+ // silent control-plane wake must remain locusless even if an explicit tool
9014
+ // opened/pinned a channel mid-turn.
9015
+ if (trigger?.suppressProse) {
9016
+ this.turnLocusPins.delete(agent.name);
9017
+ this.proseTargetPins.delete(agent.name);
9018
+ this.proseContinuations.delete(agent.name);
9019
+ }
9020
+
8264
9021
  this.touchEphemeralRun(agent.name, true);
8265
9022
  this.emitTrace({
8266
9023
  type: 'inference:started',
@@ -8268,10 +9025,11 @@ export class AgentFramework {
8268
9025
  // The turn-frozen locus was pinned just above (kept across a
8269
9026
  // context-budget restart, which skips the re-pin but re-emits this).
8270
9027
  channelId: this.turnLocusPins.get(agent.name),
9028
+ ...(trigger?.suppressProse ? { silent: true } : {}),
8271
9029
  });
8272
9030
  // A budget restart continues the same logical inference window. The
8273
9031
  // predecessor keeps EventGate liveness until its successor terminates.
8274
- if (trigger?.reason !== 'context_budget_restart') {
9032
+ if (!continuingTurn) {
8275
9033
  this.eventGate?.onInferenceStarted(agent.name);
8276
9034
  }
8277
9035
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
@@ -8286,18 +9044,14 @@ export class AgentFramework {
8286
9044
  // idempotent per channel, and owns the 7s refresh); the catch below stops
8287
9045
  // it on the no-driveStream failure paths (e.g. a compile refusal).
8288
9046
  const earlyTypingChannel =
8289
- agent.proseRouting === 'explicit'
9047
+ turnProseRouting === 'explicit'
8290
9048
  ? trigger?.channelId ?? null
8291
9049
  : this.turnLocusPins.get(agent.name) ?? null;
8292
9050
  if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
8293
9051
 
8294
9052
  try {
8295
9053
  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);
9054
+ const tools = this.agentToolSurface(agent, requestSnapshot);
8301
9055
 
8302
9056
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
8303
9057
  // Both produce ContextInjection[] that get merged before inference.
@@ -8336,6 +9090,15 @@ export class AgentFramework {
8336
9090
  }
8337
9091
  }
8338
9092
 
9093
+ if (trigger?.ephemeralSystemPrompt) {
9094
+ const silentInjection: ContextInjection = {
9095
+ namespace: 'framework:silent-heartbeat',
9096
+ position: 'system',
9097
+ content: [{ type: 'text', text: trigger.ephemeralSystemPrompt }],
9098
+ };
9099
+ injections = injections ? [...injections, silentInjection] : [silentInjection];
9100
+ }
9101
+
8339
9102
  // An ephemeral watchdog may dispose this Agent while hooks/compile await.
8340
9103
  // Do not create a provider stream after its generation lost ownership.
8341
9104
  if (this.agents.get(agent.name) !== agent) {
@@ -8466,6 +9229,7 @@ export class AgentFramework {
8466
9229
  drainKvSubmissionIds?: () => string[],
8467
9230
  knownToolNames: ReadonlySet<string> = new Set(),
8468
9231
  ): Promise<void> {
9232
+ const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
8469
9233
  const startTime = Date.now();
8470
9234
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
8471
9235
  const myStreamId = agent.streamId;
@@ -8523,7 +9287,10 @@ export class AgentFramework {
8523
9287
  // Sticky explicit-send suppression: prose after send_message stays quiet
8524
9288
  // to prevent a redundant "sent it" postscript. Fresh injected input
8525
9289
  // clears it, because the following prose is a reply to a new message.
8526
- let turnSilenced = false;
9290
+ let turnSilenced = trigger?.suppressProse === true
9291
+ || (trigger?.reason === 'tool_result_guard_retry'
9292
+ && this.guardRetryTurnSilenced.get(agent.name) === true);
9293
+ this.guardRetryTurnSilenced.delete(agent.name);
8527
9294
 
8528
9295
  // Live routing is only trusted when the membrane provides verbatim
8529
9296
  // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
@@ -8546,9 +9313,9 @@ export class AgentFramework {
8546
9313
  // sent here", which is true regardless of where the reply goes.
8547
9314
  // Heartbeat/no-trigger explicit turns show no indicator.
8548
9315
  const typingChannel =
8549
- agent.proseRouting === 'disabled'
9316
+ turnProseRouting === 'disabled'
8550
9317
  ? null
8551
- : agent.proseRouting === 'explicit'
9318
+ : turnProseRouting === 'explicit'
8552
9319
  ? trigger?.channelId ?? null
8553
9320
  : resolveTurnLocus();
8554
9321
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
@@ -8566,6 +9333,9 @@ export class AgentFramework {
8566
9333
  // id makes the discarded attempt's chunk buffer orphaned (never
8567
9334
  // finalized) instead of being appended to — see the 'retrying' case.
8568
9335
  let outgoingInferenceId = newOutgoingInferenceId();
9336
+ // RFC-007: every inference id THIS stream mints, so the stream's end
9337
+ // aborts only its own open tool calls — never a successor's.
9338
+ const lifecycleInferenceIds = new Set<string>([outgoingInferenceId]);
8569
9339
  let outgoingIndex = 0;
8570
9340
 
8571
9341
  // §10.5 inference/lifecycle: `started` now, exactly one terminal in the
@@ -8586,10 +9356,10 @@ export class AgentFramework {
8586
9356
  // turns until completion: otherwise speculative outgoing chunks could
8587
9357
  // expose the wrapper before the fail-closed classifier runs.
8588
9358
  const proseStream = this.channelRegistry &&
8589
- agent.proseRouting !== 'disabled' &&
9359
+ turnProseRouting !== 'disabled' &&
8590
9360
  !agent.toolWrapperProseGuard
8591
9361
  ? new ProseStreamRouter({
8592
- mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
9362
+ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
8593
9363
  initialTarget: typingChannel,
8594
9364
  resolve: (spec) => {
8595
9365
  const r = this.channelRegistry!.resolveProseTarget(spec);
@@ -8605,6 +9375,23 @@ export class AgentFramework {
8605
9375
  }
8606
9376
  };
8607
9377
 
9378
+ // Tool-result-guard publication boundary. While a SUBMITTED batch is
9379
+ // pending (or a guard recovery is running), a refusal can still abandon
9380
+ // this physical round, so its streamed text must not reach any surface
9381
+ // yet — neither the prose router nor the inference:tokens trace (TTS and
9382
+ // other trace consumers voice it). Hold both; release in order on a clean
9383
+ // round (tool-calls / non-guard completion), discard on a guard refusal.
9384
+ let guardHeld: Array<{ trace: Parameters<AgentFramework['emitTrace']>[0]; text?: string }> = [];
9385
+ const releaseGuardHeld = (): void => {
9386
+ const held = guardHeld;
9387
+ guardHeld = [];
9388
+ for (const item of held) {
9389
+ this.emitTrace(item.trace);
9390
+ if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text));
9391
+ }
9392
+ };
9393
+ const discardGuardHeld = (): void => { guardHeld = []; };
9394
+
8608
9395
  const adoptInjectedRound = (): void => {
8609
9396
  if (!this.midTurnInputSignals.has(agent.name)) return;
8610
9397
  this.midTurnInputSignals.delete(agent.name);
@@ -8612,7 +9399,7 @@ export class AgentFramework {
8612
9399
  // A new conversational message, not merely a tool result, means any
8613
9400
  // earlier explicit delivery has completed its conversational job.
8614
9401
  // Routing is NOT touched: the turn locus stays frozen.
8615
- turnSilenced = false;
9402
+ turnSilenced = trigger?.suppressProse === true;
8616
9403
  };
8617
9404
 
8618
9405
  try {
@@ -8627,8 +9414,8 @@ export class AgentFramework {
8627
9414
  }
8628
9415
  this.touchEphemeralRun(agent.name, true);
8629
9416
  switch (event.type) {
8630
- case 'tokens':
8631
- this.emitTrace({
9417
+ case 'tokens': {
9418
+ const trace: Parameters<AgentFramework['emitTrace']>[0] = {
8632
9419
  type: 'inference:tokens',
8633
9420
  agentName: agent.name,
8634
9421
  content: event.content,
@@ -8638,11 +9425,16 @@ export class AgentFramework {
8638
9425
  // lets trace consumers tag every chunk with the channel this
8639
9426
  // turn's prose is bound for, without re-deriving routing.
8640
9427
  channelId: typingChannel ?? undefined,
8641
- });
8642
- if (proseStream && event.meta.type === 'text') {
8643
- emitOutgoing(proseStream.feed(event.content));
9428
+ };
9429
+ const text = event.meta.type === 'text' ? event.content : undefined;
9430
+ if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) {
9431
+ guardHeld.push({ trace, text });
9432
+ } else {
9433
+ this.emitTrace(trace);
9434
+ if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text));
8644
9435
  }
8645
9436
  break;
9437
+ }
8646
9438
 
8647
9439
  case 'retrying': {
8648
9440
  // Membrane is re-issuing after a content-policy refusal. Per the
@@ -8663,7 +9455,9 @@ export class AgentFramework {
8663
9455
  ' — discarding the refused attempt',
8664
9456
  );
8665
9457
  outgoingInferenceId = newOutgoingInferenceId();
9458
+ lifecycleInferenceIds.add(outgoingInferenceId);
8666
9459
  outgoingIndex = 0;
9460
+ discardGuardHeld();
8667
9461
  proseStream?.reset();
8668
9462
  this.emitTrace({
8669
9463
  type: 'inference:tokens',
@@ -8690,6 +9484,10 @@ export class AgentFramework {
8690
9484
  }
8691
9485
 
8692
9486
  case 'tool-calls': {
9487
+ // A tool-call response is a clean physical round: admit its
9488
+ // preceding results before storing/dispatching this new round.
9489
+ agent.toolResultGuard.accept();
9490
+ releaseGuardHeld();
8693
9491
  adoptInjectedRound();
8694
9492
  hadToolCalls = true;
8695
9493
  this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
@@ -8761,7 +9559,14 @@ export class AgentFramework {
8761
9559
  agent.enterWaitingForTools(event.calls, stream);
8762
9560
 
8763
9561
  for (const call of event.calls) {
9562
+ // RFC-007: register at the one dispatch point for model-issued
9563
+ // calls, keyed by (agent, call id) with this inference's id;
9564
+ // `started` goes out once dispatch returns. Refusal sites inside
9565
+ // dispatch run synchronously and call refuse() first, so a call
9566
+ // the host refuses produces no events.
9567
+ this.toolLifecycleEmitter?.register(agent.name, outgoingInferenceId, call);
8764
9568
  this.dispatchToolCall(agent.name, call);
9569
+ this.toolLifecycleEmitter?.open(agent.name, call.id);
8765
9570
  }
8766
9571
 
8767
9572
  // Speak-while-acting: route THIS round's prose to the locus NOW
@@ -8787,12 +9592,12 @@ export class AgentFramework {
8787
9592
  liveProseRouting = true;
8788
9593
  const roundSegments = splitProseSegments(assistantBlocks);
8789
9594
  if (roundSegments.length > 0) {
8790
- if (agent.proseRouting === 'disabled') {
9595
+ if (turnProseRouting === 'disabled') {
8791
9596
  console.error(
8792
9597
  `[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`,
8793
9598
  );
8794
9599
  this.recordProseSuppression(agent.name, roundSegments.length);
8795
- } else if (agent.proseRouting === 'hybrid') {
9600
+ } else if (turnProseRouting === 'hybrid') {
8796
9601
  if (turnSilenced) {
8797
9602
  this.recordProseSuppression(agent.name, roundSegments.length);
8798
9603
  } else if (!hasSameRoundPrivateThink) {
@@ -8803,7 +9608,7 @@ export class AgentFramework {
8803
9608
  .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
8804
9609
  }
8805
9610
  }
8806
- } else if (agent.proseRouting === 'explicit') {
9611
+ } else if (turnProseRouting === 'explicit') {
8807
9612
  // Explicit mode: every segment through the prose gateway.
8808
9613
  // Silencing does not apply — unprefixed prose bounces and
8809
9614
  // prefixed prose is deliberate; think-privacy still holds.
@@ -8847,7 +9652,86 @@ export class AgentFramework {
8847
9652
  case 'complete': {
8848
9653
  adoptInjectedRound();
8849
9654
  const durationMs = Date.now() - startTime;
8850
- const response = event.response;
9655
+ let response = event.response;
9656
+ const guardCategory = (response.raw?.response as {
9657
+ stop_details?: { category?: string };
9658
+ } | undefined)?.stop_details?.category ?? 'unknown';
9659
+ // A staged batch the strategy omitted from the request cannot
9660
+ // have caused this refusal: settle it and fall through to the
9661
+ // ordinary refusal handling (rewind/reaction) below.
9662
+ if (response.stopReason === 'refusal') agent.toolResultGuard.settleUnsubmitted(guardCategory);
9663
+ const guardRefusal = response.stopReason === 'refusal'
9664
+ && (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering);
9665
+ if (guardRefusal) {
9666
+ discardGuardHeld();
9667
+ const category = guardCategory;
9668
+ const withheld = agent.toolResultGuard.withhold(category);
9669
+ // Nothing from the abandoned physical attempt is published or
9670
+ // persisted as assistant speech, including a terminal refusal
9671
+ // after the single recovery retry.
9672
+ proseStream?.reset();
9673
+ if (withheld) {
9674
+ const usage = response.details?.usage ?? response.usage;
9675
+ const tokenUsage = usage ? {
9676
+ input: usage.inputTokens, output: usage.outputTokens,
9677
+ cacheCreation: usage.cacheCreationTokens, cacheRead: usage.cacheReadTokens,
9678
+ } : undefined;
9679
+ this.noteRefusal(agent.name, category, tokenUsage);
9680
+ // The abandoned stream was billed (membrane usage here is
9681
+ // cumulative across this physical tool loop): count it before
9682
+ // the retry opens a fresh stream with its own usage.
9683
+ const abandonedUsage = response.details?.usage;
9684
+ if (abandonedUsage) {
9685
+ this.usageTracker.onInferenceCompleted(agent.name, {
9686
+ inputTokens: abandonedUsage.inputTokens,
9687
+ outputTokens: abandonedUsage.outputTokens,
9688
+ cacheCreationTokens: abandonedUsage.cacheCreationTokens,
9689
+ cacheReadTokens: abandonedUsage.cacheReadTokens,
9690
+ }, abandonedUsage.estimatedCost
9691
+ ? { total: abandonedUsage.estimatedCost.total, currency: abandonedUsage.estimatedCost.currency }
9692
+ : undefined);
9693
+ this.persistUsageState();
9694
+ }
9695
+ this.logInference({
9696
+ timestamp: startTime, agentName: agent.name, requestId,
9697
+ success: false, error: 'Tool output withheld by the guard',
9698
+ request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
9699
+ });
9700
+ console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
9701
+ this.emitTrace({
9702
+ type: 'inference:stream_restarted', agentName: agent.name,
9703
+ reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens,
9704
+ budget: agent.maxStreamTokens,
9705
+ });
9706
+ await turnSpeechChain;
9707
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
9708
+ generationLost = true;
9709
+ lifecyclePhase = 'aborted';
9710
+ return;
9711
+ }
9712
+ agent.reset();
9713
+ preserveEventGateForSuccessor = true;
9714
+ lifecyclePhase = 'aborted';
9715
+ // Carry same-turn explicit-send suppression into the retry:
9716
+ // a text-only recovery after a successful send must not post
9717
+ // a postscript to a message already delivered.
9718
+ if (turnSilenced) this.guardRetryTurnSilenced.set(agent.name, true);
9719
+ // Restart inference inside this logical turn. No tool is
9720
+ // executed again and no settle/checkpoint/locus reset occurs.
9721
+ await this.startAgentStream(agent, {
9722
+ ...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
9723
+ source: trigger?.source ?? 'framework', timestamp: Date.now(),
9724
+ }, attempt);
9725
+ return;
9726
+ }
9727
+ const lastResult = response.content.reduce((last, block, index) =>
9728
+ block.type === 'tool_result' ? index : last, -1);
9729
+ response = { ...response, content: response.content.slice(0, lastResult + 1), rawAssistantText: '' };
9730
+ agent.toolResultGuard.recovering = false;
9731
+ } else if (response.stopReason !== 'refusal') {
9732
+ agent.toolResultGuard.accept();
9733
+ }
9734
+ if (!guardRefusal) releaseGuardHeld();
8851
9735
 
8852
9736
  // If the agent is still waiting_for_tools when 'complete' fires
8853
9737
  // (shouldn't happen after incomplete-tool-call fix, but guard anyway),
@@ -8876,12 +9760,17 @@ export class AgentFramework {
8876
9760
  // one door a giant blob still walks through).
8877
9761
  const readyState = agent.state as AgentState;
8878
9762
  if (readyState.status === 'ready') {
8879
- const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
9763
+ const cap = this.resolveToolResultInlineCap(agent).cap;
9764
+ const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(
8880
9765
  agent.name,
8881
9766
  readyState.toolResults,
8882
- this.resolveToolResultInlineCap(agent).cap,
9767
+ cap,
8883
9768
  );
8884
- agent.getContextManager().addMessage('user', toolResultContent);
9769
+ agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
9770
+ this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults);
9771
+ // The response is already complete: this batch is never
9772
+ // submitted in this turn, so it cannot be refused. Admit it.
9773
+ agent.toolResultGuard.settleTurnEnded();
8885
9774
  }
8886
9775
  }
8887
9776
 
@@ -9097,7 +9986,7 @@ export class AgentFramework {
9097
9986
  },
9098
9987
  () => this.finishUnstick(agent.name, false, category),
9099
9988
  );
9100
- } else if (rh?.autoRewind) {
9989
+ } else if (rh?.autoRewind && !guardRefusal) {
9101
9990
  const cap = Math.max(1, rh.maxRewinds ?? 3);
9102
9991
  const used = this.refusalRewinds.get(agent.name) ?? 0;
9103
9992
  doRewind(
@@ -9145,7 +10034,7 @@ export class AgentFramework {
9145
10034
  }
9146
10035
 
9147
10036
  // Dispatch speech (and thoughts if any)
9148
- if (speechContent.length > 0 || thoughts.length > 0) {
10037
+ if (!trigger?.suppressProse && (speechContent.length > 0 || thoughts.length > 0)) {
9149
10038
  const speechContext: SpeechContext = {
9150
10039
  turnComplete: true,
9151
10040
  trigger: trigger ?? {
@@ -9176,10 +10065,17 @@ export class AgentFramework {
9176
10065
  .join('\n')
9177
10066
  .trim();
9178
10067
  if (speechText) {
9179
- if (agent.proseRouting === 'disabled') {
10068
+ if (turnProseRouting === 'disabled') {
9180
10069
  console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
9181
10070
  this.recordProseSuppression(agent.name, 1);
9182
- } else if (agent.proseRouting === 'hybrid') {
10071
+ } else if (turnSilenced && turnProseRouting !== 'explicit') {
10072
+ // Only reachable on a guard recovery that carried the
10073
+ // same-turn send suppression, or a suppressProse trigger
10074
+ // (a fresh text-only turn starts unsilenced). Explicit
10075
+ // mode is exempt, as mid-turn.
10076
+ console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`);
10077
+ this.recordProseSuppression(agent.name, 1);
10078
+ } else if (turnProseRouting === 'hybrid') {
9183
10079
  const locus = resolveTurnLocus();
9184
10080
  console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
9185
10081
  try {
@@ -9187,7 +10083,7 @@ export class AgentFramework {
9187
10083
  } catch (err) {
9188
10084
  console.error('text-only hybrid prose delivery failed:', err);
9189
10085
  }
9190
- } else if (agent.proseRouting === 'explicit') {
10086
+ } else if (turnProseRouting === 'explicit') {
9191
10087
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
9192
10088
  try {
9193
10089
  await this.deliverProse(agent, speechText);
@@ -9243,14 +10139,14 @@ export class AgentFramework {
9243
10139
  // the chain may still be flushing earlier rounds' posts.
9244
10140
  await turnSpeechChain;
9245
10141
 
9246
- if (agent.proseRouting === 'disabled') {
10142
+ if (turnProseRouting === 'disabled') {
9247
10143
  if (segments.length > 0) {
9248
10144
  console.error(
9249
10145
  `[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`,
9250
10146
  );
9251
10147
  this.recordProseSuppression(agent.name, segments.length);
9252
10148
  }
9253
- } else if (agent.proseRouting === 'hybrid') {
10149
+ } else if (turnProseRouting === 'hybrid') {
9254
10150
  if (silenced && segments.length > 0) {
9255
10151
  this.recordProseSuppression(agent.name, segments.length);
9256
10152
  } else if (segments.length > 0) {
@@ -9263,7 +10159,7 @@ export class AgentFramework {
9263
10159
  }
9264
10160
  }
9265
10161
  }
9266
- } else if (agent.proseRouting === 'explicit') {
10162
+ } else if (turnProseRouting === 'explicit') {
9267
10163
  if (segments.length > 0) {
9268
10164
  console.error(
9269
10165
  `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
@@ -9316,7 +10212,7 @@ export class AgentFramework {
9316
10212
  // awaited before trailing dispatch, and trailing/text-only
9317
10213
  // segments were awaited in-loop. Locus mode only; explicit-mode
9318
10214
  // envelopes acknowledge themselves through the prose gateway.
9319
- if (agent.proseRouting !== 'explicit') {
10215
+ if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
9320
10216
  this.appendProseDeliveryReceipt(agent);
9321
10217
  }
9322
10218
 
@@ -9485,7 +10381,7 @@ export class AgentFramework {
9485
10381
  // deliveries map persists (cleared only at fresh turn
9486
10382
  // start) and the continuation stream's end writes ONE
9487
10383
  // receipt for the whole turn.
9488
- if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
10384
+ if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
9489
10385
  await turnSpeechChain;
9490
10386
  this.appendProseDeliveryReceipt(agent);
9491
10387
  }
@@ -9705,6 +10601,13 @@ export class AgentFramework {
9705
10601
  // completed; catch → failed; framework-cancel → aborted). A host
9706
10602
  // crash emits nothing — that is the documented best-effort limit, and
9707
10603
  // why consumers keep their safety timeout.
10604
+ //
10605
+ // RFC-007 §7.3: a tool call still open when its turn's stream ends was
10606
+ // cancelled before a result — its terminal is `aborted`. (A normal turn
10607
+ // has none: the stream waits for every result before continuing.)
10608
+ // Scoped to this stream's own inference ids: a budget-restart successor
10609
+ // may already be running for the same agent.
10610
+ this.toolLifecycleEmitter?.abortOpen(agent.name, lifecycleInferenceIds);
9708
10611
  this.hookOrchestrator?.emitLifecycle({
9709
10612
  inferenceId: outgoingInferenceId,
9710
10613
  conversationId: agent.name,
@@ -9719,6 +10622,22 @@ export class AgentFramework {
9719
10622
  // (typing still stops, compression still runs — matching the observed
9720
10623
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
9721
10624
  if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
10625
+ // A frame that ends without handing off to a successor (abort,
10626
+ // exhausted errors) must not leave its batch pending: a later turn
10627
+ // would resubmit the originals and claim its own refusal. Successor
10628
+ // frames (error-policy retry, budget/guard restart) bump streamId or
10629
+ // set preserveEventGateForSuccessor and keep the batch.
10630
+ if (agent.streamId === myStreamId && agent.toolResultGuard.hasPending) {
10631
+ // abandon() clears the batch before its audit append, so a failed
10632
+ // write cannot re-arm it; it must not abort the rest of teardown
10633
+ // (gate release, turn token, provider admission) either.
10634
+ try {
10635
+ agent.toolResultGuard.abandon('aborted');
10636
+ } catch (error) {
10637
+ console.error(`[tool-result-guard] agent=${agent.name} failed to record aborted batch:`, error);
10638
+ }
10639
+ }
10640
+ agent.toolResultGuard.recovering = false;
9722
10641
  this.eventGate?.onInferenceEnded(agent.name);
9723
10642
  }
9724
10643
  if (!generationLost && ownsPhysicalStream) {
@@ -10061,6 +10980,7 @@ export class AgentFramework {
10061
10980
  const startTime = Date.now();
10062
10981
  this.runCodeExecution(agentName, call)
10063
10982
  .catch((error): ToolResult => {
10983
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
10064
10984
  // runCodeExecution is designed not to reject; this is the last-resort
10065
10985
  // guard so a bug here can never strand the agent in waiting_for_tools.
10066
10986
  const err = error instanceof Error ? error : new Error(String(error));
@@ -10110,6 +11030,7 @@ export class AgentFramework {
10110
11030
  background?: unknown;
10111
11031
  action?: unknown;
10112
11032
  script_id?: unknown;
11033
+ time_limit_ms?: unknown;
10113
11034
  };
10114
11035
 
10115
11036
  // Management surface: the agent's own daemon fleet is inspectable and
@@ -10158,6 +11079,18 @@ export class AgentFramework {
10158
11079
  };
10159
11080
  }
10160
11081
 
11082
+ // A per-call time limit, capped by the deployment's ceiling (the result says when it was capped).
11083
+ if (input.time_limit_ms !== undefined && (typeof input.time_limit_ms !== 'number' || !Number.isFinite(input.time_limit_ms) || input.time_limit_ms < 1000)) {
11084
+ return { success: false, error: '`time_limit_ms` must be a number of milliseconds, at least 1000', isError: true };
11085
+ }
11086
+ const limits = scriptTimeLimits(this.codeExecutionConfig ?? undefined);
11087
+ const ceilingMs = input.background === true ? limits.backgroundMaxMs : limits.maxMs;
11088
+ const requestedMs = input.time_limit_ms === undefined ? undefined : Math.floor(input.time_limit_ms);
11089
+ const timeLimitMs = requestedMs === undefined ? undefined : Math.min(requestedMs, ceilingMs);
11090
+ const capNote = requestedMs !== undefined && requestedMs > ceilingMs
11091
+ ? `time_limit_ms ${requestedMs} was capped at ${ceilingMs}, this deployment's maximum`
11092
+ : undefined;
11093
+
10161
11094
  const agent = this.agents.get(agentName);
10162
11095
  const surface = agent
10163
11096
  ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
@@ -10167,17 +11100,26 @@ export class AgentFramework {
10167
11100
  );
10168
11101
 
10169
11102
  if (input.background === true) {
10170
- return this.startBackgroundScript(agentName, input.code, injected);
11103
+ const started = this.startBackgroundScript(agentName, input.code, injected, timeLimitMs);
11104
+ if (capNote && started.success && started.data && typeof started.data === 'object') {
11105
+ (started.data as Record<string, unknown>).time_limit_note = capNote;
11106
+ }
11107
+ return started;
10171
11108
  }
10172
11109
 
10173
11110
  const runner = this.getOrCreateScriptRunner(agentName);
10174
11111
  this.scriptDeferredEndTurn.delete(agentName);
10175
- const exec = await runner.exec(input.code, injected);
11112
+ const exec = await runner.exec(input.code, injected, undefined, timeLimitMs !== undefined ? { deadlineMs: timeLimitMs } : undefined);
10176
11113
  const endTurn = this.scriptDeferredEndTurn.delete(agentName);
10177
11114
 
10178
11115
  return {
10179
11116
  success: true,
10180
- data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
11117
+ data: {
11118
+ stdout: exec.stdout,
11119
+ stderr: exec.stderr,
11120
+ return_code: exec.returnCode,
11121
+ ...(capNote ? { time_limit_note: capNote } : {}),
11122
+ },
10181
11123
  isError: false,
10182
11124
  ...(endTurn ? { endTurn: true } : {}),
10183
11125
  };
@@ -10199,6 +11141,7 @@ export class AgentFramework {
10199
11141
  agentName: string,
10200
11142
  code: string,
10201
11143
  injected: import('./code-execution/py-runner.js').InjectedTool[],
11144
+ timeLimitMs?: number,
10202
11145
  ): ToolResult {
10203
11146
  // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
10204
11147
  // would outlive its owner and its wake would land in the primary
@@ -10224,7 +11167,8 @@ export class AgentFramework {
10224
11167
  }
10225
11168
 
10226
11169
  const scriptId = `bg-${++this.backgroundScriptCounter}`;
10227
- const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
11170
+ // The agent's time_limit_ms (already capped) shortens the lifetime; it never extends it.
11171
+ const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
10228
11172
 
10229
11173
  // Journal: a file under the agent's first read-write workspace mount so
10230
11174
  // their existing read/grep/shell tools work on it. Python appends
@@ -10290,6 +11234,7 @@ export class AgentFramework {
10290
11234
  script_id: scriptId,
10291
11235
  log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
10292
11236
  lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
11237
+ lifetime: formatLimit(lifetimeMs), // exact for short limits, which lifetime_hours rounds to 0
10293
11238
  note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
10294
11239
  },
10295
11240
  };
@@ -10926,17 +11871,91 @@ export class AgentFramework {
10926
11871
  this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore);
10927
11872
  }
10928
11873
 
11874
+ /** Forget one server's RFC-008 class declarations (see the close handler). */
11875
+ private dropMcplToolClasses(serverId: string): void {
11876
+ // Tolerate partially-constructed frameworks (tests build them with
11877
+ // Object.create): nothing cached means nothing to forget.
11878
+ if (!this.mcplToolClasses?.size) return;
11879
+ const prefix = this.mcplServerConfigs?.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`;
11880
+ for (const name of [...this.mcplToolClasses.keys()]) {
11881
+ if (name.startsWith(`${prefix}--`)) this.mcplToolClasses.delete(name);
11882
+ }
11883
+ }
11884
+
11885
+ /**
11886
+ * RFC-007/RFC-008: what tools/lifecycle says about a model-facing tool —
11887
+ * its effective class, and for MCPL tools the providing connection and
11888
+ * the server's own tool name. Host knowledge (built-in table, embedding
11889
+ * host's table) applies only to tools the host implements; an MCPL tool's
11890
+ * class comes from an operator override or its server's declaration.
11891
+ */
11892
+ private describeToolForLifecycle(tool: string): { class: ToolClass[]; serverId?: string; serverTool?: string } {
11893
+ const mcpl = this.resolveMcplTool(tool);
11894
+ const { classes } = this.effectiveToolClass(tool, mcpl !== null);
11895
+ if (mcpl) return { class: classes, serverId: mcpl[0], serverTool: tool.slice(mcpl[1].length + 2) };
11896
+ return { class: classes };
11897
+ }
11898
+
11899
+ /**
11900
+ * RFC-008 §5.1: a tool's effective class and the source that decided it.
11901
+ * Operator overrides first; then host knowledge, for tools the host
11902
+ * implements (never for MCPL tools); then an MCPL tool's own declaration.
11903
+ * Tolerates partially-constructed frameworks (tests use Object.create).
11904
+ */
11905
+ private effectiveToolClass(tool: string, isMcpl: boolean): EffectiveToolClass {
11906
+ return resolveToolClass(tool, isMcpl ? this.mcplToolClasses?.get(tool) : undefined, {
11907
+ overrides: this.toolClassOverrides ?? [],
11908
+ host: isMcpl ? [] : (this.hostToolClasses ?? []),
11909
+ });
11910
+ }
11911
+
11912
+ /**
11913
+ * RFC-008 §6: tools with their effective class and where that class came
11914
+ * from (`override`, `host`, `server`, or `none` for an unclassed tool,
11915
+ * which policy treats as most restrictive). For operators: a surprising
11916
+ * class should be visible before it matters.
11917
+ *
11918
+ * With `agentName`, exactly the tools that agent is shown. Without, every
11919
+ * tool the framework offers to anyone: the shared board plus each agent's
11920
+ * own (the subconscious's surface, `prose_help`), each listed once.
11921
+ */
11922
+ listToolClasses(agentName?: string): Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> {
11923
+ let tools: import('./types/index.js').ToolDefinition[];
11924
+ if (agentName !== undefined) {
11925
+ const agent = this.agents.get(agentName);
11926
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
11927
+ tools = this.agentToolSurface(agent);
11928
+ } else {
11929
+ tools = [this.getAllTools(), ...[...this.agents.values()].map((a) => this.agentToolSurface(a))].flat();
11930
+ }
11931
+ const seen = new Set<string>();
11932
+ const listed: Array<{ tool: string; class: ToolClass[]; source: ToolClassSource; serverId?: string }> = [];
11933
+ for (const t of tools) {
11934
+ if (seen.has(t.name)) continue;
11935
+ seen.add(t.name);
11936
+ const mcpl = this.resolveMcplTool(t.name);
11937
+ const { classes, source } = this.effectiveToolClass(t.name, mcpl !== null);
11938
+ listed.push({ tool: t.name, class: classes, source, ...(mcpl ? { serverId: mcpl[0] } : {}) });
11939
+ }
11940
+ return listed;
11941
+ }
11942
+
10929
11943
  /**
10930
- * Find the MCPL server for a tool call by checking against the prefix map.
10931
- * Returns [serverId, prefix] if found, null otherwise.
11944
+ * Find the MCPL server for a tool: [serverId, prefix], or null.
11945
+ * Prefixes can nest (`foo` and `foo--bar`), so `foo--bar--x` can match
11946
+ * both. The server whose tools/list produced the name decides; a name no
11947
+ * live server listed (stale, or invented by the model) goes to the longest
11948
+ * matching prefix. Never prefix-map insertion order.
10932
11949
  */
10933
11950
  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
- }
11951
+ const listedBy = this.mcplToolServers?.get(toolName);
11952
+ let best: [string, string] | null = null;
11953
+ for (const [prefix, serverId] of this.mcplPrefixMap ?? []) {
11954
+ if (!toolName.startsWith(prefix + '--')) continue;
11955
+ if (serverId === listedBy) return [serverId, prefix];
11956
+ if (!best || prefix.length > best[1].length) best = [serverId, prefix];
10938
11957
  }
10939
- return null;
11958
+ return best;
10940
11959
  }
10941
11960
 
10942
11961
  private dispatchToolCall(agentName: string, call: ToolCall): void {
@@ -11030,11 +12049,11 @@ export class AgentFramework {
11030
12049
  return;
11031
12050
  }
11032
12051
 
11033
- // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
11034
- // stay-silent) tools — handled by the channel registry like the other
12052
+ // Route synthesized 'think' (private reasoning), 'journal' (private
12053
+ // long-form notes) and 'skip_reply' (deliberate stay-silent) tools — handled by the channel registry like the other
11035
12054
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
11036
12055
  // need an explicit route here.
11037
- if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
12056
+ if ((enrichedCall.name === 'think' || enrichedCall.name === 'journal' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
11038
12057
  // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
11039
12058
  // NOW" can also mean "back in a moment" — ends the turn, then wakes
11040
12059
  // the agent after N seconds unless something else wakes it first
@@ -11130,6 +12149,8 @@ export class AgentFramework {
11130
12149
  })
11131
12150
  .catch((error) => {
11132
12151
  const err = error instanceof Error ? error : new Error(String(error));
12152
+ // RFC-007: the module threw — the host could not obtain a result.
12153
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
11133
12154
  this.emitTrace({
11134
12155
  type: 'tool:failed',
11135
12156
  module: moduleName,
@@ -11174,6 +12195,14 @@ export class AgentFramework {
11174
12195
  * minting a second, independently replayable one.
11175
12196
  */
11176
12197
  deferredWriteId?: string;
12198
+ /** Out-param: where the message landed (RFC-006 needs to find it again). */
12199
+ placement?: { agent?: string; messageId?: MessageId; deferredId?: string };
12200
+ /**
12201
+ * RFC-006 assembly: the named agent's turn is alive but its compile has
12202
+ * not run yet (the same window the turn-start deferred flush writes
12203
+ * in). A message for THAT agent is stored directly instead of deferred.
12204
+ */
12205
+ bypassDeferralFor?: string;
11177
12206
  }
11178
12207
  ): MessageId {
11179
12208
  // Route to the named agent, else the primary (not ephemeral subagents).
@@ -11223,6 +12252,7 @@ export class AgentFramework {
11223
12252
  : this.pendingAssistantBlocks.size > 0;
11224
12253
  if (
11225
12254
  !hasToolResult &&
12255
+ opts?.bypassDeferralFor !== agent.name &&
11226
12256
  (this.quiesced ||
11227
12257
  midToolCycle ||
11228
12258
  this.activeTurnTokens.has(agent.name) ||
@@ -11239,14 +12269,17 @@ export class AgentFramework {
11239
12269
  }
11240
12270
  this.deferredMessages.push({ id, seq: seq ?? ++this.deferredSeq, participant, content, metadata, forAgent: opts?.forAgent });
11241
12271
  if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
12272
+ if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.deferredId = id; }
11242
12273
  return '' as MessageId; // Deferred — flushed at the target's next boundary
11243
12274
  }
11244
12275
 
11245
- return agent.getContextManager().addMessage(
12276
+ const stored = agent.getContextManager().addMessage(
11246
12277
  participant,
11247
12278
  content,
11248
12279
  opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata,
11249
12280
  );
12281
+ if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
12282
+ return stored;
11250
12283
  }
11251
12284
 
11252
12285
  /**
@@ -11557,24 +12590,27 @@ export class AgentFramework {
11557
12590
  * quarantine, OverBudget drain kick) gate on `errorType`, so classification
11558
12591
  * drift between sites would silently disable a safety net.
11559
12592
  *
11560
- * context-manager's OverBudgetError is recognized by `err.name`: CM does not
11561
- * export the class from its package root, so a cross-package `instanceof` is
11562
- * unavailable — but `name` is set in its constructor and survives the package
11563
- * boundary. Deliberately NOT a message match: the message wording belongs to
11564
- * CM and can be reworded without warning.
12593
+ * context-manager's refusal errors are matched by a real `instanceof` since
12594
+ * CM exports them from its package root (context-manager#41 / #71; floor
12595
+ * `^0.7.0`, ours is higher). The `err.name` comparison stays as a FALLBACK
12596
+ * only: a deployment that ends up with two CM copies (a peer package
12597
+ * pinning a different version) constructs errors from a different class
12598
+ * object, and `name` — set in the constructor — survives that boundary.
12599
+ * Deliberately NOT a message match: the message wording belongs to CM and
12600
+ * can be reworded without warning.
11565
12601
  */
11566
12602
  private classifyInferenceError(err: Error): { retryable?: boolean; errorType?: string } {
11567
12603
  if (err instanceof MembraneError) {
11568
12604
  return { retryable: err.retryable, errorType: err.type };
11569
12605
  }
11570
- if (err.name === 'OverBudgetError') {
12606
+ if (err instanceof OverBudgetError || err.name === 'OverBudgetError') {
11571
12607
  return { errorType: 'over_budget' };
11572
12608
  }
11573
12609
  // context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
11574
12610
  // 2026-07-26): a compile REFUSES rather than shipping a context with
11575
12611
  // silently-dropped messages. Recovery is identical to over_budget — kick
11576
12612
  // the compression drain so summaries cover the un-represented span.
11577
- if (err.name === 'UncoveredDropError') {
12613
+ if (err instanceof UncoveredDropError || err.name === 'UncoveredDropError') {
11578
12614
  return { errorType: 'context_refusal' };
11579
12615
  }
11580
12616
  return {};
@@ -11781,6 +12817,11 @@ export class AgentFramework {
11781
12817
  this.mcplServerRegistry = new McplServerRegistry();
11782
12818
  this.featureSetManager = new FeatureSetManager();
11783
12819
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
12820
+ this.toolLifecycleEmitter = new ToolLifecycleEmitter({
12821
+ observers: () => this.mcplServerRegistry?.getAllServers() ?? [],
12822
+ configFor: (serverId) => this.mcplServerConfigs.get(serverId)?.toolLifecycle,
12823
+ describe: (tool) => this.describeToolForLifecycle(tool),
12824
+ });
11784
12825
 
11785
12826
  // Build prefix map and store configs for tool routing
11786
12827
  for (const config of serverConfigs) {
@@ -11795,11 +12836,13 @@ export class AgentFramework {
11795
12836
  ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
11796
12837
 
11797
12838
  // Push events handler (Step 6)
12839
+ this.initializePushCoalescer();
11798
12840
  this.pushHandler = new PushHandler(
11799
12841
  this.featureSetManager,
11800
12842
  (event) => this.pushEvent(event as unknown as ProcessEvent),
11801
12843
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
11802
12844
  triggerFilter,
12845
+ (serverId, params, event) => this.handleCoalescedPush(serverId, params, event),
11803
12846
  );
11804
12847
 
11805
12848
  // Server-initiated inference router (Step 6)
@@ -11829,6 +12872,8 @@ export class AgentFramework {
11829
12872
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
11830
12873
  {
11831
12874
  store: this.store,
12875
+ handleCoalescedIncoming: (serverId, message, event) =>
12876
+ this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent),
11832
12877
  sendTypingFn: (serverId, channelId, metadata, op) => {
11833
12878
  const server = this.mcplServerRegistry!.getServer(serverId);
11834
12879
  if (server) {
@@ -11903,11 +12948,17 @@ export class AgentFramework {
11903
12948
  // implemented for years — are declared (AUDIT-001/issue #76 item 12).
11904
12949
  this.mcplHostCapabilities = {
11905
12950
  version: '0.5',
12951
+ // RFC-006 (mcpl PR #5, revision 7): both lanes, deferred rendering,
12952
+ // channel-scoped pushes, one-hour retry window.
12953
+ eventCoalescing: { ...PUSH_COALESCING_SUPPORT },
11906
12954
  pushEvents: true,
11907
12955
  contextHooks: {
11908
12956
  beforeInference: true,
11909
12957
  },
11910
12958
  inferenceLifecycle: true,
12959
+ // RFC-007: tools/lifecycle (metadata, and requested argument fields
12960
+ // under their own grant) plus the tools/observe filter.
12961
+ toolLifecycle: { observe: true, inputs: true },
11911
12962
  // modelInfo is NOT advertised: §12.2's result requires all four of
11912
12963
  // {id, vendor, contextWindow, capabilities} and the host has no
11913
12964
  // truthful source for contextWindow/capabilities (grep: none exists).
@@ -12789,6 +13840,20 @@ export class AgentFramework {
12789
13840
  deniedCapabilities: string[];
12790
13841
  /** Host-owned authority; deliberately separate from the portable grant. */
12791
13842
  allowHostCommands: boolean;
13843
+ /**
13844
+ * RFC-007 §10: the server's current tools/observe filter — its own
13845
+ * statement of which calls and argument fields it uses, shown next to
13846
+ * the grant so an operator can narrow toolLifecycle.inputs to match. It
13847
+ * confers nothing. Null = no filter (granted calls, metadata only).
13848
+ */
13849
+ toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
13850
+ /**
13851
+ * RFC-008 §6: each of this server's tools with its effective class and
13852
+ * the source that decided it — `override` (operator), `server` (its own
13853
+ * `_meta["mcpl/class"]`), or `none` (unclassed: observers never see its
13854
+ * arguments). Shown next to the grant so a surprising class is visible.
13855
+ */
13856
+ toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
12792
13857
  /**
12793
13858
  * Per-transport §17 facts about the last manifest this host fetched and
12794
13859
  * acted on. The revision is server-authored and equality-only; these are
@@ -12807,6 +13872,8 @@ export class AgentFramework {
12807
13872
  toolPrefix: string; toolCount: number; policyEstablished: boolean;
12808
13873
  effectiveGrant: string[]; maskedCapabilities: string[];
12809
13874
  deniedCapabilities: string[]; allowHostCommands: boolean;
13875
+ toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null;
13876
+ toolClasses: Array<{ tool: string; serverTool: string; class: ToolClass[]; source: ToolClassSource }>;
12810
13877
  manifestState: {
12811
13878
  lastValidatedRevision: string | null;
12812
13879
  lastFetchedAt: number | null;
@@ -12818,17 +13885,28 @@ export class AgentFramework {
12818
13885
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
12819
13886
  const connection = this.mcplServerRegistry?.getServer(id) ?? null;
12820
13887
  const connected = connection?.isConnected ?? false;
13888
+ // Attribute through the dispatch resolver: a bare prefix match would
13889
+ // also count a nested prefix's tools (`foo` vs `foo--bar`) as ours.
13890
+ const ownTools = (this.mcplTools ?? []).flatMap((t) => {
13891
+ const hit = this.resolveMcplTool(t.name);
13892
+ return hit && hit[0] === id ? [{ tool: t.name, serverTool: t.name.slice(hit[1].length + 2) }] : [];
13893
+ });
12821
13894
  result.push({
12822
13895
  id,
12823
13896
  connected,
12824
13897
  retrying: !connected && (connection?.willReconnect ?? false),
12825
13898
  toolPrefix: prefix,
12826
- toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
13899
+ toolCount: ownTools.length,
12827
13900
  policyEstablished: connection?.policyEstablished ?? false,
12828
13901
  effectiveGrant: connection?.grant.effectiveList() ?? [],
12829
13902
  maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
12830
13903
  deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
12831
13904
  allowHostCommands: config.allowHostCommands === true,
13905
+ toolObserveFilter: connection?.toolObserveFilter ?? null,
13906
+ toolClasses: ownTools.map(({ tool, serverTool }) => {
13907
+ const { classes, source } = this.effectiveToolClass(tool, true);
13908
+ return { tool, serverTool, class: classes, source };
13909
+ }),
12832
13910
  manifestState: connection
12833
13911
  ? { ...connection.manifestState }
12834
13912
  : { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
@@ -12867,7 +13945,7 @@ export class AgentFramework {
12867
13945
  ) => {
12868
13946
  const barrier = this.discordAwarenessBarrier;
12869
13947
  if (barrier) await barrier.promise;
12870
- this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
13948
+ await this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
12871
13949
  });
12872
13950
 
12873
13951
  // Handle server-initiated inference requests (Step 6)
@@ -12875,6 +13953,11 @@ export class AgentFramework {
12875
13953
  params: McplInferenceRequestParams,
12876
13954
  responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
12877
13955
  ) => {
13956
+ // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
13957
+ if (this.pushCoalescer?.isRendering(connection.id)) {
13958
+ responder?.respondError(-32600, 'Cannot request inference while push/render is in progress');
13959
+ return;
13960
+ }
12878
13961
  if (this.inferenceRouter && responder) {
12879
13962
  const barrier = this.discordAwarenessBarrier;
12880
13963
  if (barrier) await barrier.promise;
@@ -12925,6 +14008,23 @@ export class AgentFramework {
12925
14008
  });
12926
14009
  });
12927
14010
 
14011
+ // RFC-007 §6: the server's filter over tools/lifecycle. Admission already
14012
+ // required toolLifecycle.observe (-32002 otherwise). Validation failures
14013
+ // leave the previous filter in force (§6.6).
14014
+ connection.on('tools-observe', (
14015
+ params: unknown,
14016
+ responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
14017
+ ) => {
14018
+ const parsed = parseToolObserveParams(params);
14019
+ if (!parsed.ok) {
14020
+ if (responder?.respondError) responder.respondError(-32602, parsed.message, parsed.data);
14021
+ else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`);
14022
+ return;
14023
+ }
14024
+ connection.toolObserveFilter = parsed.rules;
14025
+ responder?.respond({});
14026
+ });
14027
+
12928
14028
  connection.on('model-info', (
12929
14029
  _params: unknown,
12930
14030
  responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
@@ -12948,7 +14048,7 @@ export class AgentFramework {
12948
14048
  ) => {
12949
14049
  const barrier = this.discordAwarenessBarrier;
12950
14050
  if (barrier) await barrier.promise;
12951
- this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
14051
+ await this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
12952
14052
  });
12953
14053
 
12954
14054
  // Handle host-level admin commands from a surface (e.g. Discord /undo)
@@ -13111,6 +14211,10 @@ export class AgentFramework {
13111
14211
  // disconnectMcplServer, which deletes the trees explicitly.
13112
14212
  connection.on('close', (code?: number | null, signal?: string | null) => {
13113
14213
  this.featureSetManager?.removeServer(connection.id);
14214
+ // RFC-008: a closed provider's class hints are stale — on reconnect it
14215
+ // may class its tools differently. Until the next tools/list its tools
14216
+ // are unclassed, the restrictive answer (RFC-007 §4.3).
14217
+ this.dropMcplToolClasses(connection.id);
13114
14218
  this.emitTrace({
13115
14219
  type: 'mcpl:server-closed',
13116
14220
  serverId: connection.id,
@@ -13131,6 +14235,8 @@ export class AgentFramework {
13131
14235
 
13132
14236
  const tools: import('./types/index.js').ToolDefinition[] = [];
13133
14237
  const toolFeatureSets = new Map<string, string>();
14238
+ const toolClasses = new Map<string, ToolClass[]>();
14239
+ const toolServers = new Map<string, string>();
13134
14240
 
13135
14241
  for (const server of this.mcplServerRegistry.getAllServers()) {
13136
14242
  const config = this.mcplServerConfigs.get(server.id);
@@ -13140,10 +14246,15 @@ export class AgentFramework {
13140
14246
  for (const tool of result.tools) {
13141
14247
  if (!isToolAllowed(tool.name, config)) continue;
13142
14248
  const namespacedName = `${prefix}--${tool.name}`;
14249
+ toolServers.set(namespacedName, server.id);
13143
14250
  const attributedTool = tool as typeof tool & {
13144
14251
  featureSet?: unknown;
13145
- _meta?: { featureSet?: unknown };
14252
+ _meta?: { featureSet?: unknown; [key: string]: unknown };
13146
14253
  };
14254
+ // RFC-008: the server's class hint for this tool. Never shown to
14255
+ // the model (the definition below is unchanged by it).
14256
+ const declaredClasses = parseDeclaredClasses(attributedTool._meta, namespacedName);
14257
+ if (declaredClasses) toolClasses.set(namespacedName, declaredClasses);
13147
14258
  const featureSet = typeof attributedTool.featureSet === 'string'
13148
14259
  ? attributedTool.featureSet
13149
14260
  : typeof attributedTool._meta?.featureSet === 'string'
@@ -13167,6 +14278,8 @@ export class AgentFramework {
13167
14278
 
13168
14279
  this.mcplTools = tools;
13169
14280
  this.mcplToolFeatureSets = toolFeatureSets;
14281
+ this.mcplToolClasses = toolClasses;
14282
+ this.mcplToolServers = toolServers;
13170
14283
  }
13171
14284
 
13172
14285
  /**
@@ -13232,6 +14345,8 @@ export class AgentFramework {
13232
14345
  const server = this.mcplServerRegistry!.getServer(serverId);
13233
14346
 
13234
14347
  if (!server) {
14348
+ // RFC-007: refused before executing — no lifecycle events.
14349
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
13235
14350
  this.pushEvent({
13236
14351
  type: 'tool-result',
13237
14352
  callId: call.id,
@@ -13244,6 +14359,7 @@ export class AgentFramework {
13244
14359
 
13245
14360
  const config = this.mcplServerConfigs.get(serverId);
13246
14361
  if (!isToolAllowed(toolName, config)) {
14362
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
13247
14363
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
13248
14364
  this.pushEvent({
13249
14365
  type: 'tool-result',
@@ -13420,6 +14536,8 @@ export class AgentFramework {
13420
14536
  .catch((error) => {
13421
14537
  const err = error instanceof Error ? error : new Error(String(error));
13422
14538
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
14539
+ // RFC-007: transport error, closed connection or timeout — no result.
14540
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
13423
14541
 
13424
14542
  this.pushEvent({
13425
14543
  type: 'tool-result',
@@ -13446,6 +14564,8 @@ export class AgentFramework {
13446
14564
  const home = this.conversationAgentHomes.get(agentName);
13447
14565
  if (home) {
13448
14566
  const reject = (error: string): void => {
14567
+ // RFC-007: the guard refuses before the tool runs — no lifecycle events.
14568
+ this.toolLifecycleEmitter?.refuse(agentName, call.id);
13449
14569
  this.emitTrace({
13450
14570
  type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
13451
14571
  error: `conversation agent ${agentName}: ${error}`,
@@ -13529,6 +14649,7 @@ export class AgentFramework {
13529
14649
  .catch((error) => {
13530
14650
  const err = error instanceof Error ? error : new Error(String(error));
13531
14651
  this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
14652
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
13532
14653
  this.pushEvent({
13533
14654
  type: 'tool-result',
13534
14655
  callId: call.id,
@@ -13767,7 +14888,8 @@ export class AgentFramework {
13767
14888
  if (announce && this.channelRegistry) {
13768
14889
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
13769
14890
  const agent = this.agents.get(agentName);
13770
- if (agent?.proseRouting === 'disabled') {
14891
+ const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true;
14892
+ if (silentTurn || agent?.proseRouting === 'disabled') {
13771
14893
  console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
13772
14894
  } else if (agent?.proseRouting === 'explicit') {
13773
14895
  // Explicit mode: announce to the turn's sticky prose target if the
@@ -14611,6 +15733,7 @@ export class AgentFramework {
14611
15733
  } catch (error) {
14612
15734
  const err = error instanceof Error ? error : new Error(String(error));
14613
15735
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
15736
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
14614
15737
  result = { success: false, error: err.message, isError: true };
14615
15738
  }
14616
15739
  this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
@@ -14635,6 +15758,7 @@ export class AgentFramework {
14635
15758
  .catch((error) => {
14636
15759
  const err = error instanceof Error ? error : new Error(String(error));
14637
15760
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
15761
+ this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
14638
15762
  this.pushEvent({
14639
15763
  type: 'tool-result',
14640
15764
  callId: call.id,