> Nivel 2 de Agent Harness Patterns, un recorrido de patrones sobre cómo funcionan los agentes de IA. Versión web: https://harnesspatterns.dev/es/patterns/agent-loop · Todos los patrones (en inglés): https://harnesspatterns.dev/llms.txt

# El bucle del agente

Por sí solo, un modelo no puede hacer nada: solo genera texto. El bucle del agente es lo que lo convierte en un agente. Le pasa el historial al modelo, ejecuta las herramientas que el modelo pide, le devuelve los resultados, y repite hasta que el modelo dice "listo".

## El problema

Una mamá pájaro le pregunta a un modelo "¿puedes despejar el fuerte de los cerdos?". El modelo no puede lanzar nada. Lo mejor que puede hacer es responder con un *pedido* de herramienta: `{ name: "launch_red", input: { angle: 40 } }`.

Si tu código hace una sola llamada al modelo, la conversación termina ahí. Te quedas con un pedido que nadie ejecutó y sin respuesta. Si ejecutas la herramienta a mano, chocas con el mismo problema en la siguiente ronda, porque el modelo puede necesitar otra herramienta, y después otra más.

## La solución

Un bucle con una única regla de salida:

1. Envía al modelo **todo el historial** más la lista de herramientas disponibles.
2. Si la respuesta termina con `stopReason: "end_turn"`, devuélvela. Esa es la única salida normal.
3. Si termina con `"tool_use"`, ejecuta cada herramienta pedida, agrega los resultados al historial como bloques `tool_result` y vuelve al paso 1.

El modelo decide *qué* hacer; el bucle es el que lo *hace*. Esa división es la base de todos los demás patrones: todo lo demás (steering, compactación de contexto, subagentes) se engancha en algún punto de este bucle.

## El elenco

El bucle contado como una pequeña aventura. Una vez que conoces al elenco, ya no queda nada por descifrar.

- **Astor** (el bucle): Un pequeño tanguero, y el único que se mueve. Le lleva la pregunta al Oráculo, corre a la resortera en cada tiro y le trae la respuesta a la mamá pájaro.
- **El Oráculo** (Provider): El modelo. Nunca toca la resortera: solo escucha el bandoneón y devuelve una nota. Naranja si necesita una herramienta, dorada si terminó.
- **El bandoneón** (messages[]): El historial, un pliegue de color por mensaje. El fuelle crece en cada vuelta, y el Oráculo escucha cada pliegue cada vez. Eso son las notas que suben flotando hacia el Oráculo.
- **El banco** (ToolRegistry): Un pájaro por herramienta: `launch_red`, `launch_bomb` y un tercero que hoy nadie necesita. Astor lanza el que nombra la nota y muestra el resultado: verde si funcionó.
- **La mamá pájaro** (agent.run()): Tu código. Hace la pregunta y espera.
- **Estelas, puntaje y pájaros** (historial, tokens, maxTurns): Cada tiro deja su estela en el cielo, igual que el historial guarda cada resultado. El puntaje son los tokens, y salta más en cada vuelta porque se vuelve a enviar todo el historial. Cada vuelta cuesta un pájaro de la fila de la barra superior, el presupuesto de turnos. Los números son ilustrativos.

El panel EventBus muestra los eventos que emite el bucle real en cada paso de la animación.

## El código

**Con astorlm:** El mismo bucle vive en `src/agent/loop.ts`, con streaming, reintentos, hooks, ejecución de herramientas en paralelo y cancelación. Desde afuera se ve así.

**Desde cero:** Unas 40 líneas contra cualquier endpoint compatible con OpenAI, sin SDK: `fetch` pelado en TypeScript, la biblioteca estándar en Python. Los tres pasos de arriba están marcados en los comentarios. Completa el bloque `LLM` del principio con tu propio endpoint, modelo y clave.

**Con astorlm**

```ts
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

// Your game's functions, wrapped as tools: one per bird.
const angle = z.number().min(10).max(80).describe('Launch angle in degrees')

const launchRed = tool({
  name: 'launch_red',
  description: 'Fling the red bird. Good against wood. Returns what fell and how many pigs are left.',
  schema: z.object({ angle }),
  execute: async ({ angle }) => level.fling('red', angle), // your code
})

const launchBomb = tool({
  name: 'launch_bomb',
  description: 'Fling the bomb bird. It explodes on impact: the one to use against stone.',
  schema: z.object({ angle }),
  execute: async ({ angle }) => level.fling('bomb', angle), // your code
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  tools: [launchRed, launchBomb],
  maxTurns: 10, // the birds in line: a cap on the laps
})

agent.on('tool-start', (tool) => console.log('→', tool.name, tool.input))

const answer = await agent.run('The pigs took our eggs! Can you clear their fort?')
```

**TypeScript**

```ts
// Agent loop from scratch. Plain fetch, no SDK.

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
  baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
  model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
  apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}

type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
  | { role: 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

type ToolFn = (args: Record<string, string>) => Promise<string>
const tools: Record<string, ToolFn> = { launch_red: launchRed, launch_bomb: launchBomb }
const toolSchemas = [/* one JSON Schema per tool */]

export async function runAgent(prompt: string, maxTurns = 10): Promise<string> {
  const messages: Message[] = [{ role: 'user', content: prompt }]

  for (let turn = 1; turn <= maxTurns; turn++) {
    // 1. Send the whole history plus the tool list.
    const res = await fetch(`${LLM.baseURL}/chat/completions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
      body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
    })
    const [choice] = (await res.json()).choices
    const reply: Message = choice.message
    messages.push(reply)

    // 2. No tool calls: the model is done. The only normal exit.
    if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''

    // 3. Run each requested tool and feed the result back as a message.
    for (const call of reply.tool_calls ?? []) {
      const run = tools[call.function.name]
      let output = `Unknown tool: ${call.function.name}`
      if (run) {
        try {
          output = await run(JSON.parse(call.function.arguments))
        } catch (err) {
          output = `Error: ${err instanceof Error ? err.message : err}`
        }
      }
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}
```

**Python**

```python
# Agent loop from scratch. Standard library only, no SDK.
import json
import urllib.request

# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
    "base_url": "http://localhost:11434/v1",  # e.g. Ollama's default address
    "model": "your-model",  # e.g. "llama3.1", "gpt-4o-mini"
    "api_key": "YOUR_API_KEY",  # local servers usually ignore it
}

TOOLS = {"launch_red": launch_red, "launch_bomb": launch_bomb}
TOOL_SCHEMAS = [...]  # one JSON Schema per tool

def chat(messages):
    request = urllib.request.Request(
        f"{LLM['base_url']}/chat/completions",
        data=json.dumps({"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS}).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)["choices"][0]

def run_agent(prompt, max_turns=10):
    messages = [{"role": "user", "content": prompt}]

    for _ in range(max_turns):
        # 1. Send the whole history plus the tool list.
        choice = chat(messages)
        reply = choice["message"]
        messages.append(reply)

        # 2. No tool calls: the model is done. The only normal exit.
        if choice["finish_reason"] != "tool_calls":
            return reply.get("content") or ""

        # 3. Run each requested tool and feed the result back as a message.
        for call in reply.get("tool_calls", []):
            name = call["function"]["name"]
            run = TOOLS.get(name)
            try:
                output = run(**json.loads(call["function"]["arguments"])) if run else f"Unknown tool: {name}"
            except Exception as err:
                output = f"Error: {err}"
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

    raise RuntimeError(f"No answer after {max_turns} turns")
```

Fíjate que un error de herramienta no detiene el bucle: vuelve al modelo como texto, para que pueda corregirse en la siguiente ronda.

## Cuándo usarlo y qué vigilar

**Siempre que el modelo necesite actuar** (leer, buscar, ejecutar algo) antes de poder responder. Si solo necesitas transformar texto, alcanza con una sola llamada, y es más barato.

- **El costo crece en cada vuelta.** Mira el bandoneón y el contador de tokens: el Oráculo escucha cada pliegue en cada turno, así que cada vuelta reenvía todo lo anterior. Con suficientes turnos, el contexto se llena.
- **Define siempre `maxTurns`.** Un modelo confundido puede seguir pidiendo herramientas para siempre. Sin un límite, el bucle también corre para siempre.

> Falla real
>
> Durante una ejecución de varios turnos, el proxy hizo failover a un modelo gratuito más débil. El modelo cayó en un bucle de razonamiento sin sentido y nunca devolvió `end_turn`. Lo cortó un timeout de 60 segundos, no el bucle. `maxTurns` te protege de demasiados turnos, pero no de un turno que nunca termina: para eso necesitas un timeout o un watchdog (algo que corte la ejecución cuando no hay progreso).

## Patrones relacionados

- [4 · Cuándo parar](https://harnesspatterns.dev/es/patterns/when-to-stop.md)
- [5 · Errores en el bucle](https://harnesspatterns.dev/es/patterns/errors-in-the-loop.md)
- [6 · Hooks](https://harnesspatterns.dev/es/patterns/hooks.md)
- [10 · Vueltas limpias](https://harnesspatterns.dev/es/patterns/fresh-laps.md)
