Skip to content

Commit f447e75

Browse files
committed
refactor: add strategies infrastructure and migrate deduplication
1 parent 710a4cc commit f447e75

File tree

6 files changed

+214
-94
lines changed

6 files changed

+214
-94
lines changed

lib/core/deduplicator.ts

Lines changed: 0 additions & 89 deletions
This file was deleted.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { extractParameterKey } from "../../ui/display-utils"
2+
import type { PruningStrategy, StrategyResult, ToolMetadata } from "./types"
3+
4+
/**
5+
* Deduplication strategy - prunes older tool calls that have identical
6+
* tool name and parameters, keeping only the most recent occurrence.
7+
*/
8+
export const deduplicationStrategy: PruningStrategy = {
9+
name: "deduplication",
10+
11+
detect(
12+
toolMetadata: Map<string, ToolMetadata>,
13+
unprunedIds: string[],
14+
protectedTools: string[]
15+
): StrategyResult {
16+
const signatureMap = new Map<string, string[]>()
17+
18+
const deduplicatableIds = unprunedIds.filter(id => {
19+
const metadata = toolMetadata.get(id)
20+
return !metadata || !protectedTools.includes(metadata.tool)
21+
})
22+
23+
for (const id of deduplicatableIds) {
24+
const metadata = toolMetadata.get(id)
25+
if (!metadata) continue
26+
27+
const signature = createToolSignature(metadata.tool, metadata.parameters)
28+
if (!signatureMap.has(signature)) {
29+
signatureMap.set(signature, [])
30+
}
31+
signatureMap.get(signature)!.push(id)
32+
}
33+
34+
const prunedIds: string[] = []
35+
const details = new Map()
36+
37+
for (const [signature, ids] of signatureMap.entries()) {
38+
if (ids.length > 1) {
39+
const metadata = toolMetadata.get(ids[0])!
40+
const idsToRemove = ids.slice(0, -1) // All except last
41+
prunedIds.push(...idsToRemove)
42+
43+
details.set(signature, {
44+
toolName: metadata.tool,
45+
parameterKey: extractParameterKey(metadata),
46+
reason: `duplicate (${ids.length} occurrences, kept most recent)`,
47+
duplicateCount: ids.length,
48+
prunedIds: idsToRemove,
49+
keptId: ids[ids.length - 1]
50+
})
51+
}
52+
}
53+
54+
return { prunedIds, details }
55+
}
56+
}
57+
58+
function createToolSignature(tool: string, parameters?: any): string {
59+
if (!parameters) return tool
60+
61+
const normalized = normalizeParameters(parameters)
62+
const sorted = sortObjectKeys(normalized)
63+
return `${tool}::${JSON.stringify(sorted)}`
64+
}
65+
66+
function normalizeParameters(params: any): any {
67+
if (typeof params !== 'object' || params === null) return params
68+
if (Array.isArray(params)) return params
69+
70+
const normalized: any = {}
71+
for (const [key, value] of Object.entries(params)) {
72+
if (value !== undefined && value !== null) {
73+
normalized[key] = value
74+
}
75+
}
76+
return normalized
77+
}
78+
79+
function sortObjectKeys(obj: any): any {
80+
if (typeof obj !== 'object' || obj === null) return obj
81+
if (Array.isArray(obj)) return obj.map(sortObjectKeys)
82+
83+
const sorted: any = {}
84+
for (const key of Object.keys(obj).sort()) {
85+
sorted[key] = sortObjectKeys(obj[key])
86+
}
87+
return sorted
88+
}

lib/core/strategies/index.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Strategy runner - executes all enabled pruning strategies and collects results.
3+
*/
4+
5+
import type { PruningStrategy, StrategyResult, ToolMetadata } from "./types"
6+
import { deduplicationStrategy } from "./deduplication"
7+
8+
export type { PruningStrategy, StrategyResult, ToolMetadata, StrategyDetail } from "./types"
9+
10+
/** All available strategies */
11+
const ALL_STRATEGIES: PruningStrategy[] = [
12+
deduplicationStrategy,
13+
// Future strategies will be added here:
14+
// errorPruningStrategy,
15+
// writeReadStrategy,
16+
// partialReadStrategy,
17+
]
18+
19+
export interface RunStrategiesResult {
20+
/** All tool IDs that should be pruned (deduplicated) */
21+
prunedIds: string[]
22+
/** Results keyed by strategy name */
23+
byStrategy: Map<string, StrategyResult>
24+
}
25+
26+
/**
27+
* Run all enabled strategies and collect pruned IDs.
28+
*
29+
* @param toolMetadata - Map of tool call ID to metadata
30+
* @param unprunedIds - Tool call IDs not yet pruned (chronological order)
31+
* @param protectedTools - Tool names that should never be pruned
32+
* @param enabledStrategies - Strategy names to run (defaults to all)
33+
*/
34+
export function runStrategies(
35+
toolMetadata: Map<string, ToolMetadata>,
36+
unprunedIds: string[],
37+
protectedTools: string[],
38+
enabledStrategies?: string[]
39+
): RunStrategiesResult {
40+
const byStrategy = new Map<string, StrategyResult>()
41+
const allPrunedIds = new Set<string>()
42+
43+
// Filter to enabled strategies (or all if not specified)
44+
const strategies = enabledStrategies
45+
? ALL_STRATEGIES.filter(s => enabledStrategies.includes(s.name))
46+
: ALL_STRATEGIES
47+
48+
// Track which IDs are still available for each strategy
49+
let remainingIds = unprunedIds
50+
51+
for (const strategy of strategies) {
52+
const result = strategy.detect(toolMetadata, remainingIds, protectedTools)
53+
54+
if (result.prunedIds.length > 0) {
55+
byStrategy.set(strategy.name, result)
56+
57+
// Add to overall pruned set
58+
for (const id of result.prunedIds) {
59+
allPrunedIds.add(id)
60+
}
61+
62+
// Remove pruned IDs from remaining for next strategy
63+
const prunedSet = new Set(result.prunedIds.map(id => id.toLowerCase()))
64+
remainingIds = remainingIds.filter(id => !prunedSet.has(id.toLowerCase()))
65+
}
66+
}
67+
68+
return {
69+
prunedIds: Array.from(allPrunedIds),
70+
byStrategy
71+
}
72+
}

