tva
← Insights

Ajustando un Agente al Estilo Hermes que Crece con tu Proyecto

Un asistente de IA específico para cada proyecto construido sobre un canal de mensajería —Telegram, Discord, Slack o email a través de una gateway ligera— se comporta de manera diferente en un grupo con múltiples usuarios que en un DM individual. Incorporar un equipo al bot expone una clase de bugs que el camino de prueba por DM nunca alcanza: crashes por respuestas vacías en triggers de palabras clave, notificaciones de progreso mal calibradas, race conditions entre triggers paralelos y deriva de memoria en el log de conversación del propio agente.

Esta guía documenta ocho patrones de ajuste que aplicamos a un asistente mínimo al estilo hermes —construido sobre la CLI de Claude Code como subprocess en lugar del framework completo de NousResearch. Cada patrón es un problema concreto con el código para solucionarlo. Los patrones aplican a cualquiera de los dos enfoques.

Qué soluciona esto

  • Respuestas genéricas de Unexpected error cuando el LLM devuelve stdout vacío ante un trigger de palabra clave
  • Texto de confirmación que se dispara en cada respuesta corta porque el umbral está por debajo de la latencia típica de respuesta
  • Subprocesses que se detienen a mitad del stream y nunca retornan, sin timeout para limpiarlos
  • Race conditions cuando dos miembros del grupo activan el agente simultáneamente contra una sesión compartida
  • Deadlocks silenciosos en la pipe de stderr una vez que el agente lleva suficiente tiempo en ejecución para llenar el buffer de 64 KB
  • Mensajes de fallback por bug en el log de conversación que el agente lee después como comportamiento legítimo

Prerrequisitos

Esta guía asume la arquitectura de Construyendo un Asistente de IA Específico para Proyectos vía Telegram: un container Docker por proyecto, usuario sin privilegios de root, CLI de Claude Code con autenticación OAuth persistente, wrapper de bot con aiogram, y volúmenes de workspace y estado de sesión montados con bind. Varios patrones dependen de versiones específicas:

  • CLI de Claude Code 2.1.139 o posterior (para --output-format stream-json --verbose --include-partial-messages)
  • aiogram 3.28.2 o posterior (para ChatActionSender, message.react(), ReactionTypeEmoji)
  • Imagen base de Python 3.13
  • Un grupo de Telegram en el que el bot sea miembro con can_react_to_messages: true

Para un canal de email en el mismo proyecto, consulta cómo configurar un buzón de proyecto con DKIM, SPF y DMARC.

Qué es un agente al estilo Hermes

El patrón hermes-style toma su nombre del framework abierto de NousResearch. Tres propiedades lo distinguen de un chatbot sin estado:

  • Memoria persistente. Un workspace en disco que el agente lee y escribe entre turnos, para que el contexto sobreviva a reinicios del container.
  • Presencia multicanal. La misma instancia del agente conversa en Telegram, Discord, Slack o email a través de una gateway ligera.
  • Un ciclo de aprendizaje cerrado. Las correcciones del operador se convierten en ediciones del workspace que el agente lee en el siguiente turno.

NousResearch incluye una implementación de referencia completa con TUI, gateway multicanal, sistema de habilidades y hooks de entrenamiento RL. Una variante mínima sobre el subprocess de la CLI de Claude Code mantiene las piezas móviles lo suficientemente pequeñas como para plantillarlas por mandato de consultoría. Los patrones a continuación aplican igualmente a cualquiera de los dos enfoques.

Patrón 1: Manejo tipado de respuestas vacías

Un trigger basado en palabras clave (que hace match con \bhermes\b en mensajes de grupo) puede activarse en una frase que contiene el nombre del bot pero no va dirigida a él. El LLM devuelve correctamente una salida vacía. Tres capas más abajo, ninguna maneja el caso vacío:

  • El motor devuelve "" con returncode 0.
  • La función de split devuelve [""] porque len("") <= max_chars coincide.
  • El loop de envío llama a bot.send_message(chat_id, ""); Telegram devuelve Bad Request: message text is empty; un except Exception genérico en lo alto del handler traga el traceback y envía al usuario el mensaje de error.

Filtrar strings vacíos en una sola capa evita el crash, pero produce un salto silencioso: el trigger se disparó, el bot consumió cómputo, y el usuario no ve nada. La solución en dos pasos usa una excepción tipada para la salida vacía y una reacción de Telegram (👀) sobre el mensaje que activó el trigger como acuse de recibo:

class HermesEmptyResponse(HermesError):
    """Subprocess returned successfully but with empty result."""

class HermesHangError(HermesError):
    """Watchdog killed subprocess after no stream-event for N seconds."""

El motor lanza HermesEmptyResponse cuando result.strip() == "". El handler lo captura y llama a message.react([ReactionTypeEmoji(emoji="👀")]). El log de conversación recibe un bloque marcador —una entrada separada que registra el acuse de recibo silencioso sin contaminar el chat con texto— para que las lecturas futuras de memoria del propio agente vean que un trigger se disparó y fue ignorado intencionalmente.

Patrón 2: Verificación previa de permisos de reacción con caché diferida

El setMessageReaction de Telegram no está disponible de manera universal. Algunos grupos restringen el conjunto de reacciones permitidas; algunos emojis personalizados requieren autorización de un administrador. El tipo ChatFullInfo documenta la regla: si available_reactions se omite, se permiten todos los emoji estándar; si es un array, solo funcionan esos emoji. El bot debe ser miembro del grupo —no se requiere ser administrador para reaccionar en grupos.

Verificar en cada trigger desperdicia llamadas a la API. Basta con un getChat por chat con una caché de una hora:

_reaction_cache: dict[int, tuple[bool, float]] = {}
_REACTION_CACHE_TTL_SEC = 3600
MINI_ACK_EMOJI = "👀"

async def _reactions_allowed(bot: Bot, chat_id: int) -> bool:
    now = time.monotonic()
    cached = _reaction_cache.get(chat_id)
    if cached and cached[1] > now:
        return cached[0]
    try:
        chat = await bot.get_chat(chat_id)
        allowed = (
            chat.available_reactions is None
            or any(
                isinstance(r, ReactionTypeEmoji) and r.emoji == MINI_ACK_EMOJI
                for r in (chat.available_reactions or [])
            )
        )
    except Exception:
        allowed = False
    _reaction_cache[chat_id] = (allowed, now + _REACTION_CACHE_TTL_SEC)
    return allowed

Envuelve la llamada real de reacción en try/except (TelegramBadRequest, TelegramForbiddenError) de todas formas —la caché se retrasa respecto a los cambios de permisos.

Patrón 3: Modo stream y el watchdog de tiempo de inactividad

Un timeout duro sobre todo el subprocess (asyncio.wait_for(proc.communicate(), timeout=300)) limita la duración total independientemente del progreso. Eliminarlo sin reemplazo está documentado como inseguro: el issue de cuelgues en stream inactivo de Claude Code describe llamadas a la API que se detienen a mitad del stream y nunca retornan, dejando un subprocess fugado.

Cambiar a --output-format stream-json --verbose --include-partial-messages emite eventos en cada hito —por token text_delta, inicio y fin de uso de herramientas, reintentos de API, avisos de rate limit, el evento final result. Un cuelgue real produce silencio en el stream; una tarea larga produce una secuencia de eventos pequeños. El watchdog mata por tiempo de inactividad, no por duración total:

WATCHDOG_NO_EVENT_SEC = 60

async def watchdog() -> None:
    while True:
        await asyncio.sleep(5)
        if proc.returncode is not None:
            return
        idle_sec = time.monotonic() - state["last_event_ts"]
        if idle_sec > WATCHDOG_NO_EVENT_SEC:
            state["killed_by_watchdog"] = True
            try:
                proc.kill()
            except ProcessLookupError:
                pass
            return

El texto de respuesta final proviene del campo result del evento result —determinista, de una única fuente y no afectado por el parseo parcial del stream. El mismo evento lleva is_error, api_error_status, duration_ms y total_cost_usd, todos los cuales van a la línea del log estructurado.

Patrón 4: Calibrando el calendario de mensajes de espera

La pregunta sobre el umbral —cuándo envía el bot una actualización de texto durante una llamada de larga duración— es empírica. La respuesta correcta depende de la distribución de latencia de los triggers reales. Tres umbrales, con texto alineado a lo que el usuario realmente necesita saber:

_REASSURE_SCHEDULE = (
    (15, "On it."),
    (90, "Taking longer than usual, still on it."),
    (300, "Genuinely large task — almost there."),
)

Los umbrales derivan de dos restricciones. El límite inferior lo establece la latencia típica de respuesta corta: si la mayoría de las respuestas llegan dentro de X segundos, el primer mensaje de espera debe dispararse después de X, o llegará prácticamente al mismo tiempo que la respuesta. La investigación sobre tiempos de respuesta de Nielsen identifica 10 segundos como el límite canónico para mantener la atención del usuario sin un indicador de progreso; el indicador de escritura que renderiza el ChatActionSender de aiogram por debajo de ese umbral ya satisface el requisito hasta aproximadamente 15 segundos.

