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