The Big Picture
Large language models are reasoning engines. They excel at understanding context, planning, and making decisions—but they cannot natively interact with the external world. Tool use extends LLMs with agency: the ability to call functions, query databases, send emails, execute code, and gather real-time information.
Why Tools Matter
Without tools, LLMs can only generate text. With tools, they become agents—autonomous systems capable of breaking down complex problems and taking action. A weather assistant without tool access can only recite training data; with a weather API tool, it retrieves live forecasts.
Tool Categories
Computation
Math, code execution, simulations. Example: calculate_expression("2**10") returns 1024.
Data Access
Search, database queries, file reading. Example: search_docs(query) returns relevant passages.
Action
Send emails, create files, update records. Example: send_email(to, subject, body).
Perception
Read URLs, OCR images, parse audio. Example: read_url(url) returns page markdown.
The Tool-Calling Loop
Agents operate in a tight feedback loop:
- Reason: LLM analyzes the user query and decides which tool to call
- Call: LLM generates structured tool invocation (name + arguments)
- Execute: Tool runs and returns a result
- Observe: LLM receives result as new context
- Repeat: LLM may call more tools, synthesize answer, or ask for clarification
Function Calling Fundamentals
Function calling is how LLMs invoke tools. The LLM doesn't execute code directly—instead, it generates structured JSON describing which tool to call and what arguments to pass. The host system executes the tool and feeds the result back to the LLM.
The Tool Schema
Every tool is defined by a JSON schema with four key parts:
| Field | Purpose | Example |
|---|---|---|
name |
Unique identifier; action verb + noun | send_email |
description |
What it does; when to use it | "Send an email to a recipient" |
input_schema |
JSON Schema of parameters | { type: "object", properties: {...} } |
required |
List of mandatory parameters | ["to", "subject", "body"] |
Function Calling Flow
When you ask an LLM a question, here's what happens:
User: "What's the weather in New York?"
[System injects available tools into context]
Available tools:
- get_weather(city, unit)
- search_web(query)
- get_time(timezone)
[LLM decides which tool to call]
LLM Output:
{
"type": "tool_use",
"id": "call_1",
"name": "get_weather",
"input": {
"city": "New York",
"unit": "fahrenheit"
}
}
[Host executes tool]
Tool Result: {
"city": "New York",
"temp": 72,
"condition": "Partly Cloudy",
"humidity": 65
}
[System adds result to conversation]
User Message: "What's the weather in New York?"
Assistant: [tool call above]
Tool Result: {temp: 72, ...}
[LLM continues reasoning with new context]
LLM Output (text): "It's currently 72°F and partly cloudy in New York."Tool Choice Modes
| Mode | Behavior | Use Case |
|---|---|---|
| auto | LLM decides if/when to call tools | Default; LLM has full agency |
| required | LLM must call at least one tool | Force tool invocation for verification |
| specific | Force a specific tool | Route query to predetermined handler |
Parallel Tool Calls
LLMs can call multiple independent tools in a single turn. Instead of:
1. Call weather_api("NYC")
2. Get result
3. Call weather_api("LA")
4. Get result
5. Compare temperatures
LLM can instead do this in one turn:
{
"type": "tool_use",
"tools": [
{
"name": "get_weather",
"input": {"city": "New York"}
},
{
"name": "get_weather",
"input": {"city": "Los Angeles"}
}
]
}Error Handling
When a tool fails, the system returns an error message, and the LLM can adapt:
{ "error": "City not found" }. The LLM sees this and can: (1) try a different city, (2) ask the user for clarification, (3) use general knowledge.
Tool Schema Design
The quality of your tool schema directly impacts whether the LLM will use it correctly. Vague schemas lead to wrong tool calls, missing calls, or parameter misuse.
Good Tool Names
Tool names should be:
- Action-focused: Start with a verb (get_, send_, search_, list_, create_)
- Specific:
send_emailnotcommunicate - Lowercase + snake_case:
fetch_user_profilenotFetchUserProfile - Unique: No two tools with similar names
Bad: process, action, do_thing
Good: search_documents, list_repositories, calculate_mortgage
Powerful Descriptions
A description should answer:
- What does this tool do?
- When should the LLM call it?
- What are the edge cases?
WEAK description: "Searches for documents" STRONG description: "Search a document database for relevant passages. Use this tool when the user asks a question that requires information from stored documents. Searches by keyword and returns up to 10 results ranked by relevance. Does NOT perform calculations—use calculator_tool for math. Returns empty list if no documents match."
Parameter Schema
Each parameter needs a clear type and description:
{
"name": "send_email",
"description": "Send an email to a recipient",
"input_schema": {
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "Recipient email address (e.g., user@example.com)"
},
"subject": {
"type": "string",
"description": "Email subject line (max 100 characters)"
},
"body": {
"type": "string",
"description": "Email body; supports plain text and markdown"
},
"cc": {
"type": "array",
"items": {"type": "string"},
"description": "Optional CC recipients (array of emails)"
}
},
"required": ["to", "subject", "body"]
}
}Schema Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
Vague names: get_data |
LLM unsure when to call | Use get_user_profile |
| Too many parameters (>10) | LLM overwhelmed; low accuracy | Split into multiple tools |
| Nested objects without examples | LLM can't fill parameters | Add concrete examples in description |
| Missing "when to use" guidance | Tool called in wrong contexts | Describe specific use cases |
Tool Implementation Patterns
Once you define a tool schema, you implement the actual function. Good tool implementations are predictable, fast, and handle errors gracefully.
Synchronous Tools
Simplest case: LLM calls tool, waits for result, continues immediately.
def calculate_expression(expression: str) -> dict:
"""Evaluate a mathematical expression safely."""
try:
result = eval(expression)
return {"success": True, "result": result}
except Exception as e:
return {"success": False, "error": str(e)}Asynchronous Tools
Long-running operations (file uploads, API calls) use async + polling:
async def upload_large_file(file_path: str) -> dict:
"""Start file upload and return job ID."""
job_id = start_upload(file_path)
return {"job_id": job_id, "status": "uploading"}
# LLM can then poll:
def check_upload_status(job_id: str) -> dict:
"""Check status of upload job."""
status = get_upload_status(job_id)
return {"job_id": job_id, "status": status, "progress": ...}Idempotent Tools
Tools should be safe to call multiple times. If LLM calls create_user(email="john@example.com") twice, the second call should either:
- Return the existing user (preferred)
- Return an error explaining the user exists
Never create duplicate records.
Read-Only vs Side-Effectful
| Type | Example | Safety |
|---|---|---|
| Read-only | get_user(id), search_docs(query) |
Safe; LLM can call freely |
| Side-effectful | delete_record(id), send_email(to) |
Risky; require human approval |
Error Handling Pattern
def robust_tool(param: str) -> dict:
"""Tool with comprehensive error handling."""
# Validate input
if not param:
return {"error": "Parameter is empty", "code": "INVALID_INPUT"}
try:
# Main logic
result = perform_operation(param)
return {"success": True, "data": result}
except TimeoutError:
return {"error": "Operation timed out", "code": "TIMEOUT"}
except PermissionError:
return {"error": "Access denied", "code": "PERMISSION_DENIED"}
except Exception as e:
return {"error": str(e), "code": "INTERNAL_ERROR"}Tool Composition
Tools can call other tools. Example: compare_sentiment(text_a, text_b) might internally call analyze_sentiment(text_a) and analyze_sentiment(text_b), then compare. From the LLM's perspective, this is a single tool call.
Timeout Handling
Every tool should have a timeout. If a tool takes >30 seconds, something is wrong:
import signal
def timeout_handler(signum, frame):
raise TimeoutError("Tool execution exceeded 30 seconds")
def run_with_timeout(tool_func, args, timeout_seconds=30):
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(timeout_seconds)
try:
result = tool_func(*args)
signal.alarm(0) # Cancel alarm
return result
except TimeoutError:
return {"error": "Execution timeout", "code": "TIMEOUT"}{"success": bool, "data": ..., "error": ...}. Avoid plain text results—LLM can parse JSON more reliably.
Model Context Protocol (MCP)
MCP is an open standard for connecting LLMs to data sources and tools. Before MCP, every tool integration was custom, duplicated work, and fragile. MCP standardizes it.
The Problem MCP Solves
Without MCP:
- Integrating a Slack bot required custom code for Slack's API
- Adding GitHub tool access required learning GitHub's SDK
- Each integration was isolated; no code reuse
- Credentials scattered across configs and env vars
With MCP:
- One standard interface for all tools
- Plug-and-play servers from any provider
- Centralized credential management
- Reusable across different applications
MCP Architecture
Three components:
Host
Your application (Claude, custom agent). Initiates connections, calls tools, manages state.
Client
Proxy layer. Translates host requests into MCP messages. Handles transport (stdio, HTTP).
Server
Tool provider (Slack, GitHub, database). Implements tools, resources, and prompts.
Transport
Communication mechanism. Stdio (local subprocess) or HTTP+SSE (remote).
Three Primitives
| Primitive | Purpose | Example |
|---|---|---|
| Tools | Callable functions the LLM invokes | send_message(channel, text) |
| Resources | Data the LLM can read (files, DB tables) | Database schema, file contents |
| Prompts | Parameterized prompt templates | "Summarize this: {text}" |
Transport: Stdio vs HTTP
Stdio (Local): MCP server runs as subprocess. Fast, secure, no network. Use for local tools.
HTTP+SSE (Remote): MCP server runs on a server. Network latency, but centralized. Use for shared tools across teams.
Authentication
Each MCP server handles its own auth. Examples:
- Slack: OAuth token stored securely
- GitHub: Personal access token in env var
- Database: Connection string with password
The client forwards auth to the server at initialization. The host doesn't see credentials directly.
Ecosystem
Available MCP servers include:
- Filesystem: List/read/write files
- Slack: Send messages, read conversations
- GitHub: Create issues, read repos, manage PRs
- PostgreSQL: Query databases
- Web Browser: Navigate URLs, read pages
MCP Protocol Details
MCP uses JSON-RPC 2.0 over stdio or HTTP. Understanding the protocol flow helps you debug and build MCP servers.
Message Format
Every message is JSON-RPC 2.0:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {...},
"clientInfo": {
"name": "claude-host",
"version": "1.0"
}
}
}Lifecycle: Initialize → ListTools → CallTool → Shutdown
- Initialize: Client sends
initializewith client info and capabilities - List Tools: Server responds to
list_toolswith available tools and schemas - Call Tool: Client sends
call_toolwith tool name and arguments - Resource Access: Client can call
list_resourcesandread_resource - Shutdown: Client calls
shutdownto close connection
Error Codes
| Code | Meaning | Example |
|---|---|---|
| -32700 | Parse error (malformed JSON) | Invalid JSON in message |
| -32600 | Invalid request (missing required field) | Missing "method" field |
| -32601 | Method not found (unknown RPC method) | Calling non-existent tool |
| -32602 | Invalid params (args don't match schema) | Wrong parameter types |
| -32603 | Internal error (server-side exception) | Database connection failed |
Initialize Response
Server responds with its capabilities:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "github-mcp-server",
"version": "1.0.0"
},
"capabilities": {
"tools": {
"listChanged": false
},
"resources": {
"subscribe": true
},
"prompts": {}
}
}
}listChanged: false if your tool list is static. If tools can be added/removed dynamically, set listChanged: true.
Building an MCP Server
Building an MCP server means implementing the three primitives (tools, resources, prompts) and exposing them via the MCP protocol.
Server Architecture
Basic structure:
import mcp
class MyMCPServer(mcp.Server):
def __init__(self):
super().__init__("my-server")
async def initialize(self):
"""Called when client connects."""
return {
"name": "my-server",
"version": "1.0.0"
}
async def list_tools(self):
"""Return list of available tools with schemas."""
return [
{
"name": "add_numbers",
"description": "Add two numbers",
"inputSchema": {
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"}
},
"required": ["a", "b"]
}
}
]
async def call_tool(self, name: str, arguments: dict):
"""Execute a tool."""
if name == "add_numbers":
return arguments["a"] + arguments["b"]
else:
raise ValueError(f"Unknown tool: {name}")
if __name__ == "__main__":
server = MyMCPServer()
server.run() # Runs over stdioTool Implementation with Decorators
@server.list_tools()
async def list_tools():
return [
{
"name": "calculate",
"description": "Evaluate math expression",
"inputSchema": {...}
},
{
"name": "search_documents",
"description": "Search document store",
"inputSchema": {...}
}
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "calculate":
return eval(arguments["expression"])
elif name == "search_documents":
return search_db(arguments["query"])
else:
raise ValueError(f"Unknown tool: {name}")Resources & Prompts
Resources expose data (files, schemas). Prompts are reusable templates:
# List and read resources
@server.list_resources()
async def list_resources():
return [{"uri": "file:///config.json", "name": "Config"}]
@server.read_resource()
async def read_resource(uri: str):
return open(uri).read()
# Expose prompt templates
@server.list_prompts()
async def list_prompts():
return [{"name": "code_review", "description": "Review code"}]
@server.get_prompt()
async def get_prompt(name: str, arguments: dict):
if name == "code_review":
return f"Review: {arguments['code']}"Tool Safety & Guardrails
Not all tools are safe. Deleting files, sending emails, making purchases—these need protection. Guardrails prevent abuse while enabling powerful tool use.
Tool Risk Levels
| Level | Examples | Guardrails |
|---|---|---|
| Safe | read_file, search_docs, calculate | None |
| Risky | create_file, update_record, send_message | Audit log, confirmation |
| Dangerous | delete_file, delete_user, transfer_money | Human approval, MFA, strict limits |
Principle: Least Privilege
Tools should have minimal permissions. Bad: database user can DELETE anything. Good: database user can SELECT only from public tables.
Human Approval
For dangerous operations, require explicit confirmation:
async def call_tool(name: str, arguments: dict):
if name == "delete_file":
path = arguments["path"]
print(f"Agent wants to delete: {path}")
if input("Approve? (yes/no): ").lower() != "yes":
return {"error": "Denied"}
os.remove(path)
return {"success": True}
elif name == "search": # Safe
return search(arguments["query"])Sandboxing
Run untrusted code in isolated containers with timeouts:
import subprocess
def execute_code_safely(code: str, timeout: int = 5):
"""Execute code in sandbox. Returns stdout."""
try:
result = subprocess.run(
["python", "-c", code],
capture_output=True,
text=True,
timeout=timeout,
cwd="/tmp/sandbox" # Isolated dir
)
return result.stdout
except subprocess.TimeoutExpired:
return "ERROR: Timeout"
except Exception as e:
return f"ERROR: {e}"Input Validation
Always validate before execution:
def validate_file_operation(path: str):
"""Ensure path is safe."""
abs_path = os.path.abspath(path)
if not abs_path.startswith("/safe/directory"):
raise ValueError(f"Path outside safe zone: {abs_path}")
if not os.path.exists(abs_path):
raise FileNotFoundError(f"Path not found: {abs_path}")
return abs_pathAudit Logging
Log all tool calls for accountability:
import json
from datetime import datetime
logger = logging.getLogger("tool_audit")
async def call_tool_with_audit(name: str, arguments: dict, user: str):
"""Call tool and log everything."""
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"tool": name,
"user": user,
"arguments": arguments
}
try:
result = await execute_tool(name, arguments)
log_entry["status"] = "success"
log_entry["result_summary"] = str(result)[:100]
except Exception as e:
log_entry["status"] = "error"
log_entry["error"] = str(e)
raise
finally:
logger.info(json.dumps(log_entry))Rate Limiting
Prevent runaway agent loops:
from collections import defaultdict
from time import time
class RateLimiter:
def __init__(self, max_calls: int = 100, window: int = 60):
self.max_calls = max_calls
self.window = window
self.calls = defaultdict(list)
def allow_call(self, tool_name: str) -> bool:
"""Check if tool call is allowed."""
now = time()
self.calls[tool_name] = [
t for t in self.calls[tool_name]
if now - t < self.window
]
if len(self.calls[tool_name]) >= self.max_calls:
return False
self.calls[tool_name].append(now)
return TrueTool Selection & Routing
With many tools, which one does the LLM call? Selection accuracy depends on descriptions.
Selection Quality
Better descriptions = better selection. Compare:
| Bad | Good |
|---|---|
| "Get user" | "Fetch user profile by ID. Returns name, email, role. Error if ID doesn't exist." |
| "Search" | "Search documents by keyword. Returns 10 results ranked by relevance. Does NOT calculate." |
The 20-Tool Cliff
With >20 tools, LLM accuracy drops significantly. Solutions:
- Hierarchical tools: one meta-tool selects sub-tools
- Tool routing: pre-filter tools by query type
- Consolidation: merge similar tools
Tool Routing
Show LLM only relevant tools:
def get_relevant_tools(query: str) -> list:
"""Filter tools by query type."""
if "weather" in query.lower():
return [weather_tool, location_tool]
elif "calculate" in query.lower():
return [calculator_tool, converter_tool]
elif "file" in query.lower():
return [read_file, list_files]
else:
return [search_tool]
# LLM only sees 2-4 tools, not 20
relevant = get_relevant_tools(user_query)
llm_response = llm.call(
messages=user_messages,
tools=relevant
)Hierarchical Tools
One meta-tool orchestrates many sub-tools:
tools = [
{
"name": "database_query",
"description": "Execute SQL",
"input_schema": {
"operation": {"enum": ["select","insert","update"]}
}
}
]
async def database_query(query: str, operation: str):
if operation == "select":
return execute_select(query)
elif operation == "insert":
return execute_insert(query)
elif operation == "update":
return execute_update(query)Fallback Chain
Try tools in sequence if one fails:
async def robust_search(query: str):
try:
return search_primary(query) # Primary
except TimeoutError:
return search_cache(query) # Fallback
except Exception:
return {"error": "Search unavailable"}Parallel Execution
Call independent tools concurrently:
import asyncio
async def get_user_profile(user_id: str):
profile, messages, settings = await asyncio.gather(
fetch_profile(user_id),
fetch_messages(user_id),
fetch_settings(user_id)
)
return {
"profile": profile,
"messages": messages,
"settings": settings
}Evaluation & Debugging
Measure agent quality. Identify problems through traces and metrics.
Tool Call Trace
Always log the full flow:
User: "What's the weather in NYC?"
[TRACE]
Reasoning: "User asked about weather"
Tool: get_weather
Arguments: {"city": "New York", "unit": "fahrenheit"}
Result: {"temp": 72, "condition": "Partly cloudy"}
Response: "It's 72°F and partly cloudy in NYC"Evaluation Metrics
| Metric | Measures | Good |
|---|---|---|
| Faithfulness | Did agent use result correctly? | >95% |
| Tool Accuracy | Did tool return correct result? | >99% |
| Latency | Time per response | <1s per tool |
| Token Cost | Context used by tools | <20% |
Debugging: Inspection
When things go wrong, inspect the trace:
Tool not called? → Check tool description (too vague?) → Check if tool shown to LLM? Wrong arguments? → Check schema clarity → Check examples in description Result not used? → Is result JSON or plain text? → Check error indicator LLM refusing tool? → Security guard triggered? → Tool name seems malicious?
Unit Testing
import unittest
class TestCalculatorTool(unittest.TestCase):
def test_add(self):
result = calculate_tool("2 + 2")
self.assertEqual(result, 4)
def test_syntax_error(self):
result = calculate_tool("2 +")
self.assertEqual(result["error"], "Syntax error")
def test_timeout(self):
result = calculate_tool("while True: pass")
self.assertEqual(result["error"], "Timeout")Integration Testing
class TestAgentWorkflow(unittest.TestCase):
def test_weather_query(self):
"""Test agent uses weather tool."""
agent = Agent(tools=[weather_tool, search_tool])
response = agent.run("What's the weather in NYC?")
self.assertIn("tool_call", response["trace"])
self.assertIn("72", response["text"])Python Implementation: Complete Agent
Build a complete tool-using agent in pure Python. No external APIs—mock tools only.
Base Tool Class
import json
from typing import Any, Dict, Optional
class Tool:
"""Base class for tools."""
def __init__(self, name: str, description: str):
self.name = name
self.description = description
def execute(self, **kwargs) -> Dict[str, Any]:
"""Override in subclasses."""
raise NotImplementedError
def schema(self) -> Dict[str, Any]:
"""Return JSON schema."""
return {
"name": self.name,
"description": self.description,
"input_schema": self._input_schema()
}
def _input_schema(self) -> Dict[str, Any]:
"""Return parameter schema."""
raise NotImplementedErrorConcrete Tools
class CalculatorTool(Tool):
def __init__(self):
super().__init__(
"calculate",
"Evaluate a math expression safely"
)
def execute(self, expression: str) -> Dict[str, Any]:
try:
result = eval(expression)
return {"success": True, "result": result}
except Exception as e:
return {"success": False, "error": str(e)}
def _input_schema(self):
return {
"type": "object",
"properties": {
"expression": {"type": "string"}
},
"required": ["expression"]
}
class SearchTool(Tool):
def __init__(self, documents: list):
super().__init__(
"search_documents",
"Search documents by keyword"
)
self.documents = documents
def execute(self, query: str) -> Dict[str, Any]:
query_lower = query.lower()
results = [
doc for doc in self.documents
if query_lower in doc.lower()
]
return {
"success": True,
"results": results[:5],
"count": len(results)
}
def _input_schema(self):
return {
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
}
class WeatherTool(Tool):
def __init__(self):
super().__init__(
"get_weather",
"Get weather for a city"
)
self.data = {
"new york": {"temp": 72, "condition": "Cloudy"},
"london": {"temp": 59, "condition": "Rainy"},
"tokyo": {"temp": 81, "condition": "Sunny"}
}
def execute(self, city: str) -> Dict[str, Any]:
city_lower = city.lower()
if city_lower in self.data:
return {
"success": True,
"city": city,
"data": self.data[city_lower]
}
return {"success": False, "error": f"City not found"}
def _input_schema(self):
return {
"type": "object",
"properties": {
"city": {"type": "string"}
},
"required": ["city"]
}Tool Registry
class ToolRegistry:
"""Register and discover tools."""
def __init__(self):
self.tools = {}
def register(self, tool: Tool) -> None:
"""Register a tool."""
self.tools[tool.name] = tool
def get_tool(self, name: str) -> Optional[Tool]:
"""Get tool by name."""
return self.tools.get(name)
def list_tools(self) -> list:
"""Return all tool schemas."""
return [tool.schema() for tool in self.tools.values()]
def call_tool(self, name: str, arguments: Dict) -> Dict:
"""Execute a tool."""
tool = self.get_tool(name)
if not tool:
return {"error": f"Unknown tool: {name}"}
return tool.execute(**arguments)Mock LLM
import re
class MockLLM:
"""Simulate LLM decision-making."""
def __init__(self, registry: ToolRegistry):
self.registry = registry
def call(self, user_message: str) -> Dict[str, Any]:
"""Decide which tool to call."""
msg = user_message.lower()
if any(w in msg for w in ["calculate", "math", "2+2"]):
match = re.search(r'\b(\d+[\+\-\*/]\d+)\b', user_message)
expr = match.group(1) if match else "2+2"
return {
"success": True,
"tool_selected": "calculate",
"arguments": {"expression": expr}
}
elif any(w in msg for w in ["weather", "temperature"]):
city = "new york"
if "london" in msg:
city = "london"
elif "tokyo" in msg:
city = "tokyo"
return {
"success": True,
"tool_selected": "get_weather",
"arguments": {"city": city}
}
elif any(w in msg for w in ["search", "find"]):
return {
"success": True,
"tool_selected": "search_documents",
"arguments": {"query": user_message}
}
return {
"success": False,
"response": "I'm not sure how to help."
}ReAct Agent Loop
class Agent:
"""Reason → Act → Observe loop."""
def __init__(self, registry: ToolRegistry):
self.registry = registry
self.llm = MockLLM(registry)
self.trace = []
def run(self, user_message: str) -> Dict[str, Any]:
"""Run agent."""
self.trace = []
# Reason
reasoning = self.llm.call(user_message)
self.trace.append({"step": "reason", "result": reasoning})
if not reasoning["success"]:
return {
"user_message": user_message,
"response": reasoning["response"],
"trace": self.trace
}
# Act
tool_name = reasoning["tool_selected"]
arguments = reasoning["arguments"]
tool_result = self.registry.call_tool(tool_name, arguments)
self.trace.append({
"step": "act",
"tool": tool_name,
"arguments": arguments,
"result": tool_result
})
# Observe
response = self._generate_response(tool_name, tool_result)
self.trace.append({"step": "observe", "response": response})
return {
"user_message": user_message,
"response": response,
"trace": self.trace
}
def _generate_response(self, tool: str, result: Dict) -> str:
"""Generate response from tool result."""
if not result.get("success", True):
return f"Error: {result.get('error')}"
if tool == "calculate":
return f"The answer is {result['result']}"
elif tool == "get_weather":
data = result.get("data", {})
city = result.get("city", "the city")
return f"In {city}: {data.get('temp', 'N/A')}°F, {data.get('condition')}"
elif tool == "search_documents":
count = result.get("count", 0)
return f"Found {count} matching documents"
else:
return "Done"Example Execution
# Setup
registry = ToolRegistry()
registry.register(CalculatorTool())
registry.register(SearchTool([
"Claude is an AI assistant",
"Python is a programming language",
"Tools extend LLM capabilities"
]))
registry.register(WeatherTool())
agent = Agent(registry)
# Query 1: Math
r = agent.run("What is 10 * 5?")
print(f"User: {r['user_message']}")
print(f"Agent: {r['response']}\n")
# Query 2: Weather
r = agent.run("Tell me the weather in Tokyo")
print(f"User: {r['user_message']}")
print(f"Agent: {r['response']}\n")
# Query 3: Search
r = agent.run("Search for Python in documents")
print(f"User: {r['user_message']}")
print(f"Agent: {r['response']}\n")
# Full trace
print("=== Trace ===")
for step in r["trace"]:
print(json.dumps(step, indent=2))