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.
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:
- Tool throws an exception
- Tool times out
- Model passes wrong argument types (even with schema)
- Model calls a tool that doesn't exist
- Model calls the same tool twice with the same arguments (loop)
- 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.