Un agente con herramientas, sin framework
Un agente es un bucle de veinte líneas: el modelo pide una herramienta, tú la ejecutas, le devuelves el resultado y repites hasta que responde. Con tu Ollama.
TOOLS son cuatro herramientas de un taller (stock, precio, calcular, hoy), cada una recibe un objeto y devuelve texto. SCRIPT es lo que un modelo local contestó, paso a paso, a la pregunta TASK; scriptedModel(SCRIPT) te lo devuelve como si fuera el modelo. El protocolo: el modelo responde con un JSON {"tool": nombre, "args": {...}} o, cuando sabe la respuesta, {"final": "texto"}. Escribe tres funciones:
parseToolCall(text): saca el primer objeto JSON del texto, aunque venga rodeado de prosa o dentro de un bloque `json . Devuelve { tool, args } si tool es texto (con args {} si falta), { final } si final es texto, y null en cualquier otro caso.
runTool(call): ejecuta TOOLS[call.tool](call.args) y devuelve su texto. Si la herramienta no existe, devuelve un texto que empiece por error: (el modelo debe enterarse, no tu programa romperse).
agentLoop(model, task, maxSteps): empieza con [{ role: "user", content: task }] y, como mucho maxSteps veces: pide una respuesta al modelo, la analiza y, si es final, devuelve { answer, steps, stopped: "final" }; si es una herramienta, la ejecuta, guarda { tool, args, result } en steps y añade a los mensajes la respuesta del modelo (assistant) y el resultado (tool). Si no es JSON, guarda { error: "no-json" } y pide al modelo que responda solo con JSON. Al agotar los pasos, { answer: null, steps, stopped: "max" }.
El último reto usa LIVE: la primera respuesta real de tu propio modelo a TASK si activas «Mi Ollama», o una muestra si no. La vista previa enseña la transcripción del bucle.
Retos 0/6
- Analiza JSON limpio, JSON con prosa y bloque de código, y rechaza lo demás
- Ejecuta una herramienta y convierte una desconocida en un error de texto
- El bucle completo sobre el guion: tres herramientas y la respuesta final
- El modelo recibe el resultado de cada herramienta en la conversación
- Se detiene en el tope de pasos y sobrevive a una respuesta sin JSON
- Entiende la primera respuesta real de tu modelo (Mi Ollama) o la muestra
function parseToolCall(text) {
// 1. JSON.parse del texto sin espacios; si falla, el trozo desde la primera { hasta la última }
// 2. { tool, args } si tool es texto (args {} si falta); { final } si final es texto; si no, null
return null;
}
function runTool(call) {
// TOOLS[call.tool](call.args) como texto; una herramienta desconocida devuelve 'error: …' en vez de lanzar
return '';
}
function agentLoop(model, task, maxSteps) {
// empieza con messages = [{ role: "user", content: task }] y steps = []
// como mucho maxSteps veces: text = model(messages); call = parseToolCall(text)
// sin JSON: steps recibe { error: "no-json" } y messages la respuesta (assistant) y "error: responde solo con JSON" (user)
// final: devuelve { answer: call.final, steps, stopped: "final" }
// herramienta: result = runTool(call); steps recibe { tool, args, result }; messages la respuesta (assistant) y el resultado (tool)
// sin pasos: { answer: null, steps, stopped: "max" }
return { answer: null, steps: [], stopped: 'max' };
}
console.log(agentLoop(scriptedModel(SCRIPT), TASK, 8));La idea
Quita el framework y un agente es esto: un bucle que manda la conversación al modelo, lee si pide una herramienta, la ejecuta, añade el resultado a la conversación y vuelve a empezar. Termina cuando el modelo responde con final o cuando se acaba el presupuesto de pasos. Las tres decisiones que importan son tuyas: qué herramientas existen, cuántos pasos permites y qué pasa cuando el modelo no obedece el formato.
La robustez está en parseToolCall. Un modelo pequeño a veces envuelve el JSON en prosa, lo mete en un bloque de código o inventa una herramienta. Nada de eso debe tirar tu programa: se analiza lo que se pueda, lo que no, se devuelve al modelo como error, y el bucle sigue. El límite de pasos es lo que evita que un agente confundido gaste una hora de GPU.
Lo que medimos: con esta misma pregunta y este mismo protocolo, llama3.1:8b y qwen3.6:35b (Ollama, salida en modo JSON) contestaron con una llamada válida a stock en 10 de 10 intentos cada uno, en 0,5–0,9 s por respuesta, en el DGX Spark el 18-10-2026. En modo JSON no hubo prosa alrededor; sin ese modo la hay, y por eso el parser la tolera.
Cuando te lo vendan como «agente autónomo», pregunta por el bucle: qué herramientas, qué tope de pasos, qué hace con un error. Si nadie sabe responderlo, no hay agente, hay una demo.
Hazlo en tu máquina
El mismo bucle en Python contra tu Ollama (ollama pull llama3.1:8b si no lo tienes), con el mismo mensaje de herramientas que usa «Mi Ollama». Las herramientas son las del ejercicio; con llama3.1:8b llega a la respuesta en tres llamadas:
python3 -c "
import json, re, urllib.request
def calcular(a): # models name the argument as they please: take the first value, allow arithmetic only
e = str(list(a.values())[0] if a else '').replace(',', '.')
return str(round(eval(e, {}), 2)) if re.fullmatch(r'[\d\s+*/().-]+', e) else 'error: solo aritmética con números, sin llamar a otras herramientas'
TOOLS = {'stock': lambda a: '34', 'precio': lambda a: '9.75', 'calcular': calcular, 'hoy': lambda a: '2026-10-18'}
SYSTEM = '''Eres el asistente de un taller. Herramientas, una por turno:
- stock(producto): unidades en almacén, p. ej. {\"tool\":\"stock\",\"args\":{\"producto\":\"filtro de aceite\"}}
- precio(producto): precio unitario en EUR sin IVA, p. ej. {\"tool\":\"precio\",\"args\":{\"producto\":\"filtro de aceite\"}}
- calcular(expresion): solo aritmética con números, p. ej. {\"tool\":\"calcular\",\"args\":{\"expresion\":\"12 * 9.75 * 1.21\"}}
- hoy(): la fecha, p. ej. {\"tool\":\"hoy\",\"args\":{}}
Responde SOLO con un objeto JSON: una llamada {\"tool\": nombre, \"args\": objeto} o, cuando sepas la respuesta, {\"final\": \"texto para el cliente\"}.'''
msgs = [{'role': 'system', 'content': SYSTEM}, {'role': 'user', 'content': '¿Tenéis 12 filtros de aceite y cuánto costarían con IVA?'}]
for step in range(8):
req = urllib.request.Request('http://localhost:11434/api/chat', data=json.dumps({'model': 'llama3.1:8b', 'messages': msgs, 'stream': False, 'format': 'json', 'options': {'temperature': 0.2}}).encode(), headers={'content-type': 'application/json'})
text = json.load(urllib.request.urlopen(req))['message']['content']
call = json.loads(text)
if 'final' in call: print('final:', call['final']); break
result = TOOLS.get(call.get('tool'), lambda a: 'error: herramienta desconocida')(call.get('args') or {})
print(step, call, '->', result)
msgs += [{'role': 'assistant', 'content': text}, {'role': 'tool', 'content': result}]
else: print('sin respuesta final en 8 pasos: el tope hizo su trabajo')
" Para profundizar: la referencia de Ollama (en inglés) →
¿Esto en producción, con tus datos? Hablemos 15 minutos →