tva
← Insights

Affiner un agent de style Hermes qui évolue avec votre projet

Un assistant IA dédié à un projet construit sur un canal de messagerie — Telegram, Discord, Slack, ou email via une passerelle légère — se comporte différemment dans un groupe multi-utilisateurs que dans un DM individuel. L'intégration d'une équipe au bot expose une catégorie de bugs que le chemin de test en DM n'atteint jamais : crashs sur réponse vide déclenchés par un mot-clé, notifications de progression mal calibrées, conditions de course entre déclenchements parallèles, et dérive mémoire dans le journal de conversation de l'agent lui-même.

Ce guide documente huit patterns d'optimisation appliqués à un assistant minimal de style hermes — construit sur le Claude Code CLI comme subprocess plutôt que sur le framework complet de NousResearch. Chaque pattern est un problème concret accompagné du code pour le résoudre. Ces patterns s'appliquent aux deux bases de code.

Ce que cela corrige

  • Les réponses génériques Unexpected error quand le LLM retourne un stdout vide sur un déclenchement par mot-clé
  • Le texte de réassurance qui se déclenche à chaque réponse courte parce que le seuil est inférieur à la latence de réponse habituelle
  • Les subprocesses qui se bloquent en cours de stream et ne reviennent jamais, sans timeout pour les nettoyer
  • Les conditions de course lorsque deux membres du groupe déclenchent l'agent simultanément contre une session partagée
  • Les deadlocks silencieux sur le pipe stderr dès que l'agent tourne assez longtemps pour remplir le buffer de 64 Ko
  • Les messages de fallback d'erreur dans le journal de conversation que l'agent relit ensuite comme un comportement légitime

Prérequis

Ce guide suppose l'architecture décrite dans Construire un assistant IA dédié à un projet via Telegram : un container Docker par projet, utilisateur non-root, Claude Code CLI authentifié via OAuth de façon persistante, wrapper bot aiogram, volumes workspace et état de session bind-mountés. Plusieurs patterns dépendent de versions spécifiques :

  • Claude Code CLI 2.1.139 ou ultérieur (pour --output-format stream-json --verbose --include-partial-messages)
  • aiogram 3.28.2 ou ultérieur (pour ChatActionSender, message.react(), ReactionTypeEmoji)
  • Image de base Python 3.13
  • Un groupe Telegram où le bot est membre avec can_react_to_messages: true

Pour un canal email sur le même projet, consultez la configuration d'une boîte mail de projet avec DKIM, SPF et DMARC.

Qu'est-ce qu'un agent de style Hermes

Le pattern hermes-style tire son nom du framework open source de NousResearch. Trois propriétés le distinguent d'un chatbot sans état :

  • Mémoire persistante. Un workspace sur disque que l'agent lit et écrit entre les tours, de sorte que le contexte survit aux redémarrages du container.
  • Présence multi-canal. La même instance d'agent dialogue sur Telegram, Discord, Slack ou email via une passerelle légère.
  • Une boucle d'apprentissage fermée. Les corrections de l'opérateur deviennent des modifications du workspace que l'agent lit au tour suivant.

NousResearch fournit une implémentation de référence complète avec une TUI, une passerelle multi-canal, un système de compétences et des hooks d'entraînement RL. Une variante minimale construite sur le subprocess du Claude Code CLI garde les composants suffisamment simples pour être déclinés par mandat de conseil. Les patterns ci-dessous s'appliquent également aux deux approches.

Pattern 1 : Gestion typée des réponses vides

Un déclencheur basé sur un mot-clé (correspondant à \bhermes\b dans les messages de groupe) peut se déclencher sur une phrase qui contient le nom du bot sans lui être adressée. Le LLM retourne correctement un résultat vide. Trois couches en aval échouent chacune à gérer ce cas vide :

  • Le moteur retourne "" avec un returncode 0.
  • La fonction de découpage retourne [""] car len("") <= max_chars correspond.
  • La boucle d'envoi appelle bot.send_message(chat_id, "") ; Telegram retourne Bad Request: message text is empty ; un except Exception générique en haut du handler avale la trace d'erreur et envoie l'erreur visible à l'utilisateur.

