@animalabs/agent-framework 0.11.0 → 0.13.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 (160) hide show
  1. package/dist/src/agent.d.ts +11 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +60 -4
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +72 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +873 -123
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +7 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +15 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/kv-unified-wire.d.ts +16 -0
  14. package/dist/src/kv-unified-wire.d.ts.map +1 -0
  15. package/dist/src/kv-unified-wire.js +2 -0
  16. package/dist/src/kv-unified-wire.js.map +1 -0
  17. package/dist/src/mcpl/channel-registry.d.ts +84 -1
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +188 -5
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  22. package/dist/src/mcpl/hook-orchestrator.js +28 -2
  23. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  24. package/dist/src/mcpl/push-handler.d.ts +6 -1
  25. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  26. package/dist/src/mcpl/push-handler.js +29 -2
  27. package/dist/src/mcpl/push-handler.js.map +1 -1
  28. package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
  29. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
  30. package/dist/src/mcpl/reference-fetcher.js +240 -0
  31. package/dist/src/mcpl/reference-fetcher.js.map +1 -0
  32. package/dist/src/mcpl/references.d.ts +125 -0
  33. package/dist/src/mcpl/references.d.ts.map +1 -0
  34. package/dist/src/mcpl/references.js +262 -0
  35. package/dist/src/mcpl/references.js.map +1 -0
  36. package/dist/src/mcpl/types.d.ts +32 -0
  37. package/dist/src/mcpl/types.d.ts.map +1 -1
  38. package/dist/src/mcpl/types.js.map +1 -1
  39. package/dist/src/modules/index.d.ts +2 -1
  40. package/dist/src/modules/index.d.ts.map +1 -1
  41. package/dist/src/modules/index.js +1 -1
  42. package/dist/src/modules/index.js.map +1 -1
  43. package/dist/src/modules/workspace/index.d.ts +125 -2
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +354 -58
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +7 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/tool-result-history.d.ts.map +1 -1
  51. package/dist/src/tool-result-history.js +14 -0
  52. package/dist/src/tool-result-history.js.map +1 -1
  53. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  54. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  55. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  56. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  57. package/dist/src/tune-out/coordinator.d.ts +144 -0
  58. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  59. package/dist/src/tune-out/coordinator.js +444 -0
  60. package/dist/src/tune-out/coordinator.js.map +1 -0
  61. package/dist/src/tune-out/tools.d.ts +43 -0
  62. package/dist/src/tune-out/tools.d.ts.map +1 -0
  63. package/dist/src/tune-out/tools.js +93 -0
  64. package/dist/src/tune-out/tools.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +15 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +6 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/usage-accounting.d.ts +5 -0
  70. package/dist/src/usage-accounting.d.ts.map +1 -0
  71. package/dist/src/usage-accounting.js +11 -0
  72. package/dist/src/usage-accounting.js.map +1 -0
  73. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  74. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  75. package/dist/test/channel-incoming-targeting.test.js +81 -0
  76. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  77. package/dist/test/conversation-routing.test.js +39 -0
  78. package/dist/test/conversation-routing.test.js.map +1 -1
  79. package/dist/test/kv-unified-wiring.test.d.ts +2 -0
  80. package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
  81. package/dist/test/kv-unified-wiring.test.js +56 -0
  82. package/dist/test/kv-unified-wiring.test.js.map +1 -0
  83. package/dist/test/mcpl-push-convert.test.d.ts +2 -0
  84. package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
  85. package/dist/test/mcpl-push-convert.test.js +42 -0
  86. package/dist/test/mcpl-push-convert.test.js.map +1 -0
  87. package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
  88. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
  89. package/dist/test/mcpl-reference-fetcher.test.js +199 -0
  90. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
  91. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  92. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  93. package/dist/test/per-agent-delivery.test.js +108 -0
  94. package/dist/test/per-agent-delivery.test.js.map +1 -0
  95. package/dist/test/present-while-acting.test.js +64 -0
  96. package/dist/test/present-while-acting.test.js.map +1 -1
  97. package/dist/test/tool-result-history.test.js +78 -0
  98. package/dist/test/tool-result-history.test.js.map +1 -1
  99. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  100. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  101. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  102. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  103. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  104. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  105. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  106. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  107. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  108. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  109. package/dist/test/tune-out-e2e.test.js +142 -0
  110. package/dist/test/tune-out-e2e.test.js.map +1 -0
  111. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  112. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  113. package/dist/test/tune-out-gate-composition.test.js +221 -0
  114. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  115. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  116. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  117. package/dist/test/tune-out-lifecycle.test.js +121 -0
  118. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  119. package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
  120. package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
  121. package/dist/test/turn-trigger-provenance.test.js +85 -0
  122. package/dist/test/turn-trigger-provenance.test.js.map +1 -0
  123. package/dist/test/usage-accounting.test.d.ts +2 -0
  124. package/dist/test/usage-accounting.test.d.ts.map +1 -0
  125. package/dist/test/usage-accounting.test.js +13 -0
  126. package/dist/test/usage-accounting.test.js.map +1 -0
  127. package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
  128. package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
  129. package/dist/test/workspace-branch-continuation.test.js +183 -0
  130. package/dist/test/workspace-branch-continuation.test.js.map +1 -0
  131. package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
  132. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
  133. package/dist/test/workspace-read-file-from-disk.test.js +174 -0
  134. package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
  135. package/dist/test/workspace-read-image.test.js +8 -0
  136. package/dist/test/workspace-read-image.test.js.map +1 -1
  137. package/dist/test/workspace-restore-order.test.js +1 -1
  138. package/dist/test/workspace-restore-order.test.js.map +1 -1
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +2 -2
  141. package/src/agent.ts +77 -4
  142. package/src/framework.ts +953 -128
  143. package/src/gate/event-gate.ts +28 -3
  144. package/src/kv-unified-wire.ts +12 -0
  145. package/src/mcpl/channel-registry.ts +264 -8
  146. package/src/mcpl/hook-orchestrator.ts +29 -2
  147. package/src/mcpl/push-handler.ts +30 -2
  148. package/src/mcpl/reference-fetcher.ts +240 -0
  149. package/src/mcpl/references.ts +292 -0
  150. package/src/mcpl/types.ts +29 -0
  151. package/src/modules/index.ts +2 -1
  152. package/src/modules/workspace/index.ts +425 -66
  153. package/src/modules/workspace/types.ts +7 -0
  154. package/src/tool-result-history.ts +11 -0
  155. package/src/tool-wrapper-prose-guard.ts +38 -0
  156. package/src/tune-out/coordinator.ts +548 -0
  157. package/src/tune-out/tools.ts +129 -0
  158. package/src/types/agent.ts +16 -0
  159. package/src/types/framework.ts +7 -0
  160. package/src/usage-accounting.ts +8 -0
package/src/framework.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { join } from 'node:path';
2
+ import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceRegistry, referenceStubOrNull } from './mcpl/references.js';
3
+ import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js';
2
4
  import { appendFileSync, mkdirSync } from 'node:fs';
3
5
  import { JsStore } from '@animalabs/chronicle';
4
6
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
5
7
  import { MembraneError } from '@animalabs/membrane';
6
- import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
8
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
9
+ import type { CacheWireReceipt } from './kv-unified-wire.js';
10
+ import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
11
+ import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
7
12
  import type {
8
13
  MessageId,
9
14
  MessageMetadata,
@@ -64,6 +69,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
64
69
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
65
70
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
66
71
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
72
+ import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
67
73
  import { InferenceRouter } from './mcpl/inference-router.js';
68
74
  import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
69
75
  import { ConversationRouter } from './mcpl/conversation-router.js';
@@ -81,6 +87,7 @@ import {
81
87
  CODE_EXECUTION_TOOL_NAME,
82
88
  } from './code-execution/tool-definition.js';
83
89
  import { splitProseSegments } from './prose-segments.js';
90
+ import { cumulativeDelta } from './usage-accounting.js';
84
91
 
85
92
  /** Detect a supported image media type from magic bytes (the model API
86
93
  * rejects mislabeled media types, so trust bytes over extensions).
@@ -126,8 +133,24 @@ const SILENCING_TOOLS = new Set([
126
133
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
127
134
  ]);
128
135
 
129
- /** World-surface publication names that outrank hybrid prose envelopes. */
130
- const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
136
+ /**
137
+ * World-surface publication tools (Eidoverse `say` / `whisper`). An explicit
138
+ * world utterance is the resident's chosen public speech for that round, so
139
+ * it silences adjacent auto-routed prose exactly like a channel send — in
140
+ * EVERY prose-routing mode. Until 2026-09-01 this set was consulted only in
141
+ * hybrid mode; in locus mode a round of ordinary text + `say` published twice
142
+ * (Cairn world seq 15146 = the say text, 15147 = the adjacent prose,
143
+ * byte-for-byte). Hybrid additionally lets these outrank a same-round
144
+ * `>>>destination` envelope.
145
+ */
146
+ const WORLD_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
147
+
148
+ /** True when a tool call (possibly MCPL-prefixed) is an explicit delivery
149
+ * that silences the round's auto-routed prose, regardless of mode. */
150
+ const isSilencingTool = (name: string): boolean => {
151
+ const bare = bareToolName(name);
152
+ return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare);
153
+ };
131
154
 
132
155
  /**
133
156
  * True when an injected message is real conversational input — something the
@@ -768,6 +791,16 @@ export class AgentFramework {
768
791
  private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
769
792
  /** Active runEphemeralToCompletion runs, keyed by agent name. */
770
793
  private ephemeralRuns: Map<string, EphemeralRun> = new Map();
794
+ /** Ephemeral namespaces/names are single-generation for this framework
795
+ * lifetime. Reusing one while an old stream drains would let name-keyed
796
+ * liveness/settlement state cross generations. */
797
+ private usedEphemeralAgentNames: Set<string> = new Set();
798
+ /** One-shot generation tickets minted by createEphemeralAgent. */
799
+ private ephemeralCandidates: WeakMap<Agent, ContextManager> = new WeakMap();
800
+ /** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
801
+ * The name is single-generation, so these frames still own their terminal
802
+ * typing/outgoing close even after runEphemeralToCompletion deregisters them. */
803
+ private disposedEphemeralFrames: WeakSet<Agent> = new WeakSet();
771
804
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
772
805
  * Drives hard-down escalation — see noteInferenceExhausted. */
773
806
  private consecutiveInferenceFailures: Map<string, number> = new Map();
@@ -821,7 +854,23 @@ export class AgentFramework {
821
854
 
822
855
  // Messages deferred while an agent is waiting_for_tools (to preserve
823
856
  // tool_use → tool_result adjacency required by the Anthropic API).
824
- private deferredMessages: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
857
+ /** The subconscious resident's registry name (issue #77), or null. */
858
+ private subconsciousAgentName: string | null = null;
859
+ /** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
860
+ private subconsciousStrategy: WindowedPassthroughStrategy | null = null;
861
+ /** Its config (speak_in_channel gate, voice block provenance). */
862
+ private subconsciousConfig: SubconsciousConfig | null = null;
863
+ /** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
864
+ private tuneOutCoordinator: TuneOutCoordinator | null = null;
865
+
866
+ private deferredMessages: Array<{
867
+ participant: string;
868
+ content: ContentBlock[];
869
+ metadata?: MessageMetadata;
870
+ /** Delivery target (registry name). Absent = the primary agent —
871
+ * the historical behavior of every existing caller. */
872
+ forAgent?: string;
873
+ }> = [];
825
874
 
