Den Agent stillhalten: pre_llm_call kann einen Turn jetzt vor jedem Modellaufruf mit Veto belegen


Zwei Uhr nachts, und jemand fügt einen Screenshot eines Support-Tickets in eine Bot-Session ein – Kundentelefonnummer, die letzten vier Ziffern einer ID, das volle Programm. Dein Gateway beobachtet ein Dutzend Kanäle. Eine Sekunde später geht diese Nachricht ans Modell, ins Transcript, dann durch Compaction-Zusammenfassungen, das Memory-System und den Hintergrund-Review-Lauf. Bis du denkst „das hätte das Haus nie verlassen dürfen“, ist es längst raus.

Das ist der Konstruktionsfehler jedes nachgelagerten Guardrails: Löschen kannst du Daten erst, wenn das Modell geantwortet und die Logs geschrieben sind. In v0.22.6 hat Hermes dem pre_llm_call-Hook einen neuen Rückgabewert gegeben, der den Abfangpunkt vor die Inferenzanfrage verschiebt – der Turn endet dort, mit null Modellaufrufen.

Was genau gestoppt wird

pre_llm_call feuert einmal pro Turn, nach der Kontextkompression und bevor die Tool-Schleife beginnt. Seine ursprüngliche Aufgabe war, Dinge in einen Turn hineinzulegen: erinnerte Memories, aktuelles git status, welcher Tag gerade ist. Du gibst einen String oder ein Objekt mit context-Key zurück, und der Text wird an die User-Nachricht angehängt (nie an den System-Prompt, damit der Prompt-Cache überlebt).

Jetzt kann er den Turn auch ablehnen:

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

Laut offizieller Doku passiert danach Folgendes:

  • Es wird keine Inferenzanfrage gestellt. Der Turn kostet null Modellaufrufe.
  • message wird dem Nutzer als Assistant-Antwort angezeigt und als Assistant-Antwort gespeichert, sodass das Transcript ein normales Paar aus User und Assistant behält und --resume weiter funktioniert.
  • Das Ergebnis trägt turn_exit_reason: "blocked_by_plugin_pre_llm_call", du kannst in Logs und Ergebnissen also „blockiert“ von „das Modell hat geantwortet“ unterscheiden.
  • Der erste Block gewinnt; spätere Callbacks können das Urteil nicht mehr ändern. Ein Block ohne message wird ignoriert.

Was mit dem Text passiert, den du getippt hast

Das ist der Punkt, an dem sich entscheidet, ob das Feature als Datenschutz-Guardrail wirklich taugt.

Der blockierte Prompt wird als Platzhalter im Transcript abgelegt:

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

Alles, was später die Historie liest – folgende Turns, Compaction-Zusammenfassungen, session_search, externe Memory-Provider, der Hintergrund-Memory-Review –, liest diesen Platzhalter statt des Originals. Anders gesagt: der Inhalt erreicht in diesem Turn und in keinem späteren ein Modell.

Was du tatsächlich getippt hast, überlebt nur als Anzeige-Metadatum: die --resume-Zusammenfassung der CLI, die TUI, die Desktop-App und das Dashboard zeigen es weiterhin, du kannst hinterher also nachsehen, was gefragt wurde.

Zwei Nebenwirkungen nennt die Doku ebenfalls: der Hook läuft vor der Compaction zu Turn-Beginn, ein blockierter Turn überspringt also Compaction, Session-Titling und das Prefetch/Sync des externen Memory. Bei einem Turn, der nicht stattgefunden hat, wäre diese Arbeit ohnehin verschwendet.

Selbst schreiben: ein Python-Plugin oder ein Shell-Hook

Python-Plugin (registrieren, fertig):

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)

Shell-Hook (in der config.yaml des Profils, kein Python nötig):

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

Das Skript bekommt die Event-Payload über stdin und gibt eine Zeile JSON auf stdout aus. Akzeptiert werden sowohl {"action": "block", "message": "..."} als auch der Claude-Code-Stil {"decision": "block", "reason": "..."}; gib {} aus, wenn nichts blockiert werden soll.

Drei Grenzen, die du kennen musst

Eins: es scheitert offen. Wirft ein Callback eine Exception oder läuft er über plugins.hook_callback_timeout hinaus (Standard 30 Sekunden, 0–600 konfigurierbar), loggt Hermes das und überspringt ihn – der Turn läuft weiter. Vergleiche pre_tool_call, wo ein Timeout geschlossen scheitert und das Tool blockiert. Die Doku ist bei den Konsequenzen deutlich: ein Policy-Hook sollte seine eigenen Fehler fangen und blockieren, wenn er sich nicht entscheiden kann – sonst sieht ein kaputtes Skript aus wie ein Guardrail und blockiert doch nichts.

Verwandt dazu: fail_closed: true gilt derzeit nur für pre_tool_call. Setzt man es auf einem anderen Event, wird beim Parsen der Config eine Warnung geloggt und der Wert ignoriert.

Zwei: Exit-Code 2 ist hier kein Block. Diese Konvention gehört allein zu pre_tool_call. Ein pre_llm_call-Shell-Hook, der mit 2 endet, wird wie jeder andere Exit ungleich null behandelt: Warnung, danach wird stdout trotzdem geparst.

Drei: das ist ein Eingangsfilter, kein Inhaltsfilter. Er sieht die user_message dieses Turns und die Gesprächshistorie – nicht, welche Tools das Modell gleich aufruft. Tools zu blockieren ist die Aufgabe von pre_tool_call; zu beurteilen, was das Modell gesagt hat, die von post_llm_call. Du willst beide Schichten: der Eingang stoppt, was nie ankommen sollte, die Tool-Schicht stoppt gefährliche Aktionen.

Wann es sich lohnt, das einzuschalten

Drei Fälle zahlen sich sofort aus:

  • Datenschutz und Compliance an der Tür. Ticket-IDs, Ausweisnummern, interne Hostnamen, Kunden-E-Mails: matchen und blockieren, mit einer Nachricht, die der Person sagt, welchen Kanal sie stattdessen nutzen soll.
  • Ein Kosten-Gate. Wenn das Kontingent fast aufgebraucht ist oder mitten in der Nacht: Anfragen ablehnen, die ein riesiges Retrieval nach sich ziehen würden, statt das Modell die Hälfte beantworten zu lassen und dann abzubrechen.
  • Verteidigung gegen Prompt-Injection. Eingefügte Webseiten oder E-Mails mit offensichtlichen Injection-Mustern („ignore all previous instructions“) gehen zurück, bevor das Modell sie überhaupt sieht.

Die Fähigkeit steckt im v0.22.6-Tag, das Upgrade auf diese Version reicht also. Die vollständige Event-Liste, die Rückgabe-Verträge und die Timeout-Regeln stehen in der offiziellen Hook-Dokumentation. Wenn du zuerst den Unterschied zwischen Hooks und Middleware willst, ist unser Leitfaden zu Plugin-Hooks und Lifecycle ein guter Einstieg; für Haltepunkte, an denen ein Mensch gebraucht wird, sieh dir den Beitrag zu Human-Input-Hooks an.