Skip to content

Commit 34bd730

Browse files
committed
feat: add purge errors strategy to prune tool inputs after failed calls
Introduces a new automatic strategy that removes potentially large tool inputs from errored tool calls after a configurable number of turns (default: 4). Error messages are preserved for context while reducing token usage. - Add PurgeErrors config interface with enabled, turns, and protectedTools - Implement purgeErrors strategy with turn-based pruning logic - Integrate into message transform pipeline - Update documentation with new strategy details
1 parent 8642057 commit 34bd730

6 files changed

Lines changed: 217 additions & 20 deletions

File tree

README.md

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,34 @@ Restart OpenCode. The plugin will automatically start optimizing your sessions.
2323

2424
## How Pruning Works
2525

26-
DCP uses multiple strategies to reduce context size:
26+
DCP uses multiple tools and strategies to reduce context size:
27+
28+
### Tools
29+
30+
**Discard** — Exposes a `discard` tool that the AI can call to remove completed or noisy tool content from context.
31+
32+
**Extract** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the tool content.
33+
34+
### Strategies
2735

2836
**Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost.
2937

3038
**Supersede Writes** — Prunes write tool inputs for files that have subsequently been read. When a file is written and later read, the original write content becomes redundant since the current file state is captured in the read result. Runs automatically on every request with zero LLM cost.
3139

32-
**Discard Tool** — Exposes a `discard` tool that the AI can call to remove completed or noisy tool outputs from context. Use this for task completion cleanup and removing irrelevant outputs.
33-
34-
**Extract Tool** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the raw outputs. Use this when you need to preserve key findings while reducing context size.
40+
**Purge Errors** — Prunes tool inputs for tools that returned errors after a configurable number of turns (default: 4). Error messages are preserved for context, but the potentially large input content is removed. Runs automatically on every request with zero LLM cost.
3541

36-
**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant.
42+
**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant. Disabled by default (legacy behavior).
3743

38-
Your session history is never modified. DCP replaces pruned outputs with a placeholder before sending requests to your LLM.
44+
Your session history is never modifiedDCP replaces pruned content with placeholders before sending requests to your LLM.
3945

4046
## Impact on Prompt Caching
4147

4248
LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matching. When DCP prunes a tool output, it changes the message content, which invalidates cached prefixes from that point forward.
4349

4450
**Trade-off:** You lose some cache read benefits but gain larger token savings from reduced context size. In most cases, the token savings outweigh the cache miss cost—especially in long sessions where context bloat becomes significant.
4551

52+
**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact.
53+
4654
## Configuration
4755

4856
DCP uses its own config file:
@@ -100,6 +108,14 @@ DCP uses its own config file:
100108
"supersedeWrites": {
101109
"enabled": true,
102110
},
111+
// Prune tool inputs for errored tools after X turns
112+
"purgeErrors": {
113+
"enabled": true,
114+
// Number of turns before errored tool inputs are pruned
115+
"turns": 4,
116+
// Additional tools to protect from pruning
117+
"protectedTools": [],
118+
},
103119
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
104120
"onIdle": {
105121
"enabled": false,
@@ -127,11 +143,7 @@ When enabled, turn protection prevents tool outputs from being pruned for a conf
127143
By default, these tools are always protected from pruning across all strategies:
128144
`task`, `todowrite`, `todoread`, `discard`, `extract`, `batch`
129145

130-
The `protectedTools` arrays in each section add to this default list:
131-
132-
- `tools.settings.protectedTools` — Protects tools from the `discard` and `extract` tools
133-
- `strategies.deduplication.protectedTools` — Protects tools from deduplication
134-
- `strategies.onIdle.protectedTools` — Protects tools from on-idle analysis
146+
The `protectedTools` arrays in each section add to this default list.
135147

136148
### Config Precedence
137149

lib/config.ts

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface SupersedeWrites {
4242
enabled: boolean
4343
}
4444

45+
export interface PurgeErrors {
46+
enabled: boolean
47+
turns: number
48+
protectedTools: string[]
49+
}
50+
4551
export interface TurnProtection {
4652
enabled: boolean
4753
turns: number
@@ -55,8 +61,9 @@ export interface PluginConfig {
5561
tools: Tools
5662
strategies: {
5763
deduplication: Deduplication
58-
onIdle: OnIdle
5964
supersedeWrites: SupersedeWrites
65+
purgeErrors: PurgeErrors
66+
onIdle: OnIdle
6067
}
6168
}
6269

@@ -90,6 +97,11 @@ export const VALID_CONFIG_KEYS = new Set([
9097
// strategies.supersedeWrites
9198
"strategies.supersedeWrites",
9299
"strategies.supersedeWrites.enabled",
100+
// strategies.purgeErrors
101+
"strategies.purgeErrors",
102+
"strategies.purgeErrors.enabled",
103+
"strategies.purgeErrors.turns",
104+
"strategies.purgeErrors.protectedTools",
93105
// strategies.onIdle
94106
"strategies.onIdle",
95107
"strategies.onIdle.enabled",
@@ -327,6 +339,40 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
327339
})
328340
}
329341
}
342+
343+
// purgeErrors
344+
if (strategies.purgeErrors) {
345+
if (
346+
strategies.purgeErrors.enabled !== undefined &&
347+
typeof strategies.purgeErrors.enabled !== "boolean"
348+
) {
349+
errors.push({
350+
key: "strategies.purgeErrors.enabled",
351+
expected: "boolean",
352+
actual: typeof strategies.purgeErrors.enabled,
353+
})
354+
}
355+
if (
356+
strategies.purgeErrors.turns !== undefined &&
357+
typeof strategies.purgeErrors.turns !== "number"
358+
) {
359+
errors.push({
360+
key: "strategies.purgeErrors.turns",
361+
expected: "number",
362+
actual: typeof strategies.purgeErrors.turns,
363+
})
364+
}
365+
if (
366+
strategies.purgeErrors.protectedTools !== undefined &&
367+
!Array.isArray(strategies.purgeErrors.protectedTools)
368+
) {
369+
errors.push({
370+
key: "strategies.purgeErrors.protectedTools",
371+
expected: "string[]",
372+
actual: typeof strategies.purgeErrors.protectedTools,
373+
})
374+
}
375+
}
330376
}
331377

