@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
@@ -25,6 +25,7 @@ import { ChannelRegistry } from './mcpl/channel-registry.js';
25
25
  import { ConversationRouter } from './mcpl/conversation-router.js';
26
26
  import { safeSlice } from './safe-slice.js';
27
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';
28
29
  import { randomUUID } from 'node:crypto';
29
30
  import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
30
31
  import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
@@ -673,6 +674,11 @@ export class AgentFramework {
673
674
  * framework state like the core runtime settings and restored at create
674
675
  * (antra + Sol, 08-06); reset clears it back to the residence default. */
675
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();
676
682
  /** Durable residence-configured inline cap from
677
683
  * FrameworkConfig.toolResultInlineMaxChars; null → house default. */
678
684
  toolResultInlineMaxCharsConfig = null;
@@ -885,8 +891,17 @@ export class AgentFramework {
885
891
  privilegedUsersPath: config.gate.privilegedUsersPath,
886
892
  emitTrace: (e) => framework.emitTrace(e),
887
893
  addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m, forAgent ? { forAgent } : undefined),
888
- requestInference: (agentName, reason, source) => {
889
- framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
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
+ });
890
905
  },
891
906
  getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
892
907
  });
@@ -1046,6 +1061,9 @@ export class AgentFramework {
1046
1061
  record.runner.dispose();
1047
1062
  }
1048
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();
1049
1067
  // A stopped host must never hang behind its own cooldown.
1050
1068
  this.cancelProviderAdmission();
1051
1069
  // Cancel all active streams
@@ -2248,6 +2266,7 @@ export class AgentFramework {
2248
2266
  this.eventGate?.onInferenceEnded(agent.name);
2249
2267
  this.agents.delete(agent.name);
2250
2268
  }
2269
+ this.toolImageLedgers.delete(agent.name);
2251
2270
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
2252
2271
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
2253
2272
  // entries behind for the life of the store/session.
