@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.
- package/dist/src/framework.d.ts +31 -4
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +327 -49
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +55 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +41 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +1 -1
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +175 -0
- package/dist/src/tool-image-ledger.d.ts.map +1 -0
- package/dist/src/tool-image-ledger.js +257 -0
- package/dist/src/tool-image-ledger.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +15 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +16 -9
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +10 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/test/event-gate.test.js +84 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/puppet-tool-call.test.js +1 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/save-recent-image.test.d.ts +26 -0
- package/dist/test/save-recent-image.test.d.ts.map +1 -0
- package/dist/test/save-recent-image.test.js +707 -0
- package/dist/test/save-recent-image.test.js.map +1 -0
- package/dist/test/turn-trigger-provenance.test.js +17 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/framework.ts +389 -54
- package/src/gate/event-gate.ts +71 -3
- package/src/gate/index.ts +1 -1
- package/src/index.ts +1 -1
- package/src/tool-image-ledger.ts +334 -0
- package/src/tool-result-history.ts +30 -9
- package/src/types/agent.ts +10 -0
package/dist/src/framework.js
CHANGED
|
@@ -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({
|
|
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)
|
|
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).
|
|
2497
|
-
'
|
|
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 :
|
|
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:
|
|
7261
|
-
+ `\n\n[truncated — showing ${
|
|
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:
|
|
7286
|
-
+ `\n\n[truncated — showing ${
|
|
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:
|
|
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
|
-
*
|
|
10099
|
-
*
|
|
10100
|
-
*
|
|
10101
|
-
*
|
|
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
|
|
10129
|
-
|
|
10130
|
-
|
|
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
|
-
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10490
|
+
...image.receipt,
|
|
10213
10491
|
});
|
|
10214
10492
|
}
|
|
10215
10493
|
finish({
|