Votre plugin peut enfin remarquer qu'une session se fait compacter ou supprimer


Vous publiez un plugin de mémoire qui pousse les conventions de code de votre équipe et trois endpoints d’API dans chaque tour. Pendant vingt minutes, tout marche à merveille. Puis Hermes juge la conversation trop longue et la compacte : chaque message du début se retrouve replié dans un paragraphe de résumé, vos conventions comprises. À partir de cet instant, le modèle repose des questions auxquelles vous avez déjà répondu, et votre plugin n’a pas la moindre idée de ce qui vient de se passer.

Il existe une version plus discrète du même trou : vous gardez un petit fichier d’index par session sur le disque. Un collègue supprime quarante sessions depuis l’application Desktop, et votre index reste exactement aussi dodu qu’avant — rempli d’entrées que plus personne ne consultera.

Les deux problèmes ont la même racine : un plugin pouvait voir « un outil a été appelé » et « un tour a démarré », mais pas « le transcript a été réécrit » ni « cette session n’existe plus ». Deux patches fusionnés le 18 octobre comblent le manque : un hook post_compaction tout neuf, et un hook on_session_delete qui couvre tous les points d’entrée de suppression.

Pourquoi les plugins ne pouvaient pas le voir

Le système de plugins disposait déjà d’un jeu de hooks de cycle de vie : on_session_start, on_session_end, on_session_finalize, on_session_reset. Ceux-là marquent des instants, pas des réécritures de contenu.

La compaction est une action interne. Elle peut survenir en plein tour quand le contexte sature, juste après l’arrivée d’un résultat d’outil énorme, ou quand l’agent élague de lui-même des sorties d’outils devenues obsolètes — ou quand un humain tape /compress. Pour un plugin, tout cela restait invisible. La suppression de session, c’est la même histoire vue de l’autre côté : hermes sessions delete, les suppressions unitaires et en masse depuis Desktop, /exit --delete, le balayage des sessions vides, l’élagage par rétention, la maintenance des lignes fantômes. Tout cela aboutit aux mêmes méthodes de base de données, mais les plugins ne recevaient aucun callback.

Alors les auteurs de plugins ont fait ce qu’on fait sans signal : interroger le store de sessions en boucle, deviner à partir du nombre de tokens si une compaction avait eu lieu, scanner les répertoires à la recherche d’orphelins. Lent au mieux, faux au pire.

post_compaction : remettre ce que le résumé a mangé

L’enregistrement ne change pas :

def register(ctx):
    ctx.register_hook("post_compaction", on_compaction)


def on_compaction(session_id, kind, reason, **kwargs):
    # on pose un drapeau, puis on réinjecte au prochain pre_llm_call
    mark_session_for_reinjection(session_id, kwargs.get("messages_removed"))

Ce que le callback reçoit :

Champ Signification
session_id La session qui porte désormais le transcript compacté
old_session_id La session d’origine quand la compaction a fait une rotation ; chaîne vide quand elle s’est faite sur place
kind summarize (compaction complète), micro_summarize, prune_tool_results, provider_native (Codex app-server)
reason Ce qui l’a déclenchée : manual (/compress), pre_api, post_tool, overflow, gateway_hygiene, between_turns, proactive_prune, provider, unknown
before_tokens / after_tokens Estimations grossières de tokens, None quand la valeur est inconnue
messages_removed Nombre de messages disparus, None quand la valeur est inconnue

Le point important, c’est qu’il se déclenche une fois pour chaque compaction qui aboutit réellement, sur tous les chemins : backends CLI, TUI et Desktop, agents de la gateway, sessions cron, /compress de la gateway, compaction native de Codex, micro-compaction et élagage proactif des résultats d’outils. Les tentatives qui n’ont rien changé — un résumé avorté, un autre bloqué par un cooldown, un troisième refusé parce qu’il aurait fait grossir le transcript, un /compress vide — ne déclenchent rien, donc vous n’avez jamais à vous demander si une « compaction » était en fait un no-op.

Un schéma qui marche vraiment : noter que cette session a besoin d’un rafraîchissement, puis réinjecter au pre_llm_call suivant. Le hook lui-même est un observateur — sa valeur de retour est ignorée et vous ne pouvez pas réécrire la conversation depuis l’intérieur. C’est volontaire : la compaction est déjà validée, et la modifier en vol casserait la cohérence.

_pending = {}

def on_compaction(session_id, kind, reason, messages_removed=None, **kw):
    if kind in ("summarize", "micro_summarize"):
        _pending[session_id] = {"kind": kind, "removed": messages_removed}

def inject(session_id, conversation_history, **kw):
    if session_id in _pending:
        _pending.pop(session_id)
        return "Project rules: every new endpoint needs OpenAPI annotations; migrations go through alembic only."
    return None

def register(ctx):
    ctx.register_hook("post_compaction", on_compaction)
    ctx.register_hook("pre_llm_call", inject)

on_session_delete : tenir ses propres comptes

Le second hook s’occupe du nettoyage. Il se déclenche une fois, après la validation de la suppression ou de l’archivage volontaire :

