@animalabs/agent-framework 0.13.0 → 0.15.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 (95) hide show
  1. package/dist/src/agent.d.ts +10 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +39 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +36 -6
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +451 -83
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +55 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +41 -1
  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/modules/history/index.d.ts +69 -0
  21. package/dist/src/modules/history/index.d.ts.map +1 -0
  22. package/dist/src/modules/history/index.js +519 -0
  23. package/dist/src/modules/history/index.js.map +1 -0
  24. package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
  25. package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
  26. package/dist/src/modules/history/search-regex-worker.js +57 -0
  27. package/dist/src/modules/history/search-regex-worker.js.map +1 -0
  28. package/dist/src/modules/index.d.ts +1 -0
  29. package/dist/src/modules/index.d.ts.map +1 -1
  30. package/dist/src/modules/index.js +1 -0
  31. package/dist/src/modules/index.js.map +1 -1
  32. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  33. package/dist/src/modules/workspace/index.js +42 -1
  34. package/dist/src/modules/workspace/index.js.map +1 -1
  35. package/dist/src/modules/workspace/types.d.ts +5 -0
  36. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  37. package/dist/src/modules/workspace/types.js.map +1 -1
  38. package/dist/src/tool-image-ledger.d.ts +175 -0
  39. package/dist/src/tool-image-ledger.d.ts.map +1 -0
  40. package/dist/src/tool-image-ledger.js +257 -0
  41. package/dist/src/tool-image-ledger.js.map +1 -0
  42. package/dist/src/tool-result-history.d.ts +15 -2
  43. package/dist/src/tool-result-history.d.ts.map +1 -1
  44. package/dist/src/tool-result-history.js +16 -9
  45. package/dist/src/tool-result-history.js.map +1 -1
  46. package/dist/src/types/agent.d.ts +10 -0
  47. package/dist/src/types/agent.d.ts.map +1 -1
  48. package/dist/src/types/trace.d.ts +4 -0
  49. package/dist/src/types/trace.d.ts.map +1 -1
  50. package/dist/test/event-gate.test.js +84 -1
  51. package/dist/test/event-gate.test.js.map +1 -1
  52. package/dist/test/history-module.test.d.ts +2 -0
  53. package/dist/test/history-module.test.d.ts.map +1 -0
  54. package/dist/test/history-module.test.js +449 -0
  55. package/dist/test/history-module.test.js.map +1 -0
  56. package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
  57. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
  58. package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
  59. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
  60. package/dist/test/kv-unified-wiring.test.js +39 -0
  61. package/dist/test/kv-unified-wiring.test.js.map +1 -1
  62. package/dist/test/puppet-tool-call.test.js +114 -1
  63. package/dist/test/puppet-tool-call.test.js.map +1 -1
  64. package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
  65. package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
  66. package/dist/test/puppet-turn-reservation.test.js +139 -0
  67. package/dist/test/puppet-turn-reservation.test.js.map +1 -0
  68. package/dist/test/save-recent-image.test.d.ts +26 -0
  69. package/dist/test/save-recent-image.test.d.ts.map +1 -0
  70. package/dist/test/save-recent-image.test.js +736 -0
  71. package/dist/test/save-recent-image.test.js.map +1 -0
  72. package/dist/test/tool-result-spill.test.js +114 -0
  73. package/dist/test/tool-result-spill.test.js.map +1 -1
  74. package/dist/test/turn-trigger-provenance.test.js +17 -0
  75. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  76. package/dist/test/workspace-read-text.test.d.ts +2 -0
  77. package/dist/test/workspace-read-text.test.d.ts.map +1 -0
  78. package/dist/test/workspace-read-text.test.js +101 -0
  79. package/dist/test/workspace-read-text.test.js.map +1 -0
  80. package/dist/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +3 -3
  82. package/src/agent.ts +41 -0
  83. package/src/framework.ts +519 -95
  84. package/src/gate/event-gate.ts +71 -3
  85. package/src/gate/index.ts +1 -1
  86. package/src/index.ts +1 -1
  87. package/src/modules/history/index.ts +613 -0
  88. package/src/modules/history/search-regex-worker.ts +76 -0
  89. package/src/modules/index.ts +2 -0
  90. package/src/modules/workspace/index.ts +42 -1
  91. package/src/modules/workspace/types.ts +5 -0
  92. package/src/tool-image-ledger.ts +334 -0
  93. package/src/tool-result-history.ts +30 -9
  94. package/src/types/agent.ts +10 -0
  95. package/src/types/trace.ts +4 -0
package/src/framework.ts CHANGED
@@ -80,6 +80,15 @@ import {
80
80
  truncateForHistory,
81
81
  DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
82
82
  } from './tool-result-history.js';
83
+ import {
84
+ TOOL_IMAGE_REF_RE,
85
+ ToolImageLedger,
86
+ formatPreservedImageSlots,
87
+ parseImagePlaceholders,
88
+ sha256Hex,
89
+ splitPreservingImageSlots,
90
+ type ParsedImagePlaceholder,
91
+ } from './tool-image-ledger.js';
83
92
  import { randomUUID } from 'node:crypto';
84
93
  import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
