@ :~/lab/hermes-irc-team-llm-eterogenei$
← cd ~/lab

./run hermes-irc-team-llm-eterogenei

Sei LLM su un canale IRC privato, e l'unico op è un umano

Diario tecnico: più LLM eterogenei, ognuno su una postazione isolata, coordinati su un canale IRC privato. Il coordinamento non è nel prompt, è in IRC: modi di canale, ChanServ e branch protection come enforcement fuori dal modello.

status
draft
project
hermes
updated
2026-07-07
tags
#ai agents#multi-agent#irc#hermes agent#distributed systems#llm orchestration
generate cover # alt: Diagramma di più agenti LLM isolati collegati a un canale IRC centrale, con un solo operatore umano che tiene l'op, e i repository Git come memoria persistente

L’idea sembrava una boutade: prendere cinque o sei modelli linguistici diversi — uno di OpenAI, uno di Anthropic, un paio di locali su vLLM — metterli ognuno sulla propria macchina isolata, e farli parlare su un canale IRC privato per lavorare insieme su codice vero. Non un multi-agent framework con un orchestratore che smista i turni. Un canale IRC, con la stessa netiquette che regge quei canali da trent’anni. E un’unica autorità: un umano con l’op.

La domanda che mi ha tenuto sveglio non era “funziona?”. Era: dove vive il coordinamento? La risposta che ho scelto — e che è la spina di tutto il progetto — è controintuitiva: non nel prompt.

La tesi: enforcement hard fuori dall’LLM

Costruito sopra hermes-agent di Nous Research (v0.14.0, Python 3.11 via uv), un agente always-on e model-agnostic con memoria curata. Ma Hermes è solo il cervello di un nodo. Il sistema è l’insieme, e l’insieme ruota attorno a due regole:

  1. Enforcement hard fuori dall’LLM. Tutto ciò che non deve dipendere dalla buona volontà del modello — i turni, il rate, chi può stare nel canale, l’accesso a main — è imposto da IRC, da Git o dalla CI. Mai dal prompt.
  2. Memoria di lavoro volatile su IRC, memoria persistente su Git. Il canale è effimero: annunci, “fatto”, “bloccato”. Il PLAN, lo stato, i report, il codice vivono su un GitLab self-hosted, versionati.

La conseguenza di design più forte: non ho costruito nessun chairman-LLM, nessuna logica di terminazione. Non c’è un modello-arbitro che decide quando la discussione finisce. Il giudice è umano. Gli agenti producono opzioni e si contestano; l’operatore taglia con una riga. Questo elimina in un colpo tutta la parte fragile dei sistemi multi-agente: quando ci si ferma, chi ha ragione, come si esce dai loop. Non la simulo con un altro LLM. La delego a una persona e a tre meccanismi fisici.

I tre meccanismi fisici

Modi di canale IRC. Sono enforcement, non decorazione. L’+o (op) è l’autorità di governance — l’umano. Il TOPIC è un puntatore stabile al PLAN corrente (PLAN: gitlab !42), che ogni agente rilegge a ogni JOIN. +m/+v sono floor control. KICK e mute sono il timeout per chi floodda. Nessuna di queste leve passa dal modello.

ChanServ RESTRICTED. Il canale è privato in senso duro: ci sei solo se hai un flag in access list, gli altri vengono bannati (474 Cannot join (+b)). Qui ho pagato subito un gotcha d’ordine: sotto RESTRICTED, il nick di un agente va prima registrato su NickServ, poi aggiunto all’access list, e solo dopo il suo gateway può fare JOIN. Invertire l’ordine = ban 474 e l’agente resta fuori. La sequenza per ogni nodo è diventata: bootstrap → grant dei flag da host fidato → gateway restart.

Branch protection su Git. Push diretto su main vietato dalla piattaforma. Ogni agente lavora nel suo branch agent/<nick>/<task>, idealmente in un git worktree separato, e integra solo via Merge Request. Gli agenti condividono esclusivamente via Git — mai un volume montato in comune. La concorrenza su Git è il 70% del lavoro reale, ed è trattata come un team umano ma con la disciplina imposta dalla piattaforma, non dal buonsenso.

Sopra questi tre strati “hard” c’è uno strato “soft”: il charter di canale, fuso col SOUL di ogni nodo. Regole sociali — brevità (max 3-4 righe, output lungo → apri un MR e linkalo), un messaggio = un fatto compiuto o una domanda necessaria, niente convenevoli, marca le assunzioni (un’allucinazione detta in canale diventa contesto per gli altri e si propaga). Il soft non sostituisce mai l’hard: un agente sotto contesto saturo regredisce, e allora l’op-bot lo mutta a prescindere da cosa “decide”.

Come si parla, davvero

Substrato: una rete IRC privata con Solanum + Atheme (lo stesso stack di Libera.Chat), TLS su 6697. Un dettaglio piccolo ma feroce: l’adapter IRC di Hermes non supporta SASL — zero match nel repo, nessuna CAP negotiation — quindi l’autenticazione è PASS + NickServ IDENTIFY dopo il 001. E l’indirizzamento non è @nick (convenzione Discord: IRC la ignora, e l’agente sembra muto in pubblico). Si parla a un agente mettendo il nick a inizio riga:

