January 2025
I’ve been building a lot of complex agents at work, and wanted to solidify my understanding — so thought I’d make the simplest barebones agents I could.
I ended up making two, one with built-in function calling, and one without.
Without function calling (99 lines)
from typing import List, Dict, Callable
from dataclasses import dataclass
from anthropic import Anthropic
import os
import dotenv
dotenv.load_dotenv()
@dataclass
class Tool:
name: str
description: str
func: Callable
class Agent:
def __init__(self, tools: List[Tool]):
self.tools = tools
self.client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
def get_tool_descriptions(self) -> str:
return "\n".join([
f"- {tool.name}: {tool.description}"
for tool in self.tools
])
def run(self, query: str) -> str:
result = ""
steps = []
while True:
prompt = f"""You are an agent helping answer a user query.
Available tools: {self.get_tool_descriptions()}
Original query: {query}
Current results: {result}
Steps taken: {', '.join(steps) if steps else 'None'}
If you have enough information to answer the query, respond with: DONE: [final answer]
Otherwise, respond with the name of the next tool to use (just the tool name).
If you have no information to answer the query, and neither of the tools can help, respond with: DONE: [final answer]
If you find yoursef looping, respond with: DONE: [final answer]
"""
#print(f"prompt: {prompt}")
response = self.client.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=100,
temperature=0,
messages=[{"role": "user", "content": prompt}]
)
answer = response.content[0].text.strip()
#print(answer)
if answer.startswith("DONE:"):
return answer[5:].strip() # Return everything after "DONE:"
# Find and execute the matching tool
tool_name = answer.lower()
for tool in self.tools:
if tool.name.lower() == tool_name:
tool_result = tool.func(query)
steps.append(f"Used {tool.name} (Step {len(steps)+1})")
result += f"\nStep {len(steps)}: {tool_result}"
break
# Simulate tools (not actually implemented)
def check_calendar(task: str) -> str:
return "Calendar shows: Meeting at 2pm"
def search_email(task: str) -> str:
return "Found email from Bob about project deadline"
# Create tools list
tools = [
Tool("calendar", "Checks calendar events", check_calendar),
Tool("email", "Searches emails", search_email),
]
def main():
agent = Agent(tools)
while True:
query = input("What would you like to know? (or 'quit' to exit): ")
if query.lower() == 'quit':
break
result = agent.run(query)
print(result)
if __name__ == "__main__":
main()
What’s happening here pre-call:
- we simply prompt the LLM to output a single word for the tools it wants to use
- we tell it to output "DONE: {final answer}" when finished
- we wrap it in an infinite loop, and tell it to terminate if it’s looping or out of tools
Then we simply check for these values in the text post-call:
- if it starts with "DONE", we extract the remaining portion, print it, and terminate
- if it's a one-word answer, we check if it matches any of our function names, and if so we call and append to results
- after each response, we insert the steps taken (tools used) and all prior results into the next prompt for context
All the magic here is in the prompt and context of steps taken. By providing past steps, results, and instructions to terminate if it’s looping or has a good answer -- it actually does a pretty good job!
The output:
(.venv) shwin@ashwin-m1-wan ai_assistant % python agent_no_func_call.py
What would you like to know? (or 'quit' to exit): what should I know
You've got a meeting at 2pm, and an email from Bob about a project deadline!
With built-in function calling (140 lines)
from typing import List, Dict, Callable
from dataclasses import dataclass
from anthropic import Anthropic, HUMAN_PROMPT
import os
import dotenv
dotenv.load_dotenv()
@dataclass
class Tool:
name: str
description: str
func: Callable
class Agent:
def __init__(self, tools: List[Tool]):
self.tools = tools
self.client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
def get_tool_schemas(self) -> List[Dict]:
calendar_schema = {
"name": "calendar",
"description": "Checks calendar events for a person",
"input_schema": {
"type": "object",
"properties": {
"person_name": {
"type": "string",
"description": "The name of the person to look up"
}
},
"required": ["person_name"]
}
}
email_schema = {
"name": "email",
"description": "Searches emails for a person",
"input_schema": {
"type": "object",
"properties": {
"person_name": {
"type": "string",
"description": "The name of the person to look up"
}
},
"required": ["person_name"]
}
}
return [calendar_schema, email_schema]
def run(self, query: str) -> str:
result = ""
steps = []
messages = [{
"role": "user",
"content": f"Help answer this query: {query}\nRespond with DONE: [answer] if no tools needed, we're looping, or no tools left to call for new info. Be thorough and provide all answers you possibly can if there are multiple tools."
}]
while True:
print("looping")
response = self.client.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=100,
temperature=0,
messages=messages,
tools=self.get_tool_schemas()
)
#print(response)
message = response.content[0].text
if message.startswith("DONE:"):
return message[5:].strip()
# Check for tool use blocks in the content
for block in response.content:
if block.type == 'tool_use':
tool_name = block.name.lower()
person_name = block.input.get("person_name")
# Skip if no person_name provided
if not person_name:
continue
for tool in self.tools:
if tool.name.lower() == tool_name:
tool_result = tool.func(person_name)
steps.append(f"Used {tool.name} for {person_name}")
result += f"\n{tool_result}"
# Add the tool result to messages
messages.append({
"role": "assistant",
"content": f"I used {tool.name} for {person_name} and got this result: {tool_result}"
})
messages.append({
"role": "user",
"content": f"Continue or respond with DONE: [answer] if we have enough information."
})
# Simulate tools with name-based responses
def check_calendar(name: str) -> str:
calendar_data = {
"alice": "Meeting with clients at 2pm",
"bob": "Team standup at 10am",
"charlie": "Lunch meeting at 12pm",
"default": "No meetings found"
}
return f"Calendar for {name}: {calendar_data.get(name.lower(), calendar_data['default'])}"
def search_email(name: str) -> str:
email_data = {
"alice": "Latest email about Q4 planning",
"bob": "Project status update",
"charlie": "Vacation request pending",
"default": "No recent emails"
}
return f"Emails from {name}: {email_data.get(name.lower(), email_data['default'])}"
# Create tools list
tools = [
Tool("calendar", "Checks calendar events for a person", check_calendar),
Tool("email", "Searches emails for a person", search_email),
]
def main():
agent = Agent(tools)
while True:
query = input("What would you like to know? (or 'quit' to exit): ")
if query.lower() == 'quit':
break
result = agent.run(query)
print(result)
if __name__ == "__main__":
main()
Basically a more formalized version of our non-function-calling example:
- instead of a simple description of tools, we have an official
input_schema
format for thetools
param, one for each tool - we append previous results using the
messages
parameter instead of injecting directly into our prompt (essentially the same thing) - same check for "DONE" to terminate
The output when we run:
(.venv) shwin@ashwin-m1-wan ai_assistant % python agent_func_call.py
What would you like to know? (or 'quit' to exit): what should alice know
Alice should know:
1. She has a meeting with clients at 2pm (from calendar)
2. There's an ongoing email thread about Q4 planning (from email search)
Takeaways
Maybe due to confusion with Code Interpreter, I actually didn’t fully grasp that the functions were run locally, and that built-in function calling essentially just provides a more robust, structured version of our function-name-check in the non-function calling example.
I’m also much clearer on termination methods now, there are several:
- check for safe word (if "DONE" in output)
- max number of iterations
- end of a specific function
- a called function may set some state that we check for
- include an explicit Finished() function/tool
Overall this was a worthy exercise.