Filtrer les chaînes vides dans une seule couche évite le crash mais produit un saut silencieux — le déclencheur a bien eu lieu, le bot a consommé des ressources de calcul, l'utilisateur ne voit rien. Le correctif en deux étapes utilise une exception typée pour la sortie vide et une réaction Telegram (👀) sur le message déclencheur comme accusé de réception :

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

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

Le moteur lève HermesEmptyResponse quand result.strip() == "". Le handler l'intercepte et appelle message.react([ReactionTypeEmoji(emoji="👀")]). Le journal de conversation reçoit un bloc marqueur — une entrée distincte qui enregistre l'accusé de réception silencieux sans polluer la conversation avec du texte — de sorte que les futures lectures mémoire de l'agent indiquent qu'un déclencheur a eu lieu et n'a intentionnellement pas reçu de réponse.

Pattern 2 : Pré-vérification des permissions de réaction avec cache paresseux

La méthode setMessageReaction de Telegram n'est pas universellement disponible. Certains groupes restreignent l'ensemble des réactions autorisées ; certains emoji personnalisés nécessitent une mise sur liste blanche par un administrateur. Le type ChatFullInfo documente la règle : si available_reactions est omis, tous les emoji standard sont autorisés ; s'il s'agit d'un tableau, seuls ces emoji fonctionnent. Le bot doit être membre du groupe — le statut d'administrateur n'est pas requis pour les réactions dans les groupes.

Vérifier à chaque déclenchement gaspille des appels API. Un getChat par conversation avec un cache d'une heure suffit :

_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

Encapsulez l'appel de réaction effectif dans try/except (TelegramBadRequest, TelegramForbiddenError) dans tous les cas — le cache est en retard sur les changements de permissions.

Pattern 3 : Mode stream et watchdog de temps d'inactivité

Un timeout strict sur l'ensemble du subprocess (asyncio.wait_for(proc.communicate(), timeout=300)) plafonne la durée totale indépendamment de la progression. Le supprimer sans le remplacer est documenté comme non sûr : le problème de blocage d'inactivité du stream de Claude Code décrit des appels API qui se bloquent en cours de stream et ne reviennent jamais, laissant un subprocess fuir.

Passer à --output-format stream-json --verbose --include-partial-messages émet des événements à chaque étape clé — text_delta par token, début et fin d'utilisation d'outil, tentatives de réessai API, avis de rate limit, l'événement final result. Un vrai blocage produit du silence sur le stream ; une tâche longue produit une séquence de petits événements. Le watchdog tue sur le temps d'inactivité, pas sur la durée totale :

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

Le texte de réponse final provient du champ result de l'événement result — déterministe, source unique, non affecté par l'analyse du stream partiel. Le même événement transporte is_error, api_error_status, duration_ms et total_cost_usd, qui alimentent tous la ligne de journal structuré.

Pattern 4 : Calibrer le calendrier de réassurance

La question du seuil — à quel moment le bot envoie-t-il une mise à jour textuelle durant un appel long — est empirique. La bonne réponse dépend de la distribution de latence des déclenchements réels. Trois seuils, avec un texte aligné sur ce que l'utilisateur a réellement besoin de savoir :

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

Les seuils découlent de deux contraintes. La borne inférieure est fixée par la latence typique des réponses courtes : si la plupart des réponses arrivent dans les X secondes, la première réassurance doit se déclencher plus tard que X, sinon elle arrive à peu près au même moment que la réponse. La recherche de Nielsen sur les temps de réponse identifie 10 secondes comme la limite canonique pour maintenir l'attention d'un utilisateur sans indicateur de progression ; l'indicateur de frappe que ChatActionSender d'aiogram affiche en dessous de ce seuil satisfait déjà l'exigence jusqu'à environ 15 secondes.

Le seuil supérieur (90 secondes) est l'intervalle à partir duquel le cadrage passe de en cours à en cours mais plus long que d'habitude — un signal distinct indiquant que l'appel se situe dans la queue longue de la distribution. La formulation évite de laisser entendre que l'utilisateur a demandé quelque chose de lourd. C'est le bot qui effectue le travail ; le message reconnaît ce travail, pas la demande.

