Skip to content

Commit 985bd3e

Browse files
committed
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
1 parent a13f268 commit 985bd3e

File tree

7 files changed

+598
-1
lines changed

7 files changed

+598
-1
lines changed

dcp.schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,18 @@
270270
}
271271
}
272272
},
273+
"supersedeReads": {
274+
"type": "object",
275+
"description": "Prune stale read outputs when the same file has been subsequently written or edited",
276+
"additionalProperties": false,
277+
"properties": {
278+
"enabled": {
279+
"type": "boolean",
280+
"default": true,
281+
"description": "Enable supersede reads strategy"
282+
}
283+
}
284+
},
273285
"purgeErrors": {
274286
"type": "object",
275287
"description": "Remove tool outputs that resulted in errors",

lib/config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export interface SupersedeWrites {
4040
enabled: boolean
4141
}
4242

43+
export interface SupersedeReads {
44+
enabled: boolean
45+
}
46+
4347
export interface PurgeErrors {
4448
enabled: boolean
4549
turns: number
@@ -70,6 +74,7 @@ export interface PluginConfig {
7074
strategies: {
7175
deduplication: Deduplication
7276
supersedeWrites: SupersedeWrites
77+
supersedeReads: SupersedeReads
7378
purgeErrors: PurgeErrors
7479
}
7580
}
@@ -130,6 +135,8 @@ export const VALID_CONFIG_KEYS = new Set([
130135
"strategies.deduplication.protectedTools",
131136
"strategies.supersedeWrites",
132137
"strategies.supersedeWrites.enabled",
138+
"strategies.supersedeReads",
139+
"strategies.supersedeReads.enabled",
133140
"strategies.purgeErrors",
134141
"strategies.purgeErrors.enabled",
135142
"strategies.purgeErrors.turns",
@@ -545,6 +552,19 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
545552
}
546553
}
547554

555+
if (strategies.supersedeReads) {
556+
if (
557+
strategies.supersedeReads.enabled !== undefined &&
558+
typeof strategies.supersedeReads.enabled !== "boolean"
559+
) {
560+
errors.push({
561+
key: "strategies.supersedeReads.enabled",
562+
expected: "boolean",
563+
actual: typeof strategies.supersedeReads.enabled,
564+
})
565+
}
566+
}
567+
548568
if (strategies.purgeErrors) {
549569
if (
550570
strategies.purgeErrors.enabled !== undefined &&
@@ -681,6 +701,9 @@ const defaultConfig: PluginConfig = {
681701
supersedeWrites: {
682702
enabled: true,
683703
},
704+
supersedeReads: {
705+
enabled: true,
706+
},
684707
purgeErrors: {
685708
enabled: true,
686709
turns: 4,
@@ -808,6 +831,9 @@ function mergeStrategies(
808831
supersedeWrites: {
809832
enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled,
810833
},
834+
supersedeReads: {
835+
enabled: override.supersedeReads?.enabled ?? base.supersedeReads.enabled,
836+
},
811837
purgeErrors: {
812838
enabled: override.purgeErrors?.enabled ?? base.purgeErrors.enabled,
813839
turns: override.purgeErrors?.turns ?? base.purgeErrors.turns,
@@ -909,6 +935,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
909935
protectedTools: [...config.strategies.deduplication.protectedTools],
910936
},
911937
supersedeWrites: { ...config.strategies.supersedeWrites },
938+
supersedeReads: { ...config.strategies.supersedeReads },
912939
purgeErrors: {
913940
...config.strategies.purgeErrors,
914941
protectedTools: [...config.strategies.purgeErrors.protectedTools],

lib/strategies/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { deduplicate } from "./deduplication"
22
export { supersedeWrites } from "./supersede-writes"
3+
export { supersedeReads } from "./supersede-reads"
34
export { purgeErrors } from "./purge-errors"

lib/strategies/supersede-reads.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { PluginConfig } from "../config"
2+
import { Logger } from "../logger"
3+
import type { SessionState, WithParts } from "../state"
4+
import { getFilePathsFromParameters, isFilePathProtected } from "../protected-patterns"
5+
import { getTotalToolTokens } from "./utils"
6+
7+
/**
8+
* Supersede Reads strategy - prunes read tool outputs for files that have
9+
* subsequently been written or edited. When a file is read and later modified,
10+
* the original read output becomes stale since the file contents have changed.
11+
*
12+
* Only prunes reads that are followed by a *successful* write/edit to the same
13+
* file. Errored writes do not supersede reads because the file was not actually
14+
* changed.
15+
*
16+
* Modifies the session state in place to add pruned tool call IDs.
17+
*/
18+
export const supersedeReads = (
19+
state: SessionState,
20+
logger: Logger,
21+
config: PluginConfig,
22+
messages: WithParts[],
23+
): void => {
24+
if (state.manualMode && !config.manualMode.automaticStrategies) {
25+
return
26+
}
27+
28+
if (!config.strategies.supersedeReads.enabled) {
29+
return
30+
}
31+
32+
const allToolIds = state.toolIdList
33+
if (allToolIds.length === 0) {
34+
return
35+
}
36+
37+
// Filter out IDs already pruned
38+
const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
39+
if (unprunedIds.length === 0) {
40+
return
41+
}
42+
43+
// Track read tools by file path: filePath -> [{ id, index }]
44+
// We track index to determine chronological order
45+
const readsByFile = new Map<string, { id: string; index: number }[]>()
46+
47+
// Track successful write/edit file paths with their index
48+
const writesByFile = new Map<string, number[]>()
49+
50+
for (let i = 0; i < allToolIds.length; i++) {
51+
const id = allToolIds[i]
52+
const metadata = state.toolParameters.get(id)
53+
if (!metadata) {
54+
continue
55+
}
56+
57+
const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
58+
if (filePaths.length === 0) {
59+
continue
60+
}
61+
const filePath = filePaths[0]
62+
63+
if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
64+
continue
65+
}
66+
67+
if (metadata.tool === "read") {
68+
if (!readsByFile.has(filePath)) {
69+
readsByFile.set(filePath, [])
70+
}
71+
const reads = readsByFile.get(filePath)
72+
if (reads) {
73+
reads.push({ id, index: i })
74+
}
75+
} else if (
76+
(metadata.tool === "write" || metadata.tool === "edit") &&
77+
metadata.status === "completed"
78+
) {
79+
if (!writesByFile.has(filePath)) {
80+
writesByFile.set(filePath, [])
81+
}
82+
const writes = writesByFile.get(filePath)
83+
if (writes) {
84+
writes.push(i)
85+
}
86+
}
87+
}
88+
89+
// Find reads that are superseded by subsequent writes/edits
90+
const newPruneIds: string[] = []
91+
92+
for (const [filePath, reads] of readsByFile.entries()) {
93+
const writes = writesByFile.get(filePath)
94+
if (!writes || writes.length === 0) {
95+
continue
96+
}
97+
98+
// For each read, check if there's a write that comes after it
99+
for (const read of reads) {
100+
// Skip if already pruned
101+
if (state.prune.tools.has(read.id)) {
102+
continue
103+
}
104+
105+
// Check if any write comes after this read
106+
const hasSubsequentWrite = writes.some((writeIndex) => writeIndex > read.index)
107+
if (hasSubsequentWrite) {
108+
newPruneIds.push(read.id)
109+
}
110+
}
111+
}
112+
113+
if (newPruneIds.length > 0) {
114+
state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds)
115+
for (const id of newPruneIds) {
116+
const entry = state.toolParameters.get(id)
117+
state.prune.tools.set(id, entry?.tokenCount ?? 0)
118+
}
119+
logger.debug(`Marked ${newPruneIds.length} superseded read tool calls for pruning`)
120+
}
121+
}

lib/tools/compress.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
import { isIgnoredUserMessage } from "../messages/utils"
2525
import { assignMessageRefs } from "../message-ids"
2626
import { getCurrentParams, getCurrentTokenUsage, countTokens } from "../strategies/utils"
27-
import { deduplicate, supersedeWrites, purgeErrors } from "../strategies"
27+
import { deduplicate, supersedeWrites, supersedeReads, purgeErrors } from "../strategies"
2828
import { saveSessionState } from "../state/persistence"
2929
import { sendCompressNotification } from "../ui/notification"
3030
import { NESTED_FORMAT_OVERLAY, FLAT_FORMAT_OVERLAY } from "../prompts/internal-overlays"
@@ -117,6 +117,7 @@ export function createCompressTool(ctx: ToolContext): ReturnType<typeof tool> {
117117

118118
deduplicate(ctx.state, ctx.logger, ctx.config, rawMessages)
119119
// supersedeWrites(ctx.state, ctx.logger, ctx.config, rawMessages)
120+
supersedeReads(ctx.state, ctx.logger, ctx.config, rawMessages)
120121
purgeErrors(ctx.state, ctx.logger, ctx.config, rawMessages)
121122

122123
const searchContext = buildSearchContext(ctx.state, rawMessages)

tests/compress.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ function buildConfig(): PluginConfig {
6060
supersedeWrites: {
6161
enabled: true,
6262
},
63+
supersedeReads: {
64+
enabled: true,
65+
},
6366
purgeErrors: {
6467
enabled: true,
6568
turns: 4,

0 commit comments

Comments
 (0)