Skip to content

Testing What Happens When the Model Calls a Tool That Doesn't Exist

9 min read · updated August 11, 2026

TypeError: tools[name] is not a function, or in Python KeyError: 'get_wether'. The model asked for a tool you never defined, your dispatcher looked it up, and the lookup returned nothing. The crash is the easy half.

The error you actually get

There is no provider error for this, which is why it surprises people. The API returns 200. The response is well-formed. The tool call inside it names something that is not in the tools array you sent, and the first thing that notices is your own dispatch line:

# Python
KeyError: 'get_wether'
  File "agent/loop.py", line 44, in run
    result = TOOLS[call.function.name](**json.loads(call.function.arguments))

// TypeScript
TypeError: tools[call.function.name] is not a function
    at runAgent (agent/loop.ts:31:20)

Three variants of the same thing, and they are worth telling apart. A near-miss on spelling (get_wether) usually means the model is reproducing the name from the conversation rather than from the tool list. A name from a different deployment (search_docs_v1 when you now ship search_docs) means an old tool name is somewhere in the context — often in a few-shot example or a stale conversation being replayed. A name that never existed anywhere means the model composed something plausible from your tool descriptions. Which one you have is visible in a single log line, so log it: print the requested name next to the registered names on every occurrence. The difference between them tells you whether to fix a description, purge a transcript, or build a tool. Without it the only artefact is a stack trace saying a key was missing, which is true of all three causes and useful for none of them.

Why a name you never sent comes back

Tool calling is still generation. The name is emitted token by token from a distribution, and providers constrain it to the supplied set to varying degrees and not always completely. Anything that makes the right name harder to reach makes an invented one likelier:

  • A long tool list, where the names are far from the end of the prompt.
  • Names that differ by one token — get_order and get_orders — which is a token-level coin flip.
  • A conversation history containing tool names that are no longer registered. Replayed transcripts and cached prefixes both do this.
  • A system prompt that describes a capability in prose without a tool behind it. “You can also cancel orders” with no cancel_order tool is an instruction to invent one.
  • High temperature. If you are running tool selection above zero without a reason, that is the cheapest thing to change.

The second failure, after the obvious fix

The obvious fix is to skip unknown tools. That produces a different and more confusing error on the next request, because both APIs require every tool call to be answered. On Chat Completions the rejection names the ids that went unanswered — wording along the lines of an assistant message with tool_calls having to be followed by tool messages responding to each tool_call_id, listing the ones that were missing. The Messages API rejects the same situation for tool_use blocks without a matching tool_result.

Provider error wording changes. Match on the HTTP status and the structural condition — a call id with no corresponding result — rather than on the sentence, and confirm the current text against OpenAI’s function calling guide or Anthropic’s tool use documentation.

So “skip it” is not an option. Every call gets a result, including the ones you cannot execute.

The handler, and the test for it

Return an error result that names the problem and lists what does exist. The list is the part that makes recovery likely: a model told only “unknown tool” will often try another invented name, and a model shown the four real names usually picks one.

function unknownToolResult(call: ToolCall, available: string[]) {
  return {
    role: "tool" as const,
    tool_call_id: call.id,
    content: JSON.stringify({
      error: "unknown_tool",
      requested: call.function.name,
      available,
      message: "That tool does not exist. Call one of the available tools.",
    }),
  };
}
import { describe, it, expect, vi } from "vitest";

describe("hallucinated tool name", () => {
  it("does not throw, and answers the call id", async () => {
    const get_order = vi.fn(async () => ({ order_id: "55219" }));
    const { fn, seen } = scriptedModel([
      toolCallResponse("get_wether", { city: "Lisbon" }, "call_1"),
      textResponse("Sorry, I cannot check the weather."),
    ]);

    const res = await runAgent("weather?", { model: fn, tools: { get_order } });

    expect(res.status).toBe("complete");
    const toolMsg = seen[1].find((m: any) => m.tool_call_id === "call_1");
    expect(toolMsg).toBeDefined();
    expect(JSON.parse(toolMsg.content)).toMatchObject({
      error: "unknown_tool", requested: "get_wether", available: ["get_order"],
    });
    expect(get_order).not.toHaveBeenCalled();
  });

  it("recovers when the model retries with a real tool", async () => {
    const get_order = vi.fn(async () => ({ order_id: "55219", status: "delivered" }));
    const { fn } = scriptedModel([
      toolCallResponse("fetch_order", { order_id: "55219" }, "call_1"),
      toolCallResponse("get_order", { order_id: "55219" }, "call_2"),
      textResponse("It was delivered."),
    ]);

    await runAgent("order 55219?", { model: fn, tools: { get_order } });

    expect(get_order).toHaveBeenCalledTimes(1);
    expect(get_order).toHaveBeenCalledWith({ order_id: "55219" });
  });
});

The not.toHaveBeenCalled assertion in the first test is not decoration. The genuinely dangerous version of this bug is a dispatcher that fuzzy-matches the requested name to the closest registered one and runs it, which turns a hallucinated delete_all_orders into a call to something real. Never resolve tool names approximately; make the model choose again.

The second test is the one worth keeping for the long run, because it asserts the behaviour you want rather than the absence of a crash. A loop can survive an unknown tool and still be useless: if the error result does not say what exists, the model spends its remaining steps guessing and the run ends at the step limit instead of with an answer. Asserting that the real tool ran exactly once, with the right arguments, pins recovery rather than survival.

Cap the retries. Two unknown-tool results and then a terminal status, for the same reason every other retry in the loop is capped — see the step-limit guard.

Making it rarer

Handling it correctly costs a wasted turn every time it happens, so it is worth reducing. Keep names distinct at the token level rather than at the eye level. Delete prose in the system prompt describing capabilities that have no tool. Strip tool calls naming removed tools out of any conversation you replay, rather than trusting the model to ignore them — a transcript is a strong demonstration that a name is legitimate, and a model shown three prior calls to search_docs_v1 will make a fourth. Watch prompt caching for the same reason: a cached prefix holding an old tool list keeps that list alive long after you edited the definition. And log the requested name every time this fires: a single invented name appearing hundreds of times is not randomness, it is a missing tool your users are asking for, and the fix is to build it. The nearby case where the model calls nothing at all when it should have has the opposite causes and is worth reading alongside this.