85
94
  import {
@@ -960,6 +969,11 @@ export class AgentFramework {
960
969
  * framework state like the core runtime settings and restored at create
961
970
  * (antra + Sol, 08-06); reset clears it back to the residence default. */
962
971
  private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
972
+ /** Per-agent retention of tool-result images with provenance (issue
973
+ * #104). In-memory and bounded by design: history keeps only the
974
+ * placeholder; this is what lets `save_recent_image` reach the bytes the
975
+ * resident actually saw — or fail at that exact index when they're gone. */
976
+ private toolImageLedgers: Map<string, ToolImageLedger> = new Map();
963
977
  /** Durable residence-configured inline cap from
964
978
  * FrameworkConfig.toolResultInlineMaxChars; null → house default. */
965
979
  private toolResultInlineMaxCharsConfig: number | null = null;
@@ -1221,8 +1235,17 @@ export class AgentFramework {
1221
1235
  privilegedUsersPath: config.gate.privilegedUsersPath,
1222
1236
  emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
1223
1237
  addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined),
1224
- requestInference: (agentName, reason, source) => {
1225
- framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
1238
+ requestInference: (agentName, reason, source, provenance) => {
1239
+ framework.pendingRequests.push({
1240
+ agentName, reason, source, timestamp: Date.now(),
1241
+ // gate-requested wakes carry where/who (EventGate wakeProvenance)
1242
+ // as TELEMETRY fields — the host's stamp reads them; the turn's
1243
+ // speech locus (channelId / addressed) is deliberately NOT set
1244
+ // here, so a batched wake routes exactly as it did before.
1245
+ ...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}),
1246
+ ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}),
1247
+ ...(provenance?.at ? { wakeAt: provenance.at } : {}),
1248
+ });
1226
1249
  },
1227
1250
  getAgentNames: () => [...framework.agents.keys()].filter(
1228
1251
  (n) => n !== framework.subconsciousAgentName),
@@ -1411,6 +1434,9 @@ export class AgentFramework {
1411
1434
  record.runner.dispose();
1412
1435
  }
1413
1436
  this.backgroundScripts.clear();
1437
+ // Retained tool images are per-process by design; a retained stopped
1438
+ // framework must not keep up to the whole ledger budget referenced.
1439
+ this.toolImageLedgers.clear();
1414
1440
 
1415
1441
  // A stopped host must never hang behind its own cooldown.
1416
1442
  this.cancelProviderAdmission();
@@ -2719,6 +2745,7 @@ export class AgentFramework {
2719
2745
  this.eventGate?.onInferenceEnded(agent.name);
2720
2746
  this.agents.delete(agent.name);
2721
2747
  }
2748
+ this.toolImageLedgers.delete(agent.name);
2722
2749
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2723
2750
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2724
2751
  // entries behind for the life of the store/session.
