Smolagents Code Agent Rebuild

What This Agent Does

This build is a small educational rebuild of the code-agent pattern behind Hugging Face smolagents. Instead of asking the model to return a normal paragraph answer immediately, the agent asks the model to think, write one Python-style tool action, observe the result, and continue until it calls a final answer tool.

The important part is the boundary. The model writes code-shaped actions, but this rebuild does not run arbitrary generated Python. It parses one approved function call, checks the tool name and argument shape, then dispatches that call through a local registry.


The Dispatch Desk

The cleanest way to think about this build is a dispatch desk.

A request comes in. The agent does not guess from memory. It asks the model what action belongs next. The model writes that action in a small Python-style block. The dispatch desk checks the action before anything runs.

If the action matches the approved contract, it reaches the tool. If the action is malformed, the desk sends the observation back to the model so the next attempt can correct it.

That is the real architecture lesson: model intent is not the same thing as system action.


Vocabulary You Need

ReAct means Reason and Act. It is the loop where the model thinks, takes an action, observes the result, then decides what to do next.

A tool registry is a controlled list of local functions the agent is allowed to call. In this build, the registry contains calculate_metrics, summarize_risk, and final_answer.

AST means Abstract Syntax Tree. It is Python’s structured way of reading code as a tree, which lets this rebuild inspect a function call before any tool runs.

An action contract is the rule that says which tool names, argument types, and call shapes are accepted. This build uses that contract to reject keyword arguments, variables, dictionaries, and unapproved tools.

A final answer gate is the point where the loop ends. In this build, the agent only finishes when the model calls final_answer.


What This Is Not

This is not a full replacement for Hugging Face smolagents.

It does not include Hugging Face Hub integration, remote execution, Docker, E2B, Modal, WebAssembly, Gradio, browser automation, multi-agent hierarchy, telemetry, model hosting, or production sandboxing.

It is a small architecture rebuild focused on one lesson: how a code-shaped agent can move from thought to action to observation without handing the model a raw Python execution environment.


Why I Built This

I built this because code agents expose one of the most important boundaries in agentic AI: the space between what a model writes and what a system actually does.

Everyone talks about agents as if tool use is the whole story. It is not. The sharper question is whether the action was validated before it reached the tool.

This rebuild forced that boundary into the open. The model could still write Python-style actions, but the system only accepted calls that matched the approved contract.


What Happened

What surprised me: The correction loop worked exactly like a useful control surface. The failed actions became observations, and the models used those observations to recover.

What I would change next: I would extract the action contract into a reusable validator module. That would make the contract easier to test across more code-agent builds.

Why this matters: The build proved that code-shaped agents need a gate between model output and execution. That gate is where a demo becomes inspectable.


The Architecture: How It Works

The architecture has five moving parts.

First, the script loads model configuration from .env. The model name stays outside the code so the same agent can run through different LiteLLM providers.

Second, the system prompt tells the model to output one Python-style tool call inside a fenced code block.

Third, the parser extracts that code block and reads it with Python’s AST tools.

Fourth, the validator accepts only one approved function call with literal string or number arguments.

Fifth, the result becomes an observation and returns to the model until the model calls final_answer.

The rebuild keeps the smolagents idea, but trims the surrounding production machinery so the pattern is visible.


What to Watch When You Run It

Watch the first line for the active model name. That confirms which provider the LiteLLM proxy is routing through.

Watch each company section. A clean run usually shows calculate_metrics, then summarize_risk, then final_answer.

Watch the observations. If the model writes the action in the wrong shape, the script prints a correction and feeds that back into the loop.

The first run showed exactly why this matters. Gemini completed the tasks, but it needed correction observations for argument shape. DeepSeek followed the tool format more cleanly, with one correction on Vector Freight. Both providers finished, which is the behavior this rebuild was designed to test.


The Build: Step by Step

The first important decision was to keep the tool list small.

MAX_STEPS = 5
APPROVED_TOOLS = {"calculate_metrics", "summarize_risk", "final_answer"}

def calculate_metrics(company_name, monthly_revenue, monthly_costs, active_customers):
    """Calculate fictional business metrics for an approved scenario."""
    revenue, costs, customers = float(monthly_revenue), float(monthly_costs), int(active_customers)
    gross_profit = revenue - costs
    margin = (gross_profit / revenue * 100) if revenue else 0
    risk = "HIGH" if margin < 10 else "MEDIUM" if margin < 25 else "LOW"
    return {
        "company_name": company_name,
        "gross_profit": round(gross_profit, 2),
        "margin_percent": round(margin, 2),
        "cost_per_customer": round(costs / customers, 2) if customers else 0,
        "risk_signal": risk,
    }

def summarize_risk(metric_summary):
    """Convert metric output into a plain-English risk interpretation."""
    return f"Risk interpretation: {metric_summary}. Use this to write a concise executive summary."

def final_answer(answer):
    """Mark the agent loop as finished with a final answer."""
    return {"is_final": True, "answer": str(answer)}

What This Code Is Actually Doing

This is the agent’s allowed workbench. calculate_metrics computes the fictional company numbers. summarize_risk turns observed metrics into plain language. final_answer marks the point where the loop is finished. The model sees these tools, but the code decides which calls are actually accepted.

The next decision was the system prompt.

