@animalabs/agent-framework 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/src/framework.d.ts +31 -4
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +327 -49
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +55 -1
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +41 -1
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/index.d.ts +1 -1
  10. package/dist/src/gate/index.d.ts.map +1 -1
  11. package/dist/src/gate/index.js +1 -1
  12. package/dist/src/gate/index.js.map +1 -1
  13. package/dist/src/index.d.ts +1 -1
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js.map +1 -1
  16. package/dist/src/tool-image-ledger.d.ts +175 -0
  17. package/dist/src/tool-image-ledger.d.ts.map +1 -0
  18. package/dist/src/tool-image-ledger.js +257 -0
  19. package/dist/src/tool-image-ledger.js.map +1 -0
  20. package/dist/src/tool-result-history.d.ts +15 -2
  21. package/dist/src/tool-result-history.d.ts.map +1 -1
  22. package/dist/src/tool-result-history.js +16 -9
  23. package/dist/src/tool-result-history.js.map +1 -1
  24. package/dist/src/types/agent.d.ts +10 -0
  25. package/dist/src/types/agent.d.ts.map +1 -1
  26. package/dist/test/event-gate.test.js +84 -1
  27. package/dist/test/event-gate.test.js.map +1 -1
  28. package/dist/test/puppet-tool-call.test.js +1 -0
  29. package/dist/test/puppet-tool-call.test.js.map +1 -1
  30. package/dist/test/save-recent-image.test.d.ts +26 -0
  31. package/dist/test/save-recent-image.test.d.ts.map +1 -0
  32. package/dist/test/save-recent-image.test.js +707 -0
  33. package/dist/test/save-recent-image.test.js.map +1 -0
  34. package/dist/test/turn-trigger-provenance.test.js +17 -0
  35. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  36. package/dist/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +1 -1
  38. package/src/framework.ts +389 -54
  39. package/src/gate/event-gate.ts +71 -3
  40. package/src/gate/index.ts +1 -1
  41. package/src/index.ts +1 -1
  42. package/src/tool-image-ledger.ts +334 -0
  43. package/src/tool-result-history.ts +30 -9
  44. package/src/types/agent.ts +10 -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
  }
@@ -5918,7 +5965,7 @@ export class AgentFramework {
5918
5965
  // one door a giant blob still walks through).
5919
5966
  const readyState = agent.state;
5920
5967
  if (readyState.status === 'ready') {
5921
- const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
5968
+ const { blocks: toolResultContent } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
5922
5969
  agent.getContextManager().addMessage('user', toolResultContent);
5923
5970
  }
5924
5971
  }