@@ -2979,11 +3006,18 @@ export class AgentFramework {
2979
3006
  name: 'save_recent_image',
2980
3007
  description:
2981
3008
  'Save one or more recent images from your own context to workspace files. ' +
2982
- 'Images are counted back from the most recent (index 0). A single image is ' +
3009
+ 'Images are counted back from the most recent (index 0), across BOTH ' +
3010
+ 'attachments in messages and images returned by tools (their history ' +
3011
+ 'placeholder shows a ref like `[image: image/png, ~691KB, ref img_k7x3q2_7]`; pass ' +
3012
+ '`ref` to save that exact image regardless of position). A single image is ' +
2983
3013
  'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
2984
3014
  'the range index..index+count-1 is saved with numeric suffixes ' +
2985
- '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
2986
- 'visible via workspace tools and the /files/ endpoint.',
3015
+ '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). If the image at ' +
3016
+ 'the requested position is no longer retained (evicted, or from before a ' +
3017
+ 'restart) the call fails and writes nothing — it never substitutes an older ' +
3018
+ 'image. When other tool calls in the same batch are still running, the save ' +
3019
+ 'waits for them so their images are counted. Receipts carry source tool call, MIME, byte size and SHA-256. Saved ' +
3020
+ 'files are visible via workspace tools and the /files/ endpoint.',
2987
3021
  inputSchema: {
2988
3022
  type: 'object',
2989
3023
  properties: {
@@ -2999,6 +3033,12 @@ export class AgentFramework {
2999
3033
  type: 'number',
3000
3034
  description: 'How many images to save, starting at `index` and going further back. Default 1.',
3001
3035
  },
3036
+ ref: {
3037
+ type: 'string',
3038
+ description:
3039
+ 'Save a specific tool-result image by its history ref (e.g. "img_k7x3q2_7", as shown in its placeholder in your context) instead of by position. ' +
3040
+ 'Mutually exclusive with `index`/`count`.',
3041
+ },
3002
3042
  },
3003
3043
  required: ['path'],
3004
3044
  },
@@ -4436,7 +4476,7 @@ export class AgentFramework {
4436
4476
  // byte-matched (the window stores what the membrane sends;
4437
4477
  // divergence breaks the compile prefix).
4438
4478
  const { blocks: toolResultContent, spilled } =
4439
- await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
4479
+ await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
4440
4480
  agent.getContextManager().addMessage('user', toolResultContent);
4441
4481
 
4442
4482
  // Flush any messages that were deferred while this turn was in
@@ -5117,6 +5157,7 @@ export class AgentFramework {
5117
5157
  const channelId = this.conversationAgentHomes.get(agentName);
5118
5158
  const agent = this.agents.get(agentName);
5119
5159
  this.agents.delete(agentName);
5160
+ this.toolImageLedgers.delete(agentName);
5120
5161
  this.agentConfigs.delete(agentName);
5121
5162
  this.conversationAgentHomes.delete(agentName);
5122
5163
  this.evictTurnCheckpoints(agentName);
@@ -5600,6 +5641,17 @@ export class AgentFramework {
5600
5641
  if (r.addressed) addressedReq = r;
5601
5642
  }
5602
5643
  const channelReq = addressedReq ?? ambientReq;
5644
+ // Telemetry provenance (who/where woke the agent): from the routing
5645
+ // winner when it carries one; else from the most recent request that
5646
+ // does (gate wakes name a counterparty without setting a locus),
5647
+ // ordered by the event's own time (wakeAt) rather than flush order.
5648
+ let provReq: InferenceRequest | undefined = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined;
5649
+ if (!provReq) {
5650
+ for (const r of requests) {
5651
+ if (!r.counterparty && !r.wakeChannelId) continue;
5652
+ if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
5653
+ }
5654
+ }
5603
5655
  await this.startAgentStream(agent, {
5604
5656
  ...trigger,
5605
5657
  channelId: channelReq?.channelId,
@@ -5608,7 +5660,9 @@ export class AgentFramework {
5608
5660
  // the channel for routing but names no author — the restart is its
5609
5661
  // own cause, and borrowing another request's author would be false
5610
5662
  // provenance.
5611
- counterparty: budgetRestart ? undefined : channelReq?.counterparty,
5663
+ counterparty: budgetRestart ? undefined : provReq?.counterparty,
5664
+ wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
5665
+ wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
5612
5666
  });
5613
5667
  }
5614
5668
  }
@@ -6078,6 +6132,23 @@ export class AgentFramework {
6078
6132
  this.releasePrimaryProviderGate(agent.name);
6079
6133
  return;
6080
6134
  }
6135
+ // The scheduler's turn-alive test passed BEFORE the park; the wait
6136
+ // is the one gap in which another turn-alive holder can take the
6137
+ // agent (puppetToolCall's reservation, #145). Never replace its
6138
+ // token — the re-entry would set ours over it, and a short turn
6139
+ // here ends and flushes before the puppeted tool returns, leaving
6140
+ // its pair queued behind a flush that already happened. Give
6141
+ // admission back and hand the wake to the scheduler, which keeps
6142
+ // it queued while the agent is turn-alive and starts it normally
6143
+ // once the holder releases.
6144
+ if (this.activeTurnTokens.has(agent.name)) {
6145
+ this.releasePrimaryProviderGate(agent.name);
6146
+ this.pendingRequests.push(trigger ?? {
6147
+ agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
6148
+ });
6149
+ console.error(`[provider-admission] ${agent.name}: turn-alive while parked — wake requeued, not started`);
6150
+ return;
6151
+ }
6081
6152
  await this.startAgentStream(agent, trigger, attempt, true);
6082
6153
  }).catch((error) => {
6083
6154
  this.releasePrimaryProviderGate(agent.name);
@@ -6859,6 +6930,7 @@ export class AgentFramework {
6859
6930
  const readyState = agent.state as AgentState;
6860
6931
  if (readyState.status === 'ready') {
6861
6932
  const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
6933
+ agent.name,
6862
6934
  readyState.toolResults,
6863
6935
  this.resolveToolResultInlineCap(agent).cap,
6864
6936
  );
@@ -7731,8 +7803,11 @@ export class AgentFramework {
7731
7803
  * affordance to a model that cannot find it (older models especially).
7732
7804
  *
7733
7805
  * Semantics:
7734
- * - Refused unless the agent is idle: puppeting mid-turn would corrupt
7735
- * the turn state machine and the live stream's wire ordering.
7806
+ * - Refused unless the agent is idle with no turn alive: puppeting
7807
+ * mid-turn would corrupt the turn state machine and the live stream's
7808
+ * wire ordering. For its own duration the puppet HOLDS the turn-alive
7809
+ * marker (wakes requeue, cross-turn writers defer), so a wake landing
7810
+ * during the tool's execution cannot start a turn underneath the pair.
7736
7811
  * - Refused for tools outside the agent's own surface (canUseTool): the
7737
7812
  * stored pair must be an act the agent could genuinely have taken.
7738
7813
  * - The call executes FOR REAL through the shared dispatch (MCPL,
@@ -7756,9 +7831,13 @@ export class AgentFramework {
7756
7831
  ): Promise<{ toolUseId: string; result: ToolResult }> {
7757
7832
  const agent = this.agents.get(agentName);
7758
7833
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
7759
- if (agent.state.status !== 'idle') {
7834
+ // Idle AND no turn alive: status reads 'idle' from dequeue until the
7835
+ // stream registers, and again while a turn's teardown is pending
7836
+ // (the scheduler's own busy test, 'idle+turn-alive').
7837
+ if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) {
7838
+ const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
7760
7839
  throw new Error(
7761
- `puppet refused: agent ${agentName} is ${agent.state.status} (requires idle — ` +
7840
+ `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
7762
7841
  `injecting a turn under an active stream corrupts wire ordering)`,
7763
7842
  );
7764
7843
  }
@@ -7778,43 +7857,94 @@ export class AgentFramework {
7778
7857
  for (let i = 0; i < 22; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
7779
7858
  const toolUseId = `toolu_01${suffix}`;
7780
7859
 
7781
- const started = Date.now();
7782
- const result = await this.executeToolCall({
7783
- id: toolUseId,
7784
- name: toolName,
7785
- input,
7786
- callerAgentName: agentName,
7787
- });
7788
- const durationMs = Date.now() - started;
7789
-
7790
- // Store the pair through the same shapes the ordinary path uses. Build
7791
- // the result blocks BEFORE storing the tool_use: the spill path awaits,
7792
- // and a message arriving during that await must land before the pair,
7793
- // never between tool_use and its tool_result. The two addMessage calls
7794
- // below are synchronous and adjacent — nothing can interleave.
7795
- const { blocks } = await this.buildStoredToolResultContent(
7796
- [{ id: toolUseId, name: toolName, input, result, durationMs }],
7797
- this.resolveToolResultInlineCap(agent).cap,
7798
- );
7799
- const cm = agent.getContextManager();
7800
- cm.addMessage(agentName, [
7801
- { type: 'tool_use', id: toolUseId, name: toolName, input } as ContentBlock,
7802
- ]);
7803
- cm.addMessage('user', blocks);
7860
+ // Reserve the agent against turn start for the WHOLE operation, before
7861
+ // the first await (#145). The idle check above is a point in time; the
7862
+ // tool execution and the result build both await, and a wake arriving
7863
+ // in either gap used to start a real turn — then the pair below wrote
7864
+ // straight through the context manager into a turn that never saw it,
7865
+ // the exact wire-order corruption this method exists to avoid. Holding
7866
+ // a turn token is the same invariant the scheduler respects (turn-alive
7867
+ // requeues wakes) and the addMessage guard reads (cross-turn writers
7868
+ // defer), so for the duration this behaves like a turn with no stream.
7869
+ // Token-matched release in finally, same leak-proofing as
7870
+ // startAgentStream: a token nobody clears wedges the agent.
7871
+ const turnToken = this.nextTurnToken++;
7872
+ this.activeTurnTokens.set(agentName, turnToken);
7873
+ let ownedToEnd = false;
7874
+ try {
7875
+ const started = Date.now();
7876
+ const result = await this.executeToolCall({
7877
+ id: toolUseId,
7878
+ name: toolName,
7879
+ input,
7880
+ callerAgentName: agentName,
7881
+ });
7882
+ const durationMs = Date.now() - started;
7883
+
7884
+ // Store the pair through the same shapes the ordinary path uses. Build
7885
+ // the result blocks BEFORE storing the tool_use: the spill path awaits.
7886
+ // A message arriving during either await is deferred by the turn
7887
+ // token (as during a real tool call) and lands AFTER the pair, never
7888
+ // between tool_use and its tool_result. The two addMessage calls
7889
+ // below are synchronous and adjacent — nothing can interleave.
7890
+ const { blocks } = await this.buildStoredToolResultContent(
7891
+ agentName,
7892
+ [{ id: toolUseId, name: toolName, input, result, durationMs }],
7893
+ this.resolveToolResultInlineCap(agent).cap,
7894
+ );
7895
+ const toolUse: ContentBlock[] = [
7896
+ { type: 'tool_use', id: toolUseId, name: toolName, input } as ContentBlock,
7897
+ ];
7898
+ // Defensive: no path replaces a live token any more (the provider-
7899
+ // admission re-entry re-tests turn-alive and requeues instead), but
7900
+ // if one ever does, the side effect has happened and the pair must
7901
+ // still be stored — not under that turn: queue it as a unit for the
7902
+ // next flush (drainDeferredFor keeps order; the tool_result entry is
7903
+ // never split from its tool_use because both ride the same drain).
7904
+ // The finally below flushes it itself if that turn is already over.
7905
+ ownedToEnd = this.activeTurnTokens.get(agentName) === turnToken;
7906
+ if (ownedToEnd) {
7907
+ const cm = agent.getContextManager();
7908
+ cm.addMessage(agentName, toolUse);
7909
+ cm.addMessage('user', blocks);
7910
+ } else {
7911
+ this.deferredMessages.push(
7912
+ { participant: agentName, content: toolUse, forAgent: agentName },
7913
+ { participant: 'user', content: blocks, forAgent: agentName },
7914
+ );
7915
+ }
7804
7916
 
7805
- this.emitTrace({
7806
- type: 'puppet:tool-call',
7807
- agentName,
7808
- toolName,
7809
- toolUseId,
7810
- isError: !!result.isError,
7811
- durationMs,
7812
- });
7813
- console.log(
7814
- `[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
7815
- `in ${durationMs}ms (${toolUseId})`,
7816
- );
7817
- return { toolUseId, result };
7917
+ this.emitTrace({
7918
+ type: 'puppet:tool-call',
7919
+ agentName,
7920
+ toolName,
7921
+ toolUseId,
7922
+ isError: !!result.isError,
7923
+ durationMs,
7924
+ ...(ownedToEnd ? {} : { deferred: true }),
7925
+ });
7926
+ console.log(
7927
+ `[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
7928
+ `in ${durationMs}ms (${toolUseId})${ownedToEnd ? '' : ' — deferred behind a live turn'}`,
7929
+ );
7930
+ return { toolUseId, result };
7931
+ } finally {
7932
+ if (this.activeTurnTokens.get(agentName) === turnToken) this.activeTurnTokens.delete(agentName);
7933
+ // Messages deferred while we held the token land now, after the pair
7934
+ // — the same end-of-turn flush driveStream performs, under the same
7935
+ // tool-cycle guard. (No wake is requested: the agent sees the pair,
7936
+ // and anything that arrived meanwhile, on its next turn.) Keyed on
7937
+ // "no turn alive", not on token ownership: if a turn did replace us
7938
+ // and has already ended, its flush ran before our pair was queued —
7939
+ // nobody else will flush it, so we do.
7940
+ if (!this.activeTurnTokens.has(agentName)
7941
+ && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
7942
+ for (const msg of this.drainDeferredFor(agentName)) {
7943
+ this.addMessage(msg.participant, msg.content, msg.metadata,
7944
+ msg.forAgent ? { forAgent: msg.forAgent } : undefined);
7945
+ }
7946
+ }
7947
+ }
7818
7948
  }
7819
7949
 
7820
7950
  private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
@@ -8299,6 +8429,7 @@ export class AgentFramework {
8299
8429
  * live wire copy can reuse the identical strings.
8300
8430
  */
8301
8431
  private async buildStoredToolResultContent(
8432
+ agentName: string,
8302
8433
  toolResults: CompletedToolCall[],
8303
8434
  maxChars: number | undefined,
8304
8435
  ): Promise<{
@@ -8307,10 +8438,22 @@ export class AgentFramework {
8307
8438
  }> {
8308
8439
  const spilled = new Map<string, { text: string; filePath: string | null }>();
8309
8440
  const dateLabel = new Date().toISOString().slice(0, 10);
8441
+ const ledger = this.toolImageLedgerFor(agentName);
8310
8442
  for (const tc of toolResults) {
8443
+ // Retain every image the result carries BEFORE the placeholder is
8444
+ // written (issue #104): the placeholder embeds the ref, so the image
8445
+ // stays reachable by provenance after the bytes leave the wire.
8311
8446
  const raw = tc.result.isError
8312
8447
  ? tc.result.error ?? 'Unknown error'
8313
- : toolResultDataToHistoryString(tc.result.data, undefined);
8448
+ : toolResultDataToHistoryString(tc.result.data, undefined, {
8449
+ imageRef: (blockIndex, image) => ledger.retain({
8450
+ toolCallId: tc.id,
8451
+ toolName: tc.name,
8452
+ blockIndex,
8453
+ data: image.data,
8454
+ mediaType: image.mimeType,
8455
+ }).ref,
8456
+ });
8314
8457
  spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
8315
8458
  }
8316
8459
  const blocks: ContentBlock[] = toolResults.map(tc => ({
@@ -8331,6 +8474,13 @@ export class AgentFramework {
8331
8474
  label: string,
8332
8475
  ): Promise<{ text: string; filePath: string | null }> {
8333
8476
  if (!cap || content.length <= cap) return { text: content, filePath: null };
8477
+ // The stored text is the only place save_recent_image discovers tool
8478
+ // images, and the live wire delivers every image regardless of text
8479
+ // length — so an image slot dropped by the cut is an image the resident
8480
+ // saw that the index would silently slide past (issue #104). Keep the
8481
+ // slots at or past the cut, re-appended after the notice.
8482
+ const { head, tail } = splitPreservingImageSlots(content, cap);
8483
+ const kept = formatPreservedImageSlots(tail);
8334
8484
 
8335
8485
  const workspace = this.getWorkspaceModule();
8336
8486
  const mountName = workspace ? this.firstWritableMountName(workspace) : null;
@@ -8341,11 +8491,18 @@ export class AgentFramework {
8341
8491
  try {
8342
8492
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
8343
8493
  if (result.success) {
8494
+ // JSON serialization can expand each read-back code unit to six
8495
+ // chars (\\u0000). Leave room for the page's metadata and a possible
8496
+ // extra code unit at a surrogate-pair boundary.
8497
+ const pageOverhead = JSON.stringify(path).length + 256;
8498
+ const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
8344
8499
  return {
8345
- text: safeSlice(content, 0, cap)
8346
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
8347
- + 'Read/grep it with your file tools, or raise the inline cap via '
8348
- + 'agent_settings update tool_result_inline_max_chars.]',
8500
+ text: head
8501
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
8502
+ + `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
8503
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
8504
+ + 'agent_settings update tool_result_inline_max_chars.]'
8505
+ + kept,
8349
8506
  filePath: path,
8350
8507
  };
8351
8508
  }
@@ -8368,15 +8525,17 @@ export class AgentFramework {
8368
8525
  `[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
8369
8526
  );
8370
8527
  return {
8371
- text: safeSlice(content, 0, cap)
8372
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
8373
- + ` (${failure}); content over the cap was not retained]`,
8528
+ text: head
8529
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
8530
+ + ` (${failure}); content over the cap was not retained]`
8531
+ + kept,
8374
8532
  filePath: null,
8375
8533
  };
8376
8534
  }
8377
8535
  return {
8378
- text: safeSlice(content, 0, cap)
8379
- + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
8536
+ text: head
8537
+ + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
8538
+ + kept,
8380
8539
  filePath: null,
8381
8540
  };
8382
8541
  }
@@ -8594,6 +8753,15 @@ export class AgentFramework {
8594
8753
  return hasImage ? blocks : null;
8595
8754
  }
8596
8755
 
8756
+ private toolImageLedgerFor(agentName: string): ToolImageLedger {
8757
+ let ledger = this.toolImageLedgers.get(agentName);
8758
+ if (!ledger) {
8759
+ ledger = new ToolImageLedger();
8760
+ this.toolImageLedgers.set(agentName, ledger);
8761
+ }
8762
+ return ledger;
8763
+ }
8764
+
8597
8765
  /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
8598
8766
  private strategyDerivedToolResultChars(agent: Agent): number | undefined {
8599
8767
  const strategy = agent.getContextManager().getStrategy();
@@ -8685,11 +8853,18 @@ export class AgentFramework {
8685
8853
  // Blob threshold: 10KB - typical context-heavy requests exceed this
8686
8854
  const BLOB_THRESHOLD = 10000;
8687
8855
 
8856
+ // Both branches persist the JSON view, never the live object: a compiled
8857
+ // request carries non-JSON members (the kv-unified `onCacheWireReceipt`
8858
+ // hook is a function) and Chronicle's JSON bridge rejects those with
8859
+ // "JS functions cannot be represented as a serde_json::Value" — which,
8860
+ // thrown from the failure path, masked the failure being logged.
8688
8861
  if (entry.request && typeof entry.request === 'object') {
8689
8862
  const requestJson = JSON.stringify(entry.request);
8690
8863
  if (requestJson.length > BLOB_THRESHOLD) {
8691
8864
  const blobId = this.store.storeBlob(Buffer.from(requestJson), 'application/json');
8692
8865
  entryToStore.request = { blobId };
8866
+ } else {
8867
+ entryToStore.request = JSON.parse(requestJson);
8693
8868
  }
8694
8869
  }
8695
8870
 
@@ -8698,6 +8873,8 @@ export class AgentFramework {
8698
8873
  if (responseJson.length > BLOB_THRESHOLD) {
8699
8874
  const blobId = this.store.storeBlob(Buffer.from(responseJson), 'application/json');
8700
8875
  entryToStore.response = { blobId };
8876
+ } else {
8877
+ entryToStore.response = JSON.parse(responseJson);
8701
8878
  }
8702
8879
  }
8703
8880
 
@@ -11690,10 +11867,53 @@ export class AgentFramework {
11690
11867
  }
11691
11868
 
11692
11869
  /**
11693
- * Handle the synthesized `save_recent_image` tool: locate the requested
11694
- * image blocks in the calling agent's context (blobs re-inlined, counted
11695
- * back from the most recent) and write their bytes to a workspace mount
11696
- * via WorkspaceModule.writeBinary.
11870
+ * `save_recent_image` barrier. Module tool calls in a batch are only
11871
+ * ENQUEUED by dispatch, while the save runs at once — so a [snap, save]
11872
+ * round would inventory before the snapshot exists and silently save the
11873
+ * newest OLDER image (issue #104, same-batch representation). Wait for
11874
+ * every sibling in the round to settle; if one never does, fail closed
11875
+ * rather than trust an index the batch may still change. Sibling saves
11876
+ * produce no images and are not waited on (two saves in one batch would
11877
+ * otherwise deadlock into a double timeout). Script-inner calls
11878
+ * (`pytc-…`) run sequentially inside their script host and skip the
11879
+ * barrier — the host call itself is the only pending sibling they'd see.
11880
+ */
11881
+ private async awaitSiblingToolCalls(agent: Agent, call: ToolCall): Promise<void> {
11882
+ if (call.id.startsWith('pytc-')) return;
11883
+ const BARRIER_MS = 120_000;
11884
+ const POLL_MS = 20;
11885
+ const startedAt = Date.now();
11886
+ for (;;) {
11887
+ const state = agent.state;
11888
+ // Idle (puppet path) or the round was torn down: nothing to wait on.
11889
+ if (state.status !== 'waiting_for_tools') return;
11890
+ const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
11891
+ if (open.length === 0) return;
11892
+ if (Date.now() - startedAt > BARRIER_MS) {
11893
+ throw new Error(
11894
+ `save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
11895
+ `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
11896
+ 'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
11897
+ 'Save again once they have finished.',
11898
+ );
11899
+ }
11900
+ await new Promise((r) => setTimeout(r, POLL_MS));
11901
+ }
11902
+ }
11903
+
11904
+ /**
11905
+ * Handle the synthesized `save_recent_image` tool (issue #104): build ONE
11906
+ * ordered inventory of every image the calling agent has seen — attachment
11907
+ * image blocks (blobs re-inlined) AND tool-result images (their history
11908
+ * placeholders carry a ref, resolved through the agent's ToolImageLedger)
11909
+ * — counted back from the most recent, then write the requested bytes to
11910
+ * a workspace mount via WorkspaceModule.writeBinary.
11911
+ *
11912
+ * Fail-closed: when the image at a requested position cannot be produced
11913
+ * (bytes evicted, minted by an earlier process, or a placeholder written
11914
+ * before retention existed) the call fails AT THAT INDEX and writes
11915
+ * nothing. It never slides to an older image — a filename must not
11916
+ * authenticate bytes from another surface.
11697
11917
  */
11698
11918
  private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
11699
11919
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
@@ -11708,26 +11928,219 @@ export class AgentFramework {
11708
11928
  });
11709
11929
  this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
11710
11930
  };
11931
+ type Candidate =
11932
+ | { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number }
11933
+ | {
11934
+ kind: 'tool';
11935
+ ref: string | null;
11936
+ /** The tool_result block the placeholder was found in — NOT trusted
11937
+ * text; a resolved ref must belong to this very call. */
11938
+ toolCallId: string;
11939
+ toolName: string;
11940
+ mediaType: string;
11941
+ messagesBack: number;
11942
+ }
11943
+ | {
11944
+ kind: 'reference';
11945
+ refId: string;
11946
+ mediaType: string;
11947
+ toolCallId: string;
11948
+ toolName: string;
11949
+ messagesBack: number;
11950
+ };
11951
+ interface Resolved {
11952
+ rangeOffset: number;
11953
+ data: string;
11954
+ mediaType: string;
11955
+ receipt: Record<string, unknown>;
11956
+ }
11711
11957
  void (async (): Promise<void> => {
11712
11958
  try {
11713
11959
  const workspace = this.getWorkspaceModule();
11714
11960
  if (!workspace) throw new Error('save_recent_image requires a workspace module');
11715
11961
  const agent = this.agents.get(agentName);
11716
11962
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
11717
- const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown };
11963
+ const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown; ref?: unknown };
11718
11964
  if (typeof input.path !== 'string' || input.path.length === 0) {
11719
11965
  throw new Error('save_recent_image: `path` (mount-prefixed) is required');
11720
11966
  }
11721
- const index = input.index === undefined ? 0 : Number(input.index);
11722
- if (!Number.isInteger(index) || index < 0) {
11723
- throw new Error('save_recent_image: `index` must be a non-negative integer');
11724
- }
11725
- const count = input.count === undefined ? 1 : Number(input.count);
11726
- const MAX_COUNT = 20;
11727
- if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
11728
- throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
11967
+ const ledger = this.toolImageLedgerFor(agentName);
11968
+
11969
+ const describeTool = (c: { toolName: string; toolCallId: string }): string =>
11970
+ `${c.toolName} (call ${c.toolCallId})`;
11971
+ const unavailable = (status: 'evicted' | 'unknown'): string =>
11972
+ status === 'evicted'
11973
+ ? 'its bytes were evicted from the bounded retention budget'
11974
+ : 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
11975
+ 'very old refs age out; a mistyped ref looks the same)';
11976
+ const resolve = (candidate: Candidate, position: number): Resolved => {
11977
+ const rangeOffset = 0; // filled by the caller
11978
+ if (candidate.kind === 'attachment') {
11979
+ if (candidate.data === null) {
11980
+ throw new Error(
11981
+ `save_recent_image: image at index ${position} is not stored inline (base64) — cannot save it. Nothing written.`,
11982
+ );
11983
+ }
11984
+ const bytes = Buffer.from(candidate.data, 'base64');
11985
+ return {
11986
+ rangeOffset,
11987
+ data: candidate.data,
11988
+ mediaType: candidate.mediaType,
11989
+ receipt: {
11990
+ source: 'attachment',
11991
+ mediaType: candidate.mediaType,
11992
+ byteSize: bytes.byteLength,
11993
+ sha256: sha256Hex(bytes),
11994
+ messagesBack: candidate.messagesBack,
11995
+ },
11996
+ };
11997
+ }
11998
+ if (candidate.kind === 'reference') {
11999
+ throw new Error(
12000
+ `save_recent_image: image at index ${position} is a reference (${candidate.refId}, ${candidate.mediaType}, ` +
12001
+ `from ${describeTool(candidate)}) — its bytes are not held here. Nothing written. ` +
12002
+ 'Fetch it with fetch_reference and save the materialized file instead.',
12003
+ );
12004
+ }
12005
+ if (candidate.ref === null) {
12006
+ throw new Error(
12007
+ `save_recent_image: image at index ${position} is a tool-result image from ${describeTool(candidate)} ` +
12008
+ 'recorded without a retention ref (written before tool-image retention existed, or inside a ' +
12009
+ 'script run, where tool images are not retained) — its bytes are not available. Nothing written. ' +
12010
+ 'Re-run the tool for a fresh image rather than saving an older one.',
12011
+ );
12012
+ }
12013
+ const lookup = ledger.lookup(candidate.ref);
12014
+ // The placeholder came out of stored TEXT, which a tool result can
12015
+ // quote from anywhere (a fetch_history of a channel where someone
12016
+ // pasted their save receipt). A genuine placeholder is written by
12017
+ // its own call's serialization, so the ref's provenance names that
12018
+ // call; a quoted or forged one cannot. Refuse the mismatch.
12019
+ if (lookup.status !== 'unknown' && lookup.image.toolCallId !== candidate.toolCallId) {
12020
+ throw new Error(
12021
+ `save_recent_image: image at index ${position} cites ${candidate.ref}, but that image belongs to ` +
12022
+ `${describeTool(lookup.image)}, not to ${describeTool(candidate)} where the placeholder appears — ` +
12023
+ 'the placeholder is quoted or forged text, not this result\'s own image. Nothing written.',
12024
+ );
12025
+ }
12026
+ if (lookup.status !== 'retained') {
12027
+ throw new Error(
12028
+ `save_recent_image: image at index ${position} (${candidate.ref}, from ${describeTool(candidate)}) ` +
12029
+ `is no longer retained — ${unavailable(lookup.status)}. Nothing written. ` +
12030
+ 'Re-run the tool for a fresh image rather than saving an older one.',
12031
+ );
12032
+ }
12033
+ const image = lookup.image;
12034
+ return {
12035
+ rangeOffset,
12036
+ data: image.data,
12037
+ mediaType: image.mediaType,
12038
+ receipt: {
12039
+ source: 'tool-result',
12040
+ ref: image.ref,
12041
+ toolName: image.toolName,
12042
+ toolCallId: image.toolCallId,
12043
+ blockIndex: image.blockIndex,
12044
+ mediaType: image.mediaType,
12045
+ byteSize: image.byteSize,
12046
+ sha256: image.sha256,
12047
+ messagesBack: candidate.messagesBack,
12048
+ },
12049
+ };
12050
+ };
12051
+
12052
+ // Barrier: a save dispatched alongside other calls in one batch must
12053
+ // not inventory before their results exist — those are the newest
12054
+ // images. Waits (bounded) for every sibling to settle, or fails closed.
12055
+ await this.awaitSiblingToolCalls(agent, call);
12056
+
12057
+ const found: Resolved[] = [];
12058
+ let index = 0;
12059
+ let count = 1;
12060
+ /** Direct-ref mode: the one ref to find in the scannable context. */
12061
+ let wantedRef: string | null = null;
12062
+ if (input.ref !== undefined) {
12063
+ if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
12064
+ throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
12065
+ }
12066
+ if (input.index !== undefined || input.count !== undefined) {
12067
+ throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
12068
+ }
12069
+ wantedRef = input.ref;
12070
+ } else {
12071
+ index = input.index === undefined ? 0 : Number(input.index);
12072
+ if (!Number.isInteger(index) || index < 0) {
12073
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
12074
+ }
12075
+ count = input.count === undefined ? 1 : Number(input.count);
12076
+ const MAX_COUNT = 20;
12077
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
12078
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
12079
+ }
11729
12080
  }
11730
12081
  const lastWanted = index + count - 1;
12082
+ let seen = 0;
12083
+ const collecting = (): boolean => (wantedRef !== null ? found.length === 0 : found.length < count);
12084
+ // One walk serves both modes. By index: the requested range of the
12085
+ // ordered inventory. By ref: the slot whose placeholder cites that
12086
+ // ref — the ref is resolved THROUGH its occurrence in context, so
12087
+ // the same provenance cross-check applies (a ref that is not visible
12088
+ // in the scannable window — an undone branch, quoted text naming a
12089
+ // real ref — is not saveable by name either).
12090
+ const consider = (candidate: Candidate): void => {
12091
+ if (wantedRef !== null) {
12092
+ if (candidate.kind === 'tool' && candidate.ref === wantedRef && found.length === 0) {
12093
+ found.push(resolve(candidate, seen));
12094
+ }
12095
+ } else if (seen >= index && seen <= lastWanted) {
12096
+ const resolved = resolve(candidate, seen);
12097
+ resolved.rangeOffset = seen - index;
12098
+ found.push(resolved);
12099
+ }
12100
+ seen++;
12101
+ };
12102
+ const considerToolResultImages = (
12103
+ toolCallId: string,
12104
+ toolName: string,
12105
+ placeholders: ParsedImagePlaceholder[],
12106
+ messagesBack: number,
12107
+ ): void => {
12108
+ for (let p = placeholders.length - 1; p >= 0 && collecting(); p--) {
12109
+ const ph = placeholders[p]!;
12110
+ if (ph.kind === 'inline') {
12111
+ consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
12112
+ continue;
12113
+ }
12114
+ // Reference stub: the registry's testimony (when this process
12115
+ // still has the record) says whether it was an image; the
12116
+ // sniffed stub text is the fallback. Non-image references
12117
+ // are not slots.
12118
+ const testimony = referenceRegistry.get(ph.refId)?.testimony.mimeType?.toLowerCase() ?? ph.mediaType;
12119
+ if (!testimony || !testimony.startsWith('image/')) continue;
12120
+ consider({ kind: 'reference', refId: ph.refId, mediaType: testimony, toolCallId, toolName, messagesBack });
12121
+ }
12122
+ };
12123
+
12124
+ // Same-batch results (e.g. a snapshot dispatched alongside the save)
12125
+ // are not in the store yet but ARE the newest images. Classify them
12126
+ // with the very serializer the commit path will run — contradiction
12127
+ // blocks withheld, uri-form blocks as reference stubs, unknown shapes
12128
+ // as no slot at all — so the inventory means the same thing before
12129
+ // and after commit. Retention is idempotent per block, so the
12130
+ // placeholder written at commit time carries the same ref.
12131
+ const state = agent.state;
12132
+ if (state.status === 'waiting_for_tools') {
12133
+ for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
12134
+ const tc = state.completed[i]!;
12135
+ if (tc.result.isError) continue;
12136
+ const text = toolResultDataToHistoryString(tc.result.data, undefined, {
12137
+ imageRef: (blockIndex, image) => ledger.retain({
12138
+ toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
12139
+ }).ref,
12140
+ });
12141
+ considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
12142
+ }
12143
+ }
11731
12144
 
11732
12145
  // Walk the message store tail-first in bounded windows, re-inlining
11733
12146
  // blob media, until we've collected the requested range. Scanning is
@@ -11737,45 +12150,56 @@ export class AgentFramework {
11737
12150
  const WINDOW = 25;
11738
12151
  const cm = agent.getContextManager();
11739
12152
  const total = cm.getMessageCount();
11740
- let seen = 0;
11741
12153
  let scanned = 0;
11742
- const found: Array<{
11743
- rangeOffset: number;
11744
- data: string;
11745
- mediaType: string;
11746
- messagesBack: number;
11747
- }> = [];
11748
- for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
12154
+ for (let end = total; end > 0 && scanned < MAX_SCAN && collecting(); end -= WINDOW) {
11749
12155
  const start = Math.max(0, end - WINDOW);
11750
- const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
12156
+ // By ref, attachment bytes are never needed — skip blob resolution.
12157
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: wantedRef === null });
11751
12158
  scanned += end - start;
11752
- for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
12159
+ for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
11753
12160
  const content = messages[i]?.content;
11754
12161
  if (!Array.isArray(content)) continue;
11755
- for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
12162
+ const messagesBack = total - (start + i);
12163
+ for (let b = content.length - 1; b >= 0 && collecting(); b--) {
11756
12164
  const block = content[b] as {
11757
12165
  type?: string;
11758
12166
  source?: { type?: string; data?: string; mediaType?: string };
12167
+ toolUseId?: string;
12168
+ toolName?: string;
12169
+ content?: unknown;
11759
12170
  };
11760
- if (block?.type !== 'image') continue;
11761
- if (seen >= index && seen <= lastWanted) {
11762
- if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
11763
- throw new Error(
11764
- `save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`,
11765
- );
11766
- }
11767
- found.push({
11768
- rangeOffset: seen - index,
11769
- data: block.source.data,
11770
- mediaType: block.source.mediaType ?? 'image/png',
11771
- messagesBack: total - (start + i),
12171
+ if (block?.type === 'image') {
12172
+ const inline = block.source?.type === 'base64' && typeof block.source.data === 'string';
12173
+ consider({
12174
+ kind: 'attachment',
12175
+ data: inline ? block.source!.data! : null,
12176
+ mediaType: block.source?.mediaType ?? 'image/png',
12177
+ messagesBack,
11772
12178
  });
12179
+ } else if (block?.type === 'tool_result' && typeof block.content === 'string') {
12180
+ considerToolResultImages(
12181
+ block.toolUseId ?? 'unknown',
12182
+ block.toolName ?? 'unknown tool',
12183
+ parseImagePlaceholders(block.content),
12184
+ messagesBack,
12185
+ );
11773
12186
  }
11774
- seen++;
11775
12187
  }
11776
12188
  }
11777
12189
  }
11778
12190
  if (found.length === 0) {
12191
+ if (wantedRef !== null) {
12192
+ const lookup = ledger.lookup(wantedRef);
12193
+ throw new Error(
12194
+ lookup.status === 'unknown'
12195
+ ? `save_recent_image: ref ${wantedRef} cannot be saved — ${unavailable('unknown')}. Nothing written.`
12196
+ : `save_recent_image: ref ${wantedRef} (from ${describeTool(lookup.image)}) does not appear in your recent ` +
12197
+ `context (scanned ${Math.min(scanned, MAX_SCAN)} messages) — a ref is saved through its placeholder, ` +
12198
+ 'which must be visible to you' +
12199
+ (lookup.status === 'evicted' ? '; its bytes were also evicted from the bounded retention budget' : '') +
12200
+ '. Nothing written.',
12201
+ );
12202
+ }
11779
12203
  throw new Error(
11780
12204
  seen === 0
11781
12205
  ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
@@ -11814,7 +12238,7 @@ export class AgentFramework {
11814
12238
  savedFiles.push({
11815
12239
  ...(result.data as Record<string, unknown>),
11816
12240
  imageIndex: index + image.rangeOffset,
11817
- messagesBack: image.messagesBack,
12241
+ ...image.receipt,
11818
12242
  });
11819
12243
  }
11820
12244
  finish({