@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.
- package/dist/src/agent.d.ts +10 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +39 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +36 -6
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +451 -83
- 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/modules/history/index.d.ts +69 -0
- package/dist/src/modules/history/index.d.ts.map +1 -0
- package/dist/src/modules/history/index.js +519 -0
- package/dist/src/modules/history/index.js.map +1 -0
- package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
- package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
- package/dist/src/modules/history/search-regex-worker.js +57 -0
- package/dist/src/modules/history/search-regex-worker.js.map +1 -0
- package/dist/src/modules/index.d.ts +1 -0
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -0
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +42 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +5 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.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/src/types/trace.d.ts +4 -0
- package/dist/src/types/trace.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/history-module.test.d.ts +2 -0
- package/dist/test/history-module.test.d.ts.map +1 -0
- package/dist/test/history-module.test.js +449 -0
- package/dist/test/history-module.test.js.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
- package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +39 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -1
- package/dist/test/puppet-tool-call.test.js +114 -1
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
- package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
- package/dist/test/puppet-turn-reservation.test.js +139 -0
- package/dist/test/puppet-turn-reservation.test.js.map +1 -0
- 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 +736 -0
- package/dist/test/save-recent-image.test.js.map +1 -0
- package/dist/test/tool-result-spill.test.js +114 -0
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +17 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/workspace-read-text.test.d.ts +2 -0
- package/dist/test/workspace-read-text.test.d.ts.map +1 -0
- package/dist/test/workspace-read-text.test.js +101 -0
- package/dist/test/workspace-read-text.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +41 -0
- package/src/framework.ts +519 -95
- package/src/gate/event-gate.ts +71 -3
- package/src/gate/index.ts +1 -1
- package/src/index.ts +1 -1
- package/src/modules/history/index.ts +613 -0
- package/src/modules/history/search-regex-worker.ts +76 -0
- package/src/modules/index.ts +2 -0
- package/src/modules/workspace/index.ts +42 -1
- package/src/modules/workspace/types.ts +5 -0
- package/src/tool-image-ledger.ts +334 -0
- package/src/tool-result-history.ts +30 -9
- package/src/types/agent.ts +10 -0
- package/src/types/trace.ts +4 -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
|
}
|
|
@@ -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
|
|
6728
|
-
* the turn state machine and the live stream's
|
|
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
|
-
|
|
6750
|
-
|
|
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
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
//
|
|
6775
|
-
//
|
|
6776
|
-
//
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
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:
|
|
7261
|
-
+ `\n\n[truncated — showing ${
|
|
7262
|
-
+
|
|
7263
|
-
+ '
|
|
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:
|
|
7286
|
-
+ `\n\n[truncated — showing ${
|
|
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:
|
|
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
|
-
*
|
|
10099
|
-
*
|
|
10100
|
-
*
|
|
10101
|
-
*
|
|
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
|
|
10129
|
-
|
|
10130
|
-
|
|
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
|
-
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
10580
|
+
...image.receipt,
|
|
10213
10581
|
});
|
|
10214
10582
|
}
|
|
10215
10583
|
finish({
|