Nurture TechnologiesNurture Tech
AI14 min read·July 24, 2026

AI Agent Tool Calls Randomly Failing in Production

TypeScriptNode.jsOpenAI SDKZodJSON Schema

Your AI agent calls the wrong tools, generates malformed JSON, or silently drops tool results. The bug is in your schema definition or response handling.

Problem Summary

OpenAI's tool calling (function calling) allows models to request structured function invocations during a completion. When the feature works correctly, the model returns a tool_calls array with valid JSON arguments matching your schema. When it fails, the model either hallucinates a tool name that does not exist, generates malformed JSON arguments, silently ignores tools that should be called, or most dangerously the application receives tool results but never feeds them back to the model, leaving the agent in an infinite loop.

Symptoms

  • Model calls a tool with a name that is not in your tools array
  • tool_calls[0].function.arguments contains invalid JSON or is truncated mid-string
  • Model returns a text response instead of a tool call when a tool call is expected
  • Parallel tool calls (multiple tools in one response) fail but sequential calls work
  • Tool results are sent back but the model generates another tool call instead of a final answer
  • finish_reason is 'length' on a tool call response max_tokens is cutting off the JSON
  • Required parameters are missing from tool call arguments despite being defined as required
  • Agent loops indefinitely calling the same tool with identical arguments

Business Impact

A broken tool call in an agent pipeline fails silently from the user's perspective the agent appears to 'think' indefinitely, returns a generic error, or produces an answer without performing the required action (e.g., claiming to have sent an email it never sent). In automated workflows where the agent takes real actions (database writes, API calls, order processing), a malformed tool call that your application fails to validate can trigger partial or duplicate operations.

Root Cause

The most common root cause is an invalid or inconsistent JSON schema in the tool definition. OpenAI's tool calling uses JSON Schema to constrain the arguments the model generates. A schema with an incorrect type ('number' where 'string' is expected), a missing 'required' array, or properties that use unsupported JSON Schema keywords causes the model to generate arguments that do not conform to the schema or to skip the tool call entirely because the schema is ambiguous.

When finish_reason is 'length' on a response that contains a tool call, max_tokens cut off the JSON mid-generation. The arguments JSON is incomplete and JSON.parse will throw. Always set max_tokens high enough to accommodate the full tool call JSON or remove it for agent completions and rely on the model's natural stopping point.

Diagnosis

Step 1: Log the Raw Tool Call Response

Before parsing tool call arguments, log the raw response object. This reveals whether the model is returning a tool call at all, whether finish_reason is 'tool_calls' or something else, and whether the arguments JSON is complete.

debug-tool-call.ts
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function debugToolCall(
  messages: OpenAI.ChatCompletionMessageParam[],
  tools: OpenAI.ChatCompletionTool[]
) {
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages,
    tools,
    tool_choice: "auto",
  });

  const choice = response.choices[0];

  console.log("finish_reason:", choice.finish_reason);
  // Expected: "tool_calls" when model wants to call a tool
  // Unexpected: "stop" (no tool call), "length" (truncated)

  if (choice.finish_reason === "length") {
    console.error("CRITICAL: Response truncated by max_tokens. Tool call JSON may be incomplete.");
  }

  if (choice.message.tool_calls) {
    for (const tc of choice.message.tool_calls) {
      console.log({
        id: tc.id,
        function_name: tc.function.name,
        raw_arguments: tc.function.arguments, // Log BEFORE parsing
        arguments_valid_json: (() => {
          try {
            JSON.parse(tc.function.arguments);
            return true;
          } catch {
            return false;
          }
        })(),
      });
    }
  } else {
    console.warn("No tool calls in response. Model content:", choice.message.content);
  }

  return response;
}

Step 2: Validate Tool Definitions with Zod-to-JSON-Schema

Define tools using Zod schemas and convert them to JSON Schema automatically. This eliminates hand-written JSON Schema errors and keeps your TypeScript types in sync with the tool definition.

tool-definition.ts
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import OpenAI from "openai";

