Einen Hermes-Style-Agenten tunen, der mit deinem Projekt wächst
Ein projektspezifischer KI-Assistent, der über einen Messaging-Kanal läuft – Telegram, Discord, Slack oder E-Mail über ein schlankes Gateway – verhält sich in einem Gruppen-Chat mit mehreren Nutzern anders als im Einzelgespräch. Wenn ein Team den Bot einbindet, tauchen Fehlerklassen auf, die der DM-Testpfad nie erreicht: Crashes bei Leer-Antworten auf Keyword-Trigger, falsch kalibrierte Fortschrittsbenachrichtigungen, Race Conditions zwischen parallelen Triggern und Memory-Drift im Conversation Log des Agenten.
Dieser Leitfaden dokumentiert acht Tuning-Patterns, die wir auf einen minimalen Hermes-Style-Assistenten angewandt haben – gebaut auf der Claude Code CLI als subprocess statt auf dem vollständigen NousResearch-Framework. Jedes Pattern beschreibt ein konkretes Problem mit dem Code zum Beheben. Die Patterns lassen sich auf beide Codebasen anwenden.
Was dieser Leitfaden behebt
- Generische
Unexpected error-Antworten, wenn das LLM auf einen Keyword-Trigger leere stdout zurückgibt - Beruhigungs-Nachrichten, die bei jeder kurzen Antwort feuern, weil der Schwellenwert unter der typischen Antwortlatenz liegt
- Subprocesses, die mitten im Stream hängen und nie zurückkehren, ohne dass ein Timeout sie aufräumt
- Race Conditions, wenn zwei Gruppenmitglieder den Agenten gleichzeitig gegen eine gemeinsame Session triggern
- Stille stderr-Pipe-Deadlocks, sobald der Agent lang genug läuft, um den 64-KB-Puffer zu füllen
- Fehler-Fallback-Nachrichten im Conversation Log, die der Agent beim nächsten Lesen als legitimes Verhalten interpretiert
Voraussetzungen
Dieser Leitfaden setzt die Architektur aus Building a Project-Specific AI Assistant via Telegram voraus: ein Docker-Container pro Projekt, non-root-User, persistente OAuth-authentifizierte Claude Code CLI, aiogram-Bot-Wrapper, bind-gemountete Workspace- und Session-State-Volumes. Mehrere Patterns setzen bestimmte Versionen voraus:
- Claude Code CLI 2.1.139 oder neuer (für
--output-format stream-json --verbose --include-partial-messages) - aiogram 3.28.2 oder neuer (für
ChatActionSender,message.react(),ReactionTypeEmoji) - Python 3.13 als Base Image
- Eine Telegram-Gruppe, in der der Bot Mitglied ist mit
can_react_to_messages: true
Für einen E-Mail-Kanal am selben Projekt, siehe Einrichten eines Projektpostfachs mit DKIM, SPF und DMARC.
Was ein Hermes-Style-Agent ist
Das Hermes-Style-Pattern ist nach dem offenen Framework von NousResearch benannt. Drei Eigenschaften unterscheiden es von einem zustandslosen Chatbot:
- Persistentes Gedächtnis. Ein Workspace auf der Festplatte, den der Agent zwischen den Turns liest und schreibt, sodass der Kontext Container-Restarts überlebt.
- Multi-Channel-Präsenz. Dieselbe Agent-Instanz kommuniziert auf Telegram, Discord, Slack oder E-Mail über ein schlankes Gateway.
- Ein geschlossener Lernkreislauf. Korrekturen des Operators werden zu Workspace-Edits, die der Agent beim nächsten Turn liest.
NousResearch liefert eine vollständige Referenzimplementierung mit TUI, Multi-Channel-Gateway, Skills-System und RL-Training-Hooks. Eine minimale Variante auf Basis des Claude Code CLI subprocess hält die beweglichen Teile klein genug, um pro Beratungsmandat als Template zu dienen. Die Patterns unten gelten für beide Ansätze gleichermaßen.
Pattern 1: Typisiertes Handling von Leer-Antworten
Ein Keyword-basierter Trigger (der \bhermes\b in Gruppen-Nachrichten matched) kann auf einen Satz feuern, der den Namen des Bots enthält, aber nicht an ihn gerichtet ist. Das LLM gibt korrekt leere Ausgabe zurück. Drei Ebenen weiter unten versagt jede einzelne:
- Die Engine gibt
""mit returncode 0 zurück. - Die Split-Funktion gibt
[""]zurück, weillen("") <= max_charszutrifft. - Die Send-Schleife ruft
bot.send_message(chat_id, "")auf; Telegram antwortet mitBad Request: message text is empty; ein generischesexcept Exceptionam Anfang des Handlers verschluckt den Traceback und sendet den nutzersichtigen Fehler.
Leere Strings in einer Schicht zu filtern verhindert den Crash, produziert aber ein stilles Überspringen – der Trigger hat gefeuert, der Bot hat Rechenzeit verbraucht, der Nutzer sieht nichts. Der zweiteilige Fix verwendet eine typisierte Exception für leere Ausgabe und eine Telegram-Reaktion (👀) auf die auslösende Nachricht als Bestätigung:
class HermesEmptyResponse(HermesError):
"""Subprocess returned successfully but with empty result."""
class HermesHangError(HermesError):
"""Watchdog killed subprocess after no stream-event for N seconds."""
Die Engine wirft HermesEmptyResponse, wenn result.strip() == "". Der Handler fängt sie ab und ruft message.react([ReactionTypeEmoji(emoji="👀")]) auf. Der Conversation Log bekommt einen Marker-Block – einen separaten Eintrag, der die stille Bestätigung festhält, ohne den Chat mit Text zu überhäufen – damit spätere Gedächtnislese-Vorgänge des Agenten sehen, dass ein Trigger gefeuert und bewusst nicht beantwortet wurde.
Pattern 2: Reaction-Permission-Preflight mit Lazy Cache
Telegrams setMessageReaction ist nicht universell verfügbar. Manche Gruppen schränken den erlaubten Reaktions-Set ein; manche Custom-Emojis brauchen eine Administrator-Allowlist. Der ChatFullInfo-Typ dokumentiert die Regel: Wenn available_reactions nicht angegeben ist, sind alle Standard-Emojis erlaubt; wenn es ein Array ist, funktionieren nur diese. Der Bot muss Mitglied der Gruppe sein – Administrator-Status ist für Reaktionen in Gruppen nicht erforderlich.
Eine Überprüfung pro Trigger verschwendet API-Aufrufe. Ein getChat pro Chat mit einem Einstunden-Cache reicht aus:
_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
Den eigentlichen Reaction-Aufruf trotzdem in try/except (TelegramBadRequest, TelegramForbiddenError) einwickeln – der Cache hinkt Berechtigungsänderungen hinterher.
Pattern 3: Stream-Mode und der Idle-Time-Watchdog
Ein harter Timeout auf den gesamten Subprocess (asyncio.wait_for(proc.communicate(), timeout=300)) begrenzt die Gesamtdauer unabhängig vom Fortschritt. Ihn ohne Ersatz zu entfernen ist dokumentiert als unsicher: der Claude Code stream-idle-hang Issue beschreibt API-Aufrufe, die mitten im Stream hängen und nie zurückkehren und einen Subprocess leaken.
Der Wechsel zu --output-format stream-json --verbose --include-partial-messages emittiert bei jedem Milestone Events – pro Token text_delta, Tool-Use-Start und -Stop, API-Retries, Rate-Limit-Meldungen, das abschließende result-Event. Ein echter Stall produziert Stille im Stream; eine lange Aufgabe produziert eine Folge kleiner Events. Der Watchdog killt bei Idle-Zeit, nicht bei Gesamtdauer:
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
Der endgültige Antworttext kommt aus dem result-Feld des result-Events – deterministisch, aus einer einzigen Quelle und unbeeinflusst vom Partial-Stream-Parsing. Dasselbe Event trägt is_error, api_error_status, duration_ms und total_cost_usd, die alle in die strukturierte Log-Zeile einfließen.
Pattern 4: Den Reassure-Zeitplan kalibrieren
Die Schwellenwert-Frage – wann sendet der Bot während eines lang laufenden Aufrufs ein Text-Update – ist empirisch. Die richtige Antwort hängt von der Latenzverteilung realer Trigger ab. Drei Schwellenwerte, mit Text abgestimmt auf das, was der Nutzer tatsächlich wissen muss:
_REASSURE_SCHEDULE = (
(15, "On it."),
(90, "Taking longer than usual, still on it."),
(300, "Genuinely large task — almost there."),
)
Die Schwellenwerte leiten sich aus zwei Constraints ab. Die Untergrenze wird durch die typische Kurz-Antwort-Latenz gesetzt: Wenn die meisten Antworten innerhalb von X Sekunden eintreffen, muss die erste Beruhigungsnachricht später als X feuern, sonst kommt sie ungefähr gleichzeitig mit der Antwort an. Nielsens Response-Time-Forschung identifiziert 10 Sekunden als kanonische Grenze, um die Aufmerksamkeit eines Nutzers ohne Fortschrittsanzeige zu halten; der Typing-Indicator, den aiograms ChatActionSender unterhalb dieser Schwelle anzeigt, erfüllt diese Anforderung bereits bis etwa 15 Sekunden.
Die obere Schwelle (90 Sekunden) ist der Zeitpunkt, ab dem das Framing von arbeitet gerade zu arbeitet, aber länger als üblich wechselt – ein separates Signal, dass der Aufruf im langen Schwanz der Verteilung liegt. Die Formulierung vermeidet den Anschein, der Nutzer hätte etwas Aufwändiges angefragt. Der Bot ist derjenige, der die Arbeit erledigt; die Nachricht bestätigt diese Arbeit, nicht die Anfrage.
Pattern 5: Per-Chat-Concurrency-Lock
Zwei Gruppenmitglieder können den Agenten innerhalb derselben Sekunde triggern – eines mit einem @-Mention, eines mit dem Keyword. Beide Handler-Invocations spawnen claude --continue-Subprocesses gegen dieselbe persistente Session-Datei. Das Session-Lock-File ist nicht strikt; konkurrierende Schreibzugriffe produzieren abgeschnittene Session-jsonl-Dateien und verlorene Turns.
Serialisierung pro Chat auf Handler-Ebene mit einem lazy erstellten Lock:
_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)
...
Lazy Creation ist wichtig: Ein asyncio.Lock, der zur Import-Zeit des Moduls instanziiert wird, bindet sich an den Event Loop, der beim Import aktuell ist – das ist möglicherweise nicht der Loop, auf dem der Handler nach einem Restart läuft. Die Instantiierung auf den ersten Aufruf innerhalb eines aktiven Loops zu verschieben vermeidet diesen Binding-Mismatch. Für kleine Gruppen bleibt das Lock-Dictionary klein; für größere Flotten LRU-Eviction hinzufügen.
Pattern 6: Exception-Hierarchie und Except-Reihenfolge
Die Engine-Exception-Klassen bilden einen Baum:
HermesError(RuntimeError)– alles, was mit dem Subprocess schiefläuftHermesEmptyResponse(HermesError)– erfolgreicher Lauf mit leerem ErgebnisHermesHangError(HermesError)– Watchdog hat gekillt
Pythons except matched die erste kompatible Klausel. Wenn except HermesError vor den Subklassen-Handlern steht, fängt es HermesEmptyResponse ab und leitet es in den Error-Pfad, an der Mini-Ack vorbei. Subklassen-zuerst-Reihenfolge ist erforderlich:
try:
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
except HermesEmptyResponse:
# mini-ack path
...
except HermesHangError as exc:
# retry-once-then-bail path
...
except HermesError as exc:
# exit-not-zero, api-error, etc.
...
except Exception:
# last resort
...
Das auf eine Code-Review-Checkliste setzen. Die Blöcke per Sichtkontrolle umzusortieren kehrt die Absicht um.
Pattern 7: stderr parallel drainieren
Das Streamen über stdout erfordert zeilenweises Lesen während des Empfangs: async for line in proc.stdout. Wenn stderr ebenfalls gepiped ist, kann der Subprocess seinen stderr-Puffer füllen, während stdout noch gelesen wird. Standard-Pipe-Puffer sind unter Linux rund 64 KB. Sobald stderr voll ist, blockiert der Subprocess und wartet darauf, dass er drainiert wird, und die async-for-line-Schleife kommt nicht mehr weiter. Der Watchdog killt den Subprocess schließlich nach der Idle-Zeit, aber das Ergebnis ist verloren.
stderr von Anfang des Subprocess an parallel drainieren, dann den Drain-Task nach proc.wait() awaiten:
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())
# ... stream-loop on 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()
Die Claude Code CLI emittiert im stream-json-Modus kaum stderr, daher ist das Fehlerbild in der Praxis selten. Der Fix ist eine einzige zusätzliche Zeile.
Pattern 8: Memory-Edit-Disziplin
Ein Hermes-Style-Agent liest seine eigenen Conversation Logs als Gedächtnis. Fehler-Fallback-Nachrichten, die in diesen Log geschrieben werden, sind beim nächsten Lesen nicht von absichtlichem früherem Verhalten zu unterscheiden. Der erste Impuls ist, Korrektur-Marker einzufügen ([CORRECTION: the previous entry was a bug]), damit der nächste Gedächtnislesevorgang den Fix sieht.
Zuerst prüfen, ob der Fehler-Fallback überhaupt geloggt wurde. Im oben beschriebenen Fall rief der generische except Exception-Block message.answer(...) auf, um den Fehler an den Nutzer zu schicken, rief aber nicht conversation_log.log_outgoing(...) auf. Die Fehlernachricht erreichte Telegram, aber nie die Gedächtnisdatei des Agenten. Eine nachträgliche Bearbeitung war nicht nötig.
Den Workspace des Agenten als Eigentum des Agenten behandeln. Vor jedem Plan, der das Bearbeiten von Dateien darin vorsieht, einen frischen State-Snapshot nehmen – der Agent kann seine eigene CLAUDE.md oder Notizen seit dem letzten Lesen umgeschrieben haben. Anthropics Context-Engineering-Leitfaden beschreibt persistentes Gedächtnis als Artefakt zwischen Sessions, nicht als Notizblock, in dem der Operator herumkritzelt. Domänenspezifische Skills bleiben dauerhafter, wenn sie neben agenten-kuratierten Notizen liegen statt in Operator-bearbeiteten Dateien, denen der Agent zu misstrauen lernt.
Betriebliche Hinweise
Bind-Mount-Persistenz. Bind-gemountete Volumes für den Workspace und die Claude-OAuth-Credentials überstehen docker compose up -d --force-recreate, solange die Mount-Pfade unverändert bleiben. Vor jedem Compose-File-Edit prüfen.
Pre-Deploy-Sicherheitscheck. Die letzten fünf Minuten Logs nach einem claude_subprocess_start ohne passendes claude_result_event durchsuchen. Ein noch laufender Subprocess bedeutet, dass ein Restart einen laufenden Aufruf abbricht. Warten, bis die Logs sauber sind. Für umfassendere Fehlerszenarien, siehe unseren Disaster-Recovery-Leitfaden.
Pattern-Wiederverwendung über Mandate hinweg. Der vollständige Stack – Engine, Handler, Conversation Log, File Intake – klont sich für ein neues Mandat durch das Ändern von zwei Umgebungsvariablen (ein Projektname und eine Instanz-ID). Bot-Token, OAuth-Credentials, Workspace und Allowlist lassen sich pro Projekt parametrisieren. Für den Betriebsaspekt, viele Per-Projekt-Assistenten parallel zu betreiben, siehe Solo Operations at Scale.
Auswahl des Reaktions-Emojis. Das 👀-Emoji ist im Standard-Telegram-Set enthalten und funktioniert in Gruppen, bei denen available_reactions nicht gesetzt ist. Wenn eine Gruppe auf einen eigenen Subset beschränkt, reflektiert der Cache das und die Mini-Ack wird still übersprungen. Das Emoji als konfigurierbare Konstante pro Deployment anlegen, nicht als hartkodierten Literal.
Hermes-Agent versus ein minimaler Custom-Build. Das NousResearch-Framework enthält ein TUI, ein Slash-Command-System, ein Multi-Channel-Gateway, einen Skills-Hub und RL-Training-Integration. Ein minimaler Claude Code CLI Wrapper produziert dieselbe Gesprächsform mit ungefähr einem Zehntel der beweglichen Teile. Beide konvergieren auf dieselbe Menge an Gruppen-Chat-UX-Problemen; die Patterns in diesem Beitrag gelten für beide.
Wann welches Pattern anwenden
Die Patterns sind nicht gleich dringend. In der Reihenfolge des Auftretens anwenden:
- Pattern 1 (Leer-Antwort-Handling) ist erforderlich, sobald der Bot einer Gruppe mit Keyword-Trigger-Erkennung hinzugefügt wird.
- Pattern 4 (Reassure-Zeitplan) ist erforderlich, nachdem die erste kurze Antwort gleichzeitig mit der Beruhigungsnachricht eintrifft.
- Patterns 3 und 7 (Stream-Mode, stderr-Drain) sind erforderlich, sobald lang laufende Aufgaben anfangen zu hängen.
- Pattern 5 (Concurrency Lock) ist erforderlich, wenn die erste Session-File-Abschneidung in den Logs auftaucht.
- Patterns 2, 6 und 8 sind Hintergrund-Hardening – im Code Review anwenden, bevor sie in Produktion brechen.
Zuerst den projektspezifischen Assistenten bauen: der Basis-Architektur-Leitfaden behandelt Container, OAuth, Workspace und Handler-Layout. Ein kleines Team mit dem Scaling-Leitfaden einbinden für Allowlists, Gruppen-Setup und Trigger-Erkennung. Den E-Mail-Kanal für das Projekt mit dem DKIM/DMARC-Leitfaden hinzufügen, wenn Out-of-Band-Benachrichtigungen anfangen einzutreffen. Zu diesem Beitrag zurückkehren, wenn die oben beschriebenen Patterns gebraucht werden.
tva betreibt mehrere Per-Projekt-Assistenten parallel für verschiedene Beratungsmandate. Für Hilfe beim Aufbau oder Tuning deines Assistenten, meld dich gerne.
Verwandte Insights
- Building a Project-Specific AI Assistant via Telegram — die Basis-Architektur, die dieser Leitfaden tuned
- Scaling a Telegram AI Assistant from Solo to Team — Allowlists, Gruppen-Setup, Trigger-Erkennung
- Setting Up a Mailbox for Your Project-Specific AI Agent — der E-Mail-Kanal für dasselbe Per-Projekt-Pattern
- Building AI Agent Skills for Domain-Specific Business Workflows — den Assistenten für eine Domäne nützlich machen
- Solo Operations at Scale: Managing Dozens of Projects with a Small Team — viele Assistenten parallel betreiben