@animalabs/agent-framework 0.12.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 (77) hide show
  1. package/dist/src/agent.d.ts +2 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +3 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +45 -0
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +606 -102
  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/mcpl/channel-registry.d.ts +84 -1
  14. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  15. package/dist/src/mcpl/channel-registry.js +160 -4
  16. package/dist/src/mcpl/channel-registry.js.map +1 -1
  17. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  18. package/dist/src/modules/workspace/index.js +5 -1
  19. package/dist/src/modules/workspace/index.js.map +1 -1
  20. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  21. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  22. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  23. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  24. package/dist/src/tune-out/coordinator.d.ts +144 -0
  25. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  26. package/dist/src/tune-out/coordinator.js +444 -0
  27. package/dist/src/tune-out/coordinator.js.map +1 -0
  28. package/dist/src/tune-out/tools.d.ts +43 -0
  29. package/dist/src/tune-out/tools.d.ts.map +1 -0
  30. package/dist/src/tune-out/tools.js +93 -0
  31. package/dist/src/tune-out/tools.js.map +1 -0
  32. package/dist/src/types/agent.d.ts +7 -0
  33. package/dist/src/types/agent.d.ts.map +1 -1
  34. package/dist/src/types/framework.d.ts +6 -0
  35. package/dist/src/types/framework.d.ts.map +1 -1
  36. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  37. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  38. package/dist/test/channel-incoming-targeting.test.js +81 -0
  39. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  40. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  41. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  42. package/dist/test/per-agent-delivery.test.js +108 -0
  43. package/dist/test/per-agent-delivery.test.js.map +1 -0
  44. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  45. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  46. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  47. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  48. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  49. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  50. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  51. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  52. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  53. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  54. package/dist/test/tune-out-e2e.test.js +142 -0
  55. package/dist/test/tune-out-e2e.test.js.map +1 -0
  56. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  57. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  58. package/dist/test/tune-out-gate-composition.test.js +221 -0
  59. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  60. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  61. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  62. package/dist/test/tune-out-lifecycle.test.js +121 -0
  63. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  64. package/dist/test/workspace-read-image.test.js +8 -0
  65. package/dist/test/workspace-read-image.test.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +2 -2
  68. package/src/agent.ts +3 -0
  69. package/src/framework.ts +662 -100
  70. package/src/gate/event-gate.ts +28 -3
  71. package/src/mcpl/channel-registry.ts +235 -7
  72. package/src/modules/workspace/index.ts +5 -1
  73. package/src/tool-wrapper-prose-guard.ts +38 -0
  74. package/src/tune-out/coordinator.ts +548 -0
  75. package/src/tune-out/tools.ts +129 -0
  76. package/src/types/agent.ts +8 -0
  77. package/src/types/framework.ts +7 -0
package/src/framework.ts CHANGED
@@ -5,8 +5,10 @@ import { appendFileSync, mkdirSync } from 'node:fs';
5
5
  import { JsStore } from '@animalabs/chronicle';
6
6
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
7
7
  import { MembraneError } from '@animalabs/membrane';