332378
return errors
@@ -408,6 +454,11 @@ const defaultConfig: PluginConfig = {
408454
supersedeWrites: {
409455
enabled: true,
410456
},
457+
purgeErrors: {
458+
enabled: true,
459+
turns: 4,
460+
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
461+
},
411462
onIdle: {
412463
enabled: false,
413464
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
@@ -529,6 +580,14 @@ function createDefaultConfig(): void {
529580
"supersedeWrites": {
530581
"enabled": true
531582
},
583+
// Prune tool inputs for errored tools after X turns
584+
"purgeErrors": {
585+
"enabled": true,
586+
// Number of turns before errored tool inputs are pruned
587+
"turns": 4,
588+
// Additional tools to protect from pruning
589+
"protectedTools": []
590+
},
532591
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
533592
"onIdle": {
534593
"enabled": false,
@@ -588,6 +647,19 @@ function mergeStrategies(
588647
]),
589648
],
590649
},
650+
supersedeWrites: {
651+
enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled,
652+
},
653+
purgeErrors: {
654+
enabled: override.purgeErrors?.enabled ?? base.purgeErrors.enabled,
655+
turns: override.purgeErrors?.turns ?? base.purgeErrors.turns,
656+
protectedTools: [
657+
...new Set([
658+
...base.purgeErrors.protectedTools,
659+
...(override.purgeErrors?.protectedTools ?? []),
660+
]),
661+
],
662+
},
591663
onIdle: {
592664
enabled: override.onIdle?.enabled ?? base.onIdle.enabled,
593665
model: override.onIdle?.model ?? base.onIdle.model,
@@ -602,9 +674,6 @@ function mergeStrategies(
602674
]),
603675
],
604676
},
605-
supersedeWrites: {
606-
enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled,
607-
},
608677
}
609678
}
610679

@@ -652,13 +721,17 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
652721
...config.strategies.deduplication,
653722
protectedTools: [...config.strategies.deduplication.protectedTools],
654723
},
724+
supersedeWrites: {
725+
...config.strategies.supersedeWrites,
726+
},
727+
purgeErrors: {
728+
...config.strategies.purgeErrors,
729+
protectedTools: [...config.strategies.purgeErrors.protectedTools],
730+
},
655731
onIdle: {
656732
...config.strategies.onIdle,
657733
protectedTools: [...config.strategies.onIdle.protectedTools],
658734
},
659-
supersedeWrites: {
660-
...config.strategies.supersedeWrites,
661-
},
662735
},
663736
}
664737
}

