Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
*.pyo
*.pyd
.Pythonservices/custom_ui_guardrail/data/
*.whl
20 changes: 20 additions & 0 deletions services/hermes_platform_gateway/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ DOMYN_CHANNEL_ID=
# Canvas tool-list poll interval in seconds. Set to 0 to disable. Default 60.
# PLATFORM_TOOL_REFRESH_INTERVAL=60

# --- Authorization (required for gateway mode) -------------------------------
# In gateway mode hermes runs its own per-user authorization layer on top of
# the platform. Domyn already authenticates callers at the relay/API layer,
# so we typically delegate fully and accept any author arriving on the
# subscribed channel. Without this, unknown users get a pairing prompt:
# "Hi~ I don't recognize you yet! Here's your pairing code: …"
DOMYN_ALLOW_ALL_USERS=true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this ties back to the comments I made here.
https://github.com/igeniusai/domyn-agents/pull/387/changes
But also about this, do we really want the developer to set this? What is the expected journey for the dev?


# Optional alternative: a comma-separated allowlist of Domyn author IDs
# (leave DOMYN_ALLOW_ALL_USERS unset if you use this).
# DOMYN_ALLOWED_USERS=user-id-1,user-id-2

# --- Home channel (recommended) ----------------------------------------------
# Without this, gateway-mode hermes posts a one-time
# "📬 No home channel is set for Domyn. Type /sethome …"
# notice into the first conversation it sees. The Domyn worker is already
# bound to one channel, so we just point the home target at it — cron
# results and broadcasts go back to the same channel.
DOMYN_HOME_CHANNEL=${DOMYN_CHANNEL_ID}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we move this things as defaults in the plugin instead of allowing the user to mess up with them?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait how can we default domyn-channel-id?


# --- LLM provider (required by hermes itself) --------------------------------
# Hermes needs at least one model provider. Defaults below target the
# vLLM gateway used by the example agents in `custom_samples`; swap for
Expand Down
61 changes: 33 additions & 28 deletions services/hermes_platform_gateway/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,32 +26,37 @@ RUN pip install --no-cache-dir \
# 3) domyn-agents — required by the gateway plugin for relay event models.
# Bundled as a wheel under ./wheels so the build doesn't need the private
# igenius PyPI index.
# `langchain-core` is pulled in because the `domyn` CLI eagerly imports the
# langgraph integration (needed by step 4's `domyn install-plugin` call).
COPY wheels/ wheels/
RUN pip install --no-cache-dir wheels/domyn_agents-*.whl

