Quadratic tool-call repair before message-filtered requests · Issue #7746 · CopilotKit/CopilotKit · GitHub
Skip to content

Quadratic tool-call repair before message-filtered requests #7746

Open

Description

@ZenAlexa

Summary

messageFilter repairs a retained parallel tool-call group in quadratic time. Each retained tool result expands its issuing assistant message again, including every sibling result that has already been emitted.

On current main (be3a15c82f0df78e5c90b2f07f178662d9e1e97e), one assistant message with N tool calls and its N result messages causes exactly N² + 2N tool-call ID reads. The input and output each contain N + 1 messages.

Reproduction

Run from the repository root with Node 22.23.3:

node --experimental-strip-types --input-type=module <<'JS'
import { ɵrepairToolCallPairs } from './packages/core/src/core/message-filter.ts';

for (const n of [1, 2, 4, 8, 16, 64, 256, 1024]) {
  let reads = 0;
  const toolCalls = Array.from({ length: n }, (_, i) => ({
    get id() { reads++; return `call-${i}`; },
    type: 'function',
    function: { name: 'lookup', arguments: '{}' },
  }));
  const messages = [
    { id: 'assistant', role: 'assistant', content: '', toolCalls },
    ...Array.from({ length: n }, (_, i) => ({
      id: `result-${i}`, role: 'tool', toolCallId: `call-${i}`, content: 'done',
    })),
  ];
  const result = ɵrepairToolCallPairs(messages, messages);
  console.log({ n, messages: result.length, reads });
}
JS

The getter counts work performed by the repair; it is excluded from latency measurements. With plain objects, five local runs of the same repair took 24.1–26.8 ms at 1,024 calls and 504.6–521.0 ms at 4,096 calls on Node 22.23.3. These are high-fanout stress inputs and isolated repair timings.

Mechanism and affected path

emit deduplicates output by message ID. emitWithResults scans toolCallIdsOf(message) on every invocation, including invocations for an issuer whose results have already been emitted. The retained result loop invokes it once per sibling.

ProxiedCopilotRuntimeAgent.#applyMessageFilter calls this repair before HTTP run and connect requests. All four framework providers expose messageFilter. The pairing behavior introduced in #6926 remains necessary: restoring one tool result must restore its issuer and every available sibling result.

Expected behavior

Expand each distinct issuer object once per repair, retaining output order, message-ID deduplication, open calls, orphan results, and filter-synthesized messages. Object identity matters here: a filter can return a changed assistant object with the same ID as the untrimmed issuer, and those two objects can reference different call sets.

The target is one traversal of the full input followed by one expansion of each distinct retained or restored issuer object, with the same outbound content and existing fallback behavior.

Activity

  1. copilotkit-outpost-bot commented on Oct 9, 2026

    @copilotkit-outpost-bot

    ⚠️ This is an AI-generated response. We've escalated this to our engineering team — someone will follow up in this thread shortly.

    The report is accurate about the mechanism. emitWithResults is called once per retained tool result, and each call iterates toolCallIdsOf(issuer) — which is O(N) — so a single assistant message with N parallel calls produces O(N²) work. The emitted set prevents duplicate output, but the iteration still happens.

    The fix is to track which issuers have already been expanded and skip the toolCallIdsOf scan on repeat visits. Here is a drop-in replacement for the loop in ɵrepairToolCallPairs (packages/core/src/core/message-filter.ts):

    Code example (ts)
    const expandedIssuers = new Set<string>();
    
    for (const message of kept) {
      const resultCallId = resultCallIdOf(message);
      if (resultCallId === undefined) {
        emitWithResults(message);
        continue;
      }
    
      const issuer = callIdToIssuer.get(resultCallId);
      if (issuer) {
        if (!expandedIssuers.has(issuer.id)) {
          expandedIssuers.add(issuer.id);
          emitWithResults(issuer); // emits issuer + all its results once
        }
        // result was already emitted by emitWithResults above; emit() is idempotent
        // but skipping the issuer lookup on subsequent siblings avoids the O(N) scan
        continue;
      }
      emit(message);
    }

    expandedIssuers is keyed on issuer.id (the message ID from the untrimmed thread), which is what callIdToIssuer maps to. A filter-synthesized assistant object with the same ID as the original would be treated as the same issuer — that matches the existing emitted set behaviour, which also keys on message.id. If the expected behaviour for synthesized objects with colliding IDs needs to differ, that is a separate design question for the maintainers.

    This keeps output order, the emitted-set deduplication, open calls, and orphan-result pass-through unchanged. Work per repair becomes O(M + C) where M is the number of kept messages and C is the total number of distinct call IDs across all issuers — linear in the input.

    Engineering will need to verify the fix and confirm whether the synthesized-object edge case warrants additional handling before merging.


    🤖 Generated by CopilotKit AI Support · Documentation


    Was this helpful? React with 👍 or 👎

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions