Haz que el agente se calle: pre_llm_call ya puede vetar un turno antes de cualquier llamada al modelo


Dos de la madrugada y alguien pega en una sesión del bot la captura de un ticket de soporte: el teléfono del cliente, los últimos cuatro dígitos de un documento, de todo. Tu gateway está vigilando una docena de canales. Un segundo después, ese mensaje va al modelo, entra en el transcript y de ahí pasa a los resúmenes de compactación, al sistema de memoria y a la pasada de revisión en segundo plano. Cuando piensas “esto no debería haber salido nunca de aquí”, ya ha salido.

Ese es el fallo de toda barrera a posteriori: solo puedes borrar datos cuando el modelo ya ha respondido y los logs ya están escritos. En la v0.22.6, Hermes le dio al hook pre_llm_call un nuevo valor de retorno que mueve el punto de interceptación a antes de que se haga la petición de inferencia: el turno termina ahí, con cero llamadas al modelo.

Qué se detiene exactamente

pre_llm_call se dispara una vez por turno, después de la compresión de contexto y antes de que empiece el bucle de herramientas. Su trabajo original era meter cosas dentro de un turno: memorias recuperadas, el git status actual, qué día es. Devuelves un string o un objeto con una clave context, y el texto se añade al mensaje del usuario (nunca al system prompt, para que la caché del prompt sobreviva).

Ahora también puede rechazar el turno:

{"action": "block", "message": "This message contains customer identity data and was blocked by policy. Please handle it in a 1:1 channel."}

Según la documentación oficial, esto es lo que pasa después:

  • No se hace ninguna petición de inferencia. El turno cuesta cero llamadas al modelo.
  • message se muestra al usuario como respuesta del asistente y se guarda como respuesta del asistente, así que el transcript mantiene un par normal usuario-asistente y --resume sigue funcionando.
  • El resultado lleva turn_exit_reason: "blocked_by_plugin_pre_llm_call", de forma que puedes distinguir “bloqueado” de “el modelo respondió” en logs y resultados.
  • Gana el primer bloqueo; las callbacks posteriores no pueden cambiar el veredicto. Un bloqueo sin mensaje se ignora.

Qué pasa con el texto que escribiste

Esta es la parte que decide si la función sirve de verdad como barrera de privacidad.

El prompt bloqueado se guarda en el transcript como marcador:

[Message withheld: a pre_llm_call policy hook blocked it.]

Todo lo que lee el historial después —turnos siguientes, resúmenes de compactación, session_search, proveedores de memoria externos, la revisión de memoria en segundo plano— lee ese marcador en lugar del original. Dicho de otro modo: el contenido no llega a ningún modelo en este turno ni en ninguno posterior.

Lo que escribiste de verdad sobrevive solo como metadata de visualización: el resumen de --resume en la CLI, la TUI, la app de escritorio y el dashboard lo siguen mostrando, así que puedes comprobar después qué se preguntó.

Dos efectos secundarios que la documentación también detalla: el hook se ejecuta antes de la compactación de inicio de turno, así que un turno bloqueado se salta la compactación, el titulado de la sesión y el prefetch/sync de memoria externa. Para un turno que no ha ocurrido, ese trabajo no merece la pena de todos modos.

Cómo escribir uno: plugin de Python o hook de shell

Plugin de Python (lo registras y queda activo):

import re

CUSTOMER_ID = re.compile(r"\b(?:cust|acct)-[0-9]{6,}\b", re.I)

def policy(session_id, user_message, platform, **kwargs):
    text = user_message if isinstance(user_message, str) else ""
    if CUSTOMER_ID.search(text):
        return {
            "action": "block",
            "message": "This message contains a customer identifier and was blocked by policy. Handle it in the ticket system, or resend a redacted description.",
        }
    return None

def register(ctx):
    ctx.register_hook("pre_llm_call", policy)

Hook de shell (en el config.yaml del profile, sin Python):

hooks:
  pre_llm_call:
    - command: "~/.hermes/agent-hooks/block-customer-ids.sh"
      timeout: 5

El script recibe el payload del evento por stdin e imprime una línea de JSON en stdout. Se aceptan tanto {"action": "block", "message": "..."} como el estilo de Claude Code {"decision": "block", "reason": "..."}; imprime {} cuando no haya que bloquear nada.

Tres límites que tienes que conocer

Uno: falla en abierto. Si una callback lanza una excepción o se pasa de plugins.hook_callback_timeout (30 segundos por defecto, configurable entre 0 y 600), Hermes lo registra y la omite: el turno sigue adelante. Compáralo con pre_tool_call, donde un timeout falla en cerrado y bloquea la herramienta. La documentación es explícita sobre la consecuencia: un hook de política debería capturar sus propios errores y devolver un bloqueo cuando no puede decidir; si no, un script roto parece una barrera que en realidad no bloquea nada.

Relacionado: fail_closed: true hoy solo se aplica a pre_tool_call. Ponerlo en otro evento genera un aviso al parsear la configuración y se ignora.

Dos: aquí el código de salida 2 no bloquea. Esa convención es solo de pre_tool_call. Un hook de shell en pre_llm_call que sale con 2 se trata como cualquier otra salida distinta de cero: avisa y aun así parsea stdout.

Tres: esto es un filtro de entrada, no un filtro de contenido. Ve el user_message de este turno y el historial de la conversación, no qué herramientas está a punto de llamar el modelo. Bloquear herramientas es trabajo de pre_tool_call; juzgar lo que dijo el modelo es trabajo de post_llm_call. Quieres las dos capas: la entrada frena lo que no debería llegar nunca, la capa de herramientas frena las acciones peligrosas.

Cuándo merece la pena activarlo

Hay tres casos que rinden de inmediato:

  • Privacidad y cumplimiento por delante. IDs de tickets, números de documento, hostnames internos, emails de clientes: coincide y bloquea, con un mensaje que le dice a la persona qué canal usar en su lugar.
  • Una puerta de coste. Cuando la cuota está casi agotada, o son las tantas de la madrugada, rechaza las peticiones que arrastrarían una recuperación enorme en lugar de dejar que el modelo responda la mitad y abortar.
  • Defensa contra prompt injection. Páginas web o emails pegados que llevan patrones de inyección evidentes (“ignore all previous instructions”) se devuelven antes de que el modelo llegue a verlos.

La capacidad está en la etiqueta v0.22.6, así que basta con actualizar a esa versión. La lista completa de eventos, los contratos de retorno y las reglas de timeout viven en la documentación oficial de hooks. Si primero quieres la distinción entre hooks y middleware, nuestra guía de hooks y ciclo de vida de plugins es un buen punto de partida, y para los puntos de pausa que necesitan a una persona, mira el post sobre hooks de entrada humana.