Course EN
← back to chapter

Agente de cálculo

Un agente con una calculadora segura (AST, sin `eval`) y un conversor de unidades que los encadena para resolver problemas de palabras. El agente planea; los tools hacen la aritmética y las conversiones exactas.

Showcase — Agente de cálculo

Un agente con una calculadora segura (AST, sin eval) y un conversor de unidades que los encadena para resolver problemas de palabras. El agente planea; los tools hacen la aritmética y las conversiones exactas.

Córrelo

bash bootstrap-secrets.sh              # reads ../../../../.env, writes secrets/
docker compose up --build              # default: PROVIDER=openai

Abre http://localhost:3000. Gemini: PROVIDER=gemini docker compose up --build.

Qué hay aquí

  • backend/ai_openai.py / backend/ai_gemini.py — los dos tools + el agent loop acotado.
  • frontend/app/page.tsx — caja de pregunta, respuesta encadenada.

Detenlo

docker compose down

Ejecútalo en tu máquina

Descarga el proyecto como ZIP y córrelo con Docker. Levanta un backend FastAPI y un frontend Next.js en localhost:3000.

Descargar calc-agent.zip

unzip calc-agent.zip
cd calc-agent
bash bootstrap-secrets.sh   # one-time: pulls API keys into ./secrets
docker compose up --build   # default provider: openai
# or:  PROVIDER=gemini docker compose up --build

Escribe algo, elige un proveedor y ejecuta el mismo código de Código contra la API real. Requiere iniciar sesión.


  

Los mismos módulos que ejecuta el botón Run. El proyecto completo (frontend, Dockerfile, compose) está en el ZIP, pestaña README.

backend/ai_openai.py

"""Showcase 1 (OpenAI): a calculation agent.

An agent with two tools — a safe calculator and a unit converter — that solves
word problems by chaining them. "How many pounds is 12 kg, and what's that times
3?" takes a convert then a calculate. The agent plans the chain; the tools do the
exact work models are bad at.
"""
import ast
import json
import operator

from openai import OpenAI

_client = OpenAI()

_MODEL = "gpt-5.4-nano"

_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
        ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}


def _eval(node):
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.BinOp):
        return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
    if isinstance(node, ast.UnaryOp):
        return _OPS[type(node.op)](_eval(node.operand))
    raise ValueError("unsupported expression")


def calculate(expression: str) -> dict:
    try:
        return {"result": _eval(ast.parse(expression, mode="eval").body)}
    except Exception as e:
        return {"error": str(e)}


_FACTORS = {("km", "mi"): 0.621371, ("mi", "km"): 1.60934, ("kg", "lb"): 2.20462,
            ("lb", "kg"): 0.453592, ("m", "ft"): 3.28084, ("ft", "m"): 0.3048}


def convert(value: float, from_unit: str, to_unit: str) -> dict:
    f, t = from_unit.lower(), to_unit.lower()
    if (f, t) == ("c", "f"):
        return {"result": value * 9 / 5 + 32}
    if (f, t) == ("f", "c"):
        return {"result": (value - 32) * 5 / 9}
    factor = _FACTORS.get((f, t))
    if factor is None:
        return {"error": f"no conversion {f}->{t}", "known": [f"{a}->{b}" for a, b in _FACTORS]}
    return {"result": value * factor}


_IMPL = {"calculate": calculate, "convert": convert}

