Skip to content

Commit 50c08ef

Browse files
authored
Merge pull request #41 from Tarquinen/fix/idle-pruning-optimizations
fix: Optimize idle pruning behavior and improve context_pruning reliability
2 parents c2b75a9 + 03dca37 commit 50c08ef

File tree

16 files changed

+138
-84
lines changed

16 files changed

+138
-84
lines changed

CLAUDE.md

Lines changed: 0 additions & 51 deletions
This file was deleted.

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Add to your OpenCode config:
1313
```jsonc
1414
// opencode.jsonc
1515
{
16-
"plugin": ["@tarquinen/[email protected].24"]
16+
"plugin": ["@tarquinen/[email protected].25"]
1717
}
1818
```
1919

@@ -31,13 +31,19 @@ DCP implements two complementary strategies:
3131

3232
## Context Pruning Tool
3333

34-
When `strategies.onTool` is enabled, DCP exposes a `context_pruning` tool to Opencode that the AI can call to trigger pruning on demand. To help the AI use this tool effectively, DCP also injects guidance.
34+
When `strategies.onTool` is enabled, DCP exposes a `context_pruning` tool to Opencode that the AI can call to trigger pruning on demand.
3535

3636
When `nudge_freq` is enabled, injects reminders (every `nudge_freq` tool results) prompting the AI to consider pruning when appropriate.
3737

3838
## How It Works
3939

40-
DCP is **non-destructive**—pruning state is kept in memory only. When requests go to your LLM, DCP replaces pruned outputs with a placeholder; original session data stays intact.
40+
Your session history is never modified. DCP replaces pruned outputs with a placeholder before sending requests to your LLM.
41+
42+
## Impact on Prompt Caching
43+
44+
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.
45+
46+
**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.
4147

4248
## Configuration
4349

