@@ -2,11 +2,119 @@ import { PluginConfig } from "../config"
22import { Logger } from "../logger"
33import 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+ */
510export 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}
0 commit comments