AI Agents are getting surprisingly easy to implement

As most people in the Data world, I have been more and more exposed to LLM based AI tools.
Things like Copilot or Perplexity have been adopted as new, better tools and have indeed made my regular workflow faster (to an extent).

Side note: I don't understand why we have spent over a decade saying 'Don't call it AI, call it Machine Learning, because AI is a much broader spectrum that does not necessarily rely on training data', but then OpenAI released ChatGPT a couple years ago and suddenly we are all forced to use the dreaded term again.

While the big AI companies are pushing towards bigger , smarter models (which makes sense, since bigger models are quite expensive to train and are a natural business moat to protect their position of monopoly), other voices are proposing something different. What if, instead of having massive models that can do anything (compose a song,  then solve differential equations, then tell a joke), we had ensembles of small, focused LLM models cooperating with each other?

These LLM models are called AI Agents (or Agentic workflows), They usually have access to tools (aka, functions) and can take a general user input and decide which tools to call, or which agents to call.

Up until recently, building these Agents has been quite messy, with different frameworks showing up.  
The most popular framework for LLM work is Langchain, which is extremely convoluted and unintuitive from a software engineering point of view.

Alternatively, one can use specific provider  packages directly (like Openai python client), which makes things easier to modify, at the cost of more verbose code.

However, just a few days ago, Huggingface released smolagents, a library that dramatically simplifies Agent development. Its a batteries included package that makes developing AI Agents a breeze.

Lets use an example to compare the difference between building an Agent from scratch vs using smolagents.

Building a currency exchange Agent

We will blatantly copy the excelent tutorial at SwirlAI newsletter and we will build an Agent that can take user queries, and perform currency conversion and return the converted currency.

Manual implementation

To build the Agent manually, the core thing we need to implement is the Tool. Remember, the main difference between an LLM powered chatbot and an LLM powered Agent, is that Agents have access to tools (which can be tools that call other agents).

We define a generic Tool class and a decorator that can turn a python function into an LLM compatible tool just by reading its docstring.

"""https://www.newsletter.swirlai.com/p/building-ai-agents-from-scratch-part"""
from dataclasses import dataclass
from typing import Dict, Any, Callable, get_type_hints, _GenericAlias, List
import inspect
import os
from openai import OpenAI
import json
import urllib

GITHUB_API_TOKEN = os.environ["GITHUB_API_TOKEN"]

def parse_docstring_params(docstring: str) -> Dict[str, str]:
    """Extract parameter descriptions from docstring."""
    if not docstring:
        return {}
    
    params = {}
    lines = docstring.split('\n')
    in_params = False
    current_param = None
    
    for line in lines:
        line = line.strip()
        if line.startswith('Parameters:'):
            in_params = True
        elif in_params:
            if line.startswith('-') or line.startswith('*'):
                current_param = line.lstrip('- *').split(':')[0].strip()
                params[current_param] = line.lstrip('- *').split(':')[1].strip()
            elif current_param and line:
                params[current_param] += ' ' + line.strip()
            elif not line:
                in_params = False

    return params

def get_type_description(type_hint: Any) -> str:
    """Get a human-readable description of a type hint."""
    if isinstance(type_hint, _GenericAlias):
        if type_hint._name == 'Literal':
            return f"one of {type_hint.__args__}"
    return type_hint.__name__

@dataclass
class Tool:
    """Tool class that can produce valid Agent function calls from function docstrings"""
    name: str
    description: str
    func: Callable[..., str]
    parameters: Dict[str, Dict[str, str]]

    def __call__(self, *args, **kwargs) -> str:
        return self.func(*args, **kwargs)

def tool(name: str = None):
    def decorator(func: Callable[..., str]) -> Tool:
        tool_name = name or func.__name__
        description = inspect.getdoc(func) or "No description available"

        type_hints = get_type_hints(func)
        param_docs = parse_docstring_params(description)
        sig = inspect.signature(func)

        params = {}
        for param_name, param in sig.parameters.items():
            params[param_name] = {
                "type": get_type_description(type_hints.get(param_name, Any)),
                "description": param_docs.get(param_name, "No description available")
            }

        return Tool(
            name=tool_name,
            description=description.split('\n\n')[0],
            func=func,
            parameters=params
        )
    return decorator

This tool class takes a function docstring and generates json documentation of the expected inputs the tool will take. It will also add a description to the json that will be used in the System prompt to tell the LLM which tools it has access to and how to use them.

Now that we have the decorator, we can create the currency exchange function fairly easily.

