@myagentroam/agent 0.11.55 → 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.
@@ -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 (candidate.observedRollouts >= CONTEXT_GC_POLICY.largeExecProtectionRollouts &&
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.rollouts >= 6 && maturity.thinking >= 3;
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.rollouts >= 6 && maturity.thinking >= 3;
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 (observedRollouts >= CONTEXT_GC_POLICY.largeExecProtectionRollouts &&
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.rollouts >= CONTEXT_GC_POLICY.largeExecProtectionRollouts &&
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.rollouts >= CONTEXT_GC_POLICY.largeExecProtectionRollouts &&
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.rollouts >= 6 && count.thinking >= 3;
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 (observedRollouts >= CONTEXT_GC_POLICY.largeExecProtectionRollouts &&
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.rollouts >= 6 && count.thinking >= 3;
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)).rollouts >= 6 &&
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.rollouts >= 6 && count.thinking >= 3;
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 (observedRollouts >= CONTEXT_GC_POLICY.largeExecProtectionRollouts &&
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,6 +1,19 @@
1
1
  import type { ModelUsage } from '../../model/contracts.js';
2
- import { type ContextGcEconomicsPolicy } from '../../session/context-gc-checkpoint.js';
3
- export declare const CONTEXT_GC_POLICY: ContextGcEconomicsPolicy;
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
18
  export declare const COMPACT_THRESHOLD_RATIO: number;
6
19
  interface ContextGcEconomicsInput {
@@ -1,13 +1,15 @@
1
1
  import { AGENT_EXECUTION_POLICY } from '../execution-policy.js';
2
+ import { CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS } from '../../session/context-gc-protection.js';
2
3
  export const CONTEXT_GC_POLICY = {
3
4
  version: 1,
4
5
  immediatePaybackRollouts: 3,
5
- maximumPaybackRollouts: 20,
6
+ minimumPaybackRollouts: 30,
7
+ maximumPaybackRollouts: 60,
6
8
  contextGrowthSampleWindow: 16,
7
9
  minimumContextGrowthSamples: 6,
8
10
  minimumObservationRollouts: 1,
9
- stageAProtectionRollouts: 6,
10
- largeExecProtectionRollouts: 6,
11
+ stageAProtectionRollouts: CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS,
12
+ largeExecProtectionRollouts: CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS,
11
13
  normalInputCostRate: 1,
12
14
  cacheReadCostRate: 0.05,
13
15
  outputCostRate: 1,
@@ -34,16 +36,13 @@ export function evaluateContextGcEconomics(input) {
34
36
  const reclaimedUncachedTokens = Math.max(0, reclaimedTokens - reclaimedCachedTokens);
35
37
  const firstRolloutSavings = reclaimedCachedTokens + reclaimedUncachedTokens * unitsPerNormalInput;
36
38
  const steadyRolloutSavings = reclaimedTokens;
37
- const netAt = (rollouts) => (rollouts <= 0
39
+ const netAt = (rollouts) => rollouts <= 0
38
40
  ? -cacheRebuildPenalty
39
41
  : firstRolloutSavings +
40
42
  Math.max(0, rollouts - 1) * steadyRolloutSavings -
41
- cacheRebuildPenalty) +
42
- input.compactDeferralCreditCostUnits / CONTEXT_GC_POLICY.cacheReadCostRate;
43
+ cacheRebuildPenalty;
43
44
  let requiredPositiveNetRollouts;
44
- if (netAt(0) > 0)
45
- requiredPositiveNetRollouts = 0;
46
- else if (netAt(1) > 0)
45
+ if (netAt(1) > 0)
47
46
  requiredPositiveNetRollouts = 1;
48
47
  else if (steadyRolloutSavings <= 0)
49
48
  requiredPositiveNetRollouts = null;
@@ -60,19 +59,15 @@ export function evaluateContextGcEconomics(input) {
60
59
  const minimumReclaimedTokens = Math.max(0, input.minimumReclaimedTokens ??
61
60
  Math.ceil(Math.max(0, input.contextUsageTokens) * minimumGrossReductionRate));
62
61
  const usageRatio = input.contextUsageTokens / input.contextWindowTokens;
63
- const thresholdPercent = AGENT_EXECUTION_POLICY.backgroundCompactionPercent;
64
- const activationPercent = COMPACT_CREDIT_ACTIVATION_RATIO * 100;
65
- const withinWindow = requiredPositiveNetRollouts !== null &&
66
- (usageRatio <= COMPACT_CREDIT_ACTIVATION_RATIO
67
- ? requiredPositiveNetRollouts <= CONTEXT_GC_POLICY.maximumPaybackRollouts
68
- : requiredPositiveNetRollouts *
69
- (thresholdPercent * input.contextWindowTokens - 100 * input.contextUsageTokens) <=
70
- CONTEXT_GC_POLICY.maximumPaybackRollouts *
71
- (thresholdPercent - activationPercent) *
72
- 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;
73
67
  const passes = reclaimedTokens > 0 &&
74
68
  reclaimedTokens >= minimumReclaimedTokens &&
75
69
  Number.isFinite(usageRatio) &&
70
+ usageRatio >= CONTEXT_GC_START_RATIO &&
76
71
  usageRatio < COMPACT_THRESHOLD_RATIO &&
77
72
  withinWindow;
78
73
  return {
@@ -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, contextGcAppliedCompactCreditCostUnits, modelContextUsage, clamp } from './gc-economics.js';
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';
@@ -262,7 +262,8 @@ function evaluateCandidateSet(input, existing, candidates, state, mediaProjectio
262
262
  projectedTokensAfterGc,
263
263
  contextGrowthP75Tokens
264
264
  });
265
- const appliedCompactCreditCostUnits = contextGcAppliedCompactCreditCostUnits(compactDeferralCreditCostUnits, imminentCompactAvoidanceCreditCostUnits);
265
+ // Compact estimates remain diagnostic; only normal-request savings repay GC.
266
+ const appliedCompactCreditCostUnits = 0;
266
267
  const economics = evaluateContextGcEconomics({
267
268
  contextUsageTokens: state.contextUsageTokens,
268
269
  contextWindowTokens: input.contextWindowTokens,
@@ -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.rollouts < 6 || count.thinking < 3;
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.rollouts >= 6 && count.thinking >= 3;
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.rollouts >= 6 && count.thinking >= 3;
416
+ return contextGcProtectionMature(count);
417
417
  },
418
418
  selected: (callId, toolName, summary) => {
419
419
  const argument = replacement.replacementArguments;
@@ -1110,6 +1110,8 @@ 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',
@@ -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;
@@ -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 six successful rollouts. The paired output, images, completion evidence, and nested tool side effects remain in history.',
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 six successful rollouts. An incomplete preview is not the complete cell output. The source was not replayed and nested tool side effects were not changed.'
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 six successful rollouts. An incomplete preview is not the complete search result or page, and GC did not access the network.'
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 six successful rollouts. An incomplete preview is not the complete result; the tool was not replayed.'
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 { copyToolResultResources } from '../../tool-result-resources.js';
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' || record.type === 'responses.continuation')
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 copyToolResultResources(sourceDirectory, targetDirectory, copied);
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, retained: readonly SessionRecord[], operation: {
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, retained, operation, context) {
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 staged = await stageSessionSegments(targetDirectory, retained);
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.map((path) => ({
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: originalPaths.map((path) => basename(path)),
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 path = join(sessionDirectory, `events-${String(number).padStart(6, '0')}.jsonl`);
30
- const temporaryPath = `${path}.${transactionId}.${operation}-tmp`;
31
- const handle = await open(temporaryPath, 'wx', 0o600);
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 Promise.all(files.map((item) => rm(item.temporaryPath, { force: true }))).catch(() => undefined);
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
- for (const name of names) {
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
- for (const file of files.toReversed())
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 records = await this.read(sessionId);
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
- .slice(0, boundaryIndex + 1)
707
- .filter((record) => record.type !== 'responses.continuation');
708
- return await this.#replaceHistory(meta, retained, { kind: 'rollback' });
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
- records.push(...(await readRecords(path, encoding)));
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,2 @@
1
+ /** Stop scheduling on the first failure, then drain all in-flight filesystem operations. */
2
+ export declare function forEachTransactionFile<T>(files: readonly T[], operation: (file: T) => Promise<unknown>): Promise<void>;
@@ -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
- for (const name of transaction.originalSegments) {
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
- for (const entry of transaction.stagedSegments)
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
- for (const entry of transaction.stagedSegments)
311
- await rm(join(stagedDirectory, entry.temporary), { force: true });
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
- for (const name of names) {
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
- for (const name of await readdir(directory))
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 declare function copyToolResultResources(source: string, target: string, records: readonly SessionRecord[]): Promise<void>;
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 writeFile(path, JSON.stringify({ sequence: record.sequence, recordId: record.recordId }), { flag: 'wx', mode: 0o600, flush: true });
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
- catch (cause) {
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
- return new Set(records.flatMap((record) => recordToolResources(record).flatMap((resource) => [
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 copyToolResultResources(source, target, records) {
105
- const copied = new Set();
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
- if (copied.has(resource.resourceRef))
109
- continue;
110
- copied.add(resource.resourceRef);
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
- const directory = join(target, 'tool_results');
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
- continue;
124
- throw error;
137
+ if (!(error instanceof AgentError && error.code === 'MAR_AGENT_TOOL_RESOURCE_NOT_FOUND'))
138
+ failure ??= { error };
125
139
  }
126
140
  }
127
- if (recordToolResources(record).length) {
128
- await mkdir(join(target, 'tool_results'), { recursive: true, mode: 0o700 });
129
- await bindToolResultResources(target, record, async (sequence) => records.find((item) => item.sequence === sequence));
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 = JSON.parse(await readFile(file, 'utf8'));
232
+ locator = await readToolResourceLocator(file, input.resourceRef);
181
233
  }
182
234
  catch (error) {
183
235
  throw normalizedResourceError(error);
184
236
  }
185
- if (!Number.isSafeInteger(locator.sequence) ||
186
- locator.sequence < 1 ||
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/agent",
3
- "version": "0.11.55",
3
+ "version": "0.11.56",
4
4
  "description": "Embeddable MAR coding agent SDK and CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",