@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
@@ -1,8 +1,12 @@
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 { MembraneError } from '@animalabs/membrane';
5
- import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
7
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
8
+ import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
9
+ import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
6
10
  import { ProcessQueueImpl } from './queue.js';
7
11
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
8
12
  import { Agent } from './agent.js';
@@ -15,6 +19,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
15
19
  import { PushHandler } from './mcpl/push-handler.js';
16
20
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
17
21
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
22
+ import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
18
23
  import { InferenceRouter } from './mcpl/inference-router.js';
19
24
  import { ChannelRegistry } from './mcpl/channel-registry.js';
20
25
  import { ConversationRouter } from './mcpl/conversation-router.js';
@@ -24,6 +29,7 @@ import { randomUUID } from 'node:crypto';
24
29
  import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
25
30
  import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
26
31
  import { splitProseSegments } from './prose-segments.js';
32
+ import { cumulativeDelta } from './usage-accounting.js';
27
33
  /** Detect a supported image media type from magic bytes (the model API
28
34
  * rejects mislabeled media types, so trust bytes over extensions).
29
35
  * Returns undefined for non-image content. */
@@ -64,8 +70,23 @@ function sniffImageMediaType(data) {
64
70
  const SILENCING_TOOLS = new Set([
65
71
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
66
72
  ]);
67
- /** World-surface publication names that outrank hybrid prose envelopes. */
68
- const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
73
+ /**
74
+ * World-surface publication tools (Eidoverse `say` / `whisper`). An explicit
75
+ * world utterance is the resident's chosen public speech for that round, so
76
+ * it silences adjacent auto-routed prose exactly like a channel send — in
77
+ * EVERY prose-routing mode. Until 2026-09-01 this set was consulted only in
78
+ * hybrid mode; in locus mode a round of ordinary text + `say` published twice
79
+ * (Cairn world seq 15146 = the say text, 15147 = the adjacent prose,
80
+ * byte-for-byte). Hybrid additionally lets these outrank a same-round
81
+ * `>>>destination` envelope.
82
+ */
83
+ const WORLD_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
84
+ /** True when a tool call (possibly MCPL-prefixed) is an explicit delivery
85
+ * that silences the round's auto-routed prose, regardless of mode. */
86
+ const isSilencingTool = (name) => {
87
+ const bare = bareToolName(name);
88
+ return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare);
89
+ };
69
90
  /**
70
91
  * True when an injected message is real conversational input — something the
71
92
  * agent might actually be replying to — rather than ambient machinery.
@@ -504,6 +525,16 @@ export class AgentFramework {
504
525
  frameworkCancelledStreams = new Map();
505
526
  /** Active runEphemeralToCompletion runs, keyed by agent name. */
506
527
  ephemeralRuns = new Map();
528
+ /** Ephemeral namespaces/names are single-generation for this framework
529
+ * lifetime. Reusing one while an old stream drains would let name-keyed
530
+ * liveness/settlement state cross generations. */
531
+ usedEphemeralAgentNames = new Set();
532
+ /** One-shot generation tickets minted by createEphemeralAgent. */
533
+ ephemeralCandidates = new WeakMap();
534
+ /** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
535
+ * The name is single-generation, so these frames still own their terminal
536
+ * typing/outgoing close even after runEphemeralToCompletion deregisters them. */
537
+ disposedEphemeralFrames = new WeakSet();
507
538
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
508
539
  * Drives hard-down escalation — see noteInferenceExhausted. */
509
540
  consecutiveInferenceFailures = new Map();
@@ -551,6 +582,14 @@ export class AgentFramework {
551
582
  primaryAgentName = null;
552
583
  // Messages deferred while an agent is waiting_for_tools (to preserve
553
584
  // tool_use → tool_result adjacency required by the Anthropic API).
585
+ /** The subconscious resident's registry name (issue #77), or null. */
586
+ subconsciousAgentName = null;
587
+ /** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
588
+ subconsciousStrategy = null;
589
+ /** Its config (speak_in_channel gate, voice block provenance). */
590
+ subconsciousConfig = null;
591
+ /** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
592
+ tuneOutCoordinator = null;
554
593
  deferredMessages = [];
555
594
  // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
556
595
  // token of the turn currently in progress. Set at startAgentStream ENTRY —
@@ -569,6 +608,21 @@ export class AgentFramework {
569
608
  // (KV bust). Invariant: nothing enters the window between a turn's dequeue
570
609
  // and its settle except that turn's own blocks.
571
610
  activeTurnTokens = new Map();
611
+ /**
612
+ * The InferenceRequest that started each agent's turn in progress — set and
613
+ * cleared exactly where the turn token is. Read-only outside: the host
614
+ * stamps gateway telemetry (why this call exists, in which channel, woken
615
+ * by whom) from it, so a household ledger can attribute wakes without
616
+ * guessing from the newest message in the window.
617
+ */
618
+ activeTurnTriggers = new Map();
619
+ /** The trigger of `agentName`'s turn in progress, if a turn is running. */
620
+ getActiveTurnTrigger(agentName) {
621
+ return this.activeTurnTriggers.get(agentName);
622
+ }
623
+ /** Structured calls executed in the current logical turn. Unlike the
624
+ * per-stream local this survives framework retries and budget restarts. */
625
+ logicalTurnToolCalls = new WeakMap();
572
626
  nextTurnToken = 1;
573
627
  // Undo/redo state
574
628
  turnCounters = new Map(); // agentName → next turnIndex
@@ -780,6 +834,12 @@ export class AgentFramework {
780
834
  for (const agentConfig of config.agents) {
781
835
  await framework.createAgent(agentConfig);
782
836
  }
837
+ // The subconscious resident (issue #77) registers after the residents so
838
+ // it can never become primary; it is excluded from every broadcast
839
+ // fan-out and woken only by the tune-out coordinator's explicit pushes.
840
+ if (config.subconscious?.enabled) {
841
+ await framework.createSubconsciousAgent(config.subconscious);
842
+ }
783
843
  // Finish any branch-local suppression interrupted after Chronicle switched
784
844
  // branches. This runs before modules, MCPL connections, or inbound traffic.
785
845
  await framework.resumePreparedDiscordSuppressions();
@@ -824,11 +884,11 @@ export class AgentFramework {
824
884
  initialConfig: config.gate.config,
825
885
  privilegedUsersPath: config.gate.privilegedUsersPath,
826
886
  emitTrace: (e) => framework.emitTrace(e),
827
- addMessage: (p, c, m) => framework.addMessage(p, c, m),
887
+ addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m, forAgent ? { forAgent } : undefined),
828
888
  requestInference: (agentName, reason, source) => {
829
889
  framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
830
890
  },
831
- getAgentNames: () => [...framework.agents.keys()],
891
+ getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
832
892
  });
833
893
  }
834
894
  // Liveness watchdog: fail hard if the main thread wedges (opt-in).
@@ -879,6 +939,30 @@ export class AgentFramework {
879
939
  }
880
940
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
881
941
  }
942
+ // Tune-out coordinator (issue #77): needs both the subconscious resident
943
+ // and the MCPL channel subsystem; created after both exist.
944
+ if (framework.subconsciousAgentName && framework.channelRegistry) {
945
+ framework.tuneOutCoordinator = new TuneOutCoordinator(framework.channelRegistry, framework.mcplServerRegistry, {
946
+ addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata, forAgent ? { forAgent } : undefined),
947
+ requestInference: (agentName, reason, source) => {
948
+ framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
949
+ },
950
+ subconsciousName: () => framework.subconsciousAgentName,
951
+ primaryName: () => framework.primaryAgentName,
952
+ getStoredMessages: () => {
953
+ const primary = framework.primaryAgentName
954
+ ? framework.agents.get(framework.primaryAgentName) : undefined;
955
+ return primary ? primary.getContextManager().getAllMessages() : [];
956
+ },
957
+ currentSequence: () => framework.store.currentSequence(),
958
+ setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence),
959
+ isForkBound: (channelId) => [...framework.conversationAgentHomes.values()].includes(channelId),
960
+ isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false,
961
+ allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
962
+ emitTrace: (e) => framework.emitTrace(e),
963
+ });
964
+ framework.tuneOutCoordinator.resumeActiveEpochs();
965
+ }
882
966
  // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
883
967
  // (journal) without a restart — for catching the wake-wedge on the running
884
968
  // process. Shows the gate's `inferring` set + buffered-event count (the
