Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ In `range` mode, when a new compression overlaps an earlier one, the earlier sum

Identifies repeated tool calls (same tool, same arguments) and keeps only the most recent output. Recalculated when the compress tool runs, so prompt cache is only impacted alongside compression.

When a provider reuses a tool call ID, DCP retains those calls instead of automatically pruning or sweeping an ambiguous ID. Unique IDs keep the normal pruning behavior. Original provider call/result IDs are preserved.

### Purge Errors

Prunes inputs from errored tool calls after a configurable number of turns. Error messages are preserved; only the potentially large input content is removed. Recalculated on compress tool use.
Expand Down
4 changes: 3 additions & 1 deletion lib/commands/sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { saveSessionState } from "../state/persistence"
import { isMessageCompacted } from "../state/utils"
import { isToolProtected } from "../protected-patterns"
import { syncToolCache } from "../state/tool-cache"
import { getAmbiguousToolIds } from "../tool-ids"

export interface SweepCommandContext {
client: any
Expand Down Expand Up @@ -164,8 +165,9 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void
}

// Filter out already-pruned tools, protected tools, and protected file paths
const ambiguousIds = getAmbiguousToolIds(messages)
const newToolIds = toolIdsToSweep.filter((id) => {
if (state.prune.tools.has(id)) {
if (state.prune.tools.has(id) || ambiguousIds.has(id)) {
return false
}
const entry = state.toolParameters.get(id)
Expand Down
6 changes: 4 additions & 2 deletions lib/compress/protected-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../subagents/subagent-results"
import { fetchSessionMessages } from "./search"
import type { SearchContext, SelectionResolution } from "./types"
import { toolOccurrenceKey } from "../tool-ids"

export function appendProtectedUserMessages(
summary: string,
Expand Down Expand Up @@ -152,7 +153,8 @@ export async function appendProtectedTools(
part.state?.status === "completed" &&
typeof part.state?.output === "string"
) {
const cachedSubAgentResult = state.subAgentResultCache.get(part.callID)
const cacheKey = toolOccurrenceKey(message, part)
const cachedSubAgentResult = state.subAgentResultCache.get(cacheKey)

if (cachedSubAgentResult !== undefined) {
if (cachedSubAgentResult) {
Expand All @@ -176,7 +178,7 @@ export async function appendProtectedTools(
}

if (subAgentResultText) {
state.subAgentResultCache.set(part.callID, subAgentResultText)
state.subAgentResultCache.set(cacheKey, subAgentResultText)
output = mergeSubagentResult(
part.state.output,
subAgentResultText,
Expand Down
6 changes: 4 additions & 2 deletions lib/messages/inject/subagent-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
mergeSubagentResult,
} from "../../subagents/subagent-results"
import { stripHallucinationsFromString } from "../utils"
import { toolOccurrenceKey } from "../../tool-ids"

async function fetchSubAgentMessages(client: any, sessionId: string): Promise<WithParts[]> {
const response = await client.session.messages({
Expand Down Expand Up @@ -41,7 +42,8 @@ export const injectExtendedSubAgentResults = async (
continue
}

const cachedResult = state.subAgentResultCache.get(part.callID)
const cacheKey = toolOccurrenceKey(message, part)
const cachedResult = state.subAgentResultCache.get(cacheKey)
if (cachedResult !== undefined) {
if (cachedResult) {
part.state.output = stripHallucinationsFromString(
Expand Down Expand Up @@ -74,7 +76,7 @@ export const injectExtendedSubAgentResults = async (
continue
}

state.subAgentResultCache.set(part.callID, subAgentResultText)
state.subAgentResultCache.set(cacheKey, subAgentResultText)
part.state.output = stripHallucinationsFromString(
mergeSubagentResult(part.state.output, subAgentResultText),
state.idFormat,
Expand Down
3 changes: 3 additions & 0 deletions lib/messages/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createSyntheticUserMessage, replaceBlockIdsWithBlocked } from "./utils"
import { getLastUserMessage } from "./query"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { formatBlockRef } from "../message-ids"
import { getAmbiguousToolIds } from "../tool-ids"

const PRUNED_TOOL_OUTPUT_REPLACEMENT =
"[Output removed to save context - information superseded or no longer needed]"
Expand All @@ -19,6 +20,8 @@ export const prune = (
messages: WithParts[],
summaryBase?: WithParts,
): void => {
// Legacy persisted call IDs must not remove multiple tool occurrences.
for (const id of getAmbiguousToolIds(messages)) state.prune.tools.delete(id)
filterCompressedRanges(state, logger, config, messages, summaryBase)
// pruneFullTool(state, logger, messages)
pruneToolOutputs(state, logger, messages)
Expand Down
9 changes: 8 additions & 1 deletion lib/state/tool-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Logger } from "../logger"
import { PluginConfig } from "../config"
import { isMessageCompacted } from "./utils"
import { countToolTokens } from "../token-utils"
import { getAmbiguousToolIds } from "../tool-ids"

const MAX_TOOL_CACHE_SIZE = 1000

Expand All @@ -18,6 +19,12 @@ export function syncToolCache(
try {
logger.info("Syncing tool parameters from OpenCode messages")

const ambiguousIds = getAmbiguousToolIds(messages)
for (const id of ambiguousIds) {
state.toolParameters.delete(id)
state.prune.tools.delete(id)
}

let turnCounter = 0

for (const msg of messages) {
Expand All @@ -32,7 +39,7 @@ export function syncToolCache(
continue
}

if (part.type !== "tool" || !part.callID) {
if (part.type !== "tool" || !part.callID || ambiguousIds.has(part.callID)) {
continue
}

Expand Down
6 changes: 5 additions & 1 deletion lib/strategies/deduplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import { isToolProtected } from "../protected-patterns"
import { getTotalToolTokens } from "../token-utils"
import { getAmbiguousToolIds } from "../tool-ids"

/**
* Deduplication strategy - prunes older tool calls that have identical
Expand All @@ -29,7 +30,10 @@ export const deduplicate = (
}

// Filter out IDs already pruned
const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
const ambiguousIds = getAmbiguousToolIds(messages)
const unprunedIds = allToolIds.filter(
(id) => !state.prune.tools.has(id) && !ambiguousIds.has(id),
)

if (unprunedIds.length === 0) {
return
Expand Down
6 changes: 5 additions & 1 deletion lib/strategies/purge-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import { isToolProtected } from "../protected-patterns"
import { getTotalToolTokens } from "../token-utils"
import { getAmbiguousToolIds } from "../tool-ids"

/**
* Purge Errors strategy - prunes tool inputs for tools that errored
Expand Down Expand Up @@ -32,7 +33,10 @@ export const purgeErrors = (
}

// Filter out IDs already pruned
const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
const ambiguousIds = getAmbiguousToolIds(messages)
const unprunedIds = allToolIds.filter(
(id) => !state.prune.tools.has(id) && !ambiguousIds.has(id),
)

if (unprunedIds.length === 0) {
return
Expand Down
21 changes: 21 additions & 0 deletions lib/tool-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { WithParts } from "./state"

type ToolPart = Extract<WithParts["parts"][number], { type: "tool" }>

export function getAmbiguousToolIds(messages: WithParts[]): Set<string> {
const seen = new Set<string>()
const ambiguous = new Set<string>()
for (const message of messages) {
const parts = Array.isArray(message.parts) ? message.parts : []
for (const part of parts) {
if (part.type !== "tool" || !part.callID) continue
if (seen.has(part.callID)) ambiguous.add(part.callID)
seen.add(part.callID)
}
}
return ambiguous
}

export function toolOccurrenceKey(message: WithParts, part: ToolPart): string {
return JSON.stringify([message.info.id, part.id ?? part.callID])
}
21 changes: 14 additions & 7 deletions lib/v2/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,22 @@ function output(content: ReadonlyArray<{ type: string; text?: string }>): unknow

export function project(native: Message[], entries: History, session: Session) {
const byID = new Map(entries.map((entry) => [entry.id, entry]))
const results = new Map<string, ToolResultPart>()
const owners = new Map<string, string>()
const calls = new Map<string, { part: ContentPart; messageId?: string }>()
const results = new Map<ContentPart, ToolResultPart>()
const owners = new Map<ToolResultPart, string>()
const links = new Map<ContentPart, Part>()
const originals = new Map<string, WithParts>()
const messages: WithParts[] = []
for (const message of native)
for (const part of message.content) {
if (part.type === "tool-result") results.set(part.id, part)
if (part.type === "tool-call" && message.id) owners.set(part.id, message.id)
if (part.type === "tool-call") calls.set(part.id, { part, messageId: message.id })
if (part.type === "tool-result") {
const call = calls.get(part.id)
if (!call) continue
results.set(call.part, part)
if (call.messageId) owners.set(part, call.messageId)
calls.delete(part.id)
}
}
for (const message of native) {
if (!message.id || !["user", "assistant"].includes(message.role)) continue
Expand All @@ -121,7 +128,7 @@ export function project(native: Message[], entries: History, session: Session) {
url: typeof part.data === "string" ? part.data : "",
}
if (part.type === "tool-call") {
const result = results.get(part.id)
const result = results.get(part)
const entry = byID.get(message.id)
const durable =
entry?.type === "assistant"
Expand Down Expand Up @@ -205,8 +212,8 @@ export function project(native: Message[], entries: History, session: Session) {
if (part.type === "tool-call") content.push(...added.splice(0))
if (
part.type === "tool-result" &&
originals.has(owners.get(part.id)!) &&
!retained.has(owners.get(part.id)!)
originals.has(owners.get(part)!) &&
!retained.has(owners.get(part)!)
)
continue
const edit = links.get(part)
Expand Down
Loading