feat(middleware/context-compression): Add tool response deduplication - #6267
feat(middleware/context-compression): Add tool response deduplication#6267ssbushi wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a tool response deduplication strategy to the context compression middleware, allowing duplicate tool responses to be replaced with a short notice to save context space. A critical bug was identified in the deduplication implementation: when a message contains multiple tool responses (such as in parallel tool calls), deduplicating one response incorrectly replaces all other responses in that same message. It is recommended to track and replace duplicates at the individual part level rather than the message level to prevent silent data loss.
| const groups = new Map<string, number[]>(); | ||
| for (let i = 0; i < messages.length; i++) { | ||
| const msg = messages[i]; | ||
| if (msg.role !== 'tool') continue; | ||
|
|
||
| for (const part of msg.content) { | ||
| if (!part.toolResponse) continue; | ||
|
|
||
| let toolInput = part.toolResponse.ref | ||
| ? toolInputByRef.get(part.toolResponse.ref) | ||
| : undefined; | ||
|
|
||
| // If no ref was matched, check if preceding model message had a matching toolRequest with input | ||
| if ( | ||
| toolInput === undefined && | ||
| i > 0 && | ||
| messages[i - 1]?.role === 'model' | ||
| ) { | ||
| const reqPart = messages[i - 1].content.find( | ||
| (p) => p.toolRequest?.name === part.toolResponse?.name | ||
| ); | ||
| if (reqPart?.toolRequest) { | ||
| toolInput = reqPart.toolRequest.input; | ||
| } | ||
| } | ||
|
|
||
| const key = | ||
| dedupMatchBy === 'name-only' | ||
| ? part.toolResponse.name | ||
| : JSON.stringify({ | ||
| name: part.toolResponse.name, | ||
| input: toolInput, | ||
| }); | ||
| if (!groups.has(key)) groups.set(key, []); | ||
| groups.get(key)!.push(i); | ||
| } | ||
| } | ||
|
|
||
| const indicesToReplace = new Set<number>(); | ||
| for (const indices of groups.values()) { | ||
| if (indices.length > dedupKeepRecent) { | ||
| const toRemove = indices.slice(0, indices.length - dedupKeepRecent); | ||
| for (const idx of toRemove) { | ||
| indicesToReplace.add(idx); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (indicesToReplace.size === 0) { | ||
| return { messages, deduplicated: 0 }; | ||
| } | ||
|
|
||
| let deduplicatedCount = 0; | ||
| const result = messages.map((msg, idx) => { | ||
| if (!indicesToReplace.has(idx)) return msg; | ||
|
|
||
| const newContent = msg.content.map((part): Part => { | ||
| if (part.toolResponse) { | ||
| deduplicatedCount++; | ||
| return { | ||
| toolResponse: { | ||
| ...part.toolResponse, | ||
| output: dedupNotice, | ||
| }, | ||
| }; | ||
| } | ||
| return part; | ||
| }); | ||
| return { ...msg, content: newContent }; | ||
| }); |
There was a problem hiding this comment.
Critical Bug: Over-deduplication of unrelated tool responses in parallel tool calls
Currently, the deduplication logic groups occurrences by message index (i). If a message contains multiple toolResponse parts (which is extremely common when tools are executed in parallel), and at least one of those parts is identified as a duplicate, the entire message index is added to indicesToReplace.
During the replacement phase, all tool responses within any message whose index is in indicesToReplace are replaced with the deduplication notice, even if some of those tool responses were unique and not duplicates. This leads to silent data loss of valid tool outputs.
Solution
Track duplicates at the individual part level (using a composite key of messageIndex-partIndex) rather than the message level. This ensures only the specific duplicate parts are replaced.
const groups = new Map<string, { msgIdx: number; partIdx: number }[]>();
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role !== 'tool') continue;
for (let j = 0; j < msg.content.length; j++) {
const part = msg.content[j];
if (!part.toolResponse) continue;
let toolInput = part.toolResponse.ref
? toolInputByRef.get(part.toolResponse.ref)
: undefined;
// If no ref was matched, check if preceding model message had a matching toolRequest with input
if (
toolInput === undefined &&
i > 0 &&
messages[i - 1]?.role === 'model'
) {
const reqPart = messages[i - 1].content.find(
(p) => p.toolRequest?.name === part.toolResponse?.name
);
if (reqPart?.toolRequest) {
toolInput = reqPart.toolRequest.input;
}
}
const key =
dedupMatchBy === 'name-only'
? part.toolResponse.name
: JSON.stringify({
name: part.toolResponse.name,
input: toolInput,
});
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push({ msgIdx: i, partIdx: j });
}
}
const partsToReplace = new Set<string>();
for (const occurrences of groups.values()) {
if (occurrences.length > dedupKeepRecent) {
const toRemove = occurrences.slice(0, occurrences.length - dedupKeepRecent);
for (const occ of toRemove) {
partsToReplace.add(`${occ.msgIdx}-${occ.partIdx}`);
}
}
}
if (partsToReplace.size === 0) {
return { messages, deduplicated: 0 };
}
let deduplicatedCount = 0;
const result = messages.map((msg, i) => {
if (msg.role !== 'tool') return msg;
let changed = false;
const newContent = msg.content.map((part, j): Part => {
if (part.toolResponse && partsToReplace.has(`${i}-${j}`)) {
deduplicatedCount++;
changed = true;
return {
toolResponse: {
...part.toolResponse,
output: dedupNotice,
},
};
}
return part;
});
return changed ? { ...msg, content: newContent } : msg;
});
deduplicateToolResponses: Prunes repeated tool outputs, replacing them with a replacement notice while preserving recent responses.name-and-input) or tool name only (name-only).