diff --git a/.gitignore b/.gitignore index ef4e87e..939606a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.pyo *.pyd .Pythonservices/custom_ui_guardrail/data/ +*.whl diff --git a/services/hermes_platform_gateway/.env.example b/services/hermes_platform_gateway/.env.example index 7064317..956d630 100644 --- a/services/hermes_platform_gateway/.env.example +++ b/services/hermes_platform_gateway/.env.example @@ -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 + +# 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} + # --- 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 diff --git a/services/hermes_platform_gateway/Dockerfile b/services/hermes_platform_gateway/Dockerfile index 5b9a244..4842952 100644 --- a/services/hermes_platform_gateway/Dockerfile +++ b/services/hermes_platform_gateway/Dockerfile @@ -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 + +# 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"] diff --git a/services/hermes_platform_gateway/Makefile b/services/hermes_platform_gateway/Makefile index 31013de..78728c4 100644 --- a/services/hermes_platform_gateway/Makefile +++ b/services/hermes_platform_gateway/Makefile @@ -1,7 +1,8 @@ -.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" @@ -9,7 +10,14 @@ help: @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: diff --git a/services/hermes_platform_gateway/README.md b/services/hermes_platform_gateway/README.md index 712235f..160a16d 100644 --- a/services/hermes_platform_gateway/README.md +++ b/services/hermes_platform_gateway/README.md @@ -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./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:///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./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 @@ -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./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 diff --git a/services/hermes_platform_gateway/entrypoint.sh b/services/hermes_platform_gateway/entrypoint.sh new file mode 100644 index 0000000..687df0e --- /dev/null +++ b/services/hermes_platform_gateway/entrypoint.sh @@ -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 "$@" diff --git a/services/hermes_platform_gateway/hermes-config.yaml b/services/hermes_platform_gateway/hermes-config.yaml index 27e9822..3b9a8da 100644 --- a/services/hermes_platform_gateway/hermes-config.yaml +++ b/services/hermes_platform_gateway/hermes-config.yaml @@ -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 + # 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 diff --git a/services/hermes_platform_gateway/locales/en.yaml b/services/hermes_platform_gateway/locales/en.yaml new file mode 100644 index 0000000..b5d838e --- /dev/null +++ b/services/hermes_platform_gateway/locales/en.yaml @@ -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." diff --git a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/README.md b/services/hermes_platform_gateway/plugins/hermes-platform-gateway/README.md deleted file mode 100644 index 60e02e5..0000000 --- a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/README.md +++ /dev/null @@ -1,315 +0,0 @@ -# hermes-platform-gateway - -A hermes-agent pip plugin that dynamically registers canvas-connected tools at startup. No per-canvas YAML files or shell commands required β€” tools are discovered from the platform and forwarded over the relay WebSocket using the same `TOOL_START`/`TOOL_END` protocol as `domyn expose`. - ---- - -## How it works - -1. **Tool discovery** β€” on startup, the plugin calls `POST /api/agents-service/tool/list_delegate_tools_for_channel` (on the `api.` subdomain, matching `domyn expose`) to fetch the tools currently connected on the canvas for the given `space_id` + `channel_id` (and optional `configuration_id`). -2. **Schema conversion** β€” platform parameter lists are translated to hermes JSON Schema objects and registered with `ctx.register_tool`. -3. **Relay connection** β€” a background daemon thread opens `wss://{DOMYN_BASE_URL}/relay/v1/ws` with the auth headers (`channel-id`, `space-id`, `api-key`). -4. **Tool call flow** β€” when hermes invokes a platform tool, the handler sends a `TOOL_START` relay message and blocks on a `concurrent.futures.Future` until the matching `TOOL_END` (keyed by `call_id`) resolves it. -5. **Reconnection** β€” if the WebSocket drops, in-flight calls fail immediately with an error JSON and the background loop reconnects with full-jitter exponential backoff. -6. **Canvas changes** β€” a `RefreshLoop` daemon thread polls the tool list every `PLATFORM_TOOL_REFRESH_INTERVAL` seconds (default 60). New tools are registered, removed tools are deregistered, unchanged tools are left alone. -7. **Inbound user input** β€” when the platform sends an `AGENT_START` relay event, the plugin extracts the user text and injects it into hermes via `ctx.inject_message`, triggering a normal agent turn. -8. **Turn completion** β€” after each LLM turn (`post_llm_call` hook), the plugin sends one `AGENT_END` relay event carrying the full assistant response and the correlation IDs (`author`, `interaction_id`, `turn_id`, `conversation_id`) from the originating `AGENT_START`. We intentionally do *not* stream tokens as `RESPONSE(is_partial=True)` events: the platform treats each `RESPONSE` as its own block (joined with newlines in the UI) and a non-empty `AGENT_END` is "promoted" to a final `RESPONSE` β€” mixing the two paths produces duplicated output. The current shape mirrors what `domyn expose`'s Runtime does. - ---- - -## Prerequisites - -- Python 3.11+ -- hermes-agent installed -- `domyn-agents` installed (editable install from the local repo β€” see Installation) - ---- - -## Installation - -The plugin manifest (`plugin.yaml` + `__init__.py`) lives in `~/.hermes/plugins/hermes_platform_gateway/`, but `register()` imports the actual implementation (`fetch_tools`, `GatewayConnection`, …) from the pip-installed `hermes_platform_gateway` package β€” so you must install **into the hermes-agent venv**, not whichever Python happens to be on `$PATH`: - -```bash -HERMES_VENV=~/.hermes/hermes-agent/venv - -# Plugin + dev deps (editable so local changes take effect immediately) -VIRTUAL_ENV=$HERMES_VENV uv pip install --python $HERMES_VENV/bin/python \ - -e /path/to/hermes-platform-gateway - -# domyn-agents β€” required for event models (RelayMessage, BaseEvent, etc.) -VIRTUAL_ENV=$HERMES_VENV uv pip install --python $HERMES_VENV/bin/python \ - -e /path/to/domyn-agents -``` - -Then mirror the manifest + `__init__.py` into `~/.hermes/plugins/hermes_platform_gateway/` (the plugin loader scans that directory for `plugin.yaml`). - -**Enable the plugin** in `~/.hermes/config.yaml`: - -```yaml -plugins: - enabled: - - platform-gateway -``` - -The plugin is opt-in. hermes will silently ignore it if this line is absent. - ---- - -## Configuration - -All configuration is via environment variables injected before hermes starts (typically by a sandbox supervisor): - -| Variable | Required | Default | Purpose | -|---|---|---|---| -| `DOMYN_API_KEY` | Yes | β€” | Auth for HTTP tool discovery and WebSocket handshake | -| `DOMYN_BASE_URL` | Yes | β€” | Platform host, no scheme β€” bare hostname like `conv2.crystal.io` (the plugin prepends `api.` for HTTP, mirrors `domyn expose`) | -| `DOMYN_SPACE_ID` | Yes | β€” | Scopes tool discovery to a specific canvas | -| `DOMYN_CHANNEL_ID` | Yes | β€” | WebSocket relay channel + body field on the discovery POST | -| `DOMYN_CONFIGURATION_ID` | No | β€” | Pin discovery to a specific configuration (omit for active) | -| `PLATFORM_TOOL_TIMEOUT` | No | `120` | Per-call timeout in seconds | -| `PLATFORM_TOOL_REFRESH_INTERVAL` | No | `60` | Canvas poll interval in seconds; set to `0` to disable | - -If any required variable is missing, the plugin logs a warning and hermes starts with zero platform tools (fail-open). - ---- - -## Quickstart β€” local stub - -`stub_platform.py` simulates the platform relay on `localhost:9999`. It serves the HTTP tool list and handles WebSocket tool calls. - -### 1. Install dependencies - -```bash -cd /path/to/hermes-platform-gateway -uv pip install -e ".[dev]" -``` - -### 2. Start the stub - -```bash -uv run python stub_platform.py -``` - -Expected output: -``` -[stub] Listening on http://localhost:9999 -[stub] WebSocket at ws://localhost:9999/relay/v1/ws -``` - -The stub exposes one tool: `echo` β€” it returns `"echo: "` for any `message` argument. - -### 3. Verify the tool list endpoint - -```bash -curl -s -X POST http://localhost:9999/api/agents-service/tool/list_from_config \ - -H "Content-Type: application/json" \ - -d '{"space_id": "s1"}' | python -m json.tool -``` - -Expected: -```json -[ - { - "name": "echo", - "description": "Echo the input message back", - "parameters": [ - { - "name": "message", - "type": "str", - "is_required": true, - "description": "The message to echo" - } - ] - } -] -``` - -### 4. Run hermes with platform tools - -Open a second terminal: - -```bash -DOMYN_API_KEY=test \ -DOMYN_BASE_URL=localhost:9999 \ -DOMYN_SPACE_ID=s1 \ -DOMYN_CHANNEL_ID=c1 \ -hermes -``` - -At startup you should see a log line like: -``` -platform-gateway: registered 1 platform tool(s) -``` - -### 5. Invoke the platform tool - -Ask hermes to use it: - -``` -> Use the echo tool with message "hello" -``` - -In the stub terminal you will see: -``` -[stub] TOOL_START tool=echo params={'message': 'hello'} call_id= -[stub] TOOL_END observation='echo: hello' -``` - -hermes receives the result and replies with it. - ---- - -## Connecting to the real platform - -Set the env vars to point at your actual platform: - -```bash -DOMYN_API_KEY= \ -DOMYN_BASE_URL=api.yourdomain.com \ -DOMYN_SPACE_ID= \ -DOMYN_CHANNEL_ID= \ -hermes -``` - -`DOMYN_BASE_URL` is a bare hostname (with optional port). The plugin uses `wss://` for remote hosts and `ws://` for localhost. Tool discovery uses `https://` / `http://` by the same rule. - ---- - -## Development - -```bash -# Install dev dependencies -uv pip install -e ".[dev]" - -# Run tests -uv run --active pytest tests/ -v - -# Run a single file -uv run --active pytest tests/test_gateway.py -v -``` - -Test files: - -| File | What it covers | -|---|---| -| `tests/test_schema.py` | `convert_schema()` β€” type mapping, required/optional/default, unknown types | -| `tests/test_client.py` | `fetch_tools()` HTTP requests, `build_ws_url()` scheme selection | -| `tests/test_gateway.py` | `GatewayConnection` internals β€” receive loop, fail-pending, call_tool round-trip, `_serialize_observation` | -| `tests/test_register.py` | `register(ctx)` β€” env var checks, schema wiring, handler delegation, correct WS URL and headers | -| `tests/test_integration.py` | End-to-end against a real in-process stub β€” tool discovery, TOOL_START/END round-trip, inbound AGENT_START, outbound send_event, auth headers | - ---- - -## Protocol reference - -### Tool discovery (HTTP) - -``` -POST https://api.{DOMYN_BASE_URL}/api/agents-service/tool/list_delegate_tools_for_channel -Headers: api-key: {DOMYN_API_KEY} - Content-Type: application/json -Body: { - "space_id": "{DOMYN_SPACE_ID}", - "channel_id": "{DOMYN_CHANNEL_ID}", - "configuration_id": "{DOMYN_CONFIGURATION_ID}" // null when unset - } -``` - -Response: a JSON array of tool definitions, or `{"tools": [...]}`. - -### Tool call (WebSocket) - -**Outbound (hermes β†’ platform):** -```json -{ - "meta": {}, - "payload": { - "event_type": "tool_start", - "author": "hermes", - "action": { - "type": "ToolAction", - "name": "send_email", - "parameters": {"to": "user@example.com"}, - "call_id": "" - } - } -} -``` - -**Inbound (platform β†’ hermes):** -```json -{ - "meta": {}, - "payload": { - "event_type": "tool_end", - "author": "platform", - "action": { - "type": "ToolAction", - "name": "send_email", - "call_id": "", - "observation": "Email sent successfully" - } - } -} -``` - -The `call_id` on `TOOL_END` / `TOOL_ERROR` resolves the matching in-flight `concurrent.futures.Future`. On `TOOL_ERROR`, the future is rejected and the handler returns `{"error": ""}`. - -### Bidirectional relay (platform β†’ hermes) - -**Inbound user turn (`AGENT_START`):** -```json -{ - "meta": {}, - "payload": { - "event_type": "agent_start", - "author": "platform", - "interaction_id": "", - "turn_id": "", - "action": { - "type": "AgentAction", - "name": "invoke", - "parameters": {"input": "What is the weather?"} - } - } -} -``` - -**Outbound streaming token (`RESPONSE`):** -```json -{ - "meta": {}, - "payload": { - "event_type": "response", - "author": "platform", - "interaction_id": "", - "turn_id": "", - "is_partial": true, - "content": [{"type": "Part", "text": "The weather"}] - } -} -``` - -**Outbound turn complete (`AGENT_END`):** -```json -{ - "meta": {}, - "payload": { - "event_type": "agent_end", - "author": "platform", - "interaction_id": "", - "turn_id": "", - "content": [{"type": "Part", "text": "The weather is sunny."}] - } -} -``` - -`interaction_id` and `turn_id` are copied from the originating `AGENT_START` so the platform can correlate streaming fragments with the triggering request. - -### WebSocket auth headers - -``` -channel-id: {DOMYN_CHANNEL_ID} -space-id: {DOMYN_SPACE_ID} -api-key: {DOMYN_API_KEY} -``` diff --git a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/__init__.py b/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/__init__.py deleted file mode 100644 index 63e0c85..0000000 --- a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/__init__.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Hermes platform gateway plugin β€” registers canvas tools dynamically at startup.""" -from __future__ import annotations - -import json -import logging -import os -import threading -from typing import Any - -logger = logging.getLogger(__name__) - -_REQUIRED = ("DOMYN_API_KEY", "DOMYN_BASE_URL", "DOMYN_SPACE_ID", "DOMYN_CHANNEL_ID") - - -def register(ctx) -> None: - """Called once by the hermes plugin loader at startup. - - Fetches the current canvas tool list from the platform and registers - each as a sync handler that forwards calls over the WebSocket relay. - Also wires up bidirectional relay: AGENT_START injects user input into - hermes, and on_stream_token / post_llm_call hooks stream the response back. - """ - api_key = os.environ.get("DOMYN_API_KEY", "") - base_url = os.environ.get("DOMYN_BASE_URL", "").rstrip("/") - space_id = os.environ.get("DOMYN_SPACE_ID", "") - channel_id = os.environ.get("DOMYN_CHANNEL_ID", "") - configuration_id = os.environ.get("DOMYN_CONFIGURATION_ID") or None - timeout = float(os.environ.get("PLATFORM_TOOL_TIMEOUT", "120")) - refresh_interval = float(os.environ.get("PLATFORM_TOOL_REFRESH_INTERVAL", "60")) - - missing = [v for v in _REQUIRED if not os.environ.get(v)] - if missing: - logger.warning( - "platform-gateway: skipping registration - missing env vars: %s", - ", ".join(missing), - ) - return - - try: - from hermes_platform_gateway.client import fetch_tools - raw_tools = fetch_tools( - base_url, - space_id, - channel_id, - api_key, - configuration_id=configuration_id, - ) - except Exception as exc: - logger.warning("platform-gateway: could not fetch tools - %s", exc) - return - - # _current_turn holds the AGENT_START event that triggered the current - # relay-driven turn so that streaming events and AGENT_END carry matching - # correlation IDs (author, interaction_id, turn_id, event_id). - _turn_lock = threading.Lock() - _current_turn: list[Any] = [None] # [BaseEvent | None] - - def _on_agent_start(event: Any) -> None: - text = _extract_user_text(event) - if not text: - logger.warning("platform-gateway: AGENT_START with no extractable text, skipping") - return - with _turn_lock: - _current_turn[0] = event - if not ctx.inject_message(text): - logger.warning("platform-gateway: inject_message failed (no CLI ref)") - return - logger.debug("platform-gateway: injected user message from relay") - - try: - from hermes_platform_gateway.client import GatewayConnection, build_ws_url - ws_url = build_ws_url(base_url) - headers = {"channel-id": channel_id, "space-id": space_id, "api-key": api_key} - gateway = GatewayConnection( - ws_url=ws_url, - headers=headers, - timeout=timeout, - on_agent_start=_on_agent_start, - ) - gateway.start() - except Exception as exc: - logger.warning("platform-gateway: could not start WebSocket connection - %s", exc) - return - - from hermes_platform_gateway.schema import convert_schema - - registered_names: set[str] = set() - for tool_def in raw_tools: - name = tool_def.get("name") - if not name: - logger.warning( - "platform-gateway: skipping tool with missing 'name': %r", tool_def - ) - continue - - try: - schema = convert_schema(tool_def) - except Exception as exc: - logger.warning( - "platform-gateway: skipping tool '%s' - schema error: %s", name, exc - ) - continue - - ctx.register_tool( - name=name, - toolset="platform", - schema=schema, - handler=_make_handler(gateway, name, _current_turn, _turn_lock), - is_async=False, - ) - registered_names.add(name) - logger.debug("platform-gateway: registered tool '%s'", name) - - logger.info("platform-gateway: registered %d platform tool(s)", len(registered_names)) - - if refresh_interval > 0: - from hermes_platform_gateway.client import RefreshLoop - RefreshLoop( - ctx=ctx, - handler_factory=lambda name: _make_handler( - gateway, name, _current_turn, _turn_lock - ), - base_url=base_url, - space_id=space_id, - channel_id=channel_id, - api_key=api_key, - interval=refresh_interval, - initial_names=registered_names, - configuration_id=configuration_id, - ).start() - logger.debug("platform-gateway: canvas polling every %.0fs", refresh_interval) - - # --- Bidirectional relay hook -------------------------------------------- - # - # We deliberately do NOT stream tokens as RESPONSE(is_partial=True) events: - # the platform's relay treats each RESPONSE as its own block and joins - # them with newlines in the UI (and a non-empty AGENT_END would get - # promoted into a *second* full message via the - # "[DelegateAgent] Promoting AGENT_END with content to final RESPONSE" - # path). Instead we deliver one final AGENT_END carrying the full - # assistant text β€” same shape `domyn expose`'s Runtime uses. - - def _on_turn_complete( - assistant_response: str = "", - session_id: str | None = None, - **_: Any, - ) -> None: - from domyn_agents.core import BaseEvent, ExecutionEventType, Part - with _turn_lock: - turn = _current_turn[0] - _current_turn[0] = None - if turn is None: - return - event = BaseEvent( - event_type=ExecutionEventType.AGENT_END, - author=turn.author, - event_id=turn.event_id, - interaction_id=turn.interaction_id, - turn_id=turn.turn_id, - conversation_id=turn.conversation_id, - content=[Part(text=assistant_response)] if assistant_response else [], - ) - gateway.send_event(event) - logger.debug("platform-gateway: sent AGENT_END for turn %s", turn.event_id) - - ctx.register_hook("post_llm_call", _on_turn_complete) - - -def _make_handler( - gateway: Any, - tool_name: str, - current_turn: list[Any], - turn_lock: threading.Lock, -) -> Any: - def handler(args: dict, **kwargs: Any) -> str: - with turn_lock: - turn = current_turn[0] - return gateway.call_tool(tool_name, args, turn=turn, **kwargs) - return handler - - -def _extract_user_text(event: Any) -> str: - """Extract plain user text from an AGENT_START relay event.""" - if event.action and event.action.parameters: - params = event.action.parameters - for key in ("input", "text", "message", "content"): - val = params.get(key) - if isinstance(val, str) and val: - return val - for part in event.content or []: - if getattr(part, "text", None): - return part.text - if event.action and event.action.parameters: - return json.dumps(event.action.parameters) - return "" diff --git a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/client.py b/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/client.py deleted file mode 100644 index 8b252b7..0000000 --- a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/client.py +++ /dev/null @@ -1,404 +0,0 @@ -"""Platform relay client: tool discovery and WebSocket connection management.""" -from __future__ import annotations - -import asyncio -import concurrent.futures -import json -import logging -import random -import threading -import time -import uuid -from typing import Any, Callable - -import httpx - -logger = logging.getLogger(__name__) - -_LOCALHOST = ("localhost", "127.0.0.1", "::1") - - -def _is_localhost(base_url: str) -> bool: - host = base_url.split("/")[0].split(":")[0] - return host in _LOCALHOST or base_url.startswith("localhost:") - - -def build_ws_url(base_url: str) -> str: - """Return ws:// for localhost, wss:// otherwise. - - Mirrors ``domyn expose._build_ws_url`` β€” strips any ``http://``/``https://`` - prefix (the env var sometimes carries one) and uses the raw hostname (no - ``api.`` prefix), unlike the HTTP API URL. - """ - for prefix in ("https://", "http://"): - if base_url.startswith(prefix): - base_url = base_url[len(prefix):] - break - base_url = base_url.rstrip("/") - scheme = "ws" if _is_localhost(base_url) else "wss" - return f"{scheme}://{base_url}/relay/v1/ws" - - -def build_api_base_url(base_url: str) -> str: - """Translate ```` into ``https://api.``. - - Mirrors ``domyn expose._build_api_base`` / ``domyn_platform._resolve_platform_args`` - β€” every platform HTTP call goes through the ``api.`` subdomain. Localhost - is special-cased so the in-process stub keeps working unchanged. - """ - if _is_localhost(base_url): - return f"http://{base_url.rstrip('/')}" - transformed = base_url.replace("://", "://api.") - if not transformed.startswith(("http://", "https://")): - transformed = f"https://api.{transformed}" - return transformed.rstrip("/") - - -def fetch_tools( - base_url: str, - space_id: str, - channel_id: str, - api_key: str, - configuration_id: str | None = None, -) -> list[dict[str, Any]]: - """Fetch the canvas tool list from the platform (synchronous HTTP POST). - - Matches ``domyn_agents.integrations.langgraph.domyn_platform._fetch_tool_definitions``: - endpoint is ``list_delegate_tools_for_channel`` and the body carries - ``space_id``, ``channel_id`` and optional ``configuration_id``. - """ - api_base = build_api_base_url(base_url) - url = f"{api_base}/api/agents-service/tool/list_delegate_tools_for_channel" - resp = httpx.post( - url, - headers={"api-key": api_key, "Content-Type": "application/json"}, - json={ - "space_id": space_id, - "channel_id": channel_id, - "configuration_id": configuration_id, - }, - timeout=10.0, - ) - resp.raise_for_status() - data = resp.json() - if isinstance(data, list): - return data - if isinstance(data, dict): - for key in ("tools", "data", "results"): - if key in data and isinstance(data[key], list): - return data[key] - raise ValueError(f"Unexpected tool list response shape: {type(data)}") - - -class GatewayConnection: - """Manages a persistent WebSocket to the platform relay. - - Spawns a daemon thread on start() that owns a single asyncio event loop. - That loop maintains the WebSocket connection with full-jitter exponential - backoff reconnection and a receive loop that resolves pending - concurrent.futures.Future objects when TOOL_END / TOOL_ERROR arrives. - - Tool handlers call call_tool() synchronously β€” it blocks on future.result() - until the platform responds. - """ - - def __init__( - self, - ws_url: str, - headers: dict[str, str], - timeout: float = 120.0, - on_agent_start: Callable[["BaseEvent"], None] | None = None, - ) -> None: - self._ws_url = ws_url - self._headers = headers - self._timeout = timeout - self._on_agent_start = on_agent_start - self._pending: dict[str, concurrent.futures.Future] = {} - self._lock = threading.Lock() - self._loop: asyncio.AbstractEventLoop | None = None - self._ws: Any = None - self._ready = threading.Event() - - def start(self) -> None: - """Spawn the background WebSocket thread and wait up to 15s for first connect.""" - thread = threading.Thread(target=self._run, daemon=True) - thread.start() - if not self._ready.wait(timeout=15): - logger.warning("platform-gateway: timed out waiting for initial WebSocket connection") - - def _run(self) -> None: - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - self._loop.run_until_complete(self._connect_loop()) - - async def _connect_loop(self) -> None: - import websockets - - attempt = 0 - while True: - try: - async with websockets.connect( - self._ws_url, additional_headers=self._headers - ) as ws: - self._ws = ws - self._ready.set() - attempt = 0 - await self._receive_loop(ws) - code = getattr(ws, "close_code", None) - reason = ( - getattr(ws, "close_reason", None) - or getattr(ws, "close_message", None) - ) - logger.info( - "platform-gateway: WebSocket closed by server (code=%s, reason=%r)", - code, reason, - ) - except Exception as exc: - logger.warning("platform-gateway: WebSocket error - %s", exc) - self._fail_pending("WebSocket disconnected") - self._ws = None - - delay = min(30.0, 0.5 * (2 ** attempt)) * (0.5 + 0.5 * random.random()) - logger.debug("platform-gateway: reconnecting in %.1fs", delay) - await asyncio.sleep(delay) - attempt = min(attempt + 1, 6) - - async def _receive_loop(self, ws: Any) -> None: - from domyn_agents.core import ExecutionEventType, RelayMessage - - async for raw in ws: - try: - msg = RelayMessage.model_validate_json(raw) - event = msg.payload - except Exception: - continue - - if event.event_type == ExecutionEventType.AGENT_START: - if self._on_agent_start is not None: - try: - self._on_agent_start(event) - except Exception as exc: - logger.warning("platform-gateway: on_agent_start callback failed - %s", exc) - continue - - if event.event_type not in ( - ExecutionEventType.TOOL_END, - ExecutionEventType.TOOL_ERROR, - ): - continue - - call_id = getattr(event.action, "call_id", None) if event.action else None - if not call_id: - continue - - with self._lock: - future = self._pending.pop(call_id, None) - - if future is None or future.done(): - continue - - if event.event_type == ExecutionEventType.TOOL_ERROR: - future.set_exception( - RuntimeError( - event.error_message or f"platform tool error ({event.error_code})" - ) - ) - else: - observation = ( - getattr(event.action, "observation", None) if event.action else None - ) - future.set_result(observation) - - def send_event(self, event: "BaseEvent") -> None: - """Send a relay event back to the platform (fire-and-forget).""" - if self._ws is None or self._loop is None: - return - from domyn_agents.core import RelayMessage - msg = RelayMessage(payload=event).model_dump_json() - try: - asyncio.run_coroutine_threadsafe(self._ws.send(msg), self._loop) - except Exception as exc: - logger.debug("platform-gateway: send_event failed - %s", exc) - - def _fail_pending(self, reason: str) -> None: - with self._lock: - for future in self._pending.values(): - if not future.done(): - future.set_exception(RuntimeError(reason)) - self._pending.clear() - - def call_tool( - self, - tool_name: str, - args: dict[str, Any], - *, - turn: Any = None, - **kwargs: Any, - ) -> str: - """Send TOOL_START and block until TOOL_END/TOOL_ERROR or timeout. - - ``turn`` is the originating AGENT_START :class:`BaseEvent`. When provided - we copy ``author``/``interaction_id``/``turn_id``/``conversation_id`` onto - the TOOL_START so the platform can route the call to the right session β€” - this mirrors ``Runtime.call_platform_tool`` in ``domyn-agents``. - - Always returns a JSON string. Never raises. - """ - from domyn_agents.core import BaseEvent, ExecutionEventType, RelayMessage, ToolAction - - if self._ws is None or self._loop is None: - return json.dumps({"error": "platform-gateway: WebSocket not connected"}) - - call_id = str(uuid.uuid4()) - future: concurrent.futures.Future = concurrent.futures.Future() - with self._lock: - self._pending[call_id] = future - - event = RelayMessage( - payload=BaseEvent( - event_type=ExecutionEventType.TOOL_START, - author=getattr(turn, "author", None) or "hermes", - interaction_id=getattr(turn, "interaction_id", None), - turn_id=getattr(turn, "turn_id", None), - conversation_id=getattr(turn, "conversation_id", None), - action=ToolAction(name=tool_name, parameters=args, call_id=call_id), - ) - ) - - try: - send_fut = asyncio.run_coroutine_threadsafe( - self._ws.send(event.model_dump_json()), - self._loop, - ) - send_fut.result(timeout=10) - except Exception as exc: - with self._lock: - self._pending.pop(call_id, None) - return json.dumps({"error": f"platform-gateway: send failed - {exc}"}) - - try: - observation = future.result(timeout=self._timeout) - return _serialize_observation(observation) - except concurrent.futures.TimeoutError: - with self._lock: - self._pending.pop(call_id, None) - return json.dumps({"error": f"Tool '{tool_name}' timed out after {self._timeout}s"}) - except Exception as exc: - return json.dumps({"error": str(exc)}) - - -def _serialize_observation(observation: Any) -> str: - """Serialize a platform tool observation to a JSON string. - - If observation is already a valid JSON string, return it as-is. - Otherwise wrap in {"result": observation}. - """ - if isinstance(observation, str): - try: - json.loads(observation) - return observation - except json.JSONDecodeError: - pass - return json.dumps({"result": observation}) - - -def _deregister_tool(name: str) -> None: - """Remove a tool from the hermes tool registry.""" - try: - from tools.registry import registry - registry.deregister(name) - logger.info("platform-gateway: deregistered tool '%s'", name) - except ImportError: - logger.debug("platform-gateway: tools.registry unavailable, cannot deregister '%s'", name) - - -class RefreshLoop: - """Periodically re-fetches the canvas tool list and syncs the hermes registry. - - Runs in a daemon thread. On each interval it diffs the live tool list against - the currently-registered set: new tools are registered, removed tools are - deregistered. Unchanged tools are left alone. - - Pass ``_deregister`` in tests to avoid the hermes registry import. - """ - - def __init__( - self, - ctx: Any, - handler_factory: Callable[[str], Callable], - base_url: str, - space_id: str, - channel_id: str, - api_key: str, - interval: float, - initial_names: set[str], - configuration_id: str | None = None, - _deregister: Callable[[str], None] | None = None, - ) -> None: - self._ctx = ctx - self._handler_factory = handler_factory - self._base_url = base_url - self._space_id = space_id - self._channel_id = channel_id - self._configuration_id = configuration_id - self._api_key = api_key - self._interval = interval - self._registered: set[str] = set(initial_names) - self._deregister_fn = _deregister if _deregister is not None else _deregister_tool - - def start(self) -> None: - thread = threading.Thread(target=self._run, daemon=True) - thread.start() - - def _run(self) -> None: - while True: - time.sleep(self._interval) - self._refresh() - - def _refresh(self) -> None: - from hermes_platform_gateway.schema import convert_schema - - try: - raw_tools = fetch_tools( - self._base_url, - self._space_id, - self._channel_id, - self._api_key, - configuration_id=self._configuration_id, - ) - except Exception as exc: - logger.warning("platform-gateway: refresh fetch failed - %s", exc) - return - - new_defs = {t["name"]: t for t in raw_tools if t.get("name")} - new_names = set(new_defs.keys()) - - added = new_names - self._registered - removed = self._registered - new_names - - if not added and not removed: - return - - for name in removed: - self._deregister_fn(name) - - for name in added: - try: - schema = convert_schema(new_defs[name]) - except Exception as exc: - logger.warning("platform-gateway: skipping new tool '%s': %s", name, exc) - continue - self._ctx.register_tool( - name=name, - toolset="platform", - schema=schema, - handler=self._handler_factory(name), - is_async=False, - ) - - logger.info( - "platform-gateway: canvas refresh - +%d added, -%d removed", - len(added), - len(removed), - ) - self._registered = new_names diff --git a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/schema.py b/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/schema.py deleted file mode 100644 index 18e51fa..0000000 --- a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/hermes_platform_gateway/schema.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Convert platform tool definitions to hermes JSON Schema format.""" -from __future__ import annotations - -_TYPE_MAP: dict[str, str] = { - "str": "string", - "int": "integer", - "float": "number", - "bool": "boolean", - "list": "array", - "dict": "object", -} - - -def convert_schema(tool_def: dict) -> dict: - """Convert a platform tool definition to hermes JSON Schema format. - - A field is required unless is_required is explicitly False or a default is present. - """ - name = tool_def["name"] - description = tool_def.get("description", "") - param_list: list[dict] = tool_def.get("parameters") or [] - - properties: dict[str, dict] = {} - required: list[str] = [] - - for p in param_list: - p_name = p.get("name") - if not p_name: - continue - - json_type = _TYPE_MAP.get(p.get("type", "str"), "string") - prop: dict = {"type": json_type} - - p_desc = p.get("description", "") - if p_desc: - prop["description"] = p_desc - - if "default" in p: - prop["default"] = p["default"] - elif p.get("is_required", True) is not False: - required.append(p_name) - - properties[p_name] = prop - - schema: dict = { - "name": name, - "description": description, - "parameters": { - "type": "object", - "properties": properties, - }, - } - if required: - schema["parameters"]["required"] = required - - return schema diff --git a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/plugin.yaml b/services/hermes_platform_gateway/plugins/hermes-platform-gateway/plugin.yaml deleted file mode 100644 index b6a121a..0000000 --- a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/plugin.yaml +++ /dev/null @@ -1,3 +0,0 @@ -name: hermes_platform_gateway -version: "1.0" -description: Domyn platform gateway β€” discovers canvas tools and bridges hermes ⇄ relay WebSocket diff --git a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/pyproject.toml b/services/hermes_platform_gateway/plugins/hermes-platform-gateway/pyproject.toml deleted file mode 100644 index ad9bd00..0000000 --- a/services/hermes_platform_gateway/plugins/hermes-platform-gateway/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "hermes-platform-gateway" -version = "0.2.0" -description = "Dynamic platform tool gateway plugin for hermes-agent" -requires-python = ">=3.11" -dependencies = [ - "httpx>=0.27", - "websockets>=12", -] - -[project.optional-dependencies] -dev = ["pytest>=7", "pytest-asyncio>=0.23", "respx>=0.20", "httpx>=0.27", "aiohttp>=3.9"] - -[project.entry-points."hermes_agent.plugins"] -platform-gateway = "hermes_platform_gateway" - -[tool.pytest.ini_options] -asyncio_mode = "strict"