lib/hooks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { SessionState, WithParts } from "./state"
22
import type { Logger } from "./logger"
33
import type { PluginConfig } from "./config"
44
import { syncToolCache } from "./state/tool-cache"
5-
import { deduplicate, supersedeWrites } from "./strategies"
5+
import { deduplicate, supersedeWrites, purgeErrors } from "./strategies"
66
import { prune, insertPruneToolContext } from "./messages"
77
import { checkSession } from "./state"
88
import { runOnIdle } from "./strategies/on-idle"
@@ -24,6 +24,7 @@ export function createChatMessageTransformHandler(
2424

2525
deduplicate(state, logger, config, output.messages)
2626
supersedeWrites(state, logger, config, output.messages)
27+
purgeErrors(state, logger, config, output.messages)
2728

2829
prune(state, logger, config, output.messages)
2930

lib/messages/prune.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const PRUNED_TOOL_INPUT_REPLACEMENT =
1414
"[content removed to save context, this is not what was written to the file, but a placeholder]"
1515
const PRUNED_TOOL_OUTPUT_REPLACEMENT =
1616
"[Output removed to save context - information superseded or no longer needed]"
17+
const PRUNED_TOOL_ERROR_INPUT_REPLACEMENT = "[input removed - see error message for details]"
1718

1819
const getNudgeString = (config: PluginConfig, isReasoningModel: boolean): string => {
1920
const discardEnabled = config.tools.discard.enabled
@@ -164,6 +165,7 @@ export const prune = (
164165
): void => {
165166
pruneToolOutputs(state, logger, messages)
166167
pruneToolInputs(state, logger, messages)
168+
pruneToolErrors(state, logger, messages)
167169
}
168170

169171
const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
@@ -191,6 +193,10 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar
191193

192194
const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
193195
for (const msg of messages) {
196+
if (isMessageCompacted(state, msg)) {
197+
continue
198+
}
199+
194200
for (const part of msg.parts) {
195201
if (part.type !== "tool") {
196202
continue
@@ -201,7 +207,7 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
201207
if (part.tool !== "write" && part.tool !== "edit") {
202208
continue
203209
}
204-
if (part.state.status === "pending" || part.state.status === "running") {
210+
if (part.state.status !== "completed") {
205211
continue
206212
}
207213

@@ -219,3 +225,33 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
219225
}
220226
}
221227
}
228+
229+
const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
230+
for (const msg of messages) {
231+
if (isMessageCompacted(state, msg)) {
232+
continue
233+
}
234+
235+
for (const part of msg.parts) {
236+
if (part.type !== "tool") {
237+
continue
238+
}
239+
if (!state.prune.toolIds.includes(part.callID)) {
240+
continue
241+
}
242+
if (part.state.status !== "error") {
243+
continue
244+
}
245+
246+
// Prune all string inputs for errored tools
247+
const input = part.state.input
248+
if (input && typeof input === "object") {
249+
for (const key of Object.keys(input)) {
250+
if (typeof input[key] === "string") {
251+
input[key] = PRUNED_TOOL_ERROR_INPUT_REPLACEMENT
252+
}
253+
}
254+
}
255+
}
256+
}
257+
}

lib/strategies/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { deduplicate } from "./deduplication"
22
export { runOnIdle } from "./on-idle"
33
export { createDiscardTool, createExtractTool } from "./tools"
44
export { supersedeWrites } from "./supersede-writes"
5+
export { purgeErrors } from "./purge-errors"

lib/strategies/purge-errors.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { PluginConfig } from "../config"
2+
import { Logger } from "../logger"
3+
import type { SessionState, WithParts } from "../state"
4+
import { buildToolIdList } from "../messages/utils"
5+
import { calculateTokensSaved } from "./utils"
6+
7+
/**
8+
* Purge Errors strategy - prunes tool inputs for tools that errored
9+
* after they are older than a configurable number of turns.
10+
* The error message is preserved, but the (potentially large) inputs
11+
* are removed to save context.
12+
*
13+
* Modifies the session state in place to add pruned tool call IDs.
14+
*/
15+
export const purgeErrors = (
16+
state: SessionState,
17+
logger: Logger,
18+
config: PluginConfig,
19+
messages: WithParts[],
20+
): void => {
21+
if (!config.strategies.purgeErrors.enabled) {
22+
return
23+
}
24+
25+
// Build list of all tool call IDs from messages (chronological order)
26+
const allToolIds = buildToolIdList(state, messages, logger)
27+
if (allToolIds.length === 0) {
28+
return
29+
}
30+
31+
// Filter out IDs already pruned
32+
const alreadyPruned = new Set(state.prune.toolIds)
33+
const unprunedIds = allToolIds.filter((id) => !alreadyPruned.has(id))
34+
35+
if (unprunedIds.length === 0) {
36+
return
37+
}
38+
39+
const protectedTools = config.strategies.purgeErrors.protectedTools
40+
const turnThreshold = config.strategies.purgeErrors.turns
41+
42+
const newPruneIds: string[] = []
43+
44+
for (const id of unprunedIds) {
45+
const metadata = state.toolParameters.get(id)
46+
if (!metadata) {
47+
continue
48+
}
49+
50+
// Skip protected tools
51+
if (protectedTools.includes(metadata.tool)) {
52+
continue
53+
}
54+
55+
// Only process error tools
56+
if (metadata.status !== "error") {
57+
continue
58+
}
59+
60+
// Check if the tool is old enough to prune
61+
const turnAge = state.currentTurn - metadata.turn
62+
if (turnAge >= turnThreshold) {
63+
newPruneIds.push(id)
64+
}
65+
}
66+
67+
if (newPruneIds.length > 0) {
68+
state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds)
69+
state.prune.toolIds.push(...newPruneIds)
70+
logger.debug(
71+
`Marked ${newPruneIds.length} error tool calls for pruning (older than ${turnThreshold} turns)`,
72+
)
73+
}
74+
}

0 commit comments

Comments
 (0)