# 4) hermes-platform-gateway plugin. Two installs are needed:
# - The pip package goes into site-packages so the plugin's
# `from hermes_platform_gateway.client import …` imports resolve.
# - The manifest + __init__.py are also dropped under $HERMES_HOME/plugins
# so hermes' discovery sees a `plugin.yaml` and calls `register(ctx)`.
COPY plugins/hermes-platform-gateway/ /opt/hermes-platform-gateway/
RUN pip install --no-cache-dir /opt/hermes-platform-gateway && \
mkdir -p ${HERMES_HOME}/plugins/hermes_platform_gateway && \
cp /opt/hermes-platform-gateway/hermes_platform_gateway/*.py \
${HERMES_HOME}/plugins/hermes_platform_gateway/ && \
cp /opt/hermes-platform-gateway/plugin.yaml \
${HERMES_HOME}/plugins/hermes_platform_gateway/

# 5) hermes config:
# - point hermes' default model at the vLLM gateway (api_key + base_url +
# model come from env vars at startup via hermes' ${VAR} expansion);
# - opt-in the platform-gateway plugin — standalone-kind plugins are
# skipped unless explicitly listed under `plugins.enabled`.
COPY hermes-config.yaml ${HERMES_HOME}/config.yaml

# 6) Run hermes as a relay-attached worker. Stdin is closed in containers,
# so we drop into `hermes chat` non-interactively — the plugin's
# `on_session_start` hook will still fire, the WS subscriber stays open
# in a daemon thread, and AGENT_START events injected from the platform
# drive the conversation.
CMD ["hermes", "chat"]
RUN pip install --no-cache-dir wheels/domyn_agents-*.whl langchain-core
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does hermes require langchain? I am surprised

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's because of a dependency in domyn expose, i was planning to remove it after the domyn expose PR from gabriele was merged, to avoid useless merge conflicts


# 4) hermes-platform-gateway plugin.
# `domyn install-plugin` drops the vendored plugin source plus its
# `plugin.yaml` manifest directly into ${HERMES_HOME}/plugins/hermes_platform_gateway/,
# which is what hermes' on-disk plugin scan picks up at startup. No pip
# install or manual cp is needed — the plugin uses relative imports and
# `domyn-agents` is already on sys.path from step 3.
RUN domyn install-plugin --framework hermes

# 5) i18n catalog — upstream hermes' pip install does NOT package its
# locales/ directory, so every t() call falls back to the raw key
# (you'd see "gateway.approve.session_singular" rendered verbatim in
# chat replies to /approve). We drop our own minimal en.yaml next to
# the installed agent/ package so agent.i18n._locales_dir() finds it.
COPY locales/en.yaml /usr/local/lib/python3.11/site-packages/locales/en.yaml

# 6) hermes config template + entrypoint that materialises it.
# gateway mode reads ~/.hermes/config.yaml via read_raw_config() which
# does NOT expand ${VAR} references — so we resolve them at container
# start with entrypoint.sh and write the result into $HERMES_HOME.
COPY hermes-config.yaml /opt/hermes-config.template.yaml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

# 7) Run hermes in gateway mode. The gateway is the headless, multi-session
# runner — it doesn't open a TUI and doesn't need a TTY. AGENT_START
# events for any conversation_id arriving on the subscribed channel are
# routed by the gateway to a per-conversation hermes session.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["hermes", "gateway"]
12 changes: 10 additions & 2 deletions services/hermes_platform_gateway/Makefile
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
.PHONY: help build up down logs shell clean
.PHONY: help build build-wheel up down logs shell clean

help:
@echo "Targets:"
@echo " build-wheel Build the wheel file for domyn-agents"
@echo " build Build the docker image"
@echo " up docker compose up -d"
@echo " down docker compose down"
@echo " logs Tail container logs"
@echo " shell Exec a bash shell into the running container"
@echo " clean docker compose down -v --rmi local"

build:

DOMYN_AGENT_PATH ?=../../../domyn-agents
build-wheel:
rm $(DOMYN_AGENT_PATH)/dist/*.whl
$(MAKE) -C $(DOMYN_AGENT_PATH) build-wheel
cp $(DOMYN_AGENT_PATH)/dist/*.whl ./wheels/

build: build-wheel
docker compose build

up:
Expand Down
21 changes: 10 additions & 11 deletions services/hermes_platform_gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@ hermes_platform_gateway/

## How it fits together

1. The Dockerfile installs hermes-agent from git, the `domyn-agents` wheel, then `pip install /opt/hermes-platform-gateway` so `hermes_platform_gateway` is importable.
1. The Dockerfile installs hermes-agent, the `domyn-agents` wheel, then `pip install /opt/hermes-platform-gateway` so `hermes_platform_gateway` is importable.
2. The same plugin folder is *also* copied under `$HERMES_HOME/plugins/hermes_platform_gateway/` so hermes' plugin loader picks up `plugin.yaml` and calls `register(ctx)`.
3. `config.yaml` opt-ins the plugin under `plugins.enabled` — standalone-kind plugins are skipped otherwise.
4. On startup, `register(ctx)`:
- POSTs `https://api.<DOMYN_BASE_URL>/api/agents-service/tool/list_delegate_tools_for_channel` to discover canvas tools (uses the `api.` subdomain — the same shape `domyn expose` uses).
- Registers each tool into hermes' `platform` toolset with a sync handler that forwards calls over the relay WebSocket.
- Opens `wss://<DOMYN_BASE_URL>/relay/v1/ws` with `api-key` / `space-id` / `channel-id` headers, and runs a background daemon thread that:
- injects platform `AGENT_START` events into hermes via `ctx.inject_message`,
- streams tokens back as `RESPONSE(is_partial=True)` events,
- sends `AGENT_END` after each LLM turn,
- resolves outstanding `TOOL_END`/`TOOL_ERROR` to the matching `concurrent.futures.Future` by `call_id`.
3. `config.yaml` opt-ins the plugin under `plugins.enabled`.
4. The container runs `hermes gateway` — the headless, multi-session runner. No TUI, no TTY required.
5. On startup, `register(ctx)`:
- POSTs `https://api.<DOMYN_BASE_URL>/api/agents-service/tool/list_delegate_tools_for_channel` to discover canvas tools.
- Registers each tool into hermes' `platform` toolset with an async handler that forwards calls over the relay WebSocket, correlated to the active conversation.
- Registers a `domyn` platform adapter via `ctx.register_platform`. The adapter owns the WebSocket and translates `AGENT_START` events into hermes `MessageEvent`s with `chat_id = conversation_id`.
- Registers an `on_session_start` hook that links hermes' `session_id` to the adapter's `session_key`, so tool handlers can find the right turn correlation IDs.
6. The hermes gateway maintains one `AIAgent` per Domyn `conversation_id` (LRU-cached), with per-session SQLite-backed history. Different conversations run concurrently; same-conversation messages stay serialised.

## Prerequisites

Expand Down Expand Up @@ -96,7 +95,7 @@ make build && make up
|---|---|
| Container logs: `4401 Unauthorized` on WS connect | `DOMYN_API_KEY` lacks worker-role scope on this channel. Regenerate it from the platform with worker permissions. |
| Logs say `registered 0 platform tool(s)` | Canvas has no tools attached, or `DOMYN_CHANNEL_ID` points at the wrong channel. Verify with `curl https://api.<DOMYN_BASE_URL>/api/agents-service/tool/list_delegate_tools_for_channel -H 'api-key: …' -d '{"space_id":"…","channel_id":"…","configuration_id":null}'`. |
| Two workers responding to the same message | Multiple containers/processes are subscribed to the same `channel-id`. Only one worker should subscribe per channel. |
| Two workers responding to the same message | Multiple containers/processes are subscribed to the same `channel-id`. Only one worker per channel. Multi-conversation works *within* one worker — not by running more workers on the same channel. |
| Discovery returns tools but WS reconnects in a tight loop | Same as above — relay kicks each subscriber off when another connects. |

## Stopping
Expand Down
57 changes: 57 additions & 0 deletions services/hermes_platform_gateway/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/bin/sh
# hermes-platform-gateway entrypoint.
#
# hermes' gateway-mode config loader (gateway/run.py:_load_gateway_config →
# hermes_cli/config.read_raw_config) reads ~/.hermes/config.yaml RAW —
# without expanding ${VAR} references the way load_config() does. So we
# materialise the resolved config here before hermes starts.
#
# The image ships a template at /opt/hermes-config.template.yaml; we
# substitute env vars and write the result to $HERMES_HOME/config.yaml on
# every boot. Unconditional overwrite is intentional: this deployment
# treats hermes' config as ephemeral (Domyn is the source of truth for
# everything user-mutable).

set -e

: "${HERMES_HOME:=/root/.hermes}"
TEMPLATE=/opt/hermes-config.template.yaml
TARGET="$HERMES_HOME/config.yaml"

mkdir -p "$HERMES_HOME"

# Re-sync the platform-gateway plugin from the vendored source on every boot.
# $HERMES_HOME is a docker-compose named volume that persists across rebuilds,
# so anything written into it at image-build time gets shadowed by the volume's
# captured contents on subsequent runs. Re-running `domyn install-plugin --force`
# pulls the current vendored source out of the domyn-agents wheel and
# overwrites the plugin directory inside the volume, keeping it in sync with
# the image without requiring a `docker compose down -v`.
domyn install-plugin --framework hermes --force

python3 - <<'PY'
import os
import re
import sys

template_path = "/opt/hermes-config.template.yaml"
target_path = os.path.join(os.environ.get("HERMES_HOME", "/root/.hermes"), "config.yaml")

with open(template_path, encoding="utf-8") as f:
text = f.read()

def _sub(match):
name = match.group(1)
val = os.environ.get(name)
if val is None:
print(f"warning: ${{{name}}} is not set in the environment", file=sys.stderr)
return match.group(0)
return val

resolved = re.sub(r"\$\{([A-Z0-9_]+)\}", _sub, text)

with open(target_path, "w", encoding="utf-8") as f:
f.write(resolved)
PY

exec "$@"
35 changes: 34 additions & 1 deletion services/hermes_platform_gateway/hermes-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,37 @@ model:

plugins:
enabled:
- hermes_platform_gateway
- hermes-platform-gateway

agent:
# The Domyn canvas is the source of truth for tools on this worker. Drop
# hermes' built-in web + browser toolsets so:
# 1. They don't shadow-conflict with canvas tools of the same name
# (e.g. canvas `web_search` vs built-in `web_search`).
# 2. The Chromium-not-installed warning at startup goes away.
# Anything the canvas wants to expose still arrives via the platform-gateway
# plugin's tool registration.
disabled_toolsets:
- web
- browser
# Stop hermes from injecting periodic "⏳ Still working…" progress
# messages into the chat. Default 180s; set 0 to disable. Without this
# users see "Still working..." every 3 minutes during long turns.
gateway_notify_interval: 0
reasoning_effort: "medium" # empty = medium (default). Options: none, minimal, low, medium, high, xhigh (max)



display:
# How hermes handles a new message that arrives during an active turn.
# Default "interrupt" sends a visible "⚡ Interrupting current task…"
# status message — disruptive in a chat UI. "queue" silently FIFO-s
# the new message behind the current one; nothing visible until the
# current turn completes. "steer" injects as additional context.
busy_input_mode: queue
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this will conflict with stopping executions in flight, but we'll make some tests after merging this and rebasing the stop stuff

# Mid-turn narrative ("Ti aiuto a creare uno script…") is enabled.
# The adapter routes these to LLM_END events (not AGENT_END), so
# apps that treat AGENT_END as the turn-terminator stay correct —
# only the final response and slash-command replies emit AGENT_END.
# See ``DomynPlatformAdapter.send`` for the routing logic.
interim_assistant_messages: true
55 changes: 55 additions & 0 deletions services/hermes_platform_gateway/locales/en.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Minimal i18n catalog for the Domyn worker.
#
# Hermes' ``pip install`` from git doesn't package the ``locales/``
# directory shipped in the repo, so the in-container i18n catalog is
# missing and every ``t()`` call falls back to the raw key (you'd see
# things like ``gateway.approve.session_singular`` rendered verbatim in
# chat). We bake this file in via the Dockerfile to backfill the keys
# hermes' gateway emits during normal slash-command operation.
#
# Scope is deliberately narrow — we ship only the keys that surface as
# user-facing text in this deployment (approval / deny flows, draining,
# expired-approval notices, the bundled ``approval.*`` prompt strings).
# Other ``t()`` calls hermes makes will still fall back to the raw key
# until upstream fixes its packaging or we expand this file.

approval:
dangerous_header: "⚠️ DANGEROUS COMMAND: {description}"
choose_long: " [o]nce | [s]ession | [a]lways | [d]eny"
choose_short: " [o]nce | [s]ession | [d]eny"
prompt_long: " Choice [o/s/a/D]: "
prompt_short: " Choice [o/s/D]: "
timeout: " ⏱ Timeout - denying command"
allowed_once: " ✓ Allowed once"
allowed_session: " ✓ Allowed for this session"
allowed_always: " ✓ Added to permanent allowlist"
denied: " ✗ Denied"
cancelled: " ✗ Cancelled"
blocklist_message: "This command is on the unconditional blocklist and cannot be approved."

gateway:
approval_expired: "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
draining: "⏳ Draining {count} active agent(s) before restart..."
goal_cleared: "✓ Goal cleared."
no_active_goal: "No active goal."
config_read_failed: "⚠️ Could not read config.yaml: {error}"
config_save_failed: "⚠️ Could not save config: {error}"

# /approve replies — key shape is gateway.approve.{choice}_{plural}
# where choice ∈ {once, session, always}, plural ∈ {singular, plural}.
# See gateway/run.py around line 13455.
approve:
no_pending: "No pending command to approve."
once_singular: "✅ Command approved. The agent is resuming..."
once_plural: "✅ {count} commands approved. The agent is resuming..."
session_singular: "✅ Command approved (pattern approved for this session). The agent is resuming..."
session_plural: "✅ {count} commands approved (pattern approved for this session). The agent is resuming..."
always_singular: "✅ Command approved (pattern approved permanently). The agent is resuming..."
always_plural: "✅ {count} commands approved (pattern approved permanently). The agent is resuming..."

# /deny replies — symmetric shape to /approve.
deny:
no_pending: "No pending command to deny."
stale: "❌ Command denied (approval was stale)."
denied_singular: "❌ Command denied. The agent is resuming with a BLOCKED result."
denied_plural: "❌ {count} commands denied. The agent is resuming with BLOCKED results."
Loading