<nick>: stato del task?

Ho forkato l’adapter per aggiungere tre cose: un broadcast @all:/@tutti: a inizio riga che sveglia tutti gli agenti insieme; una modalità observe in cui i messaggi non indirizzati vengono ingeriti come contesto (l’agente “sente” tutto il canale senza rispondere); e un reconnect su ping-timeout che risolve il caso peggiore — processo vivo ma socket half-open, fuori da IRC senza EOF. Il recv-loop fa wait_for(read, IRC_PING_INTERVAL); se idle manda un PING keepalive, e senza PONG entro IRC_PING_TIMEOUT considera il socket morto e riconnette (backoff 30→300s, detection ~180s). Quarantaquattro test nell’adapter, tre round di review incrociata prima del merge.

La parte tosta: un output: null ha ucciso sei agenti insieme

Il primo giugno, tutti e sei gli agenti smettono di rispondere nello stesso momento. Log identici su ogni nodo: TypeError: 'NoneType' object is not iterable, Non-retryable. Il primo istinto — “i token OAuth sono scaduti” — era sbagliato: i token erano validi (200 su /models, JWT non scaduto). L’errore era a valle.

Il relay Codex a volte emette una response.completed con HTTP 200 il cui oggetto Response ha output: null (None, non []). L’SDK OpenAI fa for output in response.output senza guardia sul None → crash dentro l’SDK, prima che il codice applicativo possa gestirlo. Sei nodi con la stessa config e lo stesso backend = sei crash simultanei. Il fix, scoped solo alla firma di quel crash:

try:
    final_response = stream.get_final_response()
except TypeError as exc:
    err_text = str(exc)
    if not ("not iterable" in err_text or "NoneType" in err_text):
        raise  # ogni altro TypeError propaga invariato
    # relay ha restituito output null → retry, poi fallback non-streaming
    if attempt < max_stream_retries:
        continue
    return _run_codex_create_stream_fallback(...)

Poi ho aggiunto un keepalive anti-idle (un cron ogni 8h che fa una call reale e risponde pong), perché il problema secondario era che i gateway idle non esercitavano mai i token, e il refresh on-use non scattava. Ecco perché sembravano scaduti: effetto, non causa.

È esattamente il punto di “enforcement fuori dall’LLM” portato al livello dell’idraulico: sei modelli diversi, un unico strato di plumbing condiviso, e quando quello strato ha un bug muoiono tutti uguali. Il modello non c’entrava niente. La resilienza non poteva stare nel prompt.

Gotcha onesti

  • L’adapter è un fork. hermes update fa git stashgit reset --hardgit pull: su conflitto le patch si perdono. Le tengo come diff versionati e uno reapply-patches.sh idempotente che riapplica dopo ogni update e segnala il conflitto senza forzare. Regola: non lanciare hermes update alla cieca.
  • Il config del modello è un dict, non una stringa. hermes config set model gpt-5.5 scrive una stringa e rompe il routing (“No inference provider configured”). Va legato modello + provider insieme.
  • I probe di test si auto-bannano. Sotto RESTRICTED, testare da un host che condivide il cloak con un nick già in canale ri-triggera il ban. I probe vanno fatti da un host con cloak diverso.
  • README vs realtà. Il blueprint iniziale proponeva ergo + Eggdrop + una coda Redis per un MVP da zero — un adapter “padrone” del socket con l’agente che legge da BLPOP. Sul Mac, dove client IRC e cervello girano sulla stessa macchina, quell’apparato è cerimonia inutile: Hermes possiede già il socket via il suo adapter asyncio. Il webhook non serve; serve la non-bloccanza (le call di coding lunghe fuori dal thread che tiene il PING). Ho tenuto il bridge Redis documentato per i nodi distribuiti, ma su singola macchina non lo uso.

Come va

MVP / fase esplorativa — “tentare per capire se funziona”. Il substrato è in piedi, i nodi fanno JOIN, l’incidente del null-output è chiuso (621 test verdi post-fix). Il flusso, con harness veri dietro, non è una chat brillante: un agente riceve un task, lavora minuti in silenzio, poi riporta. Somiglia a uno stand-up asincrono. Lo stupore, se arriva, non è nelle battute — è nei MR e nel modo in cui modelli con punti ciechi diversi si correggono a vicenda.

Cosa ho imparato

Che il valore di un sistema multi-agente non è “più LLM = meglio” (dubbio in letteratura), ma la diversità eterogenea: architetture diverse che fanno cross-check reale. E che per lasciarla emergere devi togliere ogni tentazione di mettere l’intelligenza nel coordinamento. Il coordinamento migliore è quello che non ragiona: un modo di canale, un flag ChanServ, una branch protection. Trent’anni fa qualcuno aveva già progettato un protocollo di controllo di flusso sociale robusto agli attori inaffidabili — si chiamava galateo IRC. Non l’ho reinventato. L’ho ereditato, e ho lasciato che l’unico op fosse una persona.

Costruito su hermes-agent di Nous Research.