← cd ~/lab

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

Six LLMs on a private IRC channel, and the only op is a human

Technical diary: several heterogeneous LLMs, each on an isolated box, coordinated over a private IRC channel. Coordination isn't in the prompt, it's in IRC: modes…

status
poc
project
hermes
updated
2026-07-08
tags
#ai-agents#multi-agent#irc#hermes-agent#distributed-systems#llm-orchestration
Diagram of several isolated LLM agents connected to a central IRC channel, with a single human operator holding op, and Git repositories as persistent memory

The idea sounded like a joke: take five or six different language models — one from OpenAI, one from Anthropic, a couple of local ones on vLLM — put each on its own isolated machine, and have them talk on a private IRC channel to work together on real code. Not a multi-agent framework with an orchestrator dispatching turns. An IRC channel, with the same netiquette that’s held those channels together for thirty years. And a single authority: a human with op.

The question that kept me up at night wasn’t “does it work?”. It was: where does coordination live? The answer I chose — and it’s the backbone of the whole project — is counterintuitive: not in the prompt.

The thesis: hard enforcement outside the LLM

Built on top of hermes-agent by Nous Research (v0.14.0, Python 3.11 via uv), an always-on, model-agnostic agent with curated memory. But Hermes is only the brain of a node. The system is the whole, and the whole revolves around two rules:

  1. Hard enforcement outside the LLM. Everything that shouldn’t depend on the model’s goodwill — turns, rate, who’s allowed in the channel, access to main — is imposed by IRC, by Git, or by CI. Never by the prompt.
  2. Volatile working memory on IRC, persistent memory on Git. The channel is ephemeral: announcements, “done”, “blocked”. The PLAN, the state, the reports, the code live on a self-hosted GitLab, versioned.

The strongest design consequence: I built no chairman-LLM, no termination logic. There’s no arbiter model deciding when the discussion ends. The judge is human. The agents produce options and contest each other; the operator cuts through with one line. This eliminates in one stroke all the fragile part of multi-agent systems: when to stop, who’s right, how to break out of loops. I don’t simulate it with another LLM. I delegate it to a person and to three physical mechanisms.

The three physical mechanisms

IRC channel modes. They’re enforcement, not decoration. +o (op) is the governance authority — the human. The TOPIC is a stable pointer to the current PLAN (PLAN: gitlab !42), which every agent re-reads on every JOIN. +m/+v are floor control. KICK and mute are the timeout for whoever floods. None of these levers pass through the model.

ChanServ RESTRICTED. The channel is private in the hard sense: you’re in only if you have a flag in the access list, the rest get banned (474 Cannot join (+b)). Here I paid an ordering gotcha right away: under RESTRICTED, an agent’s nick must be registered on NickServ first, then added to the access list, and only after that can its gateway JOIN. Invert the order = ban 474 and the agent stays out. The sequence for each node became: bootstrap → grant the flags from a trusted host → gateway restart.

Branch protection on Git. Direct push to main outside the pact (the token has write scope: hard-enforcement via branch protection is in the plan, today it’s convention + workflow). Every agent works in its own branch agent/<nick>/<task>, ideally in a separate git worktree, and integrates only via Merge Request. The agents share exclusively via Git — never a shared mounted volume. Concurrency on Git is 70% of the real work, and it’s handled like a human team but with the discipline of the workflow (dedicated branches, Merge Request only), not the model’s common sense.

Above these three “hard” layers there’s a “soft” layer: the channel charter, fused with each node’s SOUL. Social rules — brevity (max 3-4 lines, long output → open an MR and link it), one message = one completed fact or one necessary question, no pleasantries, mark your assumptions (a hallucination stated in-channel becomes context for the others and propagates). The soft never replaces the hard: an agent under saturated context regresses, and then the op-bot mutes it regardless of what it “decides”.

How they actually talk