@tool()
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """Converts currency using latest exchange rates.
    
    Parameters:
        - amount: Amount to convert
        - from_currency: Source currency code (e.g., USD)
        - to_currency: Target currency code (e.g., EUR)
    """
    try:
        url = f"https://open.er-api.com/v6/latest/{from_currency.upper()}"
        with urllib.request.urlopen(url) as response:
            data = json.loads(response.read())

        if "rates" not in data:
            return "Error: Could not fetch exchange rates"

        rate = data["rates"].get(to_currency.upper())
        if not rate:
            return f"Error: No rate found for {to_currency}"

        converted = amount * rate
        return f"{amount} {from_currency.upper()} = {converted:.2f} {to_currency.upper()}"

    except Exception as e:
        return f"Error converting currency: {str(e)}"

Next we need to build the Agent class, this will be the class in charge of answering the user queries, and it will have access to a list of tools.

class Agent:
    def __init__(self):
        """Initialize Agent with empty tool registry."""
        self.client = OpenAI(
              base_url="https://models.inference.ai.azure.com",
              api_key=os.environ["GITHUB_API_TOKEN"],
        )
        self.tools: Dict[str, Tool] = {}
    
    def add_tool(self, tool: Tool) -> None:
        """Register a new tool with the agent."""
        self.tools[tool.name] = tool
    
    def get_available_tools(self) -> List[str]:
        """Get list of available tool descriptions."""
        return [f"{tool.name}: {tool.description}" for tool in self.tools.values()]
    
    def use_tool(self, tool_name: str, **kwargs: Any) -> str:
        """Execute a specific tool with given arguments."""
        if tool_name not in self.tools:
            raise ValueError(f"Tool '{tool_name}' not found. Available tools: {list(self.tools.keys())}")
        
        tool = self.tools[tool_name]
        return tool.func(**kwargs)

    def create_system_prompt(self) -> str:
        """Create the system prompt for the LLM with available tools."""
        tools_json = {
            "role": "AI Assistant",
            "capabilities": [
                "Using provided tools to help users when necessary",
                "Responding directly without tools for questions that don't require tool usage",
                "Planning efficient tool usage sequences"
            ],
            "instructions": [
                "Use tools only when they are necessary for the task",
                "If a query can be answered directly, respond with a simple message instead of using tools",
                "When tools are needed, plan their usage efficiently to minimize tool calls"
            ],
            "tools": [
                {
                    "name": tool.name,
                    "description": tool.description,
                    "parameters": {
                        name: {
                            "type": info["type"],
                            "description": info["description"]
                        }
                        for name, info in tool.parameters.items()
                    }
                }
                for tool in self.tools.values()
            ],
            "response_format": {
                "type": "json",
                "schema": {
                    "requires_tools": {
                        "type": "boolean",
                        "description": "whether tools are needed for this query"
                    },
                    "direct_response": {
                        "type": "string",
                        "description": "response when no tools are needed",
                        "optional": True
                    },
                    "thought": {
                        "type": "string", 
                        "description": "reasoning about how to solve the task (when tools are needed)",
                        "optional": True
                    },
                    "plan": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "steps to solve the task (when tools are needed)",
                        "optional": True
                    },
                    "tool_calls": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "tool": {
                                    "type": "string",
                                    "description": "name of the tool"
                                },
                                "args": {
                                    "type": "object",
                                    "description": "parameters for the tool"
                                }
                            }
                        },
                        "description": "tools to call in sequence (when tools are needed)",
                        "optional": True
                    }
                },
                "examples": [
                    {
                        "query": "Convert 100 USD to EUR",
                        "response": {
                            "requires_tools": True,
                            "thought": "I need to use the currency conversion tool to convert USD to EUR",
                            "plan": [
                                "Use convert_currency tool to convert 100 USD to EUR",
                                "Return the conversion result"
                            ],
                            "tool_calls": [
                                {
                                    "tool": "convert_currency",
                                    "args": {
                                        "amount": 100,
                                        "from_currency": "USD", 
                                        "to_currency": "EUR"
                                    }
                                }
                            ]
                        }
                    },
                    {
                        "query": "What's 500 Japanese Yen in British Pounds?",
                        "response": {
                            "requires_tools": True,
                            "thought": "I need to convert JPY to GBP using the currency converter",
                            "plan": [
                                "Use convert_currency tool to convert 500 JPY to GBP",
                                "Return the conversion result"
                            ],
                            "tool_calls": [
                                {
                                    "tool": "convert_currency",
                                    "args": {
                                        "amount": 500,
                                        "from_currency": "JPY",
                                        "to_currency": "GBP"
                                    }
                                }
                            ]
                        }
                    },
                    {
                        "query": "What currency does Japan use?",
                        "response": {
                            "requires_tools": False,
                            "direct_response": "Japan uses the Japanese Yen (JPY) as its official currency. This is common knowledge that doesn't require using the currency conversion tool."
                        }
                    }
                ]
            }
        }
        
        return f"""You are an AI assistant that helps users by providing direct answers or using tools when necessary.
Configuration, instructions, and available tools are provided in JSON format below:

{json.dumps(tools_json, indent=2)}

Always respond with a JSON object following the response_format schema above. 
Remember to use tools only when they are actually needed for the task."""

    def plan(self, user_query: str) -> Dict:
        """Use LLM to create a plan for tool usage."""
        messages = [
            {"role": "system", "content": self.create_system_prompt()},
            {"role": "user", "content": user_query}
        ]
        
        response = self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            temperature=0
        )
        
        try:
            return json.loads(response.choices[0].message.content)
        except json.JSONDecodeError:
            raise ValueError("Failed to parse LLM response as JSON")

    def execute(self, user_query: str) -> str:
        """Execute the full pipeline: plan and execute tools."""
        try:
            plan = self.plan(user_query)
            
            if not plan.get("requires_tools", True):
                return plan["direct_response"]
            
            # Execute each tool in sequence
            results = []
            for tool_call in plan["tool_calls"]:
                tool_name = tool_call["tool"]
                tool_args = tool_call["args"]
                result = self.use_tool(tool_name, **tool_args)
                results.append(result)
            
            # Combine results
            return f"""Thought: {plan['thought']}
Plan: {'. '.join(plan['plan'])}
Results: {'. '.join(results)}"""
            
        except Exception as e:
            return f"Error executing plan: {str(e)}"

