tva
← Insights

Ottimizzare un Agente in Stile Hermes che Cresce con il Progetto

Un assistente AI specifico per progetto costruito su un canale di messaggistica — Telegram, Discord, Slack, o email tramite un gateway leggero — si comporta diversamente in una chat di gruppo con più utenti rispetto a un DM con un singolo utente. Integrare un team nel bot espone una classe di bug che il percorso di test in DM non raggiunge mai: crash da risposta vuota su trigger basati su parole chiave, notifiche di avanzamento mal calibrate, race condition tra trigger paralleli e deriva di memoria nel log delle conversazioni dell'agente.

Questa guida documenta otto pattern di ottimizzazione che abbiamo applicato a un assistente minimale in stile hermes — costruito sul Claude Code CLI come subprocess anziché sul framework completo di NousResearch. Ogni pattern è un problema concreto con il codice per risolverlo. I pattern si applicano a entrambe le codebase.

Cosa Risolve Questa Guida

  • Risposte generiche Unexpected error quando l'LLM restituisce stdout vuoto su un trigger da parola chiave
  • Testo di rassicurazione che si attiva su ogni risposta breve perché la soglia è inferiore alla latenza tipica delle risposte
  • Subprocess che si bloccano a metà stream e non tornano mai, senza timeout per terminarli
  • Race condition quando due membri del gruppo attivano l'agente contemporaneamente contro una sessione condivisa
  • Deadlock silenziosi della pipe stderr una volta che l'agente ha girato abbastanza a lungo da riempire il buffer da 64 KB
  • Messaggi di fallback per bug nel log delle conversazioni che l'agente rilegge come comportamento legittimo

Prerequisiti

Questa guida presuppone l'architettura descritta in Building a Project-Specific AI Assistant via Telegram: un container Docker per progetto, utente non root, Claude Code CLI persistente autenticato via OAuth, wrapper bot aiogram, volumi workspace e session-state montati come bind. Alcuni pattern dipendono da versioni specifiche:

  • Claude Code CLI 2.1.139 o successivo (per --output-format stream-json --verbose --include-partial-messages)
  • aiogram 3.28.2 o successivo (per ChatActionSender, message.react(), ReactionTypeEmoji)
  • Immagine base Python 3.13
  • Un gruppo Telegram in cui il bot è membro con can_react_to_messages: true

Per un canale email sullo stesso progetto, consulta la guida su come configurare una mailbox di progetto con DKIM, SPF e DMARC.

Cos'è un Agente in Stile Hermes

Il pattern hermes-style prende il nome dal framework open di NousResearch. Tre proprietà lo distinguono da un chatbot stateless:

  • Memoria persistente. Un workspace su disco che l'agente legge e scrive tra un turno e l'altro, così il contesto sopravvive ai riavvii del container.
  • Presenza multi-canale. La stessa istanza dell'agente dialoga su Telegram, Discord, Slack o email tramite un gateway leggero.
  • Un ciclo di apprendimento chiuso. Le correzioni dell'operatore diventano modifiche al workspace che l'agente legge al turno successivo.

NousResearch distribuisce un'implementazione di riferimento completa con TUI, gateway multi-canale, sistema di skill e hook per il training RL. Una variante minimale sopra il subprocess del Claude Code CLI mantiene le parti mobili abbastanza contenute da poter essere templata per ogni mandato di consulenza. I pattern seguenti si applicano ugualmente a entrambi gli approcci.

Pattern 1: Gestione Tipizzata della Risposta Vuota

Un trigger basato su parole chiave (che fa match con \bhermes\b nei messaggi di gruppo) può attivarsi su una frase che contiene il nome del bot ma non gli è indirizzata. L'LLM restituisce correttamente output vuoto. Tre layer a valle non gestiscono il caso vuoto:

  • L'engine restituisce "" con returncode 0.
  • La funzione di split restituisce [""] perché len("") <= max_chars corrisponde.
  • Il loop di invio chiama bot.send_message(chat_id, ""); Telegram risponde con Bad Request: message text is empty; un generico except Exception in cima all'handler inghiotte il traceback e invia l'errore all'utente.

