Tool Use and Function Calling
From Words to Actions
In 2023, GPT-4 could explain how to query a database in exquisite detail. It could write the SQL, explain the joins, anticipate the edge cases. But it could not run the query. The language model and the database existed in different worlds, separated by the human who had to copy the SQL and paste it into a terminal.
By mid-2024, the same capability that required a human middleman had been replaced by structured function calling. The model generates a tool call, your code executes it, the result returns to the model as an observation. The human is no longer in the loop for the mechanical parts of the work.
This shift - from language models that describe actions to agents that execute them - is what function calling enables. It is the mechanism by which an agent extends its reach beyond the context window into the actual world. Without tools, an agent is just a chatbot with a goal. With tools, it becomes a system that can act.
Understanding function calling at a deep level - how it actually works under the hood, how to design tools that the LLM will use correctly, how to handle failures gracefully - is essential knowledge for anyone building production agents.
:::tip 🎮 Interactive Playground Visualize this concept: Try the Tool Use & Function Calling demo on the EngineersOfAI Playground - no code required. :::
How Function Calling Works Under the Hood
The conceptual model of function calling is simple: the LLM generates a structured request, your code executes it, and returns the result. But the implementation involves several important steps.
Step 1: Tool schema definition. You define what tools are available by providing JSON Schema objects alongside the model request. The schema specifies the tool's name, a natural-language description, and the parameters it accepts (with their types, descriptions, and whether they are required).
Step 2: LLM decides to use a tool. The model processes the user message and the available tool schemas together. Based on the task and the available tools, it decides whether to call a tool and which one. If it decides to call a tool, it outputs a tool_use content block instead of (or alongside) regular text.
Step 3: Your code parses the tool call. The tool_use block contains the tool name, a unique ID, and the JSON-encoded arguments. Your scaffolding extracts these.
Step 4: Input validation. Before executing the tool, validate the inputs against your expected schema. The LLM is usually correct but can generate invalid parameters, especially for edge cases.
Step 5: Tool execution. You call the actual function with the provided arguments. This is regular application code - a database query, an API call, a file read, a calculation.
Step 6: Return the result. Wrap the tool output in a tool_result content block, referencing the original tool_use_id. This links the result back to the specific tool call that generated it, which matters for parallel tool calls.
Step 7: The LLM reads the result. In the next API call, the model sees the tool result as part of the conversation history. It uses this observation to decide what to do next.
Tool Definition Anatomy
The quality of your tool definitions determines how well the LLM uses your tools. Here is a complete example with explanations of each field.
# A production-quality tool definition
search_tool = {
"name": "search_documents", # Must be unique, snake_case
"description": """Search the document database for content matching a query.
Use this when you need to find specific information in the document store.
Returns up to 10 matching documents sorted by relevance.
Good query examples:
- "quarterly revenue Q3 2024"
- "machine learning model deployment"
- "customer churn analysis"
This tool searches across all document types. For file-specific searches,
use read_file instead.""",
# Description guidelines:
# 1. First line: one-sentence summary (shown in tool list)
# 2. When to use: explicit guidance on appropriate use cases
# 3. What it returns: help the LLM interpret the result
# 4. Examples: sample queries or inputs
# 5. Related tools: when to use this vs alternatives
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query. Use natural language. Be specific."
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (1-50). Default is 10.",
"default": 10,
"minimum": 1,
"maximum": 50
},
"document_type": {
"type": "string",
"description": "Filter by document type. Options: 'report', 'email', 'spreadsheet', 'all'.",
"enum": ["report", "email", "spreadsheet", "all"],
"default": "all"
},
"date_range": {
"type": "object",
"description": "Optional date range filter.",
"properties": {
"start": {
"type": "string",
"description": "Start date in YYYY-MM-DD format."
},
"end": {
"type": "string",
"description": "End date in YYYY-MM-DD format."
}
}
}
},
"required": ["query"] # Only query is required; others have defaults
}
}
The description field is the most important part of any tool definition. The LLM uses it to decide:
- Whether to call this tool at all
- Whether this tool or another is more appropriate
- How to interpret the result
- What parameters to provide
A vague description leads to incorrect tool selection. A precise description with usage examples dramatically improves the LLM's tool use accuracy.
The Tool Selection Problem
How does the LLM decide which tool to call? It reads the task, reads all tool descriptions, and infers which tool best serves the current step. This inference is imperfect and can be guided with good design.
Common selection failures:
-
Wrong tool for the task: The LLM calls
search_documentswhen it should callread_filebecause both are described as "getting information." Fix: make the distinction explicit in the description ("Use search_documents to find documents by content. Use read_file when you know the exact file path.") -
Too many tools: With 20+ tools, selection quality degrades. The LLM may ignore relevant tools or confuse similar ones. Fix: provide only the tools relevant to the current task, or group tools into categories.
-
Unnecessary tool calls: The LLM calls a tool to verify information it already has in context. Fix: in your system prompt, remind the agent not to re-read information already available.
-
Missing the right tool: The LLM tries to do something manually that a tool would do better. Fix: write descriptions that cover the use cases you want the tool to handle.
Tool Design Principles
Good tool design makes the difference between an agent that works reliably and one that fails unpredictably.
Single Responsibility
Each tool should do one thing. A tool called get_and_process_data that fetches data, cleans it, and aggregates it is harder to use correctly than three separate tools.
When a tool does multiple things, the LLM cannot selectively call just the part it needs. It also becomes harder to interpret tool errors - did the fetch fail, or the cleaning, or the aggregation?
Idempotency
Read tools should always be idempotent - calling them twice with the same arguments should return the same result. Write tools should be idempotent where possible - calling them twice should not cause harm.
Why this matters for agents: the agent may call a tool multiple times (retry logic, re-verification). Non-idempotent write tools cause problems: sending an email twice, inserting a database row twice, charging a credit card twice.
Error Messages That Help the LLM Recover
When a tool fails, the error message becomes an observation in the next iteration. A good error message helps the LLM understand what went wrong and what to do differently.
# Bad error message - the LLM has no idea what to do
return "Error 400"
# Better - tells what went wrong
return "Error: Invalid date format. Expected YYYY-MM-DD, received '03/15/2024'."
# Best - tells what went wrong AND how to fix it
return (
"Error: Invalid date format. "
"The 'start_date' parameter requires ISO 8601 format (YYYY-MM-DD). "
"You provided '03/15/2024'. "
"Try: start_date='2024-03-15'"
)
The best error messages are actionable. They tell the LLM what to change, not just that something was wrong.
Appropriate Granularity
Tools that are too coarse force the agent into all-or-nothing choices. Tools that are too fine-grained require too many calls for simple tasks.
# Too coarse: does everything in one call
# The agent cannot selectively fetch just headers
{"name": "fetch_email_with_all_metadata_and_attachments", ...}
# Too fine-grained: requires 4 calls to get a complete email
{"name": "get_email_subject", ...}
{"name": "get_email_body", ...}
{"name": "get_email_sender", ...}
{"name": "get_email_attachments", ...}
# Right granularity: one call returns the whole email
{"name": "get_email", ...}
# Separate call for attachments (less commonly needed)
{"name": "get_email_attachments", ...}
Tool Categories
Read tools are safe to call multiple times. They should be the default. When in doubt, read before you write. Give the agent read tools first and add write tools only when the task requires modification.
Write tools require care. They should check preconditions before executing. They should return confirmation of what was changed. Destructive write tools (delete, overwrite) should require explicit confirmation or should be restricted to certain execution contexts.
Compute tools are generally safe but can have side effects (creating files, network requests within the computation). Define clear boundaries about what compute tools are allowed to do.
Communication tools have external effects that cannot be undone. An email sent cannot be un-sent. These tools should almost always require human confirmation before executing in production.
Parallel Tool Calls
The Anthropic API and other modern LLM APIs support parallel tool calls: the model can request multiple tool executions in a single response. This is a significant performance optimization.
The model naturally generates parallel tool calls when the task benefits from it. You do not need to explicitly request it - just execute all tool_use blocks returned in a single response concurrently.
Complete Tool System Implementation
Here is a production-quality tool system with 5 real tools, input validation, error handling, and parallel execution.
"""
Production tool system for AI agents.
Features: input validation, rate limiting, comprehensive error handling,
parallel execution, and tools that return LLM-friendly error messages.
Install: pip install anthropic pydantic
"""
import anthropic
import asyncio
import json
import os
import re
import subprocess
import time
from typing import Any
from functools import wraps
# ── Rate limiting ─────────────────────────────────────────────────────────────
class RateLimiter:
"""Simple token bucket rate limiter."""
def __init__(self, calls_per_minute: int):
self.calls_per_minute = calls_per_minute
self.calls = []
def is_allowed(self) -> bool:
now = time.time()
# Remove calls older than 1 minute
self.calls = [c for c in self.calls if now - c < 60]
if len(self.calls) < self.calls_per_minute:
self.calls.append(now)
return True
return False
# ── Tool implementations ──────────────────────────────────────────────────────
class ToolSystem:
"""
A complete tool system with 5 production-quality tools.
Each tool validates input, handles errors gracefully, and returns
messages that help the LLM understand what happened.
"""
def __init__(self, working_directory: str = "."):
self.working_directory = os.path.abspath(working_directory)
self.file_rate_limiter = RateLimiter(calls_per_minute=60)
self.python_rate_limiter = RateLimiter(calls_per_minute=20)
def _safe_path(self, path: str) -> tuple[str, str | None]:
"""
Resolve and validate a file path.
Returns (resolved_path, error_message).
error_message is None if the path is valid.
"""
# Resolve relative to working directory
if not os.path.isabs(path):
resolved = os.path.normpath(os.path.join(self.working_directory, path))
else:
resolved = os.path.normpath(path)
# Path traversal protection
if not resolved.startswith(self.working_directory):
return "", (
f"Error: Path '{path}' is outside the allowed working directory. "
f"All file operations must stay within: {self.working_directory}"
)
return resolved, None
def read_file(self, path: str, start_line: int = 1, end_line: int | None = None) -> str:
"""Read a file, optionally selecting a line range."""
if not self.file_rate_limiter.is_allowed():
return "Error: Rate limit exceeded for file operations. Wait a moment and retry."
resolved, err = self._safe_path(path)
if err:
return err
try:
with open(resolved, "r", encoding="utf-8") as f:
lines = f.readlines()
total_lines = len(lines)
# Apply line range
start_idx = max(0, start_line - 1)
end_idx = min(total_lines, end_line) if end_line else total_lines
selected = lines[start_idx:end_idx]
# Warn about truncation
content = "".join(selected)
truncation_note = ""
if end_line and end_line < total_lines:
truncation_note = f"\n[Showing lines {start_line}-{end_line} of {total_lines} total lines]"
elif total_lines > 500 and end_line is None:
truncation_note = (
f"\n[File has {total_lines} lines. Showing first 500. "
f"Use start_line and end_line to read specific sections.]"
)
content = "".join(lines[:500])
return f"File: {path} ({total_lines} lines total)\n{content}{truncation_note}"
except FileNotFoundError:
# Helpful: check if similar files exist
parent = os.path.dirname(resolved)
try:
siblings = [f for f in os.listdir(parent) if f.endswith(os.path.splitext(path)[1])]
sibling_hint = ""
if siblings:
sibling_hint = f" Similar files in same directory: {', '.join(siblings[:5])}"
except Exception:
sibling_hint = ""
return f"Error: File '{path}' not found.{sibling_hint}"
except UnicodeDecodeError:
return f"Error: '{path}' appears to be a binary file. Cannot read as text."
except Exception as e:
return f"Error reading '{path}': {type(e).__name__}: {e}"
def write_file(self, path: str, content: str, create_dirs: bool = True) -> str:
"""Write content to a file."""
if not self.file_rate_limiter.is_allowed():
return "Error: Rate limit exceeded for file operations. Wait a moment and retry."
resolved, err = self._safe_path(path)
if err:
return err
try:
if create_dirs:
os.makedirs(os.path.dirname(resolved), exist_ok=True)
# Check if file exists (for the return message)
existed = os.path.exists(resolved)
with open(resolved, "w", encoding="utf-8") as f:
f.write(content)
lines = content.count("\n") + 1
action = "Updated" if existed else "Created"
return f"Success: {action} '{path}' ({lines} lines, {len(content)} characters)."
except PermissionError:
return f"Error: Permission denied writing to '{path}'."
except Exception as e:
return f"Error writing to '{path}': {type(e).__name__}: {e}"
def run_python(
self,
code: str,
timeout: int = 15,
capture_files: list[str] | None = None
) -> str:
"""Execute Python code and return output."""
if not self.python_rate_limiter.is_allowed():
return "Error: Rate limit exceeded for code execution. Wait a moment and retry."
# Basic safety checks (not comprehensive - use a sandbox in production)
dangerous_patterns = [
r"import\s+subprocess",
r"os\.system",
r"eval\s*\(",
r"exec\s*\(",
r"__import__",
r"open\s*\(.*['\"]w['\"]" # file write from within code
]
for pattern in dangerous_patterns:
if re.search(pattern, code):
return (
f"Error: Code contains potentially dangerous pattern: '{pattern}'. "
"Use the write_file tool to write files instead of opening them in Python code."
)
try:
result = subprocess.run(
["python3", "-c", code],
capture_output=True,
text=True,
timeout=timeout,
cwd=self.working_directory
)
parts = []
if result.stdout.strip():
parts.append(f"stdout:\n{result.stdout}")
if result.stderr.strip():
parts.append(f"stderr:\n{result.stderr}")
if result.returncode != 0:
parts.append(
f"Exit code: {result.returncode} (non-zero indicates an error)\n"
"Fix the error in the code and try again."
)
else:
parts.append(f"Exit code: 0 (success)")
return "\n".join(parts) if parts else "(Code ran successfully with no output)"
except subprocess.TimeoutExpired:
return (
f"Error: Code timed out after {timeout} seconds. "
"Break the code into smaller pieces or optimize for performance."
)
except Exception as e:
return f"Error executing code: {type(e).__name__}: {e}"
def list_directory(self, path: str = ".", show_hidden: bool = False) -> str:
"""List directory contents with file sizes and modification times."""
resolved, err = self._safe_path(path)
if err:
return err
try:
entries = sorted(os.listdir(resolved))
if not show_hidden:
entries = [e for e in entries if not e.startswith(".")]
if not entries:
return f"Directory '{path}' is empty."
lines = [f"Contents of {path}/:", ""]
for entry in entries:
full_path = os.path.join(resolved, entry)
is_dir = os.path.isdir(full_path)
try:
stat = os.stat(full_path)
size = stat.st_size
mtime = time.strftime("%Y-%m-%d", time.localtime(stat.st_mtime))
if is_dir:
lines.append(f" {entry}/ [dir] {mtime}")
else:
size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB"
lines.append(f" {entry} {size_str} {mtime}")
except Exception:
suffix = "/" if is_dir else ""
lines.append(f" {entry}{suffix}")
return "\n".join(lines)
except FileNotFoundError:
return f"Error: Directory '{path}' not found."
except Exception as e:
return f"Error listing '{path}': {type(e).__name__}: {e}"
def search_text(self, path: str, pattern: str, max_results: int = 20) -> str:
"""Search for a text pattern in a file or directory."""
resolved, err = self._safe_path(path)
if err:
return err
results = []
def search_in_file(file_path: str, display_path: str):
try:
with open(file_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f, 1):
if pattern in line:
results.append(f" {display_path}:{i}: {line.rstrip()}")
if len(results) >= max_results:
return
except (UnicodeDecodeError, PermissionError):
pass
try:
if os.path.isfile(resolved):
search_in_file(resolved, path)
elif os.path.isdir(resolved):
for root, _, files in os.walk(resolved):
for fname in files:
if fname.endswith((".py", ".js", ".ts", ".md", ".txt", ".json", ".yaml", ".yml")):
full = os.path.join(root, fname)
rel = os.path.relpath(full, self.working_directory)
search_in_file(full, rel)
if len(results) >= max_results:
break
if len(results) >= max_results:
break
if not results:
return f"Pattern '{pattern}' not found in {path}."
header = f"Found {len(results)} match(es) for '{pattern}' in {path}:"
if len(results) >= max_results:
header += f" (showing first {max_results}; use a narrower path for more)"
return header + "\n" + "\n".join(results)
except Exception as e:
return f"Error searching in '{path}': {type(e).__name__}: {e}"
# ── Tool schema definitions ───────────────────────────────────────────────────
def get_tool_schemas() -> list[dict]:
"""Return the JSON schema definitions for all tools."""
return [
{
"name": "read_file",
"description": (
"Read the contents of a file. Returns file content with line numbers.\n"
"Use start_line and end_line for large files to avoid loading everything.\n"
"Always prefer reading specific sections over entire large files."
),
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read."},
"start_line": {
"type": "integer",
"description": "First line to read (1-indexed). Defaults to 1.",
"default": 1
},
"end_line": {
"type": "integer",
"description": "Last line to read (inclusive). Omit to read to end of file."
}
},
"required": ["path"]
}
},
{
"name": "write_file",
"description": (
"Write content to a file. Creates the file and parent directories if needed.\n"
"Overwrites the entire file - do not use to append to existing files.\n"
"WARNING: This permanently replaces file content."
),
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to write."},
"content": {"type": "string", "description": "Full content to write."},
"create_dirs": {
"type": "boolean",
"description": "Create parent directories if needed. Default: true.",
"default": True
}
},
"required": ["path", "content"]
}
},
{
"name": "run_python",
"description": (
"Execute Python code and return stdout, stderr, and exit code.\n"
"Use for calculations, running tests, processing data, verifying code.\n"
"Code runs in the working directory. Timeout: 15 seconds.\n"
"Do not use to write files - use write_file instead."
),
"input_schema": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Python code to execute."},
"timeout": {
"type": "integer",
"description": "Execution timeout in seconds (1-30). Default: 15.",
"default": 15,
"minimum": 1,
"maximum": 30
}
},
"required": ["code"]
}
},
{
"name": "list_directory",
"description": (
"List files and subdirectories at a path, with sizes and dates.\n"
"Use this first to understand project structure before reading files."
),
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory path to list.", "default": "."},
"show_hidden": {
"type": "boolean",
"description": "Show hidden files (starting with '.'). Default: false.",
"default": False
}
},
"required": []
}
},
{
"name": "search_text",
"description": (
"Search for a text pattern in a file or recursively in a directory.\n"
"Returns matching lines with file paths and line numbers.\n"
"Use this to find where a function is defined, where a variable is used,\n"
"or to locate any specific string in the codebase."
),
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File or directory to search."},
"pattern": {"type": "string", "description": "Text pattern to find (case-sensitive)."},
"max_results": {
"type": "integer",
"description": "Maximum matches to return (1-100). Default: 20.",
"default": 20
}
},
"required": ["path", "pattern"]
}
}
]
# ── Agent with tool system ────────────────────────────────────────────────────
async def execute_tools_parallel(
tools: ToolSystem,
tool_calls: list[Any]
) -> list[dict]:
"""Execute multiple tool calls in parallel and return results."""
async def run_one(tool_call) -> dict:
"""Execute a single tool call asynchronously."""
name = tool_call.name
args = tool_call.input
# Map tool name to method
tool_methods = {
"read_file": lambda: tools.read_file(**args),
"write_file": lambda: tools.write_file(**args),
"run_python": lambda: tools.run_python(**args),
"list_directory": lambda: tools.list_directory(**args),
"search_text": lambda: tools.search_text(**args),
}
loop = asyncio.get_event_loop()
if name in tool_methods:
# Run sync function in thread pool
result = await loop.run_in_executor(None, tool_methods[name])
else:
result = f"Error: Unknown tool '{name}'."
return {
"type": "tool_result",
"tool_use_id": tool_call.id,
"content": result
}
# Execute all tool calls concurrently
results = await asyncio.gather(*[run_one(tc) for tc in tool_calls])
return list(results)
async def run_agent_with_tools(task: str, working_dir: str = ".") -> str:
"""Run an agent with the full tool system."""
client = anthropic.Anthropic()
tools_obj = ToolSystem(working_directory=working_dir)
tool_schemas = get_tool_schemas()
messages = [{"role": "user", "content": task}]
max_iterations = 25
system = """You are a precise, systematic AI agent. You have tools to read, write, \
and execute code.
Always:
1. List the directory first to understand the structure
2. Search before reading large files
3. Verify your writes by reading back what you wrote
4. Run tests to confirm code correctness
5. Be explicit about what you accomplished"""
for i in range(max_iterations):
print(f"\n[Step {i+1}]")
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=4096,
system=system,
tools=tool_schemas,
messages=messages
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
final = next(
(b.text for b in response.content if hasattr(b, "text")),
"Done."
)
return final
if response.stop_reason == "tool_use":
tool_calls = [b for b in response.content if b.type == "tool_use"]
print(f" Tool calls: {[tc.name for tc in tool_calls]}")
# Execute all tool calls in parallel
results = await execute_tools_parallel(tools_obj, tool_calls)
messages.append({"role": "user", "content": results})
return "Max iterations reached."
# ── Run it ────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
result = asyncio.run(run_agent_with_tools(
"Create a Python module called 'calculator.py' with functions for add, subtract, "
"multiply, and divide. Include proper error handling for division by zero. "
"Then write a test file 'test_calculator.py' that tests all four functions "
"including the error case. Run the tests and confirm they all pass."
))
print(f"\nResult:\n{result}")
Production Engineering Notes
:::tip Tool descriptions are your most important engineering artifact The LLM reads your tool descriptions to decide when and how to use each tool. A vague or misleading description causes incorrect tool selection, which cascades into agent failures. Spend as much time writing tool descriptions as you spend writing the tool implementations. Include: what the tool does, when to use it, when NOT to use it, what format the result takes, and examples of valid inputs. :::
:::warning Input validation protects against LLM mistakes The LLM can and does generate invalid tool parameters. Always validate inputs before executing. For file tools: check path safety to prevent traversal attacks. For compute tools: check for dangerous patterns. For API tools: validate required fields and format constraints. Return clear error messages - the LLM reads them and self-corrects. :::
:::danger Never expose unrestricted shell execution or file deletion
Tools with os.system(), subprocess.run(shell=True), or os.remove() without guards are severe security vulnerabilities. An LLM under prompt injection could be manipulated into deleting files, exfiltrating data, or executing arbitrary commands. Sandbox code execution. Restrict file operations to a defined working directory. Require confirmation for destructive operations.
:::
Interview Questions
Q: How does function calling work under the hood in the Anthropic API?
You send a messages.create request with a tools parameter containing JSON Schema definitions. The model processes the conversation and the tool schemas together - it understands tool names, descriptions, and parameter schemas as part of its context. When it decides to use a tool, instead of (or in addition to) text output, it returns a tool_use content block containing the tool name, a unique ID, and JSON-encoded arguments that match the schema. Your code extracts these, executes the tools, and returns a tool_result content block referencing the original tool_use_id. On the next API call, the model sees both what it requested and what actually happened, allowing it to reason about the results.
Q: What makes a tool description good versus bad?
A bad description is vague and does not distinguish the tool from alternatives: "Gets information about things." A good description is specific, actionable, and includes when to use and when not to use the tool: "Retrieve a customer's purchase history from the database. Use when you need order details, product names, or transaction amounts. Returns up to 100 orders sorted by date. For customer profile information (name, email, address), use get_customer_profile instead. Example: customer_id='cust_12345'." The test: if you gave only the description to a new developer with no other context, would they know when and how to use this tool? If yes, the description is good.
Q: How do you implement parallel tool calls and why does it matter?
When the model returns multiple tool_use blocks in a single response, execute them concurrently using asyncio.gather() in Python or Promise.all() in JavaScript. Collect all results and return them in a single tool_result message. This matters because many agent tasks naturally involve gathering multiple independent pieces of information - checking a database, reading a file, and calling an API simultaneously. Without parallel execution, this takes the sum of all latencies. With parallel execution, it takes the maximum. For I/O-bound tools (the most common type), this is typically a 3-5x speedup for multi-tool steps.
Q: What error message format helps agents recover from tool failures?
The best error messages are structured around the LLM's decision-making process. They explain: what failed (not just that something failed), why it failed (the root cause), and what to do differently (the corrective action). Example: "Error: The file path '../../../etc/passwd' is outside the allowed working directory '/home/agent/workspace'. All file operations must use paths within /home/agent/workspace. Try a relative path like 'data/input.txt'." This gives the LLM everything it needs to correct its approach on the next iteration. Compare to: "Error: Access denied." - which provides no corrective guidance.
Q: When should a tool require human confirmation before executing?
Any action that is irreversible or has significant external effects should require confirmation. The categories: (1) sending external communications (emails, Slack messages, SMS) - once sent, cannot be unsent; (2) financial transactions - payments, orders, refunds; (3) bulk deletions - deleting files, database records, or resources in quantity; (4) actions on behalf of other users - acting with another person's credentials or permissions; (5) actions in production systems when the agent is being tested or developed. Implement confirmation as a tool that returns a proposed action description and waits for user approval before proceeding. In automated pipelines where human-in-the-loop is not possible, use conservative defaults: prefer reads over writes, prefer reversible over irreversible, prefer specific over broad.
Q: How do you prevent a tool system from being exploited through prompt injection?
Prompt injection occurs when malicious content in tool results (like a web page or database field) contains instructions that redirect the agent's behavior. Defenses at multiple layers: (1) tool output sanitization - strip or escape content that looks like instructions ("Ignore previous instructions and..."), (2) tool output labeling - clearly mark tool output as data, not instructions, using XML-like tags so the model can distinguish, (3) restricted tool permissions - do not give the agent more tools than the task requires, (4) human confirmation for high-stakes actions - even if the agent is manipulated into planning a bad action, confirmation stops it before execution, (5) input allowlisting where possible - instead of passing arbitrary user content to tools, pass structured IDs that reference known-safe content. No single defense is complete; you need multiple layers.
