Compare commits

...
Author SHA1 Message Date
william a2c00f6f8b Fix claude-bot replying to Hermes's own messages, route Hermes through LiteLLM
Critical bug: claude-bot only ignored its OWN messages and text explicitly
addressed to @hermes — it did not ignore Hermes's own replies appearing in
the room. A single @hermes mention cascaded into claude-bot replying to
Hermes's thread messages ('Hermes says: ...'), which could itself cascade
further. Fixed with an OTHER_AGENT_USER_IDS allowlist of sender IDs to
always ignore, not just a text-prefix check.

Also point Hermes's model provider at the local litellm gateway
(OPENAI_BASE_URL/OPENAI_API_KEY) instead of OpenRouter directly, matching
claude-agent's own chat path — one place to hold the OpenRouter credential.
This does NOT grant Hermes access to the Claude subscription; that's an
Anthropic-side restriction unrelated to which proxy sits in front of it,
already proven earlier in this session.

Separately (not a code fix): the 'Billing or credits exhausted: HTTP 402'
error Hermes hit is real — the OpenRouter account currently has 0 credits.
2026-08-23 16:02:15 +00:00
william c88fdcc2ea Merge pull request 'Fix Hermes: gateway command + disable network-reachable API server' (#11) from fix/hermes-gateway-command-and-api-server into main
Reviewed-on: #11
2026-08-23 15:52:35 +00:00
william 98762e764a Fix Hermes container: add gateway command, disable network-reachable API server
Without an explicit command the image launches the interactive CLI by
default, which immediately exits with 'Input is not a terminal' in a
detached container — it was doing nothing on every restart. Also disables
API_SERVER_ENABLED: Hermes itself warns at startup that a network-reachable
API server combined with the default unsandboxed 'local' terminal backend
gives any caller on the network full terminal/file access. Not needed yet
(Matrix is the actual interface) — can re-enable properly (with a sandboxed
terminal backend) if claude-agent ever needs to call Hermes programmatically.
2026-08-23 15:52:08 +00:00
william 5507192ee6 Merge pull request 'Add Hermes Agent as a second native Matrix presence' (#10) from feat/hermes-matrix into main
build-agent / build-and-push (push) Successful in 11s
Reviewed-on: #10
2026-08-23 15:49:05 +00:00
william 9fa025f7d5 Add Hermes Agent as a second native Matrix presence
Deployed as its own service (pinned nousresearch/hermes-agent:v2026.8.19),
own Matrix bot account (@hermes), own OpenRouter-backed model config, and
its own OpenAI-compatible API server (internal network only, for possible
future use by claude-agent). Joins the same control room but only responds
when explicitly @mentioned, restricted to the human user — no conflict with
claude-bot's default no-prefix chat routing. claude-agent's router now
ignores messages addressed to @hermes so both bots don't answer the same
message.

Bridge networking (the 'web' network), not the image's default host mode —
no reason for an agent container to share the host's network namespace when
everything it needs (the homeserver, OpenRouter) is reachable over the
existing bridge.
2026-08-23 15:46:58 +00:00
william 5461754f23 Merge pull request 'Route chat through local LiteLLM gateway, drop !claude/!ai prefixes' (#8) from feat/litellm-router into main
build-agent / build-and-push (push) Successful in 7s
Reviewed-on: #8
2026-08-23 15:34:29 +00:00
3 changed files with 73 additions and 3 deletions
+15
View File
@@ -30,6 +30,10 @@ MATRIX_CONTROL_ROOM_ID=
# Required: without it the bot can't tell its own messages apart from real ones and would # Required: without it the bot can't tell its own messages apart from real ones and would
# reply to itself in a loop, so it refuses to start. # reply to itself in a loop, so it refuses to start.
MATRIX_BOT_USER_ID= MATRIX_BOT_USER_ID=
# Comma-separated Matrix IDs of OTHER agents sharing the control room (currently just
# @hermes:...) — without this, claude-bot treats every message another bot posts as
# fresh chat input and replies to it, which that bot may then react to in turn.
OTHER_AGENT_USER_IDS=@hermes:matrix.apps.williamturner.eu
# Comma-separated "owner/repo" list the chat router is allowed to open code-change PRs # Comma-separated "owner/repo" list the chat router is allowed to open code-change PRs
# against. A plain chat message mentioning a repo NOT in this list is treated as chat, # against. A plain chat message mentioning a repo NOT in this list is treated as chat,
# never as a code task — the router only matches confidently against known repos. # never as a code task — the router only matches confidently against known repos.
@@ -40,6 +44,17 @@ OPENROUTER_API_KEY=
# Any random string; also used as litellm's general_settings.master_key. # Any random string; also used as litellm's general_settings.master_key.
LITELLM_MASTER_KEY= LITELLM_MASTER_KEY=
# --- hermes (autonomous agent with its own native Matrix presence) ---
# Your own Matrix ID — Hermes only responds to this user, and only when @mentioned
# in a shared room (e.g. "@hermes <task>" in the control room).
MATRIX_HUMAN_USER_ID=@william:matrix.apps.williamturner.eu
# Access token for the @hermes bot account (register it the same way as claude-bot —
# see README — then log in as it via /_matrix/client/v3/login to get this token).
HERMES_MATRIX_ACCESS_TOKEN=
# Any random string — bearer key for Hermes's own OpenAI-compatible API server
# (internal network only, not published anywhere).
HERMES_API_SERVER_KEY=
# --- portainer (GitOps redeploy) --- # --- portainer (GitOps redeploy) ---
PORTAINER_STACK_WEBHOOK_URL= PORTAINER_STACK_WEBHOOK_URL=
+15
View File
@@ -11,6 +11,15 @@ const GITEA_URL = process.env.GITEA_URL;
// to the endpoint itself. This is also the only reliable way to filter the bot's own // to the endpoint itself. This is also the only reliable way to filter the bot's own
// messages now that there's no command prefix to naturally exclude them by. // messages now that there's no command prefix to naturally exclude them by.
const BOT_USER_ID = process.env.MATRIX_BOT_USER_ID; const BOT_USER_ID = process.env.MATRIX_BOT_USER_ID;
// Other agents sharing this room (currently just Hermes) — their own messages must be
// ignored the same way claude-bot ignores its own, or claude-bot's classifier treats
// every message another bot posts as fresh chat input and replies to it, which that
// bot may then react to in turn. Found the hard way: a single @hermes mention cascaded
// into claude-bot replying to Hermes's own thread messages ("Hermes says: ...").
const OTHER_AGENT_USER_IDS = (process.env.OTHER_AGENT_USER_IDS || "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
const KNOWN_REPOS = (process.env.KNOWN_REPOS || "") const KNOWN_REPOS = (process.env.KNOWN_REPOS || "")
.split(",") .split(",")
.map((r) => r.trim()) .map((r) => r.trim())
@@ -47,8 +56,13 @@ export async function startMatrixBot() {
client.on("room.message", async (roomId, event) => { client.on("room.message", async (roomId, event) => {
if (roomId !== CONTROL_ROOM_ID) return; if (roomId !== CONTROL_ROOM_ID) return;
if (event.sender === BOT_USER_ID) return; if (event.sender === BOT_USER_ID) return;
if (OTHER_AGENT_USER_IDS.includes(event.sender)) return;
const body = event.content?.body; const body = event.content?.body;
if (!body) return; if (!body) return;
// Messages explicitly addressed to another agent in this room (currently just
// @hermes) are that agent's to answer — without this, claude-bot's classifier would
// also see and reply to them, since it otherwise treats every message as its own.
if (/^@hermes\b/i.test(body.trim())) return;
try { try {
const decision = await routeMessage(body, KNOWN_REPOS); const decision = await routeMessage(body, KNOWN_REPOS);
@@ -63,6 +77,7 @@ export async function startMatrixBot() {
} }
const reply = await chatReply(body); const reply = await chatReply(body);
console.log("chat reply sent, length:", reply.length);
await client.sendText(roomId, truncate(reply)); await client.sendText(roomId, truncate(reply));
} catch (err) { } catch (err) {
console.error("message handling failed", err); console.error("message handling failed", err);
+43 -3
View File
@@ -61,6 +61,46 @@ services:
# Internal only — no Traefik labels. No reason to expose an LLM gateway holding a # Internal only — no Traefik labels. No reason to expose an LLM gateway holding a
# master key and OAuth-forwarding config to the public internet. # master key and OAuth-forwarding config to the public internet.
hermes:
# Pinned to a specific dated release, not :latest — same rationale as litellm above.
image: nousresearch/hermes-agent:v2026.8.19
container_name: hermes
restart: unless-stopped
environment:
HERMES_UID: "1000"
HERMES_GID: "1000"
# Internal container address, not the public HTTPS one — same docker network as
# matrix-homeserver, no reason to round-trip through Traefik/TLS for this.
MATRIX_HOMESERVER: http://matrix-homeserver:8008
MATRIX_ACCESS_TOKEN: ${HERMES_MATRIX_ACCESS_TOKEN}
# Only you can trigger it; and only with an explicit @hermes mention in shared
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
MATRIX_REQUIRE_MENTION: "true"
# Routed through the local litellm gateway, not OpenRouter directly — same pattern
# as claude-agent's chat path, one place to hold the OpenRouter credential and swap
# models. Hermes's "main"/custom-endpoint provider is any OpenAI-compatible API
# reachable via OPENAI_BASE_URL + OPENAI_API_KEY. Note: this does NOT give Hermes
# access to the Claude Pro/Max subscription — that's blocked by Anthropic itself for
# any caller other than the real Claude Code CLI, proven earlier in this session
# (reproduced with plain curl straight to api.anthropic.com, LiteLLM or not).
OPENAI_BASE_URL: http://litellm:4000/v1
OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
# Left disabled: Hermes itself warns that a network-reachable API server combined
# with the default unsandboxed ('local') terminal backend gives any caller full
# terminal/file access within the container. Matrix is the actual interface in use;
# re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if
# claude-agent ever needs to call Hermes programmatically.
API_SERVER_ENABLED: "false"
volumes:
- /home/william/hermes-data:/opt/data
networks:
- web
# Without this the image's default command launches the interactive CLI, which
# immediately exits ("Input is not a terminal") since a detached container has no
# stdin — the container then just sits there having done nothing, every restart.
command: ["gateway", "run"]
claude-agent: claude-agent:
image: ${GITEA_REGISTRY_IMAGE} image: ${GITEA_REGISTRY_IMAGE}
depends_on: depends_on:
@@ -81,10 +121,10 @@ services:
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN} MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID} MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID} MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID}
OTHER_AGENT_USER_IDS: ${OTHER_AGENT_USER_IDS}
KNOWN_REPOS: ${KNOWN_REPOS} KNOWN_REPOS: ${KNOWN_REPOS}
# All model calls now go through the local litellm service, not OpenRouter directly — # Chat replies go through the local litellm service (OpenRouter's models, incl. its
# one gateway for OpenRouter's models (incl. its auto-router) and, for the # auto-router), not OpenRouter directly.
# claude-subscription route, Anthropic itself via the forwarded OAuth token above.
LITELLM_BASE_URL: http://litellm:4000 LITELLM_BASE_URL: http://litellm:4000
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY} LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
volumes: volumes: