From dc581832e21569ac0f82c786e680c28003f3606b Mon Sep 17 00:00:00 2001 From: sudorest Date: Sat, 21 Mar 2026 21:28:48 +0000 Subject: [PATCH] feat: add supersedeReads strategy to prune stale read outputs When a file is read and then subsequently written/edited successfully, the original read output becomes stale. This new strategy automatically prunes those outdated read outputs, saving context tokens. - New strategy: supersedeReads (enabled by default) - Only prunes reads followed by successful writes/edits (errored writes are ignored) - Respects protectedFilePatterns, manual mode, and automaticStrategies - Full config support with JSON schema, validation, and merge logic - 14 comprehensive tests covering all edge cases - Wired into compress tool alongside existing strategies --- dcp.schema.json | 12 + lib/config.ts | 27 ++ lib/strategies/index.ts | 1 + lib/strategies/supersede-reads.ts | 121 +++++++++ lib/tools/compress.ts | 3 +- tests/compress.test.ts | 3 + tests/supersede-reads.test.ts | 432 ++++++++++++++++++++++++++++++ 7 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 lib/strategies/supersede-reads.ts create mode 100644 tests/supersede-reads.test.ts diff --git a/dcp.schema.json b/dcp.schema.json index a927b870..9710cb45 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -270,6 +270,18 @@ } } }, + "supersedeReads": { + "type": "object", + "description": "Prune stale read outputs when the same file has been subsequently written or edited", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable supersede reads strategy" + } + } + }, "purgeErrors": { "type": "object", "description": "Remove tool outputs that resulted in errors", diff --git a/lib/config.ts b/lib/config.ts index c0c96547..2307747e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -40,6 +40,10 @@ export interface SupersedeWrites { enabled: boolean } +export interface SupersedeReads { + enabled: boolean +} + export interface PurgeErrors { enabled: boolean turns: number @@ -70,6 +74,7 @@ export interface PluginConfig { strategies: { deduplication: Deduplication supersedeWrites: SupersedeWrites + supersedeReads: SupersedeReads purgeErrors: PurgeErrors } } @@ -130,6 +135,8 @@ export const VALID_CONFIG_KEYS = new Set([ "strategies.deduplication.protectedTools", "strategies.supersedeWrites", "strategies.supersedeWrites.enabled", + "strategies.supersedeReads", + "strategies.supersedeReads.enabled", "strategies.purgeErrors", "strategies.purgeErrors.enabled", "strategies.purgeErrors.turns", @@ -545,6 +552,19 @@ export function validateConfigTypes(config: Record): ValidationErro } } + if (strategies.supersedeReads) { + if ( + strategies.supersedeReads.enabled !== undefined && + typeof strategies.supersedeReads.enabled !== "boolean" + ) { + errors.push({ + key: "strategies.supersedeReads.enabled", + expected: "boolean", + actual: typeof strategies.supersedeReads.enabled, + }) + } + } + if (strategies.purgeErrors) { if ( strategies.purgeErrors.enabled !== undefined && @@ -681,6 +701,9 @@ const defaultConfig: PluginConfig = { supersedeWrites: { enabled: true, }, + supersedeReads: { + enabled: true, + }, purgeErrors: { enabled: true, turns: 4, @@ -808,6 +831,9 @@ function mergeStrategies( supersedeWrites: { enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled, }, + supersedeReads: { + enabled: override.supersedeReads?.enabled ?? base.supersedeReads.enabled, + }, purgeErrors: { enabled: override.purgeErrors?.enabled ?? base.purgeErrors.enabled, turns: override.purgeErrors?.turns ?? base.purgeErrors.turns, @@ -909,6 +935,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { protectedTools: [...config.strategies.deduplication.protectedTools], }, supersedeWrites: { ...config.strategies.supersedeWrites }, + supersedeReads: { ...config.strategies.supersedeReads }, purgeErrors: { ...config.strategies.purgeErrors, protectedTools: [...config.strategies.purgeErrors.protectedTools], diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index f8922df9..8ea46f55 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -1,3 +1,4 @@ export { deduplicate } from "./deduplication" export { supersedeWrites } from "./supersede-writes" +export { supersedeReads } from "./supersede-reads" export { purgeErrors } from "./purge-errors" diff --git a/lib/strategies/supersede-reads.ts b/lib/strategies/supersede-reads.ts new file mode 100644 index 00000000..df9cbc6d --- /dev/null +++ b/lib/strategies/supersede-reads.ts @@ -0,0 +1,121 @@ +import { PluginConfig } from "../config" +import { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" +import { getFilePathsFromParameters, isFilePathProtected } from "../protected-patterns" +import { getTotalToolTokens } from "./utils" + +/** + * Supersede Reads strategy - prunes read tool outputs for files that have + * subsequently been written or edited. When a file is read and later modified, + * the original read output becomes stale since the file contents have changed. + * + * Only prunes reads that are followed by a *successful* write/edit to the same + * file. Errored writes do not supersede reads because the file was not actually + * changed. + * + * Modifies the session state in place to add pruned tool call IDs. + */ +export const supersedeReads = ( + state: SessionState, + logger: Logger, + config: PluginConfig, + messages: WithParts[], +): void => { + if (state.manualMode && !config.manualMode.automaticStrategies) { + return + } + + if (!config.strategies.supersedeReads.enabled) { + return + } + + const allToolIds = state.toolIdList + if (allToolIds.length === 0) { + return + } + + // Filter out IDs already pruned + const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id)) + if (unprunedIds.length === 0) { + return + } + + // Track read tools by file path: filePath -> [{ id, index }] + // We track index to determine chronological order + const readsByFile = new Map() + + // Track successful write/edit file paths with their index + const writesByFile = new Map() + + for (let i = 0; i < allToolIds.length; i++) { + const id = allToolIds[i] + const metadata = state.toolParameters.get(id) + if (!metadata) { + continue + } + + const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters) + if (filePaths.length === 0) { + continue + } + const filePath = filePaths[0] + + if (isFilePathProtected(filePaths, config.protectedFilePatterns)) { + continue + } + + if (metadata.tool === "read") { + if (!readsByFile.has(filePath)) { + readsByFile.set(filePath, []) + } + const reads = readsByFile.get(filePath) + if (reads) { + reads.push({ id, index: i }) + } + } else if ( + (metadata.tool === "write" || metadata.tool === "edit") && + metadata.status === "completed" + ) { + if (!writesByFile.has(filePath)) { + writesByFile.set(filePath, []) + } + const writes = writesByFile.get(filePath) + if (writes) { + writes.push(i) + } + } + } + + // Find reads that are superseded by subsequent writes/edits + const newPruneIds: string[] = [] + + for (const [filePath, reads] of readsByFile.entries()) { + const writes = writesByFile.get(filePath) + if (!writes || writes.length === 0) { + continue + } + + // For each read, check if there's a write that comes after it + for (const read of reads) { + // Skip if already pruned + if (state.prune.tools.has(read.id)) { + continue + } + + // Check if any write comes after this read + const hasSubsequentWrite = writes.some((writeIndex) => writeIndex > read.index) + if (hasSubsequentWrite) { + newPruneIds.push(read.id) + } + } + } + + if (newPruneIds.length > 0) { + state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds) + for (const id of newPruneIds) { + const entry = state.toolParameters.get(id) + state.prune.tools.set(id, entry?.tokenCount ?? 0) + } + logger.debug(`Marked ${newPruneIds.length} superseded read tool calls for pruning`) + } +} diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index 6d2b62f1..0d7c30de 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -24,7 +24,7 @@ import { import { isIgnoredUserMessage } from "../messages/utils" import { assignMessageRefs } from "../message-ids" import { getCurrentParams, getCurrentTokenUsage, countTokens } from "../strategies/utils" -import { deduplicate, supersedeWrites, purgeErrors } from "../strategies" +import { deduplicate, supersedeWrites, supersedeReads, purgeErrors } from "../strategies" import { saveSessionState } from "../state/persistence" import { sendCompressNotification } from "../ui/notification" import { NESTED_FORMAT_OVERLAY, FLAT_FORMAT_OVERLAY } from "../prompts/internal-overlays" @@ -117,6 +117,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType { deduplicate(ctx.state, ctx.logger, ctx.config, rawMessages) // supersedeWrites(ctx.state, ctx.logger, ctx.config, rawMessages) + supersedeReads(ctx.state, ctx.logger, ctx.config, rawMessages) purgeErrors(ctx.state, ctx.logger, ctx.config, rawMessages) const searchContext = buildSearchContext(ctx.state, rawMessages) diff --git a/tests/compress.test.ts b/tests/compress.test.ts index 988603e2..b500df82 100644 --- a/tests/compress.test.ts +++ b/tests/compress.test.ts @@ -60,6 +60,9 @@ function buildConfig(): PluginConfig { supersedeWrites: { enabled: true, }, + supersedeReads: { + enabled: true, + }, purgeErrors: { enabled: true, turns: 4, diff --git a/tests/supersede-reads.test.ts b/tests/supersede-reads.test.ts new file mode 100644 index 00000000..ca59cf80 --- /dev/null +++ b/tests/supersede-reads.test.ts @@ -0,0 +1,432 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { supersedeReads } from "../lib/strategies/supersede-reads" +import { createSessionState } from "../lib/state" +import type { PluginConfig } from "../lib/config" +import { Logger } from "../lib/logger" + +function buildConfig( + overrides?: Partial, +): PluginConfig { + return { + enabled: true, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { + enabled: true, + protectedTools: [], + }, + manualMode: { + enabled: false, + automaticStrategies: true, + }, + turnProtection: { + enabled: false, + turns: 4, + }, + experimental: { + allowSubAgents: false, + customPrompts: false, + }, + protectedFilePatterns: [], + compress: { + permission: "allow", + showCompression: false, + maxContextLimit: 100000, + minContextLimit: 30000, + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + flatSchema: false, + protectedTools: [], + protectUserMessages: false, + }, + strategies: { + deduplication: { + enabled: true, + protectedTools: [], + }, + supersedeWrites: { + enabled: true, + }, + supersedeReads: { + enabled: true, + ...overrides, + }, + purgeErrors: { + enabled: true, + turns: 4, + protectedTools: [], + }, + }, + } +} + +const logger = new Logger(false) + +test("prunes read when same file is subsequently written", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.has("call-read-1"), true, "read should be pruned") + assert.equal(state.prune.tools.has("call-write-1"), false, "write should not be pruned") + assert.equal(state.stats.totalPruneTokens, 500) +}) + +test("prunes read when same file is subsequently edited", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-edit-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/utils.ts" }, + status: "completed", + turn: 1, + tokenCount: 800, + }) + state.toolParameters.set("call-edit-1", { + tool: "edit", + parameters: { filePath: "/src/utils.ts" }, + status: "completed", + turn: 2, + tokenCount: 200, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.has("call-read-1"), true, "read should be pruned after edit") + assert.equal(state.stats.totalPruneTokens, 800) +}) + +test("does not prune read when write comes before it", () => { + const state = createSessionState() + state.toolIdList = ["call-write-1", "call-read-1"] + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 300, + }) + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 500, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal( + state.prune.tools.has("call-read-1"), + false, + "read after write should not be pruned", + ) + assert.equal(state.prune.tools.size, 0) +}) + +test("does not prune read when write targets a different file", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/other.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.size, 0, "no tools should be pruned for different files") +}) + +test("does not prune read when subsequent write errored", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "error", + error: "Permission denied", + turn: 2, + tokenCount: 300, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.size, 0, "read should not be pruned when write errored") +}) + +test("prunes multiple reads for the same file", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-read-2", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-read-2", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 400, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 3, + tokenCount: 300, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.has("call-read-1"), true) + assert.equal(state.prune.tools.has("call-read-2"), true) + assert.equal(state.stats.totalPruneTokens, 900) +}) + +test("only prunes reads before the write, not after", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-write-1", "call-read-2"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + state.toolParameters.set("call-read-2", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 3, + tokenCount: 400, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.has("call-read-1"), true, "read before write should be pruned") + assert.equal( + state.prune.tools.has("call-read-2"), + false, + "read after write should not be pruned", + ) + assert.equal(state.stats.totalPruneTokens, 500) +}) + +test("skips already-pruned reads", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + // Pre-prune the read + state.prune.tools.set("call-read-1", 500) + + supersedeReads(state, logger, buildConfig(), []) + + // Token counter should not increase since it was already pruned + assert.equal(state.stats.totalPruneTokens, 0) +}) + +test("respects disabled config", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + + supersedeReads(state, logger, buildConfig({ enabled: false }), []) + + assert.equal(state.prune.tools.size, 0, "nothing should be pruned when disabled") +}) + +test("respects manual mode when automaticStrategies is false", () => { + const state = createSessionState() + state.manualMode = "active" + state.toolIdList = ["call-read-1", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + + const config = buildConfig() + config.manualMode.automaticStrategies = false + + supersedeReads(state, logger, config, []) + + assert.equal( + state.prune.tools.size, + 0, + "nothing should be pruned in manual mode without automaticStrategies", + ) +}) + +test("respects protectedFilePatterns", () => { + const state = createSessionState() + state.toolIdList = ["call-read-1", "call-write-1"] + state.toolParameters.set("call-read-1", { + tool: "read", + parameters: { filePath: "/src/config.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/config.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + + const config = buildConfig() + config.protectedFilePatterns = ["**/*.config.ts", "**/config.ts"] + + supersedeReads(state, logger, config, []) + + assert.equal(state.prune.tools.size, 0, "protected file reads should not be pruned") +}) + +test("handles multiple files independently", () => { + const state = createSessionState() + state.toolIdList = ["call-read-a", "call-read-b", "call-write-a"] + state.toolParameters.set("call-read-a", { + tool: "read", + parameters: { filePath: "/src/a.ts" }, + status: "completed", + turn: 1, + tokenCount: 500, + }) + state.toolParameters.set("call-read-b", { + tool: "read", + parameters: { filePath: "/src/b.ts" }, + status: "completed", + turn: 2, + tokenCount: 400, + }) + state.toolParameters.set("call-write-a", { + tool: "write", + parameters: { filePath: "/src/a.ts" }, + status: "completed", + turn: 3, + tokenCount: 300, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.has("call-read-a"), true, "read of a.ts should be pruned") + assert.equal( + state.prune.tools.has("call-read-b"), + false, + "read of b.ts should not be pruned (no write)", + ) + assert.equal(state.stats.totalPruneTokens, 500) +}) + +test("handles empty toolIdList gracefully", () => { + const state = createSessionState() + state.toolIdList = [] + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.size, 0) +}) + +test("handles tools with no file path parameters", () => { + const state = createSessionState() + state.toolIdList = ["call-bash-1", "call-write-1"] + state.toolParameters.set("call-bash-1", { + tool: "bash", + parameters: { command: "ls -la" }, + status: "completed", + turn: 1, + tokenCount: 100, + }) + state.toolParameters.set("call-write-1", { + tool: "write", + parameters: { filePath: "/src/index.ts" }, + status: "completed", + turn: 2, + tokenCount: 300, + }) + + supersedeReads(state, logger, buildConfig(), []) + + assert.equal(state.prune.tools.size, 0, "non-file tools should not be affected") +})