Skip to content

Commit 07f572b

Browse files
authored
Merge pull request #203 from Opencode-DCP/dev
2 parents 4cb7f98 + 1fc3709 commit 07f572b

File tree

9 files changed

+226
-24
lines changed

9 files changed

+226
-24
lines changed

README.md

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,39 @@ Add to your OpenCode config:
1919

2020
Using `@latest` ensures you always get the newest version automatically when OpenCode starts.
2121

22+
> **Note:** If you use OAuth plugins (e.g., for Google or other services), place this plugin last in your `plugin` array to avoid interfering with their authentication flows.
23+
2224
Restart OpenCode. The plugin will automatically start optimizing your sessions.
2325

2426
## How Pruning Works
2527

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

2838
**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.
2939

3040
**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.
3141

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.
42+
**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.
3543

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.
44+
**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).
3745

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

4048
## Impact on Prompt Caching
4149

4250
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.
4351

44-
**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.
52+
**Trade-off:** You lose some cache read benefits but gain larger token savings from reduced context size and performance improvements through reduced context poisoning. In most cases, the token savings outweigh the cache miss cost—especially in long sessions where context bloat becomes significant.
53+
54+
**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact.
4555

4656
## Configuration
4757

@@ -100,6 +110,14 @@ DCP uses its own config file:
100110
"supersedeWrites": {
101111
"enabled": true,
102112
},
113+
// Prune tool inputs for errored tools after X turns
114+
"purgeErrors": {
115+
"enabled": true,
116+
// Number of turns before errored tool inputs are pruned
117+
"turns": 4,
118+
// Additional tools to protect from pruning
119+
"protectedTools": [],
120+
},
103121
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
104122
"onIdle": {
105123
"enabled": false,
@@ -127,11 +145,7 @@ When enabled, turn protection prevents tool outputs from being pruned for a conf
127145
By default, these tools are always protected from pruning across all strategies:
128146
`task`, `todowrite`, `todoread`, `discard`, `extract`, `batch`
129147

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
148+
The `protectedTools` arrays in each section add to this default list.
135149

136150
### Config Precedence
137151

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 due to failed tool call]"
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/messages/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ export const extractParameterKey = (tool: string, parameters: any): string => {
137137
if (tool === "task" && parameters.description) {
138138
return parameters.description
139139
}
140+
if (tool === "skill" && parameters.name) {
141+
return parameters.name
142+
}
140143

141144
const paramStr = JSON.stringify(parameters)
142145
if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") {

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"

0 commit comments

Comments
 (0)