Skip to content

Commit 0e7630d

Browse files
committed
deduplicate tool id logic
1 parent da85205 commit 0e7630d

File tree

6 files changed

+118
-8
lines changed

6 files changed

+118
-8
lines changed

index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin"
22
import { getConfig } from "./lib/config"
33
import { Logger } from "./lib/logger"
44
import { createSessionState } from "./lib/state"
5-
import { createPruningTool } from "./lib/strategies/pruning-tool"
5+
import { createPruneTool } from "./lib/strategies/prune-tool"
66
import { createChatMessageTransformHandler } from "./lib/hooks"
77

88
const plugin: Plugin = (async (ctx) => {
@@ -34,7 +34,7 @@ const plugin: Plugin = (async (ctx) => {
3434
config
3535
),
3636
tool: config.strategies.pruneTool.enabled ? {
37-
prune: createPruningTool({
37+
prune: createPruneTool({
3838
client: ctx.client,
3939
state,
4040
logger,

lib/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,4 +493,4 @@ export function getConfig(ctx: PluginInput): PluginConfig {
493493
}
494494

495495
return config
496-
496+
}

lib/hooks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@ export function createChatMessageTransformHandler(
1818
syncToolCache(state, logger, output.messages);
1919

2020
deduplicate(state, logger, config, output.messages)
21+
22+
prune(state, logger, config, output.messages)
2123
}
2224
}

lib/state/persistence.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export async function saveSessionState(
6363

6464
logger.info("persist", "Saved session state to disk", {
6565
sessionId: sessionState.sessionId.slice(0, 8),
66-
totalTokensSaved: state.stats.totalTokensSaved,
66+
totalTokensSaved: state.stats.totalPruneTokens
6767
});
6868
} catch (error: any) {
6969
logger.error("persist", "Failed to save session state", {
@@ -100,7 +100,7 @@ export async function loadSessionState(
100100

101101
logger.info("persist", "Loaded session state from disk", {
102102
sessionId: sessionId.slice(0, 8),
103-
totalTokensSaved: state.stats.totalTokensSaved,
103+
totalTokensSaved: state.stats.totalPruneTokens
104104
});
105105

106106
return state;

lib/strategies/deduplication.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,119 @@ import { PluginConfig } from "../config"
22
import { Logger } from "../logger"
33
import type { SessionState, WithParts } from "../state"
44

5+
/**
6+
* Deduplication strategy - prunes older tool calls that have identical
7+
* tool name and parameters, keeping only the most recent occurrence.
8+
* Modifies the session state in place to add pruned tool call IDs.
9+
*/
510
export const deduplicate = (
611
state: SessionState,
712
logger: Logger,
813
config: PluginConfig,
914
messages: WithParts[]
10-
) => {
15+
): void => {
16+
if (!config.strategies.deduplication.enabled) {
17+
return
18+
}
1119

20+
// Build list of all tool call IDs from messages (chronological order)
21+
const allToolIds = buildToolIdList(messages)
22+
if (allToolIds.length === 0) {
23+
return
24+
}
25+
26+
// Filter out IDs already pruned
27+
const alreadyPruned = new Set(state.prune.toolIds)
28+
const unprunedIds = allToolIds.filter(id => !alreadyPruned.has(id))
29+
30+
if (unprunedIds.length === 0) {
31+
return
32+
}
33+
34+
const protectedTools = config.strategies.deduplication.protectedTools
35+
36+
// Group by signature (tool name + normalized parameters)
37+
const signatureMap = new Map<string, string[]>()
38+
39+
for (const id of unprunedIds) {
40+
const metadata = state.toolParameters.get(id)
41+
if (!metadata) {
42+
logger.warn("deduplication", `Missing metadata for tool call ID: ${id}`)
43+
continue
44+
}
45+
46+
// Skip protected tools
47+
if (protectedTools.includes(metadata.tool)) {
48+
continue
49+
}
50+
51+
const signature = createToolSignature(metadata.tool, metadata.parameters)
52+
if (!signatureMap.has(signature)) {
53+
signatureMap.set(signature, [])
54+
}
55+
signatureMap.get(signature)!.push(id)
56+
}
57+
58+
// Find duplicates - keep only the most recent (last) in each group
59+
const newPruneIds: string[] = []
60+
61+
for (const [, ids] of signatureMap.entries()) {
62+
if (ids.length > 1) {
63+
// All except last (most recent) should be pruned
64+
const idsToRemove = ids.slice(0, -1)
65+
newPruneIds.push(...idsToRemove)
66+
}
67+
}
68+
69+
if (newPruneIds.length > 0) {
70+
state.prune.toolIds.push(...newPruneIds)
71+
logger.debug("dedulication", `Marked ${newPruneIds.length} duplicate tool calls for pruning`)
72+
}
73+
}
74+
75+
function buildToolIdList(messages: WithParts[]): string[] {
76+
const toolIds: string[] = []
77+
for (const msg of messages) {
78+
if (msg.parts) {
79+
for (const part of msg.parts) {
80+
if (part.type === 'tool' && part.callID && part.tool) {
81+
toolIds.push(part.callID)
82+
}
83+
}
84+
}
85+
}
86+
return toolIds
87+
}
88+
89+
function createToolSignature(tool: string, parameters?: any): string {
90+
if (!parameters) {
91+
return tool
92+
}
93+
const normalized = normalizeParameters(parameters)
94+
const sorted = sortObjectKeys(normalized)
95+
return `${tool}::${JSON.stringify(sorted)}`
96+
}
97+
98+
function normalizeParameters(params: any): any {
99+
if (typeof params !== 'object' || params === null) return params
100+
if (Array.isArray(params)) return params
101+
102+
const normalized: any = {}
103+
for (const [key, value] of Object.entries(params)) {
104+
if (value !== undefined && value !== null) {
105+
normalized[key] = value
106+
}
107+
}
108+
return normalized
109+
}
110+
111+
function sortObjectKeys(obj: any): any {
112+
if (typeof obj !== 'object' || obj === null) return obj
113+
if (Array.isArray(obj)) return obj.map(sortObjectKeys)
114+
115+
const sorted: any = {}
116+
for (const key of Object.keys(obj).sort()) {
117+
sorted[key] = sortObjectKeys(obj[key])
118+
}
119+
return sorted
12120
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { tool } from "@opencode-ai/plugin"
2-
import type { SessionState, ToolParameterEntry} from "../state"
2+
import type { SessionState, ToolParameterEntry } from "../state"
33
import type { PluginConfig } from "../config"
44
import { findCurrentAgent, buildToolIdList, getPruneToolIds } from "../utils"
55
import { PruneReason, sendUnifiedNotification } from "../ui/notification"
@@ -25,7 +25,7 @@ export interface PruneToolContext {
2525
* Creates the prune tool definition.
2626
* Accepts numeric IDs from the <prunable-tools> list and prunes those tool outputs.
2727
*/
28-
export function createPruningTool(
28+
export function createPruneTool(
2929
ctx: PruneToolContext,
3030
): ReturnType<typeof tool> {
3131
return tool({

0 commit comments

Comments
 (0)