@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
@@ -4,7 +4,9 @@ import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } fro
4
4
  import { appendFileSync, mkdirSync } from 'node:fs';
5
5
  import { JsStore } from '@animalabs/chronicle';
6
6
  import { MembraneError } from '@animalabs/membrane';
7
- import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
7
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
8
+ import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
9
+ import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
8
10
  import { ProcessQueueImpl } from './queue.js';
9
11
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
10
12
  import { Agent } from './agent.js';
@@ -17,11 +19,13 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
17
19
  import { PushHandler } from './mcpl/push-handler.js';
18
20
  import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
19
21
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
22
+ import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
20
23
  import { InferenceRouter } from './mcpl/inference-router.js';
21
24
  import { ChannelRegistry } from './mcpl/channel-registry.js';
22
25
  import { ConversationRouter } from './mcpl/conversation-router.js';
23
26
  import { safeSlice } from './safe-slice.js';
24
27
  import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
28
+ import { TOOL_IMAGE_REF_RE, ToolImageLedger, formatPreservedImageSlots, parseImagePlaceholders, sha256Hex, splitPreservingImageSlots, } from './tool-image-ledger.js';
25
29
  import { randomUUID } from 'node:crypto';
26
30
  import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
27
31
  import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
@@ -522,6 +526,16 @@ export class AgentFramework {
522
526
  frameworkCancelledStreams = new Map();
523
527
  /** Active runEphemeralToCompletion runs, keyed by agent name. */
524
528
  ephemeralRuns = new Map();
529
+ /** Ephemeral namespaces/names are single-generation for this framework
530
+ * lifetime. Reusing one while an old stream drains would let name-keyed
531
+ * liveness/settlement state cross generations. */
532
+ usedEphemeralAgentNames = new Set();
533
+ /** One-shot generation tickets minted by createEphemeralAgent. */
534
+ ephemeralCandidates = new WeakMap();
535
+ /** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
536
+ * The name is single-generation, so these frames still own their terminal
537
+ * typing/outgoing close even after runEphemeralToCompletion deregisters them. */
538
+ disposedEphemeralFrames = new WeakSet();
525
539
  /** Per-agent count of consecutive exhausted inferences (reset on any success).
526
540
  * Drives hard-down escalation — see noteInferenceExhausted. */
527
541
  consecutiveInferenceFailures = new Map();
@@ -569,6 +583,14 @@ export class AgentFramework {
569
583
  primaryAgentName = null;
570
584
  // Messages deferred while an agent is waiting_for_tools (to preserve
571
585
  // tool_use → tool_result adjacency required by the Anthropic API).
586
+ /** The subconscious resident's registry name (issue #77), or null. */
587
+ subconsciousAgentName = null;
588
+ /** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */
589
+ subconsciousStrategy = null;
590
+ /** Its config (speak_in_channel gate, voice block provenance). */
591
+ subconsciousConfig = null;
592
+ /** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
593
+ tuneOutCoordinator = null;
572
594
  deferredMessages = [];
573
595
  // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
574
596
  // token of the turn currently in progress. Set at startAgentStream ENTRY —
@@ -599,6 +621,9 @@ export class AgentFramework {
599
621
  getActiveTurnTrigger(agentName) {
600
622
  return this.activeTurnTriggers.get(agentName);
601
623
  }
624
+ /** Structured calls executed in the current logical turn. Unlike the
625
+ * per-stream local this survives framework retries and budget restarts. */
626
+ logicalTurnToolCalls = new WeakMap();
602
627
  nextTurnToken = 1;
603
628
  // Undo/redo state
604
629
  turnCounters = new Map(); // agentName → next turnIndex
@@ -649,6 +674,11 @@ export class AgentFramework {
649
674
  * framework state like the core runtime settings and restored at create
650
675
  * (antra + Sol, 08-06); reset clears it back to the residence default. */
651
676
  toolResultInlineMaxCharsOverride = new Map();
677
+ /** Per-agent retention of tool-result images with provenance (issue
678
+ * #104). In-memory and bounded by design: history keeps only the
679
+ * placeholder; this is what lets `save_recent_image` reach the bytes the
680
+ * resident actually saw — or fail at that exact index when they're gone. */
681
+ toolImageLedgers = new Map();
652
682
  /** Durable residence-configured inline cap from
653
683
  * FrameworkConfig.toolResultInlineMaxChars; null → house default. */
654
684
  toolResultInlineMaxCharsConfig = null;
@@ -810,6 +840,12 @@ export class AgentFramework {
810
840
  for (const agentConfig of config.agents) {
811
841
  await framework.createAgent(agentConfig);
812
842
  }
843
+ // The subconscious resident (issue #77) registers after the residents so
844
+ // it can never become primary; it is excluded from every broadcast
845
+ // fan-out and woken only by the tune-out coordinator's explicit pushes.
846
+ if (config.subconscious?.enabled) {
847
+ await framework.createSubconsciousAgent(config.subconscious);
848
+ }
813
849
  // Finish any branch-local suppression interrupted after Chronicle switched
814
850
  // branches. This runs before modules, MCPL connections, or inbound traffic.
815
851
  await framework.resumePreparedDiscordSuppressions();
@@ -854,11 +890,20 @@ export class AgentFramework {
854
890
  initialConfig: config.gate.config,
855
891
  privilegedUsersPath: config.gate.privilegedUsersPath,
856
892
  emitTrace: (e) => framework.emitTrace(e),
857
- addMessage: (p, c, m) => framework.addMessage(p, c, m),
858
- requestInference: (agentName, reason, source) => {
859
- framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
893
+ addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m, forAgent ? { forAgent } : undefined),
894
+ requestInference: (agentName, reason, source, provenance) => {
895
+ framework.pendingRequests.push({
896
+ agentName, reason, source, timestamp: Date.now(),
897
+ // gate-requested wakes carry where/who (EventGate wakeProvenance)
898
+ // as TELEMETRY fields — the host's stamp reads them; the turn's
899
+ // speech locus (channelId / addressed) is deliberately NOT set
900
+ // here, so a batched wake routes exactly as it did before.
901
+ ...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
902
+ ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
903
+ ...(provenance?.at ? { wakeAt: provenance.at } : {}),
904
+ });
860
905
  },
861
- getAgentNames: () => [...framework.agents.keys()],
906
+ getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
862
907
  });
863
908
  }
864
909
  // Liveness watchdog: fail hard if the main thread wedges (opt-in).
@@ -909,6 +954,30 @@ export class AgentFramework {
909
954
  }
910
955
  await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
911
956
  }
957
+ // Tune-out coordinator (issue #77): needs both the subconscious resident
958
+ // and the MCPL channel subsystem; created after both exist.
959
+ if (framework.subconsciousAgentName && framework.channelRegistry) {
960
+ framework.tuneOutCoordinator = new TuneOutCoordinator(framework.channelRegistry, framework.mcplServerRegistry, {
961
+ addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata, forAgent ? { forAgent } : undefined),
962
+ requestInference: (agentName, reason, source) => {
963
+ framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
964
+ },
965
+ subconsciousName: () => framework.subconsciousAgentName,
966
+ primaryName: () => framework.primaryAgentName,
967
+ getStoredMessages: () => {
968
+ const primary = framework.primaryAgentName
969
+ ? framework.agents.get(framework.primaryAgentName) : undefined;
970
+ return primary ? primary.getContextManager().getAllMessages() : [];
971
+ },
972
+ currentSequence: () => framework.store.currentSequence(),
973
+ setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence),
974
+ isForkBound: (channelId) => [...framework.conversationAgentHomes.values()].includes(channelId),
975
+ isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false,
976
+ allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech,
977
+ emitTrace: (e) => framework.emitTrace(e),
978
+ });
979
+ framework.tuneOutCoordinator.resumeActiveEpochs();
980
+ }
912
981
  // Diagnostics: `kill -USR2 <pid>` dumps live wake/inference state to stderr
913
982
  // (journal) without a restart — for catching the wake-wedge on the running
914
983
  // process. Shows the gate's `inferring` set + buffered-event count (the
@@ -973,6 +1042,7 @@ export class AgentFramework {
973
1042
  this.running = false;
974
1043
  this.providerAdmissionClosed = true;
975
1044
  this.queue.close();
1045
+ this.tuneOutCoordinator?.stop();
976
1046
  // Kill running code_execution scripts before cancelling streams: a
977
1047
  // zombie script must not keep firing side-effectful tool calls into a
978
1048
  // framework that is shutting down.
@@ -991,6 +1061,9 @@ export class AgentFramework {
991
1061
  record.runner.dispose();
992
1062
  }
993
1063
  this.backgroundScripts.clear();
1064
+ // Retained tool images are per-process by design; a retained stopped
1065
+ // framework must not keep up to the whole ledger budget referenced.
1066
+ this.toolImageLedgers.clear();
994
1067
  // A stopped host must never hang behind its own cooldown.
995
1068
  this.cancelProviderAdmission();
996
1069
  // Cancel all active streams
@@ -1404,7 +1477,12 @@ export class AgentFramework {
1404
1477
  */
1405
1478
  getAllTools() {
1406
1479
  const moduleTools = this.moduleRegistry.getAllTools();
1407
- const channelTools = this.channelRegistry?.getChannelTools() ?? [];
1480
+ // Copy: getChannelTools() returns the registry's shared definitions
1481
+ // array; pushing onto it would append another tune_out on every call.
1482
+ const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])];
1483
+ if (this.tuneOutCoordinator) {
1484
+ channelTools.push(AgentFramework.TUNE_OUT_TOOL);
1485
+ }
1408
1486
  const gateTools = this.eventGate
1409
1487
  ? [
1410
1488
  this.eventGate.getToolDefinition(),
@@ -1634,6 +1712,18 @@ export class AgentFramework {
1634
1712
  };
1635
1713
  }
1636
1714
  getToolsForAgent(agentName, snapshot) {
1715
+ // The subconscious gets its own small surface (plus think/skip_reply/
1716
+ // end_turn), not the residents' tool board: no channel lifecycle, no
1717
+ // gate rules, no workspace — it observes, judges, and reports.
1718
+ if (agentName === this.subconsciousAgentName) {
1719
+ const basics = this.getAllTools()
1720
+ .filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn')
1721
+ .map((t) => t.name === 'think'
1722
+ ? this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
1723
+ ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy)
1724
+ : t);
1725
+ return [...SUBCONSCIOUS_TOOLS, ...basics];
1726
+ }
1637
1727
  return this.getAllTools().map((tool) => {
1638
1728
  if (tool.name === 'think') {
1639
1729
  return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
@@ -1989,21 +2079,35 @@ export class AgentFramework {
1989
2079
  * Call cleanup() when done to release the ContextManager.
1990
2080
  */
1991
2081
  async createEphemeralAgent(config) {
1992
- const namespace = `subagent/${config.name}`;
1993
- const contextManager = await ContextManager.open({
1994
- store: this.store,
1995
- namespace,
1996
- isolate: true,
1997
- strategy: config.strategy ?? new PassthroughStrategy(),
1998
- membrane: this.membrane,
1999
- debugLogContext: !!process.env.DEBUG_CONTEXT,
2000
- });
2001
- const agent = new Agent(config, contextManager, this.membrane);
2002
- const cleanup = () => {
2003
- // Don't close the store — it's shared. Just release the CM.
2004
- // Data persists in the store under the namespace for investigation.
2005
- };
2006
- return { agent, contextManager, cleanup };
2082
+ // Names are Chronicle namespaces and generation identities. Reserve before
2083
+ // opening the namespace so a second creation cannot append to an earlier
2084
+ // generation before runEphemeralToCompletion has a chance to reject it.
2085
+ if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
2086
+ throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
2087
+ }
2088
+ this.usedEphemeralAgentNames.add(config.name);
2089
+ try {
2090
+ const namespace = `subagent/${config.name}`;
2091
+ const contextManager = await ContextManager.open({
2092
+ store: this.store,
2093
+ namespace,
2094
+ isolate: true,
2095
+ strategy: config.strategy ?? new PassthroughStrategy(),
2096
+ membrane: this.membrane,
2097
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
2098
+ });
2099
+ const agent = new Agent(config, contextManager, this.membrane);
2100
+ this.ephemeralCandidates.set(agent, contextManager);
2101
+ const cleanup = () => {
2102
+ // Don't close the store — it's shared. Just release the CM.
2103
+ // Data persists in the store under the namespace for investigation.
2104
+ };
2105
+ return { agent, contextManager, cleanup };
2106
+ }
2107
+ catch (error) {
2108
+ this.usedEphemeralAgentNames.delete(config.name);
2109
+ throw error;
2110
+ }
2007
2111
  }
2008
2112
  createDeferred() {
2009
2113
  let resolve;
@@ -2024,6 +2128,18 @@ export class AgentFramework {
2024
2128
  }
2025
2129
  run.lastActivity = Date.now();
2026
2130
  }
2131
+ /** Record structured calls for the current logical turn. Ignore late stream
2132
+ * events after an ephemeral/conversation agent has been disposed. */
2133
+ recordLogicalTurnToolCalls(agent, turnToken, count) {
2134
+ if (count <= 0 || this.agents.get(agent.name) !== agent)
2135
+ return;
2136
+ const state = this.logicalTurnToolCalls.get(agent);
2137
+ // The Agent object is the generation: a late stream from a disposed
2138
+ // ephemeral/conversation fork cannot contaminate a same-name successor.
2139
+ if (!state || state.turnToken !== turnToken)
2140
+ return;
2141
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: state.count + count });
2142
+ }
2027
2143
  recordEphemeralToolCalls(agentName, count) {
2028
2144
  const run = this.ephemeralRuns.get(agentName);
2029
2145
  if (!run)
@@ -2060,7 +2176,18 @@ export class AgentFramework {
2060
2176
  * The task message should already be in the context manager.
2061
2177
  */
2062
2178
  async runEphemeralToCompletion(agent, contextManager, watchdogs) {
2063
- // Register temporarily so the event loop can drive it
2179
+ // Only a fresh object returned by createEphemeralAgent may enter this path.
2180
+ // Never overwrite a resident/conversation owner or a concurrent run: their
2181
+ // cleanup is name-keyed and could cancel/deregister the legitimate owner.
2182
+ if (this.ephemeralCandidates.get(agent) !== contextManager) {
2183
+ throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
2184
+ }
2185
+ // Consume before any await/registration: the exact (Agent, ContextManager)
2186
+ // generation is one-shot even if startup later fails.
2187
+ this.ephemeralCandidates.delete(agent);
2188
+ if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
2189
+ throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
2190
+ }
2064
2191
  this.agents.set(agent.name, agent);
2065
2192
  const run = {
2066
2193
  settle: this.createDeferred(),
@@ -2119,8 +2246,27 @@ export class AgentFramework {
2119
2246
  clearTimeout(startupWatchdog);
2120
2247
  if (completionWatchdog)
2121
2248
  clearInterval(completionWatchdog);
2122
- this.ephemeralRuns.delete(agent.name);
2123
- this.agents.delete(agent.name);
2249
+ // Mark before cancellation: an adapter may synchronously settle its
2250
+ // iterator, and this physical frame still owns terminal typing/outgoing
2251
+ // closure even though the ephemeral name is about to be deregistered.
2252
+ this.disposedEphemeralFrames.add(agent);
2253
+ // Cancel before deregistration. Adapters may still yield a queued event;
2254
+ // driveStream's Agent-identity check above discards it before dispatch.
2255
+ agent.cancelStream();
2256
+ if (this.ephemeralRuns.get(agent.name) === run)
2257
+ this.ephemeralRuns.delete(agent.name);
2258
+ if (this.agents.get(agent.name) === agent) {
2259
+ // Exact-generation disposal owns these name-keyed entries. Remove them
2260
+ // before deregistration; the late driveStream finally will see the
2261
+ // generation mismatch and leave any future owner untouched.
2262
+ this.activeStreams.delete(agent.name);
2263
+ this.pendingAssistantBlocks.delete(agent.name);
2264
+ // The late stream finalizer intentionally refuses name-keyed cleanup once
2265
+ // deregistered, so disposal must release EventGate liveness itself.
2266
+ this.eventGate?.onInferenceEnded(agent.name);
2267
+ this.agents.delete(agent.name);
2268
+ }
2269
+ this.toolImageLedgers.delete(agent.name);
2124
2270
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2125
2271
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2126
2272
  // entries behind for the life of the store/session.
@@ -2132,6 +2278,7 @@ export class AgentFramework {
2132
2278
  // of the map, and a late driveStream finally token-match no-ops.
2133
2279
  this.activeTurnTokens.delete(agent.name);
2134
2280
  this.activeTurnTriggers.delete(agent.name);
2281
+ this.logicalTurnToolCalls.delete(agent);
2135
2282
  }
2136
2283
  }
2137
2284
  /**
@@ -2144,6 +2291,44 @@ export class AgentFramework {
2144
2291
  * Query inference logs.
2145
2292
  * Returns entries with summary info (doesn't resolve blobs).
2146
2293
  */
2294
+ /** Tune-out (issue #77): divert a channel to your subconscious. Present
2295
+ * only when the subconscious resident is configured. */
2296
+ static TUNE_OUT_TOOL = {
2297
+ name: 'tune_out',
2298
+ description: 'Divert a channel to your subconscious for a while: its traffic stops ' +
2299
+ 'entering your context and stops waking you. Your subconscious watches ' +
2300
+ 'it instead — summarizing on a cadence in its own voice, judging ' +
2301
+ 'whether mentions merit interrupting, and able to cancel. People who ' +
2302
+ 'mention you get an automatic reaction so they know you saw nothing. ' +
2303
+ 'Cancelling (mode "cancel") delivers the diverted backlog. The ' +
2304
+ 'tune-out auto-cancels after maxWakes mention-bursts.',
2305
+ inputSchema: {
2306
+ type: 'object',
2307
+ properties: {
2308
+ channelId: { type: 'string' },
2309
+ mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
2310
+ cadenceSeconds: {
2311
+ type: 'number',
2312
+ description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
2313
+ },
2314
+ backlogCap: {
2315
+ type: 'number',
2316
+ description: `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}). ` +
2317
+ '0 = deliver no raw backlog at all: your subconscious\'s report is the whole delivery.',
2318
+ },
2319
+ maxWakes: {
2320
+ type: 'number',
2321
+ description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}; floors at 1).`,
2322
+ },
2323
+ durationSeconds: {
2324
+ type: 'number',
2325
+ description: 'Optional bounded duration: the tune-out auto-cancels (backlog ' +
2326
+ 'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
2327
+ },
2328
+ },
2329
+ required: ['channelId'],
2330
+ },
2331
+ };
2147
2332
  /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
2148
2333
  static SLEEP_TOOLS = [
2149
2334
  {
@@ -2324,11 +2509,18 @@ export class AgentFramework {
2324
2509
  static SAVE_IMAGE_TOOL = {
2325
2510
  name: 'save_recent_image',
2326
2511
  description: 'Save one or more recent images from your own context to workspace files. ' +
2327
- 'Images are counted back from the most recent (index 0). A single image is ' +
2512
+ 'Images are counted back from the most recent (index 0), across BOTH ' +
2513
+ 'attachments in messages and images returned by tools (their history ' +
2514
+ 'placeholder shows a ref like `[image: image/png, ~691KB, ref img_k7x3q2_7]`; pass ' +
2515
+ '`ref` to save that exact image regardless of position). A single image is ' +
2328
2516
  'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
2329
2517
  'the range index..index+count-1 is saved with numeric suffixes ' +
2330
- '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
2331
- 'visible via workspace tools and the /files/ endpoint.',
2518
+ '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). If the image at ' +
2519
+ 'the requested position is no longer retained (evicted, or from before a ' +
2520
+ 'restart) the call fails and writes nothing — it never substitutes an older ' +
2521
+ 'image. When other tool calls in the same batch are still running, the save ' +
2522
+ 'waits for them so their images are counted. Receipts carry source tool call, MIME, byte size and SHA-256. Saved ' +
2523
+ 'files are visible via workspace tools and the /files/ endpoint.',
2332
2524
  inputSchema: {
2333
2525
  type: 'object',
2334
2526
  properties: {
@@ -2344,6 +2536,11 @@ export class AgentFramework {
2344
2536
  type: 'number',
2345
2537
  description: 'How many images to save, starting at `index` and going further back. Default 1.',
2346
2538
  },
2539
+ ref: {
2540
+ type: 'string',
2541
+ description: '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. ' +
2542
+ 'Mutually exclusive with `index`/`count`.',
2543
+ },
2347
2544
  },
2348
2545
  required: ['path'],
2349
2546
  },
@@ -3395,6 +3592,15 @@ export class AgentFramework {
3395
3592
  // streaming agent keeps the original Membrane.
3396
3593
  membrane: this.auxiliaryMembraneFor(config.name),
3397
3594
  debugLogContext: !!process.env.DEBUG_CONTEXT,
3595
+ // Tune-out (issue #77): messages stamped at ingestion as diverted
3596
+ // (metadata.tuneOut = { epochId }) never enter a resident's compiled
3597
+ // view — not at emission, not via chunking into the memory pyramid.
3598
+ // The stamp is permanent: after cancel, the backlog arrives as one
3599
+ // appended <tuned-out-backlog> dump instead of retro-inserting into
3600
+ // the timeline (KV-prefix-stable delivery). The stored originals
3601
+ // remain in the shared slot for the subconscious's merged view,
3602
+ // fetch_history, and audit.
3603
+ viewFilter: (message) => !message.metadata?.tuneOut,
3398
3604
  });
3399
3605
  const agent = new Agent(config, contextManager, this.membrane);
3400
3606
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
@@ -3409,6 +3615,68 @@ export class AgentFramework {
3409
3615
  }
3410
3616
  return agent;
3411
3617
  }
3618
+ /**
3619
+ * Create and register the subconscious resident (issue #77): a persistent
3620
+ * side-agent with an isolated slot (`subconscious/<primary>`) whose
3621
+ * strategy view merges the residents' shared timeline with its own —
3622
+ * including the tuned-out-stamped messages the residents' viewFilter
3623
+ * excludes; the backlog is precisely what it exists to see. Windowed
3624
+ * passthrough, no memory pyramid: its durable output is what it delivers
3625
+ * into the resident's window, which accumulates there.
3626
+ */
3627
+ async createSubconsciousAgent(cfg) {
3628
+ const primaryName = this.primaryAgentName;
3629
+ const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
3630
+ if (!primaryName || !primaryConfig) {
3631
+ throw new Error('subconscious requires a primary agent to attend to');
3632
+ }
3633
+ const name = cfg.name ?? 'Subconscious';
3634
+ if (this.agents.has(name)) {
3635
+ throw new Error(`subconscious name "${name}" collides with a registered agent`);
3636
+ }
3637
+ const strategy = new WindowedPassthroughStrategy({
3638
+ reAnchorFraction: cfg.reAnchorFraction,
3639
+ // Mirror the principal's per-message truncation ceiling so both see
3640
+ // oversized tool results / attachments under the same policy.
3641
+ maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
3642
+ });
3643
+ const contextManager = await ContextManager.open({
3644
+ store: this.store,
3645
+ namespace: `subconscious/${primaryName}`,
3646
+ isolate: true,
3647
+ // The residents' shared un-namespaced slot, merged read-only and
3648
+ // UNfiltered (no viewFilter here — stamped messages included).
3649
+ auxiliaryMessageViews: [{}],
3650
+ strategy,
3651
+ membrane: this.membrane,
3652
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
3653
+ });
3654
+ // Inherit the principal's inference settings wholesale (fork-template
3655
+ // idiom): reasoning effort (thinking), provider params, token ceilings,
3656
+ // caching, refusal handling, timezone — a same-model side-process must
3657
+ // not silently run under different inference conditions than the
3658
+ // resident it serves. Only identity- and surface-defining fields are
3659
+ // overridden below.
3660
+ const agentConfig = {
3661
+ ...primaryConfig,
3662
+ name,
3663
+ model: cfg.model ?? primaryConfig.model,
3664
+ systemPrompt: cfg.systemPrompt,
3665
+ strategy: undefined, // its CM owns the windowed strategy instance
3666
+ // Prose is never auto-routed: a cadence-triggered turn carries no
3667
+ // locus, and bare prose must not fall through to the default channel.
3668
+ // Speech happens only via speak_in_channel (its own marked voice).
3669
+ proseRouting: 'explicit',
3670
+ allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
3671
+ };
3672
+ const agent = new Agent(agentConfig, contextManager, this.membrane);
3673
+ this.agents.set(name, agent);
3674
+ this.agentConfigs.set(name, agentConfig);
3675
+ this.subconsciousAgentName = name;
3676
+ this.subconsciousStrategy = strategy;
3677
+ this.subconsciousConfig = cfg;
3678
+ return agent;
3679
+ }
3412
3680
  async runLoop() {
3413
3681
  while (this.running) {
3414
3682
  try {
@@ -3508,7 +3776,7 @@ export class AgentFramework {
3508
3776
  // history copy and the live wire copy below — the two must stay
3509
3777
  // byte-matched (the window stores what the membrane sends;
3510
3778
  // divergence breaks the compile prefix).
3511
- const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
3779
+ const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
3512
3780
  agent.getContextManager().addMessage('user', toolResultContent);
3513
3781
  // Flush any messages that were deferred while this turn was in
3514
3782
  // flight. Route to the PRIMARY agent — deferred messages are
@@ -3533,12 +3801,11 @@ export class AgentFramework {
3533
3801
  // queued, they flush at the target's own next boundary — with
3534
3802
  // injection — or in driveStream's finally when its turn ends.
3535
3803
  const midTurnInjections = [];
3536
- if (this.deferredMessages.length > 0) {
3537
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
3538
- if (target === agent) {
3539
- const deferred = this.deferredMessages.splice(0);
3804
+ {
3805
+ const deferred = this.drainDeferredFor(agent.name);
3806
+ if (deferred.length > 0) {
3540
3807
  for (const msg of deferred) {
3541
- target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
3808
+ agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
3542
3809
  // Injection guards: tool blocks would corrupt the tool-cycle
3543
3810
  // structure the membrane enforces, and a message named as the
3544
3811
  // agent itself would render as an ASSISTANT turn on the wire
@@ -3835,7 +4102,7 @@ export class AgentFramework {
3835
4102
  // Broadcast requests exclude conversation forks — they are driven by
3836
4103
  // their own channel's messages, not by framework-wide events.
3837
4104
  const targetAgents = response.requestInference === true
3838
- ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
4105
+ ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
3839
4106
  : response.requestInference;
3840
4107
  for (const agentName of targetAgents) {
3841
4108
  const agent = this.agents.get(agentName);
@@ -3902,11 +4169,31 @@ export class AgentFramework {
3902
4169
  Object.assign(metadata, invitation.metadataPatch);
3903
4170
  }
3904
4171
  }
4172
+ // Tune-out divert (issue #77): stamped, stored, no resident wake. The
4173
+ // coordinator has already handled wake bookkeeping (coalesced
4174
+ // subconscious invocation, durable count, suppression ack) before we
4175
+ // stamp; the subconscious reads the message through its merged view.
4176
+ const divert = this.tuneOutCoordinator?.onIncoming(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id,
4177
+ // The resident's gate verdict rides the event; it preconditions
4178
+ // subconscious wakes (gate composes, it is not replaced).
4179
+ event.triggerInference !== false) ?? null;
4180
+ if (divert) {
4181
+ metadata.tuneOut = { epochId: divert.epochId };
4182
+ }
3905
4183
  const id = this.addMessage('user', incomingContent, metadata);
3906
4184
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3907
- if (event.triggerInference) {
4185
+ if (event.triggerInference && !divert) {
3908
4186
  const addressed = isAddressedMessage(event.tags, event.metadata);
3909
- for (const agentName of this.agents.keys()) {
4187
+ // Honor explicit targeting, mirroring the push-event path: an event
4188
+ // that names its agents wakes exactly those; an untargeted event
4189
+ // keeps the historical broadcast. (targetAgents was declared on
4190
+ // McplChannelIncomingEvent from the start but never honored here —
4191
+ // tune-out's wake routing is the first setter.)
4192
+ const targetAgents = event.targetAgents
4193
+ ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
4194
+ for (const agentName of targetAgents) {
4195
+ if (!this.agents.has(agentName))
4196
+ continue;
3910
4197
  this.pendingRequests.push({
3911
4198
  agentName,
3912
4199
  reason: 'mcpl:channel-incoming',
@@ -4019,38 +4306,48 @@ export class AgentFramework {
4019
4306
  if (!template || !templateConfig) {
4020
4307
  throw new Error(`conversation template agent "${router.templateAgent}" not found`);
4021
4308
  }
4022
- const contextManager = await ContextManager.open({
4023
- store: this.store,
4024
- namespace: `conversations/${name}`,
4025
- isolate: true,
4026
- // Strategy instances are stateful — never share the template's.
4027
- strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
4028
- // Dynamic conversation forks retain the established provider policy in
4029
- // this bounded first slice. Provider cooldown ownership belongs to the
4030
- // persistent resident only; generation-unique forks must not leak gates.
4031
- membrane: this.membrane,
4032
- debugLogContext: !!process.env.DEBUG_CONTEXT,
4033
- });
4034
- // Seed with the template's compiled context, renaming the template
4035
- // participant so the fork reads its inheritance as its own history.
4036
- // Guard: only seed a genuinely fresh namespace. Generation counters are
4037
- // persisted precisely so names aren't reused, but if this namespace has
4038
- // history anyway (counter state lost, crash between spawn and persist),
4039
- // seeding again would stack another template copy on top of it.
4040
- const { messages: existing } = await contextManager.compile();
4041
- if (existing.length === 0) {
4042
- const { messages: compiled } = await template.getContextManager().compile();
4043
- for (const msg of compiled) {
4044
- const participant = msg.participant === template.name ? name : msg.participant;
4045
- contextManager.addMessage(participant, msg.content);
4046
- }
4047
- }
4048
- const config = { ...templateConfig, name, strategy: undefined };
4049
- const agent = new Agent(config, contextManager, this.membrane);
4050
- this.agents.set(name, agent);
4051
- this.agentConfigs.set(name, config);
4052
- this.conversationAgentHomes.set(name, channelId);
4053
- return agent;
4309
+ if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
4310
+ throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
4311
+ }
4312
+ this.usedEphemeralAgentNames.add(name);
4313
+ try {
4314
+ const contextManager = await ContextManager.open({
4315
+ store: this.store,
4316
+ namespace: `conversations/${name}`,
4317
+ isolate: true,
4318
+ // Strategy instances are stateful — never share the template's.
4319
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
4320
+ // Dynamic conversation forks retain the established provider policy in
4321
+ // this bounded first slice. Provider cooldown ownership belongs to the
4322
+ // persistent resident only; generation-unique forks must not leak gates.
4323
+ membrane: this.membrane,
4324
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
4325
+ });
4326
+ // Seed with the template's compiled context, renaming the template
4327
+ // participant so the fork reads its inheritance as its own history.
4328
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
4329
+ // persisted precisely so names aren't reused, but if this namespace has
4330
+ // history anyway (counter state lost, crash between spawn and persist),
4331
+ // seeding again would stack another template copy on top of it.
4332
+ const { messages: existing } = await contextManager.compile();
4333
+ if (existing.length === 0) {
4334
+ const { messages: compiled } = await template.getContextManager().compile();
4335
+ for (const msg of compiled) {
4336
+ const participant = msg.participant === template.name ? name : msg.participant;
4337
+ contextManager.addMessage(participant, msg.content);
4338
+ }
4339
+ }
4340
+ const config = { ...templateConfig, name, strategy: undefined };
4341
+ const agent = new Agent(config, contextManager, this.membrane);
4342
+ this.agents.set(name, agent);
4343
+ this.agentConfigs.set(name, config);
4344
+ this.conversationAgentHomes.set(name, channelId);
4345
+ return agent;
4346
+ }
4347
+ catch (error) {
4348
+ this.usedEphemeralAgentNames.delete(name);
4349
+ throw error;
4350
+ }
4054
4351
  }
4055
4352
  /** Persist the router's generation counters (see hydration in create()). */
4056
4353
  persistConversationRouterState() {
@@ -4074,10 +4371,14 @@ export class AgentFramework {
4074
4371
  disposeConversationAgent(agentName) {
4075
4372
  this.closingConversationAgents.delete(agentName);
4076
4373
  const channelId = this.conversationAgentHomes.get(agentName);
4374
+ const agent = this.agents.get(agentName);
4077
4375
  this.agents.delete(agentName);
4376
+ this.toolImageLedgers.delete(agentName);
4078
4377
  this.agentConfigs.delete(agentName);
4079
4378
  this.conversationAgentHomes.delete(agentName);
4080
4379
  this.evictTurnCheckpoints(agentName);
4380
+ if (agent)
4381
+ this.logicalTurnToolCalls.delete(agent);
4081
4382
  this.emitTrace({
4082
4383
  type: 'mcpl:conversation-disposed',
4083
4384
  agentName,
@@ -4277,7 +4578,7 @@ export class AgentFramework {
4277
4578
  if (event.triggerInference) {
4278
4579
  // Default broadcast excludes conversation forks (channel-driven).
4279
4580
  const targetAgents = event.targetAgents
4280
- ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
4581
+ ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
4281
4582
  for (const agentName of targetAgents) {
4282
4583
  this.pendingRequests.push({
4283
4584
  agentName,
@@ -4459,6 +4760,10 @@ export class AgentFramework {
4459
4760
  // exactly this class of silent drop. One stderr line per drop.
4460
4761
  console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
4461
4762
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
4763
+ // A context-budget restart inherits the predecessor's EventGate liveness.
4764
+ // If policy drops the queued successor, no driveStream remains to release it.
4765
+ if (budgetRestart)
4766
+ this.eventGate?.onInferenceEnded(agentName);
4462
4767
  const gate = this.providerGates.get(agentName);
4463
4768
  if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
4464
4769
  gate.primaryPending = false;
@@ -4499,6 +4804,19 @@ export class AgentFramework {
4499
4804
  addressedReq = r;
4500
4805
  }
4501
4806
  const channelReq = addressedReq ?? ambientReq;
4807
+ // Telemetry provenance (who/where woke the agent): from the routing
4808
+ // winner when it carries one; else from the most recent request that
4809
+ // does (gate wakes name a counterparty without setting a locus),
4810
+ // ordered by the event's own time (wakeAt) rather than flush order.
4811
+ let provReq = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined;
4812
+ if (!provReq) {
4813
+ for (const r of requests) {
4814
+ if (!r.counterparty && !r.wakeChannelId)
4815
+ continue;
4816
+ if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp))
4817
+ provReq = r;
4818
+ }
4819
+ }
4502
4820
  await this.startAgentStream(agent, {
4503
4821
  ...trigger,
4504
4822
  channelId: channelReq?.channelId,
@@ -4507,7 +4825,9 @@ export class AgentFramework {
4507
4825
  // the channel for routing but names no author — the restart is its
4508
4826
  // own cause, and borrowing another request's author would be false
4509
4827
  // provenance.
4510
- counterparty: budgetRestart ? undefined : channelReq?.counterparty,
4828
+ counterparty: budgetRestart ? undefined : provReq?.counterparty,
4829
+ wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
4830
+ wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
4511
4831
  });
4512
4832
  }
4513
4833
  }
@@ -5012,9 +5332,8 @@ export class AgentFramework {
5012
5332
  if (attempt === 0 &&
5013
5333
  trigger?.reason !== 'context_budget_restart' &&
5014
5334
  this.deferredMessages.length > 0) {
5015
- const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
5016
- if (target === agent) {
5017
- const deferred = this.deferredMessages.splice(0);
5335
+ {
5336
+ const deferred = this.drainDeferredFor(agent.name);
5018
5337
  for (const msg of deferred) {
5019
5338
  // Per-message try/catch (mirrors announceLocusIfChanged): one
5020
5339
  // poison message — or a transient store-write failure — must not
@@ -5068,9 +5387,22 @@ export class AgentFramework {
5068
5387
  // BEFORE this turn compiles, so the agent always knows where its voice
5069
5388
  // goes (announce-on-change only — no per-turn chatter, append-only for
5070
5389
  // KV stability).
5390
+ if (trigger?.reason === 'context_budget_restart') {
5391
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
5392
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
5393
+ }
5071
5394
  if (trigger?.reason !== 'context_budget_restart') {
5072
5395
  if (attempt === 0)
5073
5396
  this.maybePrimeProseMode(agent);
5397
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
5398
+ if (attempt === 0) {
5399
+ // A true new turn gets a fresh generation and count.
5400
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: 0 });
5401
+ }
5402
+ else {
5403
+ // A framework retry is a new physical stream in the same logical turn.
5404
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
5405
+ }
5074
5406
  // Fresh turn: forget the previous turn's explicit-send engagements and
5075
5407
  // prose deliveries — both are strictly turn-scoped (a restart continues
5076
5408
  // the same logical turn and keeps them).
@@ -5108,7 +5440,11 @@ export class AgentFramework {
5108
5440
  // context-budget restart, which skips the re-pin but re-emits this).
5109
5441
  channelId: this.turnLocusPins.get(agent.name),
5110
5442
  });
5111
- this.eventGate?.onInferenceStarted(agent.name);
5443
+ // A budget restart continues the same logical inference window. The
5444
+ // predecessor keeps EventGate liveness until its successor terminates.
5445
+ if (trigger?.reason !== 'context_budget_restart') {
5446
+ this.eventGate?.onInferenceStarted(agent.name);
5447
+ }
5112
5448
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
5113
5449
  // Typing indicator, started at TURN START. It says "attending", which is
5114
5450
  // true from the moment the turn is accepted — but it used to start inside
@@ -5162,8 +5498,32 @@ export class AgentFramework {
5162
5498
  console.error('beforeInference hook error:', error);
5163
5499
  }
5164
5500
  }
5501
+ // An ephemeral watchdog may dispose this Agent while hooks/compile await.
5502
+ // Do not create a provider stream after its generation lost ownership.
5503
+ if (this.agents.get(agent.name) !== agent) {
5504
+ this.channelRegistry?.stopTyping();
5505
+ this.eventGate?.onInferenceEnded(agent.name);
5506
+ return false;
5507
+ }
5508
+ // The subconscious's standing dispositions ride a fixed system-position
5509
+ // injection (antra, #tuneout-talk): never repeated in its timeline, and
5510
+ // — since it has no long-term memory — structurally unable to wash out
5511
+ // of its window. Bytes change only when a disposition changes.
5512
+ if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) {
5513
+ const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection();
5514
+ if (dispositionInjection) {
5515
+ injections = injections ? [...injections, dispositionInjection] : [dispositionInjection];
5516
+ }
5517
+ }
5165
5518
  const { stream, request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, } = await agent.startStreamWithInjections(tools, injections);
5166
- const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate, takeKvSubmission, drainKvSubmissionIds);
5519
+ if (this.agents.get(agent.name) !== agent) {
5520
+ stream.cancel();
5521
+ agent.cancelStream();
5522
+ this.channelRegistry?.stopTyping();
5523
+ this.eventGate?.onInferenceEnded(agent.name);
5524
+ return false;
5525
+ }
5526
+ const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate, takeKvSubmission, drainKvSubmissionIds, new Set(tools.map((tool) => tool.name)));
5167
5527
  this.activeStreams.set(agent.name, handle);
5168
5528
  // Handoff: driveStream captured the token in its synchronous prefix;
5169
5529
  // its finally now owns clearing it. The caller's finally must not.
@@ -5236,7 +5596,7 @@ export class AgentFramework {
5236
5596
  return false;
5237
5597
  }
5238
5598
  }
5239
- async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds) {
5599
+ async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false, takeKvSubmission, drainKvSubmissionIds, knownToolNames = new Set()) {
5240
5600
  const startTime = Date.now();
5241
5601
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
5242
5602
  const myStreamId = agent.streamId;
@@ -5255,6 +5615,7 @@ export class AgentFramework {
5255
5615
  // means no successor turn can have replaced it while we compiled.
5256
5616
  const myTurnToken = this.activeTurnTokens.get(agent.name);
5257
5617
  let hadToolCalls = false;
5618
+ let generationLost = false;
5258
5619
  // ---- Present-while-acting turn state ---------------------------------
5259
5620
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
5260
5621
  // (home → addressed trigger → global default) before this stream began,
@@ -5329,13 +5690,20 @@ export class AgentFramework {
5329
5690
  // failed, a framework-cancelled stream sets aborted. Best-effort
5330
5691
  // notifications — consumers dedupe by inferenceId and keep a timeout.
5331
5692
  let lifecyclePhase = 'completed';
5693
+ let preserveEventGateForSuccessor = false;
5332
5694
  this.hookOrchestrator?.emitLifecycle({
5333
5695
  inferenceId: outgoingInferenceId,
5334
5696
  conversationId: agent.name,
5335
5697
  turnIndex: 0,
5336
5698
  phase: 'started',
5337
5699
  });
5338
- const proseStream = this.channelRegistry && agent.proseRouting !== 'disabled'
5700
+ // The wrapper guard needs the complete response before it can distinguish
5701
+ // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded
5702
+ // turns until completion: otherwise speculative outgoing chunks could
5703
+ // expose the wrapper before the fail-closed classifier runs.
5704
+ const proseStream = this.channelRegistry &&
5705
+ agent.proseRouting !== 'disabled' &&
5706
+ !agent.toolWrapperProseGuard
5339
5707
  ? new ProseStreamRouter({
5340
5708
  mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
5341
5709
  initialTarget: typingChannel,
@@ -5361,6 +5729,14 @@ export class AgentFramework {
5361
5729
  };
5362
5730
  try {
5363
5731
  for await (const event of stream) {
5732
+ // Ignore every late event from an Agent generation that no longer owns
5733
+ // this name (ephemeral watchdog/disposal, conversation replacement).
5734
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
5735
+ generationLost = true;
5736
+ lifecyclePhase = 'aborted';
5737
+ stream.cancel();
5738
+ break;
5739
+ }
5364
5740
  this.touchEphemeralRun(agent.name, true);
5365
5741
  switch (event.type) {
5366
5742
  case 'tokens':
@@ -5423,6 +5799,7 @@ export class AgentFramework {
5423
5799
  case 'tool-calls': {
5424
5800
  adoptInjectedRound();
5425
5801
  hadToolCalls = true;
5802
+ this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length);
5426
5803
  this.recordEphemeralToolCalls(agent.name, event.calls.length);
5427
5804
  this.emitTrace({
5428
5805
  type: 'inference:tool_calls_yielded',
@@ -5588,7 +5965,7 @@ export class AgentFramework {
5588
5965
  // one door a giant blob still walks through).
5589
5966
  const readyState = agent.state;
5590
5967
  if (readyState.status === 'ready') {
5591
- const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
5968
+ const { blocks: toolResultContent } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
5592
5969
  agent.getContextManager().addMessage('user', toolResultContent);
5593
5970
  }
5594
5971
  }
@@ -5604,7 +5981,29 @@ export class AgentFramework {
5604
5981
  const terminalContent = lastToolIdx >= 0
5605
5982
  ? response.content.slice(lastToolIdx + 1)
5606
5983
  : response.content;
5607
- if (lastToolIdx >= 0) {
5984
+ // This is a whole-response boundary, not a trailing-prose
5985
+ // classifier. If the turn executed any genuine structured tool
5986
+ // call, preserve its later prose exactly as ordinary history.
5987
+ const logicalToolState = this.logicalTurnToolCalls.get(agent);
5988
+ const logicalTurnHadToolCalls = logicalToolState?.turnToken === myTurnToken && (logicalToolState?.count ?? 0) > 0;
5989
+ const guardedWrapperTool = agent.toolWrapperProseGuard && !logicalTurnHadToolCalls
5990
+ ? detectKnownToolWrapperProse(response.content, knownToolNames)
5991
+ : null;
5992
+ if (guardedWrapperTool) {
5993
+ // AF #133: containment only. Never execute wrapper text, never
5994
+ // publish it, and never retain it as the freshest assistant
5995
+ // self-seed. Exact raw output remains in the provider ledger.
5996
+ agent.getContextManager().addMessage('user', [{
5997
+ type: 'text',
5998
+ text: '[tool-boundary] No tool was called.',
5999
+ }], {
6000
+ system: true,
6001
+ kind: 'tool-wrapper-prose-contained',
6002
+ toolName: guardedWrapperTool,
6003
+ });
6004
+ console.error(`[tool-boundary] ${agent.name}: contained whole-response prose wrapper for registered tool ${guardedWrapperTool}; no tool called`);
6005
+ }
6006
+ else if (lastToolIdx >= 0) {
5608
6007
  if (terminalContent.length > 0) {
5609
6008
  agent.addAssistantResponse(terminalContent);
5610
6009
  }
@@ -5627,7 +6026,9 @@ export class AgentFramework {
5627
6026
  // convention remains "text is thoughts"; on a text-only turn all
5628
6027
  // text is speech.
5629
6028
  const isTextBlock = (block) => block.type === 'text';
5630
- const allText = response.content.filter(isTextBlock);
6029
+ const allText = guardedWrapperTool
6030
+ ? []
6031
+ : response.content.filter(isTextBlock);
5631
6032
  const speechContent = hadToolCalls ? [] : allText;
5632
6033
  const thoughts = hadToolCalls ? allText : [];
5633
6034
  const du = response.details?.usage;
@@ -5639,19 +6040,30 @@ export class AgentFramework {
5639
6040
  cacheRead: du.cacheReadTokens,
5640
6041
  }
5641
6042
  : undefined;
6043
+ // Async completion work may overlap a replacement stream. Recheck
6044
+ // physical ownership before any terminal state/settlement side effect.
6045
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
6046
+ generationLost = true;
6047
+ lifecyclePhase = 'aborted';
6048
+ stream.cancel();
6049
+ return;
6050
+ }
5642
6051
  // Reset agent state before emitting inference:completed. Traces are
5643
6052
  // observability-only, but external synchronous listeners should
5644
6053
  // still see the terminal state at the terminal trace boundary.
5645
6054
  // Speech dispatch happens after but doesn't depend on the status
5646
6055
  // field.
5647
6056
  agent.reset();
5648
- this.eventGate?.onInferenceEnded(agent.name);
6057
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6058
+ this.eventGate?.onInferenceEnded(agent.name);
5649
6059
  this.settleAgent(agent.name, {
5650
6060
  stopReason: 'completed',
5651
- speech: terminalContent
5652
- .filter((block) => block.type === 'text')
5653
- .map((block) => block.text)
5654
- .join('\n'),
6061
+ speech: guardedWrapperTool
6062
+ ? ''
6063
+ : terminalContent
6064
+ .filter((block) => block.type === 'text')
6065
+ .map((block) => block.text)
6066
+ .join('\n'),
5655
6067
  });
5656
6068
  this.emitTrace({
5657
6069
  type: 'inference:completed',
@@ -5992,7 +6404,8 @@ export class AgentFramework {
5992
6404
  agent.reset();
5993
6405
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
5994
6406
  lifecyclePhase = 'failed';
5995
- this.eventGate?.onInferenceEnded(agent.name);
6407
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6408
+ this.eventGate?.onInferenceEnded(agent.name);
5996
6409
  break;
5997
6410
  }
5998
6411
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -6016,7 +6429,8 @@ export class AgentFramework {
6016
6429
  // itself) means retrying the same context can never succeed.
6017
6430
  ...this.classifyInferenceError(err),
6018
6431
  });
6019
- this.eventGate?.onInferenceEnded(agent.name);
6432
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6433
+ this.eventGate?.onInferenceEnded(agent.name);
6020
6434
  if (action.emit) {
6021
6435
  this.pushEvent(action.emit);
6022
6436
  }
@@ -6037,7 +6451,14 @@ export class AgentFramework {
6037
6451
  if (cancelKind !== undefined) {
6038
6452
  this.frameworkCancelledStreams.delete(cancelKey);
6039
6453
  lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
6040
- this.eventGate?.onInferenceEnded(agent.name);
6454
+ // A budget restart continues the same EventGate window. Do not
6455
+ // end it here or in finally; the replacement owns final release.
6456
+ if (cancelKind === 'budget_restart') {
6457
+ preserveEventGateForSuccessor = true;
6458
+ }
6459
+ else if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) {
6460
+ this.eventGate?.onInferenceEnded(agent.name);
6461
+ }
6041
6462
  // endTurn IS a logical turn end — earlier rounds may have
6042
6463
  // live-routed prose (narrate → skip_reply is a real shape),
6043
6464
  // so settle the delivery chain and drop the receipt. A
@@ -6084,7 +6505,8 @@ export class AgentFramework {
6084
6505
  request: compiledRequest ?? { note: 'streaming request aborted' },
6085
6506
  durationMs,
6086
6507
  });
6087
- this.eventGate?.onInferenceEnded(agent.name);
6508
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6509
+ this.eventGate?.onInferenceEnded(agent.name);
6088
6510
  }
6089
6511
  break;
6090
6512
  }
@@ -6160,6 +6582,14 @@ export class AgentFramework {
6160
6582
  }
6161
6583
  }
6162
6584
  catch (error) {
6585
+ // A superseded physical stream may throw after its replacement starts.
6586
+ // It has no authority to settle, reset, gate-release, or publish failure.
6587
+ if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) {
6588
+ generationLost = true;
6589
+ lifecyclePhase = 'aborted';
6590
+ stream.cancel();
6591
+ return;
6592
+ }
6163
6593
  // Stream itself threw. Organization acceleration is deferred from a
6164
6594
  // fresh compile; every other throw keeps the ordinary exhausted path.
6165
6595
  const err = error instanceof Error ? error : new Error(String(error));
@@ -6172,7 +6602,8 @@ export class AgentFramework {
6172
6602
  this.abortAgentScript(agent.name, 'provider acceleration cooldown');
6173
6603
  lifecyclePhase = 'failed';
6174
6604
  agent.reset();
6175
- this.eventGate?.onInferenceEnded(agent.name);
6605
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6606
+ this.eventGate?.onInferenceEnded(agent.name);
6176
6607
  return;
6177
6608
  }
6178
6609
  this.emitTrace({
@@ -6207,7 +6638,8 @@ export class AgentFramework {
6207
6638
  this.abortAgentScript(agent.name, 'stream threw');
6208
6639
  lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
6209
6640
  agent.reset();
6210
- this.eventGate?.onInferenceEnded(agent.name);
6641
+ if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
6642
+ this.eventGate?.onInferenceEnded(agent.name);
6211
6643
  }
6212
6644
  finally {
6213
6645
  const unsettledKvSubmissions = drainKvSubmissionIds?.() ?? [];
@@ -6220,6 +6652,19 @@ export class AgentFramework {
6220
6652
  }
6221
6653
  catch { /* receipt cleanup must not mask inference teardown */ }
6222
6654
  }
6655
+ // A framework retry/budget restart can overlap physical streams on the
6656
+ // same Agent object. Name + Agent identity is not enough: only the
6657
+ // current stream generation may mutate name-keyed teardown state.
6658
+ const ownsPhysicalStream = this.agents.get(agent.name) === agent && agent.streamId === myStreamId;
6659
+ // Ephemeral completion resolves its caller before this finally runs, so
6660
+ // runEphemeralToCompletion may already have deregistered the name. The
6661
+ // terminal frame still owns its typing/outgoing completion unless it was
6662
+ // genuinely superseded or handed EventGate liveness to a budget restart.
6663
+ const disposedEphemeralWithoutSuccessor = generationLost &&
6664
+ this.disposedEphemeralFrames.has(agent) &&
6665
+ !this.agents.has(agent.name);
6666
+ const frameReachedTerminal = (!generationLost || disposedEphemeralWithoutSuccessor) &&
6667
+ !preserveEventGateForSuccessor;
6223
6668
  // §10.5: exactly one terminal per `started`, on every exit path the
6224
6669
  // host controls. Which one was decided by the path taken (default
6225
6670
  // completed; catch → failed; framework-cancel → aborted). A host
@@ -6238,28 +6683,35 @@ export class AgentFramework {
6238
6683
  // all incoming events → the agent silently stops waking on messages
6239
6684
  // (typing still stops, compression still runs — matching the observed
6240
6685
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
6241
- this.eventGate?.onInferenceEnded(agent.name);
6242
- this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
6686
+ if (ownsPhysicalStream && !preserveEventGateForSuccessor) {
6687
+ this.eventGate?.onInferenceEnded(agent.name);
6688
+ }
6689
+ if (!generationLost && ownsPhysicalStream) {
6690
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
6691
+ }
6243
6692
  if (ownsProviderGate)
6244
6693
  this.releasePrimaryProviderGate(agent.name);
6245
6694
  // Stop the typing indicator on every exit path (complete, error,
6246
6695
  // exhausted, abort) so it never sticks after the turn ends.
6247
- this.channelRegistry?.stopTyping();
6696
+ if (frameReachedTerminal)
6697
+ this.channelRegistry?.stopTyping();
6248
6698
  // Spec 14.3: flush any held line-start text, then close each streamed
6249
6699
  // channel with its final moderated content — the consumer's signal to
6250
6700
  // finalize (end the TTS utterance, settle the rendered message).
6251
- if (proseStream) {
6701
+ if (frameReachedTerminal && proseStream) {
6252
6702
  emitOutgoing(proseStream.finish());
6253
6703
  for (const [channelId, text] of proseStream.byChannel()) {
6254
6704
  this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
6255
6705
  }
6256
6706
  }
6257
6707
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
6258
- this.activeStreams.delete(agent.name);
6259
- this.pendingAssistantBlocks.delete(agent.name);
6708
+ if (ownsPhysicalStream) {
6709
+ this.activeStreams.delete(agent.name);
6710
+ this.pendingAssistantBlocks.delete(agent.name);
6711
+ }
6260
6712
  // A conversation fork whose TTL closure turn just finished is done for
6261
6713
  // good — dispose it so the agent map doesn't grow monotonically.
6262
- if (this.closingConversationAgents.has(agent.name)) {
6714
+ if (ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
6263
6715
  this.disposeConversationAgent(agent.name);
6264
6716
  }
6265
6717
  // The turn is torn down — release the turn-alive marker BEFORE the
@@ -6273,11 +6725,14 @@ export class AgentFramework {
6273
6725
  this.activeTurnTokens.delete(agent.name);
6274
6726
  this.activeTurnTriggers.delete(agent.name);
6275
6727
  }
6276
- // Flush any deferred messages (e.g. if stream failed while tools were pending)
6277
- if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
6278
- const deferred = this.deferredMessages.splice(0);
6728
+ // Flush any deferred messages (e.g. if stream failed while tools were
6729
+ // pending). Only THIS agent's messages: other targets' entries wait
6730
+ // for their own boundaries (re-adding via addMessage re-defers if the
6731
+ // target has meanwhile started a turn).
6732
+ if (frameReachedTerminal && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
6733
+ const deferred = this.drainDeferredFor(agent.name);
6279
6734
  for (const msg of deferred) {
6280
- this.addMessage(msg.participant, msg.content, msg.metadata);
6735
+ this.addMessage(msg.participant, msg.content, msg.metadata, msg.forAgent ? { forAgent: msg.forAgent } : undefined);
6281
6736
  }
6282
6737
  }
6283
6738
  }
@@ -6368,7 +6823,7 @@ export class AgentFramework {
6368
6823
  // and a message arriving during that await must land before the pair,
6369
6824
  // never between tool_use and its tool_result. The two addMessage calls
6370
6825
  // below are synchronous and adjacent — nothing can interleave.
6371
- const { blocks } = await this.buildStoredToolResultContent([{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
6826
+ const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
6372
6827
  const cm = agent.getContextManager();
6373
6828
  cm.addMessage(agentName, [
6374
6829
  { type: 'tool_use', id: toolUseId, name: toolName, input },
@@ -6816,13 +7271,25 @@ export class AgentFramework {
6816
7271
  * #89). Returns the stored blocks plus the per-call spill outcomes so the
6817
7272
  * live wire copy can reuse the identical strings.
6818
7273
  */
6819
- async buildStoredToolResultContent(toolResults, maxChars) {
7274
+ async buildStoredToolResultContent(agentName, toolResults, maxChars) {
6820
7275
  const spilled = new Map();
6821
7276
  const dateLabel = new Date().toISOString().slice(0, 10);
7277
+ const ledger = this.toolImageLedgerFor(agentName);
6822
7278
  for (const tc of toolResults) {
7279
+ // Retain every image the result carries BEFORE the placeholder is
7280
+ // written (issue #104): the placeholder embeds the ref, so the image
7281
+ // stays reachable by provenance after the bytes leave the wire.
6823
7282
  const raw = tc.result.isError
6824
7283
  ? tc.result.error ?? 'Unknown error'
6825
- : toolResultDataToHistoryString(tc.result.data, undefined);
7284
+ : toolResultDataToHistoryString(tc.result.data, undefined, {
7285
+ imageRef: (blockIndex, image) => ledger.retain({
7286
+ toolCallId: tc.id,
7287
+ toolName: tc.name,
7288
+ blockIndex,
7289
+ data: image.data,
7290
+ mediaType: image.mimeType,
7291
+ }).ref,
7292
+ });
6826
7293
  spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
6827
7294
  }
6828
7295
  const blocks = toolResults.map(tc => ({
@@ -6839,6 +7306,13 @@ export class AgentFramework {
6839
7306
  async spillOrTruncate(content, cap, label) {
6840
7307
  if (!cap || content.length <= cap)
6841
7308
  return { text: content, filePath: null };
7309
+ // The stored text is the only place save_recent_image discovers tool
7310
+ // images, and the live wire delivers every image regardless of text
7311
+ // length — so an image slot dropped by the cut is an image the resident
7312
+ // saw that the index would silently slide past (issue #104). Keep the
7313
+ // slots at or past the cut, re-appended after the notice.
7314
+ const { head, tail } = splitPreservingImageSlots(content, cap);
7315
+ const kept = formatPreservedImageSlots(tail);
6842
7316
  const workspace = this.getWorkspaceModule();
6843
7317
  const mountName = workspace ? this.firstWritableMountName(workspace) : null;
6844
7318
  if (workspace && mountName) {
@@ -6849,10 +7323,11 @@ export class AgentFramework {
6849
7323
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
6850
7324
  if (result.success) {
6851
7325
  return {
6852
- text: safeSlice(content, 0, cap)
6853
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
7326
+ text: head
7327
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}. `
6854
7328
  + 'Read/grep it with your file tools, or raise the inline cap via '
6855
- + 'agent_settings update tool_result_inline_max_chars.]',
7329
+ + 'agent_settings update tool_result_inline_max_chars.]'
7330
+ + kept,
6856
7331
  filePath: path,
6857
7332
  };
6858
7333
  }
@@ -6874,15 +7349,17 @@ export class AgentFramework {
6874
7349
  });
6875
7350
  console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
6876
7351
  return {
6877
- text: safeSlice(content, 0, cap)
6878
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
6879
- + ` (${failure}); content over the cap was not retained]`,
7352
+ text: head
7353
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
7354
+ + ` (${failure}); content over the cap was not retained]`
7355
+ + kept,
6880
7356
  filePath: null,
6881
7357
  };
6882
7358
  }
6883
7359
  return {
6884
- text: safeSlice(content, 0, cap)
6885
- + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
7360
+ text: head
7361
+ + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
7362
+ + kept,
6886
7363
  filePath: null,
6887
7364
  };
6888
7365
  }
@@ -7085,6 +7562,14 @@ export class AgentFramework {
7085
7562
  }
7086
7563
  return hasImage ? blocks : null;
7087
7564
  }