// Define the tool arguments as a Zod schema
const SearchOrdersSchema = z.object({
  customer_id: z.string().describe("The unique customer ID to search orders for"),
  status: z
    .enum(["pending", "shipped", "delivered", "cancelled"])
    .optional()
    .describe("Filter orders by status. Omit to return all statuses."),
  limit: z
    .number()
    .int()
    .min(1)
    .max(50)
    .default(10)
    .describe("Maximum number of orders to return (1-50)"),
});

type SearchOrdersArgs = z.infer<typeof SearchOrdersSchema>;

// Convert to OpenAI tool format
const searchOrdersTool: OpenAI.ChatCompletionTool = {
  type: "function",
  function: {
    name: "search_orders",
    description:
      "Search for customer orders by customer ID and optional status filter. " +
      "Use this when the user asks about order status, history, or specific orders.",
    parameters: zodToJsonSchema(SearchOrdersSchema, { target: "openAi" }),
  },
};

// Parse and validate arguments safely
export function parseToolArgs(rawArgs: string): SearchOrdersArgs {
  let parsed: unknown;
  try {
    parsed = JSON.parse(rawArgs);
  } catch (e) {
    throw new Error(`Tool call arguments are not valid JSON: ${rawArgs}`);
  }

  const result = SearchOrdersSchema.safeParse(parsed);
  if (!result.success) {
    throw new Error(
      `Tool call arguments failed schema validation: ${JSON.stringify(result.error.issues)}`
    );
  }

  return result.data;
}

Step 3: Implement the Full Tool Call Loop Correctly

The most common agent loop bug is failing to append the assistant's tool call message AND the tool result message back to the conversation before the next completion call. Both are required.

agent-loop.ts
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

type ToolHandler = (name: string, args: Record<string, unknown>) => Promise<string>;

export async function runAgentLoop(
  initialMessages: OpenAI.ChatCompletionMessageParam[],
  tools: OpenAI.ChatCompletionTool[],
  handleTool: ToolHandler,
  maxIterations = 10
): Promise<string> {
  const messages: OpenAI.ChatCompletionMessageParam[] = [...initialMessages];
  let iterations = 0;

  while (iterations < maxIterations) {
    iterations++;

    const response = await client.chat.completions.create({
      model: "gpt-4o",
      messages,
      tools,
      tool_choice: "auto",
      // Do NOT set max_tokens for agent loops  let the model complete naturally
    });

    const choice = response.choices[0];

    if (choice.finish_reason === "stop") {
      // Model is done  return the final text response
      return choice.message.content ?? "";
    }

    if (choice.finish_reason === "tool_calls") {
      // Step 1: Append the assistant message with tool_calls to history
      messages.push(choice.message);

      // Step 2: Execute each tool call and append results
      const toolResults: OpenAI.ChatCompletionToolMessageParam[] = [];

      for (const tc of choice.message.tool_calls!) {
        let result: string;
        try {
          const args = JSON.parse(tc.function.arguments) as Record<string, unknown>;
          result = await handleTool(tc.function.name, args);
        } catch (err) {
          result = `Error executing tool ${tc.function.name}: ${(err as Error).message}`;
        }

        toolResults.push({
          role: "tool",
          tool_call_id: tc.id, // Must match the tool_call id from the assistant message
          content: result,
        });
      }

      // Step 3: Append ALL tool results before the next completion call
      messages.push(...toolResults);
      continue;
    }

    if (choice.finish_reason === "length") {
      throw new Error("Agent response truncated  increase max_tokens or reduce context");
    }
  }

  throw new Error(`Agent exceeded maximum iterations (${maxIterations})`);
}

Step 4: Handle Parallel Tool Calls Correctly

When gpt-4o returns multiple tool calls in a single response (parallel tool calls), all must be executed and all results must be appended before the next call. Appending only the first result causes an API error because the conversation history references tool_call IDs that have no corresponding tool result.

parallel-tool-calls.ts
// WRONG: Only handling the first tool call
if (choice.message.tool_calls) {
  const firstTc = choice.message.tool_calls[0]; // BUG: ignores remaining calls
  const result = await handleTool(firstTc.function.name, JSON.parse(firstTc.function.arguments));
  messages.push({ role: "tool", tool_call_id: firstTc.id, content: result });
}