@@ -2490,11 +2509,18 @@ export class AgentFramework {
2490
2509
  static SAVE_IMAGE_TOOL = {
2491
2510
  name: 'save_recent_image',
2492
2511
  description: 'Save one or more recent images from your own context to workspace files. ' +
2493
- '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 ' +
2494
2516
  'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
2495
2517
  'the range index..index+count-1 is saved with numeric suffixes ' +
2496
- '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
2497
- '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.',
2498
2524
  inputSchema: {
2499
2525
  type: 'object',
2500
2526
  properties: {
@@ -2510,6 +2536,11 @@ export class AgentFramework {
2510
2536
  type: 'number',
2511
2537
  description: 'How many images to save, starting at `index` and going further back. Default 1.',
2512
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
+ },
2513
2544
  },
2514
2545
  required: ['path'],
2515
2546
  },
@@ -3745,7 +3776,7 @@ export class AgentFramework {
3745
3776
  // history copy and the live wire copy below — the two must stay
3746
3777
  // byte-matched (the window stores what the membrane sends;
3747
3778
  // divergence breaks the compile prefix).
3748
- const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
3779
+ const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
3749
3780
  agent.getContextManager().addMessage('user', toolResultContent);
3750
3781
  // Flush any messages that were deferred while this turn was in
3751
3782
  // flight. Route to the PRIMARY agent — deferred messages are
@@ -4342,6 +4373,7 @@ export class AgentFramework {
4342
4373
  const channelId = this.conversationAgentHomes.get(agentName);
4343
4374
  const agent = this.agents.get(agentName);
4344
4375
  this.agents.delete(agentName);
4376
+ this.toolImageLedgers.delete(agentName);
4345
4377
  this.agentConfigs.delete(agentName);
4346
4378
  this.conversationAgentHomes.delete(agentName);
4347
4379
  this.evictTurnCheckpoints(agentName);
@@ -4772,6 +4804,19 @@ export class AgentFramework {
4772
4804
  addressedReq = r;
4773
4805
  }
4774
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
+ }
4775
4820
  await this.startAgentStream(agent, {
4776
4821
  ...trigger,
4777
4822
  channelId: channelReq?.channelId,
@@ -4780,7 +4825,9 @@ export class AgentFramework {
4780
4825
  // the channel for routing but names no author — the restart is its
4781
4826
  // own cause, and borrowing another request's author would be false
4782
4827
  // provenance.
4783
- counterparty: budgetRestart ? undefined : channelReq?.counterparty,
4828
+ counterparty: budgetRestart ? undefined : provReq?.counterparty,
4829
+ wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
4830
+ wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
4784
4831
  });
4785
4832
  }
4786
4833
  }
@@ -5218,6 +5265,23 @@ export class AgentFramework {
5218
5265
  this.releasePrimaryProviderGate(agent.name);
5219
5266
  return;
5220
5267
  }
5268
+ // The scheduler's turn-alive test passed BEFORE the park; the wait
5269
+ // is the one gap in which another turn-alive holder can take the
5270
+ // agent (puppetToolCall's reservation, #145). Never replace its
5271
+ // token — the re-entry would set ours over it, and a short turn
5272
+ // here ends and flushes before the puppeted tool returns, leaving
5273
+ // its pair queued behind a flush that already happened. Give
5274
+ // admission back and hand the wake to the scheduler, which keeps
5275
+ // it queued while the agent is turn-alive and starts it normally
5276
+ // once the holder releases.
5277
+ if (this.activeTurnTokens.has(agent.name)) {
5278
+ this.releasePrimaryProviderGate(agent.name);
5279
+ this.pendingRequests.push(trigger ?? {
5280
+ agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
5281
+ });
5282
+ console.error(`[provider-admission] ${agent.name}: turn-alive while parked — wake requeued, not started`);
5283
+ return;
5284
+ }
5221
5285
  await this.startAgentStream(agent, trigger, attempt, true);
5222
5286
  }).catch((error) => {
5223
5287
  this.releasePrimaryProviderGate(agent.name);
@@ -5918,7 +5982,7 @@ export class AgentFramework {
5918
5982
  // one door a giant blob still walks through).
5919
5983
  const readyState = agent.state;
5920
5984
  if (readyState.status === 'ready') {
5921
- const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
5985
+ const { blocks: toolResultContent } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
5922
5986
  agent.getContextManager().addMessage('user', toolResultContent);
5923
5987
  }
5924
5988
  }
@@ -6724,8 +6788,11 @@ export class AgentFramework {
6724
6788
  * affordance to a model that cannot find it (older models especially).
6725
6789
  *
6726
6790
  * Semantics:
6727
- * - Refused unless the agent is idle: puppeting mid-turn would corrupt
6728
- * the turn state machine and the live stream's wire ordering.
6791
+ * - Refused unless the agent is idle with no turn alive: puppeting
6792
+ * mid-turn would corrupt the turn state machine and the live stream's
6793
+ * wire ordering. For its own duration the puppet HOLDS the turn-alive
6794
+ * marker (wakes requeue, cross-turn writers defer), so a wake landing
6795
+ * during the tool's execution cannot start a turn underneath the pair.
6729
6796
  * - Refused for tools outside the agent's own surface (canUseTool): the
6730
6797
  * stored pair must be an act the agent could genuinely have taken.
6731
6798
  * - The call executes FOR REAL through the shared dispatch (MCPL,
@@ -6746,8 +6813,12 @@ export class AgentFramework {
6746
6813
  const agent = this.agents.get(agentName);
6747
6814
  if (!agent)
6748
6815
  throw new Error(`Unknown agent: ${agentName}`);
6749
- if (agent.state.status !== 'idle') {
6750
- throw new Error(`puppet refused: agent ${agentName} is ${agent.state.status} (requires idle — ` +
6816
+ // Idle AND no turn alive: status reads 'idle' from dequeue until the
6817
+ // stream registers, and again while a turn's teardown is pending
6818
+ // (the scheduler's own busy test, 'idle+turn-alive').
6819
+ if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) {
6820
+ const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
6821
+ throw new Error(`puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
6751
6822
  `injecting a turn under an active stream corrupts wire ordering)`);
6752
6823
  }
6753
6824
  const onSurface = this.getToolsForAgent(agentName)
@@ -6763,36 +6834,85 @@ export class AgentFramework {
6763
6834
  for (let i = 0; i < 22; i++)
6764
6835
  suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
6765
6836
  const toolUseId = `toolu_01${suffix}`;
6766
- const started = Date.now();
6767
- const result = await this.executeToolCall({
6768
- id: toolUseId,
6769
- name: toolName,
6770
- input,
6771
- callerAgentName: agentName,
6772
- });
6773
- const durationMs = Date.now() - started;
6774
- // Store the pair through the same shapes the ordinary path uses. Build
6775
- // the result blocks BEFORE storing the tool_use: the spill path awaits,
6776
- // and a message arriving during that await must land before the pair,
6777
- // never between tool_use and its tool_result. The two addMessage calls
6778
- // below are synchronous and adjacent — nothing can interleave.
6779
- const { blocks } = await this.buildStoredToolResultContent([{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
6780
- const cm = agent.getContextManager();
6781
- cm.addMessage(agentName, [
6782
- { type: 'tool_use', id: toolUseId, name: toolName, input },
6783
- ]);
6784
- cm.addMessage('user', blocks);
6785
- this.emitTrace({
6786
- type: 'puppet:tool-call',
6787
- agentName,
6788
- toolName,
6789
- toolUseId,
6790
- isError: !!result.isError,
6791
- durationMs,
6792
- });
6793
- console.log(`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
6794
- `in ${durationMs}ms (${toolUseId})`);
6795
- return { toolUseId, result };
6837
+ // Reserve the agent against turn start for the WHOLE operation, before
6838
+ // the first await (#145). The idle check above is a point in time; the
6839
+ // tool execution and the result build both await, and a wake arriving
6840
+ // in either gap used to start a real turn — then the pair below wrote
6841
+ // straight through the context manager into a turn that never saw it,
6842
+ // the exact wire-order corruption this method exists to avoid. Holding
6843
+ // a turn token is the same invariant the scheduler respects (turn-alive
6844
+ // requeues wakes) and the addMessage guard reads (cross-turn writers
6845
+ // defer), so for the duration this behaves like a turn with no stream.
6846
+ // Token-matched release in finally, same leak-proofing as
6847
+ // startAgentStream: a token nobody clears wedges the agent.
6848
+ const turnToken = this.nextTurnToken++;
6849
+ this.activeTurnTokens.set(agentName, turnToken);
6850
+ let ownedToEnd = false;
6851
+ try {
6852
+ const started = Date.now();
6853
+ const result = await this.executeToolCall({
6854
+ id: toolUseId,
6855
+ name: toolName,
6856
+ input,
6857
+ callerAgentName: agentName,
6858
+ });
6859
+ const durationMs = Date.now() - started;
6860
+ // Store the pair through the same shapes the ordinary path uses. Build
6861
+ // the result blocks BEFORE storing the tool_use: the spill path awaits.
6862
+ // A message arriving during either await is deferred by the turn
6863
+ // token (as during a real tool call) and lands AFTER the pair, never
6864
+ // between tool_use and its tool_result. The two addMessage calls
6865
+ // below are synchronous and adjacent — nothing can interleave.
6866
+ const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
6867
+ const toolUse = [
6868
+ { type: 'tool_use', id: toolUseId, name: toolName, input },
6869
+ ];
6870
+ // Defensive: no path replaces a live token any more (the provider-
6871
+ // admission re-entry re-tests turn-alive and requeues instead), but
6872
+ // if one ever does, the side effect has happened and the pair must
6873
+ // still be stored — not under that turn: queue it as a unit for the
6874
+ // next flush (drainDeferredFor keeps order; the tool_result entry is
6875
+ // never split from its tool_use because both ride the same drain).
6876
+ // The finally below flushes it itself if that turn is already over.
6877
+ ownedToEnd = this.activeTurnTokens.get(agentName) === turnToken;
6878
+ if (ownedToEnd) {
6879
+ const cm = agent.getContextManager();
6880
+ cm.addMessage(agentName, toolUse);
6881
+ cm.addMessage('user', blocks);
6882
+ }
6883
+ else {
6884
+ this.deferredMessages.push({ participant: agentName, content: toolUse, forAgent: agentName }, { participant: 'user', content: blocks, forAgent: agentName });
6885
+ }
6886
+ this.emitTrace({
6887
+ type: 'puppet:tool-call',
6888
+ agentName,
6889
+ toolName,
6890
+ toolUseId,
6891
+ isError: !!result.isError,
6892
+ durationMs,
6893
+ ...(ownedToEnd ? {} : { deferred: true }),
6894
+ });
6895
+ console.log(`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
6896
+ `in ${durationMs}ms (${toolUseId})${ownedToEnd ? '' : ' — deferred behind a live turn'}`);
6897
+ return { toolUseId, result };
6898
+ }
6899
+ finally {
6900
+ if (this.activeTurnTokens.get(agentName) === turnToken)
6901
+ this.activeTurnTokens.delete(agentName);
6902
+ // Messages deferred while we held the token land now, after the pair
6903
+ // — the same end-of-turn flush driveStream performs, under the same
6904
+ // tool-cycle guard. (No wake is requested: the agent sees the pair,
6905
+ // and anything that arrived meanwhile, on its next turn.) Keyed on
6906
+ // "no turn alive", not on token ownership: if a turn did replace us
6907
+ // and has already ended, its flush ran before our pair was queued —
6908
+ // nobody else will flush it, so we do.
6909
+ if (!this.activeTurnTokens.has(agentName)
6910
+ && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
6911
+ for (const msg of this.drainDeferredFor(agentName)) {
6912
+ this.addMessage(msg.participant, msg.content, msg.metadata, msg.forAgent ? { forAgent: msg.forAgent } : undefined);
6913
+ }
6914
+ }
6915
+ }
6796
6916
  }
6797
6917
  async executeToolCallFrom(call, origin) {
6798
6918
  // Client-side programmatic tool calling for promise-based callers
@@ -7224,13 +7344,25 @@ export class AgentFramework {
7224
7344
  * #89). Returns the stored blocks plus the per-call spill outcomes so the
7225
7345
  * live wire copy can reuse the identical strings.
7226
7346
  */
7227
- async buildStoredToolResultContent(toolResults, maxChars) {
7347
+ async buildStoredToolResultContent(agentName, toolResults, maxChars) {
7228
7348
  const spilled = new Map();
7229
7349
  const dateLabel = new Date().toISOString().slice(0, 10);
7350
+ const ledger = this.toolImageLedgerFor(agentName);
7230
7351
  for (const tc of toolResults) {
7352
+ // Retain every image the result carries BEFORE the placeholder is
7353
+ // written (issue #104): the placeholder embeds the ref, so the image
7354
+ // stays reachable by provenance after the bytes leave the wire.
7231
7355
  const raw = tc.result.isError
7232
7356
  ? tc.result.error ?? 'Unknown error'
7233
- : toolResultDataToHistoryString(tc.result.data, undefined);
7357
+ : toolResultDataToHistoryString(tc.result.data, undefined, {
7358
+ imageRef: (blockIndex, image) => ledger.retain({
7359
+ toolCallId: tc.id,
7360
+ toolName: tc.name,
7361
+ blockIndex,
7362
+ data: image.data,
7363
+ mediaType: image.mimeType,
7364
+ }).ref,
7365
+ });
7234
7366
  spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
7235
7367
  }
7236
7368
  const blocks = toolResults.map(tc => ({
@@ -7247,6 +7379,13 @@ export class AgentFramework {
7247
7379
  async spillOrTruncate(content, cap, label) {
7248
7380
  if (!cap || content.length <= cap)
7249
7381
  return { text: content, filePath: null };
7382
+ // The stored text is the only place save_recent_image discovers tool
7383
+ // images, and the live wire delivers every image regardless of text
7384
+ // length — so an image slot dropped by the cut is an image the resident
7385
+ // saw that the index would silently slide past (issue #104). Keep the
7386
+ // slots at or past the cut, re-appended after the notice.
7387
+ const { head, tail } = splitPreservingImageSlots(content, cap);
7388
+ const kept = formatPreservedImageSlots(tail);
7250
7389
  const workspace = this.getWorkspaceModule();
7251
7390
  const mountName = workspace ? this.firstWritableMountName(workspace) : null;
7252
7391
  if (workspace && mountName) {
@@ -7256,11 +7395,18 @@ export class AgentFramework {
7256
7395
  try {
7257
7396
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
7258
7397
  if (result.success) {
7398
+ // JSON serialization can expand each read-back code unit to six
7399
+ // chars (\\u0000). Leave room for the page's metadata and a possible
7400
+ // extra code unit at a surrogate-pair boundary.
7401
+ const pageOverhead = JSON.stringify(path).length + 256;
7402
+ const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
7259
7403
  return {
7260
- text: safeSlice(content, 0, cap)
7261
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
7262
- + 'Read/grep it with your file tools, or raise the inline cap via '
7263
- + 'agent_settings update tool_result_inline_max_chars.]',
7404
+ text: head
7405
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
7406
+ + `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
7407
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
7408
+ + 'agent_settings update tool_result_inline_max_chars.]'
7409
+ + kept,
7264
7410
  filePath: path,
7265
7411
  };
7266
7412
  }
@@ -7282,15 +7428,17 @@ export class AgentFramework {
7282
7428
  });
7283
7429
  console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
7284
7430
  return {
7285
- text: safeSlice(content, 0, cap)
7286
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
7287
- + ` (${failure}); content over the cap was not retained]`,
7431
+ text: head
7432
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
7433
+ + ` (${failure}); content over the cap was not retained]`
7434
+ + kept,
7288
7435
  filePath: null,
7289
7436
  };
7290
7437
  }
7291
7438
  return {
7292
- text: safeSlice(content, 0, cap)
7293
- + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
7439
+ text: head
7440
+ + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
7441
+ + kept,
7294
7442
  filePath: null,
7295
7443
  };
7296
7444
  }
@@ -7493,6 +7641,14 @@ export class AgentFramework {
7493
7641
  }
7494
7642
  return hasImage ? blocks : null;
7495
7643
  }
7644
+ toolImageLedgerFor(agentName) {
7645
+ let ledger = this.toolImageLedgers.get(agentName);
7646
+ if (!ledger) {
7647
+ ledger = new ToolImageLedger();
7648
+ this.toolImageLedgers.set(agentName, ledger);
7649
+ }
7650
+ return ledger;
7651
+ }
7496
7652
  /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
7497
7653
  strategyDerivedToolResultChars(agent) {
7498
7654
  const strategy = agent.getContextManager().getStrategy();
@@ -7574,12 +7730,20 @@ export class AgentFramework {
7574
7730
  const entryToStore = { ...entry };
7575
7731
  // Blob threshold: 10KB - typical context-heavy requests exceed this
7576
7732
  const BLOB_THRESHOLD = 10000;
7733
+ // Both branches persist the JSON view, never the live object: a compiled
7734
+ // request carries non-JSON members (the kv-unified `onCacheWireReceipt`
7735
+ // hook is a function) and Chronicle's JSON bridge rejects those with
7736
+ // "JS functions cannot be represented as a serde_json::Value" — which,
7737
+ // thrown from the failure path, masked the failure being logged.
7577
7738
  if (entry.request && typeof entry.request === 'object') {
7578
7739
  const requestJson = JSON.stringify(entry.request);
7579
7740
  if (requestJson.length > BLOB_THRESHOLD) {
7580
7741
  const blobId = this.store.storeBlob(Buffer.from(requestJson), 'application/json');
7581
7742
  entryToStore.request = { blobId };
7582
7743
  }
7744
+ else {
7745
+ entryToStore.request = JSON.parse(requestJson);
7746
+ }
7583
7747
  }
7584
7748
  if (entry.response && typeof entry.response === 'object') {
7585
7749
  const responseJson = JSON.stringify(entry.response);
@@ -7587,6 +7751,9 @@ export class AgentFramework {
7587
7751
  const blobId = this.store.storeBlob(Buffer.from(responseJson), 'application/json');
7588
7752
  entryToStore.response = { blobId };
7589
7753
  }
7754
+ else {
7755
+ entryToStore.response = JSON.parse(responseJson);
7756
+ }
7590
7757
  }
7591
7758
  // Append one record per inference. A read-modify-write via setStateJson
7592
7759
  // would emit a whole-array Set — record size then grows with accumulated
@@ -10095,10 +10262,53 @@ export class AgentFramework {
10095
10262
  })();
10096
10263
  }
10097
10264
  /**
10098
- * Handle the synthesized `save_recent_image` tool: locate the requested
10099
- * image blocks in the calling agent's context (blobs re-inlined, counted
10100
- * back from the most recent) and write their bytes to a workspace mount
10101
- * via WorkspaceModule.writeBinary.
10265
+ * `save_recent_image` barrier. Module tool calls in a batch are only
10266
+ * ENQUEUED by dispatch, while the save runs at once — so a [snap, save]
10267
+ * round would inventory before the snapshot exists and silently save the
10268
+ * newest OLDER image (issue #104, same-batch representation). Wait for
10269
+ * every sibling in the round to settle; if one never does, fail closed
10270
+ * rather than trust an index the batch may still change. Sibling saves
10271
+ * produce no images and are not waited on (two saves in one batch would
10272
+ * otherwise deadlock into a double timeout). Script-inner calls
10273
+ * (`pytc-…`) run sequentially inside their script host and skip the
10274
+ * barrier — the host call itself is the only pending sibling they'd see.
10275
+ */
10276
+ async awaitSiblingToolCalls(agent, call) {
10277
+ if (call.id.startsWith('pytc-'))
10278
+ return;
10279
+ const BARRIER_MS = 120_000;
10280
+ const POLL_MS = 20;
10281
+ const startedAt = Date.now();
10282
+ for (;;) {
10283
+ const state = agent.state;
10284
+ // Idle (puppet path) or the round was torn down: nothing to wait on.
10285
+ if (state.status !== 'waiting_for_tools')
10286
+ return;
10287
+ const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
10288
+ if (open.length === 0)
10289
+ return;
10290
+ if (Date.now() - startedAt > BARRIER_MS) {
10291
+ throw new Error(`save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
10292
+ `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
10293
+ 'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
10294
+ 'Save again once they have finished.');
10295
+ }
10296
+ await new Promise((r) => setTimeout(r, POLL_MS));
10297
+ }
10298
+ }
10299
+ /**
10300
+ * Handle the synthesized `save_recent_image` tool (issue #104): build ONE
10301
+ * ordered inventory of every image the calling agent has seen — attachment
10302
+ * image blocks (blobs re-inlined) AND tool-result images (their history
10303
+ * placeholders carry a ref, resolved through the agent's ToolImageLedger)
10304
+ * — counted back from the most recent, then write the requested bytes to
10305
+ * a workspace mount via WorkspaceModule.writeBinary.
10306
+ *
10307
+ * Fail-closed: when the image at a requested position cannot be produced
10308
+ * (bytes evicted, minted by an earlier process, or a placeholder written
10309
+ * before retention existed) the call fails AT THAT INDEX and writes
10310
+ * nothing. It never slides to an older image — a filename must not
10311
+ * authenticate bytes from another surface.
10102
10312
  */
10103
10313
  dispatchSaveImageToolCall(agentName, call) {
10104
10314
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
@@ -10125,16 +10335,166 @@ export class AgentFramework {
10125
10335
  if (typeof input.path !== 'string' || input.path.length === 0) {
10126
10336
  throw new Error('save_recent_image: `path` (mount-prefixed) is required');
10127
10337
  }
10128
- const index = input.index === undefined ? 0 : Number(input.index);
10129
- if (!Number.isInteger(index) || index < 0) {
10130
- throw new Error('save_recent_image: `index` must be a non-negative integer');
10338
+ const ledger = this.toolImageLedgerFor(agentName);
10339
+ const describeTool = (c) => `${c.toolName} (call ${c.toolCallId})`;
10340
+ const unavailable = (status) => status === 'evicted'
10341
+ ? 'its bytes were evicted from the bounded retention budget'
10342
+ : 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
10343
+ 'very old refs age out; a mistyped ref looks the same)';
10344
+ const resolve = (candidate, position) => {
10345
+ const rangeOffset = 0; // filled by the caller
10346
+ if (candidate.kind === 'attachment') {
10347
+ if (candidate.data === null) {
10348
+ throw new Error(`save_recent_image: image at index ${position} is not stored inline (base64) — cannot save it. Nothing written.`);
10349
+ }
10350
+ const bytes = Buffer.from(candidate.data, 'base64');
10351
+ return {
10352
+ rangeOffset,
10353
+ data: candidate.data,
10354
+ mediaType: candidate.mediaType,
10355
+ receipt: {
10356
+ source: 'attachment',
10357
+ mediaType: candidate.mediaType,
10358
+ byteSize: bytes.byteLength,
10359
+ sha256: sha256Hex(bytes),
10360
+ messagesBack: candidate.messagesBack,
10361
+ },
10362
+ };
10363
+ }
10364
+ if (candidate.kind === 'reference') {
10365
+ throw new Error(`save_recent_image: image at index ${position} is a reference (${candidate.refId}, ${candidate.mediaType}, ` +
10366
+ `from ${describeTool(candidate)}) — its bytes are not held here. Nothing written. ` +
10367
+ 'Fetch it with fetch_reference and save the materialized file instead.');
10368
+ }
10369
+ if (candidate.ref === null) {
10370
+ throw new Error(`save_recent_image: image at index ${position} is a tool-result image from ${describeTool(candidate)} ` +
10371
+ 'recorded without a retention ref (written before tool-image retention existed, or inside a ' +
10372
+ 'script run, where tool images are not retained) — its bytes are not available. Nothing written. ' +
10373
+ 'Re-run the tool for a fresh image rather than saving an older one.');
10374
+ }
10375
+ const lookup = ledger.lookup(candidate.ref);
10376
+ // The placeholder came out of stored TEXT, which a tool result can
10377
+ // quote from anywhere (a fetch_history of a channel where someone
10378
+ // pasted their save receipt). A genuine placeholder is written by
10379
+ // its own call's serialization, so the ref's provenance names that
10380
+ // call; a quoted or forged one cannot. Refuse the mismatch.
10381
+ if (lookup.status !== 'unknown' && lookup.image.toolCallId !== candidate.toolCallId) {
10382
+ throw new Error(`save_recent_image: image at index ${position} cites ${candidate.ref}, but that image belongs to ` +
10383
+ `${describeTool(lookup.image)}, not to ${describeTool(candidate)} where the placeholder appears — ` +
10384
+ 'the placeholder is quoted or forged text, not this result\'s own image. Nothing written.');
10385
+ }
10386
+ if (lookup.status !== 'retained') {
10387
+ throw new Error(`save_recent_image: image at index ${position} (${candidate.ref}, from ${describeTool(candidate)}) ` +
10388
+ `is no longer retained — ${unavailable(lookup.status)}. Nothing written. ` +
10389
+ 'Re-run the tool for a fresh image rather than saving an older one.');
10390
+ }
10391
+ const image = lookup.image;
10392
+ return {
10393
+ rangeOffset,
10394
+ data: image.data,
10395
+ mediaType: image.mediaType,
10396
+ receipt: {
10397
+ source: 'tool-result',
10398
+ ref: image.ref,
10399
+ toolName: image.toolName,
10400
+ toolCallId: image.toolCallId,
10401
+ blockIndex: image.blockIndex,
10402
+ mediaType: image.mediaType,
10403
+ byteSize: image.byteSize,
10404
+ sha256: image.sha256,
10405
+ messagesBack: candidate.messagesBack,
10406
+ },
10407
+ };
10408
+ };
10409
+ // Barrier: a save dispatched alongside other calls in one batch must
10410
+ // not inventory before their results exist — those are the newest
10411
+ // images. Waits (bounded) for every sibling to settle, or fails closed.
10412
+ await this.awaitSiblingToolCalls(agent, call);
10413
+ const found = [];
10414
+ let index = 0;
10415
+ let count = 1;
10416
+ /** Direct-ref mode: the one ref to find in the scannable context. */
10417
+ let wantedRef = null;
10418
+ if (input.ref !== undefined) {
10419
+ if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
10420
+ throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
10421
+ }
10422
+ if (input.index !== undefined || input.count !== undefined) {
10423
+ throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
10424
+ }
10425
+ wantedRef = input.ref;
10131
10426
  }
10132
- const count = input.count === undefined ? 1 : Number(input.count);
10133
- const MAX_COUNT = 20;
10134
- if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
10135
- throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
10427
+ else {
10428
+ index = input.index === undefined ? 0 : Number(input.index);
10429
+ if (!Number.isInteger(index) || index < 0) {
10430
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
10431
+ }
10432
+ count = input.count === undefined ? 1 : Number(input.count);
10433
+ const MAX_COUNT = 20;
10434
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
10435
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
10436
+ }
10136
10437
  }
10137
10438
  const lastWanted = index + count - 1;
10439
+ let seen = 0;
10440
+ const collecting = () => (wantedRef !== null ? found.length === 0 : found.length < count);
10441
+ // One walk serves both modes. By index: the requested range of the
10442
+ // ordered inventory. By ref: the slot whose placeholder cites that
10443
+ // ref — the ref is resolved THROUGH its occurrence in context, so
10444
+ // the same provenance cross-check applies (a ref that is not visible
10445
+ // in the scannable window — an undone branch, quoted text naming a
10446
+ // real ref — is not saveable by name either).
10447
+ const consider = (candidate) => {
10448
+ if (wantedRef !== null) {
10449
+ if (candidate.kind === 'tool' && candidate.ref === wantedRef && found.length === 0) {
10450
+ found.push(resolve(candidate, seen));
10451
+ }
10452
+ }
10453
+ else if (seen >= index && seen <= lastWanted) {
10454
+ const resolved = resolve(candidate, seen);
10455
+ resolved.rangeOffset = seen - index;
10456
+ found.push(resolved);
10457
+ }
10458
+ seen++;
10459
+ };
10460
+ const considerToolResultImages = (toolCallId, toolName, placeholders, messagesBack) => {
10461
+ for (let p = placeholders.length - 1; p >= 0 && collecting(); p--) {
10462
+ const ph = placeholders[p];
10463
+ if (ph.kind === 'inline') {
10464
+ consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
10465
+ continue;
10466
+ }
10467
+ // Reference stub: the registry's testimony (when this process
10468
+ // still has the record) says whether it was an image; the
10469
+ // sniffed stub text is the fallback. Non-image references
10470
+ // are not slots.
10471
+ const testimony = referenceRegistry.get(ph.refId)?.testimony.mimeType?.toLowerCase() ?? ph.mediaType;
10472
+ if (!testimony || !testimony.startsWith('image/'))
10473
+ continue;
10474
+ consider({ kind: 'reference', refId: ph.refId, mediaType: testimony, toolCallId, toolName, messagesBack });
10475
+ }
10476
+ };
10477
+ // Same-batch results (e.g. a snapshot dispatched alongside the save)
10478
+ // are not in the store yet but ARE the newest images. Classify them
10479
+ // with the very serializer the commit path will run — contradiction
10480
+ // blocks withheld, uri-form blocks as reference stubs, unknown shapes
10481
+ // as no slot at all — so the inventory means the same thing before
10482
+ // and after commit. Retention is idempotent per block, so the
10483
+ // placeholder written at commit time carries the same ref.
10484
+ const state = agent.state;
10485
+ if (state.status === 'waiting_for_tools') {
10486
+ for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
10487
+ const tc = state.completed[i];
10488
+ if (tc.result.isError)
10489
+ continue;
10490
+ const text = toolResultDataToHistoryString(tc.result.data, undefined, {
10491
+ imageRef: (blockIndex, image) => ledger.retain({
10492
+ toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
10493
+ }).ref,
10494
+ });
10495
+ considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
10496
+ }
10497
+ }
10138
10498
  // Walk the message store tail-first in bounded windows, re-inlining
10139
10499
  // blob media, until we've collected the requested range. Scanning is
10140
10500
  // capped so a pathological index can't drag the whole store through
@@ -10143,37 +10503,45 @@ export class AgentFramework {
10143
10503
  const WINDOW = 25;
10144
10504
  const cm = agent.getContextManager();
10145
10505
  const total = cm.getMessageCount();
10146
- let seen = 0;
10147
10506
  let scanned = 0;
10148
- const found = [];
10149
- for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
10507
+ for (let end = total; end > 0 && scanned < MAX_SCAN && collecting(); end -= WINDOW) {
10150
10508
  const start = Math.max(0, end - WINDOW);
10151
- const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
10509
+ // By ref, attachment bytes are never needed — skip blob resolution.
10510
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: wantedRef === null });
10152
10511
  scanned += end - start;
10153
- for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
10512
+ for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
10154
10513
  const content = messages[i]?.content;
10155
10514
  if (!Array.isArray(content))
10156
10515
  continue;
10157
- for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
10516
+ const messagesBack = total - (start + i);
10517
+ for (let b = content.length - 1; b >= 0 && collecting(); b--) {
10158
10518
  const block = content[b];
10159
- if (block?.type !== 'image')
10160
- continue;
10161
- if (seen >= index && seen <= lastWanted) {
10162
- if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
10163
- throw new Error(`save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`);
10164
- }
10165
- found.push({
10166
- rangeOffset: seen - index,
10167
- data: block.source.data,
10168
- mediaType: block.source.mediaType ?? 'image/png',
10169
- messagesBack: total - (start + i),
10519
+ if (block?.type === 'image') {
10520
+ const inline = block.source?.type === 'base64' && typeof block.source.data === 'string';
10521
+ consider({
10522
+ kind: 'attachment',
10523
+ data: inline ? block.source.data : null,
10524
+ mediaType: block.source?.mediaType ?? 'image/png',
10525
+ messagesBack,
10170
10526
  });
10171
10527
  }
10172
- seen++;
10528
+ else if (block?.type === 'tool_result' && typeof block.content === 'string') {
10529
+ considerToolResultImages(block.toolUseId ?? 'unknown', block.toolName ?? 'unknown tool', parseImagePlaceholders(block.content), messagesBack);
10530
+ }
10173
10531
  }
10174
10532
  }
10175
10533
  }
10176
10534
  if (found.length === 0) {
10535
+ if (wantedRef !== null) {
10536
+ const lookup = ledger.lookup(wantedRef);
10537
+ throw new Error(lookup.status === 'unknown'
10538
+ ? `save_recent_image: ref ${wantedRef} cannot be saved — ${unavailable('unknown')}. Nothing written.`
10539
+ : `save_recent_image: ref ${wantedRef} (from ${describeTool(lookup.image)}) does not appear in your recent ` +
10540
+ `context (scanned ${Math.min(scanned, MAX_SCAN)} messages) — a ref is saved through its placeholder, ` +
10541
+ 'which must be visible to you' +
10542
+ (lookup.status === 'evicted' ? '; its bytes were also evicted from the bounded retention budget' : '') +
10543
+ '. Nothing written.');
10544
+ }
10177
10545
  throw new Error(seen === 0
10178
10546
  ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
10179
10547
  : `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`);
@@ -10209,7 +10577,7 @@ export class AgentFramework {
10209
10577
  savedFiles.push({
10210
10578
  ...result.data,
10211
10579
  imageIndex: index + image.rangeOffset,
10212
- messagesBack: image.messagesBack,
10580
+ ...image.receipt,
10213
10581
  });
10214
10582
  }
10215
10583
  finish({