@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
@@ -4,7 +4,9 @@ import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } fro
4
4
  import { appendFileSync, mkdirSync } from 'node:fs';
5
5
  import { JsStore } from '@animalabs/chronicle';
6
6
  import { MembraneError } from '@animalabs/membrane';
7
- 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';
8
10
  import { ProcessQueueImpl } from './queue.js';
9
11
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
10
12
  import { Agent } from './agent.js';
@@ -17,6 +19,7 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
17
19
  import { PushHandler } from './mcpl/push-handler.js';
18
20
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
19
21
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
22
+ import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
20
23
  import { InferenceRouter } from './mcpl/inference-router.js';
21
24
  import { ChannelRegistry } from './mcpl/channel-registry.js';
22
25
  import { ConversationRouter } from './mcpl/conversation-router.js';
@@ -522,6 +525,16 @@ export class AgentFramework {
522
525
  frameworkCancelledStreams = new Map();
523
526
  /** Active runEphemeralToCompletion runs, keyed by agent name. */
524
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();
525
538
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
526
539
  * Drives hard-down escalation — see noteInferenceExhausted. */
527
540
  consecutiveInferenceFailures = new Map();
@@ -569,6 +582,14 @@ export class AgentFramework {
569
582
  primaryAgentName = null;
570
583
  // Messages deferred while an agent is waiting_for_tools (to preserve
571
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;
572
593
  deferredMessages = [];
573
594
  // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
574
595
  // token of the turn currently in progress. Set at startAgentStream ENTRY —
@@ -599,6 +620,9 @@ export class AgentFramework {
599
620
  getActiveTurnTrigger(agentName) {
600
621
  return this.activeTurnTriggers.get(agentName);
601
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();
602
626
  nextTurnToken = 1;
603
627
  // Undo/redo state
604
628
  turnCounters = new Map(); // agentName → next turnIndex
@@ -810,6 +834,12 @@ export class AgentFramework {
810
834
  for (const agentConfig of config.agents) {
811
835
  await framework.createAgent(agentConfig);
812
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
+ }
813
843
  // Finish any branch-local suppression interrupted after Chronicle switched
814
844
  // branches. This runs before modules, MCPL connections, or inbound traffic.
815
845
  await framework.resumePreparedDiscordSuppressions();
@@ -854,11 +884,11 @@ export class AgentFramework {
854
884
  initialConfig: config.gate.config,
855
885
  privilegedUsersPath: config.gate.privilegedUsersPath,
856
886
  emitTrace: (e) => framework.emitTrace(e),
857
- addMessage: (p, c, m) => framework.addMessage(p, c, m),
887
+ addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m, forAgent ? { forAgent } : undefined),
858
888
  requestInference: (agentName, reason, source) => {
859
889
  framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
860
890
  },
861
- getAgentNames: () => [...framework.agents.keys()],
891
+ getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
862
892
  });
863
893
  }
864
894
  // Liveness watchdog: fail hard if the main thread wedges (opt-in).
@@ -909,6 +939,30 @@ export class AgentFramework {
909
939
  }
910
940
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
911
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
+ }
912
966
  // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
913
967
  // (journal) without a restart — for catching the wake-wedge on the running
914
968
  // process. Shows the gate's `inferring` set + buffered-event count (the
@@ -973,6 +1027,7 @@ export class AgentFramework {
973
1027
  this.running = false;
974
1028
  this.providerAdmissionClosed = true;
975
1029
  this.queue.close();
1030
+ this.tuneOutCoordinator?.stop();
976
1031
  // Kill running code_execution scripts before cancelling streams: a
977
1032
  // zombie script must not keep firing side-effectful tool calls into a
978
1033
  // framework that is shutting down.
@@ -1404,7 +1459,12 @@ export class AgentFramework {
1404
1459
  */
1405
1460
  getAllTools() {
1406
1461
  const moduleTools = this.moduleRegistry.getAllTools();
1407
- 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
+ }
1408
1468
  const gateTools = this.eventGate
1409
1469
  ? [
1410
1470
  this.eventGate.getToolDefinition(),
@@ -1634,6 +1694,18 @@ export class AgentFramework {
1634
1694
  };
1635
1695
  }
1636
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
+ }
1637
1709
  return this.getAllTools().map((tool) => {
1638
1710
  if (tool.name === 'think') {
1639
1711
  return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
@@ -1989,21 +2061,35 @@ export class AgentFramework {
1989
2061
  * Call cleanup() when done to release the ContextManager.
1990
2062
  */
1991
2063
  async createEphemeralAgent(config) {
1992
- const namespace = `subagent/${config.name}`;
1993
- const contextManager = await ContextManager.open({
1994
- store: this.store,
1995
- namespace,
1996
- isolate: true,
1997
- strategy: config.strategy ?? new PassthroughStrategy(),
1998
- membrane: this.membrane,
1999
- debugLogContext: !!process.env.DEBUG_CONTEXT,
2000
- });
2001
- const agent = new Agent(config, contextManager, this.membrane);
2002
- const cleanup = () => {
2003
- // Don't close the store — it's shared. Just release the CM.
2004
- // Data persists in the store under the namespace for investigation.
2005
- };
2006
- 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
+ }
2007
2093
  }
2008
2094
  createDeferred() {
2009
2095
  let resolve;
@@ -2024,6 +2110,18 @@ export class AgentFramework {
2024
2110
  }
2025
2111
  run.lastActivity = Date.now();
2026
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
+ }
2027
2125
  recordEphemeralToolCalls(agentName, count) {
2028
2126
  const run = this.ephemeralRuns.get(agentName);
2029
2127
  if (!run)
@@ -2060,7 +2158,18 @@ export class AgentFramework {
2060
2158
  * The task message should already be in the context manager.
2061
2159
  */
2062
2160
  async runEphemeralToCompletion(agent, contextManager, watchdogs) {
2063
- // 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
+ }
2064
2173
  this.agents.set(agent.name, agent);
2065
2174
  const run = {
2066
2175
  settle: this.createDeferred(),
@@ -2119,8 +2228,26 @@ export class AgentFramework {
2119
2228
  clearTimeout(startupWatchdog);
2120
2229
  if (completionWatchdog)
2121
2230
  clearInterval(completionWatchdog);
2122
- this.ephemeralRuns.delete(agent.name);
2123
- 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
+ }
2124
2251
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2125
2252
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2126
2253
  // entries behind for the life of the store/session.
@@ -2132,6 +2259,7 @@ export class AgentFramework {
2132
2259
  // of the map, and a late driveStream finally token-match no-ops.
2133
2260
  this.activeTurnTokens.delete(agent.name);
2134
2261
  this.activeTurnTriggers.delete(agent.name);
2262
+ this.logicalTurnToolCalls.delete(agent);
2135
2263
  }
2136
2264
  }
2137
2265
  /**
@@ -2144,6 +2272,44 @@ export class AgentFramework {
2144
2272
  * Query inference logs.
2145
2273
  * Returns entries with summary info (doesn't resolve blobs).
2146
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
+ };
2147
2313
  /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
2148
2314
  static SLEEP_TOOLS = [
2149
2315
  {
@@ -3395,6 +3561,15 @@ export class AgentFramework {
3395
3561
  // streaming agent keeps the original Membrane.
3396
3562
  membrane: this.auxiliaryMembraneFor(config.name),
3397
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,
3398
3573
  });
3399
3574
  const agent = new Agent(config, contextManager, this.membrane);
3400
3575
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
@@ -3409,6 +3584,68 @@ export class AgentFramework {
3409
3584
  }
3410
3585
  return agent;
3411
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
+ }
3412
3649
  async runLoop() {
3413
3650
  while (this.running) {
3414
3651
  try {
@@ -3533,12 +3770,11 @@ export class AgentFramework {
3533
3770
  // queued, they flush at the target's own next boundary — with
3534
3771
  // injection — or in driveStream's finally when its turn ends.
3535
3772
  const midTurnInjections = [];
3536
- if (this.deferredMessages.length > 0) {
3537
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
3538
- if (target === agent) {
3539
- const deferred = this.deferredMessages.splice(0);
3773
+ {
3774
+ const deferred = this.drainDeferredFor(agent.name);
3775
+ if (deferred.length > 0) {
3540
3776
  for (const msg of deferred) {
3541
- target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
3777
+ agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
3542
3778
  // Injection guards: tool blocks would corrupt the tool-cycle
3543
3779
  // structure the membrane enforces, and a message named as the
3544
3780
  // agent itself would render as an ASSISTANT turn on the wire
@@ -3835,7 +4071,7 @@ export class AgentFramework {
3835
4071
  // Broadcast requests exclude conversation forks — they are driven by
3836
4072
  // their own channel's messages, not by framework-wide events.
3837
4073
  const targetAgents = response.requestInference === true
3838
- ? 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)
3839
4075
  : response.requestInference;
3840
4076
  for (const agentName of targetAgents) {
3841
4077
  const agent = this.agents.get(agentName);
@@ -3902,11 +4138,31 @@ export class AgentFramework {
3902
4138
  Object.assign(metadata, invitation.metadataPatch);
3903
4139
  }
3904
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
+ }
3905
4152
  const id = this.addMessage('user', incomingContent, metadata);
3906
4153
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3907
- if (event.triggerInference) {
4154
+ if (event.triggerInference && !divert) {
3908
4155
  const addressed = isAddressedMessage(event.tags, event.metadata);
3909
- 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;
3910
4166
  this.pendingRequests.push({
3911
4167
  agentName,
3912
4168
  reason: 'mcpl:channel-incoming',
@@ -4019,38 +4275,48 @@ export class AgentFramework {
4019
4275
  if (!template || !templateConfig) {
4020
4276
  throw new Error(`conversation template agent "${router.templateAgent}" not found`);
4021
4277
  }
4022
- const contextManager = await ContextManager.open({
4023
- store: this.store,
4024
- namespace: `conversations/${name}`,
4025
- isolate: true,
4026
- // Strategy instances are stateful — never share the template's.
4027
- strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
4028
- // Dynamic conversation forks retain the established provider policy in
4029
- // this bounded first slice. Provider cooldown ownership belongs to the
4030
- // persistent resident only; generation-unique forks must not leak gates.
4031
- membrane: this.membrane,
4032
- debugLogContext: !!process.env.DEBUG_CONTEXT,
4033
- });
4034
- // Seed with the template's compiled context, renaming the template
4035
- // participant so the fork reads its inheritance as its own history.
4036
- // Guard: only seed a genuinely fresh namespace. Generation counters are
4037
- // persisted precisely so names aren't reused, but if this namespace has
4038
- // history anyway (counter state lost, crash between spawn and persist),
4039
- // seeding again would stack another template copy on top of it.
4040
- const { messages: existing } = await contextManager.compile();
4041
- if (existing.length === 0) {
4042
- const { messages: compiled } = await template.getContextManager().compile();
4043
- for (const msg of compiled) {
4044
- const participant = msg.participant === template.name ? name : msg.participant;
4045
- contextManager.addMessage(participant, msg.content);
4046
- }
4047
- }
4048
- const config = { ...templateConfig, name, strategy: undefined };
4049
- const agent = new Agent(config, contextManager, this.membrane);
4050
- this.agents.set(name, agent);
4051
- this.agentConfigs.set(name, config);
4052
- this.conversationAgentHomes.set(name, channelId);
4053
- 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
+ }
4054
4320
  }
4055
4321
  /** Persist the router's generation counters (see hydration in create()). */
4056
4322
  persistConversationRouterState() {
@@ -4074,10 +4340,13 @@ export class AgentFramework {
4074
4340
  disposeConversationAgent(agentName) {
4075
4341
  this.closingConversationAgents.delete(agentName);
4076
4342
  const channelId = this.conversationAgentHomes.get(agentName);
4343
+ const agent = this.agents.get(agentName);
4077
4344
  this.agents.delete(agentName);
4078
4345
  this.agentConfigs.delete(agentName);
4079
4346
  this.conversationAgentHomes.delete(agentName);
4080
4347
  this.evictTurnCheckpoints(agentName);
4348
+ if (agent)
4349
+ this.logicalTurnToolCalls.delete(agent);
4081
4350
  this.emitTrace({
4082
4351
  type: 'mcpl:conversation-disposed',
4083
4352
  agentName,
@@ -4277,7 +4546,7 @@ export class AgentFramework {
4277
4546
  if (event.triggerInference) {
4278
4547
  // Default broadcast excludes conversation forks (channel-driven).
4279
4548
  const targetAgents = event.targetAgents
4280
- ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
4549
+ ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
4281
4550
  for (const agentName of targetAgents) {
4282
4551
  this.pendingRequests.push({
4283
4552
  agentName,
@@ -4459,6 +4728,10 @@ export class AgentFramework {
4459
4728
  // exactly this class of silent drop. One stderr line per drop.
4460
4729
  console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
4461
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);
4462
4735
  const gate = this.providerGates.get(agentName);
4463
4736
  if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
4464
4737
  gate.primaryPending = false;
@@ -5012,9 +5285,8 @@ export class AgentFramework {
5012
5285
  if (attempt === 0 &&
5013
5286
  trigger?.reason !== 'context_budget_restart' &&
5014
5287
  this.deferredMessages.length > 0) {
5015
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
5016
- if (target === agent) {
5017
- const deferred = this.deferredMessages.splice(0);
5288
+ {
5289
+ const deferred = this.drainDeferredFor(agent.name);
5018
5290
  for (const msg of deferred) {
5019
5291
  // Per-message try/catch (mirrors announceLocusIfChanged): one
5020
5292
  // poison message — or a transient store-write failure — must not
@@ -5068,9 +5340,22 @@ export class AgentFramework {
5068
5340
  // BEFORE this turn compiles, so the agent always knows where its voice
5069
5341
  // goes (announce-on-change only — no per-turn chatter, append-only for
5070
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
+ }
5071
5347
  if (trigger?.reason !== 'context_budget_restart') {
5072
5348
  if (attempt === 0)
5073
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
+ }
5074
5359
  // Fresh turn: forget the previous turn's explicit-send engagements and
5075
5360
  // prose deliveries — both are strictly turn-scoped (a restart continues
5076
5361
  // the same logical turn and keeps them).
@@ -5108,7 +5393,11 @@ export class AgentFramework {
5108
5393
  // context-budget restart, which skips the re-pin but re-emits this).
5109
5394
  channelId: this.turnLocusPins.get(agent.name),
5110
5395
  });
5111
- 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
+ }
5112
5401
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
5113
5402
  // Typing indicator, started at TURN START. It says "attending", which is
5114
5403
  // true from the moment the turn is accepted — but it used to start inside
@@ -5162,8 +5451,32 @@ export class AgentFramework {
5162
5451
  console.error('beforeInference hook error:', error);
5163
5452
  }
5164
5453
  }
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
+ }
5165
5471
  const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections);
5166
- const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate, takeKvSubmission, drainKvSubmissionIds);
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)));
5167
5480
  this.activeStreams.set(agent.name, handle);
5168
5481
  // Handoff: driveStream captured the token in its synchronous prefix;
5169
5482
  // its finally now owns clearing it. The caller's finally must not.
@@ -5236,7 +5549,7 @@ export class AgentFramework {
5236
5549
  return false;
5237
5550
  }
5238
5551
  }
5239
- async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds) {
5552
+ async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
5240
5553
  const startTime = Date.now();
5241
5554
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
5242
5555
  const myStreamId = agent.streamId;
@@ -5255,6 +5568,7 @@ export class AgentFramework {
5255
5568
  // means no successor turn can have replaced it while we compiled.
5256
5569
  const myTurnToken = this.activeTurnTokens.get(agent.name);
5257
5570
  let hadToolCalls = false;
5571
+ let generationLost = false;
5258
5572
  // ---- Present-while-acting turn state ---------------------------------
5259
5573
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
5260
5574
  // (home → addressed trigger → global default) before this stream began,
@@ -5329,13 +5643,20 @@ export class AgentFramework {
5329
5643
  // failed, a framework-cancelled stream sets aborted. Best-effort
5330
5644
  // notifications — consumers dedupe by inferenceId and keep a timeout.
5331
5645
  let lifecyclePhase = 'completed';
5646
+ let preserveEventGateForSuccessor = false;
5332
5647
  this.hookOrchestrator?.emitLifecycle({
5333
5648
  inferenceId: outgoingInferenceId,
5334
5649
  conversationId: agent.name,
5335
5650
  turnIndex: 0,
5336
5651
  phase: 'started',
5337
5652
  });
5338
- 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
5339
5660
  ? new ProseStreamRouter({
5340
5661
  mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
5341
5662
  initialTarget: typingChannel,
@@ -5361,6 +5682,14 @@ export class AgentFramework {
5361
5682
  };
5362
5683
  try {
5363
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
+ }
5364
5693
  this.touchEphemeralRun(agent.name, true);
5365
5694
  switch (event.type) {
5366
5695
  case 'tokens':
@@ -5423,6 +5752,7 @@ export class AgentFramework {
5423
5752
  case 'tool-calls': {
5424
5753
  adoptInjectedRound();
5425
5754
  hadToolCalls = true;
5755
+ this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
5426
5756
  this.recordEphemeralToolCalls(agent.name, event.calls.length);
5427
5757
  this.emitTrace({
5428
5758
  type: 'inference:tool_calls_yielded',
@@ -5604,7 +5934,29 @@ export class AgentFramework {
5604
5934
  const terminalContent = lastToolIdx >= 0
5605
5935
  ? response.content.slice(lastToolIdx + 1)
5606
5936
  : response.content;
5607
- 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) {
5608
5960
  if (terminalContent.length > 0) {
5609
5961
  agent.addAssistantResponse(terminalContent);
5610
5962
  }
@@ -5627,7 +5979,9 @@ export class AgentFramework {
5627
5979
  // convention remains "text is thoughts"; on a text-only turn all
5628
5980
  // text is speech.
5629
5981
  const isTextBlock = (block) => block.type === 'text';
5630
- const allText = response.content.filter(isTextBlock);
5982
+ const allText = guardedWrapperTool
5983
+ ? []
5984
+ : response.content.filter(isTextBlock);
5631
5985
  const speechContent = hadToolCalls ? [] : allText;
5632
5986
  const thoughts = hadToolCalls ? allText : [];
5633
5987
  const du = response.details?.usage;
@@ -5639,19 +5993,30 @@ export class AgentFramework {
5639
5993
  cacheRead: du.cacheReadTokens,
5640
5994
  }
5641
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
+ }
5642
6004
  // Reset agent state before emitting inference:completed. Traces are
5643
6005
  // observability-only, but external synchronous listeners should
5644
6006
  // still see the terminal state at the terminal trace boundary.
5645
6007
  // Speech dispatch happens after but doesn't depend on the status
5646
6008
  // field.
5647
6009
  agent.reset();
5648
- this.eventGate?.onInferenceEnded(agent.name);
6010
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6011
+ this.eventGate?.onInferenceEnded(agent.name);
5649
6012
  this.settleAgent(agent.name, {
5650
6013
  stopReason: 'completed',
5651
- speech: terminalContent
5652
- .filter((block) => block.type === 'text')
5653
- .map((block) => block.text)
5654
- .join('\n'),
6014
+ speech: guardedWrapperTool
6015
+ ? ''
6016
+ : terminalContent
6017
+ .filter((block) => block.type === 'text')
6018
+ .map((block) => block.text)
6019
+ .join('\n'),
5655
6020
  });
5656
6021
  this.emitTrace({
5657
6022
  type: 'inference:completed',
@@ -5992,7 +6357,8 @@ export class AgentFramework {
5992
6357
  agent.reset();
5993
6358
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
5994
6359
  lifecyclePhase = 'failed';
5995
- this.eventGate?.onInferenceEnded(agent.name);
6360
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6361
+ this.eventGate?.onInferenceEnded(agent.name);
5996
6362
  break;
5997
6363
  }
5998
6364
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -6016,7 +6382,8 @@ export class AgentFramework {
6016
6382
  // itself) means retrying the same context can never succeed.
6017
6383
  ...this.classifyInferenceError(err),
6018
6384
  });
6019
- this.eventGate?.onInferenceEnded(agent.name);
6385
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6386
+ this.eventGate?.onInferenceEnded(agent.name);
6020
6387
  if (action.emit) {
6021
6388
  this.pushEvent(action.emit);
6022
6389
  }
@@ -6037,7 +6404,14 @@ export class AgentFramework {
6037
6404
  if (cancelKind !== undefined) {
6038
6405
  this.frameworkCancelledStreams.delete(cancelKey);
6039
6406
  lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
6040
- 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
+ }
6041
6415
  // endTurn IS a logical turn end — earlier rounds may have
6042
6416
  // live-routed prose (narrate → skip_reply is a real shape),
6043
6417
  // so settle the delivery chain and drop the receipt. A
@@ -6084,7 +6458,8 @@ export class AgentFramework {
6084
6458
  request: compiledRequest ?? { note: 'streaming request aborted' },
6085
6459
  durationMs,
6086
6460
  });
6087
- this.eventGate?.onInferenceEnded(agent.name);
6461
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6462
+ this.eventGate?.onInferenceEnded(agent.name);
6088
6463
  }
6089
6464
  break;
6090
6465
  }
@@ -6160,6 +6535,14 @@ export class AgentFramework {
6160
6535
  }
6161
6536
  }
6162
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
+ }
6163
6546
  // Stream itself threw. Organization acceleration is deferred from a
6164
6547
  // fresh compile; every other throw keeps the ordinary exhausted path.
6165
6548
  const err = error instanceof Error ? error : new Error(String(error));
@@ -6172,7 +6555,8 @@ export class AgentFramework {
6172
6555
  this.abortAgentScript(agent.name, 'provider acceleration cooldown');
6173
6556
  lifecyclePhase = 'failed';
6174
6557
  agent.reset();
6175
- this.eventGate?.onInferenceEnded(agent.name);
6558
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6559
+ this.eventGate?.onInferenceEnded(agent.name);
6176
6560
  return;
6177
6561
  }
6178
6562
  this.emitTrace({
@@ -6207,7 +6591,8 @@ export class AgentFramework {
6207
6591
  this.abortAgentScript(agent.name, 'stream threw');
6208
6592
  lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
6209
6593
  agent.reset();
6210
- this.eventGate?.onInferenceEnded(agent.name);
6594
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6595
+ this.eventGate?.onInferenceEnded(agent.name);
6211
6596
  }
6212
6597
  finally {
6213
6598
  const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
@@ -6220,6 +6605,19 @@ export class AgentFramework {
6220
6605
  }
6221
6606
  catch { /* receipt cleanup must not mask inference teardown */ }
6222
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;
6223
6621
  // §10.5: exactly one terminal per `started`, on every exit path the
6224
6622
  // host controls. Which one was decided by the path taken (default
6225
6623
  // completed; catch → failed; framework-cancel → aborted). A host
@@ -6238,28 +6636,35 @@ export class AgentFramework {
6238
6636
  // all incoming events → the agent silently stops waking on messages
6239
6637
  // (typing still stops, compression still runs — matching the observed
6240
6638
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
6241
- this.eventGate?.onInferenceEnded(agent.name);
6242
- 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
+ }
6243
6645
  if (ownsProviderGate)
6244
6646
  this.releasePrimaryProviderGate(agent.name);
6245
6647
  // Stop the typing indicator on every exit path (complete, error,
6246
6648
  // exhausted, abort) so it never sticks after the turn ends.
6247
- this.channelRegistry?.stopTyping();
6649
+ if (frameReachedTerminal)
6650
+ this.channelRegistry?.stopTyping();
6248
6651
  // Spec 14.3: flush any held line-start text, then close each streamed
6249
6652
  // channel with its final moderated content — the consumer's signal to
6250
6653
  // finalize (end the TTS utterance, settle the rendered message).
6251
- if (proseStream) {
6654
+ if (frameReachedTerminal && proseStream) {
6252
6655
  emitOutgoing(proseStream.finish());
6253
6656
  for (const [channelId, text] of proseStream.byChannel()) {
6254
6657
  this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
6255
6658
  }
6256
6659
  }
6257
6660
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
6258
- this.activeStreams.delete(agent.name);
6259
- this.pendingAssistantBlocks.delete(agent.name);
6661
+ if (ownsPhysicalStream) {
6662
+ this.activeStreams.delete(agent.name);
6663
+ this.pendingAssistantBlocks.delete(agent.name);
6664
+ }
6260
6665
  // A conversation fork whose TTL closure turn just finished is done for
6261
6666
  // good — dispose it so the agent map doesn't grow monotonically.
6262
- if (this.closingConversationAgents.has(agent.name)) {
6667
+ if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
6263
6668
  this.disposeConversationAgent(agent.name);
6264
6669
  }
6265
6670
  // The turn is torn down — release the turn-alive marker BEFORE the
@@ -6273,11 +6678,14 @@ export class AgentFramework {
6273
6678
  this.activeTurnTokens.delete(agent.name);
6274
6679
  this.activeTurnTriggers.delete(agent.name);
6275
6680
  }
6276
- // Flush any deferred messages (e.g. if stream failed while tools were pending)
6277
- if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
6278
- const deferred = this.deferredMessages.splice(0);
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);
6279
6687
  for (const msg of deferred) {
6280
- this.addMessage(msg.participant, msg.content, msg.metadata);
6688
+ this.addMessage(msg.participant, msg.content, msg.metadata, msg.forAgent ? { forAgent: msg.forAgent } : undefined);
6281
6689
  }
6282
6690
  }
6283
6691
  }
@@ -7255,6 +7663,27 @@ export class AgentFramework {
7255
7663
  this.dispatchGateToolCall(agentName, enrichedCall);
7256
7664
  return;
7257
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
+ }
7258
7687
  // Route sleep / wake tools
7259
7688
  if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
7260
7689
  this.dispatchSleepToolCall(agentName, enrichedCall);
@@ -7383,12 +7812,19 @@ export class AgentFramework {
7383
7812
  });
7384
7813
  });
7385
7814
  }
7386
- addMessage(participant, content, metadata) {
7387
- // Route to the primary agent's context manager (not ephemeral subagents).
7388
- const agent = this.primaryAgentName
7389
- ? this.agents.get(this.primaryAgentName)
7390
- : 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;
7391
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
+ }
7392
7828
  throw new Error('No agents configured');
7393
7829
  }
7394
7830
  // Defer non-tool_result messages while a tool cycle is mid-flight
@@ -7408,15 +7844,39 @@ export class AgentFramework {
7408
7844
  // ALSO injected into the live stream — hear-while-acting), or in
7409
7845
  // driveStream's finally when the turn ends.
7410
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;
7411
7854
  if (!hasToolResult &&
7412
- (this.pendingAssistantBlocks.size > 0 ||
7855
+ (midToolCycle ||
7413
7856
  this.activeTurnTokens.has(agent.name) ||
7414
7857
  this.activeStreams.has(agent.name))) {
7415
- this.deferredMessages.push({ participant, content, metadata });
7416
- 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
7417
7860
  }
7418
7861
  return agent.getContextManager().addMessage(participant, content, metadata);
7419
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
+ }
7420
7880
  editMessage(id, content) {
7421
7881
  const agent = this.primaryAgentName
7422
7882
  ? this.agents.get(this.primaryAgentName)
@@ -9400,6 +9860,50 @@ export class AgentFramework {
9400
9860
  * suppression window, optionally announces in the sticky channel, and ends
9401
9861
  * the turn (the agent goes idle immediately). `wake` clears sleep.
9402
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
+ }
9403
9907
  dispatchSleepToolCall(agentName, call) {
9404
9908
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
9405
9909
  const gate = this.eventGate;