7565
+ toolImageLedgerFor(agentName) {
7566
+ let ledger = this.toolImageLedgers.get(agentName);
7567
+ if (!ledger) {
7568
+ ledger = new ToolImageLedger();
7569
+ this.toolImageLedgers.set(agentName, ledger);
7570
+ }
7571
+ return ledger;
7572
+ }
7088
7573
  /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
7089
7574
  strategyDerivedToolResultChars(agent) {
7090
7575
  const strategy = agent.getContextManager().getStrategy();
@@ -7255,6 +7740,27 @@ export class AgentFramework {
7255
7740
  this.dispatchGateToolCall(agentName, enrichedCall);
7256
7741
  return;
7257
7742
  }
7743
+ // Route the tune-out tool (residents) and the subconscious's surface
7744
+ if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
7745
+ this.dispatchTuneOutToolCall(agentName, enrichedCall);
7746
+ return;
7747
+ }
7748
+ if (this.tuneOutCoordinator &&
7749
+ agentName === this.subconsciousAgentName &&
7750
+ SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
7751
+ void this.tuneOutCoordinator
7752
+ .handleSubconsciousTool(enrichedCall.name, enrichedCall.input)
7753
+ .then((result) => {
7754
+ this.queue.push({
7755
+ type: 'tool-result',
7756
+ callId: enrichedCall.id,
7757
+ agentName,
7758
+ moduleName: 'tune-out',
7759
+ result,
7760
+ });
7761
+ });
7762
+ return;
7763
+ }
7258
7764
  // Route sleep / wake tools