@@ -53,7 +59,7 @@ DCP uses its own config file (`~/.config/opencode/dcp.jsonc` or `.opencode/dcp.j
5359
| `showModelErrorToasts` | `true` | Show notifications on model fallback |
5460
| `strictModelSelection` | `false` | Only run AI analysis with session or configured model (disables fallback models) |
5561
| `pruning_summary` | `"detailed"` | `"off"`, `"minimal"`, or `"detailed"` |
56-
| `nudge_freq` | `5` | Remind AI to prune every N tool results (0 = disabled) |
62+
| `nudge_freq` | `10` | How often to remind AI to prune (lower = more frequent) |
5763
| `protectedTools` | `["task", "todowrite", "todoread", "context_pruning"]` | Tools that are never pruned |
5864
| `strategies.onIdle` | `["deduplication", "ai-analysis"]` | Strategies for automatic pruning |
5965
| `strategies.onTool` | `["deduplication", "ai-analysis"]` | Strategies when AI calls `context_pruning` |

index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const plugin: Plugin = (async (ctx) => {
2525
// Initialize core components
2626
const logger = new Logger(config.debug)
2727
const state = createPluginState()
28-
28+
2929
const janitor = new Janitor(
3030
ctx.client,
3131
state.prunedIds,
@@ -43,6 +43,13 @@ const plugin: Plugin = (async (ctx) => {
4343

4444
// Create tool tracker and load prompts for synthetic instruction injection
4545
const toolTracker = createToolTracker()
46+
47+
// Wire up tool name lookup from the cached tool parameters
48+
toolTracker.getToolName = (callId: string) => {
49+
const entry = state.toolParameters.get(callId)
50+
return entry?.tool
51+
}
52+
4653
const prompts = {
4754
synthInstruction: loadPrompt("synthetic"),
4855
nudgeInstruction: loadPrompt("nudge")
@@ -81,10 +88,10 @@ const plugin: Plugin = (async (ctx) => {
8188
}
8289

8390
return {
84-
event: createEventHandler(ctx.client, janitor, logger, config),
91+
event: createEventHandler(ctx.client, janitor, logger, config, toolTracker),
8592
"chat.params": createChatParamsHandler(ctx.client, state, logger),
8693
tool: config.strategies.onTool.length > 0 ? {
87-
context_pruning: createPruningTool(janitor, config),
94+
context_pruning: createPruningTool(janitor, config, toolTracker),
8895
} : undefined,
8996
}
9097
}) satisfies Plugin

lib/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const defaultConfig: PluginConfig = {
3434
showModelErrorToasts: true,
3535
strictModelSelection: false,
3636
pruning_summary: 'detailed',
37-
nudge_freq: 5,
37+
nudge_freq: 10,
3838
strategies: {
3939
onIdle: ['deduplication', 'ai-analysis'],
4040
onTool: ['deduplication', 'ai-analysis']
@@ -122,7 +122,7 @@ function createDefaultConfig(): void {
122122
// Summary display: "off", "minimal", or "detailed"
123123
"pruning_summary": "detailed",
124124
// How often to nudge the AI to prune (every N tool results, 0 = disabled)
125-
"nudge_freq": 5,
125+
"nudge_freq": 10,
126126
// Tools that should never be pruned
127127
"protectedTools": ["task", "todowrite", "todoread", "context_pruning"]
128128
}

lib/fetch-wrapper/gemini.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export async function handleGemini(
2323

2424
// Inject synthetic instructions if onTool strategies are enabled
2525
if (ctx.config.strategies.onTool.length > 0) {
26+
const skipIdleBefore = ctx.toolTracker.skipNextIdle
27+
2628
// Inject periodic nudge based on tool result count
2729
if (ctx.config.nudge_freq > 0) {
2830
if (injectNudgeGemini(body.contents, ctx.toolTracker, ctx.prompts.nudgeInstruction, ctx.config.nudge_freq)) {
@@ -31,7 +33,10 @@ export async function handleGemini(
3133
}
3234
}
3335

34-
// Inject synthetic instruction into last user content
36+
if (skipIdleBefore && !ctx.toolTracker.skipNextIdle) {
37+
ctx.logger.debug("fetch", "skipNextIdle was reset by new tool results (Gemini)")
38+
}
39+
3540
if (injectSynthGemini(body.contents, ctx.prompts.synthInstruction)) {
3641
ctx.logger.info("fetch", "Injected synthetic instruction (Gemini)")
3742
modified = true

lib/fetch-wrapper/openai-chat.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export async function handleOpenAIChatAndAnthropic(
2828

2929
// Inject synthetic instructions if onTool strategies are enabled
3030
if (ctx.config.strategies.onTool.length > 0) {
31+
const skipIdleBefore = ctx.toolTracker.skipNextIdle
32+
3133
// Inject periodic nudge based on tool result count
3234
if (ctx.config.nudge_freq > 0) {
3335
if (injectNudge(body.messages, ctx.toolTracker, ctx.prompts.nudgeInstruction, ctx.config.nudge_freq)) {
@@ -36,7 +38,10 @@ export async function handleOpenAIChatAndAnthropic(
3638
}
3739
}
3840

39-
// Inject synthetic instruction into last user message
41+
if (skipIdleBefore && !ctx.toolTracker.skipNextIdle) {
42+
ctx.logger.debug("fetch", "skipNextIdle was reset by new tool results")
43+
}
44+
4045
if (injectSynth(body.messages, ctx.prompts.synthInstruction)) {
4146
ctx.logger.info("fetch", "Injected synthetic instruction")
4247
modified = true

lib/fetch-wrapper/openai-responses.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export async function handleOpenAIResponses(
2828

2929
// Inject synthetic instructions if onTool strategies are enabled
3030
if (ctx.config.strategies.onTool.length > 0) {
31+
const skipIdleBefore = ctx.toolTracker.skipNextIdle
32+
3133
// Inject periodic nudge based on tool result count
3234
if (ctx.config.nudge_freq > 0) {
3335
if (injectNudgeResponses(body.input, ctx.toolTracker, ctx.prompts.nudgeInstruction, ctx.config.nudge_freq)) {
@@ -36,7 +38,10 @@ export async function handleOpenAIResponses(
3638
}
3739
}
3840

39-
// Inject synthetic instruction into last user message
41+
if (skipIdleBefore && !ctx.toolTracker.skipNextIdle) {
42+
ctx.logger.debug("fetch", "skipNextIdle was reset by new tool results (Responses API)")
43+
}
44+
4045
if (injectSynthResponses(body.input, ctx.prompts.synthInstruction)) {
4146
ctx.logger.info("fetch", "Injected synthetic instruction (Responses API)")
4247
modified = true

lib/hooks.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { PluginState } from "./state"
22
import type { Logger } from "./logger"
33
import type { Janitor } from "./janitor"
4-
import type { PluginConfig } from "./config"
4+
import type { PluginConfig, PruningStrategy } from "./config"
5+
import type { ToolTracker } from "./synth-instruction"
6+
import { resetToolTrackerCount } from "./synth-instruction"
57

6-
/**
7-
* Checks if a session is a subagent session.
8-
*/
98
export async function isSubagentSession(client: any, sessionID: string): Promise<boolean> {
109
try {
1110
const result = await client.session.get({ path: { id: sessionID } })
@@ -15,23 +14,43 @@ export async function isSubagentSession(client: any, sessionID: string): Promise
1514
}
1615
}
1716

18-
/**
19-
* Creates the event handler for session status changes.
20-
*/
17+
function toolStrategiesCoveredByIdle(onIdle: PruningStrategy[], onTool: PruningStrategy[]): boolean {
18+
return onTool.every(strategy => onIdle.includes(strategy))
19+
}
20+
2121
export function createEventHandler(
2222
client: any,
2323
janitor: Janitor,
2424
logger: Logger,
25-
config: PluginConfig
25+
config: PluginConfig,
26+
toolTracker?: ToolTracker
2627
) {
2728
return async ({ event }: { event: any }) => {
2829
if (event.type === "session.status" && event.properties.status.type === "idle") {
2930
if (await isSubagentSession(client, event.properties.sessionID)) return
3031
if (config.strategies.onIdle.length === 0) return
3132

32-
janitor.runOnIdle(event.properties.sessionID, config.strategies.onIdle).catch(err => {
33+
// Skip idle pruning if the last tool used was context_pruning
34+
// and idle strategies cover the same work as tool strategies
35+
if (toolTracker?.skipNextIdle) {
36+
toolTracker.skipNextIdle = false
37+
if (toolStrategiesCoveredByIdle(config.strategies.onIdle, config.strategies.onTool)) {
38+
return
39+
}
40+
}
41+
42+
try {
43+
const result = await janitor.runOnIdle(event.properties.sessionID, config.strategies.onIdle)
44+
45+
// Reset nudge counter if idle pruning succeeded and covers tool strategies
46+
if (result && result.prunedCount > 0 && toolTracker && config.nudge_freq > 0) {
47+
if (toolStrategiesCoveredByIdle(config.strategies.onIdle, config.strategies.onTool)) {
48+
resetToolTrackerCount(toolTracker, config.nudge_freq)
49+
}
50+
}
51+
} catch (err: any) {
3352
logger.error("janitor", "Failed", { error: err.message })
34-
})
53+
}
3554
}
3655
}
3756
}

lib/janitor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ export class Janitor {
6262
}
6363
}
6464

65-
async runOnIdle(sessionID: string, strategies: PruningStrategy[]): Promise<void> {
66-
await this.runWithStrategies(sessionID, strategies, { trigger: 'idle' })
65+
async runOnIdle(sessionID: string, strategies: PruningStrategy[]): Promise<PruningResult | null> {
66+
return await this.runWithStrategies(sessionID, strategies, { trigger: 'idle' })
6767
}
6868

6969
async runForTool(

lib/prompts/nudge.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
<system-reminder>
2+
This nudge is injected by a plugin and is invisible to the user. Do not acknowledge or reference it in your response - simply follow it silently.
3+
</system-reminder>
4+
15
<instruction name=agent_nudge>
26
You have accumulated several tool outputs. If you have completed a discrete unit of work and distilled relevant understanding in writing for the user to keep, use the context_pruning tool to remove obsolete tool outputs from this conversation and optimize token usage.
37
</instruction>

0 commit comments

Comments
 (0)