How to Fix 'duplicate tool calls during development' in LlamaIndex (TypeScript)

By Cyprian AaronsUpdated 2026-04-21
duplicate-tool-calls-during-developmentllamaindextypescript

When you see duplicate tool calls during development in a LlamaIndex TypeScript app, it usually means the same tool invocation is being triggered more than once for a single model turn. In practice, this shows up during agent runs, streaming handlers, or React dev mode when your orchestration code gets executed twice.

The error is not about your tool being wrong. It usually means your app is re-entering the same execution path and LlamaIndex detects repeated tool-call state like ToolCall, ToolOutput, or AgentWorker events for the same step.

The Most Common Cause

The #1 cause is double execution from React Strict Mode or duplicate event wiring. In development, React intentionally runs effects twice, and if you start an agent inside useEffect without guarding it, the same LlamaIndex query can fire twice.

Here’s the broken pattern:

BrokenFixed
Starts the agent on every render/effect runGuards against duplicate execution
// Broken: runs twice in React dev mode
import { useEffect } from "react";
import { OpenAI } from "@llamaindex/openai";
import { ReActAgent } from "llamaindex";

const llm = new OpenAI({ model: "gpt-4o-mini" });
const agent = new ReActAgent({
  llm,
  tools: [searchTool],
});

export function ChatPanel() {
  useEffect(() => {
    agent.chat("Find policy details").then(console.log);
  }, []);

  return null;
}
// Fixed: guard the effect so it only runs once
import { useEffect, useRef } from "react";
import { OpenAI } from "@llamaindex/openai";
import { ReActAgent } from "llamaindex";

const llm = new OpenAI({ model: "gpt-4o-mini" });
const agent = new ReActAgent({
  llm,
  tools: [searchTool],
});

export function ChatPanel() {
  const started = useRef(false);

  useEffect(() => {
    if (started.current) return;
    started.current = true;

    void agent.chat("Find policy details").then(console.log);
  }, []);

  return null;
}

If you are using useChat, useQuery, or any custom hook that triggers an agent call, apply the same rule: one user action should map to one request.

Other Possible Causes

1. Registering the same tool twice

If you pass the same tool instance into multiple places, LlamaIndex can end up seeing duplicated tool metadata or duplicate handlers.

const searchTool = new FunctionTool({
  name: "search",
  description: "Search internal docs",
  fn: async ({ query }) => doSearch(query),
});

const agent = new ReActAgent({
  llm,
  tools: [searchTool, searchTool], // wrong
});

Fix:

const agent = new ReActAgent({
  llm,
  tools: [searchTool], // correct
});

2. Reusing one mutable agent across concurrent requests

A shared singleton ReActAgent or ChatEngine can leak state between requests if you call it concurrently in a server route.

// risky in high-concurrency handlers
const agent = new ReActAgent({ llm, tools });

export async function POST(req: Request) {
  const body = await req.json();
  return Response.json(await agent.chat(body.message));
}

Better:

export async function POST(req: Request) {
  const body = await req.json();

  const agent = new ReActAgent({ llm, tools });
  return Response.json(await agent.chat(body.message));
}

If you need reuse, make sure the class is documented as stateless for your exact version.

3. Calling both streaming and non-streaming paths

Some teams accidentally invoke chat() and streamChat() for the same request because UI state and logging are wired separately.

await agent.chat(message);
for await (const chunk of await agent.streamChat(message)) {
  process.stdout.write(chunk.delta ?? "");
}

Pick one:

for await (const chunk of await agent.streamChat(message)) {
  process.stdout.write(chunk.delta ?? "");
}

4. Custom callback handlers re-emitting events

If you attach a callback handler that forwards tool events back into the same pipeline, you can create a loop that looks like duplicate tool calls.

const handler = {
  onToolStart(event) {
    emitter.emit("tool:start", event); // may re-trigger your own listener
  },
};

Keep callback handling side-effect free:

const handler = {
  onToolStart(event) {
    logger.info({ toolName: event.toolName }, "tool started");
  },
};

How to Debug It

  1. Check whether the request fires twice

    • Add a log at the top of your handler.
    • If you see two identical request IDs in development, this is not a tool bug.
  2. Log every tool invocation

    • Print the tool name and input before execution.
    • In LlamaIndex TS, inspect where FunctionTool, QueryEngineTool, or custom tool wrappers are called.
const searchTool = new FunctionTool({
  name: "search",
  description: "Search internal docs",
  fn: async ({ query }) => {
    console.log("[tool] search", query);
    return doSearch(query);
  },
});
  1. Disable React Strict Mode temporarily

    • If the issue disappears, your problem is double effect execution.
    • Fix the effect guard instead of leaving Strict Mode off.
  2. Inspect for shared state

    • Search for module-level singletons like const agent = ....
    • If multiple requests share one instance and your version keeps per-run state internally, instantiate per request.

Prevention

  • Keep one user action mapped to one LlamaIndex run.
  • Treat React effects as “may run more than once” in development.
  • Build agents and tool registries per request unless you have verified they are safe to share.
  • Add request IDs and tool logs early so duplicate execution is obvious before it hits production.

If you’re seeing this with a specific stack trace like Duplicate tool call detected or repeated ToolCallEvent entries, start with Strict Mode and duplicate handler registration first. In TypeScript apps using LlamaIndex, that’s where this error usually comes from.


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