[SL_CORE]
< CD ..||7 min read

Tool Use & Function Calling: Edge Cases and Recovery

# LLM tool use fails in non-obvious ways: wrong argument types, parallel call conflicts, infinite retry loops. This guide covers the edge cases and recovery strategies that make tool-using agents reliable.

[ai-agents][tool-use][function-calling][reliability]

Tool use is where most agent bugs live. The happy path — model calls tool, tool returns result, model continues — is easy. The edge cases are what break production systems: the model invents arguments, tools time out, parallel calls conflict, and retry loops spin forever. Here's how to handle them all.

The Happy Path (and Why It's Not Enough)

// Happy path — works ~80% of the time
const response = await anthropic.messages.create({ model, messages, tools });
if (response.stop_reason === "tool_use") {
  for (const block of response.content) {
    if (block.type === "tool_use") {
      const result = await myTool(block.input);
      // return result to model
    }
  }
}

The 20% that breaks:

  1. Tool throws an exception
  2. Tool times out
  3. Model passes wrong argument types (even with schema)
  4. Model calls a tool that doesn't exist
  5. Model calls the same tool twice with the same arguments (loop)
  6. Parallel tool calls have side effects that conflict

Edge Case 1: Tool Failures

Always return a structured error — never let tool errors propagate as exceptions:

interface ToolResult {
  toolUseId: string;
  success: boolean;
  content: string;
  errorType?: "timeout" | "invalid_args" | "permission_denied" | "not_found" | "internal";
}

async function safeToolCall(
  toolUseId: string,
  toolName: string,
  args: unknown,
  executor: (args: unknown) => Promise<unknown>
): Promise<ToolResult> {
  // Validate args before calling
  const validation = validateToolArgs(toolName, args);
  if (!validation.valid) {
    return {
      toolUseId,
      success: false,
      content: `Invalid arguments: ${validation.errors.join(", ")}. Please check the tool schema.`,
      errorType: "invalid_args",
    };
  }

  try {
    const result = await Promise.race([
      executor(args),
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(new Error("timeout")), 30_000)
      ),
    ]);
    return { toolUseId, success: true, content: JSON.stringify(result) };
  } catch (err: any) {
    const errorType = err.message === "timeout" ? "timeout" :
                      err.code === "ENOENT" ? "not_found" :
                      err.code === "EPERM" ? "permission_denied" : "internal";

    return {
      toolUseId,
      success: false,
      content: `Tool ${toolName} failed: ${err.message}. ${getRecoverySuggestion(errorType)}`,
      errorType,
    };
  }
}

function getRecoverySuggestion(errorType: ToolResult["errorType"]): string {
  const suggestions: Record<string, string> = {
    timeout: "The operation took too long. Try with a smaller input or retry.",
    invalid_args: "Check the tool documentation for correct argument format.",
    permission_denied: "You don't have permission to perform this action.",
    not_found: "The specified resource doesn't exist. Verify the identifier.",
    internal: "An internal error occurred. Retry once before escalating.",
  };
  return suggestions[errorType ?? "internal"] ?? "";
}

Edge Case 2: Infinite Tool Loops

Detect when an agent is calling the same tool with the same arguments repeatedly:

class LoopDetector {
  private callHistory: Map<string, number> = new Map();

  check(toolName: string, args: unknown): boolean {
    const key = `${toolName}:${JSON.stringify(args)}`;
    const count = (this.callHistory.get(key) ?? 0) + 1;
    this.callHistory.set(key, count);
    return count >= 3; // same call 3+ times = loop
  }

  getLoopInfo(): string {
    const repeated = [...this.callHistory.entries()]
      .filter(([, count]) => count >= 2)
      .map(([key, count]) => `${key} (${count}×)`);
    return repeated.join(", ");
  }
}

// In agent loop
const loopDetector = new LoopDetector();

// Before executing tool call:
if (loopDetector.check(block.name, block.input)) {
  return {
    toolUseId: block.id,
    success: false,
    content: `You've called ${block.name} with these same arguments multiple times (${loopDetector.getLoopInfo()}). 
The tool result isn't changing. Try a different approach or different arguments.`,
  };
}

Edge Case 3: Parallel Tool Calls with Conflicts

Claude can call multiple tools in a single response. If those tools have side effects, order matters:

async function executeParallelTools(
  toolUseBlocks: Anthropic.ToolUseBlock[]
): Promise<Anthropic.ToolResultBlockParam[]> {
  // Classify tools: safe to parallelize vs. must serialize
  const { parallel, sequential } = classifyToolCalls(toolUseBlocks);

  const results: Anthropic.ToolResultBlockParam[] = [];

  // Execute sequential ones first, in dependency order
  for (const block of sequential) {
    const result = await safeToolCall(block.id, block.name, block.input, getExecutor(block.name));
    results.push({
      type: "tool_result",
      tool_use_id: result.toolUseId,
      content: result.content,
      is_error: !result.success,
    });
  }

  // Execute parallel ones concurrently
  const parallelResults = await Promise.all(
    parallel.map((block) =>
      safeToolCall(block.id, block.name, block.input, getExecutor(block.name))
    )
  );

  results.push(...parallelResults.map((r) => ({
    type: "tool_result" as const,
    tool_use_id: r.toolUseId,
    content: r.content,
    is_error: !r.success,
  })));

  return results;
}

