A simple AI agent in less than 100 lines

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:

  1. we simply prompt the LLM to output a single word for the tools it wants to use
  2. we tell it to output "DONE: {final answer}" when finished
  3. 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 the tools 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.