826
875
  // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
827
876
  // token of the turn currently in progress. Set at startAgentStream ENTRY —
@@ -840,6 +889,23 @@ export class AgentFramework {
840
889
  // (KV bust). Invariant: nothing enters the window between a turn's dequeue
841
890
  // and its settle except that turn's own blocks.
842
891
  private activeTurnTokens: Map<string, number> = new Map();
892
+ /**
893
+ * The InferenceRequest that started each agent's turn in progress — set and
894
+ * cleared exactly where the turn token is. Read-only outside: the host
895
+ * stamps gateway telemetry (why this call exists, in which channel, woken
896
+ * by whom) from it, so a household ledger can attribute wakes without
897
+ * guessing from the newest message in the window.
898
+ */
899
+ private activeTurnTriggers: Map<string, InferenceRequest | undefined> = new Map();
900
+
901
+ /** The trigger of `agentName`'s turn in progress, if a turn is running. */
902
+ getActiveTurnTrigger(agentName: string): InferenceRequest | undefined {
903
+ return this.activeTurnTriggers.get(agentName);
904
+ }
905
+
906
+ /** Structured calls executed in the current logical turn. Unlike the
907
+ * per-stream local this survives framework retries and budget restarts. */
908
+ private logicalTurnToolCalls: WeakMap<Agent, { turnToken: number; count: number }> = new WeakMap();
843
909
  private nextTurnToken = 1;
844
910
 
845
911
  // Undo/redo state
@@ -1098,6 +1164,13 @@ export class AgentFramework {
1098
1164
  await framework.createAgent(agentConfig);
1099
1165
  }
1100
1166
 
1167
+ // The subconscious resident (issue #77) registers after the residents so
1168
+ // it can never become primary; it is excluded from every broadcast
1169
+ // fan-out and woken only by the tune-out coordinator's explicit pushes.
1170
+ if (config.subconscious?.enabled) {
1171
+ await framework.createSubconsciousAgent(config.subconscious);
1172
+ }
1173
+
1101
1174
  // Finish any branch-local suppression interrupted after Chronicle switched
1102
1175
  // branches. This runs before modules, MCPL connections, or inbound traffic.
1103
1176
  await framework.resumePreparedDiscordSuppressions();
@@ -1147,11 +1220,12 @@ export class AgentFramework {
1147
1220
  initialConfig: config.gate.config,
1148
1221
  privilegedUsersPath: config.gate.privilegedUsersPath,
1149
1222
  emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
1150
- addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata),
1223
+ addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined),
1151
1224
  requestInference: (agentName, reason, source) => {
1152
1225
  framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
1153
1226
  },
1154
- getAgentNames: () => [...framework.agents.keys()],
1227
+ getAgentNames: () => [...framework.agents.keys()].filter(
1228
+ (n) => n !== framework.subconsciousAgentName),
1155
1229
  });
1156
1230
  }
1157
1231
 
@@ -1214,6 +1288,40 @@ export class AgentFramework {
1214
1288
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
1215
1289
  }
1216
1290
 
1291
+ // Tune-out coordinator (issue #77): needs both the subconscious resident
1292
+ // and the MCPL channel subsystem; created after both exist.
1293
+ if (framework.subconsciousAgentName && framework.channelRegistry) {
1294
+ framework.tuneOutCoordinator = new TuneOutCoordinator(
1295
+ framework.channelRegistry,
1296
+ framework.mcplServerRegistry!,
1297
+ {
1298
+ addMessage: (participant, content, metadata, forAgent) =>
1299
+ framework.addMessage(participant, content, metadata as MessageMetadata,
1300
+ forAgent ? { forAgent } : undefined),
1301
+ requestInference: (agentName, reason, source) => {
1302
+ framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
1303
+ },
1304
+ subconsciousName: () => framework.subconsciousAgentName,
1305
+ primaryName: () => framework.primaryAgentName,
1306
+ getStoredMessages: () => {
1307
+ const primary = framework.primaryAgentName
1308
+ ? framework.agents.get(framework.primaryAgentName) : undefined;
1309
+ return primary ? primary.getContextManager().getAllMessages() : [];
1310
+ },
1311
+ currentSequence: () => framework.store.currentSequence(),
1312
+ setSubconsciousAnchor: (sequence) =>
1313
+ framework.subconsciousStrategy?.setAnchor(sequence),
1314
+ isForkBound: (channelId) =>
1315
+ [...framework.conversationAgentHomes.values()].includes(channelId),
1316
+ isPrivilegedAuthor: (authorId) =>
1317
+ framework.eventGate?.isPrivilegedUser(authorId) ?? false,
1318
+ allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
1319
+ emitTrace: (e) => framework.emitTrace(e as never),
1320
+ },
1321
+ );
1322
+ framework.tuneOutCoordinator.resumeActiveEpochs();
1323
+ }
1324
+
1217
1325
  // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
1218
1326
  // (journal) without a restart — for catching the wake-wedge on the running
1219
1327
  // process. Shows the gate's `inferring` set + buffered-event count (the