Paramètre Signification
session_id La session visée par l’opération
reason delete, archive, empty_sweep, prune, ghost_prune, profile_repair
profile Le profile propriétaire du store (default pour le home racine)
deleted_ids Toutes les lignes supprimées (enfants en cascade, liens de chaîne de compression, lignes traitées en masse) dès qu’il y en a plus d’une
sessions_dir Le répertoire sessions/ du profile quand l’appelant en avait un
def on_delete(session_id, reason, profile=None, deleted_ids=None, sessions_dir=None, **kw):
    ids = deleted_ids or [session_id]
    for sid in ids:
        drop_sidecar(sid)          # votre cache / index par session
    log.info("cleaned %d session rows (%s)", len(ids), reason)

def register(ctx):
    ctx.register_hook("on_session_delete", on_delete)

Deux détails de ce patch sont faciles à manquer et valent la peine d’être connus.

Premier point : Desktop ne déclenchait rien auparavant. Les suppressions et archivages unitaires de Desktop et du dashboard ne passent pas par SessionDB.delete_session — ils empruntent un chemin de reçu de session runtime, si bien que l’ancien câblage restait muet de ce côté. Ce chemin se déclenche désormais aussi, et un retry à l’identique rejoue le reçu sans émettre un second événement.

Second point : l’archivage compte. Archiver signifie que l’utilisateur a masqué la session ; le transcript reste et peut être désarchivé. Pour un plugin, cela veut généralement dire « ce fil est terminé » — si vous suiviez une tâche, le moment où l’utilisateur archive le chat est un bon moment pour la clore. Seul un vrai basculement de non archivé vers archivé déclenche le hook, donc les balayages d’archivage automatique des sessions inactives et l’adoption de profile restent silencieux.

Une troisième correction dans la même fenêtre : les slash commands connaissent leur session

Un changement lié mérite un détour. Une slash command de plugin enregistrée avec ctx.register_command ne recevait que la chaîne d’arguments brute — aucune idée de qui l’avait lancée, sur quelle platform, dans quelle session. Désormais, un handler qui déclare ces mots-clés les reçoit :

def _handle_whoami(raw_args, *, session_id="", platform="", source=None):
    user = source.user_id if source is not None else "local user"
    return f"{user} on {platform}, session {session_id or '(none yet)'}"

def register(ctx):
    ctx.register_command("whoami", handler=_handle_whoami, description="Show the calling session")

Les mots-clés disponibles sont session_id, session_key, platform et source (l’objet d’identité dérivé de l’adaptateur, avec user_id, chat_id, thread_id et compagnie) — ou déclarez simplement **kwargs et prenez tout. Les plugins existants n’ont rien à modifier : un handler à un seul argument est toujours appelé comme handler(raw_args), et les handlers async sont toujours attendus.

Une note de sécurité : source est une information d’identité, pas une autorisation. Une commande de plugin sensible doit trancher à partir d’ids stables (user_id, chat_id) et refuser par défaut quand source vaut None.

Trois limites à intégrer dans votre design

  • Les deux hooks sont des observateurs. Les valeurs de retour sont ignorées ; un callback qui lève une exception est journalisé puis ignoré. La suppression ou la compaction est déjà validée — un plugin ne peut ni s’y opposer ni espérer un rollback.
  • Les callbacks sont bornés dans le temps par plugins.hook_callback_timeout (30 secondes par défaut). Si vous faites des appels réseau dans un hook, fixez votre propre timeout ; ce qui bloque, c’est votre callback, pas la conversation.
  • La livraison est locale au processus, sans garantie d’exactly-once. L’événement de suppression n’est émis que dans le processus qui a validé la suppression. Les effets de bord externes pilotés depuis le callback doivent tolérer un appel manqué ou dupliqué.

Quand pourrez-vous l’utiliser

Les deux hooks ont rejoint la branche main le 18 octobre, après la dernière release v0.22.2 du 15 octobre — ils ne vivent donc que sur main et ne figurent dans aucune version publiée. Si vous avez installé Hermes depuis PyPI ou via un canal de mise à jour, vous ne pouvez pas les utiliser aujourd’hui ; faire tourner la branche main depuis les sources est le moyen de les essayer en avance.

Pour la liste complète des hooks et les conditions exactes de déclenchement, la page officielle Event Hooks fait référence ; la référence de commande plugins du site couvre la surface CLI. Si vous travaillez dans ce coin, deux articles précédents s’y accrochent bien : le broker de tokens pour plugins montre comment un plugin connecte un utilisateur à une API tierce sans jamais détenir le token, et la reconstruction des prompts à la compaction explique comment les prompts sont réassemblés pendant la compaction. Vous écrivez un plugin de mémoire ? La consolidation de mémoire façon Dreaming prend l’autre chemin — ranger pendant l’inactivité plutôt que réagir à la compaction.

En bref

post_compaction répond à « que faire quand le contexte que j’injecte se fait résumer et disparaît ». on_session_delete répond à « qui nettoie mon bazar quand la session n’est plus là ». Ni l’un ni l’autre ne change ce que fait Hermes ; ils exposent simplement deux instants qui étaient internes. Pour les auteurs de plugins, c’est toute une catégorie de polling, de devinettes et de filets de sécurité que vous pouvez supprimer.