7259
7765
  if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
7260
7766
  this.dispatchSleepToolCall(agentName, enrichedCall);
@@ -7383,12 +7889,19 @@ export class AgentFramework {
7383
7889
  });
7384
7890
  });
7385
7891
  }
7386
- addMessage(participant, content, metadata) {
7387
- // Route to the primary agent's context manager (not ephemeral subagents).
7388
- const agent = this.primaryAgentName
7389
- ? this.agents.get(this.primaryAgentName)
7390
- : this.agents.values().next().value;
7892
+ addMessage(participant, content, metadata, opts) {
7893
+ // Route to the named agent, else the primary (not ephemeral subagents).
7894
+ const agent = opts?.forAgent
7895
+ ? this.agents.get(opts.forAgent)
7896
+ : this.primaryAgentName
7897
+ ? this.agents.get(this.primaryAgentName)
7898
+ : this.agents.values().next().value;
7391
7899
  if (!agent) {
7900
+ if (opts?.forAgent) {
7901
+ console.error(`[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` +
7902
+ `(participant=${participant})`);
7903
+ return '';
7904
+ }
7392
7905
  throw new Error('No agents configured');
7393
7906
  }
7394
7907
  // Defer non-tool_result messages while a tool cycle is mid-flight
@@ -7408,15 +7921,39 @@ export class AgentFramework {
7408
7921
  // ALSO injected into the live stream — hear-while-acting), or in
7409
7922
  // driveStream's finally when the turn ends.
7410
7923
  const hasToolResult = content.some(b => b.type === 'tool_result');
7924
+ // Explicitly-targeted deliveries scope the tool-cycle check to the
7925
+ // target (pendingAssistantBlocks is keyed by agent); the default path
7926
+ // keeps the historical GLOBAL check byte-for-byte (conservative: any
7927
+ // agent's pending cycle defers primary-bound messages).
7928
+ const midToolCycle = opts?.forAgent
7929
+ ? this.pendingAssistantBlocks.has(agent.name)
7930
+ : this.pendingAssistantBlocks.size > 0;
7411
7931
  if (!hasToolResult &&
7412
- (this.pendingAssistantBlocks.size > 0 ||
7932
+ (midToolCycle ||
7413
7933
  this.activeTurnTokens.has(agent.name) ||
7414
7934
  this.activeStreams.has(agent.name))) {
7415
- this.deferredMessages.push({ participant, content, metadata });
7416
- return ''; // Deferred — flushed at the next boundary
7935
+ this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent });
7936
+ return ''; // Deferred — flushed at the target's next boundary
7417
7937
  }
7418
7938
  return agent.getContextManager().addMessage(participant, content, metadata);
7419
7939
  }
7940
+ /**
7941
+ * Splice out the deferred messages addressed to `agentName` (explicitly
7942
+ * via forAgent, or implicitly when it is the primary), leaving other
7943
+ * targets' messages queued for their own boundaries.
7944
+ */
7945
+ drainDeferredFor(agentName) {
7946
+ if (this.deferredMessages.length === 0)
7947
+ return [];
7948
+ const mine = [];
7949
+ const rest = [];
7950
+ for (const msg of this.deferredMessages) {
7951
+ const target = msg.forAgent ?? this.primaryAgentName;
7952
+ (target === agentName ? mine : rest).push(msg);
7953
+ }
7954
+ this.deferredMessages = rest;
7955
+ return mine;
7956
+ }
7420
7957
  editMessage(id, content) {
7421
7958
  const agent = this.primaryAgentName
7422
7959
  ? this.agents.get(this.primaryAgentName)
@@ -9400,6 +9937,50 @@ export class AgentFramework {
9400
9937
  * suppression window, optionally announces in the sticky channel, and ends
9401
9938
  * the turn (the agent goes idle immediately). `wake` clears sleep.
9402
9939
  */
9940
+ /** tune_out tool (residents): enter or cancel a channel tune-out. */
9941
+ dispatchTuneOutToolCall(agentName, call) {
9942
+ const coordinator = this.tuneOutCoordinator;
9943
+ const input = (call.input ?? {});
9944
+ const channelId = String(input.channelId ?? '');
9945
+ const entry = this.channelRegistry?.listChannelsRaw()
9946
+ .find((e) => e.descriptor.id === channelId);
9947
+ let result;
9948
+ if (!entry) {
9949
+ result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
9950
+ }
9951
+ else if (input.mode === 'cancel') {
9952
+ const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
9953
+ result = r.ok
9954
+ ? { success: true, data: { cancelled: channelId } }
9955
+ : { success: false, error: r.error, isError: false };
9956
+ }
9957
+ else {
9958
+ const r = coordinator.enter(entry.serverId, channelId, {
9959
+ cadenceSeconds: input.cadenceSeconds,
9960
+ backlogCap: input.backlogCap,
9961
+ maxWakes: input.maxWakes,
9962
+ durationSeconds: input.durationSeconds,
9963
+ }, 'agent-tool');
9964
+ result = r.ok
9965
+ ? {
9966
+ success: true,
9967
+ data: {
9968
+ tunedOut: channelId,
9969
+ cadenceSeconds: r.params.cadenceSeconds,
9970
+ backlogCap: r.params.backlogCap,
9971
+ maxWakes: r.params.maxWakes,
9972
+ },
9973
+ }
9974
+ : { success: false, error: r.error, isError: false };
9975
+ }
9976
+ this.queue.push({
9977
+ type: 'tool-result',
9978
+ callId: call.id,
9979
+ agentName,
9980
+ moduleName: 'tune-out',
9981
+ result,
9982
+ });
9983
+ }
9403
9984
  dispatchSleepToolCall(agentName, call) {
9404
9985
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
9405
9986
  const gate = this.eventGate;
@@ -9591,10 +10172,53 @@ export class AgentFramework {
9591
10172
  })();
9592
10173
  }
9593
10174
  /**
9594
- * Handle the synthesized `save_recent_image` tool: locate the requested
9595
- * image blocks in the calling agent's context (blobs re-inlined, counted
9596
- * back from the most recent) and write their bytes to a workspace mount
9597
- * via WorkspaceModule.writeBinary.
10175
+ * `save_recent_image` barrier. Module tool calls in a batch are only
10176
+ * ENQUEUED by dispatch, while the save runs at once — so a [snap, save]
10177
+ * round would inventory before the snapshot exists and silently save the
10178
+ * newest OLDER image (issue #104, same-batch representation). Wait for
10179
+ * every sibling in the round to settle; if one never does, fail closed
10180
+ * rather than trust an index the batch may still change. Sibling saves
10181
+ * produce no images and are not waited on (two saves in one batch would
10182
+ * otherwise deadlock into a double timeout). Script-inner calls
10183
+ * (`pytc-…`) run sequentially inside their script host and skip the
10184
+ * barrier — the host call itself is the only pending sibling they'd see.
10185
+ */
10186
+ async awaitSiblingToolCalls(agent, call) {
10187
+ if (call.id.startsWith('pytc-'))
10188
+ return;
10189
+ const BARRIER_MS = 120_000;
10190
+ const POLL_MS = 20;
10191
+ const startedAt = Date.now();
10192
+ for (;;) {
10193
+ const state = agent.state;
10194
+ // Idle (puppet path) or the round was torn down: nothing to wait on.
10195
+ if (state.status !== 'waiting_for_tools')
10196
+ return;
10197
+ const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
10198
+ if (open.length === 0)
10199
+ return;
10200
+ if (Date.now() - startedAt > BARRIER_MS) {
10201
+ throw new Error(`save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
10202
+ `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
10203
+ 'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
10204
+ 'Save again once they have finished.');
10205
+ }
10206
+ await new Promise((r) => setTimeout(r, POLL_MS));
10207
+ }
10208
+ }
10209
+ /**
10210
+ * Handle the synthesized `save_recent_image` tool (issue #104): build ONE
10211
+ * ordered inventory of every image the calling agent has seen — attachment
10212
+ * image blocks (blobs re-inlined) AND tool-result images (their history
10213
+ * placeholders carry a ref, resolved through the agent's ToolImageLedger)
10214
+ * — counted back from the most recent, then write the requested bytes to
10215
+ * a workspace mount via WorkspaceModule.writeBinary.
10216
+ *
10217
+ * Fail-closed: when the image at a requested position cannot be produced
10218
+ * (bytes evicted, minted by an earlier process, or a placeholder written
10219
+ * before retention existed) the call fails AT THAT INDEX and writes
10220
+ * nothing. It never slides to an older image — a filename must not
10221
+ * authenticate bytes from another surface.
9598
10222
  */
9599
10223
  dispatchSaveImageToolCall(agentName, call) {
9600
10224
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
@@ -9621,16 +10245,166 @@ export class AgentFramework {
9621
10245
  if (typeof input.path !== 'string' || input.path.length === 0) {
9622
10246
  throw new Error('save_recent_image: `path` (mount-prefixed) is required');
9623
10247
  }
9624
- const index = input.index === undefined ? 0 : Number(input.index);
9625
- if (!Number.isInteger(index) || index < 0) {
9626
- throw new Error('save_recent_image: `index` must be a non-negative integer');
10248
+ const ledger = this.toolImageLedgerFor(agentName);
10249
+ const describeTool = (c) => `${c.toolName} (call ${c.toolCallId})`;
10250
+ const unavailable = (status) => status === 'evicted'
10251
+ ? 'its bytes were evicted from the bounded retention budget'
10252
+ : 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
10253
+ 'very old refs age out; a mistyped ref looks the same)';
10254
+ const resolve = (candidate, position) => {
10255
+ const rangeOffset = 0; // filled by the caller
10256
+ if (candidate.kind === 'attachment') {
10257
+ if (candidate.data === null) {
10258
+ throw new Error(`save_recent_image: image at index ${position} is not stored inline (base64) — cannot save it. Nothing written.`);
10259
+ }
10260
+ const bytes = Buffer.from(candidate.data, 'base64');
10261
+ return {
10262
+ rangeOffset,
10263
+ data: candidate.data,
10264
+ mediaType: candidate.mediaType,
10265
+ receipt: {
10266
+ source: 'attachment',
10267
+ mediaType: candidate.mediaType,
10268
+ byteSize: bytes.byteLength,
10269
+ sha256: sha256Hex(bytes),
10270
+ messagesBack: candidate.messagesBack,
10271
+ },
10272
+ };
10273
+ }
10274
+ if (candidate.kind === 'reference') {
10275
+ throw new Error(`save_recent_image: image at index ${position} is a reference (${candidate.refId}, ${candidate.mediaType}, ` +
10276
+ `from ${describeTool(candidate)}) — its bytes are not held here. Nothing written. ` +
10277
+ 'Fetch it with fetch_reference and save the materialized file instead.');
10278
+ }
10279
+ if (candidate.ref === null) {
10280
+ throw new Error(`save_recent_image: image at index ${position} is a tool-result image from ${describeTool(candidate)} ` +
10281
+ 'recorded without a retention ref (written before tool-image retention existed, or inside a ' +
10282
+ 'script run, where tool images are not retained) — its bytes are not available. Nothing written. ' +
10283
+ 'Re-run the tool for a fresh image rather than saving an older one.');
10284
+ }
10285
+ const lookup = ledger.lookup(candidate.ref);
10286
+ // The placeholder came out of stored TEXT, which a tool result can
10287
+ // quote from anywhere (a fetch_history of a channel where someone
10288
+ // pasted their save receipt). A genuine placeholder is written by
10289
+ // its own call's serialization, so the ref's provenance names that
10290
+ // call; a quoted or forged one cannot. Refuse the mismatch.
10291
+ if (lookup.status !== 'unknown' && lookup.image.toolCallId !== candidate.toolCallId) {
10292
+ throw new Error(`save_recent_image: image at index ${position} cites ${candidate.ref}, but that image belongs to ` +
10293
+ `${describeTool(lookup.image)}, not to ${describeTool(candidate)} where the placeholder appears — ` +
10294
+ 'the placeholder is quoted or forged text, not this result\'s own image. Nothing written.');
10295
+ }
10296
+ if (lookup.status !== 'retained') {
10297
+ throw new Error(`save_recent_image: image at index ${position} (${candidate.ref}, from ${describeTool(candidate)}) ` +
10298
+ `is no longer retained — ${unavailable(lookup.status)}. Nothing written. ` +
10299
+ 'Re-run the tool for a fresh image rather than saving an older one.');
10300
+ }
10301
+ const image = lookup.image;
10302
+ return {
10303
+ rangeOffset,
10304
+ data: image.data,
10305
+ mediaType: image.mediaType,
10306
+ receipt: {
10307
+ source: 'tool-result',
10308
+ ref: image.ref,
10309
+ toolName: image.toolName,
10310
+ toolCallId: image.toolCallId,
10311
+ blockIndex: image.blockIndex,
10312
+ mediaType: image.mediaType,
10313
+ byteSize: image.byteSize,
10314
+ sha256: image.sha256,
10315
+ messagesBack: candidate.messagesBack,
10316
+ },
10317
+ };
10318
+ };
10319
+ // Barrier: a save dispatched alongside other calls in one batch must
10320
+ // not inventory before their results exist — those are the newest
10321
+ // images. Waits (bounded) for every sibling to settle, or fails closed.
10322
+ await this.awaitSiblingToolCalls(agent, call);
10323
+ const found = [];
10324
+ let index = 0;
10325
+ let count = 1;
10326
+ /** Direct-ref mode: the one ref to find in the scannable context. */
10327
+ let wantedRef = null;
10328
+ if (input.ref !== undefined) {
10329
+ if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
10330
+ throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
10331
+ }
10332
+ if (input.index !== undefined || input.count !== undefined) {
10333
+ throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
10334
+ }
10335
+ wantedRef = input.ref;
9627
10336
  }
9628
- const count = input.count === undefined ? 1 : Number(input.count);
9629
- const MAX_COUNT = 20;
9630
- if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
9631
- throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
10337
+ else {
10338
+ index = input.index === undefined ? 0 : Number(input.index);
10339
+ if (!Number.isInteger(index) || index < 0) {
10340
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
10341
+ }
10342
+ count = input.count === undefined ? 1 : Number(input.count);
10343
+ const MAX_COUNT = 20;
10344
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
10345
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
10346
+ }
9632
10347
  }
9633
10348
  const lastWanted = index + count - 1;
10349
+ let seen = 0;
10350
+ const collecting = () => (wantedRef !== null ? found.length === 0 : found.length < count);
10351
+ // One walk serves both modes. By index: the requested range of the
10352
+ // ordered inventory. By ref: the slot whose placeholder cites that
10353
+ // ref — the ref is resolved THROUGH its occurrence in context, so
10354
+ // the same provenance cross-check applies (a ref that is not visible
10355
+ // in the scannable window — an undone branch, quoted text naming a
10356
+ // real ref — is not saveable by name either).
10357
+ const consider = (candidate) => {
10358
+ if (wantedRef !== null) {
10359
+ if (candidate.kind === 'tool' && candidate.ref === wantedRef && found.length === 0) {
10360
+ found.push(resolve(candidate, seen));
10361
+ }
10362
+ }
10363
+ else if (seen >= index && seen <= lastWanted) {
10364
+ const resolved = resolve(candidate, seen);
10365
+ resolved.rangeOffset = seen - index;
10366
+ found.push(resolved);
10367
+ }
10368
+ seen++;
10369
+ };
10370
+ const considerToolResultImages = (toolCallId, toolName, placeholders, messagesBack) => {
10371
+ for (let p = placeholders.length - 1; p >= 0 && collecting(); p--) {
10372
+ const ph = placeholders[p];
10373
+ if (ph.kind === 'inline') {
10374
+ consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
10375
+ continue;
10376
+ }
10377
+ // Reference stub: the registry's testimony (when this process
10378
+ // still has the record) says whether it was an image; the
10379
+ // sniffed stub text is the fallback. Non-image references
10380
+ // are not slots.
10381
+ const testimony = referenceRegistry.get(ph.refId)?.testimony.mimeType?.toLowerCase() ?? ph.mediaType;
10382
+ if (!testimony || !testimony.startsWith('image/'))
10383
+ continue;
10384
+ consider({ kind: 'reference', refId: ph.refId, mediaType: testimony, toolCallId, toolName, messagesBack });
10385
+ }
10386
+ };
10387
+ // Same-batch results (e.g. a snapshot dispatched alongside the save)
10388
+ // are not in the store yet but ARE the newest images. Classify them
10389
+ // with the very serializer the commit path will run — contradiction
10390
+ // blocks withheld, uri-form blocks as reference stubs, unknown shapes
10391
+ // as no slot at all — so the inventory means the same thing before
10392
+ // and after commit. Retention is idempotent per block, so the
10393
+ // placeholder written at commit time carries the same ref.
10394
+ const state = agent.state;
10395
+ if (state.status === 'waiting_for_tools') {
10396
+ for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
10397
+ const tc = state.completed[i];
10398
+ if (tc.result.isError)
10399
+ continue;
10400
+ const text = toolResultDataToHistoryString(tc.result.data, undefined, {
10401
+ imageRef: (blockIndex, image) => ledger.retain({
10402
+ toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
10403
+ }).ref,
10404
+ });
10405
+ considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
10406
+ }
10407
+ }
9634
10408
  // Walk the message store tail-first in bounded windows, re-inlining
9635
10409
  // blob media, until we've collected the requested range. Scanning is
9636
10410
  // capped so a pathological index can't drag the whole store through
@@ -9639,37 +10413,45 @@ export class AgentFramework {
9639
10413
  const WINDOW = 25;
9640
10414
  const cm = agent.getContextManager();
9641
10415
  const total = cm.getMessageCount();
9642
- let seen = 0;
9643
10416
  let scanned = 0;
9644
- const found = [];
9645
- for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
10417
+ for (let end = total; end > 0 && scanned < MAX_SCAN && collecting(); end -= WINDOW) {
9646
10418
  const start = Math.max(0, end - WINDOW);
9647
- const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
10419
+ // By ref, attachment bytes are never needed — skip blob resolution.
10420
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: wantedRef === null });
9648
10421
  scanned += end - start;
9649
- for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
10422
+ for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
9650
10423
  const content = messages[i]?.content;
9651
10424
  if (!Array.isArray(content))
9652
10425
  continue;
9653
- for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
10426
+ const messagesBack = total - (start + i);
10427
+ for (let b = content.length - 1; b >= 0 && collecting(); b--) {
9654
10428
  const block = content[b];
9655
- if (block?.type !== 'image')
9656
- continue;
9657
- if (seen >= index && seen <= lastWanted) {
9658
- if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
9659
- throw new Error(`save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`);
9660
- }
9661
- found.push({
9662
- rangeOffset: seen - index,
9663
- data: block.source.data,
9664
- mediaType: block.source.mediaType ?? 'image/png',
9665
- messagesBack: total - (start + i),
10429
+ if (block?.type === 'image') {
10430
+ const inline = block.source?.type === 'base64' && typeof block.source.data === 'string';
10431
+ consider({
10432
+ kind: 'attachment',
10433
+ data: inline ? block.source.data : null,
10434
+ mediaType: block.source?.mediaType ?? 'image/png',
10435
+ messagesBack,
9666
10436
  });
9667
10437
  }
9668
- seen++;
10438
+ else if (block?.type === 'tool_result' && typeof block.content === 'string') {
10439
+ considerToolResultImages(block.toolUseId ?? 'unknown', block.toolName ?? 'unknown tool', parseImagePlaceholders(block.content), messagesBack);
10440
+ }
9669
10441
  }
9670
10442
  }
9671
10443
  }
9672
10444
  if (found.length === 0) {
10445
+ if (wantedRef !== null) {
10446
+ const lookup = ledger.lookup(wantedRef);
10447
+ throw new Error(lookup.status === 'unknown'
10448
+ ? `save_recent_image: ref ${wantedRef} cannot be saved — ${unavailable('unknown')}. Nothing written.`
10449
+ : `save_recent_image: ref ${wantedRef} (from ${describeTool(lookup.image)}) does not appear in your recent ` +
10450
+ `context (scanned ${Math.min(scanned, MAX_SCAN)} messages) — a ref is saved through its placeholder, ` +
10451
+ 'which must be visible to you' +
10452
+ (lookup.status === 'evicted' ? '; its bytes were also evicted from the bounded retention budget' : '') +
10453
+ '. Nothing written.');
10454
+ }
9673
10455
  throw new Error(seen === 0
9674
10456
  ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
9675
10457
  : `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`);
@@ -9705,7 +10487,7 @@ export class AgentFramework {
9705
10487
  savedFiles.push({
9706
10488
  ...result.data,
9707
10489
  imageIndex: index + image.rangeOffset,
9708
- messagesBack: image.messagesBack,
10490
+ ...image.receipt,
9709
10491
  });
9710
10492
  }
9711
10493
  finish({