El umbral superior (90 segundos) es la brecha en la que el encuadre cambia de trabajando a trabajando pero más de lo habitual —una señal separada de que la llamada está en la cola larga de la distribución. La redacción evita insinuar que el usuario pidió algo pesado. El bot es quien hace el trabajo; el mensaje reconoce ese trabajo, no la solicitud.

Patrón 5: Lock de concurrencia por chat

Dos miembros del grupo pueden activar el agente en el mismo segundo —uno con una mención @, otro con la palabra clave. Ambas invocaciones del handler inician subprocesses de claude --continue contra el mismo archivo de sesión persistente. El lock-file de sesión no es estricto; las escrituras concurrentes producen archivos session-jsonl truncados y turnos perdidos.

Serializa por chat a nivel del handler con un lock creado de forma diferida:

_chat_locks: dict[int, asyncio.Lock] = {}

def _get_chat_lock(chat_id: int) -> asyncio.Lock:
    lock = _chat_locks.get(chat_id)
    if lock is None:
        lock = asyncio.Lock()
        _chat_locks[chat_id] = lock
    return lock

async with _get_chat_lock(message.chat.id):
    response = await _run_hermes_with_ux(bot, message, prompt, ctx)
    ...

La creación diferida es importante: un asyncio.Lock instanciado al importar el módulo se enlaza al event loop que esté activo en ese momento, que puede no ser el loop en el que corre el handler tras un reinicio. Diferir la instanciación hasta la primera llamada dentro de un loop activo evita el desajuste de enlace. Para grupos pequeños, el diccionario de locks permanece pequeño; para flotas más grandes, añade evicción LRU.

Patrón 6: Jerarquía de excepciones y orden de los except

Las clases de excepción del motor forman un árbol:

  • HermesError(RuntimeError) — cualquier problema con el subprocess
  • HermesEmptyResponse(HermesError) — ejecución exitosa con resultado vacío
  • HermesHangError(HermesError) — watchdog mató el proceso

El except de Python hace match con la primera cláusula compatible. Si except HermesError precede a los handlers de subclases, captura HermesEmptyResponse y lo enruta al camino de error, saltándose el mini-acuse de recibo. El orden subclase-primero es obligatorio:

try:
    response = await _run_hermes_with_ux(bot, message, prompt, ctx)
    ...
except HermesEmptyResponse:
    # camino del mini-acuse de recibo
    ...
except HermesHangError as exc:
    # camino de reintentar-una-vez-y-desistir
    ...
except HermesError as exc:
    # exit distinto de cero, api-error, etc.
    ...
except Exception:
    # último recurso
    ...

Añade esto a una checklist de revisión de código. Reordenar los bloques visualmente invierte la intención.

Patrón 7: Drenando stderr en paralelo

Hacer streaming por stdout requiere leer líneas a medida que llegan: async for line in proc.stdout. Si stderr también está en pipe, el subprocess puede llenar su buffer de stderr mientras stdout sigue siendo leído. Los buffers de pipe por defecto en Linux son de unos 64 KB. Una vez que stderr se llena, el subprocess se bloquea esperando que se drene, y el loop async-for-line nunca avanza. El watchdog eventualmente lo mata tras el período de inactividad, pero el resultado se pierde.

Drena stderr en paralelo desde el inicio del subprocess, luego espera la tarea de drenado después de proc.wait():

proc = await asyncio.create_subprocess_exec(
    *cmd,
    stdout=asyncio.subprocess.PIPE,
    stderr=asyncio.subprocess.PIPE,
    cwd=str(WORKSPACE),
)
stderr_task = asyncio.create_task(proc.stderr.read())

# ... loop de stream sobre stdout ...

rc = await proc.wait()
try:
    stderr_b = await stderr_task
except Exception:
    stderr_b = b""
stderr = stderr_b.decode("utf-8", errors="replace").strip()

La CLI de Claude Code emite poco stderr en modo stream-json, por lo que el modo de fallo es raro en la práctica. La solución es una línea extra.

Patrón 8: Disciplina en la edición de memoria