8
- import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
8
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
9
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';
10
12
  import type {
11
13
  MessageId,
12
14
  MessageMetadata,
@@ -67,6 +69,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
67
69
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
68
70
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
69
71
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
72
+ import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
70
73
  import { InferenceRouter } from './mcpl/inference-router.js';
71
74
  import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
72
75
  import { ConversationRouter } from './mcpl/conversation-router.js';
@@ -788,6 +791,16 @@ export class AgentFramework {
788
791
  private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
789
792
  /** Active runEphemeralToCompletion runs, keyed by agent name. */
790
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();
791
804
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
792
805
  * Drives hard-down escalation — see noteInferenceExhausted. */
793
806
  private consecutiveInferenceFailures: Map<string, number> = new Map();
@@ -841,7 +854,23 @@ export class AgentFramework {
841
854
 
842
855
  // Messages deferred while an agent is waiting_for_tools (to preserve
843
856
  // tool_use → tool_result adjacency required by the Anthropic API).
844
- 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
+ }> = [];
845
874
 
846
875
  // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
847
876
  // token of the turn currently in progress. Set at startAgentStream ENTRY —
@@ -873,6 +902,10 @@ export class AgentFramework {
873
902
  getActiveTurnTrigger(agentName: string): InferenceRequest | undefined {
874
903
  return this.activeTurnTriggers.get(agentName);
875
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();
876
909
  private nextTurnToken = 1;
877
910
 
878
911
  // Undo/redo state
@@ -1131,6 +1164,13 @@ export class AgentFramework {
1131
1164
  await framework.createAgent(agentConfig);
1132
1165
  }
1133
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
+
1134
1174
  // Finish any branch-local suppression interrupted after Chronicle switched
1135
1175
  // branches. This runs before modules, MCPL connections, or inbound traffic.
1136
1176
  await framework.resumePreparedDiscordSuppressions();
@@ -1180,11 +1220,12 @@ export class AgentFramework {
1180
1220
  initialConfig: config.gate.config,
1181
1221
  privilegedUsersPath: config.gate.privilegedUsersPath,
1182
1222
  emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
1183
- 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),
1184
1224
  requestInference: (agentName, reason, source) => {
1185
1225
  framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
1186
1226
  },
1187
- getAgentNames: () => [...framework.agents.keys()],
1227
+ getAgentNames: () => [...framework.agents.keys()].filter(
1228
+ (n) => n !== framework.subconsciousAgentName),
1188
1229
  });
1189
1230
  }
1190
1231
 
@@ -1247,6 +1288,40 @@ export class AgentFramework {
1247
1288
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
1248
1289
  }
1249
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
+
1250
1325
  // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
1251
1326
  // (journal) without a restart — for catching the wake-wedge on the running
1252
1327
  // process. Shows the gate's `inferring` set + buffered-event count (the
@@ -1316,6 +1391,7 @@ export class AgentFramework {
1316
1391
  this.running = false;
1317
1392
  this.providerAdmissionClosed = true;
1318
1393
  this.queue.close();
1394
+ this.tuneOutCoordinator?.stop();
1319
1395
 
1320
1396
  // Kill running code_execution scripts before cancelling streams: a
1321
1397
  // zombie script must not keep firing side-effectful tool calls into a
@@ -1751,7 +1827,12 @@ export class AgentFramework {
1751
1827
  */
1752
1828
  getAllTools(): import('./types/index.js').ToolDefinition[] {
1753
1829
  const moduleTools = this.moduleRegistry.getAllTools();
1754
- 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
+ }
1755
1836
  const gateTools = this.eventGate
1756
1837
  ? [
1757
1838
  this.eventGate.getToolDefinition(),
@@ -2001,6 +2082,20 @@ export class AgentFramework {
2001
2082
  agentName: string,
2002
2083
  snapshot?: InferenceToolSnapshot,
2003
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
+ }
2004
2099
  return this.getAllTools().map((tool) => {
2005
2100
  if (tool.name === 'think') {
2006
2101
  return this.buildThinkTool(
@@ -2421,25 +2516,38 @@ export class AgentFramework {
2421
2516
  contextManager: ContextManager;
2422
2517
  cleanup: () => void;
2423
2518
  }> {
2424
- 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}`;
2425
2528
 
2426
- const contextManager = await ContextManager.open({
2427
- store: this.store,
2428
- namespace,
2429
- isolate: true,
2430
- strategy: config.strategy ?? new PassthroughStrategy(),
2431
- membrane: this.membrane,
2432
- debugLogContext: !!process.env.DEBUG_CONTEXT,
2433
- });
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
+ });
2434
2537
 
2435
- const agent = new Agent(config, contextManager, this.membrane);
2538
+ const agent = new Agent(config, contextManager, this.membrane);
2539
+ this.ephemeralCandidates.set(agent, contextManager);
2436
2540
 
2437
- const cleanup = () => {
2438
- // Don't close the store — it's shared. Just release the CM.
2439
- // Data persists in the store under the namespace for investigation.
2440
- };
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
+ };
2441
2545
 
2442
- return { agent, contextManager, cleanup };
2546
+ return { agent, contextManager, cleanup };
2547
+ } catch (error) {
2548
+ this.usedEphemeralAgentNames.delete(config.name);
2549
+ throw error;
2550
+ }
2443
2551
  }
2444
2552
 
2445
2553
  private createDeferred<T>(): Deferred<T> {
@@ -2462,6 +2570,17 @@ export class AgentFramework {
2462
2570
  run.lastActivity = Date.now();
2463
2571
  }
2464
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
+
2465
2584
  private recordEphemeralToolCalls(agentName: string, count: number): void {
2466
2585
  const run = this.ephemeralRuns.get(agentName);
2467
2586
  if (!run) return;
@@ -2505,7 +2624,18 @@ export class AgentFramework {
2505
2624
  contextManager: ContextManager,
2506
2625
  watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
2507
2626
  ): Promise<{ speech: string; toolCallsCount: number }> {
2508
- // 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
+ }
2509
2639
  this.agents.set(agent.name, agent);
2510
2640
  const run: EphemeralRun = {
2511
2641
  settle: this.createDeferred<AgentSettleResult>(),
@@ -2570,8 +2700,25 @@ export class AgentFramework {
2570
2700
  } finally {
2571
2701
  if (startupWatchdog) clearTimeout(startupWatchdog);
2572
2702
  if (completionWatchdog) clearInterval(completionWatchdog);
2573
- this.ephemeralRuns.delete(agent.name);
2574
- 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
+ }
2575
2722
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2576
2723
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2577
2724
  // entries behind for the life of the store/session.
@@ -2583,6 +2730,7 @@ export class AgentFramework {
2583
2730
  // of the map, and a late driveStream finally token-match no-ops.
2584
2731
  this.activeTurnTokens.delete(agent.name);
2585
2732
  this.activeTurnTriggers.delete(agent.name);
2733
+ this.logicalTurnToolCalls.delete(agent);
2586
2734
  }
2587
2735
  }
2588
2736
 
@@ -2597,6 +2745,48 @@ export class AgentFramework {
2597
2745
  * Query inference logs.
2598
2746
  * Returns entries with summary info (doesn't resolve blobs).
2599
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
+
2600
2790
  /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
2601
2791
  private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
2602
2792
  {
@@ -4040,6 +4230,15 @@ export class AgentFramework {
4040
4230
  // streaming agent keeps the original Membrane.
4041
4231
  membrane: this.auxiliaryMembraneFor(config.name),
4042
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,
4043
4242
  });
4044
4243
 
4045
4244
  const agent = new Agent(config, contextManager, this.membrane);
@@ -4060,6 +4259,71 @@ export class AgentFramework {
4060
4259
  return agent;
4061
4260
  }
4062
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
+
4063
4327
  private async runLoop(): Promise<void> {
4064
4328
  while (this.running) {
4065
4329
  try {
@@ -4198,12 +4462,11 @@ export class AgentFramework {
4198
4462
  // queued, they flush at the target's own next boundary — with
4199
4463
  // injection — or in driveStream's finally when its turn ends.
4200
4464
  const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
4201
- if (this.deferredMessages.length > 0) {
4202
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
4203
- if (target === agent) {
4204
- const deferred = this.deferredMessages.splice(0);
4465
+ {
4466
+ const deferred = this.drainDeferredFor(agent.name);
4467
+ if (deferred.length > 0) {
4205
4468
  for (const msg of deferred) {
4206
- target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4469
+ agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4207
4470
  // Injection guards: tool blocks would corrupt the tool-cycle
4208
4471
  // structure the membrane enforces, and a message named as the
4209
4472
  // agent itself would render as an ASSISTANT turn on the wire
@@ -4538,7 +4801,8 @@ export class AgentFramework {
4538
4801
  // their own channel's messages, not by framework-wide events.
4539
4802
  const targetAgents =
4540
4803
  response.requestInference === true
4541
- ? 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)
4542
4806
  : response.requestInference;
4543
4807
 
4544
4808
  for (const agentName of targetAgents) {
@@ -4575,6 +4839,7 @@ export class AgentFramework {
4575
4839
  metadata?: Record<string, unknown>;
4576
4840
  tags?: string[];
4577
4841
  triggerInference?: boolean;
4842
+ targetAgents?: string[];
4578
4843
  }): Promise<void> {
4579
4844
  const metadata: Record<string, unknown> = {
4580
4845
  ...event.metadata,
@@ -4621,12 +4886,38 @@ export class AgentFramework {
4621
4886
  }
4622
4887
  }
4623
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
+
4624
4907
  const id = this.addMessage('user', incomingContent, metadata);
4625
4908
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
4626
4909
 
4627
- if (event.triggerInference) {
4910
+ if (event.triggerInference && !divert) {
4628
4911
  const addressed = isAddressedMessage(event.tags, event.metadata);
4629
- 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;
4630
4921
  this.pendingRequests.push({
4631
4922
  agentName,
4632
4923
  reason: 'mcpl:channel-incoming',
@@ -4757,41 +5048,50 @@ export class AgentFramework {
4757
5048
  if (!template || !templateConfig) {
4758
5049
  throw new Error(`conversation template agent "${router.templateAgent}" not found`);
4759
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 {
4760
5056
 
4761
- const contextManager = await ContextManager.open({
4762
- store: this.store,
4763
- namespace: `conversations/${name}`,
4764
- isolate: true,
4765
- // Strategy instances are stateful — never share the template's.
4766
- strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
4767
- // Dynamic conversation forks retain the established provider policy in
4768
- // this bounded first slice. Provider cooldown ownership belongs to the
4769
- // persistent resident only; generation-unique forks must not leak gates.
4770
- membrane: this.membrane,
4771
- debugLogContext: !!process.env.DEBUG_CONTEXT,
4772
- });
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
+ });
4773
5069
 
4774
- // Seed with the template's compiled context, renaming the template
4775
- // participant so the fork reads its inheritance as its own history.
4776
- // Guard: only seed a genuinely fresh namespace. Generation counters are
4777
- // persisted precisely so names aren't reused, but if this namespace has
4778
- // history anyway (counter state lost, crash between spawn and persist),
4779
- // seeding again would stack another template copy on top of it.
4780
- const { messages: existing } = await contextManager.compile();
4781
- if (existing.length === 0) {
4782
- const { messages: compiled } = await template.getContextManager().compile();
4783
- for (const msg of compiled) {
4784
- const participant = msg.participant === template.name ? name : msg.participant;
4785
- 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
+ }
4786
5083
  }
4787
- }
4788
5084
 
4789
- const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
4790
- const agent = new Agent(config, contextManager, this.membrane);
4791
- this.agents.set(name, agent);
4792
- this.agentConfigs.set(name, config);
4793
- this.conversationAgentHomes.set(name, channelId);
4794
- 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
+ }
4795
5095
  }
4796
5096
 
4797
5097
  /** Persist the router's generation counters (see hydration in create()). */
@@ -4815,10 +5115,12 @@ export class AgentFramework {
4815
5115
  private disposeConversationAgent(agentName: string): void {
4816
5116
  this.closingConversationAgents.delete(agentName);
4817
5117
  const channelId = this.conversationAgentHomes.get(agentName);
5118
+ const agent = this.agents.get(agentName);
4818
5119
  this.agents.delete(agentName);
4819
5120
  this.agentConfigs.delete(agentName);
4820
5121
  this.conversationAgentHomes.delete(agentName);
4821
5122
  this.evictTurnCheckpoints(agentName);
5123
+ if (agent) this.logicalTurnToolCalls.delete(agent);
4822
5124
  this.emitTrace({
4823
5125
  type: 'mcpl:conversation-disposed',
4824
5126
  agentName,
@@ -5054,7 +5356,8 @@ export class AgentFramework {
5054
5356
  if (event.triggerInference) {
5055
5357
  // Default broadcast excludes conversation forks (channel-driven).
5056
5358
  const targetAgents = event.targetAgents
5057
- ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
5359
+ ?? [...this.agents.keys()].filter(
5360
+ (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
5058
5361
  for (const agentName of targetAgents) {
5059
5362
  this.pendingRequests.push({
5060
5363
  agentName,
@@ -5255,6 +5558,9 @@ export class AgentFramework {
5255
5558
  `[inference-dropped] agent=${agentName} reason=policy-skip ` +
5256
5559
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
5257
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);
5258
5564
  const gate = this.providerGates.get(agentName);
5259
5565
  if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
5260
5566
  gate.primaryPending = false;
@@ -5846,9 +6152,8 @@ export class AgentFramework {
5846
6152
  trigger?.reason !== 'context_budget_restart' &&
5847
6153
  this.deferredMessages.length > 0
5848
6154
  ) {
5849
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
5850
- if (target === agent) {
5851
- const deferred = this.deferredMessages.splice(0);
6155
+ {
6156
+ const deferred = this.drainDeferredFor(agent.name);
5852
6157
  for (const msg of deferred) {
5853
6158
  // Per-message try/catch (mirrors announceLocusIfChanged): one
5854
6159
  // poison message — or a transient store-write failure — must not
@@ -5906,8 +6211,21 @@ export class AgentFramework {
5906
6211
  // BEFORE this turn compiles, so the agent always knows where its voice
5907
6212
  // goes (announce-on-change only — no per-turn chatter, append-only for
5908
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
+
5909
6219
  if (trigger?.reason !== 'context_budget_restart') {
5910
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
+ }
5911
6229
  // Fresh turn: forget the previous turn's explicit-send engagements and
5912
6230
  // prose deliveries — both are strictly turn-scoped (a restart continues
5913
6231
  // the same logical turn and keeps them).
@@ -5941,7 +6259,11 @@ export class AgentFramework {
5941
6259
  // context-budget restart, which skips the re-pin but re-emits this).
5942
6260
  channelId: this.turnLocusPins.get(agent.name),
5943
6261
  });
5944
- 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
+ }
5945
6267
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
5946
6268
 
5947
6269
  // Typing indicator, started at TURN START. It says "attending", which is
@@ -6004,12 +6326,38 @@ export class AgentFramework {
6004
6326
  }
6005
6327
  }
6006
6328
 
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
+
6007
6348
  const {
6008
6349
  stream,
6009
6350
  request: compiledRequest,
6010
6351
  takeKvSubmission,
6011
6352
  drainKvSubmissionIds,
6012
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
+ }
6013
6361
 
6014
6362
  const handle = this.driveStream(
6015
6363
  agent,
@@ -6021,6 +6369,7 @@ export class AgentFramework {
6021
6369
  ownsProviderGate,
6022
6370
  takeKvSubmission,
6023
6371
  drainKvSubmissionIds,
6372
+ new Set(tools.map((tool) => tool.name)),
6024
6373
  );
6025
6374
  this.activeStreams.set(agent.name, handle);
6026
6375
  // Handoff: driveStream captured the token in its synchronous prefix;
@@ -6105,6 +6454,7 @@ export class AgentFramework {
6105
6454
  ownsProviderGate = false,
6106
6455
  takeKvSubmission?: () => { submissionId: string; wireReceipt: CacheWireReceipt } | undefined,
6107
6456
  drainKvSubmissionIds?: () => string[],
6457
+ knownToolNames: ReadonlySet<string> = new Set(),
6108
6458
  ): Promise<void> {
6109
6459
  const startTime = Date.now();
6110
6460
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
@@ -6124,6 +6474,7 @@ export class AgentFramework {
6124
6474
  // means no successor turn can have replaced it while we compiled.
6125
6475
  const myTurnToken = this.activeTurnTokens.get(agent.name);
6126
6476
  let hadToolCalls = false;
6477
+ let generationLost = false;
6127
6478
 
6128
6479
  // ---- Present-while-acting turn state ---------------------------------
6129
6480
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
@@ -6207,13 +6558,20 @@ export class AgentFramework {
6207
6558
  // failed, a framework-cancelled stream sets aborted. Best-effort
6208
6559
  // notifications — consumers dedupe by inferenceId and keep a timeout.
6209
6560
  let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
6561
+ let preserveEventGateForSuccessor = false;
6210
6562
  this.hookOrchestrator?.emitLifecycle({
6211
6563
  inferenceId: outgoingInferenceId,
6212
6564
  conversationId: agent.name,
6213
6565
  turnIndex: 0,
6214
6566
  phase: 'started',
6215
6567
  });
6216
- 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
6217
6575
  ? new ProseStreamRouter({
6218
6576
  mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
6219
6577
  initialTarget: typingChannel,
@@ -6243,6 +6601,14 @@ export class AgentFramework {
6243
6601
 
6244
6602
  try {
6245
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
+ }
6246
6612
  this.touchEphemeralRun(agent.name, true);
6247
6613
  switch (event.type) {
6248
6614
  case 'tokens':
@@ -6310,6 +6676,7 @@ export class AgentFramework {
6310
6676
  case 'tool-calls': {
6311
6677
  adoptInjectedRound();
6312
6678
  hadToolCalls = true;
6679
+ this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
6313
6680
  this.recordEphemeralToolCalls(agent.name, event.calls.length);
6314
6681
  this.emitTrace({
6315
6682
  type: 'inference:tool_calls_yielded',
@@ -6515,7 +6882,30 @@ export class AgentFramework {
6515
6882
  const terminalContent = lastToolIdx >= 0
6516
6883
  ? response.content.slice(lastToolIdx + 1)
6517
6884
  : response.content;
6518
- 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) {
6519
6909
  if (terminalContent.length > 0) {
6520
6910
  agent.addAssistantResponse(terminalContent);
6521
6911
  }
@@ -6541,7 +6931,9 @@ export class AgentFramework {
6541
6931
  // text is speech.
6542
6932
  const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
6543
6933
  block.type === 'text';
6544
- const allText = response.content.filter(isTextBlock);
6934
+ const allText = guardedWrapperTool
6935
+ ? []
6936
+ : response.content.filter(isTextBlock);
6545
6937
 
6546
6938
  const speechContent = hadToolCalls ? [] : allText;
6547
6939
  const thoughts = hadToolCalls ? allText : [];
@@ -6556,19 +6948,30 @@ export class AgentFramework {
6556
6948
  }
6557
6949
  : undefined;
6558
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
+
6559
6960
  // Reset agent state before emitting inference:completed. Traces are
6560
6961
  // observability-only, but external synchronous listeners should
6561
6962
  // still see the terminal state at the terminal trace boundary.
6562
6963
  // Speech dispatch happens after but doesn't depend on the status
6563
6964
  // field.
6564
6965
  agent.reset();
6565
- this.eventGate?.onInferenceEnded(agent.name);
6966
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6566
6967
  this.settleAgent(agent.name, {
6567
6968
  stopReason: 'completed',
6568
- speech: terminalContent
6569
- .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
6570
- .map((block) => block.text)
6571
- .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'),
6572
6975
  });
6573
6976
 
6574
6977
  this.emitTrace({
@@ -6942,7 +7345,7 @@ export class AgentFramework {
6942
7345
 
6943
7346
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
6944
7347
  lifecyclePhase = 'failed';
6945
- this.eventGate?.onInferenceEnded(agent.name);
7348
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6946
7349
  break;
6947
7350
  }
6948
7351
 
@@ -6966,7 +7369,7 @@ export class AgentFramework {
6966
7369
  // itself) means retrying the same context can never succeed.
6967
7370
  ...this.classifyInferenceError(err),
6968
7371
  });
6969
- this.eventGate?.onInferenceEnded(agent.name);
7372
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6970
7373
  if (action.emit) {
6971
7374
  this.pushEvent(action.emit);
6972
7375
  }
@@ -6988,7 +7391,13 @@ export class AgentFramework {
6988
7391
  if (cancelKind !== undefined) {
6989
7392
  this.frameworkCancelledStreams.delete(cancelKey);
6990
7393
  lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
6991
- 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
+ }
6992
7401
  // endTurn IS a logical turn end — earlier rounds may have
6993
7402
  // live-routed prose (narrate → skip_reply is a real shape),
6994
7403
  // so settle the delivery chain and drop the receipt. A
@@ -7035,7 +7444,7 @@ export class AgentFramework {
7035
7444
  request: compiledRequest ?? { note: 'streaming request aborted' },
7036
7445
  durationMs,
7037
7446
  });
7038
- this.eventGate?.onInferenceEnded(agent.name);
7447
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
7039
7448
  }
7040
7449
  break;
7041
7450
  }
@@ -7128,6 +7537,14 @@ export class AgentFramework {
7128
7537
  }
7129
7538
  }
7130
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
+ }
7131
7548
  // Stream itself threw. Organization acceleration is deferred from a
7132
7549
  // fresh compile; every other throw keeps the ordinary exhausted path.
7133
7550
  const err = error instanceof Error ? error : new Error(String(error));
@@ -7138,7 +7555,7 @@ export class AgentFramework {
7138
7555
  error: `Provider acceleration cooldown: ${err.message}`,
7139
7556
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
7140
7557
  this.abortAgentScript(agent.name, 'provider acceleration cooldown');
7141
- 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);
7142
7559
  return;
7143
7560
  }
7144
7561
  this.emitTrace({
@@ -7173,7 +7590,7 @@ export class AgentFramework {
7173
7590
  this.abortAgentScript(agent.name, 'stream threw');
7174
7591
  lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
7175
7592
  agent.reset();
7176
- this.eventGate?.onInferenceEnded(agent.name);
7593
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
7177
7594
  } finally {
7178
7595
  const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
7179
7596
  if (unsettledKvSubmissions.length > 0) {
@@ -7186,6 +7603,22 @@ export class AgentFramework {
7186
7603
  }
7187
7604
  } catch { /* receipt cleanup must not mask inference teardown */ }
7188
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;
7189
7622
  // §10.5: exactly one terminal per `started`, on every exit path the
7190
7623
  // host controls. Which one was decided by the path taken (default
7191
7624
  // completed; catch → failed; framework-cancel → aborted). A host
@@ -7204,30 +7637,36 @@ export class AgentFramework {
7204
7637
  // all incoming events → the agent silently stops waking on messages
7205
7638
  // (typing still stops, compression still runs — matching the observed
7206
7639
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
7207
- this.eventGate?.onInferenceEnded(agent.name);
7208
- 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
+ }
7209
7646
  if (ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
7210
7647
 
7211
7648
  // Stop the typing indicator on every exit path (complete, error,
7212
7649
  // exhausted, abort) so it never sticks after the turn ends.
7213
- this.channelRegistry?.stopTyping();
7650
+ if (frameReachedTerminal) this.channelRegistry?.stopTyping();
7214
7651
 
7215
7652
  // Spec 14.3: flush any held line-start text, then close each streamed
7216
7653
  // channel with its final moderated content — the consumer's signal to
7217
7654
  // finalize (end the TTS utterance, settle the rendered message).
7218
- if (proseStream) {
7655
+ if (frameReachedTerminal && proseStream) {
7219
7656
  emitOutgoing(proseStream.finish());
7220
7657
  for (const [channelId, text] of proseStream.byChannel()) {
7221
7658
  this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
7222
7659
  }
7223
7660
  }
7224
7661
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
7225
- this.activeStreams.delete(agent.name);
7226
- this.pendingAssistantBlocks.delete(agent.name);
7662
+ if (ownsPhysicalStream) {
7663
+ this.activeStreams.delete(agent.name);
7664
+ this.pendingAssistantBlocks.delete(agent.name);
7665
+ }
7227
7666
 
7228
7667
  // A conversation fork whose TTL closure turn just finished is done for
7229
7668
  // good — dispose it so the agent map doesn't grow monotonically.
7230
- if (this.closingConversationAgents.has(agent.name)) {
7669
+ if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
7231
7670
  this.disposeConversationAgent(agent.name);
7232
7671
  }
7233
7672
 
@@ -7243,11 +7682,15 @@ export class AgentFramework {
7243
7682
  this.activeTurnTriggers.delete(agent.name);
7244
7683
  }
7245
7684
 
7246
- // Flush any deferred messages (e.g. if stream failed while tools were pending)
7247
- if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
7248
- 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);
7249
7691
  for (const msg of deferred) {
7250
- this.addMessage(msg.participant, msg.content, msg.metadata);
7692
+ this.addMessage(msg.participant, msg.content, msg.metadata,
7693
+ msg.forAgent ? { forAgent: msg.forAgent } : undefined);
7251
7694
  }
7252
7695
  }
7253
7696
  }
@@ -8348,6 +8791,30 @@ export class AgentFramework {
8348
8791
  return;
8349
8792
  }
8350
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
+
8351
8818
  // Route sleep / wake tools
8352
8819
  if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
8353
8820
  this.dispatchSleepToolCall(agentName, enrichedCall);
@@ -8492,13 +8959,32 @@ export class AgentFramework {
8492
8959
  private addMessage(
8493
8960
  participant: string,
8494
8961
  content: ContentBlock[],
8495
- 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
+ }
8496
8973
  ): MessageId {
8497
- // Route to the primary agent's context manager (not ephemeral subagents).
8498
- const agent = this.primaryAgentName
8499
- ? this.agents.get(this.primaryAgentName)
8500
- : 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;
8501
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
+ }
8502
8988
  throw new Error('No agents configured');
8503
8989
  }
8504
8990
 
@@ -8519,19 +9005,48 @@ export class AgentFramework {
8519
9005
  // ALSO injected into the live stream — hear-while-acting), or in
8520
9006
  // driveStream's finally when the turn ends.
8521
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;
8522
9015
  if (
8523
9016
  !hasToolResult &&
8524
- (this.pendingAssistantBlocks.size > 0 ||
9017
+ (midToolCycle ||
8525
9018
  this.activeTurnTokens.has(agent.name) ||
8526
9019
  this.activeStreams.has(agent.name))
8527
9020
  ) {
8528
- this.deferredMessages.push({ participant, content, metadata });
8529
- 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
8530
9023
  }
8531
9024
 
8532
9025
  return agent.getContextManager().addMessage(participant, content, metadata);
8533
9026
  }
8534
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
+
8535
9050
  private editMessage(id: MessageId, content: ContentBlock[]): void {
8536
9051
  const agent = this.primaryAgentName
8537
9052
  ? this.agents.get(this.primaryAgentName)
@@ -10922,6 +11437,53 @@ export class AgentFramework {
10922
11437
  * suppression window, optionally announces in the sticky channel, and ends
10923
11438
  * the turn (the agent goes idle immediately). `wake` clears sleep.
10924
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
+
10925
11487
  private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
10926
11488
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
10927
11489
  const gate = this.eventGate!;