Substrate: a private channel (#llm-council, closed via ChanServ RESTRICTED) on a public IRC network with Solanum + Atheme, TLS on 6697. A small but ferocious detail: Hermes’s IRC adapter doesn’t support SASL — zero matches in the repo, no CAP negotiation — so authentication is PASS + NickServ IDENTIFY after 001. And addressing is not @nick (Discord convention: IRC ignores it, and the agent looks mute in public). You speak to an agent by putting the nick at the start of the line:

<nick>: task status?

I forked the adapter to add three things: a broadcast @all:/@tutti: at the start of a line that wakes all agents at once; an observe mode where unaddressed messages are ingested as context (the agent “hears” the whole channel without replying); and a reconnect on ping-timeout that solves the worst case — process alive but socket half-open, out of IRC without an EOF. The recv-loop does wait_for(read, IRC_PING_INTERVAL); if idle it sends a keepalive PING, and without a PONG within IRC_PING_TIMEOUT it considers the socket dead and reconnects (backoff 30→300s, detection ~180s). Forty-four tests in the adapter, three rounds of cross-review before the merge.

The tough part: one output: null killed six agents at once

On June first, all six agents stopped responding at the same moment. Identical logs on every node: TypeError: 'NoneType' object is not iterable, Non-retryable. The first instinct — “the OAuth tokens expired” — was wrong: the tokens were valid (200 on /models, JWT not expired). The error was downstream.

The Codex relay sometimes emits a response.completed with HTTP 200 whose Response object has output: null (None, not []). The OpenAI SDK does for output in response.output with no guard on None → crash inside the SDK, before application code can handle it. Six nodes with the same config and the same backend = six simultaneous crashes. The fix, scoped only to that crash’s signature:

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(...)

Then I added an anti-idle keepalive (a cron every 8h that makes a real call and answers pong), because the secondary problem was that idle gateways never exercised the tokens, and on-use refresh never fired. That’s why they looked expired: effect, not cause.

It’s exactly the point of “enforcement outside the LLM” taken down to the plumbing level: six nodes, a single shared plumbing layer, and when that layer has a bug they all die the same way (they all run the same config: it’s the proof that today the system is homogeneous). The model had nothing to do with it. Resilience couldn’t live in the prompt.

Honest gotchas

  • The adapter is a fork. hermes update does git stashgit reset --hardgit pull: on conflict the patches are lost. I keep them as versioned diffs and an idempotent reapply-patches.sh that reapplies after every update and reports the conflict without forcing. Rule: don’t run hermes update blindly.
  • The model config is a dict, not a string. hermes config set model gpt-5.5 writes a string and breaks routing (“No inference provider configured”). Model + provider have to be tied together.
  • The test probes self-ban. Under RESTRICTED, testing from a host that shares the cloak with a nick already in the channel re-triggers the ban. Probes must be done from a host with a different cloak.
  • README vs reality. The initial blueprint proposed ergo + Eggdrop + a Redis queue for a from-scratch MVP — an adapter “owning” the socket with the agent reading from BLPOP. On the Mac, where the IRC client and the brain run on the same machine, that apparatus is pointless ceremony: Hermes already owns the socket via its asyncio adapter. The webhook isn’t needed; what’s needed is non-blocking (the long coding calls off the thread that holds the PING). I kept the Redis bridge documented for distributed nodes, but on a single machine I don’t use it.

How it’s going

MVP / exploratory phase — “trying it to find out if it works”. The substrate is up, the nodes JOIN, the null-output incident is closed (621 tests green post-fix). The flow, with real harnesses behind it, is not a brilliant chat: an agent gets a task, works for minutes in silence, then reports. It resembles an asynchronous stand-up. Diary honesty: today the six nodes run the same model (gpt-5.5, with an Anthropic auxiliary for memory/judgment). True heterogeneity — different models and architectures, each with its own blind spot — is still the blueprint, not the deployed thing. And that’s where the value I’m after lies: the wonder isn’t in the banter, but in the way different models would correct each other.

What I learned

That the value of a multi-agent system isn’t “more LLMs = better” (dubious in the literature), but heterogeneous diversity: different architectures doing real cross-checking. And that to let it emerge you have to strip out every temptation to put the intelligence into the coordination. The best coordination is the one that doesn’t reason: a channel mode, a ChanServ flag, a branch protection. Thirty years ago someone had already designed a flow-control protocol robust to unreliable actors — it was called IRC etiquette. I didn’t reinvent it. I inherited it, and I let the single op be a person.

Built on hermes-agent by Nous Research.