// RIGHT: Handle ALL tool calls in parallel
if (choice.message.tool_calls) {
  messages.push(choice.message); // Append full assistant message first

  const results = await Promise.all(
    choice.message.tool_calls.map(async (tc) => {
      const args = JSON.parse(tc.function.arguments) as Record<string, unknown>;
      const result = await handleTool(tc.function.name, args);
      return {
        role: "tool" as const,
        tool_call_id: tc.id,
        content: result,
      };
    })
  );

  messages.push(...results); // Append ALL results
}

Step 5: Force Tool Use with tool_choice

If the model is returning text instead of a tool call when you expect one, use tool_choice to force it. For required tools, specify the exact tool name.

force-tool-use.ts
// Force the model to call a specific tool
const response = await client.chat.completions.create({
  model: "gpt-4o",
  messages,
  tools,
  tool_choice: {
    type: "function",
    function: { name: "search_orders" }, // Force this specific tool
  },
});

// Force the model to call ANY tool (never return text)
const response2 = await client.chat.completions.create({
  model: "gpt-4o",
  messages,
  tools,
  tool_choice: "required", // Must call at least one tool
});

// Allow the model to decide (default)
const response3 = await client.chat.completions.create({
  model: "gpt-4o",
  messages,
  tools,
  tool_choice: "auto",
});

// Disable all tools for this call
const response4 = await client.chat.completions.create({
  model: "gpt-4o",
  messages,
  tools,
  tool_choice: "none",
});

Production Failure Scenarios

Scenario 1: max_tokens Truncating Tool Call JSON

An agent had max_tokens: 256 set globally. Tool call arguments for a complex search query with 8 parameters generated ~300 tokens of JSON. The response was cut off mid-JSON, JSON.parse threw a SyntaxError, and the agent crashed. Fix: remove max_tokens from agent loops or set it to at least 1,024. Only set max_tokens on leaf completion calls where you control the expected output size.

Scenario 2: Tool Name Mismatch Between Definition and Handler

The tool was defined with name: 'search_orders' but the handler switch statement had case 'searchOrders'. The tool was never executed; the handler fell through to the default case and returned an empty string. The model received an empty tool result and hallucinated an answer. Fix: use the tool name constant in both the definition and handler, or generate handlers from the tool definitions.

Scenario 3: Tool Result Not Returned to Model

A developer executed the tool successfully but forgot to append the tool result to the messages array before the next completion call. The API threw a 400 error: 'An assistant message with tool_calls must be followed by tool messages'. This error was caught and swallowed by a global error handler, so the agent silently returned an empty response. Fix: always handle API errors explicitly in agent loops and log the full error body.

Scenario 4: Model Hallucinating Tool Names

With 15 tools in the tools array, the model occasionally called 'send_email_notification' when the correct tool was 'send_notification_email'. The names were too similar and the model confused them under load. Fix: use distinct, unambiguous tool names. Remove tools from the array that are not relevant to the current task the model performs better with fewer, well-named tools.

Prevention Checklist

  • Define tool schemas with Zod and convert to JSON Schema with zod-to-json-schema
  • Validate tool call arguments against the schema before executing the handler
  • Log finish_reason on every completion and alert if 'length' appears in an agent loop
  • Never set max_tokens on agent completion calls let the model stop naturally
  • Handle ALL parallel tool calls in the tool_calls array, not just the first
  • Always append both the assistant tool call message AND tool result messages before the next completion
  • Use distinct, unambiguous tool names avoid names that differ only by word order
  • Only include tools relevant to the current task to reduce hallucinated tool names
  • Return tool errors back to the model as tool result messages so it can recover
  • Implement a max_iterations guard to prevent infinite agent loops

Resolution Summary

Tool calling failures have three failure modes: invalid schema definitions that confuse the model, implementation bugs in the agent loop (missing messages, partial parallel call handling), and configuration errors like max_tokens truncating the JSON. Fix them in that order: validate your JSON schemas with Zod, implement the full message-append loop correctly, and remove max_tokens from agent completion calls. Add finish_reason logging and argument validation on every tool call to catch regressions before they reach users.