def build_system_prompt(tool_descriptions):
    """Build the prompt that requires one code-shaped action per turn."""
    return f"""You are NosisAgent Lite, an educational code-shaped business agent.
Use exactly one approved tool per turn.
Approved tools:
{tool_descriptions}

Respond in this exact format:
Thought: brief reasoning
```python
approved_tool_name(...)

Valid examples:

calculate_metrics("Lumina Home Goods", 120000, 78000, 430)
summarize_risk("LOW risk, 35 percent margin, gross profit 42000, cost per customer 181.40")
final_answer("Executive summary text here.")

Rules:

  • You may call only calculate_metrics, summarize_risk, or final_answer.
  • Use calculate_metrics before summarizing risk.
  • Call final_answer only when ready to finish.
  • Use positional arguments only.
  • Use only literal strings or numbers as arguments.
  • Do not pass dictionaries, lists, variables, or keyword arguments.
  • Do not write imports, assignments, loops, file access, web access, or extra code.
  • Do not add text after the code block.”””

What This Code Is Actually Doing

This prompt gives the model a narrow operating lane. It asks for a thought line and one Python-style action. The examples matter because the first runtime test showed that models sometimes tried keyword arguments or dictionaries. Adding examples turned the contract into something the model could follow more consistently.

The strongest control is the parser.


```python
def parse_approved_action(code_text):
    """Validate one approved function call and return its name and arguments."""
    try:
        call = ast.parse(code_text, mode="eval").body
    except SyntaxError:
        return None, "The action was not valid Python expression syntax."
    if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name):
        return None, "The action must be a single approved function call."
    if call.func.id not in APPROVED_TOOLS:
        return None, f"{call.func.id} is not an approved tool."
    if call.keywords:
        return None, "Use positional arguments only in the action call."
    try:
        arguments = [parse_literal(argument) for argument in call.args]
    except (ValueError, TypeError):
        return None, "Arguments must be literal strings or numbers."
    if not all(isinstance(value, (str, int, float)) for value in arguments):
        return None, "Arguments must be literal strings or numbers."
    return {"tool_name": call.func.id, "arguments": arguments}, None

What This Code Is Actually Doing

This is the inspection desk. ast.parse reads the model’s Python-style action as structure instead of running it. The function checks that the action is one function call, that the function name appears in the approved list, and that every argument is a literal string or number. That is how the rebuild demonstrates code-shaped action without giving the model a general execution surface.

Then the main loop ties it together.

def run_agent(client, model_name, scenario):
    """Run the code-shaped ReAct loop for one model and scenario."""
    validate_scenario(scenario)
    tools = {"calculate_metrics": calculate_metrics, "summarize_risk": summarize_risk, "final_answer": final_answer}
    messages = [
        {"role": "system", "content": build_system_prompt(build_tool_descriptions())},
        {"role": "user", "content": build_task(scenario)},
    ]
    print(f"\nCompany: {scenario['company_name']}")
    for _ in range(MAX_STEPS):
        model_text = call_model(client, model_name, messages)
        if model_text is None:
            return
        messages.append({"role": "assistant", "content": model_text})
        thought = get_thought(model_text)
        if thought:
            print(thought)
        code_text, error = extract_python_action(model_text)
        action, error = (None, error) if error else parse_approved_action(code_text)
        if not error:
            print(f"Approved action: {action['tool_name']}")
            result, error = run_action(action, tools)
        observation = error or str(result)
        print(f"Observation: {observation}")
        if not error and action["tool_name"] == "final_answer" and isinstance(result, dict):
            print("Final answer:")
            print(result["answer"])
            return
        messages.append({"role": "user", "content": f"Observation: {observation}"})
    print("The run stopped before the agent produced a final answer.")

What This Code Is Actually Doing

This is the ReAct loop in its smallest useful form. The model sends an action, the parser checks it, the local tool runs if the action is approved, and the observation returns to the model. If the model calls final_answer, the loop stops. If the model writes the action incorrectly, the error text becomes the next observation.

The dependency file ended up being part of the build story.

# Tested with Python 3.14
litellm==1.83.7
python-dotenv==1.0.1

What This Code Is Actually Doing

This locks the demo to the versions that actually installed together. litellm gives the project a provider-agnostic model connection. python-dotenv loads local environment variables. The pin matters because a later python-dotenv version conflicted with the exact LiteLLM version tested in this environment.


Errors I Hit During This Build

The first issue was a dependency conflict during installation.

ERROR: Cannot install litellm==1.83.7 and python-dotenv==1.1.1 because these package versions have conflicting dependencies.

The conflict is caused by:
    The user requested python-dotenv==1.1.1
    litellm 1.83.7 depends on python-dotenv==1.0.1

The second issue came from provider behavior during runtime. Gemini and DeepSeek both completed the tasks, but some first actions did not match the accepted contract.

Observation: Use positional arguments only in the action call.
Observation: Arguments must be literal strings or numbers.

The third issue was size. The first agent.py draft was valid, but it was too large for the simplicity standard of this series. I rewrote it smaller while keeping the key controls: LiteLLM configuration, approved tools, AST validation, correction observations, and multi-provider scenarios.


Why This Matters

Code agents are powerful because they let models express actions in a flexible format. That flexibility is also where architecture matters.

The key lesson from this build is that a code-shaped action is still only a proposal until the system validates it. The model can write calculate_metrics(…), but the local program decides whether that call is approved, whether the arguments are shaped correctly, and whether the loop is ready to finish.

That is the difference between a demo that merely looks agentic and a demo that teaches the execution boundary.


Tools Used

Hugging Face smolagents (Apache 2.0): https://github.com/huggingface/smolagents
LiteLLM (MIT): https://github.com/BerriAI/litellm
python-dotenv (BSD 3-Clause): https://github.com/theskumar/python-dotenv
OpenAI Python SDK (Apache 2.0): https://github.com/openai/openai-python
NosisTech Agent Engineering (MIT): https://github.com/nosistech/agent-engineering


(c) 2026 NosisTech LLC. Licensed under CC BY 4.0.
Use freely, just credit us.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.