@@ -943,6 +1027,7 @@ export class AgentFramework {
943
1027
  this.running = false;
944
1028
  this.providerAdmissionClosed = true;
945
1029
  this.queue.close();
1030
+ this.tuneOutCoordinator?.stop();
946
1031
  // Kill running code_execution scripts before cancelling streams: a
947
1032
  // zombie script must not keep firing side-effectful tool calls into a
948
1033
  // framework that is shutting down.
@@ -1374,7 +1459,12 @@ export class AgentFramework {
1374
1459
  */
1375
1460
  getAllTools() {
1376
1461
  const moduleTools = this.moduleRegistry.getAllTools();
1377
- const channelTools = this.channelRegistry?.getChannelTools() ?? [];
1462
+ // Copy: getChannelTools() returns the registry's shared definitions
1463
+ // array; pushing onto it would append another tune_out on every call.
1464
+ const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
1465
+ if (this.tuneOutCoordinator) {
1466
+ channelTools.push(AgentFramework.TUNE_OUT_TOOL);
1467
+ }
1378
1468
  const gateTools = this.eventGate
1379
1469
  ? [
1380
1470
  this.eventGate.getToolDefinition(),
@@ -1392,7 +1482,8 @@ export class AgentFramework {
1392
1482
  ...gateTools,
1393
1483
  this.buildAgentSettingsTool(),
1394
1484
  ...(this.getWorkspaceModule()
1395
- ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1485
+ ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL,
1486
+ AgentFramework.FETCH_REFERENCE_TOOL]
1396
1487
  : []),
1397
1488
  ...(this.codeExecutionConfig
1398
1489
  ? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
@@ -1406,6 +1497,57 @@ export class AgentFramework {
1406
1497
  ? module
1407
1498
  : undefined;
1408
1499
  }
1500
+ /** RFC-005 host-mediated reference fetcher (lazy; needs server configs +
1501
+ * a writable workspace mount for storage). */
1502
+ referenceFetcherInstance;
1503
+ getReferenceFetcher() {
1504
+ if (!this.referenceFetcherInstance) {
1505
+ this.referenceFetcherInstance = new ReferenceFetcher((serverId) => {
1506
+ const cfg = this.mcplServerConfigs.get(serverId);
1507
+ if (!cfg?.url)
1508
+ return undefined;
1509
+ return { url: cfg.url, token: cfg.token, autofetch: cfg.autofetch };
1510
+ }, async (fileName, data, mimeType) => {
1511
+ const workspace = this.getWorkspaceModule();
1512
+ if (!workspace)
1513
+ return null;
1514
+ const mount = this.firstWritableMountName(workspace);
1515
+ if (!mount)
1516
+ return null;
1517
+ const path = `${mount}/refs/${fileName}`;
1518
+ const res = await workspace.writeBinary(path, data, mimeType);
1519
+ return res.success ? path : null;
1520
+ });
1521
+ }
1522
+ return this.referenceFetcherInstance;
1523
+ }
1524
+ /** Register every RFC-005 reference in a tool-result content array against
1525
+ * its server, then eagerly fetch the ones inside the autofetch envelope
1526
+ * (below the eager ceiling, connection origin — never worse than the
1527
+ * server having inlined the bytes). Failures are non-fatal: the stub then
1528
+ * says fetch_reference instead of a path. */
1529
+ async autoFetchReferences(content, serverId, eager) {
1530
+ if (!content)
1531
+ return;
1532
+ const fetcher = this.getReferenceFetcher();
1533
+ const eligible = [];
1534
+ for (const block of content) {
1535
+ const c = classifyBlock(block);
1536
+ if (c.kind !== 'reference')
1537
+ continue;
1538
+ const record = referenceRegistry.register(c.testimony, serverId);
1539
+ if (eager && fetcher.isEagerEligible(record))
1540
+ eligible.push(record);
1541
+ }
1542
+ // Parallel with a count cap: N small fetches cost one round-trip of
1543
+ // wall-clock, and anything past the cap stays lazy (stub says so).
1544
+ const MAX_EAGER_PER_RESULT = 8;
1545
+ await Promise.all(eligible.slice(0, MAX_EAGER_PER_RESULT).map(async (record) => {
1546
+ const outcome = await fetcher.fetch(record, { timeoutMs: EAGER_FETCH_TIMEOUT_MS });
1547
+ if (!outcome.ok)
1548
+ console.error(`[rfc005] eager fetch of ${record.refId} failed: ${outcome.error}`);
1549
+ }));
1550
+ }
1409
1551
  /** Core agent_settings keys — extension keys must not collide with these. */
1410
1552
  static AGENT_SETTINGS_CORE_KEYS = [
1411
1553
  'context_budget_tokens',
@@ -1552,6 +1694,18 @@ export class AgentFramework {
1552
1694
  };
1553
1695
  }
1554
1696
  getToolsForAgent(agentName, snapshot) {
1697
+ // The subconscious gets its own small surface (plus think/skip_reply/
1698
+ // end_turn), not the residents' tool board: no channel lifecycle, no
1699
+ // gate rules, no workspace — it observes, judges, and reports.
1700
+ if (agentName === this.subconsciousAgentName) {
1701
+ const basics = this.getAllTools()
1702
+ .filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
1703
+ .map((t) => t.name === 'think'
1704
+ ? this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
1705
+ ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy)
1706
+ : t);
1707
+ return [...SUBCONSCIOUS_TOOLS, ...basics];
1708
+ }
1555
1709
  return this.getAllTools().map((tool) => {
1556
1710
  if (tool.name === 'think') {
1557
1711
  return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
@@ -1907,21 +2061,35 @@ export class AgentFramework {
1907
2061
  * Call cleanup() when done to release the ContextManager.
1908
2062
  */
1909
2063
  async createEphemeralAgent(config) {
1910
- const namespace = `subagent/${config.name}`;
1911
- const contextManager = await ContextManager.open({
1912
- store: this.store,
1913
- namespace,
1914
- isolate: true,
1915
- strategy: config.strategy ?? new PassthroughStrategy(),
1916
- membrane: this.membrane,
1917
- debugLogContext: !!process.env.DEBUG_CONTEXT,
1918
- });
1919
- const agent = new Agent(config, contextManager, this.membrane);
1920
- const cleanup = () => {
1921
- // Don't close the store — it's shared. Just release the CM.
1922
- // Data persists in the store under the namespace for investigation.
1923
- };
1924
- return { agent, contextManager, cleanup };
2064
+ // Names are Chronicle namespaces and generation identities. Reserve before
2065
+ // opening the namespace so a second creation cannot append to an earlier
2066
+ // generation before runEphemeralToCompletion has a chance to reject it.
2067
+ if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
2068
+ throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
2069
+ }
2070
+ this.usedEphemeralAgentNames.add(config.name);
2071
+ try {
2072
+ const namespace = `subagent/${config.name}`;
2073
+ const contextManager = await ContextManager.open({
2074
+ store: this.store,
2075
+ namespace,
2076
+ isolate: true,
2077
+ strategy: config.strategy ?? new PassthroughStrategy(),
2078
+ membrane: this.membrane,
2079
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
2080
+ });
2081
+ const agent = new Agent(config, contextManager, this.membrane);
2082
+ this.ephemeralCandidates.set(agent, contextManager);
2083
+ const cleanup = () => {
2084
+ // Don't close the store — it's shared. Just release the CM.
2085
+ // Data persists in the store under the namespace for investigation.
2086
+ };
2087
+ return { agent, contextManager, cleanup };
2088
+ }
2089
+ catch (error) {
2090
+ this.usedEphemeralAgentNames.delete(config.name);
2091
+ throw error;
2092
+ }
1925
2093
  }
1926
2094
  createDeferred() {
1927
2095
  let resolve;
@@ -1942,6 +2110,18 @@ export class AgentFramework {
1942
2110
  }
1943
2111
  run.lastActivity = Date.now();
1944
2112
  }
2113
+ /** Record structured calls for the current logical turn. Ignore late stream
2114
+ * events after an ephemeral/conversation agent has been disposed. */
2115
+ recordLogicalTurnToolCalls(agent, turnToken, count) {
2116
+ if (count <= 0 || this.agents.get(agent.name) !== agent)
2117
+ return;
2118
+ const state = this.logicalTurnToolCalls.get(agent);
2119
+ // The Agent object is the generation: a late stream from a disposed
2120
+ // ephemeral/conversation fork cannot contaminate a same-name successor.
2121
+ if (!state || state.turnToken !== turnToken)
2122
+ return;
2123
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
2124
+ }
1945
2125
  recordEphemeralToolCalls(agentName, count) {
1946
2126
  const run = this.ephemeralRuns.get(agentName);
1947
2127
  if (!run)
@@ -1978,7 +2158,18 @@ export class AgentFramework {
1978
2158
  * The task message should already be in the context manager.
1979
2159
  */
1980
2160
  async runEphemeralToCompletion(agent, contextManager, watchdogs) {
1981
- // Register temporarily so the event loop can drive it
2161
+ // Only a fresh object returned by createEphemeralAgent may enter this path.
2162
+ // Never overwrite a resident/conversation owner or a concurrent run: their
2163
+ // cleanup is name-keyed and could cancel/deregister the legitimate owner.
2164
+ if (this.ephemeralCandidates.get(agent) !== contextManager) {
2165
+ throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
2166
+ }
2167
+ // Consume before any await/registration: the exact (Agent, ContextManager)
2168
+ // generation is one-shot even if startup later fails.
2169
+ this.ephemeralCandidates.delete(agent);
2170
+ if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
2171
+ throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
2172
+ }
1982
2173
  this.agents.set(agent.name, agent);
1983
2174
  const run = {
1984
2175
  settle: this.createDeferred(),
@@ -2037,8 +2228,26 @@ export class AgentFramework {
2037
2228
  clearTimeout(startupWatchdog);
2038
2229
  if (completionWatchdog)
2039
2230
  clearInterval(completionWatchdog);
2040
- this.ephemeralRuns.delete(agent.name);
2041
- this.agents.delete(agent.name);
2231
+ // Mark before cancellation: an adapter may synchronously settle its
2232
+ // iterator, and this physical frame still owns terminal typing/outgoing
2233
+ // closure even though the ephemeral name is about to be deregistered.
2234
+ this.disposedEphemeralFrames.add(agent);
2235
+ // Cancel before deregistration. Adapters may still yield a queued event;
2236
+ // driveStream's Agent-identity check above discards it before dispatch.
2237
+ agent.cancelStream();
2238
+ if (this.ephemeralRuns.get(agent.name) === run)
2239
+ this.ephemeralRuns.delete(agent.name);
2240
+ if (this.agents.get(agent.name) === agent) {
2241
+ // Exact-generation disposal owns these name-keyed entries. Remove them
2242
+ // before deregistration; the late driveStream finally will see the
2243
+ // generation mismatch and leave any future owner untouched.
2244
+ this.activeStreams.delete(agent.name);
2245
+ this.pendingAssistantBlocks.delete(agent.name);
2246
+ // The late stream finalizer intentionally refuses name-keyed cleanup once
2247
+ // deregistered, so disposal must release EventGate liveness itself.
2248
+ this.eventGate?.onInferenceEnded(agent.name);
2249
+ this.agents.delete(agent.name);
2250
+ }
2042
2251
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2043
2252
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2044
2253
  // entries behind for the life of the store/session.
@@ -2049,6 +2258,8 @@ export class AgentFramework {
2049
2258
  // unbounded map growth. Blind delete is safe: the agent is already out
2050
2259
  // of the map, and a late driveStream finally token-match no-ops.
2051
2260
  this.activeTurnTokens.delete(agent.name);
2261
+ this.activeTurnTriggers.delete(agent.name);
2262
+ this.logicalTurnToolCalls.delete(agent);
2052
2263
  }
2053
2264
  }
2054
2265
  /**
@@ -2061,6 +2272,44 @@ export class AgentFramework {
2061
2272
  * Query inference logs.
2062
2273
  * Returns entries with summary info (doesn't resolve blobs).
2063
2274
  */
2275
+ /** Tune-out (issue #77): divert a channel to your subconscious. Present
2276
+ * only when the subconscious resident is configured. */
2277
+ static TUNE_OUT_TOOL = {
2278
+ name: 'tune_out',
2279
+ description: 'Divert a channel to your subconscious for a while: its traffic stops ' +
2280
+ 'entering your context and stops waking you. Your subconscious watches ' +
2281
+ 'it instead — summarizing on a cadence in its own voice, judging ' +
2282
+ 'whether mentions merit interrupting, and able to cancel. People who ' +
2283
+ 'mention you get an automatic reaction so they know you saw nothing. ' +
2284
+ 'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
2285
+ 'tune-out auto-cancels after maxWakes mention-bursts.',
2286
+ inputSchema: {
2287
+ type: 'object',
2288
+ properties: {
2289
+ channelId: { type: 'string' },
2290
+ mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
2291
+ cadenceSeconds: {
2292
+ type: 'number',
2293
+ description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
2294
+ },
2295
+ backlogCap: {
2296
+ type: 'number',
2297
+ description: `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
2298
+ '0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
2299
+ },
2300
+ maxWakes: {
2301
+ type: 'number',
2302
+ description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
2303
+ },
2304
+ durationSeconds: {
2305
+ type: 'number',
2306
+ description: 'Optional bounded duration: the tune-out auto-cancels (backlog ' +
2307
+ 'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
2308
+ },
2309
+ },
2310
+ required: ['channelId'],
2311
+ },
2312
+ };
2064
2313
  /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
2065
2314
  static SLEEP_TOOLS = [
2066
2315
  {
@@ -2265,6 +2514,26 @@ export class AgentFramework {
2265
2514
  required: ['path'],
2266
2515
  },
2267
2516
  };
2517
+ /** Synthesized fetch_reference tool — RFC-005 on-demand dereference.
2518
+ * Present when a workspace module is registered (storage target). */
2519
+ static FETCH_REFERENCE_TOOL = {
2520
+ name: 'fetch_reference',
2521
+ description: 'Fetch a referenced payload (a [ref_…] id from a tool result or event) ' +
2522
+ 'to a workspace file via the host-mediated fetcher. The fetch is ' +
2523
+ 'origin-bound to the server that sent the reference, size-capped, and ' +
2524
+ 'digest-verified when the reference carries one. Returns the workspace ' +
2525
+ 'path plus verified size/type; then use ordinary workspace tools on it. ' +
2526
+ 'Small payloads may already be fetched automatically — their stubs show ' +
2527
+ 'a saved path instead.',
2528
+ inputSchema: {
2529
+ type: 'object',
2530
+ properties: {
2531
+ ref_id: { type: 'string', description: 'The reference id, e.g. "ref_1a_x9z2".' },
2532
+ max_bytes: { type: 'number', description: 'Optional per-call byte ceiling (default host policy).' },
2533
+ },
2534
+ required: ['ref_id'],
2535
+ },
2536
+ };
2268
2537
  /** Synthesized read_image tool — present when a workspace module is
2269
2538
  * registered. Returns the image as a native image block in the tool
2270
2539
  * result, so the agent SEES it in the live turn (history keeps a compact
@@ -3292,6 +3561,15 @@ export class AgentFramework {
3292
3561
  // streaming agent keeps the original Membrane.
3293
3562
  membrane: this.auxiliaryMembraneFor(config.name),
3294
3563
  debugLogContext: !!process.env.DEBUG_CONTEXT,
3564
+ // Tune-out (issue #77): messages stamped at ingestion as diverted
3565
+ // (metadata.tuneOut = { epochId }) never enter a resident's compiled
3566
+ // view — not at emission, not via chunking into the memory pyramid.
3567
+ // The stamp is permanent: after cancel, the backlog arrives as one
3568
+ // appended <tuned-out-backlog> dump instead of retro-inserting into
3569
+ // the timeline (KV-prefix-stable delivery). The stored originals
3570
+ // remain in the shared slot for the subconscious's merged view,
3571
+ // fetch_history, and audit.
3572
+ viewFilter: (message) => !message.metadata?.tuneOut,
3295
3573
  });
3296
3574
  const agent = new Agent(config, contextManager, this.membrane);
3297
3575
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
@@ -3306,6 +3584,68 @@ export class AgentFramework {
3306
3584
  }
3307
3585
  return agent;
3308
3586
  }
3587
+ /**
3588
+ * Create and register the subconscious resident (issue #77): a persistent
3589
+ * side-agent with an isolated slot (`subconscious/<primary>`) whose
3590
+ * strategy view merges the residents' shared timeline with its own —
3591
+ * including the tuned-out-stamped messages the residents' viewFilter
3592
+ * excludes; the backlog is precisely what it exists to see. Windowed
3593
+ * passthrough, no memory pyramid: its durable output is what it delivers
3594
+ * into the resident's window, which accumulates there.
3595
+ */
3596
+ async createSubconsciousAgent(cfg) {
3597
+ const primaryName = this.primaryAgentName;
3598
+ const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
3599
+ if (!primaryName || !primaryConfig) {
3600
+ throw new Error('subconscious requires a primary agent to attend to');
3601
+ }
3602
+ const name = cfg.name ?? 'Subconscious';
3603
+ if (this.agents.has(name)) {
3604
+ throw new Error(`subconscious name "${name}" collides with a registered agent`);
3605
+ }
3606
+ const strategy = new WindowedPassthroughStrategy({
3607
+ reAnchorFraction: cfg.reAnchorFraction,
3608
+ // Mirror the principal's per-message truncation ceiling so both see
3609
+ // oversized tool results / attachments under the same policy.
3610
+ maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
3611
+ });
3612
+ const contextManager = await ContextManager.open({
3613
+ store: this.store,
3614
+ namespace: `subconscious/${primaryName}`,
3615
+ isolate: true,
3616
+ // The residents' shared un-namespaced slot, merged read-only and
3617
+ // UNfiltered (no viewFilter here — stamped messages included).
3618
+ auxiliaryMessageViews: [{}],
3619
+ strategy,
3620
+ membrane: this.membrane,
3621
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
3622
+ });
3623
+ // Inherit the principal's inference settings wholesale (fork-template
3624
+ // idiom): reasoning effort (thinking), provider params, token ceilings,
3625
+ // caching, refusal handling, timezone — a same-model side-process must
3626
+ // not silently run under different inference conditions than the
3627
+ // resident it serves. Only identity- and surface-defining fields are
3628
+ // overridden below.
3629
+ const agentConfig = {
3630
+ ...primaryConfig,
3631
+ name,
3632
+ model: cfg.model ?? primaryConfig.model,
3633
+ systemPrompt: cfg.systemPrompt,
3634
+ strategy: undefined, // its CM owns the windowed strategy instance
3635
+ // Prose is never auto-routed: a cadence-triggered turn carries no
3636
+ // locus, and bare prose must not fall through to the default channel.
3637
+ // Speech happens only via speak_in_channel (its own marked voice).
3638
+ proseRouting: 'explicit',
3639
+ allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
3640
+ };
3641
+ const agent = new Agent(agentConfig, contextManager, this.membrane);
3642
+ this.agents.set(name, agent);
3643
+ this.agentConfigs.set(name, agentConfig);
3644
+ this.subconsciousAgentName = name;
3645
+ this.subconsciousStrategy = strategy;
3646
+ this.subconsciousConfig = cfg;
3647
+ return agent;
3648
+ }
3309
3649
  async runLoop() {
3310
3650
  while (this.running) {
3311
3651
  try {
@@ -3430,12 +3770,11 @@ export class AgentFramework {
3430
3770
  // queued, they flush at the target's own next boundary — with
3431
3771
  // injection — or in driveStream's finally when its turn ends.
3432
3772
  const midTurnInjections = [];
3433
- if (this.deferredMessages.length > 0) {
3434
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
3435
- if (target === agent) {
3436
- const deferred = this.deferredMessages.splice(0);
3773
+ {
3774
+ const deferred = this.drainDeferredFor(agent.name);
3775
+ if (deferred.length > 0) {
3437
3776
  for (const msg of deferred) {
3438
- target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
3777
+ agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
3439
3778
  // Injection guards: tool blocks would corrupt the tool-cycle
3440
3779
  // structure the membrane enforces, and a message named as the
3441
3780
  // agent itself would render as an ASSISTANT turn on the wire
@@ -3732,7 +4071,7 @@ export class AgentFramework {
3732
4071
  // Broadcast requests exclude conversation forks — they are driven by
3733
4072
  // their own channel's messages, not by framework-wide events.
3734
4073
  const targetAgents = response.requestInference === true
3735
- ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
4074
+ ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
3736
4075
  : response.requestInference;
3737
4076
  for (const agentName of targetAgents) {
3738
4077
  const agent = this.agents.get(agentName);
@@ -3799,11 +4138,31 @@ export class AgentFramework {
3799
4138
  Object.assign(metadata, invitation.metadataPatch);
3800
4139
  }
3801
4140
  }
4141
+ // Tune-out divert (issue #77): stamped, stored, no resident wake. The
4142
+ // coordinator has already handled wake bookkeeping (coalesced
4143
+ // subconscious invocation, durable count, suppression ack) before we
4144
+ // stamp; the subconscious reads the message through its merged view.
4145
+ const divert = this.tuneOutCoordinator?.onIncoming(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id,
4146
+ // The resident's gate verdict rides the event; it preconditions
4147
+ // subconscious wakes (gate composes, it is not replaced).
4148
+ event.triggerInference !== false) ?? null;
4149
+ if (divert) {
4150
+ metadata.tuneOut = { epochId: divert.epochId };
4151
+ }
3802
4152
  const id = this.addMessage('user', incomingContent, metadata);
3803
4153
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3804
- if (event.triggerInference) {
4154
+ if (event.triggerInference && !divert) {
3805
4155
  const addressed = isAddressedMessage(event.tags, event.metadata);
3806
- for (const agentName of this.agents.keys()) {
4156
+ // Honor explicit targeting, mirroring the push-event path: an event
4157
+ // that names its agents wakes exactly those; an untargeted event
4158
+ // keeps the historical broadcast. (targetAgents was declared on
4159
+ // McplChannelIncomingEvent from the start but never honored here —
4160
+ // tune-out's wake routing is the first setter.)
4161
+ const targetAgents = event.targetAgents
4162
+ ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
4163
+ for (const agentName of targetAgents) {
4164
+ if (!this.agents.has(agentName))
4165
+ continue;
3807
4166
  this.pendingRequests.push({
3808
4167
  agentName,
3809
4168
  reason: 'mcpl:channel-incoming',
@@ -3812,6 +4171,7 @@ export class AgentFramework {
3812
4171
  // Route this turn's auto-published speech back to THIS channel, not
3813
4172
  // the global most-recent-inbound locus (item-3 redux, trunk agents).
3814
4173
  channelId: event.channelId,
4174
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
3815
4175
  // Addressed messages outrank ambient chatter when a batched wake
3816
4176
  // picks the turn's frozen speech locus.
3817
4177
  addressed,
@@ -3896,6 +4256,7 @@ export class AgentFramework {
3896
4256
  // A fork's home channel wins in routeSpeech regardless, but carry the
3897
4257
  // triggering channel too so the trunk/active path stays consistent.
3898
4258
  channelId: event.channelId,
4259
+ counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
3899
4260
  addressed: isAddressedMessage(event.tags, event.metadata),
3900
4261
  });
3901
4262
  }
@@ -3914,38 +4275,48 @@ export class AgentFramework {
3914
4275
  if (!template || !templateConfig) {
3915
4276
  throw new Error(`conversation template agent "${router.templateAgent}" not found`);
3916
4277
  }
3917
- const contextManager = await ContextManager.open({
3918
- store: this.store,
3919
- namespace: `conversations/${name}`,
3920
- isolate: true,
3921
- // Strategy instances are stateful — never share the template's.
3922
- strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
3923
- // Dynamic conversation forks retain the established provider policy in
3924
- // this bounded first slice. Provider cooldown ownership belongs to the
3925
- // persistent resident only; generation-unique forks must not leak gates.
3926
- membrane: this.membrane,
3927
- debugLogContext: !!process.env.DEBUG_CONTEXT,
3928
- });
3929
- // Seed with the template's compiled context, renaming the template
3930
- // participant so the fork reads its inheritance as its own history.
3931
- // Guard: only seed a genuinely fresh namespace. Generation counters are
3932
- // persisted precisely so names aren't reused, but if this namespace has
3933
- // history anyway (counter state lost, crash between spawn and persist),
3934
- // seeding again would stack another template copy on top of it.
3935
- const { messages: existing } = await contextManager.compile();
3936
- if (existing.length === 0) {
3937
- const { messages: compiled } = await template.getContextManager().compile();
3938
- for (const msg of compiled) {
3939
- const participant = msg.participant === template.name ? name : msg.participant;
3940
- contextManager.addMessage(participant, msg.content);
3941
- }
3942
- }
3943
- const config = { ...templateConfig, name, strategy: undefined };
3944
- const agent = new Agent(config, contextManager, this.membrane);
3945
- this.agents.set(name, agent);
3946
- this.agentConfigs.set(name, config);
3947
- this.conversationAgentHomes.set(name, channelId);
3948
- return agent;
4278
+ if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
4279
+ throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
4280
+ }
4281
+ this.usedEphemeralAgentNames.add(name);
4282
+ try {
4283
+ const contextManager = await ContextManager.open({
4284
+ store: this.store,
4285
+ namespace: `conversations/${name}`,
4286
+ isolate: true,
4287
+ // Strategy instances are stateful — never share the template's.
4288
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
4289
+ // Dynamic conversation forks retain the established provider policy in
4290
+ // this bounded first slice. Provider cooldown ownership belongs to the
4291
+ // persistent resident only; generation-unique forks must not leak gates.
4292
+ membrane: this.membrane,
4293
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
4294
+ });
4295
+ // Seed with the template's compiled context, renaming the template
4296
+ // participant so the fork reads its inheritance as its own history.
4297
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
4298
+ // persisted precisely so names aren't reused, but if this namespace has
4299
+ // history anyway (counter state lost, crash between spawn and persist),
4300
+ // seeding again would stack another template copy on top of it.
4301
+ const { messages: existing } = await contextManager.compile();
4302
+ if (existing.length === 0) {
4303
+ const { messages: compiled } = await template.getContextManager().compile();
4304
+ for (const msg of compiled) {
4305
+ const participant = msg.participant === template.name ? name : msg.participant;
4306
+ contextManager.addMessage(participant, msg.content);
4307
+ }
4308
+ }
4309
+ const config = { ...templateConfig, name, strategy: undefined };
4310
+ const agent = new Agent(config, contextManager, this.membrane);
4311
+ this.agents.set(name, agent);
4312
+ this.agentConfigs.set(name, config);
4313
+ this.conversationAgentHomes.set(name, channelId);
4314
+ return agent;
4315
+ }
4316
+ catch (error) {
4317
+ this.usedEphemeralAgentNames.delete(name);
4318
+ throw error;
4319
+ }
3949
4320
  }
3950
4321
  /** Persist the router's generation counters (see hydration in create()). */
3951
4322
  persistConversationRouterState() {
@@ -3969,10 +4340,13 @@ export class AgentFramework {
3969
4340
  disposeConversationAgent(agentName) {
3970
4341
  this.closingConversationAgents.delete(agentName);
3971
4342
  const channelId = this.conversationAgentHomes.get(agentName);
4343
+ const agent = this.agents.get(agentName);
3972
4344
  this.agents.delete(agentName);
3973
4345
  this.agentConfigs.delete(agentName);
3974
4346
  this.conversationAgentHomes.delete(agentName);
3975
4347
  this.evictTurnCheckpoints(agentName);
4348
+ if (agent)
4349
+ this.logicalTurnToolCalls.delete(agent);
3976
4350
  this.emitTrace({
3977
4351
  type: 'mcpl:conversation-disposed',
3978
4352
  agentName,
@@ -4172,7 +4546,7 @@ export class AgentFramework {
4172
4546
  if (event.triggerInference) {
4173
4547
  // Default broadcast excludes conversation forks (channel-driven).
4174
4548
  const targetAgents = event.targetAgents
4175
- ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
4549
+ ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
4176
4550
  for (const agentName of targetAgents) {
4177
4551
  this.pendingRequests.push({
4178
4552
  agentName,
@@ -4354,6 +4728,10 @@ export class AgentFramework {
4354
4728
  // exactly this class of silent drop. One stderr line per drop.
4355
4729
  console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
4356
4730
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
4731
+ // A context-budget restart inherits the predecessor's EventGate liveness.
4732
+ // If policy drops the queued successor, no driveStream remains to release it.
4733
+ if (budgetRestart)
4734
+ this.eventGate?.onInferenceEnded(agentName);
4357
4735
  const gate = this.providerGates.get(agentName);
4358
4736
  if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
4359
4737
  gate.primaryPending = false;
@@ -4380,21 +4758,29 @@ export class AgentFramework {
4380
4758
  // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
4381
4759
  // Non-channel wakes (heartbeats, module events, reactions — which never
4382
4760
  // carry channelId) leave both undefined → global fallback.
4383
- let ambientChannel;
4384
- let addressedChannel;
4761
+ // Track the winning REQUEST, not just its channel: channel, addressed
4762
+ // and counterparty must come from the same message, or a batch of
4763
+ // "ambient from A, then addressed from B" would report B's channel
4764
+ // with A's author (review finding on the provenance change).
4765
+ let ambientReq;
4766
+ let addressedReq;
4385
4767
  for (const r of requests) {
4386
4768
  if (!r.channelId)
4387
4769
  continue;
4388
- ambientChannel = r.channelId;
4770
+ ambientReq = r;
4389
4771
  if (r.addressed)
4390
- addressedChannel = r.channelId;
4772
+ addressedReq = r;
4391
4773
  }
4392
- const triggerChannel = addressedChannel ?? ambientChannel;
4393
- const triggerAddressed = addressedChannel !== undefined;
4774
+ const channelReq = addressedReq ?? ambientReq;
4394
4775
  await this.startAgentStream(agent, {
4395
4776
  ...trigger,
4396
- channelId: triggerChannel,
4397
- addressed: triggerAddressed,
4777
+ channelId: channelReq?.channelId,
4778
+ addressed: addressedReq !== undefined,
4779
+ // A context-budget restart continues the same logical turn: it keeps
4780
+ // the channel for routing but names no author — the restart is its
4781
+ // own cause, and borrowing another request's author would be false
4782
+ // provenance.
4783
+ counterparty: budgetRestart ? undefined : channelReq?.counterparty,
4398
4784
  });
4399
4785
  }
4400
4786
  }
@@ -4862,6 +5248,7 @@ export class AgentFramework {
4862
5248
  // no-ops instead of clobbering a successor's marker.
4863
5249
  const turnToken = this.nextTurnToken++;
4864
5250
  this.activeTurnTokens.set(agent.name, turnToken);
5251
+ this.activeTurnTriggers.set(agent.name, trigger);
4865
5252
  let tokenHandedOff = false;
4866
5253
  try {
4867
5254
  tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
@@ -4869,6 +5256,7 @@ export class AgentFramework {
4869
5256
  finally {
4870
5257
  if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
4871
5258
  this.activeTurnTokens.delete(agent.name);
5259
+ this.activeTurnTriggers.delete(agent.name);
4872
5260
  }
4873
5261
  if (!tokenHandedOff && ownsProviderGate)
4874
5262
  this.releasePrimaryProviderGate(agent.name);
@@ -4897,9 +5285,8 @@ export class AgentFramework {
4897
5285
  if (attempt === 0 &&
4898
5286
  trigger?.reason !== 'context_budget_restart' &&
4899
5287
  this.deferredMessages.length > 0) {
4900
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
4901
- if (target === agent) {
4902
- const deferred = this.deferredMessages.splice(0);
5288
+ {
5289
+ const deferred = this.drainDeferredFor(agent.name);
4903
5290
  for (const msg of deferred) {
4904
5291
  // Per-message try/catch (mirrors announceLocusIfChanged): one
4905
5292
  // poison message — or a transient store-write failure — must not
@@ -4953,9 +5340,22 @@ export class AgentFramework {
4953
5340
  // BEFORE this turn compiles, so the agent always knows where its voice
4954
5341
  // goes (announce-on-change only — no per-turn chatter, append-only for
4955
5342
  // KV stability).
5343
+ if (trigger?.reason === 'context_budget_restart') {
5344
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
5345
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
5346
+ }
4956
5347
  if (trigger?.reason !== 'context_budget_restart') {
4957
5348
  if (attempt === 0)
4958
5349
  this.maybePrimeProseMode(agent);
5350
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
5351
+ if (attempt === 0) {
5352
+ // A true new turn gets a fresh generation and count.
5353
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
5354
+ }
5355
+ else {
5356
+ // A framework retry is a new physical stream in the same logical turn.
5357
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
5358
+ }
4959
5359
  // Fresh turn: forget the previous turn's explicit-send engagements and
4960
5360
  // prose deliveries — both are strictly turn-scoped (a restart continues
4961
5361
  // the same logical turn and keeps them).
@@ -4993,7 +5393,11 @@ export class AgentFramework {
4993
5393
  // context-budget restart, which skips the re-pin but re-emits this).
4994
5394
  channelId: this.turnLocusPins.get(agent.name),
4995
5395
  });
4996
- this.eventGate?.onInferenceStarted(agent.name);
5396
+ // A budget restart continues the same logical inference window. The
5397
+ // predecessor keeps EventGate liveness until its successor terminates.
5398
+ if (trigger?.reason !== 'context_budget_restart') {
5399
+ this.eventGate?.onInferenceStarted(agent.name);
5400
+ }
4997
5401
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
4998
5402
  // Typing indicator, started at TURN START. It says "attending", which is
4999
5403
  // true from the moment the turn is accepted — but it used to start inside
@@ -5047,8 +5451,32 @@ export class AgentFramework {
5047
5451
  console.error('beforeInference hook error:', error);
5048
5452
  }
5049
5453
  }
5050
- const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
5051
- const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate);
5454
+ // An ephemeral watchdog may dispose this Agent while hooks/compile await.
5455
+ // Do not create a provider stream after its generation lost ownership.
5456
+ if (this.agents.get(agent.name) !== agent) {
5457
+ this.channelRegistry?.stopTyping();
5458
+ this.eventGate?.onInferenceEnded(agent.name);
5459
+ return false;
5460
+ }
5461
+ // The subconscious's standing dispositions ride a fixed system-position
5462
+ // injection (antra, #tuneout-talk): never repeated in its timeline, and
5463
+ // — since it has no long-term memory — structurally unable to wash out
5464
+ // of its window. Bytes change only when a disposition changes.
5465
+ if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
5466
+ const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
5467
+ if (dispositionInjection) {
5468
+ injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
5469
+ }
5470
+ }
5471
+ const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections);
5472
+ if (this.agents.get(agent.name) !== agent) {
5473
+ stream.cancel();
5474
+ agent.cancelStream();
5475
+ this.channelRegistry?.stopTyping();
5476
+ this.eventGate?.onInferenceEnded(agent.name);
5477
+ return false;
5478
+ }
5479
+ const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate, takeKvSubmission, drainKvSubmissionIds, new Set(tools.map((tool) => tool.name)));
5052
5480
  this.activeStreams.set(agent.name, handle);
5053
5481
  // Handoff: driveStream captured the token in its synchronous prefix;
5054
5482
  // its finally now owns clearing it. The caller's finally must not.
@@ -5088,6 +5516,7 @@ export class AgentFramework {
5088
5516
  // the eventGate `inferring` leak) — every exit path must clear it.
5089
5517
  if (this.activeTurnTokens.get(agent.name) === turnToken) {
5090
5518
  this.activeTurnTokens.delete(agent.name);
5519
+ this.activeTurnTriggers.delete(agent.name);
5091
5520
  }
5092
5521
  this.settleAgent(agent.name, {
5093
5522
  stopReason: 'exhausted',
@@ -5120,15 +5549,26 @@ export class AgentFramework {
5120
5549
  return false;
5121
5550
  }
5122
5551
  }
5123
- async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false) {
5552
+ async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
5124
5553
  const startTime = Date.now();
5125
5554
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
5126
5555
  const myStreamId = agent.streamId;
5556
+ // Membrane usage events are cumulative across the native/XML tool loop.
5557
+ // Keep the previous cumulative sample so consumers that operate at the
5558
+ // physical provider-call boundary (estimator calibration and kv receipt
5559
+ // acceptance) see this call only, not an ever-growing turn total.
5560
+ let previousUsage = {
5561
+ inputTokens: 0,
5562
+ outputTokens: 0,
5563
+ cacheCreationTokens: 0,
5564
+ cacheReadTokens: 0,
5565
+ };
5127
5566
  // This turn's alive-marker, set at startAgentStream entry. Safe to read
5128
5567
  // from the map here: the turn-alive busy check in processInferenceRequests
5129
5568
  // means no successor turn can have replaced it while we compiled.
5130
5569
  const myTurnToken = this.activeTurnTokens.get(agent.name);
5131
5570
  let hadToolCalls = false;
5571
+ let generationLost = false;
5132
5572
  // ---- Present-while-acting turn state ---------------------------------
5133
5573
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
5134
5574
  // (home → addressed trigger → global default) before this stream began,
@@ -5203,13 +5643,20 @@ export class AgentFramework {
5203
5643
  // failed, a framework-cancelled stream sets aborted. Best-effort
5204
5644
  // notifications — consumers dedupe by inferenceId and keep a timeout.
5205
5645
  let lifecyclePhase = 'completed';
5646
+ let preserveEventGateForSuccessor = false;
5206
5647
  this.hookOrchestrator?.emitLifecycle({
5207
5648
  inferenceId: outgoingInferenceId,
5208
5649
  conversationId: agent.name,
5209
5650
  turnIndex: 0,
5210
5651
  phase: 'started',
5211
5652
  });
5212
- const proseStream = this.channelRegistry && agent.proseRouting !== 'disabled'
5653
+ // The wrapper guard needs the complete response before it can distinguish
5654
+ // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
5655
+ // turns until completion: otherwise speculative outgoing chunks could
5656
+ // expose the wrapper before the fail-closed classifier runs.
5657
+ const proseStream = this.channelRegistry &&
5658
+ agent.proseRouting !== 'disabled' &&
5659
+ !agent.toolWrapperProseGuard
5213
5660
  ? new ProseStreamRouter({
5214
5661
  mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
5215
5662
  initialTarget: typingChannel,
@@ -5235,6 +5682,14 @@ export class AgentFramework {
5235
5682
  };
5236
5683
  try {
5237
5684
  for await (const event of stream) {
5685
+ // Ignore every late event from an Agent generation that no longer owns
5686
+ // this name (ephemeral watchdog/disposal, conversation replacement).
5687
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
5688
+ generationLost = true;
5689
+ lifecyclePhase = 'aborted';
5690
+ stream.cancel();
5691
+ break;
5692
+ }
5238
5693
  this.touchEphemeralRun(agent.name, true);
5239
5694
  switch (event.type) {
5240
5695
  case 'tokens':
@@ -5297,6 +5752,7 @@ export class AgentFramework {
5297
5752
  case 'tool-calls': {
5298
5753
  adoptInjectedRound();
5299
5754
  hadToolCalls = true;
5755
+ this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
5300
5756
  this.recordEphemeralToolCalls(agent.name, event.calls.length);
5301
5757
  this.emitTrace({
5302
5758
  type: 'inference:tool_calls_yielded',
@@ -5374,8 +5830,7 @@ export class AgentFramework {
5374
5830
  const roundToolNames = event.calls.map((c) => c.name);
5375
5831
  const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
5376
5832
  requestSnapshot.sameRoundThinkTextPolicy === 'private';
5377
- if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
5378
- (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))))) {
5833
+ if (roundToolNames.some(isSilencingTool)) {
5379
5834
  turnSilenced = true;
5380
5835
  }
5381
5836
  if (roundContent && roundContent.length > 0) {
@@ -5479,7 +5934,29 @@ export class AgentFramework {
5479
5934
  const terminalContent = lastToolIdx >= 0
5480
5935
  ? response.content.slice(lastToolIdx + 1)
5481
5936
  : response.content;
5482
- if (lastToolIdx >= 0) {
5937
+ // This is a whole-response boundary, not a trailing-prose
5938
+ // classifier. If the turn executed any genuine structured tool
5939
+ // call, preserve its later prose exactly as ordinary history.
5940
+ const logicalToolState = this.logicalTurnToolCalls.get(agent);
5941
+ const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
5942
+ const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
5943
+ ? detectKnownToolWrapperProse(response.content, knownToolNames)
5944
+ : null;
5945
+ if (guardedWrapperTool) {
5946
+ // AF #133: containment only. Never execute wrapper text, never
5947
+ // publish it, and never retain it as the freshest assistant
5948
+ // self-seed. Exact raw output remains in the provider ledger.
5949
+ agent.getContextManager().addMessage('user', [{
5950
+ type: 'text',
5951
+ text: '[tool-boundary] No tool was called.',
5952
+ }], {
5953
+ system: true,
5954
+ kind: 'tool-wrapper-prose-contained',
5955
+ toolName: guardedWrapperTool,
5956
+ });
5957
+ console.error(`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`);
5958
+ }
5959
+ else if (lastToolIdx >= 0) {
5483
5960
  if (terminalContent.length > 0) {
5484
5961
  agent.addAssistantResponse(terminalContent);
5485
5962
  }
@@ -5502,7 +5979,9 @@ export class AgentFramework {
5502
5979
  // convention remains "text is thoughts"; on a text-only turn all
5503
5980
  // text is speech.
5504
5981
  const isTextBlock = (block) => block.type === 'text';
5505
- const allText = response.content.filter(isTextBlock);
5982
+ const allText = guardedWrapperTool
5983
+ ? []
5984
+ : response.content.filter(isTextBlock);
5506
5985
  const speechContent = hadToolCalls ? [] : allText;
5507
5986
  const thoughts = hadToolCalls ? allText : [];
5508
5987
  const du = response.details?.usage;
@@ -5514,19 +5993,30 @@ export class AgentFramework {
5514
5993
  cacheRead: du.cacheReadTokens,
5515
5994
  }
5516
5995
  : undefined;
5996
+ // Async completion work may overlap a replacement stream. Recheck
5997
+ // physical ownership before any terminal state/settlement side effect.
5998
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
5999
+ generationLost = true;
6000
+ lifecyclePhase = 'aborted';
6001
+ stream.cancel();
6002
+ return;
6003
+ }
5517
6004
  // Reset agent state before emitting inference:completed. Traces are
5518
6005
  // observability-only, but external synchronous listeners should
5519
6006
  // still see the terminal state at the terminal trace boundary.
5520
6007
  // Speech dispatch happens after but doesn't depend on the status
5521
6008
  // field.
5522
6009
  agent.reset();
5523
- this.eventGate?.onInferenceEnded(agent.name);
6010
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6011
+ this.eventGate?.onInferenceEnded(agent.name);
5524
6012
  this.settleAgent(agent.name, {
5525
6013
  stopReason: 'completed',
5526
- speech: terminalContent
5527
- .filter((block) => block.type === 'text')
5528
- .map((block) => block.text)
5529
- .join('\n'),
6014
+ speech: guardedWrapperTool
6015
+ ? ''
6016
+ : terminalContent
6017
+ .filter((block) => block.type === 'text')
6018
+ .map((block) => block.text)
6019
+ .join('\n'),
5530
6020
  });
5531
6021
  this.emitTrace({
5532
6022
  type: 'inference:completed',
@@ -5745,8 +6235,7 @@ export class AgentFramework {
5745
6235
  .filter((n) => typeof n === 'string');
5746
6236
  const silenced = liveProseRouting
5747
6237
  ? turnSilenced
5748
- : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
5749
- (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))));
6238
+ : turnSilenced || toolNames.some(isSilencingTool);
5750
6239
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
5751
6240
  // Preserve in-channel ordering: everything enqueued live must
5752
6241
  // land before the trailing prose. Awaited even when silenced —
@@ -5868,7 +6357,8 @@ export class AgentFramework {
5868
6357
  agent.reset();
5869
6358
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
5870
6359
  lifecyclePhase = 'failed';
5871
- this.eventGate?.onInferenceEnded(agent.name);
6360
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6361
+ this.eventGate?.onInferenceEnded(agent.name);
5872
6362
  break;
5873
6363
  }
5874
6364
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -5892,7 +6382,8 @@ export class AgentFramework {
5892
6382
  // itself) means retrying the same context can never succeed.
5893
6383
  ...this.classifyInferenceError(err),
5894
6384
  });
5895
- this.eventGate?.onInferenceEnded(agent.name);
6385
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6386
+ this.eventGate?.onInferenceEnded(agent.name);
5896
6387
  if (action.emit) {
5897
6388
  this.pushEvent(action.emit);
5898
6389
  }
@@ -5913,7 +6404,14 @@ export class AgentFramework {
5913
6404
  if (cancelKind !== undefined) {
5914
6405
  this.frameworkCancelledStreams.delete(cancelKey);
5915
6406
  lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
5916
- this.eventGate?.onInferenceEnded(agent.name);
6407
+ // A budget restart continues the same EventGate window. Do not
6408
+ // end it here or in finally; the replacement owns final release.
6409
+ if (cancelKind === 'budget_restart') {
6410
+ preserveEventGateForSuccessor = true;
6411
+ }
6412
+ else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
6413
+ this.eventGate?.onInferenceEnded(agent.name);
6414
+ }
5917
6415
  // endTurn IS a logical turn end — earlier rounds may have
5918
6416
  // live-routed prose (narrate → skip_reply is a real shape),
5919
6417
  // so settle the delivery chain and drop the receipt. A
@@ -5960,7 +6458,8 @@ export class AgentFramework {
5960
6458
  request: compiledRequest ?? { note: 'streaming request aborted' },
5961
6459
  durationMs,
5962
6460
  });
5963
- this.eventGate?.onInferenceEnded(agent.name);
6461
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6462
+ this.eventGate?.onInferenceEnded(agent.name);
5964
6463
  }
5965
6464
  break;
5966
6465
  }
@@ -5980,14 +6479,46 @@ export class AgentFramework {
5980
6479
  // loop (5 calls x ~160k reported as 884k), which is not a
5981
6480
  // window-shaped number and drove the multiplier to 2.37 before
5982
6481
  // the guards caught it.
6482
+ const cumulativeUsage = {
6483
+ inputTokens: event.usage.inputTokens ?? 0,
6484
+ outputTokens: event.usage.outputTokens ?? 0,
6485
+ cacheCreationTokens: event.usage.cacheCreationTokens ?? 0,
6486
+ cacheReadTokens: event.usage.cacheReadTokens ?? 0,
6487
+ };
6488
+ const perCallUsage = {
6489
+ inputTokens: cumulativeDelta(cumulativeUsage.inputTokens, previousUsage.inputTokens),
6490
+ outputTokens: cumulativeDelta(cumulativeUsage.outputTokens, previousUsage.outputTokens),
6491
+ cacheCreationTokens: cumulativeDelta(cumulativeUsage.cacheCreationTokens, previousUsage.cacheCreationTokens),
6492
+ cacheReadTokens: cumulativeDelta(cumulativeUsage.cacheReadTokens, previousUsage.cacheReadTokens),
6493
+ };
6494
+ previousUsage = cumulativeUsage;
6495
+ const strat = agent.getContextManager?.()?.getStrategy?.();
5983
6496
  try {
5984
- const realTotal = (event.usage.inputTokens ?? 0) +
5985
- (event.usage.cacheCreationTokens ?? 0) +
5986
- (event.usage.cacheReadTokens ?? 0);
5987
- const strat = agent.getContextManager?.()?.getStrategy?.();
6497
+ const realTotal = perCallUsage.inputTokens +
6498
+ perCallUsage.cacheCreationTokens +
6499
+ perCallUsage.cacheReadTokens;
5988
6500
  strat?.reportRealInputTokens?.(realTotal);
5989
6501
  }
5990
6502
  catch { /* calibration is best-effort */ }
6503
+ const kvSubmission = takeKvSubmission?.();
6504
+ if (kvSubmission) {
6505
+ try {
6506
+ strat?.reportKvUnifiedAccepted?.({
6507
+ submissionId: kvSubmission.submissionId,
6508
+ acceptedAt: Date.now(),
6509
+ wireReceipt: kvSubmission.wireReceipt,
6510
+ });
6511
+ }
6512
+ catch {
6513
+ // The queue item has already been consumed. Explicitly close
6514
+ // the receipt flight so one persistence error cannot wedge
6515
+ // every later provider call on this branch.
6516
+ try {
6517
+ strat?.reportKvUnifiedFailed?.(kvSubmission.submissionId);
6518
+ }
6519
+ catch { /* teardown only */ }
6520
+ }
6521
+ }
5991
6522
  this.emitTrace({
5992
6523
  type: 'inference:usage',
5993
6524
  agentName: agent.name,
@@ -6004,6 +6535,14 @@ export class AgentFramework {
6004
6535
  }
6005
6536
  }
6006
6537
  catch (error) {
6538
+ // A superseded physical stream may throw after its replacement starts.
6539
+ // It has no authority to settle, reset, gate-release, or publish failure.
6540
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
6541
+ generationLost = true;
6542
+ lifecyclePhase = 'aborted';
6543
+ stream.cancel();
6544
+ return;
6545
+ }
6007
6546
  // Stream itself threw. Organization acceleration is deferred from a
6008
6547
  // fresh compile; every other throw keeps the ordinary exhausted path.
6009
6548
  const err = error instanceof Error ? error : new Error(String(error));
@@ -6016,7 +6555,8 @@ export class AgentFramework {
6016
6555
  this.abortAgentScript(agent.name, 'provider acceleration cooldown');
6017
6556
  lifecyclePhase = 'failed';
6018
6557
  agent.reset();
6019
- this.eventGate?.onInferenceEnded(agent.name);
6558
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6559
+ this.eventGate?.onInferenceEnded(agent.name);
6020
6560
  return;
6021
6561
  }
6022
6562
  this.emitTrace({
@@ -6051,9 +6591,33 @@ export class AgentFramework {
6051
6591
  this.abortAgentScript(agent.name, 'stream threw');
6052
6592
  lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
6053
6593
  agent.reset();
6054
- this.eventGate?.onInferenceEnded(agent.name);
6594
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6595
+ this.eventGate?.onInferenceEnded(agent.name);
6055
6596
  }
6056
6597
  finally {
6598
+ const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
6599
+ if (unsettledKvSubmissions.length > 0) {
6600
+ try {
6601
+ const strategy = agent.getContextManager().getStrategy();
6602
+ for (const submissionId of unsettledKvSubmissions) {
6603
+ strategy.reportKvUnifiedFailed?.(submissionId);
6604
+ }
6605
+ }
6606
+ catch { /* receipt cleanup must not mask inference teardown */ }
6607
+ }
6608
+ // A framework retry/budget restart can overlap physical streams on the
6609
+ // same Agent object. Name + Agent identity is not enough: only the
6610
+ // current stream generation may mutate name-keyed teardown state.
6611
+ const ownsPhysicalStream = this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
6612
+ // Ephemeral completion resolves its caller before this finally runs, so
6613
+ // runEphemeralToCompletion may already have deregistered the name. The
6614
+ // terminal frame still owns its typing/outgoing completion unless it was
6615
+ // genuinely superseded or handed EventGate liveness to a budget restart.
6616
+ const disposedEphemeralWithoutSuccessor = generationLost &&
6617
+ this.disposedEphemeralFrames.has(agent) &&
6618
+ !this.agents.has(agent.name);
6619
+ const frameReachedTerminal = (!generationLost || disposedEphemeralWithoutSuccessor) &&
6620
+ !preserveEventGateForSuccessor;
6057
6621
  // §10.5: exactly one terminal per `started`, on every exit path the
6058
6622
  // host controls. Which one was decided by the path taken (default
6059
6623
  // completed; catch → failed; framework-cancel → aborted). A host
@@ -6072,28 +6636,35 @@ export class AgentFramework {
6072
6636
  // all incoming events → the agent silently stops waking on messages
6073
6637
  // (typing still stops, compression still runs — matching the observed
6074
6638
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
6075
- this.eventGate?.onInferenceEnded(agent.name);
6076
- this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
6639
+ if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
6640
+ this.eventGate?.onInferenceEnded(agent.name);
6641
+ }
6642
+ if (!generationLost && ownsPhysicalStream) {
6643
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
6644
+ }
6077
6645
  if (ownsProviderGate)
6078
6646
  this.releasePrimaryProviderGate(agent.name);
6079
6647
  // Stop the typing indicator on every exit path (complete, error,
6080
6648
  // exhausted, abort) so it never sticks after the turn ends.
6081
- this.channelRegistry?.stopTyping();
6649
+ if (frameReachedTerminal)
6650
+ this.channelRegistry?.stopTyping();
6082
6651
  // Spec 14.3: flush any held line-start text, then close each streamed
6083
6652
  // channel with its final moderated content — the consumer's signal to
6084
6653
  // finalize (end the TTS utterance, settle the rendered message).
6085
- if (proseStream) {
6654
+ if (frameReachedTerminal && proseStream) {
6086
6655
  emitOutgoing(proseStream.finish());
6087
6656
  for (const [channelId, text] of proseStream.byChannel()) {
6088
6657
  this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
6089
6658
  }
6090
6659
  }
6091
6660
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
6092
- this.activeStreams.delete(agent.name);
6093
- this.pendingAssistantBlocks.delete(agent.name);
6661
+ if (ownsPhysicalStream) {
6662
+ this.activeStreams.delete(agent.name);
6663
+ this.pendingAssistantBlocks.delete(agent.name);
6664
+ }
6094
6665
  // A conversation fork whose TTL closure turn just finished is done for
6095
6666
  // good — dispose it so the agent map doesn't grow monotonically.
6096
- if (this.closingConversationAgents.has(agent.name)) {
6667
+ if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
6097
6668
  this.disposeConversationAgent(agent.name);
6098
6669
  }
6099
6670
  // The turn is torn down — release the turn-alive marker BEFORE the
@@ -6105,12 +6676,16 @@ export class AgentFramework {
6105
6676
  // injection / teardown delivers the messages at a correct position.
6106
6677
  if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
6107
6678
  this.activeTurnTokens.delete(agent.name);
6108
- }
6109
- // Flush any deferred messages (e.g. if stream failed while tools were pending)
6110
- if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
6111
- const deferred = this.deferredMessages.splice(0);
6679
+ this.activeTurnTriggers.delete(agent.name);
6680
+ }
6681
+ // Flush any deferred messages (e.g. if stream failed while tools were
6682
+ // pending). Only THIS agent's messages: other targets' entries wait
6683
+ // for their own boundaries (re-adding via addMessage re-defers if the
6684
+ // target has meanwhile started a turn).
6685
+ if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
6686
+ const deferred = this.drainDeferredFor(agent.name);
6112
6687
  for (const msg of deferred) {
6113
- this.addMessage(msg.participant, msg.content, msg.metadata);
6688
+ this.addMessage(msg.participant, msg.content, msg.metadata, msg.forAgent ? { forAgent: msg.forAgent } : undefined);
6114
6689
  }
6115
6690
  }
6116
6691
  }
@@ -6760,6 +7335,23 @@ export class AgentFramework {
6760
7335
  }
6761
7336
  if (result.data === undefined)
6762
7337
  return '';
7338
+ // RFC-005 "eagerly lazy": a script receiving a result is the strongest
7339
+ // available signal that the bytes are wanted (scripts process, they do
7340
+ // not browse) — materialize every referenced payload now, so the stub
7341
+ // the script receives carries a workspace path it can open directly.
7342
+ if (Array.isArray(result.data)) {
7343
+ const fetcher = this.getReferenceFetcher();
7344
+ for (const block of result.data) {
7345
+ const c = classifyBlock(block);
7346
+ if (c.kind !== 'reference')
7347
+ continue;
7348
+ const record = referenceRegistry.findByUri(c.testimony.uri)
7349
+ ?? referenceRegistry.register(c.testimony);
7350
+ const outcome = await fetcher.fetch(record);
7351
+ if (!outcome.ok)
7352
+ console.error(`[rfc005] script-demand fetch of ${record.refId} failed: ${outcome.error}`);
7353
+ }
7354
+ }
6763
7355
  // Scripts get (nearly) full data — the script environment IS the spill:
6764
7356
  // filtering big results in code is the whole point. 5MB protocol safety
6765
7357
  // cap only; no context-cap truncation, no file spill.
@@ -6864,6 +7456,11 @@ export class AgentFramework {
6864
7456
  if (!raw || typeof raw !== 'object')
6865
7457
  return null;
6866
7458
  const b = raw;
7459
+ if (isInlineContradiction(b)) {
7460
+ // RFC-005 vector 2: withhold inline data claiming bulk disposition.
7461
+ blocks.push({ type: 'text', text: INLINE_WITHHELD_TEXT });
7462
+ continue;
7463
+ }
6867
7464
  if (b.type === 'text' && typeof b.text === 'string') {
6868
7465
  let text = b.text;
6869
7466
  if (maxChars && text.length > maxChars) {
@@ -6881,6 +7478,15 @@ export class AgentFramework {
6881
7478
  source: { type: 'base64', data: b.data, mediaType: b.mimeType },
6882
7479
  });
6883
7480
  }
7481
+ else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof b.uri === 'string')) {
7482
+ // RFC-005 reference block → bounded stub. Previously this bailed the
7483
+ // whole array to JSON, which both inlined the reference verbatim and
7484
+ // demoted any adjacent image block to stringified base64.
7485
+ const stub = referenceStubOrNull(b, 'from tool result');
7486
+ if (stub === null)
7487
+ return null;
7488
+ blocks.push({ type: 'text', text: stub });
7489
+ }
6884
7490
  else {
6885
7491
  return null; // unknown shape — bail to JSON path
6886
7492
  }
@@ -7048,11 +7654,36 @@ export class AgentFramework {
7048
7654
  this.dispatchReadImageToolCall(agentName, enrichedCall);
7049
7655
  return;
7050
7656
  }
7657
+ if (enrichedCall.name === 'fetch_reference') {
7658
+ this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
7659
+ return;
7660
+ }
7051
7661
  // Route gate_status tool
7052
7662
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
7053
7663
  this.dispatchGateToolCall(agentName, enrichedCall);
7054
7664
  return;
7055
7665
  }
7666
+ // Route the tune-out tool (residents) and the subconscious's surface
7667
+ if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
7668
+ this.dispatchTuneOutToolCall(agentName, enrichedCall);
7669
+ return;
7670
+ }
7671
+ if (this.tuneOutCoordinator &&
7672
+ agentName === this.subconsciousAgentName &&
7673
+ SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
7674
+ void this.tuneOutCoordinator
7675
+ .handleSubconsciousTool(enrichedCall.name, enrichedCall.input)
7676
+ .then((result) => {
7677
+ this.queue.push({
7678
+ type: 'tool-result',
7679
+ callId: enrichedCall.id,
7680
+ agentName,
7681
+ moduleName: 'tune-out',
7682
+ result,
7683
+ });
7684
+ });
7685
+ return;
7686
+ }
7056
7687
  // Route sleep / wake tools
7057
7688
  if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
7058
7689
  this.dispatchSleepToolCall(agentName, enrichedCall);
@@ -7181,12 +7812,19 @@ export class AgentFramework {
7181
7812
  });
7182
7813
  });
7183
7814
  }
7184
- addMessage(participant, content, metadata) {
7185
- // Route to the primary agent's context manager (not ephemeral subagents).
7186
- const agent = this.primaryAgentName
7187
- ? this.agents.get(this.primaryAgentName)
7188
- : this.agents.values().next().value;
7815
+ addMessage(participant, content, metadata, opts) {
7816
+ // Route to the named agent, else the primary (not ephemeral subagents).
7817
+ const agent = opts?.forAgent
7818
+ ? this.agents.get(opts.forAgent)
7819
+ : this.primaryAgentName
7820
+ ? this.agents.get(this.primaryAgentName)
7821
+ : this.agents.values().next().value;
7189
7822
  if (!agent) {
7823
+ if (opts?.forAgent) {
7824
+ console.error(`[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
7825
+ `(participant=${participant})`);
7826
+ return '';
7827
+ }
7190
7828
  throw new Error('No agents configured');
7191
7829
  }
7192
7830
  // Defer non-tool_result messages while a tool cycle is mid-flight
@@ -7206,15 +7844,39 @@ export class AgentFramework {
7206
7844
  // ALSO injected into the live stream — hear-while-acting), or in
7207
7845
  // driveStream's finally when the turn ends.
7208
7846
  const hasToolResult = content.some(b => b.type === 'tool_result');
7847
+ // Explicitly-targeted deliveries scope the tool-cycle check to the
7848
+ // target (pendingAssistantBlocks is keyed by agent); the default path
7849
+ // keeps the historical GLOBAL check byte-for-byte (conservative: any
7850
+ // agent's pending cycle defers primary-bound messages).
7851
+ const midToolCycle = opts?.forAgent
7852
+ ? this.pendingAssistantBlocks.has(agent.name)
7853
+ : this.pendingAssistantBlocks.size > 0;
7209
7854
  if (!hasToolResult &&
7210
- (this.pendingAssistantBlocks.size > 0 ||
7855
+ (midToolCycle ||
7211
7856
  this.activeTurnTokens.has(agent.name) ||
7212
7857
  this.activeStreams.has(agent.name))) {
7213
- this.deferredMessages.push({ participant, content, metadata });
7214
- return ''; // Deferred — flushed at the next boundary
7858
+ this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
7859
+ return ''; // Deferred — flushed at the target's next boundary
7215
7860
  }
7216
7861
  return agent.getContextManager().addMessage(participant, content, metadata);
7217
7862
  }
7863
+ /**
7864
+ * Splice out the deferred messages addressed to `agentName` (explicitly
7865
+ * via forAgent, or implicitly when it is the primary), leaving other
7866
+ * targets' messages queued for their own boundaries.
7867
+ */
7868
+ drainDeferredFor(agentName) {
7869
+ if (this.deferredMessages.length === 0)
7870
+ return [];
7871
+ const mine = [];
7872
+ const rest = [];
7873
+ for (const msg of this.deferredMessages) {
7874
+ const target = msg.forAgent ?? this.primaryAgentName;
7875
+ (target === agentName ? mine : rest).push(msg);
7876
+ }
7877
+ this.deferredMessages = rest;
7878
+ return mine;
7879
+ }
7218
7880
  editMessage(id, content) {
7219
7881
  const agent = this.primaryAgentName
7220
7882
  ? this.agents.get(this.primaryAgentName)
@@ -8856,6 +9518,13 @@ export class AgentFramework {
8856
9518
  }
8857
9519
  }
8858
9520
  server.sendToolsCall(toolName, args, stateParams)
9521
+ .then(async (result) => {
9522
+ // RFC-005: register references and eagerly fetch the small ones
9523
+ // before serialization, so their stubs carry a saved path.
9524
+ await this.autoFetchReferences(result.content, serverId, true)
9525
+ .catch((e) => console.error('[rfc005] autofetch error:', e.message));
9526
+ return result;
9527
+ })
8859
9528
  .then((result) => {
8860
9529
  const durationMs = Date.now() - startTime;
8861
9530
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
@@ -9191,6 +9860,50 @@ export class AgentFramework {
9191
9860
  * suppression window, optionally announces in the sticky channel, and ends
9192
9861
  * the turn (the agent goes idle immediately). `wake` clears sleep.
9193
9862
  */
9863
+ /** tune_out tool (residents): enter or cancel a channel tune-out. */
9864
+ dispatchTuneOutToolCall(agentName, call) {
9865
+ const coordinator = this.tuneOutCoordinator;
9866
+ const input = (call.input ?? {});
9867
+ const channelId = String(input.channelId ?? '');
9868
+ const entry = this.channelRegistry?.listChannelsRaw()
9869
+ .find((e) => e.descriptor.id === channelId);
9870
+ let result;
9871
+ if (!entry) {
9872
+ result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
9873
+ }
9874
+ else if (input.mode === 'cancel') {
9875
+ const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
9876
+ result = r.ok
9877
+ ? { success: true, data: { cancelled: channelId } }
9878
+ : { success: false, error: r.error, isError: false };
9879
+ }
9880
+ else {
9881
+ const r = coordinator.enter(entry.serverId, channelId, {
9882
+ cadenceSeconds: input.cadenceSeconds,
9883
+ backlogCap: input.backlogCap,
9884
+ maxWakes: input.maxWakes,
9885
+ durationSeconds: input.durationSeconds,
9886
+ }, 'agent-tool');
9887
+ result = r.ok
9888
+ ? {
9889
+ success: true,
9890
+ data: {
9891
+ tunedOut: channelId,
9892
+ cadenceSeconds: r.params.cadenceSeconds,
9893
+ backlogCap: r.params.backlogCap,
9894
+ maxWakes: r.params.maxWakes,
9895
+ },
9896
+ }
9897
+ : { success: false, error: r.error, isError: false };
9898
+ }
9899
+ this.queue.push({
9900
+ type: 'tool-result',
9901
+ callId: call.id,
9902
+ agentName,
9903
+ moduleName: 'tune-out',
9904
+ result,
9905
+ });
9906
+ }
9194
9907
  dispatchSleepToolCall(agentName, call) {
9195
9908
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
9196
9909
  const gate = this.eventGate;
@@ -9264,6 +9977,43 @@ export class AgentFramework {
9264
9977
  * blocks) delivers the actual image to the model; history storage keeps
9265
9978
  * the standard compact `[image: …]` placeholder.
9266
9979
  */
9980
+ /** RFC-005 on-demand dereference: [ref id] → workspace file, via the
9981
+ * origin-bound, ceiling-bounded, digest-verified host fetcher. */
9982
+ dispatchFetchReferenceToolCall(agentName, call) {
9983
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
9984
+ const startTime = Date.now();
9985
+ void (async () => {
9986
+ const args = (call.input ?? {});
9987
+ const refId = String(args.ref_id ?? '');
9988
+ const record = referenceRegistry.get(refId);
9989
+ let result;
9990
+ if (!record) {
9991
+ // Vector 20: a stale or unknown id is a defined miss, never a
9992
+ // different record.
9993
+ result = { success: false, isError: true,
9994
+ error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
9995
+ }
9996
+ else {
9997
+ const maxBytes = typeof args.max_bytes === 'number'
9998
+ ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
9999
+ const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
10000
+ result = outcome.ok
10001
+ ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
10002
+ mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
10003
+ : { success: false, isError: true, error: outcome.error };
10004
+ }
10005
+ this.emitTrace({
10006
+ type: result.isError ? 'tool:failed' : 'tool:completed',
10007
+ module: 'workspace', tool: call.name, callId: call.id,
10008
+ durationMs: Date.now() - startTime,
10009
+ ...(result.isError ? { error: result.error } : {}),
10010
+ });
10011
+ this.pushEvent({
10012
+ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace',
10013
+ result: { ...result, isError: result.isError ?? false },
10014
+ });
10015
+ })();
10016
+ }
9267
10017
  dispatchReadImageToolCall(agentName, call) {
9268
10018
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
9269
10019
  const finish = (result) => {