Lessons Learned

  • Both the assistant tool call message AND tool result must be appended before the next completion
  • max_tokens cuts off tool call JSON silently finish_reason: 'length' is the only warning
  • Parallel tool calls require all results to be appended, not just the first
  • Fewer, well-named tools perform better than many similar-sounding tools
  • Tool errors should be returned to the model as tool results so it can self-correct
  • Validate tool arguments with Zod before executing to catch schema bugs early

Conclusion

AI agent tool calling is a powerful primitive that, when implemented correctly, enables models to take reliable, structured actions. When it fails, the failures are almost always deterministic bugs in schema definitions, message handling, or configuration not model unpredictability. Instrument every step of the loop, validate every argument, handle every tool call in a parallel batch, and always feed results back to the model. With those patterns in place, tool calling becomes the most reliable component of your agent architecture.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

What is the difference between function calling and tool calling in OpenAI?+

They are the same feature under different names. 'Function calling' was the original name introduced in 2023. OpenAI renamed it 'tool calling' and expanded the tools array to support future non-function tool types. The API still accepts both the legacy functions parameter and the newer tools parameter, but the tools format is preferred for all new code.

Why does the model sometimes return text instead of a tool call?+

With tool_choice: 'auto', the model decides whether a tool call is appropriate based on the conversation. If the query is answerable from training data, it may skip the tool. Use tool_choice: 'required' to force at least one tool call, or tool_choice: { type: 'function', function: { name: 'your_tool' } } to force a specific tool.

How do I handle tool call errors gracefully?+

Catch exceptions in your tool handler, format the error as a readable string, and return it as the tool result message content. The model can then read the error and decide to retry with different arguments, call a different tool, or inform the user. Never throw unhandled exceptions in tool handlers they break the agent loop.

What is finish_reason: 'tool_calls' vs 'stop'?+

finish_reason: 'tool_calls' means the model wants to execute one or more tools before generating a final response. Your loop should execute the tools and call the API again. finish_reason: 'stop' means the model has generated its final text response and the agent loop should exit and return the content.

Can I stream responses that contain tool calls?+

Yes. With stream: true, tool call information arrives in chunks via delta.tool_calls. You must accumulate the chunks to reconstruct the full tool call. The OpenAI SDK's stream.finalChatCompletion() method accumulates all chunks for you and returns the complete response object, making streaming with tool calls straightforward.

How many tools can I pass in a single request?+

The OpenAI API documentation does not specify a hard limit but practical performance degrades above 20–30 tools. Each tool definition adds tokens to the prompt (typically 50–200 tokens per tool). More importantly, the model's tool selection accuracy decreases with more tools. Dynamically select only the tools relevant to the current conversation context.

What is strict mode for tool calling?+

Setting strict: true in the tool function definition enables Structured Outputs for tool arguments. The model is constrained to generate arguments that exactly match your JSON Schema, eliminating malformed JSON and missing required fields. Strict mode requires that all properties be listed in required and additionalProperties be set to false. It is highly recommended for production.

How do I prevent the model from calling tools in an infinite loop?+

Implement a maxIterations counter in your agent loop and throw an error when it is exceeded. Also look for repeated identical tool calls if the model calls the same tool with the same arguments twice, the tool result probably does not contain what the model needs. Log the full conversation history when loops occur to understand what the model is trying to do.

Should I use gpt-4o or gpt-4o-mini for tool calling?+

gpt-4o-mini handles straightforward tool calling reliably (single tool, clear schema, unambiguous query). For complex multi-step agent workflows with many tools, ambiguous queries, or parallel tool calls requiring reasoning, gpt-4o produces more reliable results. Start with gpt-4o-mini and upgrade to gpt-4o if you observe tool selection errors or malformed arguments.

How do I test tool calling in development without executing real side effects?+

Implement a mock tool handler that returns fake data based on the tool name and arguments. Log the arguments to verify the model is calling tools with the expected parameters. Once the schema and loop logic are confirmed correct with mocks, swap in the real handler. This also enables automated testing of your agent without hitting external APIs.