Un agente al estilo hermes lee sus propios logs de conversación como memoria. Los mensajes de fallback por bug escritos en ese log se vuelven indistinguibles del comportamiento pasado intencional en la siguiente lectura. El primer impulso es insertar marcadores de corrección ([CORRECTION: la entrada anterior fue un bug]) para que la siguiente lectura de memoria vea la corrección.

Verifica que el mensaje de fallback por bug fue realmente registrado antes de editar. En el caso anterior, el bloque genérico except Exception llamaba a message.answer(...) para enviar el error al usuario, pero no llamaba a conversation_log.log_outgoing(...). El mensaje de error llegó a Telegram pero nunca llegó al archivo de memoria del agente. No fue necesaria ninguna edición retroactiva.

Trata el workspace del agente como propiedad del agente. Antes de cualquier plan que implique editar archivos dentro de él, toma un snapshot fresco del estado —el agente puede haber reescrito su propio CLAUDE.md o notas desde la última lectura. La guía de ingeniería de contexto de Anthropic describe la memoria persistente como un artefacto entre sesiones, no como un bloc de notas en el que el operador garabatea. Las habilidades específicas de dominio son más duraderas cuando conviven con notas curadas por el agente en lugar de en archivos editados por el operador en los que el agente aprende a desconfiar.

Notas operacionales

Persistencia con bind-mount. Los volúmenes montados con bind para el workspace y las credenciales OAuth de Claude sobreviven a docker compose up -d --force-recreate siempre que las rutas de montaje no cambien. Verifica esto antes de cualquier edición al compose-file.

Verificación de seguridad antes del despliegue. Busca en los logs de los últimos cinco minutos un claude_subprocess_start sin un claude_result_event correspondiente. Un subprocess pendiente significa que un reinicio matará una ejecución en curso. Espera hasta que los logs estén limpios. Para escenarios de fallo más amplios, consulta nuestro artículo sobre recuperación ante desastres.

Reutilización de patrones entre mandatos. El stack completo —motor, handlers, log de conversación, ingesta de archivos— se clona a un nuevo mandato cambiando dos variables de entorno (un nombre de proyecto y un instance-id). El token del bot, las credenciales OAuth, el workspace y la allow-list se parametrizan por proyecto. Para el ángulo operacional de ejecutar muchos asistentes por proyecto en paralelo, consulta operaciones en solitario a escala.

Selección del emoji de reacción. El emoji 👀 está en el conjunto estándar por defecto de Telegram y funciona en grupos donde available_reactions no está definido. Si un grupo restringe a un subconjunto personalizado, la caché lo refleja y el mini-acuse de recibo se omite silenciosamente. Haz del emoji una constante de configuración por despliegue en lugar de un literal codificado.

Hermes-Agent versus una implementación mínima propia. El framework de NousResearch incluye TUI, sistema de slash-commands, gateway multicanal, hub de habilidades e integración de entrenamiento RL. Un wrapper mínimo de la CLI de Claude Code produce la misma forma conversacional con aproximadamente una décima parte de las piezas móviles. Ambos convergen en el mismo conjunto de problemas de UX en chat grupal; los patrones de este artículo aplican a cualquiera de los dos.

Cuándo aplicar cada patrón

Los patrones no son igualmente urgentes. Aplícalos en el orden en que los encuentres:

  • El patrón 1 (manejo de respuesta vacía) es obligatorio en cuanto el bot se agrega a un grupo con detección de triggers por palabras clave.
  • El patrón 4 (calendario de mensajes de espera) es obligatorio después de que la primera respuesta corta llega al mismo tiempo que el mensaje de confirmación.
  • Los patrones 3 y 7 (modo stream, drenado de stderr) son obligatorios en cuanto las tareas de larga duración empiezan a colgarse.
  • El patrón 5 (lock de concurrencia) es obligatorio cuando aparece la primera truncación del archivo de sesión en los logs.
  • Los patrones 2, 6 y 8 son hardening de fondo —aplícalos durante la revisión de código antes de que fallen en producción.

Construye primero el asistente específico del proyecto: la guía de arquitectura base cubre el container, OAuth, workspace y layout de handlers. Incorpora un equipo pequeño con la guía de escalado para allow-lists, configuración de grupos y detección de triggers. Añade el canal de email para el proyecto con la guía de DKIM/DMARC cuando comiencen a llegar notificaciones fuera de banda. Vuelve a este artículo cuando necesites los patrones descritos aquí.

tva gestiona varios asistentes por proyecto en paralelo para diferentes mandatos de consultoría. Para recibir ayuda construyendo o ajustando el tuyo, contáctanos.


Artículos relacionados

Artículos relacionados