lib/core/strategies/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Common interface for rule-based pruning strategies.
3+
* Each strategy analyzes tool metadata and returns IDs that should be pruned.
4+
*/
5+
6+
export interface ToolMetadata {
7+
tool: string
8+
parameters?: any
9+
}
10+
11+
export interface StrategyResult {
12+
/** Tool call IDs that should be pruned */
13+
prunedIds: string[]
14+
/** Optional details about what was pruned and why */
15+
details?: Map<string, StrategyDetail>
16+
}
17+
18+
export interface StrategyDetail {
19+
toolName: string
20+
parameterKey: string
21+
reason: string
22+
/** Additional info specific to the strategy */
23+
[key: string]: any
24+
}
25+
26+
export interface PruningStrategy {
27+
/** Unique identifier for this strategy */
28+
name: string
29+
30+
/**
31+
* Analyze tool metadata and determine which tool calls should be pruned.
32+
*
33+
* @param toolMetadata - Map of tool call ID to metadata (tool name + parameters)
34+
* @param unprunedIds - Tool call IDs that haven't been pruned yet (chronological order)
35+
* @param protectedTools - Tool names that should never be pruned
36+
* @returns IDs to prune and optional details
37+
*/
38+
detect(
39+
toolMetadata: Map<string, ToolMetadata>,
40+
unprunedIds: string[],
41+
protectedTools: string[]
42+
): StrategyResult
43+
}

lib/fetch-wrapper/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { PluginConfig } from "../config"
66
import { handleOpenAIChatAndAnthropic } from "./openai-chat"
77
import { handleGemini } from "./gemini"
88
import { handleOpenAIResponses } from "./openai-responses"
9-
import { detectDuplicates } from "../core/deduplicator"
9+
import { runStrategies } from "../core/strategies"
1010

1111
export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./types"
1212

@@ -79,18 +79,22 @@ export function installFetchWrapper(
7979
}
8080
}
8181

82-
// Run deduplication after handlers have populated toolParameters cache
82+
// Run strategies after handlers have populated toolParameters cache
8383
const sessionId = state.lastSeenSessionId
8484
if (sessionId && state.toolParameters.size > 0) {
8585
const toolIds = Array.from(state.toolParameters.keys())
8686
const alreadyPruned = state.prunedIds.get(sessionId) ?? []
8787
const alreadyPrunedLower = new Set(alreadyPruned.map(id => id.toLowerCase()))
8888
const unpruned = toolIds.filter(id => !alreadyPrunedLower.has(id.toLowerCase()))
8989
if (unpruned.length > 1) {
90-
const { duplicateIds } = detectDuplicates(state.toolParameters, unpruned, config.protectedTools)
91-
if (duplicateIds.length > 0) {
90+
const result = runStrategies(
91+
state.toolParameters,
92+
unpruned,
93+
config.protectedTools
94+
)
95+
if (result.prunedIds.length > 0) {
9296
// Normalize to lowercase to match janitor's ID normalization
93-
const normalizedIds = duplicateIds.map(id => id.toLowerCase())
97+
const normalizedIds = result.prunedIds.map(id => id.toLowerCase())
9498
state.prunedIds.set(sessionId, [...new Set([...alreadyPruned, ...normalizedIds])])
9599
}
96100
}

lib/ui/notification.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ export function formatPruningResultForTool(
159159

160160
// ============================================================================
161161
// Summary building helpers
162+
// Groups pruned tool IDs by tool name with their key parameter (file path, command, etc.)
163+
// for human-readable display: e.g. "read (3): foo.ts, bar.ts, baz.ts"
162164
// ============================================================================
163165

164166
export function buildToolsSummary(

0 commit comments

Comments
 (0)