Pattern 5 : Lock de concurrence par conversation

Deux membres d'un groupe peuvent déclencher l'agent dans la même seconde — l'un avec une @-mention, l'autre avec le mot-clé. Les deux invocations du handler lancent des subprocesses claude --continue contre le même fichier de session persistant. Le fichier de lock de session n'est pas strict ; les écritures concurrentes produisent des fichiers session-jsonl tronqués et des tours perdus.

Sérialisez par conversation au niveau de la couche handler avec un lock créé paresseusement :

_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 création paresseuse est importante : un asyncio.Lock instancié à l'import du module se lie à la boucle d'événements courante au moment de l'import, qui peut ne pas être la boucle sur laquelle le handler tourne après un redémarrage. Différer l'instanciation jusqu'au premier appel dans une boucle active évite ce conflit de liaison. Pour les petits groupes, le dictionnaire de locks reste compact ; pour les flottes plus importantes, ajoutez une éviction LRU.

Pattern 6 : Hiérarchie d'exceptions et ordre des clauses except

Les classes d'exceptions du moteur forment un arbre :

  • HermesError(RuntimeError) — tout problème avec le subprocess
  • HermesEmptyResponse(HermesError) — exécution réussie avec résultat vide
  • HermesHangError(HermesError) — tué par le watchdog

La clause except de Python correspond à la première clause compatible. Si except HermesError précède les handlers de sous-classes, elle capture HermesEmptyResponse et la route vers le chemin d'erreur, contournant le mini-accusé de réception. L'ordre sous-classe en premier est obligatoire :

try:
    response = await _run_hermes_with_ux(bot, message, prompt, ctx)
    ...
except HermesEmptyResponse:
    # chemin mini-ack
    ...
except HermesHangError as exc:
    # chemin retry-une-fois-puis-abandon
    ...
except HermesError as exc:
    # exit-not-zero, api-error, etc.
    ...
except Exception:
    # dernier recours
    ...

Ajoutez ceci à une checklist de revue de code. Réordonner les blocs par inspection visuelle inverse l'intention.

Pattern 7 : Vidange de stderr en parallèle

Le streaming sur stdout nécessite de lire les lignes au fur et à mesure qu'elles arrivent : async for line in proc.stdout. Si stderr est également pipé, le subprocess peut remplir son buffer stderr pendant que stdout est encore en cours de lecture. Les buffers de pipe par défaut sous Linux sont d'environ 64 Ko. Une fois stderr rempli, le subprocess se bloque en attendant qu'il se vide, et la boucle async-for-line n'avance plus. Le watchdog finit par le tuer après la période d'inactivité, mais le résultat est perdu.

Vidangez stderr en parallèle dès le démarrage du subprocess, puis attendez la tâche de vidange après 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())

# ... boucle de stream sur 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()

Le Claude Code CLI émet peu de stderr en mode stream-json, donc le mode de défaillance est rare en pratique. Le correctif tient en une ligne supplémentaire.

Pattern 8 : Discipline d'édition mémoire

Un agent de style hermes relit ses propres journaux de conversation comme mémoire. Les messages de fallback d'erreur écrits dans ce journal deviennent indiscernables d'un comportement passé intentionnel lors de la prochaine lecture. Le premier réflexe est d'insérer des marqueurs de correction ([CORRECTION: the previous entry was a bug]) pour que la prochaine lecture mémoire voie le correctif.

Vérifiez d'abord que le message de fallback a bien été journalisé avant d'éditer quoi que ce soit. Dans le cas ci-dessus, le bloc générique except Exception appelait message.answer(...) pour envoyer l'erreur à l'utilisateur mais n'appelait pas conversation_log.log_outgoing(...). Le message d'erreur a atteint Telegram mais n'a jamais atteint le fichier mémoire de l'agent. Aucune édition rétroactive n'était nécessaire.

