@myagentroam/agent 0.11.54 → 0.11.56
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/runtime/context/gc-candidates-code.js +5 -6
- package/dist/runtime/context/gc-candidates-exec.js +2 -2
- package/dist/runtime/context/gc-candidates-read-patch.js +3 -3
- package/dist/runtime/context/gc-candidates-web.js +6 -8
- package/dist/runtime/context/gc-economics.d.ts +18 -6
- package/dist/runtime/context/gc-economics.js +43 -46
- package/dist/runtime/context/gc.js +7 -18
- package/dist/session/context-gc-checkpoint.js +14 -10
- package/dist/session/context-gc-contract.d.ts +5 -4
- package/dist/session/context-gc-protection.d.ts +5 -0
- package/dist/session/context-gc-protection.js +4 -0
- package/dist/session/context-gc-replacements.js +4 -4
- package/dist/session/store/jsonl/fork-session.js +38 -3
- package/dist/session/store/jsonl/history-replacement.d.ts +2 -1
- package/dist/session/store/jsonl/history-replacement.js +16 -5
- package/dist/session/store/jsonl/history.d.ts +5 -0
- package/dist/session/store/jsonl/history.js +84 -24
- package/dist/session/store/jsonl/jsonl-store.js +25 -8
- package/dist/session/store/jsonl/transaction-files.d.ts +2 -0
- package/dist/session/store/jsonl/transaction-files.js +22 -0
- package/dist/session/store/jsonl/transaction-recovery.js +11 -14
- package/dist/session/tool-result-resources.d.ts +10 -1
- package/dist/session/tool-result-resources.js +109 -38
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { retainedCodeInputs } from '../../session/context-gc-observations.js';
|
|
|
2
2
|
import { contextGcTargetKey } from '../../session/context-gc-checkpoint.js';
|
|
3
3
|
import { codeCellCompletionSequence, contextGcCodeModeInputReplacement, contextGcCodeModeOutputReplacement, hasGenericContextGcPreview, normalizeContextGcCodeModeInput } from '../../session/context-gc-replacements.js';
|
|
4
4
|
import { CONTEXT_GC_POLICY } from './gc-economics.js';
|
|
5
|
+
import { contextGcProtectionMature } from '../../session/context-gc-protection.js';
|
|
5
6
|
import { targetFor, isRecord } from './gc-candidates.js';
|
|
6
7
|
export function collectCodeModeCandidates(input, groups, existingTargets, candidateSavings) {
|
|
7
8
|
const eligible = [];
|
|
@@ -41,8 +42,7 @@ export function collectCodeModeCandidates(input, groups, existingTargets, candid
|
|
|
41
42
|
const reclaimedTokens = candidateSavings(candidate);
|
|
42
43
|
if (reclaimedTokens <= 0)
|
|
43
44
|
continue;
|
|
44
|
-
if (
|
|
45
|
-
protection.thinking >= 3)
|
|
45
|
+
if (contextGcProtectionMature(protection))
|
|
46
46
|
eligible.push(candidate);
|
|
47
47
|
else if (candidate.observedRollouts < CONTEXT_GC_POLICY.largeExecProtectionRollouts) {
|
|
48
48
|
protectedTokens += reclaimedTokens;
|
|
@@ -74,12 +74,12 @@ export function collectCodeModeCandidates(input, groups, existingTargets, candid
|
|
|
74
74
|
replacementArguments: contextGcCodeModeInputReplacement(code, group.result.message.observations
|
|
75
75
|
? retainedCodeInputs(input.records, group.callId, (original, successor) => {
|
|
76
76
|
const maturity = input.protection.after(Math.max(evidenceSequence, completionSequence, original.sequence, successor.sequence));
|
|
77
|
-
return maturity
|
|
77
|
+
return contextGcProtectionMature(maturity);
|
|
78
78
|
}, {
|
|
79
79
|
activeProcessIds: input.activeProcessIds,
|
|
80
80
|
evidenceMature: (sequence) => {
|
|
81
81
|
const maturity = input.protection.after(Math.max(evidenceSequence, completionSequence, sequence));
|
|
82
|
-
return maturity
|
|
82
|
+
return contextGcProtectionMature(maturity);
|
|
83
83
|
}
|
|
84
84
|
})
|
|
85
85
|
: []),
|
|
@@ -105,8 +105,7 @@ export function collectCodeModeCandidates(input, groups, existingTargets, candid
|
|
|
105
105
|
const reclaimedTokens = candidateSavings(candidate);
|
|
106
106
|
if (reclaimedTokens <= 0)
|
|
107
107
|
continue;
|
|
108
|
-
if (
|
|
109
|
-
protection.thinking >= 3)
|
|
108
|
+
if (contextGcProtectionMature(protection))
|
|
110
109
|
eligible.push(candidate);
|
|
111
110
|
else if (observedRollouts < CONTEXT_GC_POLICY.largeExecProtectionRollouts) {
|
|
112
111
|
protectedTokens += reclaimedTokens;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { contextGcTargetKey } from '../../session/context-gc-checkpoint.js';
|
|
2
2
|
import { contextGcExecInputReplacement, contextGcExecOutputReplacement, normalizeContextGcExecResult } from '../../session/context-gc-replacements.js';
|
|
3
3
|
import { CONTEXT_GC_POLICY } from './gc-economics.js';
|
|
4
|
+
import { contextGcProtectionMature } from '../../session/context-gc-protection.js';
|
|
4
5
|
import { targetFor, isRecord } from './gc-candidates.js';
|
|
5
6
|
import { terminalExecLifecycles } from '../../session/context-gc-exec-lifecycle.js';
|
|
6
7
|
export function collectExecCandidates(input, groups, existingTargets, candidateSavings) {
|
|
@@ -80,8 +81,7 @@ export function collectExecCandidates(input, groups, existingTargets, candidateS
|
|
|
80
81
|
let observedRollouts = Infinity;
|
|
81
82
|
for (const replacement of replacements) {
|
|
82
83
|
const protection = input.protection.after(Math.max(evidenceSequence, replacement.target.sequence));
|
|
83
|
-
if (protection
|
|
84
|
-
protection.thinking >= 3) {
|
|
84
|
+
if (contextGcProtectionMature(protection)) {
|
|
85
85
|
mature.push(replacement);
|
|
86
86
|
observedRollouts = Math.min(observedRollouts, protection.rollouts);
|
|
87
87
|
}
|
|
@@ -2,6 +2,7 @@ import { contextGcLogicalPathKey, contextGcTargetKey } from '../../session/conte
|
|
|
2
2
|
import { contextGcApplyPatchInputReplacement, contextGcPatchResultFromCompletedPayload, contextGcPatchResultFromFailedPayload, contextGcStaleReadReplacement, contextGcReadRangeCovers, contextGcCoveredReadReplacement } from '../../session/context-gc-replacements.js';
|
|
3
3
|
import { parseReadContextOutput } from '../../prompts/read-context.js';
|
|
4
4
|
import { CONTEXT_GC_POLICY } from './gc-economics.js';
|
|
5
|
+
import { contextGcProtectionMature } from '../../session/context-gc-protection.js';
|
|
5
6
|
import { targetFor } from './gc-candidates.js';
|
|
6
7
|
export function collectDeterministicCandidates(input, groups, existingReplacements, existingTargets, candidateSavings) {
|
|
7
8
|
const eligible = [];
|
|
@@ -103,8 +104,7 @@ export function collectDeterministicCandidates(input, groups, existingReplacemen
|
|
|
103
104
|
let observedRollouts = Infinity;
|
|
104
105
|
for (const replacement of replacements) {
|
|
105
106
|
const protection = input.protection.after(Math.max(evidenceSequence, replacement.target.sequence));
|
|
106
|
-
if (protection
|
|
107
|
-
protection.thinking >= 3) {
|
|
107
|
+
if (contextGcProtectionMature(protection)) {
|
|
108
108
|
mature.push(replacement);
|
|
109
109
|
observedRollouts = Math.min(observedRollouts, protection.rollouts);
|
|
110
110
|
}
|
|
@@ -162,7 +162,7 @@ export function collectDeterministicCandidates(input, groups, existingReplacemen
|
|
|
162
162
|
?.items.some((prior) => prior.index === candidate.index)));
|
|
163
163
|
const successor = later.findLast((entry) => {
|
|
164
164
|
const count = input.protection.after(entry.target.sequence);
|
|
165
|
-
return count
|
|
165
|
+
return contextGcProtectionMature(count);
|
|
166
166
|
});
|
|
167
167
|
if (!successor)
|
|
168
168
|
continue;
|
|
@@ -2,6 +2,7 @@ import { contextGcTargetKey } from '../../session/context-gc-checkpoint.js';
|
|
|
2
2
|
import { completedTodoWrite, todoInputReplacement, todoSnapshotReplacement, visibleTodoSnapshot } from '../../session/context-gc-todo.js';
|
|
3
3
|
import { contextGcWebOutputReplacement, contextGcToolOutputReplacement, contextGcSkillOutputReplacement, hasGenericContextGcPreview, isGenericContextGcTool } from '../../session/context-gc-replacements.js';
|
|
4
4
|
import { CONTEXT_GC_POLICY } from './gc-economics.js';
|
|
5
|
+
import { contextGcProtectionMature } from '../../session/context-gc-protection.js';
|
|
5
6
|
import { targetFor, isRecord } from './gc-candidates.js';
|
|
6
7
|
export function collectWebCandidates(input, groups, existingTargets, candidateSavings) {
|
|
7
8
|
const eligible = [];
|
|
@@ -46,8 +47,7 @@ export function collectWebCandidates(input, groups, existingTargets, candidateSa
|
|
|
46
47
|
const reclaimedTokens = candidateSavings(candidate);
|
|
47
48
|
if (reclaimedTokens <= 0)
|
|
48
49
|
continue;
|
|
49
|
-
if (
|
|
50
|
-
protection.thinking >= 3)
|
|
50
|
+
if (contextGcProtectionMature(protection))
|
|
51
51
|
eligible.push(candidate);
|
|
52
52
|
else if (observedRollouts < CONTEXT_GC_POLICY.largeExecProtectionRollouts) {
|
|
53
53
|
protectedTokens += reclaimedTokens;
|
|
@@ -82,7 +82,7 @@ export function collectToolOutputCandidates(input, groups, existingTargets, cand
|
|
|
82
82
|
candidate.result.message.toolSearchTools.some((item) => item.name === tool.name) &&
|
|
83
83
|
(() => {
|
|
84
84
|
const count = input.protection.after(Math.max(target.sequence, candidate.result.record.sequence));
|
|
85
|
-
return count
|
|
85
|
+
return contextGcProtectionMature(count);
|
|
86
86
|
})());
|
|
87
87
|
if (successor)
|
|
88
88
|
evidence.push(successor.result.record.sequence);
|
|
@@ -133,8 +133,7 @@ export function collectToolOutputCandidates(input, groups, existingTargets, cand
|
|
|
133
133
|
continue;
|
|
134
134
|
const successor = writes
|
|
135
135
|
.filter((write) => write.revision > currentRevision &&
|
|
136
|
-
input.protection.after(Math.max(target.sequence, group.completed.sequence, write.record.sequence))
|
|
137
|
-
input.protection.after(Math.max(target.sequence, group.completed.sequence, write.record.sequence)).thinking >= 3)
|
|
136
|
+
contextGcProtectionMature(input.protection.after(Math.max(target.sequence, group.completed.sequence, write.record.sequence))))
|
|
138
137
|
.sort((a, b) => b.revision - a.revision)[0];
|
|
139
138
|
if (!successor)
|
|
140
139
|
continue;
|
|
@@ -196,7 +195,7 @@ export function collectToolOutputCandidates(input, groups, existingTargets, cand
|
|
|
196
195
|
});
|
|
197
196
|
const successor = later.findLast((candidate) => {
|
|
198
197
|
const count = input.protection.after(Math.max(target.sequence, candidate.result.record.sequence));
|
|
199
|
-
return count
|
|
198
|
+
return contextGcProtectionMature(count);
|
|
200
199
|
});
|
|
201
200
|
if (!successor)
|
|
202
201
|
continue;
|
|
@@ -259,8 +258,7 @@ export function collectToolOutputCandidates(input, groups, existingTargets, cand
|
|
|
259
258
|
const reclaimedTokens = candidateSavings(candidate);
|
|
260
259
|
if (reclaimedTokens <= 0)
|
|
261
260
|
continue;
|
|
262
|
-
if (
|
|
263
|
-
protection.thinking >= 3)
|
|
261
|
+
if (contextGcProtectionMature(protection))
|
|
264
262
|
eligible.push(candidate);
|
|
265
263
|
else if (observedRollouts < CONTEXT_GC_POLICY.largeExecProtectionRollouts) {
|
|
266
264
|
protectedTokens += reclaimedTokens;
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type { ModelUsage } from '../../model/contracts.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
export declare const CONTEXT_GC_POLICY: {
|
|
3
|
+
version: 1;
|
|
4
|
+
immediatePaybackRollouts: number;
|
|
5
|
+
minimumPaybackRollouts: number;
|
|
6
|
+
maximumPaybackRollouts: number;
|
|
7
|
+
contextGrowthSampleWindow: number;
|
|
8
|
+
minimumContextGrowthSamples: number;
|
|
9
|
+
minimumObservationRollouts: number;
|
|
10
|
+
stageAProtectionRollouts: number;
|
|
11
|
+
largeExecProtectionRollouts: number;
|
|
12
|
+
normalInputCostRate: number;
|
|
13
|
+
cacheReadCostRate: number;
|
|
14
|
+
outputCostRate: number;
|
|
15
|
+
minimumGrossReductionRate: number;
|
|
16
|
+
};
|
|
4
17
|
export declare const COMPACT_CREDIT_ACTIVATION_RATIO = 0.8;
|
|
5
|
-
export declare const COMPACT_THRESHOLD_RATIO
|
|
18
|
+
export declare const COMPACT_THRESHOLD_RATIO: number;
|
|
6
19
|
interface ContextGcEconomicsInput {
|
|
7
20
|
readonly contextUsageTokens: number;
|
|
21
|
+
readonly contextWindowTokens: number;
|
|
8
22
|
readonly reclaimedTokens: number;
|
|
9
23
|
readonly reclaimedCachedTokens: number;
|
|
10
24
|
readonly cacheReadTokens: number;
|
|
@@ -17,10 +31,8 @@ interface ContextGcEconomicsInput {
|
|
|
17
31
|
}
|
|
18
32
|
export interface ContextGcEconomicsResult {
|
|
19
33
|
readonly cacheRebuildPenaltyCostUnits: number;
|
|
20
|
-
readonly requiredPositiveNetRollouts: number;
|
|
21
|
-
readonly requiredTargetRoiRollouts: number;
|
|
34
|
+
readonly requiredPositiveNetRollouts: number | null;
|
|
22
35
|
readonly expectedNetSavedCostUnits: number;
|
|
23
|
-
readonly cacheInvestmentRoi: number;
|
|
24
36
|
readonly grossReductionRate: number;
|
|
25
37
|
readonly passes: boolean;
|
|
26
38
|
}
|
|
@@ -1,25 +1,26 @@
|
|
|
1
|
+
import { AGENT_EXECUTION_POLICY } from '../execution-policy.js';
|
|
2
|
+
import { CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS } from '../../session/context-gc-protection.js';
|
|
1
3
|
export const CONTEXT_GC_POLICY = {
|
|
2
4
|
version: 1,
|
|
3
5
|
immediatePaybackRollouts: 3,
|
|
4
|
-
|
|
6
|
+
minimumPaybackRollouts: 30,
|
|
7
|
+
maximumPaybackRollouts: 60,
|
|
5
8
|
contextGrowthSampleWindow: 16,
|
|
6
9
|
minimumContextGrowthSamples: 6,
|
|
7
10
|
minimumObservationRollouts: 1,
|
|
8
|
-
stageAProtectionRollouts:
|
|
9
|
-
largeExecProtectionRollouts:
|
|
11
|
+
stageAProtectionRollouts: CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS,
|
|
12
|
+
largeExecProtectionRollouts: CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS,
|
|
10
13
|
normalInputCostRate: 1,
|
|
11
|
-
cacheReadCostRate: 0.
|
|
12
|
-
cacheWriteCostRate: 1.25,
|
|
14
|
+
cacheReadCostRate: 0.05,
|
|
13
15
|
outputCostRate: 1,
|
|
14
|
-
minimumGrossReductionRate: 0.2
|
|
15
|
-
minimumCacheInvestmentRoi: 0.1
|
|
16
|
+
minimumGrossReductionRate: 0.2
|
|
16
17
|
};
|
|
17
18
|
const CONTEXT_GC_START_RATIO = 0.5;
|
|
18
19
|
const HIGH_WATER_GROSS_REDUCTION_START_RATIO = 0.7;
|
|
19
20
|
const HIGH_WATER_MINIMUM_GROSS_REDUCTION_RATE = 0.1;
|
|
20
21
|
const MINIMUM_CONTEXT_WINDOW_REDUCTION_RATIO = 0.05;
|
|
21
22
|
export const COMPACT_CREDIT_ACTIVATION_RATIO = 0.8;
|
|
22
|
-
export const COMPACT_THRESHOLD_RATIO =
|
|
23
|
+
export const COMPACT_THRESHOLD_RATIO = AGENT_EXECUTION_POLICY.backgroundCompactionPercent / 100;
|
|
23
24
|
const IMMINENT_COMPACT_CREDIT_DISCOUNT = 0.8;
|
|
24
25
|
export function evaluateContextGcEconomics(input) {
|
|
25
26
|
const reclaimedTokens = Math.max(0, input.reclaimedTokens);
|
|
@@ -28,49 +29,51 @@ export function evaluateContextGcEconomics(input) {
|
|
|
28
29
|
const invalidatedCachedSuffixTokens = cacheReadTokens - preservedCachedPrefixTokens;
|
|
29
30
|
const reclaimedCachedTokens = clamp(input.reclaimedCachedTokens, 0, invalidatedCachedSuffixTokens);
|
|
30
31
|
const invalidatedRetainedCachedTokens = Math.max(0, invalidatedCachedSuffixTokens - reclaimedCachedTokens);
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// Calculate token-derived costs in cache-read units to keep exact zero exact.
|
|
33
|
+
const unitsPerNormalInput = CONTEXT_GC_POLICY.normalInputCostRate / CONTEXT_GC_POLICY.cacheReadCostRate;
|
|
34
|
+
const cacheRebuildPenalty = invalidatedRetainedCachedTokens * (unitsPerNormalInput - 1);
|
|
35
|
+
const cacheRebuildPenaltyCostUnits = cacheRebuildPenalty * CONTEXT_GC_POLICY.cacheReadCostRate;
|
|
33
36
|
const reclaimedUncachedTokens = Math.max(0, reclaimedTokens - reclaimedCachedTokens);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
? 0
|
|
55
|
-
: expectedNetSavedCostUnits / cacheRebuildPenaltyCostUnits;
|
|
37
|
+
const firstRolloutSavings = reclaimedCachedTokens + reclaimedUncachedTokens * unitsPerNormalInput;
|
|
38
|
+
const steadyRolloutSavings = reclaimedTokens;
|
|
39
|
+
const netAt = (rollouts) => rollouts <= 0
|
|
40
|
+
? -cacheRebuildPenalty
|
|
41
|
+
: firstRolloutSavings +
|
|
42
|
+
Math.max(0, rollouts - 1) * steadyRolloutSavings -
|
|
43
|
+
cacheRebuildPenalty;
|
|
44
|
+
let requiredPositiveNetRollouts;
|
|
45
|
+
if (netAt(1) > 0)
|
|
46
|
+
requiredPositiveNetRollouts = 1;
|
|
47
|
+
else if (steadyRolloutSavings <= 0)
|
|
48
|
+
requiredPositiveNetRollouts = null;
|
|
49
|
+
else {
|
|
50
|
+
requiredPositiveNetRollouts = Math.max(2, Math.floor(-netAt(1) / steadyRolloutSavings) + 2);
|
|
51
|
+
if (netAt(requiredPositiveNetRollouts - 1) > 0)
|
|
52
|
+
requiredPositiveNetRollouts--;
|
|
53
|
+
else if (netAt(requiredPositiveNetRollouts) <= 0)
|
|
54
|
+
requiredPositiveNetRollouts++;
|
|
55
|
+
}
|
|
56
|
+
const expectedNetSavedCostUnits = netAt(requiredPositiveNetRollouts ?? 0) * CONTEXT_GC_POLICY.cacheReadCostRate;
|
|
56
57
|
const grossReductionRate = reclaimedTokens / Math.max(1, Math.max(0, input.contextUsageTokens));
|
|
57
58
|
const minimumGrossReductionRate = Math.max(0, input.minimumGrossReductionRate ?? CONTEXT_GC_POLICY.minimumGrossReductionRate);
|
|
58
59
|
const minimumReclaimedTokens = Math.max(0, input.minimumReclaimedTokens ??
|
|
59
60
|
Math.ceil(Math.max(0, input.contextUsageTokens) * minimumGrossReductionRate));
|
|
60
|
-
const
|
|
61
|
+
const usageRatio = input.contextUsageTokens / input.contextWindowTokens;
|
|
62
|
+
const progress = clamp((usageRatio - CONTEXT_GC_START_RATIO) / (COMPACT_THRESHOLD_RATIO - CONTEXT_GC_START_RATIO), 0, 1);
|
|
63
|
+
const paybackWindow = CONTEXT_GC_POLICY.minimumPaybackRollouts +
|
|
64
|
+
progress *
|
|
65
|
+
(CONTEXT_GC_POLICY.maximumPaybackRollouts - CONTEXT_GC_POLICY.minimumPaybackRollouts);
|
|
66
|
+
const withinWindow = requiredPositiveNetRollouts !== null && requiredPositiveNetRollouts <= paybackWindow;
|
|
61
67
|
const passes = reclaimedTokens > 0 &&
|
|
62
68
|
reclaimedTokens >= minimumReclaimedTokens &&
|
|
63
|
-
Number.isFinite(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
requiredTargetRoiRollouts <= input.projectedRolloutsUntilCompactAfterGc));
|
|
69
|
+
Number.isFinite(usageRatio) &&
|
|
70
|
+
usageRatio >= CONTEXT_GC_START_RATIO &&
|
|
71
|
+
usageRatio < COMPACT_THRESHOLD_RATIO &&
|
|
72
|
+
withinWindow;
|
|
68
73
|
return {
|
|
69
74
|
cacheRebuildPenaltyCostUnits,
|
|
70
75
|
requiredPositiveNetRollouts,
|
|
71
|
-
requiredTargetRoiRollouts,
|
|
72
76
|
expectedNetSavedCostUnits,
|
|
73
|
-
cacheInvestmentRoi,
|
|
74
77
|
grossReductionRate,
|
|
75
78
|
passes
|
|
76
79
|
};
|
|
@@ -128,12 +131,6 @@ export function modelContextUsage(value) {
|
|
|
128
131
|
? context
|
|
129
132
|
: undefined;
|
|
130
133
|
}
|
|
131
|
-
function firstMatchingRollout(limit, predicate) {
|
|
132
|
-
for (let rollouts = 0; rollouts <= limit; rollouts++)
|
|
133
|
-
if (predicate(rollouts))
|
|
134
|
-
return rollouts;
|
|
135
|
-
return Number.POSITIVE_INFINITY;
|
|
136
|
-
}
|
|
137
134
|
export function clamp(value, minimum, maximum) {
|
|
138
135
|
return Math.min(maximum, Math.max(minimum, value));
|
|
139
136
|
}
|
|
@@ -2,7 +2,7 @@ import { ContextGcCheckpointCache, contextGcBaseFromRecords, contextGcCheckpoint
|
|
|
2
2
|
import { buildContextGcProtection } from '../../session/context-gc-protection.js';
|
|
3
3
|
import { CONTEXT_GC_TOKEN_ESTIMATOR, contextGcCacheImpact, contextGcMessageTokens, contextGcPrefixTokens, contextGcReclaimedTokens } from './gc-tokens.js';
|
|
4
4
|
import { applyContextGcReplacementsToMessages, providerToolCallId } from './projection.js';
|
|
5
|
-
import { CONTEXT_GC_POLICY, COMPACT_CREDIT_ACTIVATION_RATIO, COMPACT_THRESHOLD_RATIO, evaluateContextGcEconomics, contextGcMinimumGrossReductionRate, contextGcMinimumReclaimedTokens, contextGcEligibleUsage, contextGcImminentCompactAvoidanceCredit,
|
|
5
|
+
import { CONTEXT_GC_POLICY, COMPACT_CREDIT_ACTIVATION_RATIO, COMPACT_THRESHOLD_RATIO, evaluateContextGcEconomics, contextGcMinimumGrossReductionRate, contextGcMinimumReclaimedTokens, contextGcEligibleUsage, contextGcImminentCompactAvoidanceCredit, modelContextUsage, clamp } from './gc-economics.js';
|
|
6
6
|
import { buildToolGroups, countSuccessfulNormalRollouts, isRecord } from './gc-candidates.js';
|
|
7
7
|
import { collectCodeModeCandidates } from './gc-candidates-code.js';
|
|
8
8
|
import { collectDeterministicCandidates } from './gc-candidates-read-patch.js';
|
|
@@ -181,19 +181,13 @@ function finalizePlan(input, evaluated, includeMedia = false) {
|
|
|
181
181
|
compactDeferralCreditCostUnits: evaluated.compactDeferralCreditCostUnits,
|
|
182
182
|
imminentCompactAvoidanceCreditCostUnits: evaluated.imminentCompactAvoidanceCreditCostUnits,
|
|
183
183
|
appliedCompactCreditCostUnits: evaluated.appliedCompactCreditCostUnits,
|
|
184
|
-
requiredPositiveNetRollouts:
|
|
185
|
-
? evaluated.economics.requiredPositiveNetRollouts
|
|
186
|
-
: null,
|
|
187
|
-
requiredTargetRoiRollouts: Number.isFinite(evaluated.economics.requiredTargetRoiRollouts)
|
|
188
|
-
? evaluated.economics.requiredTargetRoiRollouts
|
|
189
|
-
: null,
|
|
184
|
+
requiredPositiveNetRollouts: evaluated.economics.requiredPositiveNetRollouts,
|
|
190
185
|
projectedRolloutsUntilCompactAfterGc: evaluated.projectedRolloutsUntilCompactAfterGc,
|
|
191
186
|
projectedTokensBeforeGc: evaluated.projectedTokensBeforeGc,
|
|
192
187
|
projectedTokensAfterGc: evaluated.projectedTokensAfterGc,
|
|
193
188
|
contextGrowthSampleCount: evaluated.contextGrowthSamples.length,
|
|
194
189
|
contextGrowthP75Tokens: evaluated.contextGrowthP75Tokens,
|
|
195
190
|
expectedNetSavedCostUnits: evaluated.economics.expectedNetSavedCostUnits,
|
|
196
|
-
cacheInvestmentRoi: evaluated.economics.cacheInvestmentRoi,
|
|
197
191
|
minimumObservedRollouts: evaluated.minimumObservedRollouts,
|
|
198
192
|
protectedCandidateTokens: evaluated.protectedCandidateTokens,
|
|
199
193
|
eligibleCandidateCount: evaluated.eligibleCandidateCount,
|
|
@@ -254,8 +248,6 @@ function evaluateCandidateSet(input, existing, candidates, state, mediaProjectio
|
|
|
254
248
|
const pendingTokens = Math.max(0, input.usage.outputTokens ?? 0) + contextGcMessageTokens(input, pendingMessages);
|
|
255
249
|
const projectedTokensBeforeGc = state.contextUsageTokens + pendingTokens;
|
|
256
250
|
const projectedTokensAfterGc = Math.max(0, projectedTokensBeforeGc - reclaimedTokens);
|
|
257
|
-
if (!state.mediaOnly && projectedTokensAfterGc >= compactThresholdTokens)
|
|
258
|
-
return undefined;
|
|
259
251
|
const projectedRolloutsUntilCompactAfterGc = contextGrowthP75Tokens <= 0
|
|
260
252
|
? 0
|
|
261
253
|
: Math.max(0, Math.floor((compactThresholdTokens - projectedTokensAfterGc) / Math.max(1, contextGrowthP75Tokens)));
|
|
@@ -270,9 +262,11 @@ function evaluateCandidateSet(input, existing, candidates, state, mediaProjectio
|
|
|
270
262
|
projectedTokensAfterGc,
|
|
271
263
|
contextGrowthP75Tokens
|
|
272
264
|
});
|
|
273
|
-
|
|
265
|
+
// Compact estimates remain diagnostic; only normal-request savings repay GC.
|
|
266
|
+
const appliedCompactCreditCostUnits = 0;
|
|
274
267
|
const economics = evaluateContextGcEconomics({
|
|
275
268
|
contextUsageTokens: state.contextUsageTokens,
|
|
269
|
+
contextWindowTokens: input.contextWindowTokens,
|
|
276
270
|
reclaimedTokens,
|
|
277
271
|
reclaimedCachedTokens: cache.reclaimedCachedTokens,
|
|
278
272
|
cacheReadTokens: input.usage.cacheReadTokens ?? 0,
|
|
@@ -382,20 +376,15 @@ function compactDeferralCredit(input, reclaimedTokens, compactThresholdTokens) {
|
|
|
382
376
|
const usage = compact.payload.usage;
|
|
383
377
|
const contextInput = modelContextUsage(usage);
|
|
384
378
|
const cacheRead = nonnegativeNumber(usage.cacheReadTokens);
|
|
385
|
-
const cacheWrite = nonnegativeNumber(usage.cacheWriteTokens);
|
|
386
379
|
const output = nonnegativeNumber(usage.outputTokens);
|
|
387
|
-
if (contextInput === undefined ||
|
|
388
|
-
cacheRead === undefined ||
|
|
389
|
-
cacheWrite === undefined ||
|
|
390
|
-
output === undefined)
|
|
380
|
+
if (contextInput === undefined || cacheRead === undefined || output === undefined)
|
|
391
381
|
return 0;
|
|
392
382
|
const baseline = firstMatchingUsageAfter(input.records, compact.sequence, input.modelId, input.contextWindowTokens);
|
|
393
383
|
if (baseline === undefined || baseline >= compactThresholdTokens)
|
|
394
384
|
return 0;
|
|
395
|
-
const fresh = Math.max(0, contextInput - cacheRead
|
|
385
|
+
const fresh = Math.max(0, contextInput - cacheRead);
|
|
396
386
|
const compactCost = fresh * CONTEXT_GC_POLICY.normalInputCostRate +
|
|
397
387
|
cacheRead * CONTEXT_GC_POLICY.cacheReadCostRate +
|
|
398
|
-
cacheWrite * CONTEXT_GC_POLICY.cacheWriteCostRate +
|
|
399
388
|
output * CONTEXT_GC_POLICY.outputCostRate;
|
|
400
389
|
const cycleCapacity = Math.max(1, compactThresholdTokens - baseline);
|
|
401
390
|
return compactCost * clamp(reclaimedTokens / cycleCapacity, 0, 1);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { posix, win32 } from 'node:path';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
3
|
import { inlineMediaGcAnchored, parseInlineMediaGc } from './inline-media.js';
|
|
4
|
-
import { buildContextGcProtection } from './context-gc-protection.js';
|
|
4
|
+
import { buildContextGcProtection, contextGcProtectionMature } from './context-gc-protection.js';
|
|
5
5
|
import { completedTodoWrite, todoInputReplacement, todoSnapshotReplacement, visibleTodoSnapshot } from './context-gc-todo.js';
|
|
6
6
|
import { contextGcRetainedInputMarkup } from './context-gc-markup.js';
|
|
7
7
|
import { codeCallEvidence, codePartialPatchCallEvidence, callsFromRecord, codeObservationEvidence, observationsFromRecord, retainedCodeInputs } from './context-gc-observations.js';
|
|
@@ -239,7 +239,7 @@ export function validateContextGcCheckpoint(records, record, workspace = session
|
|
|
239
239
|
}
|
|
240
240
|
if (anchors.some((anchor) => {
|
|
241
241
|
const count = protection.after(anchor);
|
|
242
|
-
return count
|
|
242
|
+
return !contextGcProtectionMature(count);
|
|
243
243
|
}))
|
|
244
244
|
return undefined;
|
|
245
245
|
if (!replacementMatchesRecords(replacement, records, recordsBySequence, workspace, payload.trigger.platform, payload.replacements, protection, committed.get(contextGcReplacementKey(replacement))))
|
|
@@ -408,12 +408,12 @@ function replacementMatchesRecords(replacement, records, recordsBySequence, work
|
|
|
408
408
|
isDeepStrictEqual(replacement.replacementArguments, contextGcCodeModeInputReplacement(input, observationsFromRecord(evidence.result)
|
|
409
409
|
? retainedCodeInputs(records, replacement.callId, (original, successor) => {
|
|
410
410
|
const count = protection.after(Math.max(replacement.target.sequence, replacement.evidenceSequence, codeCellCompletionSequence(records, evidence.completed) ?? 0, original.sequence, successor.sequence));
|
|
411
|
-
return count
|
|
411
|
+
return contextGcProtectionMature(count);
|
|
412
412
|
}, {
|
|
413
413
|
activeProcessIds: new Set(),
|
|
414
414
|
evidenceMature: (sequence) => {
|
|
415
415
|
const count = protection.after(Math.max(replacement.target.sequence, replacement.evidenceSequence, codeCellCompletionSequence(records, evidence.completed) ?? 0, sequence));
|
|
416
|
-
return count
|
|
416
|
+
return contextGcProtectionMature(count);
|
|
417
417
|
},
|
|
418
418
|
selected: (callId, toolName, summary) => {
|
|
419
419
|
const argument = replacement.replacementArguments;
|
|
@@ -1110,14 +1110,15 @@ function isContextGcEconomicsPolicy(value) {
|
|
|
1110
1110
|
'stageAProtectionRollouts',
|
|
1111
1111
|
'largeExecProtectionRollouts'
|
|
1112
1112
|
].every((key) => isNonnegativeInteger(value[key])) &&
|
|
1113
|
+
(value.minimumPaybackRollouts === undefined ||
|
|
1114
|
+
isNonnegativeInteger(value.minimumPaybackRollouts)) &&
|
|
1113
1115
|
[
|
|
1114
1116
|
'normalInputCostRate',
|
|
1115
1117
|
'cacheReadCostRate',
|
|
1116
|
-
'cacheWriteCostRate',
|
|
1117
1118
|
'outputCostRate',
|
|
1118
|
-
'minimumGrossReductionRate'
|
|
1119
|
-
|
|
1120
|
-
].every((key) => isNonnegativeFinite(value[key])));
|
|
1119
|
+
'minimumGrossReductionRate'
|
|
1120
|
+
].every((key) => isNonnegativeFinite(value[key])) &&
|
|
1121
|
+
['cacheWriteCostRate', 'minimumCacheInvestmentRoi'].every((key) => value[key] === undefined || isNonnegativeFinite(value[key])));
|
|
1121
1122
|
}
|
|
1122
1123
|
function isContextGcMetrics(value) {
|
|
1123
1124
|
if (!isRecord(value) ||
|
|
@@ -1150,10 +1151,13 @@ function isContextGcMetrics(value) {
|
|
|
1150
1151
|
'projectedTokensAfterGc'
|
|
1151
1152
|
];
|
|
1152
1153
|
return (nonnegative.every((key) => isNonnegativeFinite(value[key])) &&
|
|
1153
|
-
[
|
|
1154
|
+
[
|
|
1155
|
+
'requiredPositiveNetRollouts',
|
|
1156
|
+
...(value.requiredTargetRoiRollouts === undefined ? [] : ['requiredTargetRoiRollouts'])
|
|
1157
|
+
].every((key) => isNonnegativeFinite(value[key]) || (value.stage === 'INLINE_MEDIA' && value[key] === null)) &&
|
|
1154
1158
|
optionalNonnegative.every((key) => value[key] === undefined || isNonnegativeFinite(value[key])) &&
|
|
1155
1159
|
Number.isFinite(value.expectedNetSavedCostUnits) &&
|
|
1156
|
-
Number.isFinite(value.cacheInvestmentRoi) &&
|
|
1160
|
+
(value.cacheInvestmentRoi === undefined || Number.isFinite(value.cacheInvestmentRoi)) &&
|
|
1157
1161
|
(value.tokenEstimator === undefined || value.tokenEstimator === 'UTF8_BYTES_V1') &&
|
|
1158
1162
|
(value.cachePenaltyKnown === undefined || typeof value.cachePenaltyKnown === 'boolean'));
|
|
1159
1163
|
}
|
|
@@ -5,6 +5,7 @@ export type ContextGcPlatform = 'linux' | 'darwin' | 'win32';
|
|
|
5
5
|
export interface ContextGcEconomicsPolicy {
|
|
6
6
|
readonly version: 1;
|
|
7
7
|
readonly immediatePaybackRollouts: number;
|
|
8
|
+
readonly minimumPaybackRollouts?: number;
|
|
8
9
|
readonly maximumPaybackRollouts: number;
|
|
9
10
|
readonly contextGrowthSampleWindow: number;
|
|
10
11
|
readonly minimumContextGrowthSamples: number;
|
|
@@ -13,10 +14,10 @@ export interface ContextGcEconomicsPolicy {
|
|
|
13
14
|
readonly largeExecProtectionRollouts: number;
|
|
14
15
|
readonly normalInputCostRate: number;
|
|
15
16
|
readonly cacheReadCostRate: number;
|
|
16
|
-
readonly cacheWriteCostRate
|
|
17
|
+
readonly cacheWriteCostRate?: number;
|
|
17
18
|
readonly outputCostRate: number;
|
|
18
19
|
readonly minimumGrossReductionRate: number;
|
|
19
|
-
readonly minimumCacheInvestmentRoi
|
|
20
|
+
readonly minimumCacheInvestmentRoi?: number;
|
|
20
21
|
}
|
|
21
22
|
export type ContextGcBase = {
|
|
22
23
|
readonly type: 'SESSION_START';
|
|
@@ -112,14 +113,14 @@ export interface ContextGcMetrics {
|
|
|
112
113
|
readonly imminentCompactAvoidanceCreditCostUnits?: number;
|
|
113
114
|
readonly appliedCompactCreditCostUnits?: number;
|
|
114
115
|
readonly requiredPositiveNetRollouts: number | null;
|
|
115
|
-
readonly requiredTargetRoiRollouts
|
|
116
|
+
readonly requiredTargetRoiRollouts?: number | null;
|
|
116
117
|
readonly projectedRolloutsUntilCompactAfterGc: number;
|
|
117
118
|
readonly projectedTokensBeforeGc?: number;
|
|
118
119
|
readonly projectedTokensAfterGc?: number;
|
|
119
120
|
readonly contextGrowthSampleCount: number;
|
|
120
121
|
readonly contextGrowthP75Tokens: number;
|
|
121
122
|
readonly expectedNetSavedCostUnits: number;
|
|
122
|
-
readonly cacheInvestmentRoi
|
|
123
|
+
readonly cacheInvestmentRoi?: number;
|
|
123
124
|
readonly minimumObservedRollouts: number;
|
|
124
125
|
readonly protectedCandidateTokens: number;
|
|
125
126
|
readonly eligibleCandidateCount: number;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { SessionRecord } from './record-contract.js';
|
|
2
|
+
export declare const CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS = 10;
|
|
3
|
+
export declare function contextGcProtectionMature(count: {
|
|
4
|
+
readonly rollouts: number;
|
|
5
|
+
readonly thinking: number;
|
|
6
|
+
}): boolean;
|
|
2
7
|
/** One immutable boundary index, shared by all candidate anchors in this evaluation. */
|
|
3
8
|
export declare function buildContextGcProtection(records: readonly SessionRecord[], modelId: string, contextWindowTokens: number): {
|
|
4
9
|
after(anchor: number): {
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { completedNativeThinkingIdentity } from '../model/native-context.js';
|
|
2
|
+
export const CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS = 10;
|
|
3
|
+
export function contextGcProtectionMature(count) {
|
|
4
|
+
return count.rollouts >= CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS && count.thinking >= 3;
|
|
5
|
+
}
|
|
2
6
|
/** One immutable boundary index, shared by all candidate anchors in this evaluation. */
|
|
3
7
|
export function buildContextGcProtection(records, modelId, contextWindowTokens) {
|
|
4
8
|
const configurations = new Map();
|
|
@@ -24,7 +24,7 @@ export function contextGcCodeModeInputReplacement(input, retainedInputs = []) {
|
|
|
24
24
|
originalBytes: Buffer.byteLength(input.code),
|
|
25
25
|
lines: lineCount(input.code),
|
|
26
26
|
inputMode: input.inputMode,
|
|
27
|
-
contextGc: 'Completed Code Mode source was reclaimed after
|
|
27
|
+
contextGc: 'Completed Code Mode source was reclaimed after the protection period. The paired output, images, completion evidence, and nested tool side effects remain in history.',
|
|
28
28
|
retainedInputs
|
|
29
29
|
});
|
|
30
30
|
return input.inputMode === 'freeform' ? content : { code: content };
|
|
@@ -37,7 +37,7 @@ export function contextGcCodeModeOutputReplacement(originalContent, truncated, v
|
|
|
37
37
|
truncated,
|
|
38
38
|
preview,
|
|
39
39
|
previewComplete: !truncated && preview === originalContent,
|
|
40
|
-
contextGc: 'Detailed Code Mode output was reclaimed after
|
|
40
|
+
contextGc: 'Detailed Code Mode output was reclaimed after the protection period. An incomplete preview is not the complete cell output. The source was not replayed and nested tool side effects were not changed.'
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
export function contextGcPatchResultFromCompletedPayload(payload) {
|
|
@@ -111,7 +111,7 @@ export function contextGcWebOutputReplacement(toolName, originalContent, truncat
|
|
|
111
111
|
truncated,
|
|
112
112
|
preview,
|
|
113
113
|
previewComplete: preview === originalContent,
|
|
114
|
-
contextGc: 'Detailed web tool output was reclaimed after
|
|
114
|
+
contextGc: 'Detailed web tool output was reclaimed after the protection period. An incomplete preview is not the complete search result or page, and GC did not access the network.'
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
/** Tools with their own evidence and lifecycle policies are never handled as generic output. */
|
|
@@ -142,7 +142,7 @@ export function contextGcToolOutputReplacement(toolName, originalContent, trunca
|
|
|
142
142
|
truncated,
|
|
143
143
|
preview,
|
|
144
144
|
previewComplete: !truncated && preview === originalContent,
|
|
145
|
-
contextGc: 'Detailed tool output was reclaimed after
|
|
145
|
+
contextGc: 'Detailed tool output was reclaimed after the protection period. An incomplete preview is not the complete result; the tool was not replayed.'
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
export function contextGcSkillOutputReplacement(name, originalContent, successorCallId) {
|
|
@@ -3,7 +3,7 @@ import { mkdir, rename, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { AgentError } from '../../../error.js';
|
|
5
5
|
import { inlineMediaHistory } from '../../inline-media.js';
|
|
6
|
-
import {
|
|
6
|
+
import { prepareToolResultResources, writeToolResultResourceIndex } from '../../tool-result-resources.js';
|
|
7
7
|
import { syncJsonlDirectory } from './fs-durability.js';
|
|
8
8
|
import { forkedPayload, stageSessionSegments } from './history.js';
|
|
9
9
|
import { copyGeneratedImages, generatedImageFileNames } from './images.js';
|
|
@@ -63,14 +63,49 @@ export async function forkSessionOperation(source, records, input, sourceRoot, s
|
|
|
63
63
|
}
|
|
64
64
|
];
|
|
65
65
|
const retained = input.boundaryTurnId === null ? [] : records.slice(1, boundaryIndex + 1);
|
|
66
|
+
const resourcesByOwner = new Map();
|
|
67
|
+
for (const [resourceRef, { owner, bytes }] of await prepareToolResultResources(sourceDirectory, retained)) {
|
|
68
|
+
const resources = resourcesByOwner.get(owner) ?? [];
|
|
69
|
+
resources.push({ resourceRef, bytes });
|
|
70
|
+
resourcesByOwner.set(owner, resources);
|
|
71
|
+
}
|
|
72
|
+
const resourceLocators = new Map();
|
|
66
73
|
const inlineState = inlineMediaHistory(retained).state;
|
|
67
74
|
const stateAnchor = inlineState &&
|
|
68
75
|
retained.findLast((record) => record.type === 'resource.window' || record.type === 'compact.completed');
|
|
69
76
|
const forkIdentities = new Map();
|
|
70
77
|
for (const record of retained) {
|
|
71
|
-
if (record.type === 'context.gc.completed' ||
|
|
78
|
+
if (record.type === 'context.gc.completed' ||
|
|
79
|
+
record.type === 'responses.continuation' ||
|
|
80
|
+
record.type === 'tool.resource')
|
|
72
81
|
continue;
|
|
82
|
+
const pendingLocators = [];
|
|
83
|
+
for (const { resourceRef, bytes } of resourcesByOwner.get(record.recordId) ?? []) {
|
|
84
|
+
const bodySequences = [];
|
|
85
|
+
// Small private records share normal segment durability and rollback boundaries.
|
|
86
|
+
for (let offset = 0; offset < Math.max(1, bytes.length); offset += 1024 * 1024) {
|
|
87
|
+
const sequence = copied.length + 1;
|
|
88
|
+
bodySequences.push(sequence);
|
|
89
|
+
copied.push({
|
|
90
|
+
recordId: randomUUID(),
|
|
91
|
+
sequence,
|
|
92
|
+
sessionId: target.id,
|
|
93
|
+
type: 'tool.resource',
|
|
94
|
+
payload: {
|
|
95
|
+
resourceRef,
|
|
96
|
+
dataBase64: bytes.subarray(offset, offset + 1024 * 1024).toString('base64')
|
|
97
|
+
},
|
|
98
|
+
...(record.executionId === undefined ? {} : { executionId: record.executionId }),
|
|
99
|
+
...(record.turnId === undefined ? {} : { turnId: record.turnId }),
|
|
100
|
+
timestamp: record.timestamp
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
pendingLocators.push({ resourceRef, bodySequences });
|
|
104
|
+
}
|
|
105
|
+
resourcesByOwner.delete(record.recordId);
|
|
73
106
|
const identity = { recordId: randomUUID(), sequence: copied.length + 1 };
|
|
107
|
+
for (const { resourceRef, bodySequences } of pendingLocators)
|
|
108
|
+
resourceLocators.set(resourceRef, { ...identity, bodySequences });
|
|
74
109
|
forkIdentities.set(record.recordId, identity);
|
|
75
110
|
copied.push({
|
|
76
111
|
...identity,
|
|
@@ -95,7 +130,7 @@ export async function forkSessionOperation(source, records, input, sourceRoot, s
|
|
|
95
130
|
await rename(file.temporaryPath, file.path);
|
|
96
131
|
target.segments = staged.segments;
|
|
97
132
|
target.nextSequence = copied.length + 1;
|
|
98
|
-
await
|
|
133
|
+
await writeToolResultResourceIndex(targetDirectory, resourceLocators);
|
|
99
134
|
await copyGeneratedImages(sourceRoot, publication.directory, generatedImageFileNames(retained));
|
|
100
135
|
await copyResources(sourceDirectory, targetDirectory, retained);
|
|
101
136
|
for (const path of [
|
|
@@ -39,8 +39,9 @@ export type HistoryIndexOutcome = {
|
|
|
39
39
|
readonly error: unknown;
|
|
40
40
|
};
|
|
41
41
|
/** Execute one rollback/clear transaction while its caller holds maintenance. */
|
|
42
|
-
export declare function replaceSessionHistory(meta: SessionMeta,
|
|
42
|
+
export declare function replaceSessionHistory(meta: SessionMeta, prefix: readonly SessionRecord[], operation: {
|
|
43
43
|
kind: 'rollback';
|
|
44
|
+
reuseSegments: boolean;
|
|
44
45
|
} | {
|
|
45
46
|
kind: 'clear';
|
|
46
47
|
clearedAt: string;
|
|
@@ -4,17 +4,26 @@ import { basename, join } from 'node:path';
|
|
|
4
4
|
import { toolResultResourceFiles } from '../../tool-result-resources.js';
|
|
5
5
|
import { beginSessionStorageTransaction, retiredSessionTrashPath, recoverSessionStorageTransaction } from './transaction-recovery.js';
|
|
6
6
|
import { deriveExecutionState } from '../execution-state.js';
|
|
7
|
-
import { renameIfExists, retainedSessionTitle, stageSessionSegments, stageUnreferencedImages } from './history.js';
|
|
7
|
+
import { renameIfExists, retainedSessionTitle, stageRollbackSegments, stageSessionSegments, stageUnreferencedImages } from './history.js';
|
|
8
8
|
import { generatedImageFileNames } from './images.js';
|
|
9
9
|
import { CONTEXTUAL_WORLD_STATE_FILE } from './open-recovery.js';
|
|
10
10
|
import { resourceFiles } from './resources.js';
|
|
11
11
|
import { syncJsonlDirectory } from './fs-durability.js';
|
|
12
12
|
/** Execute one rollback/clear transaction while its caller holds maintenance. */
|
|
13
|
-
export async function replaceSessionHistory(meta,
|
|
13
|
+
export async function replaceSessionHistory(meta, prefix, operation, context) {
|
|
14
|
+
const retained = operation.kind === 'rollback'
|
|
15
|
+
? prefix.filter((record) => record.type !== 'responses.continuation')
|
|
16
|
+
: prefix;
|
|
14
17
|
const targetDirectory = context.targetGeneration === undefined
|
|
15
18
|
? context.directory
|
|
16
19
|
: join(context.sessionDirectory, 'v2', context.targetGeneration);
|
|
17
|
-
const
|
|
20
|
+
const incremental = operation.kind === 'rollback' &&
|
|
21
|
+
operation.reuseSegments &&
|
|
22
|
+
meta.storage?.encoding === 2 &&
|
|
23
|
+
meta.segments !== undefined;
|
|
24
|
+
const staged = incremental
|
|
25
|
+
? await stageRollbackSegments(targetDirectory, prefix, meta.segments)
|
|
26
|
+
: await stageSessionSegments(targetDirectory, retained);
|
|
18
27
|
const transactionId = randomUUID();
|
|
19
28
|
const targetNextSequence = operation.kind === 'clear' ? 2 : (retained.at(-1)?.sequence ?? 0) + 1;
|
|
20
29
|
const contextualWorldStatePath = join(context.directory, CONTEXTUAL_WORLD_STATE_FILE);
|
|
@@ -22,7 +31,9 @@ export async function replaceSessionHistory(meta, retained, operation, context)
|
|
|
22
31
|
const originalTransactionId = meta.storageTransactionId;
|
|
23
32
|
const originalPaths = await context.segmentPaths();
|
|
24
33
|
const retiredPaths = await retiredSessionPaths(context.sessionDirectory, context.directory, targetDirectory);
|
|
25
|
-
const backups = originalPaths
|
|
34
|
+
const backups = originalPaths
|
|
35
|
+
.filter((path) => !staged.reused?.has(basename(path)))
|
|
36
|
+
.map((path) => ({
|
|
26
37
|
path,
|
|
27
38
|
backup: `${path}.${transactionId}.${operation.kind}-backup`
|
|
28
39
|
}));
|
|
@@ -30,7 +41,7 @@ export async function replaceSessionHistory(meta, retained, operation, context)
|
|
|
30
41
|
version: 1,
|
|
31
42
|
id: transactionId,
|
|
32
43
|
kind: operation.kind,
|
|
33
|
-
originalSegments:
|
|
44
|
+
originalSegments: backups.map((item) => basename(item.path)),
|
|
34
45
|
stagedSegments: staged.files.map((item) => ({
|
|
35
46
|
path: basename(item.path),
|
|
36
47
|
temporary: basename(item.temporaryPath)
|
|
@@ -6,8 +6,13 @@ interface StagedSessionSegments {
|
|
|
6
6
|
readonly temporaryPath: string;
|
|
7
7
|
}[];
|
|
8
8
|
readonly segments: NonNullable<SessionMeta['segments']>;
|
|
9
|
+
readonly reused?: ReadonlySet<string>;
|
|
9
10
|
}
|
|
10
11
|
export declare function stageSessionSegments(sessionDirectory: string, records: readonly SessionRecord[], operation?: 'rollback' | 'fork'): Promise<StagedSessionSegments>;
|
|
12
|
+
/** Reuse intact files in ordered v2 histories; journal only replacements and removals. */
|
|
13
|
+
export declare function stageRollbackSegments(directory: string, prefix: readonly SessionRecord[], originals: NonNullable<SessionMeta['segments']>): Promise<StagedSessionSegments & {
|
|
14
|
+
readonly reused: ReadonlySet<string>;
|
|
15
|
+
}>;
|
|
11
16
|
export declare function stageUnreferencedImages(sessionDirectory: string, directoryName: 'generated_images' | 'tool_results' | 'resources', retained: ReadonlySet<string>, transactionId: string): Promise<Array<{
|
|
12
17
|
from: string;
|
|
13
18
|
to: string;
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { AgentError } from '../../../error.js';
|
|
5
5
|
import { parseInlineMediaState } from '../../inline-media.js';
|
|
6
6
|
import { MAX_SEGMENT_BYTES } from './records.js';
|
|
7
|
+
import { forEachTransactionFile } from './transaction-files.js';
|
|
7
8
|
export async function renameIfExists(from, to) {
|
|
8
9
|
try {
|
|
9
10
|
await rename(from, to);
|
|
@@ -26,23 +27,9 @@ export async function stageSessionSegments(sessionDirectory, records, operation
|
|
|
26
27
|
if (group === undefined)
|
|
27
28
|
return;
|
|
28
29
|
const number = segments.length + 1;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
files.push({ path, temporaryPath });
|
|
33
|
-
try {
|
|
34
|
-
await handle.writeFile(group.lines.join(''));
|
|
35
|
-
await handle.sync();
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
await handle.close();
|
|
39
|
-
}
|
|
40
|
-
segments.push({
|
|
41
|
-
number,
|
|
42
|
-
firstSequence: group.firstSequence,
|
|
43
|
-
lastSequence: group.lastSequence,
|
|
44
|
-
bytes: group.bytes
|
|
45
|
-
});
|
|
30
|
+
const staged = await writeStagedSegment(sessionDirectory, group, number, transactionId, operation);
|
|
31
|
+
files.push(staged.file);
|
|
32
|
+
segments.push(staged.segment);
|
|
46
33
|
group = undefined;
|
|
47
34
|
};
|
|
48
35
|
try {
|
|
@@ -67,10 +54,86 @@ export async function stageSessionSegments(sessionDirectory, records, operation
|
|
|
67
54
|
return { files, segments };
|
|
68
55
|
}
|
|
69
56
|
catch (error) {
|
|
70
|
-
await
|
|
57
|
+
await forEachTransactionFile(files, (file) => rm(file.temporaryPath, { force: true }).catch(() => undefined));
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Reuse intact files in ordered v2 histories; journal only replacements and removals. */
|
|
62
|
+
export async function stageRollbackSegments(directory, prefix, originals) {
|
|
63
|
+
const files = [];
|
|
64
|
+
const segments = [];
|
|
65
|
+
const reused = new Set();
|
|
66
|
+
const transactionId = randomUUID();
|
|
67
|
+
let offset = 0;
|
|
68
|
+
try {
|
|
69
|
+
for (const original of originals) {
|
|
70
|
+
const start = offset;
|
|
71
|
+
while (offset < prefix.length && prefix[offset].sequence <= original.lastSequence)
|
|
72
|
+
offset++;
|
|
73
|
+
const source = prefix.slice(start, offset);
|
|
74
|
+
if (source.length === 0)
|
|
75
|
+
break;
|
|
76
|
+
const retained = source.filter((record) => record.type !== 'responses.continuation');
|
|
77
|
+
if (retained.length === source.length && source.at(-1).sequence === original.lastSequence) {
|
|
78
|
+
reused.add(`events-${String(original.number).padStart(6, '0')}.jsonl`);
|
|
79
|
+
segments.push({ ...original });
|
|
80
|
+
}
|
|
81
|
+
else if (retained.length > 0) {
|
|
82
|
+
const lines = retained.map((record) => `${JSON.stringify(record)}\n`);
|
|
83
|
+
const staged = await writeStagedSegment(directory, {
|
|
84
|
+
lines,
|
|
85
|
+
bytes: lines.reduce((total, line) => total + Buffer.byteLength(line), 0),
|
|
86
|
+
firstSequence: retained[0].sequence,
|
|
87
|
+
lastSequence: retained.at(-1).sequence
|
|
88
|
+
}, original.number, transactionId, 'rollback');
|
|
89
|
+
files.push(staged.file);
|
|
90
|
+
segments.push(staged.segment);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { files, segments, reused };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
await forEachTransactionFile(files, (file) => rm(file.temporaryPath, { force: true }).catch(() => undefined));
|
|
71
97
|
throw error;
|
|
72
98
|
}
|
|
73
99
|
}
|
|
100
|
+
async function writeStagedSegment(directory, group, number, transactionId, operation) {
|
|
101
|
+
const path = join(directory, `events-${String(number).padStart(6, '0')}.jsonl`);
|
|
102
|
+
const temporaryPath = `${path}.${transactionId}.${operation}-tmp`;
|
|
103
|
+
const handle = await open(temporaryPath, 'wx', 0o600);
|
|
104
|
+
let failed = false;
|
|
105
|
+
let failure;
|
|
106
|
+
try {
|
|
107
|
+
await handle.writeFile(group.lines.join(''));
|
|
108
|
+
await handle.sync();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
failed = true;
|
|
112
|
+
failure = error;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await handle.close();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!failed) {
|
|
119
|
+
failed = true;
|
|
120
|
+
failure = error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (failed) {
|
|
124
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
125
|
+
throw failure;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
file: { path, temporaryPath },
|
|
129
|
+
segment: {
|
|
130
|
+
number,
|
|
131
|
+
firstSequence: group.firstSequence,
|
|
132
|
+
lastSequence: group.lastSequence,
|
|
133
|
+
bytes: group.bytes
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
74
137
|
export async function stageUnreferencedImages(sessionDirectory, directoryName, retained, transactionId) {
|
|
75
138
|
const directory = join(sessionDirectory, directoryName);
|
|
76
139
|
let names;
|
|
@@ -84,14 +147,12 @@ export async function stageUnreferencedImages(sessionDirectory, directoryName, r
|
|
|
84
147
|
}
|
|
85
148
|
const moved = [];
|
|
86
149
|
try {
|
|
87
|
-
|
|
88
|
-
if (retained.has(name))
|
|
89
|
-
continue;
|
|
150
|
+
await forEachTransactionFile(names.filter((name) => !retained.has(name)), async (name) => {
|
|
90
151
|
const from = join(directory, name);
|
|
91
152
|
const trash = join(directory, `.${name}.${transactionId}.rollback-trash`);
|
|
92
153
|
await rename(from, trash);
|
|
93
154
|
moved.push({ from: trash, to: from, trash });
|
|
94
|
-
}
|
|
155
|
+
});
|
|
95
156
|
return moved;
|
|
96
157
|
}
|
|
97
158
|
catch (error) {
|
|
@@ -100,8 +161,7 @@ export async function stageUnreferencedImages(sessionDirectory, directoryName, r
|
|
|
100
161
|
}
|
|
101
162
|
}
|
|
102
163
|
export async function restoreRenamedFiles(files) {
|
|
103
|
-
|
|
104
|
-
await rename(file.from, file.to).catch(() => undefined);
|
|
164
|
+
await forEachTransactionFile(files, (file) => rename(file.from, file.to).catch(() => undefined));
|
|
105
165
|
}
|
|
106
166
|
export function retainedSessionTitle(records, fallback) {
|
|
107
167
|
let title = fallback;
|
|
@@ -698,14 +698,28 @@ export class JsonlSessionStore {
|
|
|
698
698
|
const meta = this.#shared.index.sessions.find((item) => item.id === sessionId);
|
|
699
699
|
if (!meta)
|
|
700
700
|
throw new AgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
|
|
701
|
-
const
|
|
701
|
+
const indexedSegments = meta.segments;
|
|
702
|
+
let segmentIndex = 0;
|
|
703
|
+
let matchingSegments = indexedSegments !== undefined;
|
|
704
|
+
// Check optional cache bounds during the history read already required by rollback.
|
|
705
|
+
const records = await this.#serializeRecordOperation(sessionId, () => this.#readRecords(sessionId, undefined, (path, segment) => {
|
|
706
|
+
const indexed = indexedSegments?.[segmentIndex];
|
|
707
|
+
segmentIndex++;
|
|
708
|
+
matchingSegments &&=
|
|
709
|
+
indexed !== undefined &&
|
|
710
|
+
path === this.#segmentPath(sessionId, indexed.number, meta) &&
|
|
711
|
+
segment[0]?.sequence === indexed.firstSequence &&
|
|
712
|
+
segment.at(-1)?.sequence === indexed.lastSequence;
|
|
713
|
+
}));
|
|
702
714
|
if (records[0]?.type !== 'session.created')
|
|
703
715
|
throw new AgentError('MAR_AGENT_SESSION_CORRUPT', 'Session history is corrupt.');
|
|
704
716
|
const boundaryIndex = rollbackBoundaryIndex(records, input);
|
|
705
|
-
const retained = records
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
717
|
+
const retained = records.slice(0, boundaryIndex + 1);
|
|
718
|
+
// Migrated v1 histories may preserve duplicate or non-increasing sequences.
|
|
719
|
+
const reuseSegments = matchingSegments &&
|
|
720
|
+
segmentIndex === indexedSegments?.length &&
|
|
721
|
+
records.every((record, index) => index === 0 || record.sequence > records[index - 1].sequence);
|
|
722
|
+
return await this.#replaceHistory(meta, retained, { kind: 'rollback', reuseSegments });
|
|
709
723
|
}
|
|
710
724
|
catch (error) {
|
|
711
725
|
failed = true;
|
|
@@ -1110,7 +1124,7 @@ export class JsonlSessionStore {
|
|
|
1110
1124
|
async read(sessionId) {
|
|
1111
1125
|
return this.#serializeRecordOperation(sessionId, () => this.#readRecords(sessionId));
|
|
1112
1126
|
}
|
|
1113
|
-
async #readRecords(sessionId, selectedMeta) {
|
|
1127
|
+
async #readRecords(sessionId, selectedMeta, onSegment) {
|
|
1114
1128
|
return withSessionReadPin(this.#sessionRoot(sessionId), async () => {
|
|
1115
1129
|
this.#assertOpen();
|
|
1116
1130
|
if (!selectedMeta)
|
|
@@ -1125,8 +1139,11 @@ export class JsonlSessionStore {
|
|
|
1125
1139
|
const records = [];
|
|
1126
1140
|
const meta = selectedMeta ?? this.#shared.index.sessions.find((item) => item.id === sessionId);
|
|
1127
1141
|
const encoding = meta?.storage?.encoding ?? 1;
|
|
1128
|
-
for (const path of await this.#segmentPaths(sessionId, meta))
|
|
1129
|
-
|
|
1142
|
+
for (const path of await this.#segmentPaths(sessionId, meta)) {
|
|
1143
|
+
const segment = await readRecords(path, encoding);
|
|
1144
|
+
onSegment?.(path, segment);
|
|
1145
|
+
records.push(...segment);
|
|
1146
|
+
}
|
|
1130
1147
|
return records;
|
|
1131
1148
|
}
|
|
1132
1149
|
catch (cause) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Stop scheduling on the first failure, then drain all in-flight filesystem operations. */
|
|
2
|
+
export async function forEachTransactionFile(files, operation) {
|
|
3
|
+
let next = 0;
|
|
4
|
+
let failed = false;
|
|
5
|
+
let failure;
|
|
6
|
+
await Promise.all(Array.from({ length: Math.min(8, files.length) }, async () => {
|
|
7
|
+
while (!failed && next < files.length) {
|
|
8
|
+
const file = files[next++];
|
|
9
|
+
try {
|
|
10
|
+
await operation(file);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (!failed) {
|
|
14
|
+
failed = true;
|
|
15
|
+
failure = error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
if (failed)
|
|
21
|
+
throw failure;
|
|
22
|
+
}
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { AgentError } from '../../../error.js';
|
|
5
|
+
import { forEachTransactionFile } from './transaction-files.js';
|
|
5
6
|
const JOURNAL = 'storage-transaction.json';
|
|
6
7
|
const SEGMENT = /^events-\d{6}\.jsonl$/u;
|
|
7
8
|
const TRANSACTION_ID = /^[0-9a-f-]{36}$/iu;
|
|
@@ -291,24 +292,24 @@ export async function recoverSessionStorageTransaction(directory, committedTrans
|
|
|
291
292
|
? directory
|
|
292
293
|
: join(sessionDirectory, 'v2', transaction.targetGeneration);
|
|
293
294
|
const originals = new Set(transaction.originalSegments);
|
|
294
|
-
|
|
295
|
+
await forEachTransactionFile([...originals], async (name) => {
|
|
295
296
|
const canonical = join(directory, name);
|
|
296
297
|
const backup = `${canonical}.${transaction.id}.${transaction.kind}-backup`;
|
|
297
|
-
if (!(await exists(backup)))
|
|
298
|
-
continue;
|
|
299
298
|
if (committed)
|
|
300
299
|
await rm(backup, { force: true });
|
|
301
300
|
else {
|
|
301
|
+
if (!(await exists(backup)))
|
|
302
|
+
return;
|
|
302
303
|
await rm(canonical, { force: true });
|
|
303
304
|
await rename(backup, canonical);
|
|
304
305
|
}
|
|
305
|
-
}
|
|
306
|
+
});
|
|
306
307
|
if (!committed)
|
|
307
|
-
|
|
308
|
+
await forEachTransactionFile(transaction.stagedSegments, async (entry) => {
|
|
308
309
|
if (stagedDirectory !== directory || !originals.has(entry.path))
|
|
309
310
|
await rm(join(stagedDirectory, entry.path), { force: true });
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
});
|
|
312
|
+
await forEachTransactionFile(transaction.stagedSegments, (entry) => rm(join(stagedDirectory, entry.temporary), { force: true }));
|
|
312
313
|
if (!committed && transaction.targetGeneration !== undefined)
|
|
313
314
|
await rm(stagedDirectory, { recursive: true, force: true });
|
|
314
315
|
const sidecar = join(directory, 'context-world-state.json');
|
|
@@ -333,15 +334,13 @@ export async function recoverSessionStorageTransaction(directory, committedTrans
|
|
|
333
334
|
throw error;
|
|
334
335
|
}
|
|
335
336
|
const suffix = `.${transaction.id}.rollback-trash`;
|
|
336
|
-
|
|
337
|
-
if (!name.startsWith('.') || !name.endsWith(suffix))
|
|
338
|
-
continue;
|
|
337
|
+
await forEachTransactionFile(names.filter((name) => name.startsWith('.') && name.endsWith(suffix)), async (name) => {
|
|
339
338
|
const trash = join(path, name);
|
|
340
339
|
if (committed)
|
|
341
340
|
await rm(trash, { force: true });
|
|
342
341
|
else
|
|
343
342
|
await rename(trash, join(path, name.slice(1, -suffix.length)));
|
|
344
|
-
}
|
|
343
|
+
});
|
|
345
344
|
}
|
|
346
345
|
for (const path of transaction.retiredPaths ?? []) {
|
|
347
346
|
const source = join(sessionDirectory, path);
|
|
@@ -357,9 +356,7 @@ export async function recoverSessionStorageTransaction(directory, committedTrans
|
|
|
357
356
|
await removeUnjournaledTransactionFiles(directory);
|
|
358
357
|
}
|
|
359
358
|
async function removeUnjournaledTransactionFiles(directory) {
|
|
360
|
-
|
|
361
|
-
if (isUnjournaledTransactionFile(name))
|
|
362
|
-
await rm(join(directory, name), { force: true });
|
|
359
|
+
await forEachTransactionFile((await readdir(directory)).filter(isUnjournaledTransactionFile), (name) => rm(join(directory, name), { force: true }));
|
|
363
360
|
}
|
|
364
361
|
async function exists(path) {
|
|
365
362
|
try {
|
|
@@ -20,7 +20,16 @@ export declare function recordToolResources(record: Pick<SessionRecord, 'type' |
|
|
|
20
20
|
/** A locator is only a hint: reads must verify its exact committed record identity. */
|
|
21
21
|
export declare function bindToolResultResources(directory: string, record: SessionRecord, readRecord?: (sequence: number) => Promise<SessionRecord | undefined>, degrade?: boolean): Promise<void>;
|
|
22
22
|
export declare function toolResultResourceFiles(records: readonly SessionRecord[]): Set<string>;
|
|
23
|
-
export
|
|
23
|
+
export interface ToolResourceLocator {
|
|
24
|
+
sequence: number;
|
|
25
|
+
recordId: string;
|
|
26
|
+
bodySequences?: number[];
|
|
27
|
+
}
|
|
28
|
+
export declare function prepareToolResultResources(source: string, records: readonly SessionRecord[]): Promise<Map<string, {
|
|
29
|
+
owner: string;
|
|
30
|
+
bytes: Buffer;
|
|
31
|
+
}>>;
|
|
32
|
+
export declare function writeToolResultResourceIndex(target: string, locators: ReadonlyMap<string, ToolResourceLocator>): Promise<void>;
|
|
24
33
|
export declare function completeToolResultResources(directory: string, record: SessionRecord): Promise<void>;
|
|
25
34
|
export declare function cleanupUnreferencedToolResources(directory: string, readRecords: () => Promise<readonly SessionRecord[]>): Promise<void>;
|
|
26
35
|
/** Caller must hold a stable history and resource generation for the entire read. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import { AgentError } from '../error.js';
|
|
5
5
|
import { parseToolResultExtensions } from '../tools/result-extensions.js';
|
|
6
6
|
const REF = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u;
|
|
@@ -73,13 +73,8 @@ export async function bindToolResultResources(directory, record, readRecord, deg
|
|
|
73
73
|
async function bindToolResultResource(directory, record, resource, readRecord) {
|
|
74
74
|
const path = join(directory, 'tool_results', `${resource.resourceRef}.json`);
|
|
75
75
|
try {
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
catch (error) {
|
|
79
|
-
if (error.code !== 'EEXIST')
|
|
80
|
-
throw error;
|
|
81
|
-
try {
|
|
82
|
-
const locator = JSON.parse(await readFile(path, 'utf8'));
|
|
76
|
+
const locator = await readToolResourceLocator(path, resource.resourceRef);
|
|
77
|
+
if (locator !== undefined) {
|
|
83
78
|
const existing = locator.sequence === record.sequence && locator.recordId === record.recordId
|
|
84
79
|
? record
|
|
85
80
|
: Number.isSafeInteger(locator.sequence)
|
|
@@ -89,46 +84,103 @@ async function bindToolResultResource(directory, record, resource, readRecord) {
|
|
|
89
84
|
existing.recordId !== locator.recordId ||
|
|
90
85
|
!recordToolResources(existing).some((entry) => entry.resourceRef === resource.resourceRef && entry.sha256 === resource.sha256))
|
|
91
86
|
throw resourceError('CORRUPT', 'Tool resource locator conflicts with committed history.');
|
|
87
|
+
return;
|
|
92
88
|
}
|
|
93
|
-
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
if (cause.code !== 'ENOENT')
|
|
94
92
|
throw normalizedResourceError(cause);
|
|
95
|
-
}
|
|
96
93
|
}
|
|
94
|
+
await writeFile(path, JSON.stringify({ sequence: record.sequence, recordId: record.recordId }), {
|
|
95
|
+
flag: 'wx',
|
|
96
|
+
mode: 0o600,
|
|
97
|
+
flush: true
|
|
98
|
+
});
|
|
97
99
|
}
|
|
98
100
|
export function toolResultResourceFiles(records) {
|
|
99
|
-
|
|
101
|
+
const files = new Set(records.flatMap((record) => recordToolResources(record).flatMap((resource) => [
|
|
100
102
|
`${resource.resourceRef}.diff`,
|
|
101
103
|
`${resource.resourceRef}.json`
|
|
102
104
|
])));
|
|
105
|
+
if (files.size > 0)
|
|
106
|
+
files.add('index.json');
|
|
107
|
+
return files;
|
|
103
108
|
}
|
|
104
|
-
export async function
|
|
105
|
-
const
|
|
109
|
+
export async function prepareToolResultResources(source, records) {
|
|
110
|
+
const resources = new Map();
|
|
106
111
|
for (const record of records) {
|
|
107
112
|
for (const resource of recordToolResources(record)) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
const previous = resources.get(resource.resourceRef);
|
|
114
|
+
if (previous && previous.resource.sha256 !== resource.sha256)
|
|
115
|
+
throw resourceError('CORRUPT', 'Tool resource locator conflicts with committed history.');
|
|
116
|
+
if (!previous)
|
|
117
|
+
resources.set(resource.resourceRef, { record, resource });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const bodies = new Map();
|
|
121
|
+
if (resources.size === 0)
|
|
122
|
+
return bodies;
|
|
123
|
+
const bySequence = new Map(records.map((record) => [record.sequence, record]));
|
|
124
|
+
const pending = resources.values();
|
|
125
|
+
let failure;
|
|
126
|
+
const read = async () => {
|
|
127
|
+
while (!failure) {
|
|
128
|
+
const next = pending.next();
|
|
129
|
+
if (next.done)
|
|
130
|
+
return;
|
|
131
|
+
const { record, resource } = next.value;
|
|
111
132
|
try {
|
|
112
|
-
const bytes = await readResourceBytes(source, resource);
|
|
113
|
-
|
|
114
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
115
|
-
await writeFile(join(directory, `${resource.resourceRef}.diff`), bytes, {
|
|
116
|
-
flag: 'wx',
|
|
117
|
-
mode: 0o600,
|
|
118
|
-
flush: true
|
|
119
|
-
});
|
|
133
|
+
const bytes = await readResourceBytes(source, resource, async (sequence) => bySequence.get(sequence));
|
|
134
|
+
bodies.set(resource.resourceRef, { owner: record.recordId, bytes });
|
|
120
135
|
}
|
|
121
136
|
catch (error) {
|
|
122
|
-
if (error instanceof AgentError && error.code === 'MAR_AGENT_TOOL_RESOURCE_NOT_FOUND')
|
|
123
|
-
|
|
124
|
-
throw error;
|
|
137
|
+
if (!(error instanceof AgentError && error.code === 'MAR_AGENT_TOOL_RESOURCE_NOT_FOUND'))
|
|
138
|
+
failure ??= { error };
|
|
125
139
|
}
|
|
126
140
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
};
|
|
142
|
+
await Promise.all(Array.from({ length: Math.min(8, resources.size) }, read));
|
|
143
|
+
if (failure)
|
|
144
|
+
throw failure.error;
|
|
145
|
+
return bodies;
|
|
146
|
+
}
|
|
147
|
+
export async function writeToolResultResourceIndex(target, locators) {
|
|
148
|
+
if (locators.size === 0)
|
|
149
|
+
return;
|
|
150
|
+
const directory = join(target, 'tool_results');
|
|
151
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
152
|
+
await writeFile(join(directory, 'index.json'), JSON.stringify({ version: 1, locators: Object.fromEntries(locators) }), {
|
|
153
|
+
flag: 'wx',
|
|
154
|
+
mode: 0o600,
|
|
155
|
+
flush: true
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function readToolResourceLocator(path, resourceRef) {
|
|
159
|
+
let text;
|
|
160
|
+
try {
|
|
161
|
+
text = await readFile(path, 'utf8');
|
|
131
162
|
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code !== 'ENOENT')
|
|
165
|
+
throw error;
|
|
166
|
+
text = await readFile(join(dirname(path), 'index.json'), 'utf8');
|
|
167
|
+
}
|
|
168
|
+
const value = JSON.parse(text);
|
|
169
|
+
const locator = value?.version === 1 ? value.locators?.[resourceRef] : value;
|
|
170
|
+
if (value?.version === 1 && locator === undefined)
|
|
171
|
+
return undefined;
|
|
172
|
+
if (!Number.isSafeInteger(locator?.sequence) ||
|
|
173
|
+
locator.sequence < 1 ||
|
|
174
|
+
typeof locator.recordId !== 'string')
|
|
175
|
+
throw resourceError('CORRUPT', 'Invalid tool resource locator.');
|
|
176
|
+
if (locator.bodySequences !== undefined &&
|
|
177
|
+
(!Array.isArray(locator.bodySequences) ||
|
|
178
|
+
locator.bodySequences.length === 0 ||
|
|
179
|
+
!locator.bodySequences.every((sequence) => Number.isSafeInteger(sequence) &&
|
|
180
|
+
sequence > 0 &&
|
|
181
|
+
sequence < locator.sequence)))
|
|
182
|
+
throw resourceError('CORRUPT', 'Invalid tool resource body locator.');
|
|
183
|
+
return locator;
|
|
132
184
|
}
|
|
133
185
|
export async function completeToolResultResources(directory, record) {
|
|
134
186
|
await Promise.all(recordToolResources(record).map((resource) => rm(join(directory, 'tool_results', `.${resource.resourceRef}.pending`), {
|
|
@@ -177,22 +229,20 @@ export async function readToolResultResource(directory, sessionId, input, readRe
|
|
|
177
229
|
let locator;
|
|
178
230
|
try {
|
|
179
231
|
const file = join(directory, 'tool_results', `${input.resourceRef}.json`);
|
|
180
|
-
locator =
|
|
232
|
+
locator = await readToolResourceLocator(file, input.resourceRef);
|
|
181
233
|
}
|
|
182
234
|
catch (error) {
|
|
183
235
|
throw normalizedResourceError(error);
|
|
184
236
|
}
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
typeof locator.recordId !== 'string')
|
|
188
|
-
throw resourceError('CORRUPT', 'Invalid tool resource locator.');
|
|
237
|
+
if (locator === undefined)
|
|
238
|
+
throw resourceError('NOT_FOUND', 'Tool resource is unavailable.');
|
|
189
239
|
const record = await readRecord(locator.sequence);
|
|
190
240
|
const resource = record?.recordId === locator.recordId
|
|
191
241
|
? recordToolResources(record).find((entry) => entry.resourceRef === input.resourceRef)
|
|
192
242
|
: undefined;
|
|
193
243
|
if (!resource)
|
|
194
244
|
throw resourceError('NOT_FOUND', 'Tool resource is not part of this session history.');
|
|
195
|
-
const bytes = await readResourceBytes(directory, resource);
|
|
245
|
+
const bytes = await readResourceBytes(directory, resource, readRecord, locator);
|
|
196
246
|
let offset = 0;
|
|
197
247
|
if (input.cursor !== undefined) {
|
|
198
248
|
try {
|
|
@@ -237,10 +287,31 @@ export async function readToolResultResource(directory, sessionId, input, readRe
|
|
|
237
287
|
: {})
|
|
238
288
|
};
|
|
239
289
|
}
|
|
240
|
-
async function readResourceBytes(directory, resource) {
|
|
290
|
+
async function readResourceBytes(directory, resource, readRecord, knownLocator) {
|
|
241
291
|
try {
|
|
242
292
|
return await readFile(join(directory, 'tool_results', `${resource.resourceRef}.diff`));
|
|
243
293
|
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error.code !== 'ENOENT')
|
|
296
|
+
throw normalizedResourceError(error);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const locator = knownLocator ??
|
|
300
|
+
(await readToolResourceLocator(join(directory, 'tool_results', `${resource.resourceRef}.json`), resource.resourceRef));
|
|
301
|
+
if (!locator?.bodySequences || !readRecord)
|
|
302
|
+
throw resourceError('NOT_FOUND', 'Tool resource is unavailable.');
|
|
303
|
+
const chunks = [];
|
|
304
|
+
for (const sequence of locator.bodySequences) {
|
|
305
|
+
const record = await readRecord(sequence);
|
|
306
|
+
const payload = record?.payload;
|
|
307
|
+
if (record?.type !== 'tool.resource' ||
|
|
308
|
+
payload?.resourceRef !== resource.resourceRef ||
|
|
309
|
+
typeof payload.dataBase64 !== 'string')
|
|
310
|
+
throw resourceError('CORRUPT', 'Invalid tool resource body.');
|
|
311
|
+
chunks.push(Buffer.from(payload.dataBase64, 'base64'));
|
|
312
|
+
}
|
|
313
|
+
return Buffer.concat(chunks);
|
|
314
|
+
}
|
|
244
315
|
catch (error) {
|
|
245
316
|
throw normalizedResourceError(error);
|
|
246
317
|
}
|