[GH-ISSUE #127] PatchToolCallsMiddleware triggers unnecessary REMOVE_ALL_MESSAGES on every request #32

Closed
opened 2026-02-16 06:16:55 -05:00 by yindo · 1 comment
Owner

Originally created by @magi-wang on GitHub (Jan 18, 2026).
Original GitHub issue: https://github.com/langchain-ai/deepagentsjs/issues/127

Originally assigned to: @christian-bromann on GitHub.

Issue: PatchToolCallsMiddleware triggers unnecessary REMOVE_ALL_MESSAGES on every request

Summary

The PatchToolCallsMiddleware in DeepAgents.js unconditionally triggers REMOVE_ALL_MESSAGES and rebuilds the entire message history on every single request, even when there are no dangling tool calls to patch. This causes unnecessary frontend re-renders and visible UI flickering (messages briefly disappearing and reappearing).

Environment

  • Package: deepagents (TypeScript/JavaScript)
  • Affected Middleware: PatchToolCallsMiddleware
  • File: libs/deepagents/src/middleware/patch_tool_calls.ts

Current Behavior

The middleware always returns:

return {
  messages: [
    new RemoveMessage({ id: REMOVE_ALL_MESSAGES }),
    ...patchedMessages,
  ],
};

This happens even when:

  • There are no AI messages with tool_calls
  • All tool calls have corresponding ToolMessage responses
  • The message history is completely valid

Impact on Frontend

When using LangGraph SDK's useStream hook with fetchStateHistory: true, the frontend receives:

  1. event: messages{ id: "__remove_all__", type: "remove" }
  2. event: values{ messages: [] } (empty array)
  3. event: values{ messages: [...all messages] } (restored)

This causes:

  • React components to re-render unnecessarily
  • Scroll position jumps (when using auto-scroll libraries like use-stick-to-bottom)
  • Visual flickering as the message list briefly empties

Expected Behavior

The middleware should only trigger REMOVE_ALL_MESSAGES when it actually needs to patch dangling tool calls. If no patching is required, it should return early without modifying the state.

Root Cause Analysis

Why Python version doesn't have this issue

The Python version of DeepAgents uses LangGraph's Overwrite class:

# Python: deepagents/middleware/patch_tool_calls.py
return {"messages": Overwrite(patched_messages)}

Overwrite bypasses the reducer and writes directly to the channel, so:

  • No REMOVE_ALL_MESSAGES event is sent
  • Frontend doesn't see the temporary clearing
  • No unnecessary re-renders

Why TypeScript version has this issue

LangGraph.js does not implement the Overwrite class (as of latest version). The only way to "replace" messages in the middle of the array is to:

  1. Clear all messages with REMOVE_ALL_MESSAGES
  2. Rebuild with patched messages

This is necessary only when patching is needed, but the current implementation does it unconditionally.

Proposed Solution

Add a needsPatch flag to track whether any dangling tool calls were found:

export function createPatchToolCallsMiddleware() {
  return createMiddleware({
    name: "patchToolCallsMiddleware",
    beforeAgent: async (state) => {
      const messages = state.messages;

      if (!messages || messages.length === 0) {
        return; // Early exit
      }

      const patchedMessages: any[] = [];
      let needsPatch = false; // ← Track if patching is needed

      for (let i = 0; i < messages.length; i++) {
        const msg = messages[i];
        patchedMessages.push(msg);

        if (AIMessage.isInstance(msg) && msg.tool_calls != null) {
          for (const toolCall of msg.tool_calls) {
            const correspondingToolMsg = messages
              .slice(i)
              .find(
                (m: any) =>
                  ToolMessage.isInstance(m) && m.tool_call_id === toolCall.id,
              );

            if (!correspondingToolMsg) {
              needsPatch = true; // ← Mark as needing patch
              const toolMsg = `Tool call ${toolCall.name} with id ${toolCall.id} was cancelled - another message came in before it could be completed.`;
              patchedMessages.push(
                new ToolMessage({
                  content: toolMsg,
                  name: toolCall.name,
                  tool_call_id: toolCall.id!,
                }),
              );
            }
          }
        }
      }

      // ✅ Only trigger REMOVE_ALL if patching is actually needed
      if (!needsPatch) {
        return; // Early exit - no state modification
      }

      return {
        messages: [
          new RemoveMessage({ id: REMOVE_ALL_MESSAGES }),
          ...patchedMessages,
        ],
      };
    },
  });
}

Benefits

  1. Performance: Eliminates unnecessary state clears when no patching is needed
  2. UX: Prevents frontend flickering in the common case (no dangling tool calls)
  3. Consistency: Matches the Python version's behavior (silent when no patching needed)
  4. Backward Compatible: Only changes behavior when no patching is needed (which was unnecessary anyway)

Reproduction

  1. Create a simple agent with createDeepAgent()
  2. Send a message: "Hello"
  3. Observe the SSE stream events
  4. You'll see REMOVE_ALL_MESSAGES even though there were no tool calls
import { createDeepAgent } from 'deepagents';

const agent = createDeepAgent();
const stream = await agent.stream({ messages: [{ role: 'human', content: 'Hello' }] });

for await (const chunk of stream) {
  console.log(chunk); // Will show REMOVE_ALL_MESSAGES event
}

Alternative: Implement Overwrite in LangGraph.js

The long-term solution would be to implement the Overwrite class in LangGraph.js (similar to Python's implementation). This would allow middleware to bypass the reducer entirely and avoid emitting unnecessary stream events.

However, until that's available, the needsPatch flag optimization is a simple fix that significantly improves the user experience.

Workaround (Frontend)

Until this is fixed, frontend applications can work around the issue by caching the messa

Originally created by @magi-wang on GitHub (Jan 18, 2026). Original GitHub issue: https://github.com/langchain-ai/deepagentsjs/issues/127 Originally assigned to: @christian-bromann on GitHub. # Issue: PatchToolCallsMiddleware triggers unnecessary REMOVE_ALL_MESSAGES on every request ## Summary The `PatchToolCallsMiddleware` in DeepAgents.js unconditionally triggers `REMOVE_ALL_MESSAGES` and rebuilds the entire message history on **every single request**, even when there are no dangling tool calls to patch. This causes unnecessary frontend re-renders and visible UI flickering (messages briefly disappearing and reappearing). ## Environment - **Package**: `deepagents` (TypeScript/JavaScript) - **Affected Middleware**: `PatchToolCallsMiddleware` - **File**: `libs/deepagents/src/middleware/patch_tool_calls.ts` ## Current Behavior The middleware **always** returns: ```typescript return { messages: [ new RemoveMessage({ id: REMOVE_ALL_MESSAGES }), ...patchedMessages, ], }; ``` This happens even when: - There are no AI messages with `tool_calls` - All tool calls have corresponding `ToolMessage` responses - The message history is completely valid ### Impact on Frontend When using LangGraph SDK's `useStream` hook with `fetchStateHistory: true`, the frontend receives: 1. `event: messages` → `{ id: "__remove_all__", type: "remove" }` 2. `event: values` → `{ messages: [] }` (empty array) 3. `event: values` → `{ messages: [...all messages] }` (restored) This causes: - React components to re-render unnecessarily - Scroll position jumps (when using auto-scroll libraries like `use-stick-to-bottom`) - Visual flickering as the message list briefly empties ## Expected Behavior The middleware should **only** trigger `REMOVE_ALL_MESSAGES` when it actually needs to patch dangling tool calls. If no patching is required, it should return early without modifying the state. ## Root Cause Analysis ### Why Python version doesn't have this issue The Python version of DeepAgents uses LangGraph's `Overwrite` class: ```python # Python: deepagents/middleware/patch_tool_calls.py return {"messages": Overwrite(patched_messages)} ``` **`Overwrite`** bypasses the reducer and writes directly to the channel, so: - ✅ No `REMOVE_ALL_MESSAGES` event is sent - ✅ Frontend doesn't see the temporary clearing - ✅ No unnecessary re-renders ### Why TypeScript version has this issue LangGraph.js **does not implement the `Overwrite` class** (as of latest version). The only way to "replace" messages in the middle of the array is to: 1. Clear all messages with `REMOVE_ALL_MESSAGES` 2. Rebuild with patched messages This is necessary **only when patching is needed**, but the current implementation does it unconditionally. ## Proposed Solution Add a `needsPatch` flag to track whether any dangling tool calls were found: ```typescript export function createPatchToolCallsMiddleware() { return createMiddleware({ name: "patchToolCallsMiddleware", beforeAgent: async (state) => { const messages = state.messages; if (!messages || messages.length === 0) { return; // Early exit } const patchedMessages: any[] = []; let needsPatch = false; // ← Track if patching is needed for (let i = 0; i < messages.length; i++) { const msg = messages[i]; patchedMessages.push(msg); if (AIMessage.isInstance(msg) && msg.tool_calls != null) { for (const toolCall of msg.tool_calls) { const correspondingToolMsg = messages .slice(i) .find( (m: any) => ToolMessage.isInstance(m) && m.tool_call_id === toolCall.id, ); if (!correspondingToolMsg) { needsPatch = true; // ← Mark as needing patch const toolMsg = `Tool call ${toolCall.name} with id ${toolCall.id} was cancelled - another message came in before it could be completed.`; patchedMessages.push( new ToolMessage({ content: toolMsg, name: toolCall.name, tool_call_id: toolCall.id!, }), ); } } } } // ✅ Only trigger REMOVE_ALL if patching is actually needed if (!needsPatch) { return; // Early exit - no state modification } return { messages: [ new RemoveMessage({ id: REMOVE_ALL_MESSAGES }), ...patchedMessages, ], }; }, }); } ``` ## Benefits 1. **Performance**: Eliminates unnecessary state clears when no patching is needed 2. **UX**: Prevents frontend flickering in the common case (no dangling tool calls) 3. **Consistency**: Matches the Python version's behavior (silent when no patching needed) 4. **Backward Compatible**: Only changes behavior when no patching is needed (which was unnecessary anyway) ## Reproduction 1. Create a simple agent with `createDeepAgent()` 2. Send a message: "Hello" 3. Observe the SSE stream events 4. You'll see `REMOVE_ALL_MESSAGES` even though there were no tool calls ```typescript import { createDeepAgent } from 'deepagents'; const agent = createDeepAgent(); const stream = await agent.stream({ messages: [{ role: 'human', content: 'Hello' }] }); for await (const chunk of stream) { console.log(chunk); // Will show REMOVE_ALL_MESSAGES event } ``` ## Alternative: Implement `Overwrite` in LangGraph.js The long-term solution would be to implement the `Overwrite` class in LangGraph.js (similar to Python's implementation). This would allow middleware to bypass the reducer entirely and avoid emitting unnecessary stream events. However, until that's available, the `needsPatch` flag optimization is a simple fix that significantly improves the user experience. ## Workaround (Frontend) Until this is fixed, frontend applications can work around the issue by caching the messa
yindo closed this issue 2026-02-16 06:16:55 -05:00
Author
Owner

@christian-bromann commented on GitHub (Jan 18, 2026):

Thanks for filling the issue, will take a look!

@christian-bromann commented on GitHub (Jan 18, 2026): Thanks for filling the issue, will take a look!
yindo changed title from PatchToolCallsMiddleware triggers unnecessary REMOVE_ALL_MESSAGES on every request to [GH-ISSUE #127] PatchToolCallsMiddleware triggers unnecessary REMOVE_ALL_MESSAGES on every request 2026-06-05 17:21:04 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/deepagentsjs#32