Filtrare le stringhe vuote in un solo layer previene il crash ma produce uno skip silenzioso — il trigger è scattato, il bot ha consumato risorse di calcolo, l'utente non vede nulla. Il fix in due fasi usa un'eccezione tipizzata per l'output vuoto e una reaction Telegram (👀) sul messaggio scatenante come acknowledgment:

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

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

L'engine solleva HermesEmptyResponse quando result.strip() == "". L'handler la intercetta e chiama message.react([ReactionTypeEmoji(emoji="👀")]). Il log delle conversazioni riceve un blocco marker — una voce separata che registra l'acknowledgment silenzioso senza inquinare la chat con testo — così le future letture di memoria dell'agente vedono che un trigger è scattato ed è stato intenzionalmente ignorato.

Pattern 2: Pre-Flight dei Permessi di Reaction con Cache Lazy

Il setMessageReaction di Telegram non è universalmente disponibile. Alcuni gruppi limitano il set di reaction consentite; alcune emoji personalizzate richiedono l'inserimento in allowlist da parte di un amministratore. Il tipo ChatFullInfo documenta la regola: se available_reactions è omesso, tutte le emoji standard sono consentite; se è un array, funzionano solo quelle emoji. Il bot deve essere membro del gruppo — lo status di amministratore non è richiesto per le reaction nei gruppi.

Verificare ad ogni trigger spreca chiamate API. Una getChat per chat con cache di un'ora è sufficiente:

_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

Wrappa comunque la chiamata di reaction effettiva in try/except (TelegramBadRequest, TelegramForbiddenError) — la cache può ritardare rispetto ai cambiamenti dei permessi.

Pattern 3: Modalità Stream e il Watchdog sui Tempi di Inattività

Un timeout fisso sull'intero subprocess (asyncio.wait_for(proc.communicate(), timeout=300)) cap la durata totale indipendentemente dall'avanzamento. Rimuoverlo senza un sostituto è documentato come non sicuro: il problema di hang idle dello stream di Claude Code descrive chiamate API che si bloccano a metà stream e non tornano mai, lasciando un subprocess in leak.

Passare a --output-format stream-json --verbose --include-partial-messages emette eventi ad ogni milestone — text_delta per token, avvio e stop degli strumenti, retry API, avvisi di rate limit, l'evento finale result. Un vero blocco produce silenzio sullo stream; un task lungo produce una sequenza di piccoli eventi. Il watchdog termina per inattività, non per durata 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

Il testo della risposta finale proviene dal campo result dell'evento result — deterministico, da fonte unica e non influenzato dal parsing parziale dello stream. Lo stesso evento porta is_error, api_error_status, duration_ms e total_cost_usd, tutti inseriti nella riga di log strutturato.

Pattern 4: Calibrare lo Schedule di Rassicurazione

La domanda sulla soglia — quando il bot invia un aggiornamento testuale durante una chiamata lunga — è empirica. La risposta giusta dipende dalla distribuzione di latenza dei trigger reali. Tre soglie, con testo allineato a ciò che l'utente ha effettivamente bisogno di sapere:

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

Le soglie derivano da due vincoli. Il limite inferiore è fissato dalla latenza tipica delle risposte brevi: se la maggior parte delle risposte arriva entro X secondi, la prima rassicurazione deve scattare dopo X, altrimenti arriva all'incirca nello stesso momento della risposta. La ricerca sui tempi di risposta di Nielsen identifica i 10 secondi come limite canonico per mantenere l'attenzione dell'utente senza un indicatore di avanzamento; il typing indicator che ChatActionSender di aiogram mostra al di sotto di quella soglia soddisfa già il requisito fino a circa 15 secondi.

La soglia superiore (90 secondi) è il gap a cui l'inquadramento cambia da in lavorazione a in lavorazione ma più a lungo del solito — un segnale distinto che la chiamata è nella coda lunga della distribuzione. La formulazione evita di far intendere che l'utente abbia chiesto qualcosa di pesante. È il bot che sta lavorando; il messaggio riconosce quel lavoro, non la richiesta.

Pattern 5: Lock di Concorrenza per Chat

Due membri del gruppo possono attivare l'agente nello stesso secondo — uno con una @-menzione, uno con la parola chiave. Entrambe le invocazioni dell'handler avviano subprocess claude --continue contro lo stesso file di sessione persistente. Il lock-file di sessione non è rigoroso; le scritture concorrenti producono file session-jsonl troncati e turni persi.

Serializza per chat a livello di handler con un lock creato in modo lazy:

_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 creazione lazy è importante: un asyncio.Lock istanziato al momento dell'import del modulo si lega all'event loop corrente al momento dell'import, che potrebbe non essere il loop su cui l'handler gira dopo un riavvio. Rimandare l'istanziazione al primo invocazione all'interno di un loop attivo evita il disallineamento del binding. Per gruppi piccoli, il dizionario dei lock rimane contenuto; per fleet più grandi, aggiungi l'eviction LRU.

Pattern 6: Gerarchia delle Eccezioni e Ordine degli Except

Le classi di eccezione dell'engine formano un albero:

  • HermesError(RuntimeError) — qualsiasi problema con il subprocess
  • HermesEmptyResponse(HermesError) — esecuzione riuscita con risultato vuoto
  • HermesHangError(HermesError) — watchdog ha terminato il processo

L'except di Python fa match sulla prima clausola compatibile. Se except HermesError precede gli handler delle sottoclassi, intercetta HermesEmptyResponse e la indirizza al percorso di errore, bypassando il mini-ack. L'ordine sottoclasse-prima è obbligatorio:

try:
    response = await _run_hermes_with_ux(bot, message, prompt, ctx)
    ...
except HermesEmptyResponse:
    # percorso mini-ack
    ...
except HermesHangError as exc:
    # percorso retry-una-volta-poi-bail
    ...
except HermesError as exc:
    # exit-not-zero, api-error, ecc.
    ...
except Exception:
    # ultima risorsa
    ...

Aggiungilo a una checklist di code review. Riordinare i blocchi per ispezione visiva inverte l'intento.

Pattern 7: Drenare lo stderr in Parallelo

Fare streaming su stdout richiede la lettura delle righe man mano che arrivano: async for line in proc.stdout. Se anche stderr è nella pipe, il subprocess può riempire il suo buffer stderr mentre stdout è ancora in lettura. I buffer di pipe predefiniti su Linux sono circa 64 KB. Una volta riempito stderr, il subprocess si blocca in attesa che venga drenato, e il loop async-for-line non avanza più. Il watchdog alla fine lo termina dopo il periodo di inattività, ma il risultato è perso.

Drena stderr in parallelo dall'avvio del subprocess, poi fai await del task di drain dopo 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())

# ... stream-loop su 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()

Il Claude Code CLI emette poco stderr in modalità stream-json, quindi il failure mode è raro in pratica. Il fix è una riga in più.

Pattern 8: Disciplina di Modifica della Memoria

Un agente in stile hermes legge i propri log delle conversazioni come memoria. I messaggi di fallback per bug scritti in quel log diventano indistinguibili dal comportamento passato intenzionale alla prossima lettura. Il primo istinto è inserire marker di correzione ([CORRECTION: the previous entry was a bug]) affinché la prossima lettura di memoria veda il fix.

Verifica che il messaggio di fallback sia stato effettivamente loggato prima di modificarlo. Nel caso descritto, il blocco generico except Exception chiamava message.answer(...) per inviare l'errore all'utente ma non chiamava conversation_log.log_outgoing(...). Il messaggio di errore raggiunse Telegram ma non raggiunse mai il file di memoria dell'agente. Nessuna modifica retroattiva era necessaria.

Tratta il workspace dell'agente come spazio dell'agente. Prima di qualsiasi piano che preveda la modifica di file al suo interno, fai uno snapshot fresco dello stato — l'agente potrebbe aver riscritto il proprio CLAUDE.md o le proprie note dall'ultima lettura. La guida al context engineering di Anthropic descrive la memoria persistente come un artefatto tra sessioni, non un blocco note su cui l'operatore scarabocchia. Le skill specifiche per dominio restano più durature quando vivono accanto a note curate dall'agente piuttosto che in file modificati dall'operatore che l'agente impara a non fidarsi.

Note Operative

Persistenza dei bind-mount. I volumi montati come bind per il workspace e le credenziali OAuth di Claude sopravvivono a docker compose up -d --force-recreate finché i percorsi di mount non cambiano. Verificare prima di qualsiasi modifica al compose file.

Controllo di sicurezza pre-deploy. Fai grep degli ultimi cinque minuti di log cercando un claude_subprocess_start senza un claude_result_event corrispondente. Un subprocess in sospeso significa che un riavvio terminerà un'esecuzione in corso. Aspetta finché i log non sono puliti. Per scenari di failure più ampi, consulta il nostro writeup sul disaster recovery.

Riutilizzabilità dei pattern tra mandati. Lo stack completo — engine, handler, log delle conversazioni, file intake — si clona per un nuovo mandato cambiando due variabili d'ambiente (un nome progetto e un id istanza). Il token del bot, le credenziali OAuth, il workspace e l'allow-list si parametrizzano per progetto. Per la prospettiva operativa sulla gestione di molti assistenti per progetto in parallelo, consulta le operazioni in solitaria su larga scala.

Selezione dell'emoji per la reaction. L'emoji 👀 è nel set standard predefinito di Telegram e funziona nei gruppi dove available_reactions non è impostato. Se un gruppo è limitato a un sottoinsieme personalizzato, la cache lo riflette e il mini-ack viene silenziosamente saltato. Rendi l'emoji una costante di configurazione per deployment anziché un letterale hardcoded.

Hermes-Agent versus una build personalizzata minimale. Il framework di NousResearch include una TUI, sistema di slash-command, gateway multi-canale, hub di skill e integrazione per il training RL. Un wrapper minimale del Claude Code CLI produce la stessa forma conversazionale con circa un decimo delle parti mobili. Entrambi convergono sullo stesso insieme di problemi UX nelle chat di gruppo; i pattern di questo post si applicano a entrambi.

Quando Applicare Ciascun Pattern

I pattern non hanno la stessa urgenza. Applicali nell'ordine in cui li si incontra:

  • Pattern 1 (gestione risposta vuota) è obbligatorio non appena il bot viene aggiunto a un gruppo con rilevamento dei trigger da parola chiave.
  • Pattern 4 (schedule di rassicurazione) è obbligatorio dopo che la prima risposta breve arriva nello stesso momento del messaggio di rassicurazione.
  • Pattern 3 e 7 (modalità stream, drain dello stderr) sono obbligatori non appena i task a lunga durata iniziano a bloccarsi.
  • Pattern 5 (lock di concorrenza) è obbligatorio quando la prima troncatura del file di sessione compare nei log.
  • Pattern 2, 6 e 8 sono hardening di background — applicali durante il code review prima che si rompano in produzione.

Costruisci prima l'assistente specifico per progetto: la guida all'architettura base copre il container, OAuth, il workspace e il layout degli handler. Integra un piccolo team con la guida allo scaling per allow-list, configurazione del gruppo e rilevamento dei trigger. Aggiungi il canale email per il progetto con la guida DKIM/DMARC quando iniziano ad arrivare notifiche fuori banda. Torna a questo post quando i pattern sopra descritti sono necessari.

tva gestisce diversi assistenti per progetto in parallelo per mandati di consulenza differenti. Per ricevere aiuto nella costruzione o nell'ottimizzazione del tuo, contattaci.


Insight Correlati

Articoli correlati