Traitez le workspace de l'agent comme appartenant à l'agent. Avant tout plan impliquant de modifier des fichiers à l'intérieur, prenez un instantané de l'état frais — l'agent a peut-être réécrit son propre CLAUDE.md ou ses notes depuis la dernière lecture. Le guide d'ingénierie de contexte d'Anthropic décrit la mémoire persistante comme un artefact entre les sessions, et non comme un bloc-notes dans lequel l'opérateur griffonne. Les compétences spécifiques au domaine restent plus durables lorsqu'elles coexistent avec des notes curées par l'agent plutôt que dans des fichiers édités par l'opérateur que l'agent apprend à ne plus faire confiance.

Notes opérationnelles

Persistance des bind-mounts. Les volumes bind-mountés pour le workspace et les identifiants OAuth de Claude survivent à docker compose up -d --force-recreate tant que les chemins de montage restent inchangés. Vérifiez avant toute modification du fichier compose.

Vérification de sécurité avant déploiement. Recherchez dans les cinq dernières minutes de logs un claude_subprocess_start sans claude_result_event correspondant. Un subprocess en attente signifie qu'un redémarrage interrompra une exécution en cours. Attendez que les logs soient propres. Pour des scénarios de défaillance plus larges, consultez notre article sur la reprise après sinistre.

Réutilisabilité des patterns entre mandats. La stack complète — moteur, handlers, journal de conversation, réception de fichiers — se clone vers un nouveau mandat en changeant deux variables d'environnement (un nom de projet et un identifiant d'instance). Le token du bot, les identifiants OAuth, le workspace et la liste d'autorisation sont paramétrés par projet. Pour l'angle opérationnel sur la gestion de nombreux assistants par projet en parallèle, consultez les opérations solo à grande échelle.

Sélection de l'emoji de réaction. L'emoji 👀 fait partie du jeu standard de Telegram et fonctionne dans les groupes où available_reactions n'est pas défini. Si un groupe restreint à un sous-ensemble personnalisé, le cache le reflète et le mini-accusé de réception est silencieusement ignoré. Faites de l'emoji une constante de configuration par déploiement plutôt qu'un littéral codé en dur.

Hermes-Agent versus une build personnalisée minimale. Le framework de NousResearch inclut une TUI, un système de commandes slash, une passerelle multi-canal, un hub de compétences et une intégration d'entraînement RL. Un wrapper minimal du Claude Code CLI produit la même forme conversationnelle avec environ un dixième des composants mobiles. Les deux convergent vers le même ensemble de problèmes UX de chat de groupe ; les patterns de cet article s'appliquent aux deux.

Quand appliquer chaque pattern

Les patterns n'ont pas tous la même urgence. Appliquez-les dans l'ordre de rencontre :

  • Le pattern 1 (gestion des réponses vides) est requis dès que le bot est ajouté à un groupe avec détection de déclencheurs par mot-clé.
  • Le pattern 4 (calendrier de réassurance) est requis après que la première réponse courte arrive en même temps que le message de réassurance.
  • Les patterns 3 et 7 (mode stream, vidange de stderr) sont requis dès que les tâches longues commencent à se bloquer.
  • Le pattern 5 (lock de concurrence) est requis lorsque la première troncature de fichier de session apparaît dans les logs.
  • Les patterns 2, 6 et 8 constituent un durcissement de fond — appliquez-les lors de la revue de code avant qu'ils ne cassent en production.

Construisez d'abord l'assistant dédié au projet : le guide d'architecture de base couvre le container, OAuth, le workspace et la structure des handlers. Intégrez une petite équipe avec le guide de mise à l'échelle pour les listes d'autorisation, la configuration des groupes et la détection des déclencheurs. Ajoutez le canal email au projet avec le guide DKIM/DMARC quand les notifications hors bande commencent à arriver. Revenez à cet article lorsque les patterns ci-dessus sont nécessaires.

tva gère plusieurs assistants par projet en parallèle pour différents mandats de conseil. Pour obtenir de l'aide pour construire ou affiner le vôtre, contactez-nous.


Insights connexes

Articles connexes