How to Fix 'duplicate tool calls' in LangGraph (TypeScript)

By Cyprian AaronsUpdated 2026-04-21
duplicate-tool-callslanggraphtypescript

What “duplicate tool calls” usually means

In LangGraph, this error usually shows up when the same tool invocation is emitted twice for the same assistant turn, or when your graph replays a state that already contains tool results. In practice, it happens most often in TypeScript when you accidentally run the same node twice, append messages incorrectly, or let an agent model call tools while your graph also injects tool execution.

The error often appears alongside LangChain/LangGraph message classes like AIMessage, ToolMessage, and ToolNode, and the failure tends to surface during checkpoint replay or after a conditional edge sends execution back into the tool path.

The Most Common Cause

The #1 cause is double-appending messages or reusing the same messages array across runs. LangGraph expects message state to be treated immutably; if you manually push tool results into state and also let ToolNode do it, you end up with duplicate tool call handling.

Here’s the broken pattern:

BrokenFixed
```ts
import { StateGraph, Annotation } from "@langchain/langgraph";
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";

const State = Annotation.Root({ messages: Annotation<any[]>({ reducer: (left, right) => left.concat(right), default: () => [], }), });

const graph = new StateGraph(State) .addNode("agent", async (state) => { const ai = await model.invoke(state.messages); // ❌ manually mutating + returning same array shape state.messages.push(ai); return { messages: state.messages }; }) .addNode("tools", async (state) => { const last = state.messages[state.messages.length - 1] as AIMessage; const toolResult = await runTool(last.tool_calls![0]); // ❌ duplicate tool message creation return { messages: [ new ToolMessage({ content: toolResult, tool_call_id: last.tool_calls![0].id, }), ], }; }); |ts import { StateGraph, Annotation } from "@langchain/langgraph"; import { AIMessage } from "@langchain/core/messages"; import { ToolNode } from "@langchain/langgraph/prebuilt";

const State = Annotation.Root({ messages: Annotation<any[]>({ reducer: (left, right) => left.concat(right), default: () => [], }), });

const tools = [weatherTool]; const toolNode = new ToolNode(tools);

const graph = new StateGraph(State) .addNode("agent", async (state) => { const ai = await model.bindTools(tools).invoke(state.messages); return { messages: [ai] }; // ✅ return only the new message }) .addNode("tools", toolNode) .addEdge("start", "agent") .addConditionalEdges("agent", (state) => { const last = state.messages[state.messages.length - 1] as AIMessage; return last.tool_calls?.length ? "tools" : "end"; }) .addEdge("tools", "agent");


The key rule is simple:

- Return only the new messages from each node
- Let `ToolNode` create `ToolMessage`s
- Don’t mutate `state.messages` in place
- Don’t manually re-add prior history unless you’re intentionally reconstructing state

If you’re using an agent helper like `createReactAgent`, the same issue can happen if you also add your own tool execution node. Pick one orchestration path.

## Other Possible Causes

### 1. Your model is not actually bound to tools

If the assistant emits `tool_calls` but the runtime doesn’t know how to execute them correctly, you can get repeated attempts or inconsistent replay behavior.

```ts
// Broken
const ai = await model.invoke(messages); // no bindTools()

// Fixed
const ai = await model.bindTools([searchTool]).invoke(messages);

Without bindTools(), some models still produce structured tool-call-like output, but your graph may not route it cleanly through ToolNode.

2. You are looping back to the agent without clearing state correctly

A bad conditional edge can send the same assistant message back into tools repeatedly.

// Broken: always routes to tools if any tool call exists anywhere in history
.addConditionalEdges("agent", (state) =>
  state.messages.some((m) => "tool_calls" in m && m.tool_calls?.length)
    ? "tools"
    : "__end__"
)

// Fixed: inspect only the latest AIMessage
.addConditionalEdges("agent", (state) => {
  const last = state.messages[state.messages.length - 1];
  return "tool_calls" in last && last.tool_calls?.length ? "tools" : "__end__";
})

Use the latest assistant turn only. Looking at full history almost always causes stale tool calls to fire again.

3. Checkpoint replay is restoring a partially executed turn

If you use a checkpointer and resume from an interrupted run, LangGraph may restore a state where an AIMessage with pending tool_calls already exists.

const graph = builder.compile({
  checkpointer,
});

// If you rerun with the same thread id and same input,
// you may replay pending tool calls.
await graph.invoke(
  { messages: [new HumanMessage("Check policy status")] },
  { configurable: { thread_id: "abc-123" } }
);

Fix:

  • Use a fresh thread_id for fresh conversations
  • Inspect persisted state before resuming
  • Make sure your nodes are idempotent

4. Your reducer is concatenating duplicates from parallel branches

If two branches both emit overlapping message arrays into the same channel, your reducer can merge them into duplicated history.

messages: Annotation<any[]>({
  reducer: (left, right) => [...left, ...right],
  default: () => [],
})

That reducer is fine only if each branch emits unique messages. If both branches forward the same prior array plus a new item, duplicates are guaranteed.

Prefer returning deltas only:

return { messages: [newAiMessage] };

How to Debug It

  1. Log the exact last message before routing

    • Print state.messages.at(-1) in every node.
    • Confirm whether the duplicate starts at the agent step or after tool execution.
  2. Check whether you are mutating arrays

    • Search for .push( on state.messages.
    • In LangGraph TypeScript, treat state as immutable input and return patches only.
  3. Verify your routing logic

    • Make sure conditional edges inspect only the latest AIMessage.
    • If your route function checks all history, it will re-trigger old tool calls.
  4. Inspect checkpointed thread state

    • If using a checkpointer, dump stored state for that thread.
    • Look for repeated AIMessage.tool_calls entries or duplicated ToolMessages with the same tool_call_id.

Prevention

  • Always use bindTools() on the model when tools are part of the graph.
  • Return only incremental updates from each node; never re-return full message history unless required.
  • Use one orchestration style:
    • either manual graph + ToolNode
    • or prebuilt agent helpers
      Don’t mix both for the same flow.
  • When adding checkpoints, make nodes idempotent and test resume behavior separately from first-run behavior.

If you want a quick sanity check, compare your graph against this rule:

  • One assistant turn produces one AIMessage
  • Each tool call produces one matching ToolMessage
  • No node should create both for the same step unless it owns that responsibility end-to-end

Keep learning

By Cyprian Aarons, AI Consultant at Topiax.

Want the complete 8-step roadmap?

Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.

Get the Starter Kit

Related Guides