@animalabs/agent-framework 0.12.0 → 0.14.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 (106) 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 +76 -4
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +933 -151
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +62 -2
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +56 -2
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/index.d.ts +1 -1
  14. package/dist/src/gate/index.d.ts.map +1 -1
  15. package/dist/src/gate/index.js +1 -1
  16. package/dist/src/gate/index.js.map +1 -1
  17. package/dist/src/index.d.ts +1 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/mcpl/channel-registry.d.ts +84 -1
  21. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  22. package/dist/src/mcpl/channel-registry.js +160 -4
  23. package/dist/src/mcpl/channel-registry.js.map +1 -1
  24. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  25. package/dist/src/modules/workspace/index.js +5 -1
  26. package/dist/src/modules/workspace/index.js.map +1 -1
  27. package/dist/src/tool-image-ledger.d.ts +175 -0
  28. package/dist/src/tool-image-ledger.d.ts.map +1 -0
  29. package/dist/src/tool-image-ledger.js +257 -0
  30. package/dist/src/tool-image-ledger.js.map +1 -0
  31. package/dist/src/tool-result-history.d.ts +15 -2
  32. package/dist/src/tool-result-history.d.ts.map +1 -1
  33. package/dist/src/tool-result-history.js +16 -9
  34. package/dist/src/tool-result-history.js.map +1 -1
  35. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  36. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  37. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  38. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  39. package/dist/src/tune-out/coordinator.d.ts +144 -0
  40. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  41. package/dist/src/tune-out/coordinator.js +444 -0
  42. package/dist/src/tune-out/coordinator.js.map +1 -0
  43. package/dist/src/tune-out/tools.d.ts +43 -0
  44. package/dist/src/tune-out/tools.d.ts.map +1 -0
  45. package/dist/src/tune-out/tools.js +93 -0
  46. package/dist/src/tune-out/tools.js.map +1 -0
  47. package/dist/src/types/agent.d.ts +17 -0
  48. package/dist/src/types/agent.d.ts.map +1 -1
  49. package/dist/src/types/framework.d.ts +6 -0
  50. package/dist/src/types/framework.d.ts.map +1 -1
  51. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  52. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  53. package/dist/test/channel-incoming-targeting.test.js +81 -0
  54. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  55. package/dist/test/event-gate.test.js +84 -1
  56. package/dist/test/event-gate.test.js.map +1 -1
  57. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  58. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  59. package/dist/test/per-agent-delivery.test.js +108 -0
  60. package/dist/test/per-agent-delivery.test.js.map +1 -0
  61. package/dist/test/puppet-tool-call.test.js +1 -0
  62. package/dist/test/puppet-tool-call.test.js.map +1 -1
  63. package/dist/test/save-recent-image.test.d.ts +26 -0
  64. package/dist/test/save-recent-image.test.d.ts.map +1 -0
  65. package/dist/test/save-recent-image.test.js +707 -0
  66. package/dist/test/save-recent-image.test.js.map +1 -0
  67. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  68. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  69. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  70. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  71. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  72. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  73. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  74. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  75. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  76. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  77. package/dist/test/tune-out-e2e.test.js +142 -0
  78. package/dist/test/tune-out-e2e.test.js.map +1 -0
  79. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  80. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  81. package/dist/test/tune-out-gate-composition.test.js +221 -0
  82. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  83. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  84. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  85. package/dist/test/tune-out-lifecycle.test.js +121 -0
  86. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  87. package/dist/test/turn-trigger-provenance.test.js +17 -0
  88. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  89. package/dist/test/workspace-read-image.test.js +8 -0
  90. package/dist/test/workspace-read-image.test.js.map +1 -1
  91. package/dist/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +2 -2
  93. package/src/agent.ts +3 -0
  94. package/src/framework.ts +1051 -154
  95. package/src/gate/event-gate.ts +99 -6
  96. package/src/gate/index.ts +1 -1
  97. package/src/index.ts +1 -1
  98. package/src/mcpl/channel-registry.ts +235 -7
  99. package/src/modules/workspace/index.ts +5 -1
  100. package/src/tool-image-ledger.ts +334 -0
  101. package/src/tool-result-history.ts +30 -9
  102. package/src/tool-wrapper-prose-guard.ts +38 -0
  103. package/src/tune-out/coordinator.ts +548 -0
  104. package/src/tune-out/tools.ts +129 -0
  105. package/src/types/agent.ts +18 -0
  106. 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';
@@ -77,6 +80,15 @@ import {
77
80
  truncateForHistory,
78
81
  DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
79
82
  } from './tool-result-history.js';
83
+ import {
84
+ TOOL_IMAGE_REF_RE,
85
+ ToolImageLedger,
86
+ formatPreservedImageSlots,
87
+ parseImagePlaceholders,
88
+ sha256Hex,
89
+ splitPreservingImageSlots,
90
+ type ParsedImagePlaceholder,
91
+ } from './tool-image-ledger.js';
80
92
  import { randomUUID } from 'node:crypto';
81
93
  import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
82
94
  import {
@@ -788,6 +800,16 @@ export class AgentFramework {
788
800
  private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
789
801
  /** Active runEphemeralToCompletion runs, keyed by agent name. */
790
802
  private ephemeralRuns: Map<string, EphemeralRun> = new Map();
803
+ /** Ephemeral namespaces/names are single-generation for this framework
804
+ * lifetime. Reusing one while an old stream drains would let name-keyed
805
+ * liveness/settlement state cross generations. */
806
+ private usedEphemeralAgentNames: Set<string> = new Set();
807
+ /** One-shot generation tickets minted by createEphemeralAgent. */
808
+ private ephemeralCandidates: WeakMap<Agent, ContextManager> = new WeakMap();
809
+ /** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
810
+ * The name is single-generation, so these frames still own their terminal
811
+ * typing/outgoing close even after runEphemeralToCompletion deregisters them. */
812
+ private disposedEphemeralFrames: WeakSet<Agent> = new WeakSet();
791
813
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
792
814
  * Drives hard-down escalation — see noteInferenceExhausted. */
793
815
  private consecutiveInferenceFailures: Map<string, number> = new Map();
@@ -841,7 +863,23 @@ export class AgentFramework {
841
863
 
842
864
  // Messages deferred while an agent is waiting_for_tools (to preserve
843
865
  // tool_use → tool_result adjacency required by the Anthropic API).
844
- private deferredMessages: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
866
+ /** The subconscious resident's registry name (issue #77), or null. */
867
+ private subconsciousAgentName: string | null = null;
868
+ /** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
869
+ private subconsciousStrategy: WindowedPassthroughStrategy | null = null;
870
+ /** Its config (speak_in_channel gate, voice block provenance). */
871
+ private subconsciousConfig: SubconsciousConfig | null = null;
872
+ /** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
873
+ private tuneOutCoordinator: TuneOutCoordinator | null = null;
874
+
875
+ private deferredMessages: Array<{
876
+ participant: string;
877
+ content: ContentBlock[];
878
+ metadata?: MessageMetadata;
879
+ /** Delivery target (registry name). Absent = the primary agent —
880
+ * the historical behavior of every existing caller. */
881
+ forAgent?: string;
882
+ }> = [];
845
883
 
846
884
  // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
847
885
  // token of the turn currently in progress. Set at startAgentStream ENTRY —
@@ -873,6 +911,10 @@ export class AgentFramework {
873
911
  getActiveTurnTrigger(agentName: string): InferenceRequest | undefined {
874
912
  return this.activeTurnTriggers.get(agentName);
875
913
  }
914
+
915
+ /** Structured calls executed in the current logical turn. Unlike the
916
+ * per-stream local this survives framework retries and budget restarts. */
917
+ private logicalTurnToolCalls: WeakMap<Agent, { turnToken: number; count: number }> = new WeakMap();
876
918
  private nextTurnToken = 1;
877
919
 
878
920
  // Undo/redo state
@@ -927,6 +969,11 @@ export class AgentFramework {
927
969
  * framework state like the core runtime settings and restored at create
928
970
  * (antra + Sol, 08-06); reset clears it back to the residence default. */
929
971
  private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
972
+ /** Per-agent retention of tool-result images with provenance (issue
973
+ * #104). In-memory and bounded by design: history keeps only the
974
+ * placeholder; this is what lets `save_recent_image` reach the bytes the
975
+ * resident actually saw — or fail at that exact index when they're gone. */
976
+ private toolImageLedgers: Map<string, ToolImageLedger> = new Map();
930
977
  /** Durable residence-configured inline cap from
931
978
  * FrameworkConfig.toolResultInlineMaxChars; null → house default. */
932
979
  private toolResultInlineMaxCharsConfig: number | null = null;
@@ -1131,6 +1178,13 @@ export class AgentFramework {
1131
1178
  await framework.createAgent(agentConfig);
1132
1179
  }
1133
1180
 
1181
+ // The subconscious resident (issue #77) registers after the residents so
1182
+ // it can never become primary; it is excluded from every broadcast
1183
+ // fan-out and woken only by the tune-out coordinator's explicit pushes.
1184
+ if (config.subconscious?.enabled) {
1185
+ await framework.createSubconsciousAgent(config.subconscious);
1186
+ }
1187
+
1134
1188
  // Finish any branch-local suppression interrupted after Chronicle switched
1135
1189
  // branches. This runs before modules, MCPL connections, or inbound traffic.
1136
1190
  await framework.resumePreparedDiscordSuppressions();
@@ -1180,11 +1234,21 @@ export class AgentFramework {
1180
1234
  initialConfig: config.gate.config,
1181
1235
  privilegedUsersPath: config.gate.privilegedUsersPath,
1182
1236
  emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
1183
- addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata),
1184
- requestInference: (agentName, reason, source) => {
1185
- framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
1237
+ addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined),
1238
+ requestInference: (agentName, reason, source, provenance) => {
1239
+ framework.pendingRequests.push({
1240
+ agentName, reason, source, timestamp: Date.now(),
1241
+ // gate-requested wakes carry where/who (EventGate wakeProvenance)
1242
+ // as TELEMETRY fields — the host's stamp reads them; the turn's
1243
+ // speech locus (channelId / addressed) is deliberately NOT set
1244
+ // here, so a batched wake routes exactly as it did before.
1245
+ ...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
1246
+ ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
1247
+ ...(provenance?.at ? { wakeAt: provenance.at } : {}),
1248
+ });
1186
1249
  },
1187
- getAgentNames: () => [...framework.agents.keys()],
1250
+ getAgentNames: () => [...framework.agents.keys()].filter(
1251
+ (n) => n !== framework.subconsciousAgentName),
1188
1252
  });
1189
1253
  }
1190
1254
 
@@ -1247,6 +1311,40 @@ export class AgentFramework {
1247
1311
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
1248
1312
  }
1249
1313
 
1314
+ // Tune-out coordinator (issue #77): needs both the subconscious resident
1315
+ // and the MCPL channel subsystem; created after both exist.
1316
+ if (framework.subconsciousAgentName && framework.channelRegistry) {
1317
+ framework.tuneOutCoordinator = new TuneOutCoordinator(
1318
+ framework.channelRegistry,
1319
+ framework.mcplServerRegistry!,
1320
+ {
1321
+ addMessage: (participant, content, metadata, forAgent) =>
1322
+ framework.addMessage(participant, content, metadata as MessageMetadata,
1323
+ forAgent ? { forAgent } : undefined),
1324
+ requestInference: (agentName, reason, source) => {
1325
+ framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
1326
+ },
1327
+ subconsciousName: () => framework.subconsciousAgentName,
1328
+ primaryName: () => framework.primaryAgentName,
1329
+ getStoredMessages: () => {
1330
+ const primary = framework.primaryAgentName
1331
+ ? framework.agents.get(framework.primaryAgentName) : undefined;
1332
+ return primary ? primary.getContextManager().getAllMessages() : [];
1333
+ },
1334
+ currentSequence: () => framework.store.currentSequence(),
1335
+ setSubconsciousAnchor: (sequence) =>
1336
+ framework.subconsciousStrategy?.setAnchor(sequence),
1337
+ isForkBound: (channelId) =>
1338
+ [...framework.conversationAgentHomes.values()].includes(channelId),
1339
+ isPrivilegedAuthor: (authorId) =>
1340
+ framework.eventGate?.isPrivilegedUser(authorId) ?? false,
1341
+ allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
1342
+ emitTrace: (e) => framework.emitTrace(e as never),
1343
+ },
1344
+ );
1345
+ framework.tuneOutCoordinator.resumeActiveEpochs();
1346
+ }
1347
+
1250
1348
  // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
1251
1349
  // (journal) without a restart — for catching the wake-wedge on the running
1252
1350
  // process. Shows the gate's `inferring` set + buffered-event count (the
@@ -1316,6 +1414,7 @@ export class AgentFramework {
1316
1414
  this.running = false;
1317
1415
  this.providerAdmissionClosed = true;
1318
1416
  this.queue.close();
1417
+ this.tuneOutCoordinator?.stop();
1319
1418
 
1320
1419
  // Kill running code_execution scripts before cancelling streams: a
1321
1420
  // zombie script must not keep firing side-effectful tool calls into a
@@ -1335,6 +1434,9 @@ export class AgentFramework {
1335
1434
  record.runner.dispose();
1336
1435
  }
1337
1436
  this.backgroundScripts.clear();
1437
+ // Retained tool images are per-process by design; a retained stopped
1438
+ // framework must not keep up to the whole ledger budget referenced.
1439
+ this.toolImageLedgers.clear();
1338
1440
 
1339
1441
  // A stopped host must never hang behind its own cooldown.
1340
1442
  this.cancelProviderAdmission();
@@ -1751,7 +1853,12 @@ export class AgentFramework {
1751
1853
  */
1752
1854
  getAllTools(): import('./types/index.js').ToolDefinition[] {
1753
1855
  const moduleTools = this.moduleRegistry.getAllTools();
1754
- const channelTools = this.channelRegistry?.getChannelTools() ?? [];
1856
+ // Copy: getChannelTools() returns the registry's shared definitions
1857
+ // array; pushing onto it would append another tune_out on every call.
1858
+ const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
1859
+ if (this.tuneOutCoordinator) {
1860
+ channelTools.push(AgentFramework.TUNE_OUT_TOOL);
1861
+ }
1755
1862
  const gateTools = this.eventGate
1756
1863
  ? [
1757
1864
  this.eventGate.getToolDefinition(),
@@ -2001,6 +2108,20 @@ export class AgentFramework {
2001
2108
  agentName: string,
2002
2109
  snapshot?: InferenceToolSnapshot,
2003
2110
  ): import('./types/index.js').ToolDefinition[] {
2111
+ // The subconscious gets its own small surface (plus think/skip_reply/
2112
+ // end_turn), not the residents' tool board: no channel lifecycle, no
2113
+ // gate rules, no workspace — it observes, judges, and reports.
2114
+ if (agentName === this.subconsciousAgentName) {
2115
+ const basics = this.getAllTools()
2116
+ .filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
2117
+ .map((t) => t.name === 'think'
2118
+ ? this.buildThinkTool(
2119
+ snapshot?.sameRoundThinkTextPolicy
2120
+ ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy,
2121
+ )
2122
+ : t);
2123
+ return [...SUBCONSCIOUS_TOOLS, ...basics];
2124
+ }
2004
2125
  return this.getAllTools().map((tool) => {
2005
2126
  if (tool.name === 'think') {
2006
2127
  return this.buildThinkTool(
@@ -2421,25 +2542,38 @@ export class AgentFramework {
2421
2542
  contextManager: ContextManager;
2422
2543
  cleanup: () => void;
2423
2544
  }> {
2424
- const namespace = `subagent/${config.name}`;
2545
+ // Names are Chronicle namespaces and generation identities. Reserve before
2546
+ // opening the namespace so a second creation cannot append to an earlier
2547
+ // generation before runEphemeralToCompletion has a chance to reject it.
2548
+ if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
2549
+ throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
2550
+ }
2551
+ this.usedEphemeralAgentNames.add(config.name);
2552
+ try {
2553
+ const namespace = `subagent/${config.name}`;
2425
2554
 
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
- });
2555
+ const contextManager = await ContextManager.open({
2556
+ store: this.store,
2557
+ namespace,
2558
+ isolate: true,
2559
+ strategy: config.strategy ?? new PassthroughStrategy(),
2560
+ membrane: this.membrane,
2561
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
2562
+ });
2434
2563
 
2435
- const agent = new Agent(config, contextManager, this.membrane);
2564
+ const agent = new Agent(config, contextManager, this.membrane);
2565
+ this.ephemeralCandidates.set(agent, contextManager);
2436
2566
 
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
- };
2567
+ const cleanup = () => {
2568
+ // Don't close the store — it's shared. Just release the CM.
2569
+ // Data persists in the store under the namespace for investigation.
2570
+ };
2441
2571
 
2442
- return { agent, contextManager, cleanup };
2572
+ return { agent, contextManager, cleanup };
2573
+ } catch (error) {
2574
+ this.usedEphemeralAgentNames.delete(config.name);
2575
+ throw error;
2576
+ }
2443
2577
  }
2444
2578
 
2445
2579
  private createDeferred<T>(): Deferred<T> {
@@ -2462,6 +2596,17 @@ export class AgentFramework {
2462
2596
  run.lastActivity = Date.now();
2463
2597
  }
2464
2598
 
2599
+ /** Record structured calls for the current logical turn. Ignore late stream
2600
+ * events after an ephemeral/conversation agent has been disposed. */
2601
+ private recordLogicalTurnToolCalls(agent: Agent, turnToken: number, count: number): void {
2602
+ if (count <= 0 || this.agents.get(agent.name) !== agent) return;
2603
+ const state = this.logicalTurnToolCalls.get(agent);
2604
+ // The Agent object is the generation: a late stream from a disposed
2605
+ // ephemeral/conversation fork cannot contaminate a same-name successor.
2606
+ if (!state || state.turnToken !== turnToken) return;
2607
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
2608
+ }
2609
+
2465
2610
  private recordEphemeralToolCalls(agentName: string, count: number): void {
2466
2611
  const run = this.ephemeralRuns.get(agentName);
2467
2612
  if (!run) return;
@@ -2505,7 +2650,18 @@ export class AgentFramework {
2505
2650
  contextManager: ContextManager,
2506
2651
  watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
2507
2652
  ): Promise<{ speech: string; toolCallsCount: number }> {
2508
- // Register temporarily so the event loop can drive it
2653
+ // Only a fresh object returned by createEphemeralAgent may enter this path.
2654
+ // Never overwrite a resident/conversation owner or a concurrent run: their
2655
+ // cleanup is name-keyed and could cancel/deregister the legitimate owner.
2656
+ if (this.ephemeralCandidates.get(agent) !== contextManager) {
2657
+ throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
2658
+ }
2659
+ // Consume before any await/registration: the exact (Agent, ContextManager)
2660
+ // generation is one-shot even if startup later fails.
2661
+ this.ephemeralCandidates.delete(agent);
2662
+ if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
2663
+ throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
2664
+ }
2509
2665
  this.agents.set(agent.name, agent);
2510
2666
  const run: EphemeralRun = {
2511
2667
  settle: this.createDeferred<AgentSettleResult>(),
@@ -2570,8 +2726,26 @@ export class AgentFramework {
2570
2726
  } finally {
2571
2727
  if (startupWatchdog) clearTimeout(startupWatchdog);
2572
2728
  if (completionWatchdog) clearInterval(completionWatchdog);
2573
- this.ephemeralRuns.delete(agent.name);
2574
- this.agents.delete(agent.name);
2729
+ // Mark before cancellation: an adapter may synchronously settle its
2730
+ // iterator, and this physical frame still owns terminal typing/outgoing
2731
+ // closure even though the ephemeral name is about to be deregistered.
2732
+ this.disposedEphemeralFrames.add(agent);
2733
+ // Cancel before deregistration. Adapters may still yield a queued event;
2734
+ // driveStream's Agent-identity check above discards it before dispatch.
2735
+ agent.cancelStream();
2736
+ if (this.ephemeralRuns.get(agent.name) === run) this.ephemeralRuns.delete(agent.name);
2737
+ if (this.agents.get(agent.name) === agent) {
2738
+ // Exact-generation disposal owns these name-keyed entries. Remove them
2739
+ // before deregistration; the late driveStream finally will see the
2740
+ // generation mismatch and leave any future owner untouched.
2741
+ this.activeStreams.delete(agent.name);
2742
+ this.pendingAssistantBlocks.delete(agent.name);
2743
+ // The late stream finalizer intentionally refuses name-keyed cleanup once
2744
+ // deregistered, so disposal must release EventGate liveness itself.
2745
+ this.eventGate?.onInferenceEnded(agent.name);
2746
+ this.agents.delete(agent.name);
2747
+ }
2748
+ this.toolImageLedgers.delete(agent.name);
2575
2749
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2576
2750
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2577
2751
  // entries behind for the life of the store/session.
@@ -2583,6 +2757,7 @@ export class AgentFramework {
2583
2757
  // of the map, and a late driveStream finally token-match no-ops.
2584
2758
  this.activeTurnTokens.delete(agent.name);
2585
2759
  this.activeTurnTriggers.delete(agent.name);
2760
+ this.logicalTurnToolCalls.delete(agent);
2586
2761
  }
2587
2762
  }
2588
2763
 
@@ -2597,6 +2772,48 @@ export class AgentFramework {
2597
2772
  * Query inference logs.
2598
2773
  * Returns entries with summary info (doesn't resolve blobs).
2599
2774
  */
2775
+ /** Tune-out (issue #77): divert a channel to your subconscious. Present
2776
+ * only when the subconscious resident is configured. */
2777
+ private static readonly TUNE_OUT_TOOL: import('./types/index.js').ToolDefinition = {
2778
+ name: 'tune_out',
2779
+ description:
2780
+ 'Divert a channel to your subconscious for a while: its traffic stops ' +
2781
+ 'entering your context and stops waking you. Your subconscious watches ' +
2782
+ 'it instead — summarizing on a cadence in its own voice, judging ' +
2783
+ 'whether mentions merit interrupting, and able to cancel. People who ' +
2784
+ 'mention you get an automatic reaction so they know you saw nothing. ' +
2785
+ 'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
2786
+ 'tune-out auto-cancels after maxWakes mention-bursts.',
2787
+ inputSchema: {
2788
+ type: 'object',
2789
+ properties: {
2790
+ channelId: { type: 'string' },
2791
+ mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
2792
+ cadenceSeconds: {
2793
+ type: 'number',
2794
+ description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
2795
+ },
2796
+ backlogCap: {
2797
+ type: 'number',
2798
+ description:
2799
+ `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
2800
+ '0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
2801
+ },
2802
+ maxWakes: {
2803
+ type: 'number',
2804
+ description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
2805
+ },
2806
+ durationSeconds: {
2807
+ type: 'number',
2808
+ description:
2809
+ 'Optional bounded duration: the tune-out auto-cancels (backlog ' +
2810
+ 'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
2811
+ },
2812
+ },
2813
+ required: ['channelId'],
2814
+ },
2815
+ };
2816
+
2600
2817
  /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
2601
2818
  private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
2602
2819
  {
@@ -2789,11 +3006,18 @@ export class AgentFramework {
2789
3006
  name: 'save_recent_image',
2790
3007
  description:
2791
3008
  'Save one or more recent images from your own context to workspace files. ' +
2792
- 'Images are counted back from the most recent (index 0). A single image is ' +
3009
+ 'Images are counted back from the most recent (index 0), across BOTH ' +
3010
+ 'attachments in messages and images returned by tools (their history ' +
3011
+ 'placeholder shows a ref like `[image: image/png, ~691KB, ref img_k7x3q2_7]`; pass ' +
3012
+ '`ref` to save that exact image regardless of position). A single image is ' +
2793
3013
  'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
2794
3014
  'the range index..index+count-1 is saved with numeric suffixes ' +
2795
- '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
2796
- 'visible via workspace tools and the /files/ endpoint.',
3015
+ '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). If the image at ' +
3016
+ 'the requested position is no longer retained (evicted, or from before a ' +
3017
+ 'restart) the call fails and writes nothing — it never substitutes an older ' +
3018
+ 'image. When other tool calls in the same batch are still running, the save ' +
3019
+ 'waits for them so their images are counted. Receipts carry source tool call, MIME, byte size and SHA-256. Saved ' +
3020
+ 'files are visible via workspace tools and the /files/ endpoint.',
2797
3021
  inputSchema: {
2798
3022
  type: 'object',
2799
3023
  properties: {
@@ -2809,6 +3033,12 @@ export class AgentFramework {
2809
3033
  type: 'number',
2810
3034
  description: 'How many images to save, starting at `index` and going further back. Default 1.',
2811
3035
  },
3036
+ ref: {
3037
+ type: 'string',
3038
+ description:
3039
+ 'Save a specific tool-result image by its history ref (e.g. "img_k7x3q2_7", as shown in its placeholder in your context) instead of by position. ' +
3040
+ 'Mutually exclusive with `index`/`count`.',
3041
+ },
2812
3042
  },
2813
3043
  required: ['path'],
2814
3044
  },
@@ -4040,6 +4270,15 @@ export class AgentFramework {
4040
4270
  // streaming agent keeps the original Membrane.
4041
4271
  membrane: this.auxiliaryMembraneFor(config.name),
4042
4272
  debugLogContext: !!process.env.DEBUG_CONTEXT,
4273
+ // Tune-out (issue #77): messages stamped at ingestion as diverted
4274
+ // (metadata.tuneOut = { epochId }) never enter a resident's compiled
4275
+ // view — not at emission, not via chunking into the memory pyramid.
4276
+ // The stamp is permanent: after cancel, the backlog arrives as one
4277
+ // appended <tuned-out-backlog> dump instead of retro-inserting into
4278
+ // the timeline (KV-prefix-stable delivery). The stored originals
4279
+ // remain in the shared slot for the subconscious's merged view,
4280
+ // fetch_history, and audit.
4281
+ viewFilter: (message) => !(message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut,
4043
4282
  });
4044
4283
 
4045
4284
  const agent = new Agent(config, contextManager, this.membrane);
@@ -4060,6 +4299,71 @@ export class AgentFramework {
4060
4299
  return agent;
4061
4300
  }
4062
4301
 
4302
+ /**
4303
+ * Create and register the subconscious resident (issue #77): a persistent
4304
+ * side-agent with an isolated slot (`subconscious/<primary>`) whose
4305
+ * strategy view merges the residents' shared timeline with its own —
4306
+ * including the tuned-out-stamped messages the residents' viewFilter
4307
+ * excludes; the backlog is precisely what it exists to see. Windowed
4308
+ * passthrough, no memory pyramid: its durable output is what it delivers
4309
+ * into the resident's window, which accumulates there.
4310
+ */
4311
+ private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise<Agent> {
4312
+ const primaryName = this.primaryAgentName;
4313
+ const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
4314
+ if (!primaryName || !primaryConfig) {
4315
+ throw new Error('subconscious requires a primary agent to attend to');
4316
+ }
4317
+ const name = cfg.name ?? 'Subconscious';
4318
+ if (this.agents.has(name)) {
4319
+ throw new Error(`subconscious name "${name}" collides with a registered agent`);
4320
+ }
4321
+
4322
+ const strategy = new WindowedPassthroughStrategy({
4323
+ reAnchorFraction: cfg.reAnchorFraction,
4324
+ // Mirror the principal's per-message truncation ceiling so both see
4325
+ // oversized tool results / attachments under the same policy.
4326
+ maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
4327
+ });
4328
+ const contextManager = await ContextManager.open({
4329
+ store: this.store,
4330
+ namespace: `subconscious/${primaryName}`,
4331
+ isolate: true,
4332
+ // The residents' shared un-namespaced slot, merged read-only and
4333
+ // UNfiltered (no viewFilter here — stamped messages included).
4334
+ auxiliaryMessageViews: [{}],
4335
+ strategy,
4336
+ membrane: this.membrane,
4337
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
4338
+ });
4339
+
4340
+ // Inherit the principal's inference settings wholesale (fork-template
4341
+ // idiom): reasoning effort (thinking), provider params, token ceilings,
4342
+ // caching, refusal handling, timezone — a same-model side-process must
4343
+ // not silently run under different inference conditions than the
4344
+ // resident it serves. Only identity- and surface-defining fields are
4345
+ // overridden below.
4346
+ const agentConfig: AgentConfig = {
4347
+ ...primaryConfig,
4348
+ name,
4349
+ model: cfg.model ?? primaryConfig.model,
4350
+ systemPrompt: cfg.systemPrompt,
4351
+ strategy: undefined, // its CM owns the windowed strategy instance
4352
+ // Prose is never auto-routed: a cadence-triggered turn carries no
4353
+ // locus, and bare prose must not fall through to the default channel.
4354
+ // Speech happens only via speak_in_channel (its own marked voice).
4355
+ proseRouting: 'explicit',
4356
+ allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
4357
+ };
4358
+ const agent = new Agent(agentConfig, contextManager, this.membrane);
4359
+ this.agents.set(name, agent);
4360
+ this.agentConfigs.set(name, agentConfig);
4361
+ this.subconsciousAgentName = name;
4362
+ this.subconsciousStrategy = strategy;
4363
+ this.subconsciousConfig = cfg;
4364
+ return agent;
4365
+ }
4366
+
4063
4367
  private async runLoop(): Promise<void> {
4064
4368
  while (this.running) {
4065
4369
  try {
@@ -4172,7 +4476,7 @@ export class AgentFramework {
4172
4476
  // byte-matched (the window stores what the membrane sends;
4173
4477
  // divergence breaks the compile prefix).
4174
4478
  const { blocks: toolResultContent, spilled } =
4175
- await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
4479
+ await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
4176
4480
  agent.getContextManager().addMessage('user', toolResultContent);
4177
4481
 
4178
4482
  // Flush any messages that were deferred while this turn was in
@@ -4198,12 +4502,11 @@ export class AgentFramework {
4198
4502
  // queued, they flush at the target's own next boundary — with
4199
4503
  // injection — or in driveStream's finally when its turn ends.
4200
4504
  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);
4505
+ {
4506
+ const deferred = this.drainDeferredFor(agent.name);
4507
+ if (deferred.length > 0) {
4205
4508
  for (const msg of deferred) {
4206
- target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4509
+ agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4207
4510
  // Injection guards: tool blocks would corrupt the tool-cycle
4208
4511
  // structure the membrane enforces, and a message named as the
4209
4512
  // agent itself would render as an ASSISTANT turn on the wire
@@ -4538,7 +4841,8 @@ export class AgentFramework {
4538
4841
  // their own channel's messages, not by framework-wide events.
4539
4842
  const targetAgents =
4540
4843
  response.requestInference === true
4541
- ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
4844
+ ? Array.from(this.agents.keys()).filter(
4845
+ (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
4542
4846
  : response.requestInference;
4543
4847
 
4544
4848
  for (const agentName of targetAgents) {
@@ -4575,6 +4879,7 @@ export class AgentFramework {
4575
4879
  metadata?: Record<string, unknown>;
4576
4880
  tags?: string[];
4577
4881
  triggerInference?: boolean;
4882
+ targetAgents?: string[];
4578
4883
  }): Promise<void> {
4579
4884
  const metadata: Record<string, unknown> = {
4580
4885
  ...event.metadata,
@@ -4621,12 +4926,38 @@ export class AgentFramework {
4621
4926
  }
4622
4927
  }
4623
4928
 
4929
+ // Tune-out divert (issue #77): stamped, stored, no resident wake. The
4930
+ // coordinator has already handled wake bookkeeping (coalesced
4931
+ // subconscious invocation, durable count, suppression ack) before we
4932
+ // stamp; the subconscious reads the message through its merged view.
4933
+ const divert = this.tuneOutCoordinator?.onIncoming(
4934
+ event.serverId,
4935
+ event.channelId,
4936
+ event.messageId,
4937
+ event.tags,
4938
+ event.author?.id,
4939
+ // The resident's gate verdict rides the event; it preconditions
4940
+ // subconscious wakes (gate composes, it is not replaced).
4941
+ event.triggerInference !== false,
4942
+ ) ?? null;
4943
+ if (divert) {
4944
+ metadata.tuneOut = { epochId: divert.epochId };
4945
+ }
4946
+
4624
4947
  const id = this.addMessage('user', incomingContent, metadata);
4625
4948
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
4626
4949
 
4627
- if (event.triggerInference) {
4950
+ if (event.triggerInference && !divert) {
4628
4951
  const addressed = isAddressedMessage(event.tags, event.metadata);
4629
- for (const agentName of this.agents.keys()) {
4952
+ // Honor explicit targeting, mirroring the push-event path: an event
4953
+ // that names its agents wakes exactly those; an untargeted event
4954
+ // keeps the historical broadcast. (targetAgents was declared on
4955
+ // McplChannelIncomingEvent from the start but never honored here —
4956
+ // tune-out's wake routing is the first setter.)
4957
+ const targetAgents = event.targetAgents
4958
+ ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
4959
+ for (const agentName of targetAgents) {
4960
+ if (!this.agents.has(agentName)) continue;
4630
4961
  this.pendingRequests.push({
4631
4962
  agentName,
4632
4963
  reason: 'mcpl:channel-incoming',
@@ -4757,41 +5088,50 @@ export class AgentFramework {
4757
5088
  if (!template || !templateConfig) {
4758
5089
  throw new Error(`conversation template agent "${router.templateAgent}" not found`);
4759
5090
  }
5091
+ if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
5092
+ throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
5093
+ }
5094
+ this.usedEphemeralAgentNames.add(name);
5095
+ try {
4760
5096
 
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
- });
5097
+ const contextManager = await ContextManager.open({
5098
+ store: this.store,
5099
+ namespace: `conversations/${name}`,
5100
+ isolate: true,
5101
+ // Strategy instances are stateful — never share the template's.
5102
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
5103
+ // Dynamic conversation forks retain the established provider policy in
5104
+ // this bounded first slice. Provider cooldown ownership belongs to the
5105
+ // persistent resident only; generation-unique forks must not leak gates.
5106
+ membrane: this.membrane,
5107
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
5108
+ });
4773
5109
 
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);
5110
+ // Seed with the template's compiled context, renaming the template
5111
+ // participant so the fork reads its inheritance as its own history.
5112
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
5113
+ // persisted precisely so names aren't reused, but if this namespace has
5114
+ // history anyway (counter state lost, crash between spawn and persist),
5115
+ // seeding again would stack another template copy on top of it.
5116
+ const { messages: existing } = await contextManager.compile();
5117
+ if (existing.length === 0) {
5118
+ const { messages: compiled } = await template.getContextManager().compile();
5119
+ for (const msg of compiled) {
5120
+ const participant = msg.participant === template.name ? name : msg.participant;
5121
+ contextManager.addMessage(participant, msg.content);
5122
+ }
4786
5123
  }
4787
- }
4788
5124
 
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;
5125
+ const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
5126
+ const agent = new Agent(config, contextManager, this.membrane);
5127
+ this.agents.set(name, agent);
5128
+ this.agentConfigs.set(name, config);
5129
+ this.conversationAgentHomes.set(name, channelId);
5130
+ return agent;
5131
+ } catch (error) {
5132
+ this.usedEphemeralAgentNames.delete(name);
5133
+ throw error;
5134
+ }
4795
5135
  }
4796
5136
 
4797
5137
  /** Persist the router's generation counters (see hydration in create()). */
@@ -4815,10 +5155,13 @@ export class AgentFramework {
4815
5155
  private disposeConversationAgent(agentName: string): void {
4816
5156
  this.closingConversationAgents.delete(agentName);
4817
5157
  const channelId = this.conversationAgentHomes.get(agentName);
5158
+ const agent = this.agents.get(agentName);
4818
5159
  this.agents.delete(agentName);
5160
+ this.toolImageLedgers.delete(agentName);
4819
5161
  this.agentConfigs.delete(agentName);
4820
5162
  this.conversationAgentHomes.delete(agentName);
4821
5163
  this.evictTurnCheckpoints(agentName);
5164
+ if (agent) this.logicalTurnToolCalls.delete(agent);
4822
5165
  this.emitTrace({
4823
5166
  type: 'mcpl:conversation-disposed',
4824
5167
  agentName,
@@ -5054,7 +5397,8 @@ export class AgentFramework {
5054
5397
  if (event.triggerInference) {
5055
5398
  // Default broadcast excludes conversation forks (channel-driven).
5056
5399
  const targetAgents = event.targetAgents
5057
- ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
5400
+ ?? [...this.agents.keys()].filter(
5401
+ (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
5058
5402
  for (const agentName of targetAgents) {
5059
5403
  this.pendingRequests.push({
5060
5404
  agentName,
@@ -5255,6 +5599,9 @@ export class AgentFramework {
5255
5599
  `[inference-dropped] agent=${agentName} reason=policy-skip ` +
5256
5600
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
5257
5601
  );
5602
+ // A context-budget restart inherits the predecessor's EventGate liveness.
5603
+ // If policy drops the queued successor, no driveStream remains to release it.
5604
+ if (budgetRestart) this.eventGate?.onInferenceEnded(agentName);
5258
5605
  const gate = this.providerGates.get(agentName);
5259
5606
  if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
5260
5607
  gate.primaryPending = false;
@@ -5294,6 +5641,17 @@ export class AgentFramework {
5294
5641
  if (r.addressed) addressedReq = r;
5295
5642
  }
5296
5643
  const channelReq = addressedReq ?? ambientReq;
5644
+ // Telemetry provenance (who/where woke the agent): from the routing
5645
+ // winner when it carries one; else from the most recent request that
5646
+ // does (gate wakes name a counterparty without setting a locus),
5647
+ // ordered by the event's own time (wakeAt) rather than flush order.
5648
+ let provReq: InferenceRequest | undefined = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined;
5649
+ if (!provReq) {
5650
+ for (const r of requests) {
5651
+ if (!r.counterparty && !r.wakeChannelId) continue;
5652
+ if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
5653
+ }
5654
+ }
5297
5655
  await this.startAgentStream(agent, {
5298
5656
  ...trigger,
5299
5657
  channelId: channelReq?.channelId,
@@ -5302,7 +5660,9 @@ export class AgentFramework {
5302
5660
  // the channel for routing but names no author — the restart is its
5303
5661
  // own cause, and borrowing another request's author would be false
5304
5662
  // provenance.
5305
- counterparty: budgetRestart ? undefined : channelReq?.counterparty,
5663
+ counterparty: budgetRestart ? undefined : provReq?.counterparty,
5664
+ wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
5665
+ wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
5306
5666
  });
5307
5667
  }
5308
5668
  }
@@ -5846,9 +6206,8 @@ export class AgentFramework {
5846
6206
  trigger?.reason !== 'context_budget_restart' &&
5847
6207
  this.deferredMessages.length > 0
5848
6208
  ) {
5849
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
5850
- if (target === agent) {
5851
- const deferred = this.deferredMessages.splice(0);
6209
+ {
6210
+ const deferred = this.drainDeferredFor(agent.name);
5852
6211
  for (const msg of deferred) {
5853
6212
  // Per-message try/catch (mirrors announceLocusIfChanged): one
5854
6213
  // poison message — or a transient store-write failure — must not
@@ -5906,8 +6265,21 @@ export class AgentFramework {
5906
6265
  // BEFORE this turn compiles, so the agent always knows where its voice
5907
6266
  // goes (announce-on-change only — no per-turn chatter, append-only for
5908
6267
  // KV stability).
6268
+ if (trigger?.reason === 'context_budget_restart') {
6269
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
6270
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
6271
+ }
6272
+
5909
6273
  if (trigger?.reason !== 'context_budget_restart') {
5910
6274
  if (attempt === 0) this.maybePrimeProseMode(agent);
6275
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
6276
+ if (attempt === 0) {
6277
+ // A true new turn gets a fresh generation and count.
6278
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
6279
+ } else {
6280
+ // A framework retry is a new physical stream in the same logical turn.
6281
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
6282
+ }
5911
6283
  // Fresh turn: forget the previous turn's explicit-send engagements and
5912
6284
  // prose deliveries — both are strictly turn-scoped (a restart continues
5913
6285
  // the same logical turn and keeps them).
@@ -5941,7 +6313,11 @@ export class AgentFramework {
5941
6313
  // context-budget restart, which skips the re-pin but re-emits this).
5942
6314
  channelId: this.turnLocusPins.get(agent.name),
5943
6315
  });
5944
- this.eventGate?.onInferenceStarted(agent.name);
6316
+ // A budget restart continues the same logical inference window. The
6317
+ // predecessor keeps EventGate liveness until its successor terminates.
6318
+ if (trigger?.reason !== 'context_budget_restart') {
6319
+ this.eventGate?.onInferenceStarted(agent.name);
6320
+ }
5945
6321
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
5946
6322
 
5947
6323
  // Typing indicator, started at TURN START. It says "attending", which is
@@ -6004,12 +6380,38 @@ export class AgentFramework {
6004
6380
  }
6005
6381
  }
6006
6382
 
6383
+ // An ephemeral watchdog may dispose this Agent while hooks/compile await.
6384
+ // Do not create a provider stream after its generation lost ownership.
6385
+ if (this.agents.get(agent.name) !== agent) {
6386
+ this.channelRegistry?.stopTyping();
6387
+ this.eventGate?.onInferenceEnded(agent.name);
6388
+ return false;
6389
+ }
6390
+
6391
+ // The subconscious's standing dispositions ride a fixed system-position
6392
+ // injection (antra, #tuneout-talk): never repeated in its timeline, and
6393
+ // — since it has no long-term memory — structurally unable to wash out
6394
+ // of its window. Bytes change only when a disposition changes.
6395
+ if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
6396
+ const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
6397
+ if (dispositionInjection) {
6398
+ injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
6399
+ }
6400
+ }
6401
+
6007
6402
  const {
6008
6403
  stream,
6009
6404
  request: compiledRequest,
6010
6405
  takeKvSubmission,
6011
6406
  drainKvSubmissionIds,
6012
6407
  } = await agent.startStreamWithInjections(tools, injections);
6408
+ if (this.agents.get(agent.name) !== agent) {
6409
+ stream.cancel();
6410
+ agent.cancelStream();
6411
+ this.channelRegistry?.stopTyping();
6412
+ this.eventGate?.onInferenceEnded(agent.name);
6413
+ return false;
6414
+ }
6013
6415
 
6014
6416
  const handle = this.driveStream(
6015
6417
  agent,
@@ -6021,6 +6423,7 @@ export class AgentFramework {
6021
6423
  ownsProviderGate,
6022
6424
  takeKvSubmission,
6023
6425
  drainKvSubmissionIds,
6426
+ new Set(tools.map((tool) => tool.name)),
6024
6427
  );
6025
6428
  this.activeStreams.set(agent.name, handle);
6026
6429
  // Handoff: driveStream captured the token in its synchronous prefix;
@@ -6105,6 +6508,7 @@ export class AgentFramework {
6105
6508
  ownsProviderGate = false,
6106
6509
  takeKvSubmission?: () => { submissionId: string; wireReceipt: CacheWireReceipt } | undefined,
6107
6510
  drainKvSubmissionIds?: () => string[],
6511
+ knownToolNames: ReadonlySet<string> = new Set(),
6108
6512
  ): Promise<void> {
6109
6513
  const startTime = Date.now();
6110
6514
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
@@ -6124,6 +6528,7 @@ export class AgentFramework {
6124
6528
  // means no successor turn can have replaced it while we compiled.
6125
6529
  const myTurnToken = this.activeTurnTokens.get(agent.name);
6126
6530
  let hadToolCalls = false;
6531
+ let generationLost = false;
6127
6532
 
6128
6533
  // ---- Present-while-acting turn state ---------------------------------
6129
6534
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
@@ -6207,13 +6612,20 @@ export class AgentFramework {
6207
6612
  // failed, a framework-cancelled stream sets aborted. Best-effort
6208
6613
  // notifications — consumers dedupe by inferenceId and keep a timeout.
6209
6614
  let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
6615
+ let preserveEventGateForSuccessor = false;
6210
6616
  this.hookOrchestrator?.emitLifecycle({
6211
6617
  inferenceId: outgoingInferenceId,
6212
6618
  conversationId: agent.name,
6213
6619
  turnIndex: 0,
6214
6620
  phase: 'started',
6215
6621
  });
6216
- const proseStream = this.channelRegistry && agent.proseRouting !== 'disabled'
6622
+ // The wrapper guard needs the complete response before it can distinguish
6623
+ // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
6624
+ // turns until completion: otherwise speculative outgoing chunks could
6625
+ // expose the wrapper before the fail-closed classifier runs.
6626
+ const proseStream = this.channelRegistry &&
6627
+ agent.proseRouting !== 'disabled' &&
6628
+ !agent.toolWrapperProseGuard
6217
6629
  ? new ProseStreamRouter({
6218
6630
  mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
6219
6631
  initialTarget: typingChannel,
@@ -6243,6 +6655,14 @@ export class AgentFramework {
6243
6655
 
6244
6656
  try {
6245
6657
  for await (const event of stream) {
6658
+ // Ignore every late event from an Agent generation that no longer owns
6659
+ // this name (ephemeral watchdog/disposal, conversation replacement).
6660
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
6661
+ generationLost = true;
6662
+ lifecyclePhase = 'aborted';
6663
+ stream.cancel();
6664
+ break;
6665
+ }
6246
6666
  this.touchEphemeralRun(agent.name, true);
6247
6667
  switch (event.type) {
6248
6668
  case 'tokens':
@@ -6310,6 +6730,7 @@ export class AgentFramework {
6310
6730
  case 'tool-calls': {
6311
6731
  adoptInjectedRound();
6312
6732
  hadToolCalls = true;
6733
+ this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
6313
6734
  this.recordEphemeralToolCalls(agent.name, event.calls.length);
6314
6735
  this.emitTrace({
6315
6736
  type: 'inference:tool_calls_yielded',
@@ -6492,6 +6913,7 @@ export class AgentFramework {
6492
6913
  const readyState = agent.state as AgentState;
6493
6914
  if (readyState.status === 'ready') {
6494
6915
  const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
6916
+ agent.name,
6495
6917
  readyState.toolResults,
6496
6918
  this.resolveToolResultInlineCap(agent).cap,
6497
6919
  );
@@ -6515,7 +6937,30 @@ export class AgentFramework {
6515
6937
  const terminalContent = lastToolIdx >= 0
6516
6938
  ? response.content.slice(lastToolIdx + 1)
6517
6939
  : response.content;
6518
- if (lastToolIdx >= 0) {
6940
+ // This is a whole-response boundary, not a trailing-prose
6941
+ // classifier. If the turn executed any genuine structured tool
6942
+ // call, preserve its later prose exactly as ordinary history.
6943
+ const logicalToolState = this.logicalTurnToolCalls.get(agent);
6944
+ const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
6945
+ const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
6946
+ ? detectKnownToolWrapperProse(response.content, knownToolNames)
6947
+ : null;
6948
+ if (guardedWrapperTool) {
6949
+ // AF #133: containment only. Never execute wrapper text, never
6950
+ // publish it, and never retain it as the freshest assistant
6951
+ // self-seed. Exact raw output remains in the provider ledger.
6952
+ agent.getContextManager().addMessage('user', [{
6953
+ type: 'text',
6954
+ text: '[tool-boundary] No tool was called.',
6955
+ }], {
6956
+ system: true,
6957
+ kind: 'tool-wrapper-prose-contained',
6958
+ toolName: guardedWrapperTool,
6959
+ });
6960
+ console.error(
6961
+ `[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`,
6962
+ );
6963
+ } else if (lastToolIdx >= 0) {
6519
6964
  if (terminalContent.length > 0) {
6520
6965
  agent.addAssistantResponse(terminalContent);
6521
6966
  }
@@ -6541,7 +6986,9 @@ export class AgentFramework {
6541
6986
  // text is speech.
6542
6987
  const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
6543
6988
  block.type === 'text';
6544
- const allText = response.content.filter(isTextBlock);
6989
+ const allText = guardedWrapperTool
6990
+ ? []
6991
+ : response.content.filter(isTextBlock);
6545
6992
 
6546
6993
  const speechContent = hadToolCalls ? [] : allText;
6547
6994
  const thoughts = hadToolCalls ? allText : [];
@@ -6556,19 +7003,30 @@ export class AgentFramework {
6556
7003
  }
6557
7004
  : undefined;
6558
7005
 
7006
+ // Async completion work may overlap a replacement stream. Recheck
7007
+ // physical ownership before any terminal state/settlement side effect.
7008
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
7009
+ generationLost = true;
7010
+ lifecyclePhase = 'aborted';
7011
+ stream.cancel();
7012
+ return;
7013
+ }
7014
+
6559
7015
  // Reset agent state before emitting inference:completed. Traces are
6560
7016
  // observability-only, but external synchronous listeners should
6561
7017
  // still see the terminal state at the terminal trace boundary.
6562
7018
  // Speech dispatch happens after but doesn't depend on the status
6563
7019
  // field.
6564
7020
  agent.reset();
6565
- this.eventGate?.onInferenceEnded(agent.name);
7021
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6566
7022
  this.settleAgent(agent.name, {
6567
7023
  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'),
7024
+ speech: guardedWrapperTool
7025
+ ? ''
7026
+ : terminalContent
7027
+ .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
7028
+ .map((block) => block.text)
7029
+ .join('\n'),
6572
7030
  });
6573
7031
 
6574
7032
  this.emitTrace({
@@ -6942,7 +7400,7 @@ export class AgentFramework {
6942
7400
 
6943
7401
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
6944
7402
  lifecyclePhase = 'failed';
6945
- this.eventGate?.onInferenceEnded(agent.name);
7403
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6946
7404
  break;
6947
7405
  }
6948
7406
 
@@ -6966,7 +7424,7 @@ export class AgentFramework {
6966
7424
  // itself) means retrying the same context can never succeed.
6967
7425
  ...this.classifyInferenceError(err),
6968
7426
  });
6969
- this.eventGate?.onInferenceEnded(agent.name);
7427
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
6970
7428
  if (action.emit) {
6971
7429
  this.pushEvent(action.emit);
6972
7430
  }
@@ -6988,7 +7446,13 @@ export class AgentFramework {
6988
7446
  if (cancelKind !== undefined) {
6989
7447
  this.frameworkCancelledStreams.delete(cancelKey);
6990
7448
  lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
6991
- this.eventGate?.onInferenceEnded(agent.name);
7449
+ // A budget restart continues the same EventGate window. Do not
7450
+ // end it here or in finally; the replacement owns final release.
7451
+ if (cancelKind === 'budget_restart') {
7452
+ preserveEventGateForSuccessor = true;
7453
+ } else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
7454
+ this.eventGate?.onInferenceEnded(agent.name);
7455
+ }
6992
7456
  // endTurn IS a logical turn end — earlier rounds may have
6993
7457
  // live-routed prose (narrate → skip_reply is a real shape),
6994
7458
  // so settle the delivery chain and drop the receipt. A
@@ -7035,7 +7499,7 @@ export class AgentFramework {
7035
7499
  request: compiledRequest ?? { note: 'streaming request aborted' },
7036
7500
  durationMs,
7037
7501
  });
7038
- this.eventGate?.onInferenceEnded(agent.name);
7502
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
7039
7503
  }
7040
7504
  break;
7041
7505
  }
@@ -7128,6 +7592,14 @@ export class AgentFramework {
7128
7592
  }
7129
7593
  }
7130
7594
  } catch (error) {
7595
+ // A superseded physical stream may throw after its replacement starts.
7596
+ // It has no authority to settle, reset, gate-release, or publish failure.
7597
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
7598
+ generationLost = true;
7599
+ lifecyclePhase = 'aborted';
7600
+ stream.cancel();
7601
+ return;
7602
+ }
7131
7603
  // Stream itself threw. Organization acceleration is deferred from a
7132
7604
  // fresh compile; every other throw keeps the ordinary exhausted path.
7133
7605
  const err = error instanceof Error ? error : new Error(String(error));
@@ -7138,7 +7610,7 @@ export class AgentFramework {
7138
7610
  error: `Provider acceleration cooldown: ${err.message}`,
7139
7611
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
7140
7612
  this.abortAgentScript(agent.name, 'provider acceleration cooldown');
7141
- lifecyclePhase = 'failed'; agent.reset(); this.eventGate?.onInferenceEnded(agent.name);
7613
+ lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
7142
7614
  return;
7143
7615
  }
7144
7616
  this.emitTrace({
@@ -7173,7 +7645,7 @@ export class AgentFramework {
7173
7645
  this.abortAgentScript(agent.name, 'stream threw');
7174
7646
  lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
7175
7647
  agent.reset();
7176
- this.eventGate?.onInferenceEnded(agent.name);
7648
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
7177
7649
  } finally {
7178
7650
  const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
7179
7651
  if (unsettledKvSubmissions.length > 0) {
@@ -7186,6 +7658,22 @@ export class AgentFramework {
7186
7658
  }
7187
7659
  } catch { /* receipt cleanup must not mask inference teardown */ }
7188
7660
  }
7661
+ // A framework retry/budget restart can overlap physical streams on the
7662
+ // same Agent object. Name + Agent identity is not enough: only the
7663
+ // current stream generation may mutate name-keyed teardown state.
7664
+ const ownsPhysicalStream =
7665
+ this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
7666
+ // Ephemeral completion resolves its caller before this finally runs, so
7667
+ // runEphemeralToCompletion may already have deregistered the name. The
7668
+ // terminal frame still owns its typing/outgoing completion unless it was
7669
+ // genuinely superseded or handed EventGate liveness to a budget restart.
7670
+ const disposedEphemeralWithoutSuccessor =
7671
+ generationLost &&
7672
+ this.disposedEphemeralFrames.has(agent) &&
7673
+ !this.agents.has(agent.name);
7674
+ const frameReachedTerminal =
7675
+ (!generationLost || disposedEphemeralWithoutSuccessor) &&
7676
+ !preserveEventGateForSuccessor;
7189
7677
  // §10.5: exactly one terminal per `started`, on every exit path the
7190
7678
  // host controls. Which one was decided by the path taken (default
7191
7679
  // completed; catch → failed; framework-cancel → aborted). A host
@@ -7204,30 +7692,36 @@ export class AgentFramework {
7204
7692
  // all incoming events → the agent silently stops waking on messages
7205
7693
  // (typing still stops, compression still runs — matching the observed
7206
7694
  // 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() });
7695
+ if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
7696
+ this.eventGate?.onInferenceEnded(agent.name);
7697
+ }
7698
+ if (!generationLost && ownsPhysicalStream) {
7699
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
7700
+ }
7209
7701
  if (ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
7210
7702
 
7211
7703
  // Stop the typing indicator on every exit path (complete, error,
7212
7704
  // exhausted, abort) so it never sticks after the turn ends.
7213
- this.channelRegistry?.stopTyping();
7705
+ if (frameReachedTerminal) this.channelRegistry?.stopTyping();
7214
7706
 
7215
7707
  // Spec 14.3: flush any held line-start text, then close each streamed
7216
7708
  // channel with its final moderated content — the consumer's signal to
7217
7709
  // finalize (end the TTS utterance, settle the rendered message).
7218
- if (proseStream) {
7710
+ if (frameReachedTerminal && proseStream) {
7219
7711
  emitOutgoing(proseStream.finish());
7220
7712
  for (const [channelId, text] of proseStream.byChannel()) {
7221
7713
  this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
7222
7714
  }
7223
7715
  }
7224
7716
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
7225
- this.activeStreams.delete(agent.name);
7226
- this.pendingAssistantBlocks.delete(agent.name);
7717
+ if (ownsPhysicalStream) {
7718
+ this.activeStreams.delete(agent.name);
7719
+ this.pendingAssistantBlocks.delete(agent.name);
7720
+ }
7227
7721
 
7228
7722
  // A conversation fork whose TTL closure turn just finished is done for
7229
7723
  // good — dispose it so the agent map doesn't grow monotonically.
7230
- if (this.closingConversationAgents.has(agent.name)) {
7724
+ if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
7231
7725
  this.disposeConversationAgent(agent.name);
7232
7726
  }
7233
7727
 
@@ -7243,11 +7737,15 @@ export class AgentFramework {
7243
7737
  this.activeTurnTriggers.delete(agent.name);
7244
7738
  }
7245
7739
 
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);
7740
+ // Flush any deferred messages (e.g. if stream failed while tools were
7741
+ // pending). Only THIS agent's messages: other targets' entries wait
7742
+ // for their own boundaries (re-adding via addMessage re-defers if the
7743
+ // target has meanwhile started a turn).
7744
+ if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
7745
+ const deferred = this.drainDeferredFor(agent.name);
7249
7746
  for (const msg of deferred) {
7250
- this.addMessage(msg.participant, msg.content, msg.metadata);
7747
+ this.addMessage(msg.participant, msg.content, msg.metadata,
7748
+ msg.forAgent ? { forAgent: msg.forAgent } : undefined);
7251
7749
  }
7252
7750
  }
7253
7751
  }
@@ -7350,6 +7848,7 @@ export class AgentFramework {
7350
7848
  // never between tool_use and its tool_result. The two addMessage calls
7351
7849
  // below are synchronous and adjacent — nothing can interleave.
7352
7850
  const { blocks } = await this.buildStoredToolResultContent(
7851
+ agentName,
7353
7852
  [{ id: toolUseId, name: toolName, input, result, durationMs }],
7354
7853
  this.resolveToolResultInlineCap(agent).cap,
7355
7854
  );
@@ -7856,6 +8355,7 @@ export class AgentFramework {
7856
8355
  * live wire copy can reuse the identical strings.
7857
8356
  */
7858
8357
  private async buildStoredToolResultContent(
8358
+ agentName: string,
7859
8359
  toolResults: CompletedToolCall[],
7860
8360
  maxChars: number | undefined,
7861
8361
  ): Promise<{
@@ -7864,10 +8364,22 @@ export class AgentFramework {
7864
8364
  }> {
7865
8365
  const spilled = new Map<string, { text: string; filePath: string | null }>();
7866
8366
  const dateLabel = new Date().toISOString().slice(0, 10);
8367
+ const ledger = this.toolImageLedgerFor(agentName);
7867
8368
  for (const tc of toolResults) {
8369
+ // Retain every image the result carries BEFORE the placeholder is
8370
+ // written (issue #104): the placeholder embeds the ref, so the image
8371
+ // stays reachable by provenance after the bytes leave the wire.
7868
8372
  const raw = tc.result.isError
7869
8373
  ? tc.result.error ?? 'Unknown error'
7870
- : toolResultDataToHistoryString(tc.result.data, undefined);
8374
+ : toolResultDataToHistoryString(tc.result.data, undefined, {
8375
+ imageRef: (blockIndex, image) => ledger.retain({
8376
+ toolCallId: tc.id,
8377
+ toolName: tc.name,
8378
+ blockIndex,
8379
+ data: image.data,
8380
+ mediaType: image.mimeType,
8381
+ }).ref,
8382
+ });
7871
8383
  spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
7872
8384
  }
7873
8385
  const blocks: ContentBlock[] = toolResults.map(tc => ({
@@ -7888,6 +8400,13 @@ export class AgentFramework {
7888
8400
  label: string,
7889
8401
  ): Promise<{ text: string; filePath: string | null }> {
7890
8402
  if (!cap || content.length <= cap) return { text: content, filePath: null };
8403
+ // The stored text is the only place save_recent_image discovers tool
8404
+ // images, and the live wire delivers every image regardless of text
8405
+ // length — so an image slot dropped by the cut is an image the resident
8406
+ // saw that the index would silently slide past (issue #104). Keep the
8407
+ // slots at or past the cut, re-appended after the notice.
8408
+ const { head, tail } = splitPreservingImageSlots(content, cap);
8409
+ const kept = formatPreservedImageSlots(tail);
7891
8410
 
7892
8411
  const workspace = this.getWorkspaceModule();
7893
8412
  const mountName = workspace ? this.firstWritableMountName(workspace) : null;
@@ -7899,10 +8418,11 @@ export class AgentFramework {
7899
8418
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
7900
8419
  if (result.success) {
7901
8420
  return {
7902
- text: safeSlice(content, 0, cap)
7903
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
8421
+ text: head
8422
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}. `
7904
8423
  + 'Read/grep it with your file tools, or raise the inline cap via '
7905
- + 'agent_settings update tool_result_inline_max_chars.]',
8424
+ + 'agent_settings update tool_result_inline_max_chars.]'
8425
+ + kept,
7906
8426
  filePath: path,
7907
8427
  };
7908
8428
  }
@@ -7925,15 +8445,17 @@ export class AgentFramework {
7925
8445
  `[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
7926
8446
  );
7927
8447
  return {
7928
- text: safeSlice(content, 0, cap)
7929
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
7930
- + ` (${failure}); content over the cap was not retained]`,
8448
+ text: head
8449
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
8450
+ + ` (${failure}); content over the cap was not retained]`
8451
+ + kept,
7931
8452
  filePath: null,
7932
8453
  };
7933
8454
  }
7934
8455
  return {
7935
- text: safeSlice(content, 0, cap)
7936
- + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
8456
+ text: head
8457
+ + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
8458
+ + kept,
7937
8459
  filePath: null,
7938
8460
  };
7939
8461
  }
@@ -8151,6 +8673,15 @@ export class AgentFramework {
8151
8673
  return hasImage ? blocks : null;
8152
8674
  }
8153
8675
 
8676
+ private toolImageLedgerFor(agentName: string): ToolImageLedger {
8677
+ let ledger = this.toolImageLedgers.get(agentName);
8678
+ if (!ledger) {
8679
+ ledger = new ToolImageLedger();
8680
+ this.toolImageLedgers.set(agentName, ledger);
8681
+ }
8682
+ return ledger;
8683
+ }
8684
+
8154
8685
  /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
8155
8686
  private strategyDerivedToolResultChars(agent: Agent): number | undefined {
8156
8687
  const strategy = agent.getContextManager().getStrategy();
@@ -8348,6 +8879,30 @@ export class AgentFramework {
8348
8879
  return;
8349
8880
  }
8350
8881
 
8882
+ // Route the tune-out tool (residents) and the subconscious's surface
8883
+ if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
8884
+ this.dispatchTuneOutToolCall(agentName, enrichedCall);
8885
+ return;
8886
+ }
8887
+ if (
8888
+ this.tuneOutCoordinator &&
8889
+ agentName === this.subconsciousAgentName &&
8890
+ (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)
8891
+ ) {
8892
+ void this.tuneOutCoordinator
8893
+ .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>)
8894
+ .then((result) => {
8895
+ this.queue.push({
8896
+ type: 'tool-result',
8897
+ callId: enrichedCall.id,
8898
+ agentName,
8899
+ moduleName: 'tune-out',
8900
+ result,
8901
+ });
8902
+ });
8903
+ return;
8904
+ }
8905
+
8351
8906
  // Route sleep / wake tools
8352
8907
  if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
8353
8908
  this.dispatchSleepToolCall(agentName, enrichedCall);
@@ -8492,13 +9047,32 @@ export class AgentFramework {
8492
9047
  private addMessage(
8493
9048
  participant: string,
8494
9049
  content: ContentBlock[],
8495
- metadata?: MessageMetadata
9050
+ metadata?: MessageMetadata,
9051
+ opts?: {
9052
+ /**
9053
+ * Deliver into this registry agent's window instead of the primary's,
9054
+ * with the deferral/turn-alive guard evaluated against THAT agent's
9055
+ * turn state. Default (absent) is byte-identical to the historical
9056
+ * primary-only behavior. Unknown names log and drop (loud, non-fatal:
9057
+ * this is reachable from timer callbacks that may outlive an agent).
9058
+ */
9059
+ forAgent?: string;
9060
+ }
8496
9061
  ): 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;
9062
+ // Route to the named agent, else the primary (not ephemeral subagents).
9063
+ const agent = opts?.forAgent
9064
+ ? this.agents.get(opts.forAgent)
9065
+ : this.primaryAgentName
9066
+ ? this.agents.get(this.primaryAgentName)
9067
+ : this.agents.values().next().value;
8501
9068
  if (!agent) {
9069
+ if (opts?.forAgent) {
9070
+ console.error(
9071
+ `[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
9072
+ `(participant=${participant})`,
9073
+ );
9074
+ return '' as MessageId;
9075
+ }
8502
9076
  throw new Error('No agents configured');
8503
9077
  }
8504
9078
 
@@ -8519,19 +9093,48 @@ export class AgentFramework {
8519
9093
  // ALSO injected into the live stream — hear-while-acting), or in
8520
9094
  // driveStream's finally when the turn ends.
8521
9095
  const hasToolResult = content.some(b => b.type === 'tool_result');
9096
+ // Explicitly-targeted deliveries scope the tool-cycle check to the
9097
+ // target (pendingAssistantBlocks is keyed by agent); the default path
9098
+ // keeps the historical GLOBAL check byte-for-byte (conservative: any
9099
+ // agent's pending cycle defers primary-bound messages).
9100
+ const midToolCycle = opts?.forAgent
9101
+ ? this.pendingAssistantBlocks.has(agent.name)
9102
+ : this.pendingAssistantBlocks.size > 0;
8522
9103
  if (
8523
9104
  !hasToolResult &&
8524
- (this.pendingAssistantBlocks.size > 0 ||
9105
+ (midToolCycle ||
8525
9106
  this.activeTurnTokens.has(agent.name) ||
8526
9107
  this.activeStreams.has(agent.name))
8527
9108
  ) {
8528
- this.deferredMessages.push({ participant, content, metadata });
8529
- return '' as MessageId; // Deferred — flushed at the next boundary
9109
+ this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
9110
+ return '' as MessageId; // Deferred — flushed at the target's next boundary
8530
9111
  }
8531
9112
 
8532
9113
  return agent.getContextManager().addMessage(participant, content, metadata);
8533
9114
  }
8534
9115
 
9116
+ /**
9117
+ * Splice out the deferred messages addressed to `agentName` (explicitly
9118
+ * via forAgent, or implicitly when it is the primary), leaving other
9119
+ * targets' messages queued for their own boundaries.
9120
+ */
9121
+ private drainDeferredFor(agentName: string): Array<{
9122
+ participant: string;
9123
+ content: ContentBlock[];
9124
+ metadata?: MessageMetadata;
9125
+ forAgent?: string;
9126
+ }> {
9127
+ if (this.deferredMessages.length === 0) return [];
9128
+ const mine: typeof this.deferredMessages = [];
9129
+ const rest: typeof this.deferredMessages = [];
9130
+ for (const msg of this.deferredMessages) {
9131
+ const target = msg.forAgent ?? this.primaryAgentName;
9132
+ (target === agentName ? mine : rest).push(msg);
9133
+ }
9134
+ this.deferredMessages = rest;
9135
+ return mine;
9136
+ }
9137
+
8535
9138
  private editMessage(id: MessageId, content: ContentBlock[]): void {
8536
9139
  const agent = this.primaryAgentName
8537
9140
  ? this.agents.get(this.primaryAgentName)
@@ -10922,6 +11525,53 @@ export class AgentFramework {
10922
11525
  * suppression window, optionally announces in the sticky channel, and ends
10923
11526
  * the turn (the agent goes idle immediately). `wake` clears sleep.
10924
11527
  */
11528
+ /** tune_out tool (residents): enter or cancel a channel tune-out. */
11529
+ private dispatchTuneOutToolCall(agentName: string, call: ToolCall): void {
11530
+ const coordinator = this.tuneOutCoordinator!;
11531
+ const input = (call.input ?? {}) as {
11532
+ channelId?: string; mode?: string;
11533
+ cadenceSeconds?: number; backlogCap?: number; maxWakes?: number;
11534
+ durationSeconds?: number;
11535
+ };
11536
+ const channelId = String(input.channelId ?? '');
11537
+ const entry = this.channelRegistry?.listChannelsRaw()
11538
+ .find((e) => e.descriptor.id === channelId);
11539
+ let result: { success: boolean; data?: unknown; error?: string; isError?: boolean };
11540
+ if (!entry) {
11541
+ result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
11542
+ } else if (input.mode === 'cancel') {
11543
+ const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
11544
+ result = r.ok
11545
+ ? { success: true, data: { cancelled: channelId } }
11546
+ : { success: false, error: r.error, isError: false };
11547
+ } else {
11548
+ const r = coordinator.enter(entry.serverId, channelId, {
11549
+ cadenceSeconds: input.cadenceSeconds,
11550
+ backlogCap: input.backlogCap,
11551
+ maxWakes: input.maxWakes,
11552
+ durationSeconds: input.durationSeconds,
11553
+ }, 'agent-tool');
11554
+ result = r.ok
11555
+ ? {
11556
+ success: true,
11557
+ data: {
11558
+ tunedOut: channelId,
11559
+ cadenceSeconds: r.params.cadenceSeconds,
11560
+ backlogCap: r.params.backlogCap,
11561
+ maxWakes: r.params.maxWakes,
11562
+ },
11563
+ }
11564
+ : { success: false, error: r.error, isError: false };
11565
+ }
11566
+ this.queue.push({
11567
+ type: 'tool-result',
11568
+ callId: call.id,
11569
+ agentName,
11570
+ moduleName: 'tune-out',
11571
+ result,
11572
+ });
11573
+ }
11574
+
10925
11575
  private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
10926
11576
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
10927
11577
  const gate = this.eventGate!;
@@ -11128,10 +11778,53 @@ export class AgentFramework {
11128
11778
  }
11129
11779
 
11130
11780
  /**
11131
- * Handle the synthesized `save_recent_image` tool: locate the requested
11132
- * image blocks in the calling agent's context (blobs re-inlined, counted
11133
- * back from the most recent) and write their bytes to a workspace mount
11134
- * via WorkspaceModule.writeBinary.
11781
+ * `save_recent_image` barrier. Module tool calls in a batch are only
11782
+ * ENQUEUED by dispatch, while the save runs at once — so a [snap, save]
11783
+ * round would inventory before the snapshot exists and silently save the
11784
+ * newest OLDER image (issue #104, same-batch representation). Wait for
11785
+ * every sibling in the round to settle; if one never does, fail closed
11786
+ * rather than trust an index the batch may still change. Sibling saves
11787
+ * produce no images and are not waited on (two saves in one batch would
11788
+ * otherwise deadlock into a double timeout). Script-inner calls
11789
+ * (`pytc-…`) run sequentially inside their script host and skip the
11790
+ * barrier — the host call itself is the only pending sibling they'd see.
11791
+ */
11792
+ private async awaitSiblingToolCalls(agent: Agent, call: ToolCall): Promise<void> {
11793
+ if (call.id.startsWith('pytc-')) return;
11794
+ const BARRIER_MS = 120_000;
11795
+ const POLL_MS = 20;
11796
+ const startedAt = Date.now();
11797
+ for (;;) {
11798
+ const state = agent.state;
11799
+ // Idle (puppet path) or the round was torn down: nothing to wait on.
11800
+ if (state.status !== 'waiting_for_tools') return;
11801
+ const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
11802
+ if (open.length === 0) return;
11803
+ if (Date.now() - startedAt > BARRIER_MS) {
11804
+ throw new Error(
11805
+ `save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
11806
+ `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
11807
+ 'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
11808
+ 'Save again once they have finished.',
11809
+ );
11810
+ }
11811
+ await new Promise((r) => setTimeout(r, POLL_MS));
11812
+ }
11813
+ }
11814
+
11815
+ /**
11816
+ * Handle the synthesized `save_recent_image` tool (issue #104): build ONE
11817
+ * ordered inventory of every image the calling agent has seen — attachment
11818
+ * image blocks (blobs re-inlined) AND tool-result images (their history
11819
+ * placeholders carry a ref, resolved through the agent's ToolImageLedger)
11820
+ * — counted back from the most recent, then write the requested bytes to
11821
+ * a workspace mount via WorkspaceModule.writeBinary.
11822
+ *
11823
+ * Fail-closed: when the image at a requested position cannot be produced
11824
+ * (bytes evicted, minted by an earlier process, or a placeholder written
11825
+ * before retention existed) the call fails AT THAT INDEX and writes
11826
+ * nothing. It never slides to an older image — a filename must not
11827
+ * authenticate bytes from another surface.
11135
11828
  */
11136
11829
  private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
11137
11830
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
@@ -11146,26 +11839,219 @@ export class AgentFramework {
11146
11839
  });
11147
11840
  this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
11148
11841
  };
11842
+ type Candidate =
11843
+ | { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number }
11844
+ | {
11845
+ kind: 'tool';
11846
+ ref: string | null;
11847
+ /** The tool_result block the placeholder was found in — NOT trusted
11848
+ * text; a resolved ref must belong to this very call. */
11849
+ toolCallId: string;
11850
+ toolName: string;
11851
+ mediaType: string;
11852
+ messagesBack: number;
11853
+ }
11854
+ | {
11855
+ kind: 'reference';
11856
+ refId: string;
11857
+ mediaType: string;
11858
+ toolCallId: string;
11859
+ toolName: string;
11860
+ messagesBack: number;
11861
+ };
11862
+ interface Resolved {
11863
+ rangeOffset: number;
11864
+ data: string;
11865
+ mediaType: string;
11866
+ receipt: Record<string, unknown>;
11867
+ }
11149
11868
  void (async (): Promise<void> => {
11150
11869
  try {
11151
11870
  const workspace = this.getWorkspaceModule();
11152
11871
  if (!workspace) throw new Error('save_recent_image requires a workspace module');
11153
11872
  const agent = this.agents.get(agentName);
11154
11873
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
11155
- const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown };
11874
+ const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown; ref?: unknown };
11156
11875
  if (typeof input.path !== 'string' || input.path.length === 0) {
11157
11876
  throw new Error('save_recent_image: `path` (mount-prefixed) is required');
11158
11877
  }
11159
- const index = input.index === undefined ? 0 : Number(input.index);
11160
- if (!Number.isInteger(index) || index < 0) {
11161
- throw new Error('save_recent_image: `index` must be a non-negative integer');
11162
- }
11163
- const count = input.count === undefined ? 1 : Number(input.count);
11164
- const MAX_COUNT = 20;
11165
- if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
11166
- throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
11878
+ const ledger = this.toolImageLedgerFor(agentName);
11879
+
11880
+ const describeTool = (c: { toolName: string; toolCallId: string }): string =>
11881
+ `${c.toolName} (call ${c.toolCallId})`;
11882
+ const unavailable = (status: 'evicted' | 'unknown'): string =>
11883
+ status === 'evicted'
11884
+ ? 'its bytes were evicted from the bounded retention budget'
11885
+ : 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
11886
+ 'very old refs age out; a mistyped ref looks the same)';
11887
+ const resolve = (candidate: Candidate, position: number): Resolved => {
11888
+ const rangeOffset = 0; // filled by the caller
11889
+ if (candidate.kind === 'attachment') {
11890
+ if (candidate.data === null) {
11891
+ throw new Error(
11892
+ `save_recent_image: image at index ${position} is not stored inline (base64) — cannot save it. Nothing written.`,
11893
+ );
11894
+ }
11895
+ const bytes = Buffer.from(candidate.data, 'base64');
11896
+ return {
11897
+ rangeOffset,
11898
+ data: candidate.data,
11899
+ mediaType: candidate.mediaType,
11900
+ receipt: {
11901
+ source: 'attachment',
11902
+ mediaType: candidate.mediaType,
11903
+ byteSize: bytes.byteLength,
11904
+ sha256: sha256Hex(bytes),
11905
+ messagesBack: candidate.messagesBack,
11906
+ },
11907
+ };
11908
+ }
11909
+ if (candidate.kind === 'reference') {
11910
+ throw new Error(
11911
+ `save_recent_image: image at index ${position} is a reference (${candidate.refId}, ${candidate.mediaType}, ` +
11912
+ `from ${describeTool(candidate)}) — its bytes are not held here. Nothing written. ` +
11913
+ 'Fetch it with fetch_reference and save the materialized file instead.',
11914
+ );
11915
+ }
11916
+ if (candidate.ref === null) {
11917
+ throw new Error(
11918
+ `save_recent_image: image at index ${position} is a tool-result image from ${describeTool(candidate)} ` +
11919
+ 'recorded without a retention ref (written before tool-image retention existed, or inside a ' +
11920
+ 'script run, where tool images are not retained) — its bytes are not available. Nothing written. ' +
11921
+ 'Re-run the tool for a fresh image rather than saving an older one.',
11922
+ );
11923
+ }
11924
+ const lookup = ledger.lookup(candidate.ref);
11925
+ // The placeholder came out of stored TEXT, which a tool result can
11926
+ // quote from anywhere (a fetch_history of a channel where someone
11927
+ // pasted their save receipt). A genuine placeholder is written by
11928
+ // its own call's serialization, so the ref's provenance names that
11929
+ // call; a quoted or forged one cannot. Refuse the mismatch.
11930
+ if (lookup.status !== 'unknown' && lookup.image.toolCallId !== candidate.toolCallId) {
11931
+ throw new Error(
11932
+ `save_recent_image: image at index ${position} cites ${candidate.ref}, but that image belongs to ` +
11933
+ `${describeTool(lookup.image)}, not to ${describeTool(candidate)} where the placeholder appears — ` +
11934
+ 'the placeholder is quoted or forged text, not this result\'s own image. Nothing written.',
11935
+ );
11936
+ }
11937
+ if (lookup.status !== 'retained') {
11938
+ throw new Error(
11939
+ `save_recent_image: image at index ${position} (${candidate.ref}, from ${describeTool(candidate)}) ` +
11940
+ `is no longer retained — ${unavailable(lookup.status)}. Nothing written. ` +
11941
+ 'Re-run the tool for a fresh image rather than saving an older one.',
11942
+ );
11943
+ }
11944
+ const image = lookup.image;
11945
+ return {
11946
+ rangeOffset,
11947
+ data: image.data,
11948
+ mediaType: image.mediaType,
11949
+ receipt: {
11950
+ source: 'tool-result',
11951
+ ref: image.ref,
11952
+ toolName: image.toolName,
11953
+ toolCallId: image.toolCallId,
11954
+ blockIndex: image.blockIndex,
11955
+ mediaType: image.mediaType,
11956
+ byteSize: image.byteSize,
11957
+ sha256: image.sha256,
11958
+ messagesBack: candidate.messagesBack,
11959
+ },
11960
+ };
11961
+ };
11962
+
11963
+ // Barrier: a save dispatched alongside other calls in one batch must
11964
+ // not inventory before their results exist — those are the newest
11965
+ // images. Waits (bounded) for every sibling to settle, or fails closed.
11966
+ await this.awaitSiblingToolCalls(agent, call);
11967
+
11968
+ const found: Resolved[] = [];
11969
+ let index = 0;
11970
+ let count = 1;
11971
+ /** Direct-ref mode: the one ref to find in the scannable context. */
11972
+ let wantedRef: string | null = null;
11973
+ if (input.ref !== undefined) {
11974
+ if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
11975
+ throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
11976
+ }
11977
+ if (input.index !== undefined || input.count !== undefined) {
11978
+ throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
11979
+ }
11980
+ wantedRef = input.ref;
11981
+ } else {
11982
+ index = input.index === undefined ? 0 : Number(input.index);
11983
+ if (!Number.isInteger(index) || index < 0) {
11984
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
11985
+ }
11986
+ count = input.count === undefined ? 1 : Number(input.count);
11987
+ const MAX_COUNT = 20;
11988
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
11989
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
11990
+ }
11167
11991
  }
11168
11992
  const lastWanted = index + count - 1;
11993
+ let seen = 0;
11994
+ const collecting = (): boolean => (wantedRef !== null ? found.length === 0 : found.length < count);
11995
+ // One walk serves both modes. By index: the requested range of the
11996
+ // ordered inventory. By ref: the slot whose placeholder cites that
11997
+ // ref — the ref is resolved THROUGH its occurrence in context, so
11998
+ // the same provenance cross-check applies (a ref that is not visible
11999
+ // in the scannable window — an undone branch, quoted text naming a
12000
+ // real ref — is not saveable by name either).
12001
+ const consider = (candidate: Candidate): void => {
12002
+ if (wantedRef !== null) {
12003
+ if (candidate.kind === 'tool' && candidate.ref === wantedRef && found.length === 0) {
12004
+ found.push(resolve(candidate, seen));
12005
+ }
12006
+ } else if (seen >= index && seen <= lastWanted) {
12007
+ const resolved = resolve(candidate, seen);
12008
+ resolved.rangeOffset = seen - index;
12009
+ found.push(resolved);
12010
+ }
12011
+ seen++;
12012
+ };
12013
+ const considerToolResultImages = (
12014
+ toolCallId: string,
12015
+ toolName: string,
12016
+ placeholders: ParsedImagePlaceholder[],
12017
+ messagesBack: number,
12018
+ ): void => {
12019
+ for (let p = placeholders.length - 1; p >= 0 && collecting(); p--) {
12020
+ const ph = placeholders[p]!;
12021
+ if (ph.kind === 'inline') {
12022
+ consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
12023
+ continue;
12024
+ }
12025
+ // Reference stub: the registry's testimony (when this process
12026
+ // still has the record) says whether it was an image; the
12027
+ // sniffed stub text is the fallback. Non-image references
12028
+ // are not slots.
12029
+ const testimony = referenceRegistry.get(ph.refId)?.testimony.mimeType?.toLowerCase() ?? ph.mediaType;
12030
+ if (!testimony || !testimony.startsWith('image/')) continue;
12031
+ consider({ kind: 'reference', refId: ph.refId, mediaType: testimony, toolCallId, toolName, messagesBack });
12032
+ }
12033
+ };
12034
+
12035
+ // Same-batch results (e.g. a snapshot dispatched alongside the save)
12036
+ // are not in the store yet but ARE the newest images. Classify them
12037
+ // with the very serializer the commit path will run — contradiction
12038
+ // blocks withheld, uri-form blocks as reference stubs, unknown shapes
12039
+ // as no slot at all — so the inventory means the same thing before
12040
+ // and after commit. Retention is idempotent per block, so the
12041
+ // placeholder written at commit time carries the same ref.
12042
+ const state = agent.state;
12043
+ if (state.status === 'waiting_for_tools') {
12044
+ for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
12045
+ const tc = state.completed[i]!;
12046
+ if (tc.result.isError) continue;
12047
+ const text = toolResultDataToHistoryString(tc.result.data, undefined, {
12048
+ imageRef: (blockIndex, image) => ledger.retain({
12049
+ toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
12050
+ }).ref,
12051
+ });
12052
+ considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
12053
+ }
12054
+ }
11169
12055
 
11170
12056
  // Walk the message store tail-first in bounded windows, re-inlining
11171
12057
  // blob media, until we've collected the requested range. Scanning is
@@ -11175,45 +12061,56 @@ export class AgentFramework {
11175
12061
  const WINDOW = 25;
11176
12062
  const cm = agent.getContextManager();
11177
12063
  const total = cm.getMessageCount();
11178
- let seen = 0;
11179
12064
  let scanned = 0;
11180
- const found: Array<{
11181
- rangeOffset: number;
11182
- data: string;
11183
- mediaType: string;
11184
- messagesBack: number;
11185
- }> = [];
11186
- for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
12065
+ for (let end = total; end > 0 && scanned < MAX_SCAN && collecting(); end -= WINDOW) {
11187
12066
  const start = Math.max(0, end - WINDOW);
11188
- const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
12067
+ // By ref, attachment bytes are never needed — skip blob resolution.
12068
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: wantedRef === null });
11189
12069
  scanned += end - start;
11190
- for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
12070
+ for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
11191
12071
  const content = messages[i]?.content;
11192
12072
  if (!Array.isArray(content)) continue;
11193
- for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
12073
+ const messagesBack = total - (start + i);
12074
+ for (let b = content.length - 1; b >= 0 && collecting(); b--) {
11194
12075
  const block = content[b] as {
11195
12076
  type?: string;
11196
12077
  source?: { type?: string; data?: string; mediaType?: string };
12078
+ toolUseId?: string;
12079
+ toolName?: string;
12080
+ content?: unknown;
11197
12081
  };
11198
- if (block?.type !== 'image') continue;
11199
- if (seen >= index && seen <= lastWanted) {
11200
- if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
11201
- throw new Error(
11202
- `save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`,
11203
- );
11204
- }
11205
- found.push({
11206
- rangeOffset: seen - index,
11207
- data: block.source.data,
11208
- mediaType: block.source.mediaType ?? 'image/png',
11209
- messagesBack: total - (start + i),
12082
+ if (block?.type === 'image') {
12083
+ const inline = block.source?.type === 'base64' && typeof block.source.data === 'string';
12084
+ consider({
12085
+ kind: 'attachment',
12086
+ data: inline ? block.source!.data! : null,
12087
+ mediaType: block.source?.mediaType ?? 'image/png',
12088
+ messagesBack,
11210
12089
  });
12090
+ } else if (block?.type === 'tool_result' && typeof block.content === 'string') {
12091
+ considerToolResultImages(
12092
+ block.toolUseId ?? 'unknown',
12093
+ block.toolName ?? 'unknown tool',
12094
+ parseImagePlaceholders(block.content),
12095
+ messagesBack,
12096
+ );
11211
12097
  }
11212
- seen++;
11213
12098
  }
11214
12099
  }
11215
12100
  }
11216
12101
  if (found.length === 0) {
12102
+ if (wantedRef !== null) {
12103
+ const lookup = ledger.lookup(wantedRef);
12104
+ throw new Error(
12105
+ lookup.status === 'unknown'
12106
+ ? `save_recent_image: ref ${wantedRef} cannot be saved — ${unavailable('unknown')}. Nothing written.`
12107
+ : `save_recent_image: ref ${wantedRef} (from ${describeTool(lookup.image)}) does not appear in your recent ` +
12108
+ `context (scanned ${Math.min(scanned, MAX_SCAN)} messages) — a ref is saved through its placeholder, ` +
12109
+ 'which must be visible to you' +
12110
+ (lookup.status === 'evicted' ? '; its bytes were also evicted from the bounded retention budget' : '') +
12111
+ '. Nothing written.',
12112
+ );
12113
+ }
11217
12114
  throw new Error(
11218
12115
  seen === 0
11219
12116
  ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
@@ -11252,7 +12149,7 @@ export class AgentFramework {
11252
12149
  savedFiles.push({
11253
12150
  ...(result.data as Record<string, unknown>),
11254
12151
  imageIndex: index + image.rangeOffset,
11255
- messagesBack: image.messagesBack,
12152
+ ...image.receipt,
11256
12153
  });
11257
12154
  }
11258
12155
  finish({