_TOOLS = [
    {"type": "function", "name": "calculate",
     "description": "Evaluate arithmetic like '12.5 * 3'. Supports + - * / ** and parentheses.",
     "parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}},
    {"type": "function", "name": "convert",
     "description": "Convert a value between units: km/mi, kg/lb, m/ft, c/f.",
     "parameters": {"type": "object", "properties": {
         "value": {"type": "number"}, "from_unit": {"type": "string"}, "to_unit": {"type": "string"}},
      "required": ["value", "from_unit", "to_unit"]}},
]

_GOAL = ("You are a calculation agent. Use `convert` for unit conversions and "
         "`calculate` for arithmetic. Never compute in your head. Answer plainly with units.")


def run(question: str) -> str:
    q = question.strip()
    if not q:
        return "Ask a math or unit-conversion question."
    input_list = [{"role": "user", "content": q}]
    response = None
    for _ in range(8):
        response = _client.responses.create(model=_MODEL, instructions=_GOAL, input=input_list, tools=_TOOLS)
        calls = [i for i in response.output if i.type == "function_call"]
        if not calls:
            break
        input_list += response.output
        for c in calls:
            result = _IMPL[c.name](**json.loads(c.arguments))
            input_list.append({"type": "function_call_output", "call_id": c.call_id, "output": json.dumps(result)})
    return response.output_text if response else ""

backend/ai_gemini.py

"""Showcase 1 (Gemini): a calculation agent.

Same calculator + converter tools and bounded loop, Gemini's tool-calling.
"""
import ast
import operator
import os

from google import genai
from google.genai import types

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

_MODEL = "gemini-3.1-flash-lite"

_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
        ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}


def _eval(node):
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.BinOp):
        return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
    if isinstance(node, ast.UnaryOp):
        return _OPS[type(node.op)](_eval(node.operand))
    raise ValueError("unsupported expression")


def calculate(expression: str) -> dict:
    try:
        return {"result": _eval(ast.parse(expression, mode="eval").body)}
    except Exception as e:
        return {"error": str(e)}


_FACTORS = {("km", "mi"): 0.621371, ("mi", "km"): 1.60934, ("kg", "lb"): 2.20462,
            ("lb", "kg"): 0.453592, ("m", "ft"): 3.28084, ("ft", "m"): 0.3048}


def convert(value: float, from_unit: str, to_unit: str) -> dict:
    f, t = from_unit.lower(), to_unit.lower()
    if (f, t) == ("c", "f"):
        return {"result": value * 9 / 5 + 32}
    if (f, t) == ("f", "c"):
        return {"result": (value - 32) * 5 / 9}
    factor = _FACTORS.get((f, t))
    if factor is None:
        return {"error": f"no conversion {f}->{t}"}
    return {"result": value * factor}


_IMPL = {"calculate": calculate, "convert": convert}

_CONFIG = types.GenerateContentConfig(
    system_instruction=("You are a calculation agent. Use `convert` for unit conversions "
                        "and `calculate` for arithmetic. Never compute in your head. Answer with units."),
    tools=[types.Tool(function_declarations=[
        types.FunctionDeclaration(name="calculate", description="Evaluate arithmetic like '12.5 * 3'.",
                                  parameters=types.Schema(type=types.Type.OBJECT,
                                      properties={"expression": types.Schema(type=types.Type.STRING)}, required=["expression"])),
        types.FunctionDeclaration(name="convert", description="Convert a value between units: km/mi, kg/lb, m/ft, c/f.",
                                  parameters=types.Schema(type=types.Type.OBJECT, properties={
                                      "value": types.Schema(type=types.Type.NUMBER),
                                      "from_unit": types.Schema(type=types.Type.STRING),
                                      "to_unit": types.Schema(type=types.Type.STRING)}, required=["value", "from_unit", "to_unit"])),
    ])],
)


def run(question: str) -> str:
    q = question.strip()
    if not q:
        return "Ask a math or unit-conversion question."
    contents = [types.Content(role="user", parts=[types.Part(text=q)])]
    response = None
    for _ in range(8):
        response = _client.models.generate_content(model=_MODEL, contents=contents, config=_CONFIG)
        if not response.function_calls:
            break
        contents.append(response.candidates[0].content)
        parts = [types.Part.from_function_response(name=fc.name, response=_IMPL[fc.name](**dict(fc.args)))
                 for fc in response.function_calls]
        contents.append(types.Content(role="user", parts=parts))
    return (response.text or "") if response else ""

Archivos del proyecto

  • .gitignore
  • README.es.md
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json