- gateway-plugin/: android platform plugin (plugin.yaml + adapter.py register(ctx) + no-op AndroidAdapter) + stub modules for M1-M5 - app/: Compose Multiplatform project (shared KMP + androidApp + desktopApp) with Gradle wrapper; builds :androidApp:assembleDebug and :desktopApp:compileKotlin - scripts/guard_hermes_agent.sh + pre-commit hook: fail if hermes-agent/ is staged (read-only reference, never committed) - .gitignore excludes hermes-agent/; docs/ reference library
263 lines
12 KiB
Markdown
263 lines
12 KiB
Markdown
# 03 — Gateway Plugin (Python)
|
|
|
|
The plugin is a **community-style hermes platform plugin** named `android`.
|
|
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
|
|
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
|
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
|
|
`httpx` are already core deps).
|
|
|
|
> Source map for every hermes integration point: see `15-hermes-reference.md`.
|
|
|
|
## 3.1 `plugin.yaml` (manifest)
|
|
|
|
```yaml
|
|
name: android-platform
|
|
label: Android
|
|
kind: platform
|
|
version: 0.1.0
|
|
description: >
|
|
Native Android / Desktop client gateway adapter for Hermes Agent.
|
|
Runs a WebSocket server inside the gateway; the app connects with a
|
|
pairing token. Supports streaming, reasoning, structured tool events,
|
|
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
|
author: <you>
|
|
requires_env:
|
|
- name: ANDROID_TOKEN
|
|
description: "Shared pairing token the app presents on connect"
|
|
prompt: "Android pairing token"
|
|
password: true
|
|
optional_env:
|
|
- name: ANDROID_WS_HOST
|
|
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
|
prompt: "WS host"
|
|
password: false
|
|
- name: ANDROID_WS_PORT
|
|
description: "WS port (default 8790)"
|
|
prompt: "WS port"
|
|
password: false
|
|
- name: ANDROID_HOME_CHANNEL
|
|
description: "Default chat id for cron/notification delivery (default android:default)"
|
|
prompt: "Home channel"
|
|
password: false
|
|
- name: ANDROID_ALLOWED_USERS
|
|
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
|
prompt: "Allowed device ids"
|
|
password: false
|
|
- name: ANDROID_ALLOW_ALL_USERS
|
|
description: "Allow any paired device (dev only)"
|
|
prompt: "Allow all devices? (true/false)"
|
|
password: false
|
|
- name: ANDROID_PUSH_BACKEND
|
|
description: "Push backend: fcm (default) or ntfy"
|
|
prompt: "Push backend"
|
|
password: false
|
|
- name: ANDROID_FCM_SERVICE_ACCOUNT
|
|
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
|
prompt: "FCM service account path"
|
|
password: true
|
|
- name: ANDROID_FCM_SERVER_KEY
|
|
description: "Legacy FCM server key (fallback if no service account)"
|
|
prompt: "FCM server key"
|
|
password: true
|
|
- name: NTFY_TOPIC
|
|
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
|
|
prompt: "ntfy topic"
|
|
password: false
|
|
- name: NTFY_SERVER_URL
|
|
description: "ntfy server URL (default https://ntfy.sh)"
|
|
prompt: "ntfy server URL"
|
|
password: false
|
|
- name: ANDROID_WS_CERT
|
|
description: "TLS cert path for WSS (optional)"
|
|
prompt: "WSS cert"
|
|
password: false
|
|
- name: ANDROID_WS_KEY
|
|
description: "TLS key path for WSS (optional)"
|
|
prompt: "WSS key"
|
|
password: false
|
|
```
|
|
|
|
Behavioral (non-secret) settings live in `config.yaml` under
|
|
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention,
|
|
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
|
|
only.)
|
|
|
|
## 3.2 `register(ctx)` entry point
|
|
|
|
```python
|
|
def register(ctx):
|
|
ctx.register_platform(
|
|
name="android",
|
|
label="Android",
|
|
adapter_factory=lambda cfg: AndroidAdapter(cfg),
|
|
check_fn=check_requirements, # passive: websockets importable + token set
|
|
validate_config=validate_config, # host/port/token present
|
|
is_connected=is_connected,
|
|
required_env=["ANDROID_TOKEN"],
|
|
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
|
setup_fn=interactive_setup, # hermes gateway setup flow
|
|
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
|
|
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
|
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
|
|
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
|
|
allowed_users_env="ANDROID_ALLOWED_USERS",
|
|
allow_all_env="ANDROID_ALLOW_ALL_USERS",
|
|
max_message_length=0, # 0 = no limit (WS has none)
|
|
emoji="📱",
|
|
pii_safe=False,
|
|
platform_hint=(
|
|
"You are chatting with the user through their native Iris app "
|
|
"(Android/Desktop). It renders Markdown, inline code, images, "
|
|
"audio and video, and shows your reasoning and tool activity. "
|
|
"Conversations are organized into channels and optional threads. "
|
|
"Keep formatting rich but readable."
|
|
),
|
|
)
|
|
```
|
|
|
|
Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
|
|
`adapter_factory`, `check_fn`, `validate_config`, `is_connected`, `required_env`,
|
|
`install_hint`, `setup_fn`, `env_enablement_fn`, `apply_yaml_config_fn`,
|
|
`cron_deliver_env_var`, `parse_target_ref_fn`, `allowed_users_env`,
|
|
`allow_all_env`, `max_message_length`, `pii_safe`, `platform_hint`, `emoji`,
|
|
`ensure_deps_fn`.
|
|
|
|
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
|
|
`ANDROID_TOKEN` is set. Never installs.
|
|
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
|
|
(host/port/home_channel/push_backend) + a `home_channel` key
|
|
`{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status`
|
|
and cron home-channel resolution work without instantiating the adapter.
|
|
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
|
|
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`.
|
|
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
|
|
push backend + credentials, prints a QR code (pairing) and the app URL.
|
|
|
|
## 3.3 `AndroidAdapter(BasePlatformAdapter)`
|
|
|
|
Constructor: `super().__init__(config=config, platform=Platform("android"))`.
|
|
Reads `config.extra` (env overrides win). Initializes: WS server (not started
|
|
until `connect()`), connection registry, outbox (SQLite under
|
|
`get_hermes_home()/"android"`), push backend, pairing store, channel directory.
|
|
|
|
### Lifecycle
|
|
- **`connect(*, is_reconnect=False) -> bool`**
|
|
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`)
|
|
so two profiles can't bind the same port/identity.
|
|
- Start the `websockets` server on `host:port` (TLS if cert/key set).
|
|
- `_mark_connected()`; return True.
|
|
- **`disconnect()`**
|
|
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
|
|
|
|
### Inbound (app → agent)
|
|
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
|
|
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
|
|
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
|
|
media_urls=[cached paths], media_types=[…], reply_to_message_id=…)` →
|
|
`await self.handle_message(event)`.
|
|
- Slash commands arrive as plain text starting with `/`; the gateway's command
|
|
pipeline resolves + dispatches them (no special handling needed).
|
|
- `picker.select {picker_id, value}` → route to the gateway-side resolvers
|
|
(model picker / choice picker / clarify / approval / slash-confirm) using the
|
|
shared callback-id conventions (`cl:<id>:<idx>`, `appr:<id>:<choice>`,
|
|
`sc:<choice>:<id>`).
|
|
- `channel.create` / `channel.rename` / `channel.set_default` → mutate the
|
|
channel directory (SQLite) + emit `channel.*` frames to all devices.
|
|
- `search {query, scope, chat_id?, thread_id?}` → `search.py` → `search.results`.
|
|
- `media.upload` (chunked) → `media.py` → `cache_*_from_bytes` → `media_ref`.
|
|
- `media.pull {media_id}` → stream cached bytes as binary frames.
|
|
- `fcm.register {token}` / `hello` → update device registry.
|
|
- `read.receipt {message_id}` → mark delivered/read (drives ✓✓), ack.
|
|
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
|
|
|
|
### Outbound (agent → app)
|
|
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
|
|
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
|
|
- If **any** device is connected: broadcast `message` frame to all.
|
|
- Else (no live devices): append to **outbox** + fire **push** (FCM/ntfy).
|
|
- Return `SendResult(success=True, message_id=<id>)`.
|
|
- **`edit_message(chat_id, message_id, content)`** → `message.update` frame
|
|
(drives streaming). If no device, no-op (outbox holds the final `send`).
|
|
- **`send_typing(chat_id, metadata=None)`** → `typing` frame.
|
|
- **`get_chat_info(chat_id) -> dict`** → `{"name": <channel name>, "type": "dm"|"channel"}`
|
|
from the channel directory.
|
|
- **Media send** — `send_image / send_video / send_document / send_voice /
|
|
send_image_file / send_multiple_images`: stage the file in the media cache,
|
|
mint a `media_id`, emit `media.offer {media_id, mime, size, filename, kind}`,
|
|
serve bytes on `media.pull`. (Base-class `extract_media`/`extract_images`
|
|
already pull `MEDIA:`/image tags out of agent text and call these.)
|
|
- **Interactive pickers** — `send_model_picker(...)`, `send_choice_picker(...)`,
|
|
`send_clarify(...)`, `send_exec_approval(...)`, `send_slash_confirm(...)`:
|
|
emit `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` /
|
|
`picker.confirm` frames with options; store pending state keyed by
|
|
`picker_id`; resolve on `picker.select`.
|
|
- **`create_handoff_thread(chat_id, name)`** → create a thread id, register in
|
|
channel directory, return it (used by cron "continuable" threads).
|
|
|
|
### Streaming hooks
|
|
The main gateway drives delivery through the **legacy callback path**:
|
|
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
|
|
`edit_message()` (updates) → `message.start` / `message.update`.
|
|
- `tool_progress_callback` → progress queue → `send_progress_messages` →
|
|
`send()` → `tool.*` frames (structured; classified in the adapter).
|
|
- `interim_assistant_callback` → consumer `on_commentary` → `send()` →
|
|
`commentary` frames.
|
|
|
|
The adapter tracks per-chat **turn state** (in-turn, current streaming
|
|
`message_id`, last tool index) to classify outbound `send()` calls into
|
|
`message` vs `tool.*` vs `commentary`. The exact classification markers are
|
|
verified empirically in M2 (see `13-testing.md`).
|
|
|
|
## 3.4 WebSocket server (`ws_server.py`)
|
|
|
|
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host,
|
|
port, ssl=ctx)`.
|
|
- **Handler** per connection:
|
|
1. Await first frame; must be `hello {token, device_id, device_name, caps,
|
|
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
|
|
`error {code:"auth"}` and close.
|
|
2. On success: register in connection registry
|
|
(`device_id → {ws, caps, fcm_token}`), send
|
|
`hello.ack {server_caps, sync_cursor, channels[]}`.
|
|
3. Loop: decode frames, dispatch to adapter inbound handlers.
|
|
4. On close: deregister; if no devices remain, ensure pending outbox
|
|
frames have push fired.
|
|
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
|
devices (no per-chat subscribe; single-user model). Global frames
|
|
(`channel.*`, `status`) also broadcast to all.
|
|
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
|
|
- **Backpressure:** per-connection send queue with a bounded buffer; drop
|
|
`message.update` (coalesce to latest) under pressure, never drop
|
|
`message`/`tool.end`/`notification`.
|
|
|
|
## 3.5 State & storage (all under `get_hermes_home()/"android"`)
|
|
|
|
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
|
|
> Never hardcode `~/.hermes`.
|
|
|
|
- `devices.db` — device registry (device_id, name, caps, fcm_token, ntfy_topic,
|
|
last_seen, created).
|
|
- `channels.db` — channel directory (chat_id, name, kind: default|channel|thread,
|
|
parent_chat_id, created, is_default).
|
|
- `outbox.db` — undelivered frames per chat_id + monotonic cursor.
|
|
- `media/` — inbound + outbound media cache (reuse hermes `cache_*_from_bytes`
|
|
dirs where possible).
|
|
|
|
## 3.6 Config resolution
|
|
|
|
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
|
|
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
|
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
|
|
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
|
`max_upload_bytes`, `tls`.
|
|
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
|
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
|
so multiplexed profiles don't leak each other's tokens.
|
|
|
|
## 3.7 Failure & lifecycle safety
|
|
|
|
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
|
|
- All outbound sends are best-effort; a dead socket latches and the frame falls
|
|
to the outbox.
|
|
- `disconnect()` cancels the server task and closes sockets cleanly.
|
|
- Token/PII redaction in all logs (hermes PII policy). |