Skip to content

feat(middleware/context-compression): Add tool response deduplication - #6267

Draft
ssbushi wants to merge 1 commit into
sb/context-compression-1-corefrom
sb/context-compression-2-dedup
Draft

feat(middleware/context-compression): Add tool response deduplication#6267
ssbushi wants to merge 1 commit into
sb/context-compression-1-corefrom
sb/context-compression-2-dedup

Conversation

@ssbushi

@ssbushi ssbushi commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
  • deduplicateToolResponses: Prunes repeated tool outputs, replacing them with a replacement notice while preserving recent responses.
  • Input matching: Supports matching by tool name and exact input arguments (name-and-input) or tool name only (name-only).

@github-actions github-actions Bot added docs Improvements or additions to documentation js labels Sep 2, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +339 to +408
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 };
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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;
      });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation js

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant