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

# Hooks

Los eventos te dejan mirar el bucle. Los hooks te dejan cambiarlo. Un hook es una función tuya que el bucle llama en un punto fijo, y lo que devuelva, el bucle lo obedece.

## El problema

Tu asistente de viajes funciona. Entonces la empresa agrega una regla: nada de pasajes en primera clase sin la aprobación de un gerente. Y el área legal agrega otra: el número de documento del pasajero nunca debe llegar al modelo.

Ninguna de las dos reglas es sobre el modelo. Al modelo se le puede decir, pero un prompt es un pedido, no un candado. Las dos reglas son sobre lo que hace el *bucle*: qué llamadas a herramientas ejecuta y qué pone en el historial. Si el bucle no te da forma de entrar, la única opción que queda es copiar su código y editarlo. Ese es Ironclad, el bucle sellado.

Los eventos tampoco ayudan. Un evento te avisa que `book_ticket` está por ejecutarse. Para cuando tu listener lo recibe, nada de lo que hagas ahí puede detenerlo.

## La solución

El bucle llama a tus funciones en puntos fijos de cada turno, y usa lo que devuelven. Cinco puntos cubren casi todo:

- `beforeTurn`
   **Al principio de cada turno.**
   Revisar un presupuesto, registrar el turno, detener una ejecución que ya duró demasiado.
- `beforeProviderCall`
   **Justo antes de que el pedido salga hacia el modelo.**
   Cambiar lo que se envía: recortar mensajes viejos, agregar la fecha de hoy, ocultar una herramienta en este turno.
- `beforeToolExecution`
   **Después de que el modelo pide una herramienta, antes de que se ejecute.**
   Dejarla pasar, rechazarla (el modelo recibe tu motivo en su lugar) o responder con un resultado predefinido.
- `afterToolExecution`
   **Después de que la herramienta se ejecuta, antes de que el resultado entre en el historial.**
   Reescribir lo que va a leer el modelo: ocultar datos personales, acortar una salida enorme.
- `afterTurn`
   **Cuando ya llegaron la respuesta del modelo y los resultados de herramientas.**
   Guardar el progreso, actualizar un panel, contar el costo.

La regla práctica: **los eventos miran, los hooks cambian.** Usa un evento cuando solo quieres saber qué pasó. Usa un hook cuando necesitas decidir qué pasa.

## El elenco

El mismo elenco de siempre, esta vez en una maqueta de tren.

- **El circuito** (el bucle): Un anillo cerrado de vías que solo va en un sentido. Cada vuelta es un turno: pasa por el Oráculo, pasa por las herramientas, y otra vez.
- **Astor** (quien recorre el bucle): Bombea el carrito de mano por el circuito, con el bandoneón de mensajes a la espalda.
- **Las cabinas** (hooks): Una por punto de hook. Una cabina vacía no hace nada. Una con personal detiene el carrito, revisa lo que lleva, y puede bajar su barrera o tapar la carga con un sello. Esta ejecución tiene dos con personal: `beforeToolExecution` y `afterToolExecution`.
- **La tribuna** (EventBus): Tres espectadores que anotan todo lo que pasa. Lo ven todo, y no pueden tocar nada.
- **Los andenes** (herramientas): `find_trains` y `book_ticket`, en la curva del fondo.

En el panel EventBus, las líneas `hook` y `code` marcan tus propias funciones en ejecución: tus hooks y tus herramientas. astorlm no emite eventos para ellas. Fíjate dónde caen: `tool_execution_end` llega después de `afterToolExecution`, así que ya trae el texto sellado.

## El código

**Con astorlm:** Pásale al agente un objeto `hooks`. `beforeToolExecution` devuelve `{ authorize: false }` para rechazar una llamada, y `afterToolExecution` devuelve el texto que va a leer el modelo.

**Desde cero:** El bucle del nivel 2, con una llamada a cada uno de los cinco hooks. Un hook que nadie definió simplemente se omite.

**Con astorlm**

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

const findTrains = tool({
  name: 'find_trains',
  description: 'List the trains to a destination on a date, with the fare for each class.',
  schema: z.object({ to: z.string(), date: z.string() }),
  execute: async ({ to, date }) => searchTimetable(to, date), // your code
})

const bookTicket = tool({
  name: 'book_ticket',
  description: 'Book one seat on a train for the employee who is asking.',
  schema: z.object({ train: z.number().int(), seat_class: z.enum(['first', 'tourist']) }),
  execute: async ({ train, seat_class }) => reserveSeat(train, seat_class), // 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: [findTrains, bookTicket],
  maxTurns: 10,
  hooks: {
    // The first booth: runs before every tool call, and decides whether it runs at all.
    beforeToolExecution: async ({ toolName, input }) => {
      const { seat_class } = input as { seat_class?: string }
      if (toolName === 'book_ticket' && seat_class === 'first') {
        // The tool never runs. The model reads this text as an error result instead.
        return { authorize: false, mockResult: 'Blocked by policy: first class needs a manager’s approval. Book tourist instead.' }
      }
      return { authorize: true }
    },
    // The second booth: runs after every tool call. What you return is what the model reads.
    afterToolExecution: async ({ output }) => output.replace(/DNI [\d.]+/g, 'DNI ***'),
  },
})

// Events only watch. By the time this fires, the hook has already stamped over the DNI.
agent.on('tool-end', ({ name, output, isError }) => console.log(name, isError ? 'refused:' : 'ok:', output))

const last = await agent.run('Book me the most comfortable seat to Mar del Plata on Friday.')
console.log(last.content)
```

**TypeScript**

```ts
// The agent loop with hooks, 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 Args = Record<string, string | number>
type ToolFn = (args: Args) => Promise<string>
const tools: Record<string, ToolFn> = { find_trains: findTrains, book_ticket: bookTicket }
const toolSchemas = [/* one JSON Schema per tool */]

// The five points where the loop lets your code in. Every one is optional.
type Hooks = {
  beforeTurn?: (turn: number, messages: Message[]) => Promise<void>
  // Return the messages to send: trim them, add context, or pass them through.
  beforeProviderCall?: (messages: Message[]) => Promise<Message[]>
  // Say no, and the tool never runs: `result` goes back to the model instead.
  beforeToolExecution?: (name: string, args: Args) => Promise<{ authorize: boolean; result?: string }>
  // Whatever you return is what the model reads.
  afterToolExecution?: (name: string, output: string) => Promise<string>
  afterTurn?: (turn: number, reply: Message) => Promise<void>
}

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

  for (let turn = 1; turn <= maxTurns; turn++) {
    await hooks.beforeTurn?.(turn, messages)
    const outgoing = (await hooks.beforeProviderCall?.(messages)) ?? messages

    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: outgoing, tools: toolSchemas }),
    })
    const [choice] = (await res.json()).choices
    const reply: Message = choice.message
    messages.push(reply)

    if (choice.finish_reason !== 'tool_calls') {
      await hooks.afterTurn?.(turn, reply)
      return reply.content ?? ''
    }

    for (const call of reply.tool_calls ?? []) {
      const name = call.function.name
      const run = tools[name]
      let output = `Unknown tool: ${name}`
      try {
        const args: Args = JSON.parse(call.function.arguments)
        // Booth 1: before the tool runs.
        const gate = (await hooks.beforeToolExecution?.(name, args)) ?? { authorize: true }
        if (!gate.authorize) output = gate.result ?? 'Rejected by policy.'
        else if (run) output = await run(args)
      } catch (err) {
        output = `Error: ${err instanceof Error ? err.message : err}`
      }
      // Booth 2: before the result joins the history.
      output = (await hooks.afterToolExecution?.(name, output)) ?? output
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
    await hooks.afterTurn?.(turn, reply)
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

// The two booths from the animation.
const answer = await runAgent('Book me the most comfortable seat to Mar del Plata on Friday.', {
  beforeToolExecution: async (name, args) =>
    name === 'book_ticket' && args.seat_class === 'first'
      ? { authorize: false, result: 'Blocked by policy: first class needs a manager’s approval. Book tourist instead.' }
      : { authorize: true },
  afterToolExecution: async (_name, output) => output.replace(/DNI [\d.]+/g, 'DNI ***'),
})
```

**Python**

```python
# The agent loop with hooks, from scratch. Standard library only, no SDK.
import json
import re
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 = {"find_trains": find_trains, "book_ticket": book_ticket}
TOOL_SCHEMAS = [...]  # one JSON Schema per tool

# The five points where the loop lets your code in. Every one is optional:
#   before_turn(turn, messages)
#   before_provider_call(messages) -> the messages to send
#   before_tool_execution(name, args) -> {"authorize": bool, "result": str}
#   after_tool_execution(name, output) -> the text the model will read
#   after_turn(turn, reply)

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, hooks=None, max_turns=10):
    hooks = hooks or {}

    def call_hook(point, *args):
        return hooks[point](*args) if point in hooks else None

    messages = [{"role": "user", "content": prompt}]

    for turn in range(1, max_turns + 1):
        call_hook("before_turn", turn, messages)
        outgoing = call_hook("before_provider_call", messages) or messages

        choice = chat(outgoing)
        reply = choice["message"]
        messages.append(reply)

        if choice["finish_reason"] != "tool_calls":
            call_hook("after_turn", turn, reply)
            return reply.get("content") or ""

        for call in reply.get("tool_calls", []):
            name = call["function"]["name"]
            run = TOOLS.get(name)
            try:
                args = json.loads(call["function"]["arguments"])
                # Booth 1: before the tool runs. Say no, and it never does.
                gate = call_hook("before_tool_execution", name, args) or {"authorize": True}
                if not gate["authorize"]:
                    output = gate.get("result", "Rejected by policy.")
                else:
                    output = run(**args) if run else f"Unknown tool: {name}"
            except Exception as err:
                output = f"Error: {err}"
            # Booth 2: before the result joins the history. What it returns is what the model reads.
            output = call_hook("after_tool_execution", name, output) or output
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

        call_hook("after_turn", turn, reply)

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

# The two booths from the animation.
def check_policy(name, args):
    if name == "book_ticket" and args.get("seat_class") == "first":
        return {"authorize": False, "result": "Blocked by policy: first class needs a manager's approval. Book tourist instead."}
    return {"authorize": True}

def hide_ids(name, output):
    return re.sub(r"DNI [\d.]+", "DNI ***", output)

answer = run_agent(
    "Book me the most comfortable seat to Mar del Plata on Friday.",
    hooks={"before_tool_execution": check_policy, "after_tool_execution": hide_ids},
)
```

## Qué vigilar

- **Di por qué cuando rechazas.** El rechazo vuelve al modelo como un resultado con error. "Bloqueado por política: reserva turista en su lugar" te consigue un pasaje en turista. Un "denegado" a secas te devuelve la misma llamada otra vez.
- **Los hooks corren en cada llamada, así que mantenlos rápidos.** Un hook que consulta una base de datos suma esa demora a cada herramienta y a cada turno.
- **Un hook que lanza una excepción tira abajo la ejecución.** El bucle atrapa los errores de tus herramientas, no los de tus hooks. Envuelve todo lo que pueda fallar.
- **No uses un hook para mirar.** Si solo registras, escucha eventos. Guarda los hooks para cuando necesitas cambiar algo.

## Patrones relacionados

- [2 · El bucle del agente](https://harnesspatterns.dev/es/patterns/agent-loop.md)
- [5 · Errores en el bucle](https://harnesspatterns.dev/es/patterns/errors-in-the-loop.md)
- [7 · La mochila se llena](https://harnesspatterns.dev/es/patterns/compaction.md)
- [13 · Humano en el bucle](https://harnesspatterns.dev/es/patterns/human-in-the-loop.md)
- [14 · Seguridad y sandboxing](https://harnesspatterns.dev/es/patterns/security.md)
