diff --git a/docs/design/2026-07-11-managed-memory-microcompaction.md b/docs/design/2026-07-11-managed-memory-microcompaction.md new file mode 100644 index 00000000000..dc973d63d86 --- /dev/null +++ b/docs/design/2026-07-11-managed-memory-microcompaction.md @@ -0,0 +1,31 @@ +# Managed Memory Microcompaction Preservation + +## Problem + +Managed-memory topic files are loaded lazily with `read_file`. Microcompaction currently treats those results like ordinary tool output and replaces older content with `[Old tool result content cleared]`. The memory index remains available, and recent fixes let a later `read_file` return real bytes again, but the active model is not guaranteed to notice that it must reload the memory. + +Issue #6487 also reports a stale index after `/remember`; PR #6497 already owns that part. This design only addresses managed-memory content removed by microcompaction. + +## Chosen design + +Add a narrow `MicrocompactOptions` callback that identifies `read_file` paths whose successful results must be preserved. Before building idle, forced, or size-based clearing plans, microcompaction correlates each response with its request-side `file_path` and removes protected results from the compactable set. Other tools, ordinary file reads, errors, and responses whose path cannot be resolved retain the current behavior. + +Every production microcompaction entry point supplies the same predicate: + +- pre-send idle and size-based compaction +- `/compress-fast` +- memory-pressure history compaction + +The predicate recognizes project, user, and team managed-memory roots using realpath-aware containment. Symlinks that escape a managed root are not protected. + +## Why this level + +Injecting every loaded memory body into the system instruction would make memory permanently consume context and would replace the existing index-plus-lazy-read design. Reattaching every memory file after full compaction needs a separate token budget and restoration policy. Preserving only managed-memory reads from microcompaction directly fixes the reproduced clearing behavior with a bounded change and leaves full compaction as the existing hard context-reduction boundary. + +Full compaction is therefore intentionally not byte-preserving. Its summary sees the pre-compaction memory content, `MEMORY.md` indexes remain in the system instruction, and the file-read cache is cleared so the model can reload exact bytes. This change guarantees preservation only across microcompaction. + +## Risk and tests + +Repeated reads of managed-memory files can retain multiple copies until full compaction. That is an intentional tradeoff: durable guidance is more important than reclaiming those tool-result tokens, while full compaction remains available as the hard cap. + +Tests cover project, user, and team roots; ordinary reads; symlink escapes; idle, forced, and size-based paths; mixed protected and compactable results; ambiguous or missing response IDs; and eviction metadata. diff --git a/docs/plans/2026-07-11-managed-memory-microcompaction.md b/docs/plans/2026-07-11-managed-memory-microcompaction.md new file mode 100644 index 00000000000..d5339f30341 --- /dev/null +++ b/docs/plans/2026-07-11-managed-memory-microcompaction.md @@ -0,0 +1,68 @@ +# Managed Memory Microcompaction Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Keep successful managed-memory `read_file` results available across every microcompaction trigger without changing ordinary tool-result compaction. + +**Architecture:** A realpath-safe memory-path helper classifies project, user, and team memory. Microcompaction receives a pure preservation predicate, correlates response IDs to request paths, and excludes protected reads before calculating clear plans and metadata. The three production callers pass the same predicate. + +**Tech Stack:** TypeScript, Vitest, Node.js filesystem/path APIs. + +### Task 1: Specify preservation behavior + +**Files:** + +- Modify: `packages/core/src/services/microcompaction/microcompact.test.ts` + +1. Add helpers that build paired `read_file` calls/results with IDs and paths. +2. Add failing tests for idle/force and size-only preservation, ordinary reads, mixed/ambiguous IDs, and eviction metadata. +3. Run `cd packages/core && npx vitest run src/services/microcompaction/microcompact.test.ts` and confirm the new assertions fail because the option is ignored. + +### Task 2: Add safe memory path classification + +**Files:** + +- Modify: `packages/core/src/memory/paths.ts` +- Modify: `packages/core/src/memory/team-paths.test.ts` + +1. Add failing tests for project, user, team, outside, and symlink-escape paths. +2. Add a read/retention-specific helper that resolves the nearest existing real path and checks all three managed roots without changing write-approval semantics. +3. Run the path tests and confirm they pass. + +### Task 3: Exclude protected reads from clear plans + +**Files:** + +- Modify: `packages/core/src/services/microcompaction/microcompact.ts` + +1. Extend `MicrocompactOptions` with the preservation predicate. +2. Correlate `functionResponse.id` to request-side `read_file` paths. +3. Exclude a result only when its path mapping is unambiguous and every candidate path is protected. +4. Apply the filtered tool-reference set before idle/force and size-based planning so token counts and eviction metadata stay accurate. +5. Run the focused microcompaction tests and confirm they pass. + +### Task 4: Wire every production caller + +**Files:** + +- Modify: `packages/core/src/core/client.ts` +- Modify: `packages/core/src/core/geminiChat.ts` +- Modify: `packages/core/src/services/memoryPressureMonitor.ts` +- Test: corresponding focused test files + +1. Resolve relative paths against the configured target directory. +2. Pass the same managed-memory predicate through pre-send idle/size, `/compress-fast`, and memory-pressure compaction. +3. Add or update focused caller tests that verify the option reaches microcompaction. +4. Run all affected focused tests. + +### Task 5: Verify and review + +**Files:** + +- Review all changed files. + +1. Run Prettier on changed files. +2. Run focused tests for microcompaction, path classification, client, GeminiChat, and memory-pressure behavior. +3. Run `npm run build && npm run typecheck` from the worktree root. +4. Run an independent code review, fix important findings, and repeat focused verification. +5. Re-run the original E2E reproduction against `node dist/cli.js` after a fresh bundle. diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index d050d988156..6353ba94886 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -63,6 +63,7 @@ import { CommitAttributionService } from '../services/commitAttribution.js'; // Tools import type { RelevantAutoMemoryPromptResult } from '../memory/manager.js'; import { AUTO_SKILL_THRESHOLD } from '../memory/manager.js'; +import { isManagedMemoryPath } from '../memory/paths.js'; import { DEFAULT_AUTO_SKILL_MAX_TURNS } from '../memory/skillReviewAgentPlanner.js'; import { isProjectSkillPath } from '../skills/skill-paths.js'; import { ToolNames } from '../tools/tool-names.js'; @@ -1759,11 +1760,17 @@ export class GeminiClient { opts?: MicrocompactOptions, ): Promise { try { + const projectRoot = this.config.getProjectRoot(); + const targetDir = this.config.getTargetDir?.() ?? projectRoot; const mcResult = microcompactHistory( this.getHistoryShallow(), lastCompletionTimestamp, this.config.getClearContextOnIdle(), - opts, + { + ...opts, + preserveReadFileResult: (filePath) => + isManagedMemoryPath(filePath, projectRoot, targetDir), + }, ); if (!mcResult.meta) { return false; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index e3230e8f73b..bb77cc271b4 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -47,6 +47,7 @@ import { } from './tokenLimits.js'; import { hasCycleInSchema } from '../tools/tools.js'; import { ToolNames } from '../tools/tool-names.js'; +import { isManagedMemoryPath } from '../memory/paths.js'; import { STRUCTURED_OUTPUT_REDACTED_ARGS } from '../tools/syntheticOutput.js'; import type { StructuredError } from './turn.js'; import { @@ -1769,13 +1770,19 @@ export class GeminiChat { // apples to apples. The API-authoritative lastPromptTokenCount is // then adjusted by the estimated delta — never replaced wholesale. const beforeEstimate = estimateContentTokens(this.history); + const projectRoot = this.config.getProjectRoot(); + const targetDir = this.config.getTargetDir?.() ?? projectRoot; // Step 1: force microcompaction (clear old tool results + media) const mcResult = microcompactHistory( this.history, null, this.config.getClearContextOnIdle(), - { force: true }, + { + force: true, + preserveReadFileResult: (filePath) => + isManagedMemoryPath(filePath, projectRoot, targetDir), + }, ); const mcMeta = mcResult.meta; diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index 37391fb5938..ff7bd8f8c27 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -247,6 +247,33 @@ export function isTeamAutoMemPath( return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); } +/** + * Returns true when the resolved file lives in any managed-memory layer. + * + * Unlike {@link isAnyAutoMemPath}, this helper includes team memory and is + * intended only for read retention. It does not grant write permissions. + * Resolving the nearest existing path prevents a symlink inside a memory root + * from protecting content that actually lives outside that root. + */ +export function isManagedMemoryPath( + filePath: string, + projectRoot: string, + baseDir: string = projectRoot, +): boolean { + const absolutePath = path.resolve(baseDir, filePath); + const resolvedPath = path.normalize(realpathNearestExisting(absolutePath)); + const roots = [ + getAutoMemoryRoot(projectRoot), + getUserAutoMemoryRoot(), + getTeamAutoMemoryRoot(projectRoot), + ]; + return roots.some((root) => { + const resolvedRoot = path.normalize(realpathNearestExisting(root)); + const rel = path.relative(resolvedRoot, resolvedPath); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); + }); +} + /** * Follow a leading symlink chain at `inputPath` to its eventual target, even * when that target does not exist yet (a dangling link). diff --git a/packages/core/src/memory/team-paths.test.ts b/packages/core/src/memory/team-paths.test.ts index ca926e33d3a..cc5d1918fcb 100644 --- a/packages/core/src/memory/team-paths.test.ts +++ b/packages/core/src/memory/team-paths.test.ts @@ -10,9 +10,12 @@ import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { clearAutoMemoryRootCache, + getAutoMemoryRoot, getTeamAutoMemoryIndexPath, getTeamAutoMemoryRoot, + getUserAutoMemoryRoot, isAnyAutoMemPath, + isManagedMemoryPath, isTeamAutoMemPath, TEAM_AUTO_MEMORY_DIRNAME, } from './paths.js'; @@ -93,6 +96,54 @@ describe('team auto-memory paths', () => { expect(isAnyAutoMemPath(teamFile, projectRoot)).toBe(false); }); + it('classifies all managed roots for read retention and rejects symlink escapes', () => { + const previousBaseDir = process.env['QWEN_CODE_MEMORY_BASE_DIR']; + const memoryBaseDir = path.join(projectRoot, '.runtime'); + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = memoryBaseDir; + clearAutoMemoryRootCache(); + try { + const projectMemory = path.join( + getAutoMemoryRoot(projectRoot), + 'project', + 'context.md', + ); + const userMemory = path.join( + getUserAutoMemoryRoot(), + 'feedback', + 'testing.md', + ); + const teamMemory = path.join( + getTeamAutoMemoryRoot(projectRoot), + 'reference', + 'architecture.md', + ); + expect(isManagedMemoryPath(projectMemory, projectRoot)).toBe(true); + expect(isManagedMemoryPath(userMemory, projectRoot)).toBe(true); + expect(isManagedMemoryPath(teamMemory, projectRoot)).toBe(true); + expect( + isManagedMemoryPath( + path.join(projectRoot, 'src', 'main.ts'), + projectRoot, + ), + ).toBe(false); + + const projectMemoryRoot = getAutoMemoryRoot(projectRoot); + const outside = path.join(projectRoot, 'outside.md'); + const escaped = path.join(projectMemoryRoot, 'escaped.md'); + fs.mkdirSync(projectMemoryRoot, { recursive: true }); + fs.writeFileSync(outside, 'outside'); + fs.symlinkSync(outside, escaped); + expect(isManagedMemoryPath(escaped, projectRoot)).toBe(false); + } finally { + if (previousBaseDir === undefined) { + delete process.env['QWEN_CODE_MEMORY_BASE_DIR']; + } else { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = previousBaseDir; + } + clearAutoMemoryRootCache(); + } + }); + it('recognizes a first-ever write before the team-memory dir exists', () => { const root = getTeamAutoMemoryRoot(projectRoot); // Normal first-write state: nothing under .qwen has been created yet, so diff --git a/packages/core/src/services/memoryPressureMonitor.test.ts b/packages/core/src/services/memoryPressureMonitor.test.ts index fb697a2ef16..901a287c87a 100644 --- a/packages/core/src/services/memoryPressureMonitor.test.ts +++ b/packages/core/src/services/memoryPressureMonitor.test.ts @@ -81,12 +81,14 @@ const { }; }); -vi.mock('node:os', () => ({ +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), totalmem: () => getMockOsTotalmem(), cpus: () => [{ model: 'mock', speed: 0, times: {} }], })); -vi.mock('node:fs', () => ({ +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), readFileSync: (path: string) => getMockCgroupFile(path), })); @@ -162,6 +164,8 @@ function createMockConfig( } : overrides.geminiClient; return { + getProjectRoot: () => '/mock/project', + getTargetDir: () => '/mock/project', getFileReadCache: () => ({ clear: vi.fn(), @@ -1355,14 +1359,19 @@ describe('MemoryPressureMonitor', () => { // Build history with 7 read_file tool results (keep=5, so 2 get cleared) const toolHistory: Content[] = []; for (let i = 0; i < 7; i++) { + const filePath = + i === 0 + ? '/mock/project/.qwen/team-memory/feedback/testing.md' + : `/f${i}.ts`; toolHistory.push( { role: 'model', parts: [ { functionCall: { + id: `call_${i}`, name: 'read_file', - args: { path: `/f${i}.ts` }, + args: { file_path: filePath }, }, }, ], @@ -1419,6 +1428,12 @@ describe('MemoryPressureMonitor', () => { ), ); expect(blankedResponses.length).toBeGreaterThan(0); + const memoryResult = compacted + .flatMap((entry) => entry.parts ?? []) + .find((part) => part.functionResponse?.id === 'call_0'); + expect(memoryResult?.functionResponse?.response?.['output']).toBe( + 'content of f0', + ); }); it('overrides positive toolResultsThresholdMinutes to 0', async () => { diff --git a/packages/core/src/services/memoryPressureMonitor.ts b/packages/core/src/services/memoryPressureMonitor.ts index 9c44871d8d5..e4c01779523 100644 --- a/packages/core/src/services/memoryPressureMonitor.ts +++ b/packages/core/src/services/memoryPressureMonitor.ts @@ -13,6 +13,7 @@ import { getErrorMessage } from '../utils/errors.js'; import type { Config } from '../config/config.js'; import { MemoryDiagnosticsDumper } from './memoryDiagnosticsDumper.js'; import { microcompactHistory } from './microcompaction/microcompact.js'; +import { isManagedMemoryPath } from '../memory/paths.js'; import { recordMemoryUsage, recordCpuUsage, @@ -716,13 +717,23 @@ export class MemoryPressureMonitor extends EventEmitter { const chat = client.getChat(); const history = chat.getHistoryShallow?.() ?? chat.getHistory(); const settings = this.coreConfig.getClearContextOnIdle(); - const result = microcompactHistory(history, Date.now() - 1, { - ...settings, - toolResultsThresholdMinutes: - (settings.toolResultsThresholdMinutes ?? 0) < 0 - ? settings.toolResultsThresholdMinutes - : 0, - }); + const projectRoot = this.coreConfig.getProjectRoot(); + const targetDir = this.coreConfig.getTargetDir?.() ?? projectRoot; + const result = microcompactHistory( + history, + Date.now() - 1, + { + ...settings, + toolResultsThresholdMinutes: + (settings.toolResultsThresholdMinutes ?? 0) < 0 + ? settings.toolResultsThresholdMinutes + : 0, + }, + { + preserveReadFileResult: (filePath) => + isManagedMemoryPath(filePath, projectRoot, targetDir), + }, + ); if (result.meta) { chat.setHistory(result.history); // Explicitly clear fileReadCache here instead of relying on diff --git a/packages/core/src/services/microcompaction/microcompact.test.ts b/packages/core/src/services/microcompaction/microcompact.test.ts index 8653a9cbbd8..770411deccc 100644 --- a/packages/core/src/services/microcompaction/microcompact.test.ts +++ b/packages/core/src/services/microcompaction/microcompact.test.ts @@ -40,6 +40,51 @@ function makeToolResult(name: string, output: string): Content { }; } +function makeFileToolCall(id: string, filePath: string): Content { + return { + role: 'model', + parts: [ + { + functionCall: { + id, + name: 'read_file', + args: { file_path: filePath }, + }, + }, + ], + }; +} + +function makeFileToolResult(id: string, output: string): Content { + return { + role: 'user', + parts: [ + { + functionResponse: { + id, + name: 'read_file', + response: { output }, + }, + }, + ], + }; +} + +function makeFileToolErrorResult(id: string, error: string): Content { + return { + role: 'user', + parts: [ + { + functionResponse: { + id, + name: 'read_file', + response: { error }, + }, + }, + ], + }; +} + function makeUserMessage(text: string): Content { return { role: 'user', parts: [{ text }] }; } @@ -137,6 +182,193 @@ describe('microcompactHistory', () => { ).toBe('recent file content'); }); + it('preserves managed-memory reads while clearing ordinary reads', () => { + const memoryPath = '/memory/feedback/testing.md'; + const ordinaryPath = '/project/src/example.ts'; + const history: Content[] = [ + makeFileToolCall('memory', memoryPath), + makeFileToolResult('memory', 'durable testing guidance'), + makeFileToolCall('ordinary', ordinaryPath), + makeFileToolResult('ordinary', 'ordinary source content'), + makeToolCall('grep_search'), + makeToolResult('grep_search', 'recent grep output'), + ]; + + const result = microcompactHistory(history, twoHoursAgo, DEFAULT_SETTINGS, { + preserveReadFileResult: (filePath: string) => + filePath.startsWith('/memory/'), + }); + + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('durable testing guidance'); + expect( + result.history[3]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect(result.meta!.toolsCleared).toBe(1); + expect(result.meta!.evictedReadPaths).toEqual([ordinaryPath]); + }); + + it('preserves managed-memory reads during size-based clearing', () => { + const memoryPath = '/memory/project/context.md'; + const history: Content[] = [ + makeFileToolCall('memory', memoryPath), + makeFileToolResult('memory', 'durable guidance '.repeat(20)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'old shell output '.repeat(20)), + makeToolCall('grep_search'), + makeToolResult('grep_search', 'recent grep output'), + ]; + + const result = microcompactHistory( + history, + Date.now(), + { + ...DEFAULT_SETTINGS, + toolResultsTotalCharsThreshold: 50, + }, + { + sizeOnly: true, + preserveReadFileResult: (filePath: string) => + filePath.startsWith('/memory/'), + }, + ); + + expect(result.meta!.triggerReason).toBe('size'); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('durable guidance '.repeat(20)); + expect( + result.history[3]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect(result.meta!.toolResultCharsBefore).toBeGreaterThan( + 'durable guidance '.repeat(20).length, + ); + }); + + it('reports a size overage when only protected memory can remain', () => { + const memoryContent = 'durable guidance '.repeat(20); + const history: Content[] = [ + makeFileToolCall('memory', '/memory/project/context.md'), + makeFileToolResult('memory', memoryContent), + ]; + + const result = microcompactHistory( + history, + Date.now(), + { + ...DEFAULT_SETTINGS, + toolResultsTotalCharsThreshold: 50, + }, + { + sizeOnly: true, + preserveReadFileResult: (filePath) => filePath.startsWith('/memory/'), + }, + ); + + expect(result.meta!.triggerReason).toBe('size'); + expect(result.meta!.toolsCleared).toBe(0); + expect(result.meta!.toolResultCharsBefore).toBe(memoryContent.length); + expect(result.meta!.toolResultCharsAfter).toBe(memoryContent.length); + expect(result.history).toBe(history); + }); + + it('does not charge protected memory against the recent-result budget', () => { + const ordinaryContent = 'ordinary output '.repeat(20); + const memoryContent = 'durable guidance '.repeat(20); + const history: Content[] = [ + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', ordinaryContent), + makeFileToolCall('memory', '/memory/project/context.md'), + makeFileToolResult('memory', memoryContent), + ]; + + const result = microcompactHistory( + history, + Date.now(), + { + ...DEFAULT_SETTINGS, + toolResultsTotalCharsThreshold: 50, + toolResultsNumToKeep: 1, + }, + { + sizeOnly: true, + preserveReadFileResult: (filePath) => filePath.startsWith('/memory/'), + }, + ); + + expect(result.meta!.toolsCleared).toBe(0); + expect(result.meta!.toolsKept).toBe(1); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(ordinaryContent); + expect( + result.history[3]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(memoryContent); + }); + + it('preserves managed-memory reads during forced clearing', () => { + const memoryPath = '/memory/user/profile.md'; + const history: Content[] = [ + makeFileToolCall('memory', memoryPath), + makeFileToolResult('memory', 'durable user profile'), + makeToolCall('grep_search'), + makeToolResult('grep_search', 'recent grep output'), + ]; + + const result = microcompactHistory(history, null, DEFAULT_SETTINGS, { + force: true, + preserveReadFileResult: (filePath) => filePath.startsWith('/memory/'), + }); + + expect(result.meta).toBeUndefined(); + expect(result.history).toBe(history); + }); + + it('does not preserve a read when a reused call id maps to mixed paths', () => { + const history: Content[] = [ + makeFileToolCall('reused', '/memory/project/context.md'), + makeFileToolCall('reused', '/project/src/example.ts'), + makeFileToolResult('reused', 'ambiguous content'), + makeToolCall('grep_search'), + makeToolResult('grep_search', 'recent grep output'), + ]; + + const result = microcompactHistory(history, twoHoursAgo, DEFAULT_SETTINGS, { + preserveReadFileResult: (filePath: string) => + filePath.startsWith('/memory/'), + }); + + expect( + result.history[2]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect(result.meta!.unresolvedEvictedReads).toBe(0); + expect(result.meta!.evictedReadPaths.sort()).toEqual([ + '/memory/project/context.md', + '/project/src/example.ts', + ]); + }); + + it('does not preserve error responses for managed-memory reads', () => { + const history: Content[] = [ + makeFileToolCall('err', '/memory/project/context.md'), + makeFileToolErrorResult('err', 'ENOENT'), + makeToolCall('grep_search'), + makeToolResult('grep_search', 'recent grep output'), + ]; + + const result = microcompactHistory(history, twoHoursAgo, DEFAULT_SETTINGS, { + preserveReadFileResult: () => { + throw new Error('error responses should not be preserved'); + }, + }); + + expect(result.meta).toBeUndefined(); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['error'], + ).toBe('ENOENT'); + }); + it('should not clear non-compactable tools', () => { const history: Content[] = [ makeToolCall('ask_user_question'), diff --git a/packages/core/src/services/microcompaction/microcompact.ts b/packages/core/src/services/microcompaction/microcompact.ts index 5c14d4b8812..252b515f27e 100644 --- a/packages/core/src/services/microcompaction/microcompact.ts +++ b/packages/core/src/services/microcompaction/microcompact.ts @@ -120,6 +120,8 @@ interface CollectedRefs { nestedMedia: PartRef[]; } +export type PreserveReadFileResult = (filePath: string) => boolean; + function refKey(r: PartRef): string { return `${r.contentIndex}:${r.partIndex}`; } @@ -149,7 +151,10 @@ function hasNestedMedia(part: Part): boolean { * `toolResultsNumToKeep: 1` keeps 1 tool result AND 1 media item, not * 1 entry total across the combined list. */ -function collectCompactablePartRefs(history: Content[]): CollectedRefs { +function collectCompactablePartRefs( + history: Content[], + preserveReadFileResult?: PreserveReadFileResult, +): CollectedRefs { const tool: PartRef[] = []; const media: PartRef[] = []; const nestedMedia: PartRef[] = []; @@ -174,7 +179,20 @@ function collectCompactablePartRefs(history: Content[]): CollectedRefs { } } } - return { tool, media, nestedMedia }; + if (!preserveReadFileResult) { + return { tool, media, nestedMedia }; + } + + const preservedRefs = buildPreservedReadRefs( + history, + tool, + preserveReadFileResult, + ); + return { + tool: tool.filter((ref) => !preservedRefs.has(refKey(ref))), + media, + nestedMedia, + }; } // --- Helpers --- @@ -310,6 +328,33 @@ function getFilePathsForResponse( return paths && paths.length > 0 ? [...new Set(paths)] : undefined; } +function buildPreservedReadRefs( + history: Content[], + refs: PartRef[], + preserveReadFileResult: PreserveReadFileResult, +): Set { + const callIdToFilePath = buildCallIdToFilePath(history); + const preserved = new Set(); + for (const ref of refs) { + const part = getPart(history, ref); + if ( + part?.functionResponse?.name !== ToolNames.READ_FILE || + isErrorResponse(part) + ) { + continue; + } + const paths = getFilePathsForResponse(part, callIdToFilePath); + if ( + paths && + paths.length > 0 && + paths.every((filePath) => preserveReadFileResult(filePath)) + ) { + preserved.add(refKey(ref)); + } + } + return preserved; +} + function buildKeptFilePaths( history: Content[], refs: PartRef[], @@ -347,6 +392,7 @@ function planSizeBasedClearing( settings: ClearContextOnIdleSettings, keepRecent: number, pendingContent: Content | Content[] | undefined, + preserveReadFileResult?: PreserveReadFileResult, ): SizeClearPlan | null { const threshold = getToolResultsTotalCharsThreshold(settings); if (!Number.isFinite(threshold) || threshold < 0) { @@ -373,10 +419,16 @@ function planSizeBasedClearing( return null; } - const keepToolRefs = buildKeepRefs(tool, keepRecent); + const preservedToolRefs = preserveReadFileResult + ? buildPreservedReadRefs(virtualHistory, tool, preserveReadFileResult) + : new Set(); + const compactableToolRefs = tool.filter( + (ref) => !preservedToolRefs.has(refKey(ref)), + ); + const keepToolRefs = buildKeepRefs(compactableToolRefs, keepRecent); const clearRefs: PartRef[] = []; let remainingChars = totalChars; - for (const ref of tool) { + for (const ref of compactableToolRefs) { if (remainingChars <= threshold) break; const key = refKey(ref); @@ -395,7 +447,7 @@ function planSizeBasedClearing( return { clearRefs, - toolRefs: tool, + toolRefs: compactableToolRefs, keepToolRefs, toolResultCharsBefore: totalChars, toolResultCharsAfter: remainingChars - pendingChars, @@ -412,6 +464,7 @@ export interface MicrocompactOptions { force?: boolean; sizeOnly?: boolean; pendingContent?: Content | Content[]; + preserveReadFileResult?: PreserveReadFileResult; } export interface MicrocompactMeta { @@ -494,7 +547,10 @@ export function microcompactHistory( } if (triggerReason === 'force' || triggerReason === 'idle') { - ({ tool, media, nestedMedia } = collectCompactablePartRefs(history)); + ({ tool, media, nestedMedia } = collectCompactablePartRefs( + history, + opts?.preserveReadFileResult, + )); // Each kind gets its own keepRecent budget: setting // `toolResultsNumToKeep: 1` keeps 1 of each, not 1 total. This // matches what users typically expect when they configure the @@ -514,6 +570,7 @@ export function microcompactHistory( settings, keepRecent, pending, + opts?.preserveReadFileResult, ); if (!sizePlan) { return { history };