function classifyToolCalls(blocks: Anthropic.ToolUseBlock[]): {
  parallel: Anthropic.ToolUseBlock[];
  sequential: Anthropic.ToolUseBlock[];
} {
  const WRITE_TOOLS = new Set(["write_file", "send_email", "update_database", "delete_record"]);
  const READ_TOOLS = new Set(["read_file", "search_web", "get_record"]);

  return {
    parallel: blocks.filter((b) => READ_TOOLS.has(b.name)),
    sequential: blocks.filter((b) => WRITE_TOOLS.has(b.name) || !READ_TOOLS.has(b.name)),
  };
}

Edge Case 4: Hallucinated Tool Names

Models occasionally call tools that don't exist (especially after context truncation):

function buildToolCallHandler(tools: Anthropic.Tool[]) {
  const toolNames = new Set(tools.map((t) => t.name));

  return async function handleToolCall(block: Anthropic.ToolUseBlock): Promise<string> {
    if (!toolNames.has(block.name)) {
      // Find closest match for helpful error
      const closest = findClosestToolName(block.name, [...toolNames]);
      return `Tool "${block.name}" doesn't exist. ${closest ? `Did you mean "${closest}"?` : `Available tools: ${[...toolNames].join(", ")}`}`;
    }
    return executeToolByName(block.name, block.input);
  };
}

function findClosestToolName(name: string, toolNames: string[]): string | null {
  const scored = toolNames.map((t) => ({ name: t, score: levenshteinDistance(name, t) }));
  const best = scored.sort((a, b) => a.score - b.score)[0];
  return best.score <= 3 ? best.name : null;
}

Edge Case 5: Large Tool Outputs

When a tool returns a huge response (10K+ tokens), it can overflow the context:

async function truncateToolOutput(output: unknown, maxTokens = 2000): Promise<string> {
  const text = typeof output === "string" ? output : JSON.stringify(output, null, 2);

  if (estimateTokens(text) <= maxTokens) return text;

  // Summarize large outputs
  const summary = await anthropic.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: maxTokens,
    messages: [{
      role: "user",
      content: `Summarize this tool output, keeping all important data and numbers:
${text.slice(0, 10000)}
${text.length > 10000 ? `\n[Truncated — ${text.length - 10000} more chars]` : ""}`,
    }],
  });

  return `[Summarized — original was ${estimateTokens(text)} tokens]\n${summary.content[0].text}`;
}

The Complete Tool Execution Wrapper

async function executeAllTools(
  response: Anthropic.Message,
  executors: Record<string, (args: unknown) => Promise<unknown>>,
  options: {
    loopDetector: LoopDetector;
    maxOutputTokens?: number;
    traceId?: string;
  }
): Promise<Anthropic.ToolResultBlockParam[]> {
  const toolBlocks = response.content.filter(
    (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
  );

  const { parallel, sequential } = classifyToolCalls(toolBlocks);

  async function processBlock(block: Anthropic.ToolUseBlock): Promise<Anthropic.ToolResultBlockParam> {
    // Loop detection
    if (options.loopDetector.check(block.name, block.input)) {
      return { type: "tool_result", tool_use_id: block.id, content: "Loop detected — same args called 3+ times. Try a different approach.", is_error: true };
    }

    // Execute safely
    const result = await safeToolCall(block.id, block.name, block.input, executors[block.name] ?? (async () => { throw new Error("Unknown tool"); }));

    // Truncate if needed
    const content = options.maxOutputTokens
      ? await truncateToolOutput(result.content, options.maxOutputTokens)
      : result.content;

    return { type: "tool_result", tool_use_id: block.id, content, is_error: !result.success };
  }

  const seqResults = [];
  for (const b of sequential) seqResults.push(await processBlock(b));

  const parResults = await Promise.all(parallel.map(processBlock));

  return [...seqResults, ...parResults];
}

FAQ

What should I return when a tool isn't available? Return a clear error message explaining what's available. Don't throw — a thrown error causes the agent loop to crash. The model can recover from an error message; it can't recover from a crash.

How many tools should I give an agent? 5–10 tools maximum. More than 10 causes the model to call wrong tools or miss the right one. If you need more tools, create sub-agents with specialized toolsets.

Should tool inputs always be JSON? Yes — JSON is unambiguous and easy to validate with schemas. String arguments cause parsing ambiguities ("is this a number or a string?").

What if the model keeps trying to call a tool even after getting an error? After 2 consecutive errors on the same tool, inject a message: "Tool X is unavailable. Complete the task without it or ask the user for help." This breaks retry loops.

Can I let the user approve tool calls? Yes — add a human-in-the-loop step for destructive actions. Show the user the proposed tool call before executing. This is the safest pattern for tools like delete_file or send_email.