Most of the code in the Agent is the system prompt, and how to add the available tools as part of the prompt.

Notice how the Agent also has a plan step in which it decides if any tool has to be used, then all the required tools are executed sequentially.

Also, for the sake of simplicity, this Agent doesnt run a ReAct (Reason, Act) loop, meaning all potential tools are decided at the planning step and there is no chance of reevaluating such plan based on the tools output. For example if one tool returns an error, then we wont have a way to adapt to that error.

Now if we want to build an app with an Agent that can do currency conversion we just have to do this.

agent = Agent()
agent.add_tool(convert_currency)

query = "I am traveling to Japan from Serbia, I have 1500 of local currency, how much of Japanese currency will I be able to get?"

print(f"\nQuery: {query}")
result = agent.execute(query)
print(result)

And this is the output:

Magic!

Pretty neat right?

SmolAgents implementation

With smolagents all of the class definition is managed by the library. For convenience I will use the HFAPI model (which uses Huggingface Inference service). But you can use OpenAI as well.

import json
import os
from typing import Optional
import urllib

from smolagents.agents import ToolCallingAgent
from smolagents import tool, HfApiModel, LiteLLMModel

GITHUB_API_TOKEN = os.environ["GITHUB_API_TOKEN"]


'''
# NOTE, I dont have a personal OpenAI account, and Azure Inference API doesnt 
# Have access to tool calling models. So i will use the Hf Inference API model for this example.

model = LiteLLMModel(
    model_id="gpt-4o",
    api_base="https://models.inference.ai.azure.com",
    api_key=os.environ["GITHUB_API_TOKEN"],
)
'''
model = HfApiModel("Qwen/Qwen2.5-72B-Instruct")

@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """
    Converts currency using latest exchange rates.
    
    Args:
        amount: Amount to convert
        from_currency: Source currency code (e.g., USD)
        to_currency: Target currency code (e.g., EUR)
    """
    try:
        url = f"https://open.er-api.com/v6/latest/{from_currency.upper()}"
        with urllib.request.urlopen(url) as response:
            data = json.loads(response.read())

        if "rates" not in data:
            return "Error: Could not fetch exchange rates"

        rate = data["rates"].get(to_currency.upper())
        if not rate:
            return f"Error: No rate found for {to_currency}"

        converted = amount * rate
        return f"{amount} {from_currency.upper()} = {converted:.2f} {to_currency.upper()}"

    except Exception as e:
        return f"Error converting currency: {str(e)}"

agent = ToolCallingAgent(tools=[convert_currency], model=model)
query = "I am traveling to Japan from Serbia, I have 1500 of local currency, how much of Japanese currency will I be able to get?"

result = agent.run(query)
print(result)

As you can see, now we only need to define the custom tool we require, usually this is all we will require in production, as the business logic is usually enclosed in this kind of custom functions.

Running this version we get the following output.

More magic!

Running the smolagents model takes significantly longer (even when using the OpenAI model).  It seems like some processing is happening even before the inference is called.

However, the benefits are tremendous. As you can see the results are nicer, since smolagents adds logging by default. And we can add a ton of modifications to the behaviour (such as planning) with just one or two keywords.

I am very excited about this library, and cant recommend checking their examples enough.