@@ -6776,7 +6823,7 @@ export class AgentFramework {
6776
6823
  // and a message arriving during that await must land before the pair,
6777
6824
  // never between tool_use and its tool_result. The two addMessage calls
6778
6825
  // 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);
6826
+ const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
6780
6827
  const cm = agent.getContextManager();
6781
6828
  cm.addMessage(agentName, [
6782
6829
  { type: 'tool_use', id: toolUseId, name: toolName, input },
@@ -7224,13 +7271,25 @@ export class AgentFramework {
7224
7271
  * #89). Returns the stored blocks plus the per-call spill outcomes so the
7225
7272
  * live wire copy can reuse the identical strings.
7226
7273
  */
7227
- async buildStoredToolResultContent(toolResults, maxChars) {
7274
+ async buildStoredToolResultContent(agentName, toolResults, maxChars) {
7228
7275
  const spilled = new Map();
7229
7276
  const dateLabel = new Date().toISOString().slice(0, 10);
7277
+ const ledger = this.toolImageLedgerFor(agentName);
7230
7278
  for (const tc of toolResults) {
7279
+ // Retain every image the result carries BEFORE the placeholder is
7280
+ // written (issue #104): the placeholder embeds the ref, so the image
7281
+ // stays reachable by provenance after the bytes leave the wire.
7231
7282
  const raw = tc.result.isError
7232
7283
  ? tc.result.error ?? 'Unknown error'
7233
- : toolResultDataToHistoryString(tc.result.data, undefined);
7284
+ : toolResultDataToHistoryString(tc.result.data, undefined, {
7285
+ imageRef: (blockIndex, image) => ledger.retain({
7286
+ toolCallId: tc.id,
7287
+ toolName: tc.name,
7288
+ blockIndex,
7289
+ data: image.data,
7290
+ mediaType: image.mimeType,
7291
+ }).ref,
7292
+ });
7234
7293
  spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
7235
7294
  }
7236
7295
  const blocks = toolResults.map(tc => ({
@@ -7247,6 +7306,13 @@ export class AgentFramework {
7247
7306
  async spillOrTruncate(content, cap, label) {
7248
7307
  if (!cap || content.length <= cap)
7249
7308
  return { text: content, filePath: null };
7309
+ // The stored text is the only place save_recent_image discovers tool
7310
+ // images, and the live wire delivers every image regardless of text
7311
+ // length — so an image slot dropped by the cut is an image the resident
7312
+ // saw that the index would silently slide past (issue #104). Keep the
7313
+ // slots at or past the cut, re-appended after the notice.
7314
+ const { head, tail } = splitPreservingImageSlots(content, cap);
7315
+ const kept = formatPreservedImageSlots(tail);
7250
7316
  const workspace = this.getWorkspaceModule();
7251
7317
  const mountName = workspace ? this.firstWritableMountName(workspace) : null;
7252
7318
  if (workspace && mountName) {
@@ -7257,10 +7323,11 @@ export class AgentFramework {
7257
7323
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
7258
7324
  if (result.success) {
7259
7325
  return {
7260
- text: safeSlice(content, 0, cap)
7261
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
7326
+ text: head
7327
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}. `
7262
7328
  + 'Read/grep it with your file tools, or raise the inline cap via '
7263
- + 'agent_settings update tool_result_inline_max_chars.]',
7329
+ + 'agent_settings update tool_result_inline_max_chars.]'
7330
+ + kept,
7264
7331
  filePath: path,
7265
7332
  };
7266
7333
  }
@@ -7282,15 +7349,17 @@ export class AgentFramework {
7282
7349
  });
7283
7350
  console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
7284
7351
  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]`,
7352
+ text: head
7353
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${path} FAILED`
7354
+ + ` (${failure}); content over the cap was not retained]`
7355
+ + kept,
7288
7356
  filePath: null,
7289
7357
  };
7290
7358
  }
7291
7359
  return {
7292
- text: safeSlice(content, 0, cap)
7293
- + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
7360
+ text: head
7361
+ + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]'
7362
+ + kept,
7294
7363
  filePath: null,
7295
7364
  };
7296
7365
  }
@@ -7493,6 +7562,14 @@ export class AgentFramework {
7493
7562
  }
7494
7563
  return hasImage ? blocks : null;
7495
7564
  }
7565
+ toolImageLedgerFor(agentName) {
7566
+ let ledger = this.toolImageLedgers.get(agentName);
7567
+ if (!ledger) {
7568
+ ledger = new ToolImageLedger();
7569
+ this.toolImageLedgers.set(agentName, ledger);
7570
+ }
7571
+ return ledger;
7572
+ }
7496
7573
  /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
7497
7574
  strategyDerivedToolResultChars(agent) {
7498
7575
  const strategy = agent.getContextManager().getStrategy();
@@ -10095,10 +10172,53 @@ export class AgentFramework {
10095
10172
  })();
10096
10173
  }
10097
10174
  /**
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.
10175
+ * `save_recent_image` barrier. Module tool calls in a batch are only
10176
+ * ENQUEUED by dispatch, while the save runs at once — so a [snap, save]
10177
+ * round would inventory before the snapshot exists and silently save the
10178
+ * newest OLDER image (issue #104, same-batch representation). Wait for
10179
+ * every sibling in the round to settle; if one never does, fail closed
10180
+ * rather than trust an index the batch may still change. Sibling saves
10181
+ * produce no images and are not waited on (two saves in one batch would
10182
+ * otherwise deadlock into a double timeout). Script-inner calls
10183
+ * (`pytc-…`) run sequentially inside their script host and skip the
10184
+ * barrier — the host call itself is the only pending sibling they'd see.
10185
+ */
10186
+ async awaitSiblingToolCalls(agent, call) {
10187
+ if (call.id.startsWith('pytc-'))
10188
+ return;
10189
+ const BARRIER_MS = 120_000;
10190
+ const POLL_MS = 20;
10191
+ const startedAt = Date.now();
10192
+ for (;;) {
10193
+ const state = agent.state;
10194
+ // Idle (puppet path) or the round was torn down: nothing to wait on.
10195
+ if (state.status !== 'waiting_for_tools')
10196
+ return;
10197
+ const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
10198
+ if (open.length === 0)
10199
+ return;
10200
+ if (Date.now() - startedAt > BARRIER_MS) {
10201
+ throw new Error(`save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
10202
+ `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
10203
+ 'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
10204
+ 'Save again once they have finished.');
10205
+ }
10206
+ await new Promise((r) => setTimeout(r, POLL_MS));
10207
+ }
10208
+ }
10209
+ /**
10210
+ * Handle the synthesized `save_recent_image` tool (issue #104): build ONE
10211
+ * ordered inventory of every image the calling agent has seen — attachment
10212
+ * image blocks (blobs re-inlined) AND tool-result images (their history
10213
+ * placeholders carry a ref, resolved through the agent's ToolImageLedger)
10214
+ * — counted back from the most recent, then write the requested bytes to
10215
+ * a workspace mount via WorkspaceModule.writeBinary.
10216
+ *
10217
+ * Fail-closed: when the image at a requested position cannot be produced
10218
+ * (bytes evicted, minted by an earlier process, or a placeholder written
10219
+ * before retention existed) the call fails AT THAT INDEX and writes
10220
+ * nothing. It never slides to an older image — a filename must not
10221
+ * authenticate bytes from another surface.
10102
10222
  */
10103
10223
  dispatchSaveImageToolCall(agentName, call) {
10104
10224
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
@@ -10125,16 +10245,166 @@ export class AgentFramework {
10125
10245
  if (typeof input.path !== 'string' || input.path.length === 0) {
10126
10246
  throw new Error('save_recent_image: `path` (mount-prefixed) is required');
10127
10247
  }
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');
10248
+ const ledger = this.toolImageLedgerFor(agentName);
10249
+ const describeTool = (c) => `${c.toolName} (call ${c.toolCallId})`;
10250
+ const unavailable = (status) => status === 'evicted'
10251
+ ? 'its bytes were evicted from the bounded retention budget'
10252
+ : 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
10253
+ 'very old refs age out; a mistyped ref looks the same)';
10254
+ const resolve = (candidate, position) => {
10255
+ const rangeOffset = 0; // filled by the caller
10256
+ if (candidate.kind === 'attachment') {
10257
+ if (candidate.data === null) {
10258
+ throw new Error(`save_recent_image: image at index ${position} is not stored inline (base64) — cannot save it. Nothing written.`);
10259
+ }
10260
+ const bytes = Buffer.from(candidate.data, 'base64');
10261
+ return {
10262
+ rangeOffset,
10263
+ data: candidate.data,
10264
+ mediaType: candidate.mediaType,
10265
+ receipt: {
10266
+ source: 'attachment',
10267
+ mediaType: candidate.mediaType,
10268
+ byteSize: bytes.byteLength,
10269
+ sha256: sha256Hex(bytes),
10270
+ messagesBack: candidate.messagesBack,
10271
+ },
10272
+ };
10273
+ }
10274
+ if (candidate.kind === 'reference') {
10275
+ throw new Error(`save_recent_image: image at index ${position} is a reference (${candidate.refId}, ${candidate.mediaType}, ` +
10276
+ `from ${describeTool(candidate)}) — its bytes are not held here. Nothing written. ` +
10277
+ 'Fetch it with fetch_reference and save the materialized file instead.');
10278
+ }
10279
+ if (candidate.ref === null) {
10280
+ throw new Error(`save_recent_image: image at index ${position} is a tool-result image from ${describeTool(candidate)} ` +
10281
+ 'recorded without a retention ref (written before tool-image retention existed, or inside a ' +
10282
+ 'script run, where tool images are not retained) — its bytes are not available. Nothing written. ' +
10283
+ 'Re-run the tool for a fresh image rather than saving an older one.');
10284
+ }
10285
+ const lookup = ledger.lookup(candidate.ref);
10286
+ // The placeholder came out of stored TEXT, which a tool result can
10287
+ // quote from anywhere (a fetch_history of a channel where someone
10288
+ // pasted their save receipt). A genuine placeholder is written by
10289
+ // its own call's serialization, so the ref's provenance names that
10290
+ // call; a quoted or forged one cannot. Refuse the mismatch.
10291
+ if (lookup.status !== 'unknown' && lookup.image.toolCallId !== candidate.toolCallId) {
10292
+ throw new Error(`save_recent_image: image at index ${position} cites ${candidate.ref}, but that image belongs to ` +
10293
+ `${describeTool(lookup.image)}, not to ${describeTool(candidate)} where the placeholder appears — ` +
10294
+ 'the placeholder is quoted or forged text, not this result\'s own image. Nothing written.');
10295
+ }
10296
+ if (lookup.status !== 'retained') {
10297
+ throw new Error(`save_recent_image: image at index ${position} (${candidate.ref}, from ${describeTool(candidate)}) ` +
10298
+ `is no longer retained — ${unavailable(lookup.status)}. Nothing written. ` +
10299
+ 'Re-run the tool for a fresh image rather than saving an older one.');
10300
+ }
10301
+ const image = lookup.image;
10302
+ return {
10303
+ rangeOffset,
10304
+ data: image.data,
10305
+ mediaType: image.mediaType,
10306
+ receipt: {
10307
+ source: 'tool-result',
10308
+ ref: image.ref,
10309
+ toolName: image.toolName,
10310
+ toolCallId: image.toolCallId,
10311
+ blockIndex: image.blockIndex,
10312
+ mediaType: image.mediaType,
10313
+ byteSize: image.byteSize,
10314
+ sha256: image.sha256,
10315
+ messagesBack: candidate.messagesBack,
10316
+ },
10317
+ };
10318
+ };
10319
+ // Barrier: a save dispatched alongside other calls in one batch must
10320
+ // not inventory before their results exist — those are the newest
10321
+ // images. Waits (bounded) for every sibling to settle, or fails closed.
10322
+ await this.awaitSiblingToolCalls(agent, call);
10323
+ const found = [];
10324
+ let index = 0;
10325
+ let count = 1;
10326
+ /** Direct-ref mode: the one ref to find in the scannable context. */
10327
+ let wantedRef = null;
10328
+ if (input.ref !== undefined) {
10329
+ if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
10330
+ throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
10331
+ }
10332
+ if (input.index !== undefined || input.count !== undefined) {
10333
+ throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
10334
+ }
10335
+ wantedRef = input.ref;
10131
10336
  }
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}`);
10337
+ else {
10338
+ index = input.index === undefined ? 0 : Number(input.index);
10339
+ if (!Number.isInteger(index) || index < 0) {
10340
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
10341
+ }
10342
+ count = input.count === undefined ? 1 : Number(input.count);
10343
+ const MAX_COUNT = 20;
10344
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
10345
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
10346
+ }
10136
10347
  }
10137
10348
  const lastWanted = index + count - 1;
10349
+ let seen = 0;
10350
+ const collecting = () => (wantedRef !== null ? found.length === 0 : found.length < count);
10351
+ // One walk serves both modes. By index: the requested range of the
10352
+ // ordered inventory. By ref: the slot whose placeholder cites that
10353
+ // ref — the ref is resolved THROUGH its occurrence in context, so
10354
+ // the same provenance cross-check applies (a ref that is not visible
10355
+ // in the scannable window — an undone branch, quoted text naming a
10356
+ // real ref — is not saveable by name either).
10357
+ const consider = (candidate) => {
10358
+ if (wantedRef !== null) {
10359
+ if (candidate.kind === 'tool' && candidate.ref === wantedRef && found.length === 0) {
10360
+ found.push(resolve(candidate, seen));
10361
+ }
10362
+ }
10363
+ else if (seen >= index && seen <= lastWanted) {
10364
+ const resolved = resolve(candidate, seen);
10365
+ resolved.rangeOffset = seen - index;
10366
+ found.push(resolved);
10367
+ }
10368
+ seen++;
10369
+ };
10370
+ const considerToolResultImages = (toolCallId, toolName, placeholders, messagesBack) => {
10371
+ for (let p = placeholders.length - 1; p >= 0 && collecting(); p--) {
10372
+ const ph = placeholders[p];
10373
+ if (ph.kind === 'inline') {
10374
+ consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
10375
+ continue;
10376
+ }
10377
+ // Reference stub: the registry's testimony (when this process
10378
+ // still has the record) says whether it was an image; the
10379
+ // sniffed stub text is the fallback. Non-image references
10380
+ // are not slots.
10381
+ const testimony = referenceRegistry.get(ph.refId)?.testimony.mimeType?.toLowerCase() ?? ph.mediaType;
10382
+ if (!testimony || !testimony.startsWith('image/'))
10383
+ continue;
10384
+ consider({ kind: 'reference', refId: ph.refId, mediaType: testimony, toolCallId, toolName, messagesBack });
10385
+ }
10386
+ };
10387
+ // Same-batch results (e.g. a snapshot dispatched alongside the save)
10388
+ // are not in the store yet but ARE the newest images. Classify them
10389
+ // with the very serializer the commit path will run — contradiction
10390
+ // blocks withheld, uri-form blocks as reference stubs, unknown shapes
10391
+ // as no slot at all — so the inventory means the same thing before
10392
+ // and after commit. Retention is idempotent per block, so the
10393
+ // placeholder written at commit time carries the same ref.
10394
+ const state = agent.state;
10395
+ if (state.status === 'waiting_for_tools') {
10396
+ for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
10397
+ const tc = state.completed[i];
10398
+ if (tc.result.isError)
10399
+ continue;
10400
+ const text = toolResultDataToHistoryString(tc.result.data, undefined, {
10401
+ imageRef: (blockIndex, image) => ledger.retain({
10402
+ toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
10403
+ }).ref,
10404
+ });
10405
+ considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
10406
+ }
10407
+ }
10138
10408
  // Walk the message store tail-first in bounded windows, re-inlining
10139
10409
  // blob media, until we've collected the requested range. Scanning is
10140
10410
  // capped so a pathological index can't drag the whole store through
@@ -10143,37 +10413,45 @@ export class AgentFramework {
10143
10413
  const WINDOW = 25;
10144
10414
  const cm = agent.getContextManager();
10145
10415
  const total = cm.getMessageCount();
10146
- let seen = 0;
10147
10416
  let scanned = 0;
10148
- const found = [];
10149
- for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
10417
+ for (let end = total; end > 0 && scanned < MAX_SCAN && collecting(); end -= WINDOW) {
10150
10418
  const start = Math.max(0, end - WINDOW);
10151
- const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
10419
+ // By ref, attachment bytes are never needed — skip blob resolution.
10420
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: wantedRef === null });
10152
10421
  scanned += end - start;
10153
- for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
10422
+ for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
10154
10423
  const content = messages[i]?.content;
10155
10424
  if (!Array.isArray(content))
10156
10425
  continue;
10157
- for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
10426
+ const messagesBack = total - (start + i);
10427
+ for (let b = content.length - 1; b >= 0 && collecting(); b--) {
10158
10428
  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),
10429
+ if (block?.type === 'image') {
10430
+ const inline = block.source?.type === 'base64' && typeof block.source.data === 'string';
10431
+ consider({
10432
+ kind: 'attachment',
10433
+ data: inline ? block.source.data : null,
10434
+ mediaType: block.source?.mediaType ?? 'image/png',
10435
+ messagesBack,
10170
10436
  });
10171
10437
  }
10172
- seen++;
10438
+ else if (block?.type === 'tool_result' && typeof block.content === 'string') {
10439
+ considerToolResultImages(block.toolUseId ?? 'unknown', block.toolName ?? 'unknown tool', parseImagePlaceholders(block.content), messagesBack);
10440
+ }
10173
10441
  }
10174
10442
  }
10175
10443
  }
10176
10444
  if (found.length === 0) {
10445
+ if (wantedRef !== null) {
10446
+ const lookup = ledger.lookup(wantedRef);
10447
+ throw new Error(lookup.status === 'unknown'
10448
+ ? `save_recent_image: ref ${wantedRef} cannot be saved — ${unavailable('unknown')}. Nothing written.`
10449
+ : `save_recent_image: ref ${wantedRef} (from ${describeTool(lookup.image)}) does not appear in your recent ` +
10450
+ `context (scanned ${Math.min(scanned, MAX_SCAN)} messages) — a ref is saved through its placeholder, ` +
10451
+ 'which must be visible to you' +
10452
+ (lookup.status === 'evicted' ? '; its bytes were also evicted from the bounded retention budget' : '') +
10453
+ '. Nothing written.');
10454
+ }
10177
10455
  throw new Error(seen === 0
10178
10456
  ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
10179
10457
  : `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`);
@@ -10209,7 +10487,7 @@ export class AgentFramework {
10209
10487
  savedFiles.push({
10210
10488
  ...result.data,
10211
10489
  imageIndex: index + image.rangeOffset,
10212
- messagesBack: image.messagesBack,
10490
+ ...image.receipt,
10213
10491
  });
10214
10492
  }
10215
10493
  finish({