@@ -1283,6 +1391,7 @@ export class AgentFramework {
1283
1391
  this.running = false;
1284
1392
  this.providerAdmissionClosed = true;
1285
1393
  this.queue.close();
1394
+ this.tuneOutCoordinator?.stop();
1286
1395
 
1287
1396
  // Kill running code_execution scripts before cancelling streams: a
1288
1397
  // zombie script must not keep firing side-effectful tool calls into a
@@ -1718,7 +1827,12 @@ export class AgentFramework {
1718
1827
  */
1719
1828
  getAllTools(): import('./types/index.js').ToolDefinition[] {
1720
1829
  const moduleTools = this.moduleRegistry.getAllTools();
1721
- const channelTools = this.channelRegistry?.getChannelTools() ?? [];
1830
+ // Copy: getChannelTools() returns the registry's shared definitions
1831
+ // array; pushing onto it would append another tune_out on every call.
1832
+ const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
1833
+ if (this.tuneOutCoordinator) {
1834
+ channelTools.push(AgentFramework.TUNE_OUT_TOOL);
1835
+ }
1722
1836
  const gateTools = this.eventGate
1723
1837
  ? [
1724
1838
  this.eventGate.getToolDefinition(),
@@ -1736,7 +1850,8 @@ export class AgentFramework {
1736
1850
  ...gateTools,
1737
1851
  this.buildAgentSettingsTool(),
1738
1852
  ...(this.getWorkspaceModule()
1739
- ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1853
+ ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL,
1854
+ AgentFramework.FETCH_REFERENCE_TOOL]
1740
1855
  : []),
1741
1856
  ...(this.codeExecutionConfig
1742
1857
  ? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
@@ -1752,6 +1867,55 @@ export class AgentFramework {
1752
1867
  : undefined;
1753
1868
  }
1754
1869
 
1870
+ /** RFC-005 host-mediated reference fetcher (lazy; needs server configs +
1871
+ * a writable workspace mount for storage). */
1872
+ private referenceFetcherInstance?: ReferenceFetcher;
1873
+ private getReferenceFetcher(): ReferenceFetcher {
1874
+ if (!this.referenceFetcherInstance) {
1875
+ this.referenceFetcherInstance = new ReferenceFetcher(
1876
+ (serverId) => {
1877
+ const cfg = this.mcplServerConfigs.get(serverId);
1878
+ if (!cfg?.url) return undefined;
1879
+ return { url: cfg.url, token: cfg.token, autofetch: cfg.autofetch };
1880
+ },
1881
+ async (fileName, data, mimeType) => {
1882
+ const workspace = this.getWorkspaceModule();
1883
+ if (!workspace) return null;
1884
+ const mount = this.firstWritableMountName(workspace);
1885
+ if (!mount) return null;
1886
+ const path = `${mount}/refs/${fileName}`;
1887
+ const res = await workspace.writeBinary(path, data, mimeType);
1888
+ return res.success ? path : null;
1889
+ },
1890
+ );
1891
+ }
1892
+ return this.referenceFetcherInstance;
1893
+ }
1894
+
1895
+ /** Register every RFC-005 reference in a tool-result content array against
1896
+ * its server, then eagerly fetch the ones inside the autofetch envelope
1897
+ * (below the eager ceiling, connection origin — never worse than the
1898
+ * server having inlined the bytes). Failures are non-fatal: the stub then
1899
+ * says fetch_reference instead of a path. */
1900
+ private async autoFetchReferences(content: unknown[] | undefined, serverId: string, eager: boolean): Promise<void> {
1901
+ if (!content) return;
1902
+ const fetcher = this.getReferenceFetcher();
1903
+ const eligible: import('./mcpl/references.js').ReferenceRecord[] = [];
1904
+ for (const block of content) {
1905
+ const c = classifyBlock(block);
1906
+ if (c.kind !== 'reference') continue;
1907
+ const record = referenceRegistry.register(c.testimony, serverId);
1908
+ if (eager && fetcher.isEagerEligible(record)) eligible.push(record);
1909
+ }
1910
+ // Parallel with a count cap: N small fetches cost one round-trip of
1911
+ // wall-clock, and anything past the cap stays lazy (stub says so).
1912
+ const MAX_EAGER_PER_RESULT = 8;
1913
+ await Promise.all(eligible.slice(0, MAX_EAGER_PER_RESULT).map(async (record) => {
1914
+ const outcome = await fetcher.fetch(record, { timeoutMs: EAGER_FETCH_TIMEOUT_MS });
1915
+ if (!outcome.ok) console.error(`[rfc005] eager fetch of ${record.refId} failed: ${outcome.error}`);
1916
+ }));
1917
+ }
1918
+
1755
1919
  /** Core agent_settings keys — extension keys must not collide with these. */
1756
1920
  private static readonly AGENT_SETTINGS_CORE_KEYS = [
1757
1921
  'context_budget_tokens',
@@ -1918,6 +2082,20 @@ export class AgentFramework {
1918
2082
  agentName: string,
1919
2083
  snapshot?: InferenceToolSnapshot,
1920
2084
  ): import('./types/index.js').ToolDefinition[] {
2085
+ // The subconscious gets its own small surface (plus think/skip_reply/
2086
+ // end_turn), not the residents' tool board: no channel lifecycle, no
2087
+ // gate rules, no workspace — it observes, judges, and reports.
2088
+ if (agentName === this.subconsciousAgentName) {
2089
+ const basics = this.getAllTools()
2090
+ .filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
2091
+ .map((t) => t.name === 'think'
2092
+ ? this.buildThinkTool(
2093
+ snapshot?.sameRoundThinkTextPolicy
2094
+ ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy,
2095
+ )
2096
+ : t);
2097
+ return [...SUBCONSCIOUS_TOOLS, ...basics];
2098
+ }
1921
2099
  return this.getAllTools().map((tool) => {
1922
2100
  if (tool.name === 'think') {
1923
2101
  return this.buildThinkTool(
@@ -2338,25 +2516,38 @@ export class AgentFramework {
2338
2516
  contextManager: ContextManager;
2339
2517
  cleanup: () => void;
2340
2518
  }> {
2341
- const namespace = `subagent/${config.name}`;
2519
+ // Names are Chronicle namespaces and generation identities. Reserve before
2520
+ // opening the namespace so a second creation cannot append to an earlier
2521
+ // generation before runEphemeralToCompletion has a chance to reject it.
2522
+ if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
2523
+ throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
2524
+ }
2525
+ this.usedEphemeralAgentNames.add(config.name);
2526
+ try {
2527
+ const namespace = `subagent/${config.name}`;
2342
2528
 
2343
- const contextManager = await ContextManager.open({
2344
- store: this.store,
2345
- namespace,
2346
- isolate: true,
2347
- strategy: config.strategy ?? new PassthroughStrategy(),
2348
- membrane: this.membrane,
2349
- debugLogContext: !!process.env.DEBUG_CONTEXT,
2350
- });
2529
+ const contextManager = await ContextManager.open({
2530
+ store: this.store,
2531
+ namespace,
2532
+ isolate: true,
2533
+ strategy: config.strategy ?? new PassthroughStrategy(),
2534
+ membrane: this.membrane,
2535
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
2536
+ });
2351
2537
 
2352
- const agent = new Agent(config, contextManager, this.membrane);
2538
+ const agent = new Agent(config, contextManager, this.membrane);
2539
+ this.ephemeralCandidates.set(agent, contextManager);
2353
2540
 
2354
- const cleanup = () => {
2355
- // Don't close the store — it's shared. Just release the CM.
2356
- // Data persists in the store under the namespace for investigation.
2357
- };
2541
+ const cleanup = () => {
2542
+ // Don't close the store — it's shared. Just release the CM.
2543
+ // Data persists in the store under the namespace for investigation.
2544
+ };
2358
2545
 
2359
- return { agent, contextManager, cleanup };
2546
+ return { agent, contextManager, cleanup };
2547
+ } catch (error) {
2548
+ this.usedEphemeralAgentNames.delete(config.name);
2549
+ throw error;
2550
+ }
2360
2551
  }
2361
2552
 
2362
2553
  private createDeferred<T>(): Deferred<T> {
@@ -2379,6 +2570,17 @@ export class AgentFramework {
2379
2570
  run.lastActivity = Date.now();
2380
2571
  }
2381
2572
 
2573
+ /** Record structured calls for the current logical turn. Ignore late stream
2574
+ * events after an ephemeral/conversation agent has been disposed. */
2575
+ private recordLogicalTurnToolCalls(agent: Agent, turnToken: number, count: number): void {
2576
+ if (count <= 0 || this.agents.get(agent.name) !== agent) return;
2577
+ const state = this.logicalTurnToolCalls.get(agent);
2578
+ // The Agent object is the generation: a late stream from a disposed
2579
+ // ephemeral/conversation fork cannot contaminate a same-name successor.
2580
+ if (!state || state.turnToken !== turnToken) return;
2581
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
2582
+ }
2583
+
2382
2584
  private recordEphemeralToolCalls(agentName: string, count: number): void {
2383
2585
  const run = this.ephemeralRuns.get(agentName);
2384
2586
  if (!run) return;
@@ -2422,7 +2624,18 @@ export class AgentFramework {
2422
2624
  contextManager: ContextManager,
2423
2625
  watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
2424
2626
  ): Promise<{ speech: string; toolCallsCount: number }> {
2425
- // Register temporarily so the event loop can drive it
2627
+ // Only a fresh object returned by createEphemeralAgent may enter this path.
2628
+ // Never overwrite a resident/conversation owner or a concurrent run: their
2629
+ // cleanup is name-keyed and could cancel/deregister the legitimate owner.
2630
+ if (this.ephemeralCandidates.get(agent) !== contextManager) {
2631
+ throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
2632
+ }
2633
+ // Consume before any await/registration: the exact (Agent, ContextManager)
2634
+ // generation is one-shot even if startup later fails.
2635
+ this.ephemeralCandidates.delete(agent);
2636
+ if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
2637
+ throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
2638
+ }
2426
2639
  this.agents.set(agent.name, agent);
2427
2640
  const run: EphemeralRun = {
2428
2641
  settle: this.createDeferred<AgentSettleResult>(),
@@ -2487,8 +2700,25 @@ export class AgentFramework {
2487
2700
  } finally {
2488
2701
  if (startupWatchdog) clearTimeout(startupWatchdog);
2489
2702
  if (completionWatchdog) clearInterval(completionWatchdog);
2490
- this.ephemeralRuns.delete(agent.name);
2491
- this.agents.delete(agent.name);
2703
+ // Mark before cancellation: an adapter may synchronously settle its
2704
+ // iterator, and this physical frame still owns terminal typing/outgoing
2705
+ // closure even though the ephemeral name is about to be deregistered.
2706
+ this.disposedEphemeralFrames.add(agent);
2707
+ // Cancel before deregistration. Adapters may still yield a queued event;
2708
+ // driveStream's Agent-identity check above discards it before dispatch.
2709
+ agent.cancelStream();
2710
+ if (this.ephemeralRuns.get(agent.name) === run) this.ephemeralRuns.delete(agent.name);
2711
+ if (this.agents.get(agent.name) === agent) {
2712
+ // Exact-generation disposal owns these name-keyed entries. Remove them
2713
+ // before deregistration; the late driveStream finally will see the
2714
+ // generation mismatch and leave any future owner untouched.
2715
+ this.activeStreams.delete(agent.name);
2716
+ this.pendingAssistantBlocks.delete(agent.name);
2717
+ // The late stream finalizer intentionally refuses name-keyed cleanup once
2718
+ // deregistered, so disposal must release EventGate liveness itself.
2719
+ this.eventGate?.onInferenceEnded(agent.name);
2720
+ this.agents.delete(agent.name);
2721
+ }
2492
2722
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2493
2723
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2494
2724
  // entries behind for the life of the store/session.
@@ -2499,6 +2729,8 @@ export class AgentFramework {
2499
2729
  // unbounded map growth. Blind delete is safe: the agent is already out
2500
2730
  // of the map, and a late driveStream finally token-match no-ops.
2501
2731
  this.activeTurnTokens.delete(agent.name);
2732
+ this.activeTurnTriggers.delete(agent.name);
2733
+ this.logicalTurnToolCalls.delete(agent);
2502
2734
  }
2503
2735
  }
2504
2736
 
@@ -2513,6 +2745,48 @@ export class AgentFramework {
2513
2745
  * Query inference logs.
2514
2746
  * Returns entries with summary info (doesn't resolve blobs).
2515
2747
  */
2748
+ /** Tune-out (issue #77): divert a channel to your subconscious. Present
2749
+ * only when the subconscious resident is configured. */
2750
+ private static readonly TUNE_OUT_TOOL: import('./types/index.js').ToolDefinition = {
2751
+ name: 'tune_out',
2752
+ description:
2753
+ 'Divert a channel to your subconscious for a while: its traffic stops ' +
2754
+ 'entering your context and stops waking you. Your subconscious watches ' +
2755
+ 'it instead — summarizing on a cadence in its own voice, judging ' +
2756
+ 'whether mentions merit interrupting, and able to cancel. People who ' +
2757
+ 'mention you get an automatic reaction so they know you saw nothing. ' +
2758
+ 'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
2759
+ 'tune-out auto-cancels after maxWakes mention-bursts.',
2760
+ inputSchema: {
2761
+ type: 'object',
2762
+ properties: {
2763
+ channelId: { type: 'string' },
2764
+ mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
2765
+ cadenceSeconds: {
2766
+ type: 'number',
2767
+ description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
2768
+ },
2769
+ backlogCap: {
2770
+ type: 'number',
2771
+ description:
2772
+ `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
2773
+ '0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
2774
+ },
2775
+ maxWakes: {
2776
+ type: 'number',
2777
+ description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
2778
+ },
2779
+ durationSeconds: {
2780
+ type: 'number',
2781
+ description:
2782
+ 'Optional bounded duration: the tune-out auto-cancels (backlog ' +
2783
+ 'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
2784
+ },
2785
+ },
2786
+ required: ['channelId'],
2787
+ },
2788
+ };
2789
+
2516
2790
  /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
2517
2791
  private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
2518
2792
  {
@@ -2730,6 +3004,28 @@ export class AgentFramework {
2730
3004
  },
2731
3005
  };
2732
3006
 
3007
+ /** Synthesized fetch_reference tool — RFC-005 on-demand dereference.
3008
+ * Present when a workspace module is registered (storage target). */
3009
+ private static readonly FETCH_REFERENCE_TOOL: import('./types/index.js').ToolDefinition = {
3010
+ name: 'fetch_reference',
3011
+ description:
3012
+ 'Fetch a referenced payload (a [ref_…] id from a tool result or event) ' +
3013
+ 'to a workspace file via the host-mediated fetcher. The fetch is ' +
3014
+ 'origin-bound to the server that sent the reference, size-capped, and ' +
3015
+ 'digest-verified when the reference carries one. Returns the workspace ' +
3016
+ 'path plus verified size/type; then use ordinary workspace tools on it. ' +
3017
+ 'Small payloads may already be fetched automatically — their stubs show ' +
3018
+ 'a saved path instead.',
3019
+ inputSchema: {
3020
+ type: 'object',
3021
+ properties: {
3022
+ ref_id: { type: 'string', description: 'The reference id, e.g. "ref_1a_x9z2".' },
3023
+ max_bytes: { type: 'number', description: 'Optional per-call byte ceiling (default host policy).' },
3024
+ },
3025
+ required: ['ref_id'],
3026
+ },
3027
+ };
3028
+
2733
3029
  /** Synthesized read_image tool — present when a workspace module is
2734
3030
  * registered. Returns the image as a native image block in the tool
2735
3031
  * result, so the agent SEES it in the live turn (history keeps a compact
@@ -3934,6 +4230,15 @@ export class AgentFramework {
3934
4230
  // streaming agent keeps the original Membrane.
3935
4231
  membrane: this.auxiliaryMembraneFor(config.name),
3936
4232
  debugLogContext: !!process.env.DEBUG_CONTEXT,
4233
+ // Tune-out (issue #77): messages stamped at ingestion as diverted
4234
+ // (metadata.tuneOut = { epochId }) never enter a resident's compiled
4235
+ // view — not at emission, not via chunking into the memory pyramid.
4236
+ // The stamp is permanent: after cancel, the backlog arrives as one
4237
+ // appended <tuned-out-backlog> dump instead of retro-inserting into
4238
+ // the timeline (KV-prefix-stable delivery). The stored originals
4239
+ // remain in the shared slot for the subconscious's merged view,
4240
+ // fetch_history, and audit.
4241
+ viewFilter: (message) => !(message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut,
3937
4242
  });
3938
4243
 
3939
4244
  const agent = new Agent(config, contextManager, this.membrane);
@@ -3954,6 +4259,71 @@ export class AgentFramework {
3954
4259
  return agent;
3955
4260
  }
3956
4261
 
4262
+ /**
4263
+ * Create and register the subconscious resident (issue #77): a persistent
4264
+ * side-agent with an isolated slot (`subconscious/<primary>`) whose
4265
+ * strategy view merges the residents' shared timeline with its own —
4266
+ * including the tuned-out-stamped messages the residents' viewFilter
4267
+ * excludes; the backlog is precisely what it exists to see. Windowed
4268
+ * passthrough, no memory pyramid: its durable output is what it delivers
4269
+ * into the resident's window, which accumulates there.
4270
+ */
4271
+ private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise<Agent> {
4272
+ const primaryName = this.primaryAgentName;
4273
+ const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
4274
+ if (!primaryName || !primaryConfig) {
4275
+ throw new Error('subconscious requires a primary agent to attend to');
4276
+ }
4277
+ const name = cfg.name ?? 'Subconscious';
4278
+ if (this.agents.has(name)) {
4279
+ throw new Error(`subconscious name "${name}" collides with a registered agent`);
4280
+ }
4281
+
4282
+ const strategy = new WindowedPassthroughStrategy({
4283
+ reAnchorFraction: cfg.reAnchorFraction,
4284
+ // Mirror the principal's per-message truncation ceiling so both see
4285
+ // oversized tool results / attachments under the same policy.
4286
+ maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
4287
+ });
4288
+ const contextManager = await ContextManager.open({
4289
+ store: this.store,
4290
+ namespace: `subconscious/${primaryName}`,
4291
+ isolate: true,
4292
+ // The residents' shared un-namespaced slot, merged read-only and
4293
+ // UNfiltered (no viewFilter here — stamped messages included).
4294
+ auxiliaryMessageViews: [{}],
4295
+ strategy,
4296
+ membrane: this.membrane,
4297
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
4298
+ });
4299
+
4300
+ // Inherit the principal's inference settings wholesale (fork-template
4301
+ // idiom): reasoning effort (thinking), provider params, token ceilings,
4302
+ // caching, refusal handling, timezone — a same-model side-process must
4303
+ // not silently run under different inference conditions than the
4304
+ // resident it serves. Only identity- and surface-defining fields are
4305
+ // overridden below.
4306
+ const agentConfig: AgentConfig = {
4307
+ ...primaryConfig,
4308
+ name,
4309
+ model: cfg.model ?? primaryConfig.model,
4310
+ systemPrompt: cfg.systemPrompt,
4311
+ strategy: undefined, // its CM owns the windowed strategy instance
4312
+ // Prose is never auto-routed: a cadence-triggered turn carries no
4313
+ // locus, and bare prose must not fall through to the default channel.
4314
+ // Speech happens only via speak_in_channel (its own marked voice).
4315
+ proseRouting: 'explicit',
4316
+ allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
4317
+ };
4318
+ const agent = new Agent(agentConfig, contextManager, this.membrane);
4319
+ this.agents.set(name, agent);
4320
+ this.agentConfigs.set(name, agentConfig);
4321
+ this.subconsciousAgentName = name;
4322
+ this.subconsciousStrategy = strategy;
4323
+ this.subconsciousConfig = cfg;
4324
+ return agent;
4325
+ }
4326
+
3957
4327
  private async runLoop(): Promise<void> {
3958
4328
  while (this.running) {
3959
4329
  try {
@@ -4092,12 +4462,11 @@ export class AgentFramework {
4092
4462
  // queued, they flush at the target's own next boundary — with
4093
4463
  // injection — or in driveStream's finally when its turn ends.
4094
4464
  const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
4095
- if (this.deferredMessages.length > 0) {
4096
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
4097
- if (target === agent) {
4098
- const deferred = this.deferredMessages.splice(0);
4465
+ {
4466
+ const deferred = this.drainDeferredFor(agent.name);
4467
+ if (deferred.length > 0) {
4099
4468
  for (const msg of deferred) {
4100
- target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4469
+ agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4101
4470
  // Injection guards: tool blocks would corrupt the tool-cycle
4102
4471
  // structure the membrane enforces, and a message named as the
4103
4472
  // agent itself would render as an ASSISTANT turn on the wire
@@ -4432,7 +4801,8 @@ export class AgentFramework {
4432
4801
  // their own channel's messages, not by framework-wide events.
4433
4802
  const targetAgents =
4434
4803
  response.requestInference === true
4435
- ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
4804
+ ? Array.from(this.agents.keys()).filter(
4805
+ (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
4436
4806
  : response.requestInference;
4437
4807
 
4438
4808
  for (const agentName of targetAgents) {
@@ -4469,6 +4839,7 @@ export class AgentFramework {
4469
4839
  metadata?: Record<string, unknown>;
4470
4840
  tags?: string[];
4471
4841
  triggerInference?: boolean;
4842
+ targetAgents?: string[];
4472
4843
  }): Promise<void> {
4473
4844
  const metadata: Record<string, unknown> = {
4474
4845
  ...event.metadata,
@@ -4515,12 +4886,38 @@ export class AgentFramework {
4515
4886
  }
4516
4887
  }
4517
4888
 
4889
+ // Tune-out divert (issue #77): stamped, stored, no resident wake. The
4890
+ // coordinator has already handled wake bookkeeping (coalesced
4891
+ // subconscious invocation, durable count, suppression ack) before we
4892
+ // stamp; the subconscious reads the message through its merged view.
4893
+ const divert = this.tuneOutCoordinator?.onIncoming(
4894
+ event.serverId,
4895
+ event.channelId,
4896
+ event.messageId,
4897
+ event.tags,
4898
+ event.author?.id,
4899
+ // The resident's gate verdict rides the event; it preconditions
4900
+ // subconscious wakes (gate composes, it is not replaced).
4901
+ event.triggerInference !== false,
4902
+ ) ?? null;
4903
+ if (divert) {
4904
+ metadata.tuneOut = { epochId: divert.epochId };
4905
+ }
4906
+
4518
4907
  const id = this.addMessage('user', incomingContent, metadata);
4519
4908
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
4520
4909
 
4521
- if (event.triggerInference) {
4910
+ if (event.triggerInference && !divert) {
4522
4911
  const addressed = isAddressedMessage(event.tags, event.metadata);
4523
- for (const agentName of this.agents.keys()) {
4912
+ // Honor explicit targeting, mirroring the push-event path: an event
4913
+ // that names its agents wakes exactly those; an untargeted event
4914
+ // keeps the historical broadcast. (targetAgents was declared on
4915
+ // McplChannelIncomingEvent from the start but never honored here —
4916
+ // tune-out's wake routing is the first setter.)
4917
+ const targetAgents = event.targetAgents
4918
+ ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
4919
+ for (const agentName of targetAgents) {
4920
+ if (!this.agents.has(agentName)) continue;
4524
4921
  this.pendingRequests.push({
4525
4922
  agentName,
4526
4923
  reason: 'mcpl:channel-incoming',
@@ -4529,6 +4926,7 @@ export class AgentFramework {
4529
4926
  // Route this turn's auto-published speech back to THIS channel, not
4530
4927
  // the global most-recent-inbound locus (item-3 redux, trunk agents).
4531
4928
  channelId: event.channelId,
4929
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
4532
4930
  // Addressed messages outrank ambient chatter when a batched wake
4533
4931
  // picks the turn's frozen speech locus.
4534
4932
  addressed,
@@ -4630,6 +5028,7 @@ export class AgentFramework {
4630
5028
  // A fork's home channel wins in routeSpeech regardless, but carry the
4631
5029
  // triggering channel too so the trunk/active path stays consistent.
4632
5030
  channelId: event.channelId,
5031
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
4633
5032
  addressed: isAddressedMessage(event.tags, event.metadata),
4634
5033
  });
4635
5034
  }
@@ -4649,41 +5048,50 @@ export class AgentFramework {
4649
5048
  if (!template || !templateConfig) {
4650
5049
  throw new Error(`conversation template agent "${router.templateAgent}" not found`);
4651
5050
  }
5051
+ if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
5052
+ throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
5053
+ }
5054
+ this.usedEphemeralAgentNames.add(name);
5055
+ try {
4652
5056
 
4653
- const contextManager = await ContextManager.open({
4654
- store: this.store,
4655
- namespace: `conversations/${name}`,
4656
- isolate: true,
4657
- // Strategy instances are stateful — never share the template's.
4658
- strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
4659
- // Dynamic conversation forks retain the established provider policy in
4660
- // this bounded first slice. Provider cooldown ownership belongs to the
4661
- // persistent resident only; generation-unique forks must not leak gates.
4662
- membrane: this.membrane,
4663
- debugLogContext: !!process.env.DEBUG_CONTEXT,
4664
- });
5057
+ const contextManager = await ContextManager.open({
5058
+ store: this.store,
5059
+ namespace: `conversations/${name}`,
5060
+ isolate: true,
5061
+ // Strategy instances are stateful — never share the template's.
5062
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
5063
+ // Dynamic conversation forks retain the established provider policy in
5064
+ // this bounded first slice. Provider cooldown ownership belongs to the
5065
+ // persistent resident only; generation-unique forks must not leak gates.
5066
+ membrane: this.membrane,
5067
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
5068
+ });
4665
5069
 
4666
- // Seed with the template's compiled context, renaming the template
4667
- // participant so the fork reads its inheritance as its own history.
4668
- // Guard: only seed a genuinely fresh namespace. Generation counters are
4669
- // persisted precisely so names aren't reused, but if this namespace has
4670
- // history anyway (counter state lost, crash between spawn and persist),
4671
- // seeding again would stack another template copy on top of it.
4672
- const { messages: existing } = await contextManager.compile();
4673
- if (existing.length === 0) {
4674
- const { messages: compiled } = await template.getContextManager().compile();
4675
- for (const msg of compiled) {
4676
- const participant = msg.participant === template.name ? name : msg.participant;
4677
- contextManager.addMessage(participant, msg.content);
5070
+ // Seed with the template's compiled context, renaming the template
5071
+ // participant so the fork reads its inheritance as its own history.
5072
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
5073
+ // persisted precisely so names aren't reused, but if this namespace has
5074
+ // history anyway (counter state lost, crash between spawn and persist),
5075
+ // seeding again would stack another template copy on top of it.
5076
+ const { messages: existing } = await contextManager.compile();
5077
+ if (existing.length === 0) {
5078
+ const { messages: compiled } = await template.getContextManager().compile();
5079
+ for (const msg of compiled) {
5080
+ const participant = msg.participant === template.name ? name : msg.participant;
5081
+ contextManager.addMessage(participant, msg.content);
5082
+ }
4678
5083
  }
4679
- }
4680
5084
 
4681
- const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
4682
- const agent = new Agent(config, contextManager, this.membrane);
4683
- this.agents.set(name, agent);
4684
- this.agentConfigs.set(name, config);
4685
- this.conversationAgentHomes.set(name, channelId);
4686
- return agent;
5085
+ const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
5086
+ const agent = new Agent(config, contextManager, this.membrane);
5087
+ this.agents.set(name, agent);
5088
+ this.agentConfigs.set(name, config);
5089
+ this.conversationAgentHomes.set(name, channelId);
5090
+ return agent;
5091
+ } catch (error) {
5092
+ this.usedEphemeralAgentNames.delete(name);
5093
+ throw error;
5094
+ }
4687
5095
  }
4688
5096
 
4689
5097
  /** Persist the router's generation counters (see hydration in create()). */
@@ -4707,10 +5115,12 @@ export class AgentFramework {
4707
5115
  private disposeConversationAgent(agentName: string): void {
4708
5116
  this.closingConversationAgents.delete(agentName);
4709
5117
  const channelId = this.conversationAgentHomes.get(agentName);
5118
+ const agent = this.agents.get(agentName);
4710
5119
  this.agents.delete(agentName);
4711
5120
  this.agentConfigs.delete(agentName);
4712
5121
  this.conversationAgentHomes.delete(agentName);
4713
5122
  this.evictTurnCheckpoints(agentName);
5123
+ if (agent) this.logicalTurnToolCalls.delete(agent);
4714
5124
  this.emitTrace({
4715
5125
  type: 'mcpl:conversation-disposed',
4716
5126
  agentName,
@@ -4946,7 +5356,8 @@ export class AgentFramework {
4946
5356
  if (event.triggerInference) {
4947
5357
  // Default broadcast excludes conversation forks (channel-driven).
4948
5358
  const targetAgents = event.targetAgents
4949
- ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
5359
+ ?? [...this.agents.keys()].filter(
5360
+ (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
4950
5361
  for (const agentName of targetAgents) {
4951
5362
  this.pendingRequests.push({
4952
5363
  agentName,
@@ -5147,6 +5558,9 @@ export class AgentFramework {
5147
5558
  `[inference-dropped] agent=${agentName} reason=policy-skip ` +
5148
5559
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
5149
5560
  );
5561
+ // A context-budget restart inherits the predecessor's EventGate liveness.
5562
+ // If policy drops the queued successor, no driveStream remains to release it.
5563
+ if (budgetRestart) this.eventGate?.onInferenceEnded(agentName);
5150
5564
  const gate = this.providerGates.get(agentName);
5151
5565
  if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
5152
5566
  gate.primaryPending = false;
@@ -5174,19 +5588,27 @@ export class AgentFramework {
5174
5588
  // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
5175
5589
  // Non-channel wakes (heartbeats, module events, reactions — which never
5176
5590
  // carry channelId) leave both undefined → global fallback.
5177
- let ambientChannel: string | undefined;
5178
- let addressedChannel: string | undefined;
5591
+ // Track the winning REQUEST, not just its channel: channel, addressed
5592
+ // and counterparty must come from the same message, or a batch of
5593
+ // "ambient from A, then addressed from B" would report B's channel
5594
+ // with A's author (review finding on the provenance change).
5595
+ let ambientReq: InferenceRequest | undefined;
5596
+ let addressedReq: InferenceRequest | undefined;
5179
5597
  for (const r of requests) {
5180
5598
  if (!r.channelId) continue;
5181
- ambientChannel = r.channelId;
5182
- if (r.addressed) addressedChannel = r.channelId;
5599
+ ambientReq = r;
5600
+ if (r.addressed) addressedReq = r;
5183
5601
  }
5184
- const triggerChannel = addressedChannel ?? ambientChannel;
5185
- const triggerAddressed = addressedChannel !== undefined;
5602
+ const channelReq = addressedReq ?? ambientReq;
5186
5603
  await this.startAgentStream(agent, {
5187
5604
  ...trigger,
5188
- channelId: triggerChannel,
5189
- addressed: triggerAddressed,
5605
+ channelId: channelReq?.channelId,
5606
+ addressed: addressedReq !== undefined,
5607
+ // A context-budget restart continues the same logical turn: it keeps
5608
+ // the channel for routing but names no author — the restart is its
5609
+ // own cause, and borrowing another request's author would be false
5610
+ // provenance.
5611
+ counterparty: budgetRestart ? undefined : channelReq?.counterparty,
5190
5612
  });
5191
5613
  }
5192
5614
  }
@@ -5686,12 +6108,14 @@ export class AgentFramework {
5686
6108
  // no-ops instead of clobbering a successor's marker.
5687
6109
  const turnToken = this.nextTurnToken++;
5688
6110
  this.activeTurnTokens.set(agent.name, turnToken);
6111
+ this.activeTurnTriggers.set(agent.name, trigger);
5689
6112
  let tokenHandedOff = false;
5690
6113
  try {
5691
6114
  tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
5692
6115
  } finally {
5693
6116
  if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
5694
6117
  this.activeTurnTokens.delete(agent.name);
6118
+ this.activeTurnTriggers.delete(agent.name);
5695
6119
  }
5696
6120
  if (!tokenHandedOff && ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
5697
6121
  }
@@ -5728,9 +6152,8 @@ export class AgentFramework {
5728
6152
  trigger?.reason !== 'context_budget_restart' &&
5729
6153
  this.deferredMessages.length > 0
5730
6154
  ) {
5731
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
5732
- if (target === agent) {
5733
- const deferred = this.deferredMessages.splice(0);
6155
+ {
6156
+ const deferred = this.drainDeferredFor(agent.name);
5734
6157
  for (const msg of deferred) {
5735
6158
  // Per-message try/catch (mirrors announceLocusIfChanged): one
5736
6159
  // poison message — or a transient store-write failure — must not
@@ -5788,8 +6211,21 @@ export class AgentFramework {
5788
6211
  // BEFORE this turn compiles, so the agent always knows where its voice
5789
6212
  // goes (announce-on-change only — no per-turn chatter, append-only for
5790
6213
  // KV stability).
6214
+ if (trigger?.reason === 'context_budget_restart') {
6215
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
6216
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
6217
+ }
6218
+
5791
6219
  if (trigger?.reason !== 'context_budget_restart') {
5792
6220
  if (attempt === 0) this.maybePrimeProseMode(agent);
6221
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
6222
+ if (attempt === 0) {
6223
+ // A true new turn gets a fresh generation and count.
6224
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
6225
+ } else {
6226
+ // A framework retry is a new physical stream in the same logical turn.
6227
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
6228
+ }
5793
6229
  // Fresh turn: forget the previous turn's explicit-send engagements and
5794
6230
  // prose deliveries — both are strictly turn-scoped (a restart continues
5795
6231
  // the same logical turn and keeps them).
@@ -5823,7 +6259,11 @@ export class AgentFramework {
5823
6259
  // context-budget restart, which skips the re-pin but re-emits this).
5824
6260
  channelId: this.turnLocusPins.get(agent.name),
5825
6261
  });
5826
- this.eventGate?.onInferenceStarted(agent.name);
6262
+ // A budget restart continues the same logical inference window. The
6263
+ // predecessor keeps EventGate liveness until its successor terminates.
6264
+ if (trigger?.reason !== 'context_budget_restart') {
6265
+ this.eventGate?.onInferenceStarted(agent.name);
6266
+ }
5827
6267
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
5828
6268
 
5829
6269
  // Typing indicator, started at TURN START. It says "attending", which is
@@ -5886,7 +6326,38 @@ export class AgentFramework {
5886
6326
  }
5887
6327
  }
5888
6328
 
5889
- const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
6329
+ // An ephemeral watchdog may dispose this Agent while hooks/compile await.
6330
+ // Do not create a provider stream after its generation lost ownership.
6331
+ if (this.agents.get(agent.name) !== agent) {
6332
+ this.channelRegistry?.stopTyping();
6333
+ this.eventGate?.onInferenceEnded(agent.name);
6334
+ return false;
6335
+ }
6336
+
6337
+ // The subconscious's standing dispositions ride a fixed system-position
6338
+ // injection (antra, #tuneout-talk): never repeated in its timeline, and
6339
+ // — since it has no long-term memory — structurally unable to wash out
6340
+ // of its window. Bytes change only when a disposition changes.
6341
+ if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
6342
+ const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
6343
+ if (dispositionInjection) {
6344
+ injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
6345
+ }
6346
+ }
6347
+
6348
+ const {
6349
+ stream,
6350
+ request: compiledRequest,
6351
+ takeKvSubmission,
6352
+ drainKvSubmissionIds,
6353
+ } = await agent.startStreamWithInjections(tools, injections);
6354
+ if (this.agents.get(agent.name) !== agent) {
6355
+ stream.cancel();
6356
+ agent.cancelStream();
6357
+ this.channelRegistry?.stopTyping();
6358
+ this.eventGate?.onInferenceEnded(agent.name);
6359
+ return false;
6360
+ }
5890
6361
 
5891
6362
  const handle = this.driveStream(
5892
6363
  agent,
@@ -5896,6 +6367,9 @@ export class AgentFramework {
5896
6367
  attempt,
5897
6368
  compiledRequest,
5898
6369
  ownsProviderGate,
6370
+ takeKvSubmission,
6371
+ drainKvSubmissionIds,
6372
+ new Set(tools.map((tool) => tool.name)),
5899
6373
  );
5900
6374
  this.activeStreams.set(agent.name, handle);
5901
6375
  // Handoff: driveStream captured the token in its synchronous prefix;
@@ -5936,6 +6410,7 @@ export class AgentFramework {
5936
6410
  // the eventGate `inferring` leak) — every exit path must clear it.
5937
6411
  if (this.activeTurnTokens.get(agent.name) === turnToken) {
5938
6412
  this.activeTurnTokens.delete(agent.name);
6413
+ this.activeTurnTriggers.delete(agent.name);
5939
6414
  }
5940
6415
  this.settleAgent(agent.name, {
5941
6416
  stopReason: 'exhausted',
@@ -5977,15 +6452,29 @@ export class AgentFramework {
5977
6452
  attempt = 0,
5978
6453
  compiledRequest?: NormalizedRequest,
5979
6454
  ownsProviderGate = false,
6455
+ takeKvSubmission?: () => { submissionId: string; wireReceipt: CacheWireReceipt } | undefined,
6456
+ drainKvSubmissionIds?: () => string[],
6457
+ knownToolNames: ReadonlySet<string> = new Set(),
5980
6458
  ): Promise<void> {
5981
6459
  const startTime = Date.now();
5982
6460
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
5983
6461
  const myStreamId = agent.streamId;
6462
+ // Membrane usage events are cumulative across the native/XML tool loop.
6463
+ // Keep the previous cumulative sample so consumers that operate at the
6464
+ // physical provider-call boundary (estimator calibration and kv receipt
6465
+ // acceptance) see this call only, not an ever-growing turn total.
6466
+ let previousUsage = {
6467
+ inputTokens: 0,
6468
+ outputTokens: 0,
6469
+ cacheCreationTokens: 0,
6470
+ cacheReadTokens: 0,
6471
+ };
5984
6472
  // This turn's alive-marker, set at startAgentStream entry. Safe to read
5985
6473
  // from the map here: the turn-alive busy check in processInferenceRequests
5986
6474
  // means no successor turn can have replaced it while we compiled.
5987
6475
  const myTurnToken = this.activeTurnTokens.get(agent.name);
5988
6476
  let hadToolCalls = false;
6477
+ let generationLost = false;
5989
6478
 
5990
6479
  // ---- Present-while-acting turn state ---------------------------------
5991
6480
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
@@ -6069,13 +6558,20 @@ export class AgentFramework {
6069
6558
  // failed, a framework-cancelled stream sets aborted. Best-effort
6070
6559
  // notifications — consumers dedupe by inferenceId and keep a timeout.
6071
6560
  let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
6561
+ let preserveEventGateForSuccessor = false;
6072
6562
  this.hookOrchestrator?.emitLifecycle({
6073
6563
  inferenceId: outgoingInferenceId,
6074
6564
  conversationId: agent.name,
6075
6565
  turnIndex: 0,
6076
6566
  phase: 'started',
6077
6567
  });
6078
- const proseStream = this.channelRegistry && agent.proseRouting !== 'disabled'
6568
+ // The wrapper guard needs the complete response before it can distinguish
6569
+ // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
6570
+ // turns until completion: otherwise speculative outgoing chunks could
6571
+ // expose the wrapper before the fail-closed classifier runs.
6572
+ const proseStream = this.channelRegistry &&
6573
+ agent.proseRouting !== 'disabled' &&
6574
+ !agent.toolWrapperProseGuard
6079
6575
  ? new ProseStreamRouter({
6080
6576
  mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
6081
6577
  initialTarget: typingChannel,
@@ -6105,6 +6601,14 @@ export class AgentFramework {
6105
6601
 
6106
6602
  try {
6107
6603
  for await (const event of stream) {
6604
+ // Ignore every late event from an Agent generation that no longer owns
6605
+ // this name (ephemeral watchdog/disposal, conversation replacement).
6606
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
6607
+ generationLost = true;
6608
+ lifecyclePhase = 'aborted';
6609
+ stream.cancel();
6610
+ break;
6611
+ }
6108
6612
  this.touchEphemeralRun(agent.name, true);
6109
6613
  switch (event.type) {
6110
6614
  case 'tokens':
@@ -6172,6 +6676,7 @@ export class AgentFramework {
6172
6676
  case 'tool-calls': {
6173
6677
  adoptInjectedRound();
6174
6678
  hadToolCalls = true;
6679
+ this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
6175
6680
  this.recordEphemeralToolCalls(agent.name, event.calls.length);
6176
6681
  this.emitTrace({
6177
6682
  type: 'inference:tool_calls_yielded',
@@ -6257,10 +6762,7 @@ export class AgentFramework {
6257
6762
  const hasSameRoundPrivateThink =
6258
6763
  roundToolNames.includes('think') &&
6259
6764
  requestSnapshot.sameRoundThinkTextPolicy === 'private';
6260
- if (roundToolNames.some((n) =>
6261
- SILENCING_TOOLS.has(bareToolName(n)) ||
6262
- (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
6263
- )) {
6765
+ if (roundToolNames.some(isSilencingTool)) {
6264
6766
  turnSilenced = true;
6265
6767
  }
6266
6768
  if (roundContent && roundContent.length > 0) {
@@ -6380,7 +6882,30 @@ export class AgentFramework {
6380
6882
  const terminalContent = lastToolIdx >= 0
6381
6883
  ? response.content.slice(lastToolIdx + 1)
6382
6884
  : response.content;
6383
- if (lastToolIdx >= 0) {
6885
+ // This is a whole-response boundary, not a trailing-prose
6886
+ // classifier. If the turn executed any genuine structured tool
6887
+ // call, preserve its later prose exactly as ordinary history.
6888
+ const logicalToolState = this.logicalTurnToolCalls.get(agent);
6889
+ const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
6890
+ const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
6891
+ ? detectKnownToolWrapperProse(response.content, knownToolNames)
6892
+ : null;
6893
+ if (guardedWrapperTool) {
6894
+ // AF #133: containment only. Never execute wrapper text, never
6895
+ // publish it, and never retain it as the freshest assistant
6896
+ // self-seed. Exact raw output remains in the provider ledger.
6897
+ agent.getContextManager().addMessage('user', [{
6898
+ type: 'text',
6899
+ text: '[tool-boundary] No tool was called.',
6900
+ }], {
6901
+ system: true,
6902
+ kind: 'tool-wrapper-prose-contained',
6903
+ toolName: guardedWrapperTool,
6904
+ });
6905
+ console.error(
6906
+ `[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`,
6907
+ );
6908
+ } else if (lastToolIdx >= 0) {
6384
6909
  if (terminalContent.length > 0) {
6385
6910
  agent.addAssistantResponse(terminalContent);
6386
6911
  }
@@ -6406,7 +6931,9 @@ export class AgentFramework {
6406
6931
  // text is speech.
6407
6932
  const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
6408
6933
  block.type === 'text';
6409
- const allText = response.content.filter(isTextBlock);
6934
+ const allText = guardedWrapperTool
6935
+ ? []
6936
+ : response.content.filter(isTextBlock);
6410
6937
 
6411
6938
  const speechContent = hadToolCalls ? [] : allText;
6412
6939
  const thoughts = hadToolCalls ? allText : [];
@@ -6421,19 +6948,30 @@ export class AgentFramework {
6421
6948
  }
6422
6949
  : undefined;
6423
6950
 
6951
+ // Async completion work may overlap a replacement stream. Recheck
6952
+ // physical ownership before any terminal state/settlement side effect.
6953
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
6954
+ generationLost = true;
6955
+ lifecyclePhase = 'aborted';
6956
+ stream.cancel();
6957
+ return;
6958
+ }
6959
+
6424
6960
  // Reset agent state before emitting inference:completed. Traces are
6425
6961
  // observability-only, but external synchronous listeners should
6426
6962
  // still see the terminal state at the terminal trace boundary.
6427
6963
  // Speech dispatch happens after but doesn't depend on the status
6428
6964
  // field.
6429
6965
  agent.reset();
6430
- this.eventGate?.onInferenceEnded(agent.name);
6966
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6431
6967
  this.settleAgent(agent.name, {
6432
6968
  stopReason: 'completed',
6433
- speech: terminalContent
6434
- .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
6435
- .map((block) => block.text)
6436
- .join('\n'),
6969
+ speech: guardedWrapperTool
6970
+ ? ''
6971
+ : terminalContent
6972
+ .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
6973
+ .map((block) => block.text)
6974
+ .join('\n'),
6437
6975
  });
6438
6976
 
6439
6977
  this.emitTrace({
@@ -6675,10 +7213,7 @@ export class AgentFramework {
6675
7213
  .filter((n): n is string => typeof n === 'string');
6676
7214
  const silenced = liveProseRouting
6677
7215
  ? turnSilenced
6678
- : turnSilenced || toolNames.some((n) =>
6679
- SILENCING_TOOLS.has(bareToolName(n)) ||
6680
- (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
6681
- );
7216
+ : turnSilenced || toolNames.some(isSilencingTool);
6682
7217
 
6683
7218
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
6684
7219
 
@@ -6810,7 +7345,7 @@ export class AgentFramework {
6810
7345
 
6811
7346
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
6812
7347
  lifecyclePhase = 'failed';
6813
- this.eventGate?.onInferenceEnded(agent.name);
7348
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6814
7349
  break;
6815
7350
  }
6816
7351
 
@@ -6834,7 +7369,7 @@ export class AgentFramework {
6834
7369
  // itself) means retrying the same context can never succeed.
6835
7370
  ...this.classifyInferenceError(err),
6836
7371
  });
6837
- this.eventGate?.onInferenceEnded(agent.name);
7372
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6838
7373
  if (action.emit) {
6839
7374
  this.pushEvent(action.emit);
6840
7375
  }
@@ -6856,7 +7391,13 @@ export class AgentFramework {
6856
7391
  if (cancelKind !== undefined) {
6857
7392
  this.frameworkCancelledStreams.delete(cancelKey);
6858
7393
  lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
6859
- this.eventGate?.onInferenceEnded(agent.name);
7394
+ // A budget restart continues the same EventGate window. Do not
7395
+ // end it here or in finally; the replacement owns final release.
7396
+ if (cancelKind === 'budget_restart') {
7397
+ preserveEventGateForSuccessor = true;
7398
+ } else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
7399
+ this.eventGate?.onInferenceEnded(agent.name);
7400
+ }
6860
7401
  // endTurn IS a logical turn end — earlier rounds may have
6861
7402
  // live-routed prose (narrate → skip_reply is a real shape),
6862
7403
  // so settle the delivery chain and drop the receipt. A
@@ -6903,7 +7444,7 @@ export class AgentFramework {
6903
7444
  request: compiledRequest ?? { note: 'streaming request aborted' },
6904
7445
  durationMs,
6905
7446
  });
6906
- this.eventGate?.onInferenceEnded(agent.name);
7447
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6907
7448
  }
6908
7449
  break;
6909
7450
  }
@@ -6925,19 +7466,62 @@ export class AgentFramework {
6925
7466
  // loop (5 calls x ~160k reported as 884k), which is not a
6926
7467
  // window-shaped number and drove the multiplier to 2.37 before
6927
7468
  // the guards caught it.
7469
+ const cumulativeUsage = {
7470
+ inputTokens: event.usage.inputTokens ?? 0,
7471
+ outputTokens: event.usage.outputTokens ?? 0,
7472
+ cacheCreationTokens: event.usage.cacheCreationTokens ?? 0,
7473
+ cacheReadTokens: event.usage.cacheReadTokens ?? 0,
7474
+ };
7475
+ const perCallUsage = {
7476
+ inputTokens: cumulativeDelta(cumulativeUsage.inputTokens, previousUsage.inputTokens),
7477
+ outputTokens: cumulativeDelta(cumulativeUsage.outputTokens, previousUsage.outputTokens),
7478
+ cacheCreationTokens: cumulativeDelta(
7479
+ cumulativeUsage.cacheCreationTokens,
7480
+ previousUsage.cacheCreationTokens,
7481
+ ),
7482
+ cacheReadTokens: cumulativeDelta(
7483
+ cumulativeUsage.cacheReadTokens,
7484
+ previousUsage.cacheReadTokens,
7485
+ ),
7486
+ };
7487
+ previousUsage = cumulativeUsage;
7488
+ const strat = (agent as unknown as {
7489
+ getContextManager?: () => { getStrategy?: () => unknown };
7490
+ }).getContextManager?.()?.getStrategy?.() as
7491
+ | {
7492
+ reportRealInputTokens?: (n: number) => void;
7493
+ reportKvUnifiedAccepted?: (args: {
7494
+ submissionId: string;
7495
+ acceptedAt?: number;
7496
+ wireReceipt?: CacheWireReceipt;
7497
+ }) => void;
7498
+ reportKvUnifiedFailed?: (submissionId: string) => void;
7499
+ }
7500
+ | undefined;
6928
7501
  try {
6929
7502
  const realTotal =
6930
- (event.usage.inputTokens ?? 0) +
6931
- (event.usage.cacheCreationTokens ?? 0) +
6932
- (event.usage.cacheReadTokens ?? 0);
6933
- const strat = (agent as unknown as {
6934
- getContextManager?: () => { getStrategy?: () => unknown };
6935
- }).getContextManager?.()?.getStrategy?.() as
6936
- | { reportRealInputTokens?: (n: number) => void }
6937
- | undefined;
7503
+ perCallUsage.inputTokens +
7504
+ perCallUsage.cacheCreationTokens +
7505
+ perCallUsage.cacheReadTokens;
6938
7506
  strat?.reportRealInputTokens?.(realTotal);
6939
7507
  } catch { /* calibration is best-effort */ }
6940
7508
 
7509
+ const kvSubmission = takeKvSubmission?.();
7510
+ if (kvSubmission) {
7511
+ try {
7512
+ strat?.reportKvUnifiedAccepted?.({
7513
+ submissionId: kvSubmission.submissionId,
7514
+ acceptedAt: Date.now(),
7515
+ wireReceipt: kvSubmission.wireReceipt,
7516
+ });
7517
+ } catch {
7518
+ // The queue item has already been consumed. Explicitly close
7519
+ // the receipt flight so one persistence error cannot wedge
7520
+ // every later provider call on this branch.
7521
+ try { strat?.reportKvUnifiedFailed?.(kvSubmission.submissionId); } catch { /* teardown only */ }
7522
+ }
7523
+ }
7524
+
6941
7525
  this.emitTrace({
6942
7526
  type: 'inference:usage',
6943
7527
  agentName: agent.name,
@@ -6953,6 +7537,14 @@ export class AgentFramework {
6953
7537
  }
6954
7538
  }
6955
7539
  } catch (error) {
7540
+ // A superseded physical stream may throw after its replacement starts.
7541
+ // It has no authority to settle, reset, gate-release, or publish failure.
7542
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
7543
+ generationLost = true;
7544
+ lifecyclePhase = 'aborted';
7545
+ stream.cancel();
7546
+ return;
7547
+ }
6956
7548
  // Stream itself threw. Organization acceleration is deferred from a
6957
7549
  // fresh compile; every other throw keeps the ordinary exhausted path.
6958
7550
  const err = error instanceof Error ? error : new Error(String(error));
@@ -6963,7 +7555,7 @@ export class AgentFramework {
6963
7555
  error: `Provider acceleration cooldown: ${err.message}`,
6964
7556
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
6965
7557
  this.abortAgentScript(agent.name, 'provider acceleration cooldown');
6966
- lifecyclePhase = 'failed'; agent.reset(); this.eventGate?.onInferenceEnded(agent.name);
7558
+ lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6967
7559
  return;
6968
7560
  }
6969
7561
  this.emitTrace({
@@ -6998,8 +7590,35 @@ export class AgentFramework {
6998
7590
  this.abortAgentScript(agent.name, 'stream threw');
6999
7591
  lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
7000
7592
  agent.reset();
7001
- this.eventGate?.onInferenceEnded(agent.name);
7593
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
7002
7594
  } finally {
7595
+ const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
7596
+ if (unsettledKvSubmissions.length > 0) {
7597
+ try {
7598
+ const strategy = agent.getContextManager().getStrategy() as unknown as {
7599
+ reportKvUnifiedFailed?: (submissionId: string) => void;
7600
+ };
7601
+ for (const submissionId of unsettledKvSubmissions) {
7602
+ strategy.reportKvUnifiedFailed?.(submissionId);
7603
+ }
7604
+ } catch { /* receipt cleanup must not mask inference teardown */ }
7605
+ }
7606
+ // A framework retry/budget restart can overlap physical streams on the
7607
+ // same Agent object. Name + Agent identity is not enough: only the
7608
+ // current stream generation may mutate name-keyed teardown state.
7609
+ const ownsPhysicalStream =
7610
+ this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
7611
+ // Ephemeral completion resolves its caller before this finally runs, so
7612
+ // runEphemeralToCompletion may already have deregistered the name. The
7613
+ // terminal frame still owns its typing/outgoing completion unless it was
7614
+ // genuinely superseded or handed EventGate liveness to a budget restart.
7615
+ const disposedEphemeralWithoutSuccessor =
7616
+ generationLost &&
7617
+ this.disposedEphemeralFrames.has(agent) &&
7618
+ !this.agents.has(agent.name);
7619
+ const frameReachedTerminal =
7620
+ (!generationLost || disposedEphemeralWithoutSuccessor) &&
7621
+ !preserveEventGateForSuccessor;
7003
7622
  // §10.5: exactly one terminal per `started`, on every exit path the
7004
7623
  // host controls. Which one was decided by the path taken (default
7005
7624
  // completed; catch → failed; framework-cancel → aborted). A host
@@ -7018,30 +7637,36 @@ export class AgentFramework {
7018
7637
  // all incoming events → the agent silently stops waking on messages
7019
7638
  // (typing still stops, compression still runs — matching the observed
7020
7639
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
7021
- this.eventGate?.onInferenceEnded(agent.name);
7022
- this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
7640
+ if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
7641
+ this.eventGate?.onInferenceEnded(agent.name);
7642
+ }
7643
+ if (!generationLost && ownsPhysicalStream) {
7644
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
7645
+ }
7023
7646
  if (ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
7024
7647
 
7025
7648
  // Stop the typing indicator on every exit path (complete, error,
7026
7649
  // exhausted, abort) so it never sticks after the turn ends.
7027
- this.channelRegistry?.stopTyping();
7650
+ if (frameReachedTerminal) this.channelRegistry?.stopTyping();
7028
7651
 
7029
7652
  // Spec 14.3: flush any held line-start text, then close each streamed
7030
7653
  // channel with its final moderated content — the consumer's signal to
7031
7654
  // finalize (end the TTS utterance, settle the rendered message).
7032
- if (proseStream) {
7655
+ if (frameReachedTerminal && proseStream) {
7033
7656
  emitOutgoing(proseStream.finish());
7034
7657
  for (const [channelId, text] of proseStream.byChannel()) {
7035
7658
  this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
7036
7659
  }
7037
7660
  }
7038
7661
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
7039
- this.activeStreams.delete(agent.name);
7040
- this.pendingAssistantBlocks.delete(agent.name);
7662
+ if (ownsPhysicalStream) {
7663
+ this.activeStreams.delete(agent.name);
7664
+ this.pendingAssistantBlocks.delete(agent.name);
7665
+ }
7041
7666
 
7042
7667
  // A conversation fork whose TTL closure turn just finished is done for
7043
7668
  // good — dispose it so the agent map doesn't grow monotonically.
7044
- if (this.closingConversationAgents.has(agent.name)) {
7669
+ if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
7045
7670
  this.disposeConversationAgent(agent.name);
7046
7671
  }
7047
7672
 
@@ -7054,13 +7679,18 @@ export class AgentFramework {
7054
7679
  // injection / teardown delivers the messages at a correct position.
7055
7680
  if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
7056
7681
  this.activeTurnTokens.delete(agent.name);
7682
+ this.activeTurnTriggers.delete(agent.name);
7057
7683
  }
7058
7684
 
7059
- // Flush any deferred messages (e.g. if stream failed while tools were pending)
7060
- if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
7061
- const deferred = this.deferredMessages.splice(0);
7685
+ // Flush any deferred messages (e.g. if stream failed while tools were
7686
+ // pending). Only THIS agent's messages: other targets' entries wait
7687
+ // for their own boundaries (re-adding via addMessage re-defers if the
7688
+ // target has meanwhile started a turn).
7689
+ if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
7690
+ const deferred = this.drainDeferredFor(agent.name);
7062
7691
  for (const msg of deferred) {
7063
- this.addMessage(msg.participant, msg.content, msg.metadata);
7692
+ this.addMessage(msg.participant, msg.content, msg.metadata,
7693
+ msg.forAgent ? { forAgent: msg.forAgent } : undefined);
7064
7694
  }
7065
7695
  }
7066
7696
  }
@@ -7796,6 +8426,21 @@ export class AgentFramework {
7796
8426
  return `Error: ${result.error ?? 'tool call failed'}`;
7797
8427
  }
7798
8428
  if (result.data === undefined) return '';
8429
+ // RFC-005 "eagerly lazy": a script receiving a result is the strongest
8430
+ // available signal that the bytes are wanted (scripts process, they do
8431
+ // not browse) — materialize every referenced payload now, so the stub
8432
+ // the script receives carries a workspace path it can open directly.
8433
+ if (Array.isArray(result.data)) {
8434
+ const fetcher = this.getReferenceFetcher();
8435
+ for (const block of result.data) {
8436
+ const c = classifyBlock(block);
8437
+ if (c.kind !== 'reference') continue;
8438
+ const record = referenceRegistry.findByUri(c.testimony.uri)
8439
+ ?? referenceRegistry.register(c.testimony);
8440
+ const outcome = await fetcher.fetch(record);
8441
+ if (!outcome.ok) console.error(`[rfc005] script-demand fetch of ${record.refId} failed: ${outcome.error}`);
8442
+ }
8443
+ }
7799
8444
  // Scripts get (nearly) full data — the script environment IS the spill:
7800
8445
  // filtering big results in code is the whole point. 5MB protocol safety
7801
8446
  // cap only; no context-cap truncation, no file spill.
@@ -7915,6 +8560,11 @@ export class AgentFramework {
7915
8560
  for (const raw of data) {
7916
8561
  if (!raw || typeof raw !== 'object') return null;
7917
8562
  const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
8563
+ if (isInlineContradiction(b)) {
8564
+ // RFC-005 vector 2: withhold inline data claiming bulk disposition.
8565
+ blocks.push({ type: 'text', text: INLINE_WITHHELD_TEXT });
8566
+ continue;
8567
+ }
7918
8568
  if (b.type === 'text' && typeof b.text === 'string') {
7919
8569
  let text = b.text;
7920
8570
  if (maxChars && text.length > maxChars) {
@@ -7930,6 +8580,13 @@ export class AgentFramework {
7930
8580
  type: 'image',
7931
8581
  source: { type: 'base64', data: b.data, mediaType: b.mimeType },
7932
8582
  });
8583
+ } else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof (b as { uri?: unknown }).uri === 'string')) {
8584
+ // RFC-005 reference block → bounded stub. Previously this bailed the
8585
+ // whole array to JSON, which both inlined the reference verbatim and
8586
+ // demoted any adjacent image block to stringified base64.
8587
+ const stub = referenceStubOrNull(b, 'from tool result');
8588
+ if (stub === null) return null;
8589
+ blocks.push({ type: 'text', text: stub });
7933
8590
  } else {
7934
8591
  return null; // unknown shape — bail to JSON path
7935
8592
  }
@@ -8123,12 +8780,41 @@ export class AgentFramework {
8123
8780
  return;
8124
8781
  }
8125
8782
 
8783
+ if (enrichedCall.name === 'fetch_reference') {
8784
+ this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
8785
+ return;
8786
+ }
8787
+
8126
8788
  // Route gate_status tool
8127
8789
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
8128
8790
  this.dispatchGateToolCall(agentName, enrichedCall);
8129
8791
  return;
8130
8792
  }
8131
8793
 
8794
+ // Route the tune-out tool (residents) and the subconscious's surface
8795
+ if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
8796
+ this.dispatchTuneOutToolCall(agentName, enrichedCall);
8797
+ return;
8798
+ }
8799
+ if (
8800
+ this.tuneOutCoordinator &&
8801
+ agentName === this.subconsciousAgentName &&
8802
+ (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)
8803
+ ) {
8804
+ void this.tuneOutCoordinator
8805
+ .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>)
8806
+ .then((result) => {
8807
+ this.queue.push({
8808
+ type: 'tool-result',
8809
+ callId: enrichedCall.id,
8810
+ agentName,
8811
+ moduleName: 'tune-out',
8812
+ result,
8813
+ });
8814
+ });
8815
+ return;
8816
+ }
8817
+
8132
8818
  // Route sleep / wake tools
8133
8819
  if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
8134
8820
  this.dispatchSleepToolCall(agentName, enrichedCall);
@@ -8273,13 +8959,32 @@ export class AgentFramework {
8273
8959
  private addMessage(
8274
8960
  participant: string,
8275
8961
  content: ContentBlock[],
8276
- metadata?: MessageMetadata
8962
+ metadata?: MessageMetadata,
8963
+ opts?: {
8964
+ /**
8965
+ * Deliver into this registry agent's window instead of the primary's,
8966
+ * with the deferral/turn-alive guard evaluated against THAT agent's
8967
+ * turn state. Default (absent) is byte-identical to the historical
8968
+ * primary-only behavior. Unknown names log and drop (loud, non-fatal:
8969
+ * this is reachable from timer callbacks that may outlive an agent).
8970
+ */
8971
+ forAgent?: string;
8972
+ }
8277
8973
  ): MessageId {
8278
- // Route to the primary agent's context manager (not ephemeral subagents).
8279
- const agent = this.primaryAgentName
8280
- ? this.agents.get(this.primaryAgentName)
8281
- : this.agents.values().next().value;
8974
+ // Route to the named agent, else the primary (not ephemeral subagents).
8975
+ const agent = opts?.forAgent
8976
+ ? this.agents.get(opts.forAgent)
8977
+ : this.primaryAgentName
8978
+ ? this.agents.get(this.primaryAgentName)
8979
+ : this.agents.values().next().value;
8282
8980
  if (!agent) {
8981
+ if (opts?.forAgent) {
8982
+ console.error(
8983
+ `[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
8984
+ `(participant=${participant})`,
8985
+ );
8986
+ return '' as MessageId;
8987
+ }
8283
8988
  throw new Error('No agents configured');
8284
8989
  }
8285
8990
 
@@ -8300,19 +9005,48 @@ export class AgentFramework {
8300
9005
  // ALSO injected into the live stream — hear-while-acting), or in
8301
9006
  // driveStream's finally when the turn ends.
8302
9007
  const hasToolResult = content.some(b => b.type === 'tool_result');
9008
+ // Explicitly-targeted deliveries scope the tool-cycle check to the
9009
+ // target (pendingAssistantBlocks is keyed by agent); the default path
9010
+ // keeps the historical GLOBAL check byte-for-byte (conservative: any
9011
+ // agent's pending cycle defers primary-bound messages).
9012
+ const midToolCycle = opts?.forAgent
9013
+ ? this.pendingAssistantBlocks.has(agent.name)
9014
+ : this.pendingAssistantBlocks.size > 0;
8303
9015
  if (
8304
9016
  !hasToolResult &&
8305
- (this.pendingAssistantBlocks.size > 0 ||
9017
+ (midToolCycle ||
8306
9018
  this.activeTurnTokens.has(agent.name) ||
8307
9019
  this.activeStreams.has(agent.name))
8308
9020
  ) {
8309
- this.deferredMessages.push({ participant, content, metadata });
8310
- return '' as MessageId; // Deferred — flushed at the next boundary
9021
+ this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
9022
+ return '' as MessageId; // Deferred — flushed at the target's next boundary
8311
9023
  }
8312
9024
 
8313
9025
  return agent.getContextManager().addMessage(participant, content, metadata);
8314
9026
  }
8315
9027
 
9028
+ /**
9029
+ * Splice out the deferred messages addressed to `agentName` (explicitly
9030
+ * via forAgent, or implicitly when it is the primary), leaving other
9031
+ * targets' messages queued for their own boundaries.
9032
+ */
9033
+ private drainDeferredFor(agentName: string): Array<{
9034
+ participant: string;
9035
+ content: ContentBlock[];
9036
+ metadata?: MessageMetadata;
9037
+ forAgent?: string;
9038
+ }> {
9039
+ if (this.deferredMessages.length === 0) return [];
9040
+ const mine: typeof this.deferredMessages = [];
9041
+ const rest: typeof this.deferredMessages = [];
9042
+ for (const msg of this.deferredMessages) {
9043
+ const target = msg.forAgent ?? this.primaryAgentName;
9044
+ (target === agentName ? mine : rest).push(msg);
9045
+ }
9046
+ this.deferredMessages = rest;
9047
+ return mine;
9048
+ }
9049
+
8316
9050
  private editMessage(id: MessageId, content: ContentBlock[]): void {
8317
9051
  const agent = this.primaryAgentName
8318
9052
  ? this.agents.get(this.primaryAgentName)
@@ -10329,6 +11063,13 @@ export class AgentFramework {
10329
11063
  }
10330
11064
 
10331
11065
  server.sendToolsCall(toolName, args, stateParams)
11066
+ .then(async (result) => {
11067
+ // RFC-005: register references and eagerly fetch the small ones
11068
+ // before serialization, so their stubs carry a saved path.
11069
+ await this.autoFetchReferences(result.content, serverId, true)
11070
+ .catch((e) => console.error('[rfc005] autofetch error:', (e as Error).message));
11071
+ return result;
11072
+ })
10332
11073
  .then((result) => {
10333
11074
  const durationMs = Date.now() - startTime;
10334
11075
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
@@ -10696,6 +11437,53 @@ export class AgentFramework {
10696
11437
  * suppression window, optionally announces in the sticky channel, and ends
10697
11438
  * the turn (the agent goes idle immediately). `wake` clears sleep.
10698
11439
  */
11440
+ /** tune_out tool (residents): enter or cancel a channel tune-out. */
11441
+ private dispatchTuneOutToolCall(agentName: string, call: ToolCall): void {
11442
+ const coordinator = this.tuneOutCoordinator!;
11443
+ const input = (call.input ?? {}) as {
11444
+ channelId?: string; mode?: string;
11445
+ cadenceSeconds?: number; backlogCap?: number; maxWakes?: number;
11446
+ durationSeconds?: number;
11447
+ };
11448
+ const channelId = String(input.channelId ?? '');
11449
+ const entry = this.channelRegistry?.listChannelsRaw()
11450
+ .find((e) => e.descriptor.id === channelId);
11451
+ let result: { success: boolean; data?: unknown; error?: string; isError?: boolean };
11452
+ if (!entry) {
11453
+ result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
11454
+ } else if (input.mode === 'cancel') {
11455
+ const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
11456
+ result = r.ok
11457
+ ? { success: true, data: { cancelled: channelId } }
11458
+ : { success: false, error: r.error, isError: false };
11459
+ } else {
11460
+ const r = coordinator.enter(entry.serverId, channelId, {
11461
+ cadenceSeconds: input.cadenceSeconds,
11462
+ backlogCap: input.backlogCap,
11463
+ maxWakes: input.maxWakes,
11464
+ durationSeconds: input.durationSeconds,
11465
+ }, 'agent-tool');
11466
+ result = r.ok
11467
+ ? {
11468
+ success: true,
11469
+ data: {
11470
+ tunedOut: channelId,
11471
+ cadenceSeconds: r.params.cadenceSeconds,
11472
+ backlogCap: r.params.backlogCap,
11473
+ maxWakes: r.params.maxWakes,
11474
+ },
11475
+ }
11476
+ : { success: false, error: r.error, isError: false };
11477
+ }
11478
+ this.queue.push({
11479
+ type: 'tool-result',
11480
+ callId: call.id,
11481
+ agentName,
11482
+ moduleName: 'tune-out',
11483
+ result,
11484
+ });
11485
+ }
11486
+
10699
11487
  private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
10700
11488
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
10701
11489
  const gate = this.eventGate!;
@@ -10774,6 +11562,43 @@ export class AgentFramework {
10774
11562
  * blocks) delivers the actual image to the model; history storage keeps
10775
11563
  * the standard compact `[image: …]` placeholder.
10776
11564
  */
11565
+ /** RFC-005 on-demand dereference: [ref id] → workspace file, via the
11566
+ * origin-bound, ceiling-bounded, digest-verified host fetcher. */
11567
+ private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall): void {
11568
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
11569
+ const startTime = Date.now();
11570
+ void (async () => {
11571
+ const args = (call.input ?? {}) as Record<string, unknown>;
11572
+ const refId = String(args.ref_id ?? '');
11573
+ const record = referenceRegistry.get(refId);
11574
+ let result: { success: boolean; data?: string; error?: string; isError?: boolean };
11575
+ if (!record) {
11576
+ // Vector 20: a stale or unknown id is a defined miss, never a
11577
+ // different record.
11578
+ result = { success: false, isError: true,
11579
+ error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
11580
+ } else {
11581
+ const maxBytes = typeof args.max_bytes === 'number'
11582
+ ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
11583
+ const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
11584
+ result = outcome.ok
11585
+ ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
11586
+ mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
11587
+ : { success: false, isError: true, error: outcome.error };
11588
+ }
11589
+ this.emitTrace({
11590
+ type: result.isError ? 'tool:failed' : 'tool:completed',
11591
+ module: 'workspace', tool: call.name, callId: call.id,
11592
+ durationMs: Date.now() - startTime,
11593
+ ...(result.isError ? { error: result.error } : {}),
11594
+ });
11595
+ this.pushEvent({
11596
+ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
11597
+ result: { ...result, isError: result.isError ?? false },
11598
+ });
11599
+ })();
11600
+ }
11601
+
10777
11602
  private dispatchReadImageToolCall(agentName: string, call: ToolCall): void {
10778
11603
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
10779
11604
  const finish = (result: ToolResult): void => {