M1+M2: gateway core loop + agent transparency
M1 (gateway core loop / text round-trip): - WS server (ws_server.py): bind, hello auth (constant-time), hello.ack, heartbeat, connection registry - pairing.py: token generation, pairing store, QR payload - adapter.py: send() -> message frame; inbound message.send -> MessageEvent -> handle_message - app: Connect screen, GatewayClient (connect + reconnect), ChatScreen send/render, SecureStore (Android/Desktop) - tests/ws_probe.py: probe harness driving a real turn M2 (streaming + reasoning + tools + commentary): - protocol.py: M2 frame types (message.start/update/stop, tool.start/progress/end, commentary) - adapter.py: per-chat turn-state machine; classify outbound into frames; _split_reasoning; tool-line parsing - reasoning in streaming: capture via on_stream_delta hook (kind=reasoning, gated by plugins.stream_reasoning_deltas) with a FIFO barrier, attach to message.stop - app: live streaming bubble, ReasoningBlock (collapse + copy), ToolCard (Everything/Truncated/Nothing), dimmed commentary, typing - docs/14-milestones.md: M1/M2 marked done; reasoning note corrected
This commit is contained in:
1 parent
59acf66c89
commit
218c50d688
21 files changed
+3437
-129
No files matched your search
+762
-54
@@ -9,11 +9,17 @@ with a pairing token and talks to the agent over a single WS transport
|
||||
Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core
|
||||
deps. Zero hermes-core changes.
|
||||
|
||||
Milestone M0: this is a *skeleton* adapter. It registers the ``android``
|
||||
platform, resolves its configuration, and implements the abstract adapter
|
||||
contract as no-ops so that ``hermes gateway status`` lists ``android``. The
|
||||
WebSocket server, pairing, streaming, media, outbox, push, and search are
|
||||
wired in later milestones (see ``docs/14-milestones.md``).
|
||||
Milestone M1: the gateway core loop (text round-trip). The WS server binds
|
||||
and authenticates devices (``hello`` with constant-time token check), the
|
||||
adapter emits ``message`` frames from ``send()`` and turns inbound
|
||||
``message.send`` frames into ``MessageEvent``s for ``handle_message()``.
|
||||
|
||||
Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
|
||||
``message.start``/``message.update``/``message.stop`` (streaming), tool
|
||||
progress is classified into structured ``tool.start``/``tool.end`` frames,
|
||||
interim commentary becomes ``commentary`` frames, and the code-style
|
||||
reasoning prefix is split into a ``reasoning`` field. Media, outbox, push,
|
||||
and search land in later milestones (see ``docs/14-milestones.md``).
|
||||
|
||||
Configuration in config.yaml::
|
||||
|
||||
@@ -34,11 +40,15 @@ Or via environment variables (overrides config.yaml; secrets live in .env):
|
||||
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
|
||||
from agent.secret_scope import get_secret as _scoped_get_secret
|
||||
@@ -79,6 +89,77 @@ from gateway.platforms.base import ( # noqa: E402
|
||||
MessageType,
|
||||
)
|
||||
from gateway.config import Platform # noqa: E402
|
||||
from hermes_constants import get_hermes_home # noqa: E402
|
||||
|
||||
from . import protocol # noqa: E402
|
||||
from .pairing import ( # noqa: E402
|
||||
DeviceRegistry,
|
||||
generate_token,
|
||||
pairing_url,
|
||||
qr_payload,
|
||||
)
|
||||
from .ws_server import WsServer # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M2 — reasoning capture (streaming)
|
||||
#
|
||||
# The gateway streams only ``content`` to the platform and suppresses the
|
||||
# final send (which would carry the prepended reasoning), so the model's
|
||||
# separate ``reasoning_content`` is otherwise lost in the streaming case.
|
||||
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
|
||||
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
|
||||
# We accumulate those deltas here and attach the result to the turn's
|
||||
# ``message.stop`` frame. Single-chat for now (android:default), so a
|
||||
# module-level buffer suffices; it is reset at each turn start.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_reasoning_parts: List[str] = []
|
||||
_reasoning_lock = threading.Lock()
|
||||
# Barrier: set by the hook worker once it has processed the first content
|
||||
# delta (kind="text"). The worker drains a FIFO queue and reasoning deltas are
|
||||
# enqueued before content deltas, so at that point every reasoning delta has
|
||||
# already been appended -- a reliable "reasoning flushed" signal that avoids
|
||||
# racing message.stop against the async hook thread.
|
||||
_reasoning_flushed = threading.Event()
|
||||
|
||||
|
||||
def _on_stream_delta(**kwargs: Any) -> None:
|
||||
"""Plugin hook: capture reasoning deltas (kind="reasoning")."""
|
||||
kind = kwargs.get("kind")
|
||||
if kind == "reasoning":
|
||||
delta = kwargs.get("delta") or ""
|
||||
if delta:
|
||||
with _reasoning_lock:
|
||||
_reasoning_parts.append(delta)
|
||||
elif kind == "text":
|
||||
_reasoning_flushed.set()
|
||||
|
||||
|
||||
async def _wait_for_reasoning_flushed(timeout: float = 0.3) -> None:
|
||||
"""Wait (without blocking the event loop) until the hook worker has
|
||||
processed all reasoning deltas, or *timeout* seconds elapse."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while loop.time() < deadline:
|
||||
if _reasoning_flushed.is_set():
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
def _take_reasoning() -> str:
|
||||
"""Drain and return the accumulated reasoning (empty string if none)."""
|
||||
with _reasoning_lock:
|
||||
parts = _reasoning_parts[:]
|
||||
_reasoning_parts.clear()
|
||||
_reasoning_flushed.clear()
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _reset_reasoning() -> None:
|
||||
with _reasoning_lock:
|
||||
_reasoning_parts.clear()
|
||||
_reasoning_flushed.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -88,6 +169,7 @@ from gateway.config import Platform # noqa: E402
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8790
|
||||
DEFAULT_HOME_CHANNEL = "android:default"
|
||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||
DEFAULT_PUSH_BACKEND = "fcm"
|
||||
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
||||
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
@@ -97,6 +179,211 @@ def _truthy(value: Optional[str]) -> bool:
|
||||
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M2 — turn state + outbound classification
|
||||
#
|
||||
# The main gateway delivers through the legacy callback path: the stream
|
||||
# consumer calls ``send()`` (first bubble of a segment) and ``edit_message()``
|
||||
# (updates), tool progress flows through ``send()``/``edit_message()`` of an
|
||||
# accumulated line buffer, and interim commentary arrives as a plain
|
||||
# ``send()``. We classify each outbound call into a structured frame using a
|
||||
# per-chat turn state machine + content markers:
|
||||
#
|
||||
# * ``metadata["expect_edits"] is True`` -> streaming segment start
|
||||
# * ``metadata["notify"] is True`` -> final message (or fallback final)
|
||||
# * tool-progress line format -> tool.start / tool.end
|
||||
# * anything else -> commentary
|
||||
#
|
||||
# Verified empirically against the live gateway with ``tests/ws_probe.py``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Streaming cursor the gateway appends to in-progress edits (" ▉"). Stripped
|
||||
# before we forward text to the app (the app renders its own live indicator).
|
||||
_STREAMING_CURSOR = " ▉"
|
||||
|
||||
# Code-style reasoning prefix (gateway/run.py, reasoning_style="code"):
|
||||
# "💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>"
|
||||
_REASONING_PREFIX = "💭 **Reasoning:**\n```\n"
|
||||
_REASONING_CLOSE = "\n```\n\n"
|
||||
|
||||
# A gateway tool-progress line begins with a (non-ASCII) tool emoji.
|
||||
_TOOL_LINE_RE = re.compile(r"^(\S+)\s+(.+)$")
|
||||
_TOOL_NAME_PREVIEW_RE = re.compile(r'^(\S+):\s*"(.*)"\s*$')
|
||||
_TOOL_NAME_BARE_RE = re.compile(r"^(\S+)\.\.\.\s*$")
|
||||
_TOOL_NAME_ARGS_RE = re.compile(r"^(\S+)\(([^)]*)\)\s*$")
|
||||
# Terminal code block: "💻 terminal\n```\n<cmd>\n```"
|
||||
_TOOL_CODEBLOCK_HEAD_RE = re.compile(r"^(\S+)\s+(\S+)\s*$")
|
||||
|
||||
# Reverse map of the gateway's friendly tool verbs (agent/display.py
|
||||
# _TOOL_VERBS) so a verb-form line ("🔍 Searching the web for …") can be
|
||||
# recovered to a structured (tool_name, preview). Longest-first matching is
|
||||
# done at parse time. Verbs shared by several tools map to the most common.
|
||||
_VERB_TO_TOOL: Dict[str, str] = {
|
||||
"Searching the web": "web_search",
|
||||
"Searching files": "search_files",
|
||||
"Searching past sessions": "session_search",
|
||||
"Running code": "execute_code",
|
||||
"Running": "terminal",
|
||||
"Reading skill": "skill_view",
|
||||
"Reading": "read_file",
|
||||
"Writing": "write_file",
|
||||
"Editing": "patch",
|
||||
"Browsing": "browser_navigate",
|
||||
"Clicking": "browser_click",
|
||||
"Typing": "browser_type",
|
||||
"Generating image": "image_generate",
|
||||
"Generating video": "video_generate",
|
||||
"Generating speech": "text_to_speech",
|
||||
"Looking at the image": "vision_analyze",
|
||||
"Listing skills": "skills_list",
|
||||
"Updating skill": "skill_manage",
|
||||
"Updating memory": "memory",
|
||||
"Updating tasks": "todo",
|
||||
"Delegating": "delegate_task",
|
||||
"Scheduling": "cronjob",
|
||||
"Asking": "clarify",
|
||||
}
|
||||
# Verbs that take a " for " connector before the preview.
|
||||
_VERB_FOR_CONNECTOR = {"web_search", "search_files"}
|
||||
|
||||
|
||||
def _mint_message_id() -> str:
|
||||
return f"m_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def _thread_id_from_metadata(metadata: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not metadata:
|
||||
return None
|
||||
tid = metadata.get("thread_id")
|
||||
if isinstance(tid, str) and tid:
|
||||
return tid
|
||||
return None
|
||||
|
||||
|
||||
def _strip_streaming_cursor(text: str) -> str:
|
||||
if text and text.endswith(_STREAMING_CURSOR):
|
||||
return text[: -len(_STREAMING_CURSOR)]
|
||||
return text
|
||||
|
||||
|
||||
def _split_reasoning(text: str) -> Tuple[Optional[str], str]:
|
||||
"""Split a code-style reasoning prefix off the front of *text*.
|
||||
|
||||
Returns ``(reasoning, body)``; ``reasoning`` is ``None`` when no prefix is
|
||||
present (reasoning off / no reasoning / non-code style). Best-effort parse
|
||||
of a stable, gateway-owned format: on any mismatch the fallback is
|
||||
``(None, full text)`` so the answer still renders.
|
||||
"""
|
||||
if not text or not text.startswith(_REASONING_PREFIX):
|
||||
return None, text
|
||||
close_idx = text.find(_REASONING_CLOSE, len(_REASONING_PREFIX))
|
||||
if close_idx == -1:
|
||||
return None, text
|
||||
reasoning = text[len(_REASONING_PREFIX):close_idx]
|
||||
body = text[close_idx + len(_REASONING_CLOSE):]
|
||||
return reasoning, body
|
||||
|
||||
|
||||
def _parse_tool_line(line: str) -> Optional[Tuple[str, Optional[str]]]:
|
||||
"""Parse a single gateway tool-progress line into ``(name, preview)``.
|
||||
|
||||
Returns ``None`` when the line is not a tool line. The gateway formats
|
||||
tool lines as ``<emoji> <name>: "<preview>"``, ``<emoji> <name>...``,
|
||||
``<emoji> <name>(keys)``, or a friendly verb phrase (``<emoji> <verb> …``).
|
||||
The verb form is lossy (no tool name), so we surface the verb as the name.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
m = _TOOL_LINE_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
emoji, rest = m.group(1), m.group(2)
|
||||
if emoji.isascii():
|
||||
return None # a tool line always leads with a non-ASCII emoji
|
||||
mp = _TOOL_NAME_PREVIEW_RE.match(rest)
|
||||
if mp:
|
||||
return mp.group(1), mp.group(2)
|
||||
mb = _TOOL_NAME_BARE_RE.match(rest)
|
||||
if mb:
|
||||
return mb.group(1), None
|
||||
ma = _TOOL_NAME_ARGS_RE.match(rest)
|
||||
if ma:
|
||||
return ma.group(1), None
|
||||
# Friendly verb phrase: reverse-map to (tool_name, preview).
|
||||
verb_parsed = _parse_verb_phrase(rest)
|
||||
if verb_parsed is not None:
|
||||
return verb_parsed
|
||||
# Unrecognised: use the phrase as the label.
|
||||
return rest, None
|
||||
|
||||
|
||||
def _parse_verb_phrase(phrase: str) -> Optional[Tuple[str, Optional[str]]]:
|
||||
"""Reverse-map a friendly verb phrase to ``(tool_name, preview)``.
|
||||
|
||||
Matches the longest verb first so "Running code" wins over "Running".
|
||||
Returns ``None`` when no known verb leads the phrase.
|
||||
"""
|
||||
for verb in sorted(_VERB_TO_TOOL, key=len, reverse=True):
|
||||
tool = _VERB_TO_TOOL[verb]
|
||||
if phrase == verb:
|
||||
return tool, None
|
||||
if tool in _VERB_FOR_CONNECTOR and phrase.startswith(verb + " for "):
|
||||
return tool, phrase[len(verb) + len(" for "):].strip() or None
|
||||
if phrase.startswith(verb + " "):
|
||||
return tool, phrase[len(verb) + 1:].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_code_block(content: str) -> Optional[str]:
|
||||
"""Return the first fenced code block's body in *content*, else ``None``.
|
||||
|
||||
Used to recover the terminal command from a tool-progress code block
|
||||
(``<emoji> terminal`` head line + fenced command).
|
||||
"""
|
||||
m = re.search(r"```[^\n]*\n(.*?)\n```", content, re.DOTALL)
|
||||
if m:
|
||||
return m.group(1).strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _is_tool_progress(content: str) -> bool:
|
||||
"""Heuristic: does *content* look like gateway tool-progress line(s)?
|
||||
|
||||
Tool progress is delivered as one or more lines, each led by a tool emoji
|
||||
(or a terminal code block). Commentary is free-form prose. We classify on
|
||||
the first non-empty line; subsequent lines of the same bubble are tracked
|
||||
by message id, not re-classified.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
lines = [ln for ln in content.splitlines() if ln.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
first = lines[0].strip()
|
||||
# Terminal code block: "<emoji> terminal" then a fenced command.
|
||||
if len(lines) > 1 and lines[1].strip().startswith("```"):
|
||||
return _TOOL_CODEBLOCK_HEAD_RE.match(first) is not None
|
||||
return _parse_tool_line(first) is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TurnState:
|
||||
"""Per-chat turn state for outbound frame classification (M2)."""
|
||||
|
||||
active: bool = False
|
||||
# message_id of the currently streaming segment (message.start open).
|
||||
stream_id: Optional[str] = None
|
||||
# message_id of the current tool-progress bubble (editable line buffer).
|
||||
tool_msg_id: Optional[str] = None
|
||||
# Monotonic per-turn tool counter (start -> end correlation).
|
||||
tool_index: int = 0
|
||||
# Index of the most recently started tool (awaiting tool.end).
|
||||
open_tool_index: Optional[int] = None
|
||||
# Tool lines already emitted as tool.start (dedup across edits).
|
||||
seen_tool_lines: set = field(default_factory=set)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passive / config probes (called from status displays -- no side effects)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -147,19 +434,26 @@ def _env_enablement() -> Optional[dict]:
|
||||
if not token:
|
||||
return None
|
||||
|
||||
seed: Dict[str, Any] = {
|
||||
"host": os.getenv("ANDROID_WS_HOST", "").strip() or DEFAULT_HOST,
|
||||
"port": _parse_port(os.getenv("ANDROID_WS_PORT", "")),
|
||||
"push_backend": (
|
||||
os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
||||
or DEFAULT_PUSH_BACKEND
|
||||
),
|
||||
}
|
||||
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip() or DEFAULT_HOME_CHANNEL
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or "Default",
|
||||
}
|
||||
# Seed ONLY explicitly-set env vars: the core commits this seed on top of
|
||||
# config.yaml (``extra.update(seed)``), so default values here would
|
||||
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
||||
# defaults.
|
||||
seed: Dict[str, Any] = {}
|
||||
host = os.getenv("ANDROID_WS_HOST", "").strip()
|
||||
if host:
|
||||
seed["host"] = host
|
||||
port_raw = os.getenv("ANDROID_WS_PORT", "").strip()
|
||||
if port_raw:
|
||||
seed["port"] = _parse_port(port_raw)
|
||||
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
||||
if push:
|
||||
seed["push_backend"] = push
|
||||
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip()
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
||||
}
|
||||
return seed
|
||||
|
||||
|
||||
@@ -215,7 +509,7 @@ async def _standalone_send(
|
||||
|
||||
The outbox is served by the *running* gateway, so standalone delivery
|
||||
while the gateway process is fully down is best-effort only (see
|
||||
``docs/00-overview.md`` "Out of scope"). For M0 this is a stub that
|
||||
``docs/00-overview.md`` "Out of scope"). For M1 this is a stub that
|
||||
reports the gateway is required; the real implementation lands with the
|
||||
outbox (M3/M5).
|
||||
"""
|
||||
@@ -228,14 +522,14 @@ async def _standalone_send(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive setup (hermes gateway setup flow) -- full version in M1
|
||||
# Interactive setup (hermes gateway setup flow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Prompt for the pairing token / host / port / push backend.
|
||||
|
||||
M0: minimal. M1 adds token generation, QR payload, and a live ``hello``
|
||||
connectivity test.
|
||||
M1: token generation, host/port/push prompts, and the pairing QR payload
|
||||
(``iris://pair?...``) + app URL printed for the Connect screen.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import (
|
||||
@@ -253,7 +547,7 @@ def interactive_setup() -> None:
|
||||
print_info("📱 Android / Desktop (Iris x Hermes)")
|
||||
token = get_env_value("ANDROID_TOKEN") or ""
|
||||
if not token:
|
||||
generated = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars
|
||||
generated = generate_token()
|
||||
save_env_value("ANDROID_TOKEN", generated)
|
||||
print_success(f"Generated pairing token: {generated}")
|
||||
print_warning("Keep this secret -- the app presents it on connect.")
|
||||
@@ -267,6 +561,19 @@ def interactive_setup() -> None:
|
||||
backend = prompt("Push backend (fcm/ntfy)", default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND)
|
||||
save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
||||
|
||||
# Pairing payload for the app's Connect screen (QR / manual entry).
|
||||
try:
|
||||
from hermes_cli.config import print_code
|
||||
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
|
||||
payload = qr_payload(host or DEFAULT_HOST, _parse_port(port), token)
|
||||
print_info("Pair your device (scan with the app or enter on the Connect screen):")
|
||||
print_code(payload)
|
||||
print_info(f"Server URL: {url}")
|
||||
except Exception:
|
||||
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
|
||||
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
|
||||
print_info(f"Server URL: {url}")
|
||||
|
||||
print_success("Android configuration saved to ~/.hermes/.env")
|
||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
|
||||
@@ -278,10 +585,10 @@ def interactive_setup() -> None:
|
||||
class AndroidAdapter(BasePlatformAdapter):
|
||||
"""WebSocket-backed adapter for the native Iris Android / Desktop app.
|
||||
|
||||
M0: skeleton. Implements the abstract adapter contract as no-ops and
|
||||
resolves configuration. The WebSocket server, connection registry,
|
||||
pairing, streaming, media, outbox, push, and search are added in later
|
||||
milestones.
|
||||
M1: the WS server (``ws_server.WsServer``) authenticates devices with the
|
||||
pairing token, the connection registry tracks live sockets, ``send()``
|
||||
emits ``message`` frames, and inbound ``message.send`` frames become
|
||||
``MessageEvent``s for ``handle_message()``.
|
||||
"""
|
||||
|
||||
def __init__(self, config, **kwargs):
|
||||
@@ -294,7 +601,6 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||
self.port = _parse_port(os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT)))
|
||||
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
||||
self.home_channel = extra.get("home_channel", DEFAULT_HOME_CHANNEL)
|
||||
self.push_backend = (
|
||||
os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
||||
or extra.get("push_backend", DEFAULT_PUSH_BACKEND)
|
||||
@@ -306,6 +612,25 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
extra.get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES)
|
||||
)
|
||||
|
||||
# Home channel: the core hook turns the env-seeded ``home_channel``
|
||||
# dict into a HomeChannel dataclass on the config; config.yaml may
|
||||
# also put it in extra (dict or bare string).
|
||||
home = getattr(config, "home_channel", None)
|
||||
if home is not None and getattr(home, "chat_id", None):
|
||||
self.home_channel = str(home.chat_id)
|
||||
self.home_channel_name = str(getattr(home, "name", "") or DEFAULT_HOME_CHANNEL_NAME)
|
||||
else:
|
||||
hc = extra.get("home_channel")
|
||||
if isinstance(hc, dict) and hc.get("chat_id"):
|
||||
self.home_channel = str(hc["chat_id"])
|
||||
self.home_channel_name = str(hc.get("name") or DEFAULT_HOME_CHANNEL_NAME)
|
||||
elif isinstance(hc, str) and hc.strip():
|
||||
self.home_channel = hc.strip()
|
||||
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
||||
else:
|
||||
self.home_channel = DEFAULT_HOME_CHANNEL
|
||||
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
||||
|
||||
# TLS (optional)
|
||||
self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "")
|
||||
self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "")
|
||||
@@ -317,10 +642,19 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
)
|
||||
self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS"))
|
||||
|
||||
# Runtime state (populated by the WS server in M1)
|
||||
self._ws_server = None
|
||||
self._connections: Dict[str, Any] = {}
|
||||
# Runtime state
|
||||
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
|
||||
self._ws_server = WsServer(self, self._devices)
|
||||
self._connected = False
|
||||
# M2: per-chat turn state for outbound frame classification.
|
||||
self._turns: Dict[str, _TurnState] = {}
|
||||
|
||||
def _turn_state(self, chat_id: str) -> _TurnState:
|
||||
st = self._turns.get(chat_id)
|
||||
if st is None:
|
||||
st = _TurnState()
|
||||
self._turns[chat_id] = st
|
||||
return st
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -329,12 +663,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Bring the platform up.
|
||||
|
||||
M0: no WebSocket server yet -- just validate config and mark
|
||||
connected so ``hermes gateway status`` reflects the platform. M1
|
||||
starts the ``websockets`` server here.
|
||||
"""
|
||||
"""Bring the platform up: bind the WS server on host:port."""
|
||||
if not self.token:
|
||||
logger.error("android: ANDROID_TOKEN must be set")
|
||||
self._set_fatal_error(
|
||||
@@ -360,21 +689,33 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
except ImportError:
|
||||
self._lock_key = None # status module not available (e.g. tests)
|
||||
|
||||
# M1: start the websockets server on host:port (TLS if cert/key set).
|
||||
try:
|
||||
await self._ws_server.start()
|
||||
except Exception:
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
self._connected = True
|
||||
self._mark_connected()
|
||||
logger.info("android: connected (skeleton; WS server starts in M1) on %s:%s", self.host, self.port)
|
||||
logger.info("android: connected; WS server on %s:%s", self.host, self.port)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Tear down the platform."""
|
||||
"""Tear down the platform: stop the server, close device sockets."""
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
if getattr(self, "_lock_key", None):
|
||||
release_scoped_lock("android", self._lock_key)
|
||||
except ImportError:
|
||||
pass
|
||||
# M1: stop the server and close all device sockets.
|
||||
try:
|
||||
await self._ws_server.stop()
|
||||
except Exception:
|
||||
logger.warning("android: WS server stop failed", exc_info=True)
|
||||
try:
|
||||
self._devices.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
self._mark_disconnected()
|
||||
logger.info("android: disconnected")
|
||||
@@ -390,18 +731,277 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
) -> SendResult:
|
||||
"""Send a message to a chat.
|
||||
|
||||
M0: no live devices yet -- log and report success with a minted id.
|
||||
M1: broadcast a ``message`` frame to connected devices, else fall to
|
||||
the outbox + fire push.
|
||||
M2: classify the outbound call into a structured frame using the
|
||||
per-chat turn state machine (see module docstring):
|
||||
|
||||
* ``metadata["expect_edits"]`` -> ``message.start`` (streaming segment)
|
||||
* ``metadata["notify"]`` -> ``message`` / ``message.stop`` (final)
|
||||
* tool-progress line format -> ``tool.start`` (first tool bubble)
|
||||
* anything else -> ``commentary``
|
||||
|
||||
With no live devices the frame is dropped here (the outbox + push
|
||||
replay lands in M3/M5).
|
||||
"""
|
||||
message_id = f"msg_{uuid.uuid4().hex}"
|
||||
logger.debug("android: send to %s (%d chars) [skeleton no-op]", chat_id, len(content or ""))
|
||||
content = content or ""
|
||||
meta = metadata or {}
|
||||
thread_id = _thread_id_from_metadata(meta)
|
||||
state = self._turn_state(chat_id)
|
||||
|
||||
# 1. Streaming segment start (stream consumer first send).
|
||||
if meta.get("expect_edits") is True:
|
||||
# A new content segment means the tool the model was waiting on
|
||||
# has returned -> close it before the segment opens.
|
||||
await self._close_open_tool(chat_id, state, thread_id)
|
||||
message_id = _mint_message_id()
|
||||
state.active = True
|
||||
state.stream_id = message_id
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message_start(chat_id, message_id, protocol.ROLE_ASSISTANT, thread_id=thread_id),
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
# 2. Final message (non-streaming final, or streaming fallback final).
|
||||
if meta.get("notify") is True:
|
||||
reasoning, body = _split_reasoning(content)
|
||||
# Non-streaming: reasoning is prepended to content (split above).
|
||||
# Streaming fallback: content has no reasoning, so use the
|
||||
# reasoning captured via the on_stream_delta hook (wait for the
|
||||
# async hook worker to flush it first).
|
||||
if not reasoning:
|
||||
await _wait_for_reasoning_flushed()
|
||||
reasoning = _take_reasoning() or None
|
||||
else:
|
||||
_reset_reasoning()
|
||||
if state.stream_id:
|
||||
# Fallback final: close the open streaming segment in place.
|
||||
message_id = state.stream_id
|
||||
state.stream_id = None
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message_stop(
|
||||
chat_id, message_id, body,
|
||||
reasoning=reasoning, thread_id=thread_id,
|
||||
ts=int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
else:
|
||||
message_id = _mint_message_id()
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
role=protocol.ROLE_ASSISTANT,
|
||||
text=body,
|
||||
thread_id=thread_id,
|
||||
reasoning=reasoning,
|
||||
reply_to=reply_to,
|
||||
ts=int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
await self._close_open_tool(chat_id, state, thread_id)
|
||||
self._reset_tool_state(state)
|
||||
state.active = False
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
# 3. Tool progress (first tool bubble of an editable line buffer).
|
||||
if _is_tool_progress(content):
|
||||
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False)
|
||||
|
||||
# 4. Commentary (interim assistant beat).
|
||||
message_id = _mint_message_id()
|
||||
state.active = True
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.commentary(chat_id, message_id, content, thread_id=thread_id),
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Send a typing indicator. M0: no-op (M1 emits a ``typing`` frame)."""
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
finalize: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Edit a previously sent message (M2: drives streaming + tool updates).
|
||||
|
||||
* ``message_id == state.stream_id`` -> ``message.update``
|
||||
(``finalize=True`` -> ``message.stop``).
|
||||
* ``message_id == state.tool_msg_id`` -> tool-progress update
|
||||
(new lines -> ``tool.start``).
|
||||
* unknown id -> best-effort ``message.update``.
|
||||
"""
|
||||
content = content or ""
|
||||
thread_id = _thread_id_from_metadata(metadata)
|
||||
state = self._turn_state(chat_id)
|
||||
|
||||
if message_id and message_id == state.stream_id:
|
||||
if finalize:
|
||||
reasoning, body = _split_reasoning(_strip_streaming_cursor(content))
|
||||
# Streaming: the gateway drops the model's separate
|
||||
# reasoning_content (final send suppressed), so attach the
|
||||
# reasoning we captured via the on_stream_delta hook (wait
|
||||
# for the async hook worker to flush it first).
|
||||
if not reasoning:
|
||||
await _wait_for_reasoning_flushed()
|
||||
reasoning = _take_reasoning() or None
|
||||
else:
|
||||
_reset_reasoning()
|
||||
state.stream_id = None
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message_stop(
|
||||
chat_id, message_id, body,
|
||||
reasoning=reasoning, thread_id=thread_id,
|
||||
ts=int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
await self._close_open_tool(chat_id, state, thread_id)
|
||||
self._reset_tool_state(state)
|
||||
state.active = False
|
||||
else:
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message_update(
|
||||
chat_id, message_id, _strip_streaming_cursor(content),
|
||||
thread_id=thread_id,
|
||||
),
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
if message_id and message_id == state.tool_msg_id:
|
||||
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=True)
|
||||
|
||||
# Unknown id: treat as a streaming update (best effort).
|
||||
if finalize:
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message_stop(
|
||||
chat_id, message_id, _strip_streaming_cursor(content),
|
||||
thread_id=thread_id, ts=int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
else:
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message_update(
|
||||
chat_id, message_id, _strip_streaming_cursor(content),
|
||||
thread_id=thread_id,
|
||||
),
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
# ── M2: tool-progress helpers ─────────────────────────────────────────
|
||||
|
||||
async def _emit_tool_lines(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
state: _TurnState,
|
||||
thread_id: Optional[str],
|
||||
*,
|
||||
is_edit: bool,
|
||||
) -> SendResult:
|
||||
"""Emit ``tool.start`` for each NEW tool line in *content*.
|
||||
|
||||
The gateway accumulates tool lines in one editable bubble; on an edit
|
||||
the full buffer is re-sent, so we diff against ``seen_tool_lines`` to
|
||||
emit only the new ones. A new tool closes the previously-open tool.
|
||||
"""
|
||||
message_id = state.tool_msg_id or _mint_message_id()
|
||||
state.tool_msg_id = message_id
|
||||
state.active = True
|
||||
|
||||
lines = [ln for ln in content.splitlines() if ln.strip()]
|
||||
for line in lines:
|
||||
key = line.strip()
|
||||
if key in state.seen_tool_lines:
|
||||
continue
|
||||
state.seen_tool_lines.add(key)
|
||||
parsed = self._parse_tool_line_or_block(line, content)
|
||||
if parsed is None:
|
||||
continue
|
||||
name, preview = parsed
|
||||
# A new tool begins: close the previously-open one.
|
||||
if state.open_tool_index is not None:
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id),
|
||||
)
|
||||
state.tool_index += 1
|
||||
state.open_tool_index = state.tool_index
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.tool_start(
|
||||
chat_id, state.tool_index, name,
|
||||
preview=preview, thread_id=thread_id,
|
||||
),
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_line_or_block(line: str, content: str) -> Optional[Tuple[str, Optional[str]]]:
|
||||
"""Parse a tool line, expanding a terminal code block to its command."""
|
||||
parsed = _parse_tool_line(line)
|
||||
if parsed is not None:
|
||||
name, preview = parsed
|
||||
# Terminal code block: the command lives in the fenced lines that
|
||||
# follow the "<emoji> terminal" head line.
|
||||
if name == "terminal" and preview is None and "```" in content:
|
||||
cmd = _extract_code_block(content)
|
||||
if cmd:
|
||||
return name, cmd
|
||||
return parsed
|
||||
return None
|
||||
|
||||
async def _close_open_tool(
|
||||
self, chat_id: str, state: _TurnState, thread_id: Optional[str]
|
||||
) -> None:
|
||||
"""Emit ``tool.end`` for the currently-open tool, if any.
|
||||
|
||||
A tool is considered complete when the next tool starts OR a new
|
||||
content segment begins (the model only produces content after the
|
||||
tool it was waiting on has returned).
|
||||
"""
|
||||
if state.open_tool_index is not None:
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id),
|
||||
)
|
||||
state.open_tool_index = None
|
||||
|
||||
def _reset_tool_state(self, state: _TurnState) -> None:
|
||||
"""Clear per-turn tool bookkeeping (called at turn finalization)."""
|
||||
state.tool_msg_id = None
|
||||
state.seen_tool_lines = set()
|
||||
state.tool_index = 0
|
||||
state.open_tool_index = None
|
||||
|
||||
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
|
||||
delivered = await self._ws_server.broadcast(frame)
|
||||
if delivered == 0:
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame not delivered (outbox lands in M3)",
|
||||
chat_id, frame.type,
|
||||
)
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Send a typing indicator (``typing`` frame, on=true)."""
|
||||
thread_id = None
|
||||
if metadata:
|
||||
tid = metadata.get("thread_id")
|
||||
if isinstance(tid, str) and tid:
|
||||
thread_id = tid
|
||||
await self._ws_server.broadcast(protocol.typing(chat_id, True, thread_id=thread_id))
|
||||
|
||||
async def stop_typing(self, chat_id: str) -> None:
|
||||
"""Clear the typing indicator (``typing`` frame, on=false)."""
|
||||
await self._ws_server.broadcast(protocol.typing(chat_id, False))
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -410,19 +1010,120 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image. M0: not implemented (M4)."""
|
||||
"""Send an image. M1: not implemented (M4)."""
|
||||
return SendResult(success=False, error="android: media not implemented yet (M4)")
|
||||
|
||||
# ── Inbound (app -> agent) ────────────────────────────────────────────
|
||||
|
||||
async def on_message_send(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
"""Handle an inbound ``message.send`` frame.
|
||||
|
||||
Echoes the user message to all devices (multi-device sync + ack),
|
||||
then builds a ``MessageEvent`` and hands it to ``handle_message()``
|
||||
(the gateway's command pipeline + agent turn).
|
||||
"""
|
||||
payload = frame.payload
|
||||
text = payload.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id),
|
||||
)
|
||||
return
|
||||
|
||||
chat_id = frame.chat_id or payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
chat_id = self.home_channel
|
||||
chat_id = chat_id.strip()
|
||||
|
||||
thread_id = frame.thread_id or payload.get("thread_id")
|
||||
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||
thread_id = None
|
||||
|
||||
reply_to = payload.get("reply_to")
|
||||
if not isinstance(reply_to, str) or not reply_to.strip():
|
||||
reply_to = None
|
||||
|
||||
device = self._devices.get(device_id) or {}
|
||||
user_name = device.get("name") or device_id
|
||||
|
||||
# Echo to all devices: the sender confirms (server-assigned id),
|
||||
# other devices see the message too (single-user, multi-device).
|
||||
message_id = f"m_{uuid.uuid4().hex[:16]}"
|
||||
echo = protocol.message(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
role=protocol.ROLE_USER,
|
||||
text=text,
|
||||
thread_id=thread_id,
|
||||
reply_to=reply_to,
|
||||
ts=int(time.time() * 1000),
|
||||
)
|
||||
await self._ws_server.broadcast(echo)
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=self._channel_name(chat_id),
|
||||
chat_type="dm",
|
||||
user_id=device_id,
|
||||
user_name=user_name,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
user_id=device_id,
|
||||
user_name=user_name,
|
||||
source=source,
|
||||
message_id=message_id,
|
||||
reply_to_message_id=reply_to,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
# ── Chat info ─────────────────────────────────────────────────────────
|
||||
|
||||
def _channel_name(self, chat_id: str) -> str:
|
||||
"""Channel display name. M1: home channel only (directory is M3)."""
|
||||
if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL):
|
||||
return self.home_channel_name
|
||||
return chat_id or "chat"
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return ``{name, type, chat_id}`` for a chat.
|
||||
|
||||
M0: the channel directory is not persisted yet, so report the home
|
||||
channel name for the default chat and a generic name otherwise.
|
||||
M1: the channel directory is not persisted yet, so report the home
|
||||
channel name for the default chat and the raw id otherwise.
|
||||
"""
|
||||
name = "Default" if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL) else (chat_id or "chat")
|
||||
return {"name": name, "type": "channel", "chat_id": chat_id}
|
||||
return {
|
||||
"name": self._channel_name(chat_id),
|
||||
"type": "channel",
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
|
||||
# ── hello.ack helpers ─────────────────────────────────────────────────
|
||||
|
||||
def server_caps(self) -> Dict[str, Any]:
|
||||
"""Capability flags advertised in ``hello.ack`` (M2 surface)."""
|
||||
return {
|
||||
"streaming": True, # M2: message.start/update/stop
|
||||
"reasoning": True, # M2: reasoning field on message / message.stop
|
||||
"tools": True, # M2: tool.start/progress/end
|
||||
"media": False, # M4
|
||||
"search": False, # M3
|
||||
"push": self.push_backend,
|
||||
"pickers": False, # M2+
|
||||
}
|
||||
|
||||
def channel_list(self) -> List[Dict[str, Any]]:
|
||||
"""Channel directory for ``hello.ack``. M1: home channel only (M3)."""
|
||||
return [
|
||||
{
|
||||
"chat_id": self.home_channel,
|
||||
"name": self.home_channel_name,
|
||||
"kind": "default",
|
||||
"is_default": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,6 +1132,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point: called by the Hermes plugin system."""
|
||||
# M2: capture the model's separate reasoning_content during streaming so
|
||||
# it can be attached to the turn's message.stop frame (the gateway
|
||||
# otherwise drops it when streaming suppresses the final send).
|
||||
try:
|
||||
ctx.register_hook("on_stream_delta", _on_stream_delta)
|
||||
except Exception:
|
||||
logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
|
||||
ctx.register_platform(
|
||||
name="android",
|
||||
label="Android",
|
||||
|
||||
+180
-1
@@ -7,4 +7,183 @@ created. QR payload for the pairing flow (``interactive_setup``).
|
||||
Storage: ``get_hermes_home()/"android"/devices.db``.
|
||||
|
||||
Milestone M1.
|
||||
"""
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 32 random bytes -> 64 hex chars (docs/09-pairing-security.md)
|
||||
TOKEN_BYTES = 32
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
"""Mint a fresh high-entropy pairing token (64 hex chars)."""
|
||||
return secrets.token_hex(TOKEN_BYTES)
|
||||
|
||||
|
||||
def verify_token(provided: Optional[str], expected: Optional[str]) -> bool:
|
||||
"""Constant-time token comparison (never time-leaks the token)."""
|
||||
if not provided or not expected:
|
||||
return False
|
||||
return hmac.compare_digest(
|
||||
provided.encode("utf-8", "replace"),
|
||||
expected.encode("utf-8", "replace"),
|
||||
)
|
||||
|
||||
|
||||
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
|
||||
"""Pairing URL encoded into the QR / pre-filled into the app.
|
||||
|
||||
``iris://pair?host=<lan-ip>&port=8790&token=<token>`` — the app's
|
||||
Connect screen parses this to pre-fill settings (docs/09 §9.2).
|
||||
"""
|
||||
return (
|
||||
f"iris://pair?host={quote(host, safe='')}"
|
||||
f"&port={int(port)}"
|
||||
f"&secure={'1' if secure else '0'}"
|
||||
f"&token={quote(token, safe='')}"
|
||||
)
|
||||
|
||||
|
||||
def pairing_url(host: str, port: int, secure: bool = False) -> str:
|
||||
"""Plain ws(s) URL the app connects to (shown next to the QR)."""
|
||||
scheme = "wss" if secure else "ws"
|
||||
return f"{scheme}://{host}:{int(port)}/ws"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device registry (SQLite)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DeviceRegistry:
|
||||
"""Persistent device registry under ``get_hermes_home()/"android"``.
|
||||
|
||||
Thread-safe (single connection + lock); all operations are small and
|
||||
fast enough to run inline on the gateway's asyncio loop.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = Path(db_path)
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
caps TEXT NOT NULL DEFAULT '{}',
|
||||
fcm_token TEXT,
|
||||
ntfy_topic TEXT,
|
||||
last_seen REAL NOT NULL DEFAULT 0,
|
||||
created REAL NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
device_id: str,
|
||||
name: str,
|
||||
caps: Optional[Dict[str, Any]] = None,
|
||||
fcm_token: Optional[str] = None,
|
||||
ntfy_topic: Optional[str] = None,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
caps_json = json.dumps(caps or {}, separators=(",", ":"))
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, name, caps, fcm_token, ntfy_topic,
|
||||
last_seen, created)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
caps = excluded.caps,
|
||||
fcm_token = COALESCE(excluded.fcm_token, devices.fcm_token),
|
||||
ntfy_topic = COALESCE(excluded.ntfy_topic, devices.ntfy_topic),
|
||||
last_seen = excluded.last_seen
|
||||
""",
|
||||
(device_id, name or "", caps_json, fcm_token, ntfy_topic, now, now),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def update_push_tokens(
|
||||
self,
|
||||
device_id: str,
|
||||
fcm_token: Optional[str] = None,
|
||||
ntfy_topic: Optional[str] = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE devices SET
|
||||
fcm_token = COALESCE(?, fcm_token),
|
||||
ntfy_topic = COALESCE(?, ntfy_topic),
|
||||
last_seen = ?
|
||||
WHERE device_id = ?
|
||||
""",
|
||||
(fcm_token, ntfy_topic, time.time(), device_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def touch(self, device_id: str) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE devices SET last_seen = ? WHERE device_id = ?",
|
||||
(time.time(), device_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get(self, device_id: str) -> Optional[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM devices WHERE device_id = ?", (device_id,)
|
||||
).fetchone()
|
||||
return _row_to_device(row) if row else None
|
||||
|
||||
def list(self) -> List[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM devices ORDER BY last_seen DESC"
|
||||
).fetchall()
|
||||
return [_row_to_device(r) for r in rows]
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _row_to_device(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
try:
|
||||
caps = json.loads(row["caps"] or "{}")
|
||||
if not isinstance(caps, dict):
|
||||
caps = {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
caps = {}
|
||||
return {
|
||||
"device_id": row["device_id"],
|
||||
"name": row["name"],
|
||||
"caps": caps,
|
||||
"fcm_token": row["fcm_token"],
|
||||
"ntfy_topic": row["ntfy_topic"],
|
||||
"last_seen": row["last_seen"],
|
||||
"created": row["created"],
|
||||
}
|
||||
+345
-4
@@ -5,18 +5,359 @@ constants. ``docs/protocol/frames.schema.json`` is generated/mirrored from
|
||||
this module, and the Kotlin side mirrors these shapes (see
|
||||
``docs/04-wire-protocol.md``).
|
||||
|
||||
Milestone M1: hello/hello.ack, message, error, ping/pong.
|
||||
Milestone M2: message.start/update/stop, reasoning, tool.*, commentary, typing.
|
||||
Milestone M1: hello/hello.ack, message, message.send, error, ping/pong,
|
||||
typing (typing is pulled forward from M2 so the app gets a live
|
||||
"working…" indicator during the first milestone).
|
||||
Milestone M2: message.start/update/stop, reasoning (on message /
|
||||
message.stop), tool.start/progress/end, commentary.
|
||||
Milestone M3: channel.*, search, sync.
|
||||
Milestone M4: media.*.
|
||||
Milestone M5: notification, fcm.register, read.receipt.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
# Frame type constants (the ``type`` field of every frame).
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame type constants (the ``type`` field of every frame)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pairing / lifecycle
|
||||
TYPE_HELLO = "hello"
|
||||
TYPE_HELLO_ACK = "hello.ack"
|
||||
TYPE_ERROR = "error"
|
||||
TYPE_PING = "ping"
|
||||
TYPE_PONG = "pong"
|
||||
TYPE_PONG = "pong"
|
||||
|
||||
# Chat
|
||||
TYPE_MESSAGE = "message"
|
||||
TYPE_MESSAGE_SEND = "message.send"
|
||||
TYPE_TYPING = "typing"
|
||||
|
||||
# Streaming (M2)
|
||||
TYPE_MESSAGE_START = "message.start"
|
||||
TYPE_MESSAGE_UPDATE = "message.update"
|
||||
TYPE_MESSAGE_STOP = "message.stop"
|
||||
|
||||
# Tool activity (M2)
|
||||
TYPE_TOOL_START = "tool.start"
|
||||
TYPE_TOOL_PROGRESS = "tool.progress"
|
||||
TYPE_TOOL_END = "tool.end"
|
||||
|
||||
# Intermediate assistant beat (M2)
|
||||
TYPE_COMMENTARY = "commentary"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error codes (``error`` frame payload.code)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ERR_AUTH = "auth"
|
||||
ERR_NOT_FOUND = "not_found"
|
||||
ERR_RATE_LIMITED = "rate_limited"
|
||||
ERR_MEDIA_TOO_LARGE = "media_too_large"
|
||||
ERR_UNSUPPORTED = "unsupported"
|
||||
ERR_INTERNAL = "internal"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message roles (``message`` frame payload.role)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ROLE_USER = "user"
|
||||
ROLE_ASSISTANT = "assistant"
|
||||
ROLE_SYSTEM = "system"
|
||||
ROLE_CRON = "cron"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
"""One wire frame.
|
||||
|
||||
``v`` is always serialised; ``id``/``chat_id``/``thread_id`` are
|
||||
omitted when ``None`` (events carry no ``id``; chat-scoped frames carry
|
||||
``chat_id``/``thread_id`` at the top level for convenience).
|
||||
"""
|
||||
|
||||
type: str
|
||||
payload: Dict[str, Any] = field(default_factory=dict)
|
||||
id: Optional[int] = None
|
||||
chat_id: Optional[str] = None
|
||||
thread_id: Optional[str] = None
|
||||
v: int = PROTOCOL_VERSION
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d: Dict[str, Any] = {"v": self.v, "type": self.type}
|
||||
if self.id is not None:
|
||||
d["id"] = self.id
|
||||
if self.chat_id is not None:
|
||||
d["chat_id"] = self.chat_id
|
||||
if self.thread_id is not None:
|
||||
d["thread_id"] = self.thread_id
|
||||
d["payload"] = self.payload
|
||||
return d
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw: "str | bytes") -> Optional["Frame"]:
|
||||
"""Parse a text frame. Returns ``None`` for anything not a valid
|
||||
frame (bad JSON, non-object, missing/invalid ``type``) so callers
|
||||
can ignore malformed input (forward-compat)."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError, UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
ftype = data.get("type")
|
||||
if not isinstance(ftype, str) or not ftype:
|
||||
return None
|
||||
payload = data.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
fid = data.get("id")
|
||||
if not isinstance(fid, int) or isinstance(fid, bool):
|
||||
fid = None
|
||||
chat_id = data.get("chat_id")
|
||||
if not isinstance(chat_id, str):
|
||||
chat_id = None
|
||||
thread_id = data.get("thread_id")
|
||||
if not isinstance(thread_id, str):
|
||||
thread_id = None
|
||||
return cls(type=ftype, payload=payload, id=fid, chat_id=chat_id, thread_id=thread_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame constructors (server -> app)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def hello_ack(
|
||||
server_caps: Dict[str, Any],
|
||||
sync_cursor: int = 0,
|
||||
channels: Optional[list] = None,
|
||||
) -> Frame:
|
||||
return Frame(
|
||||
type=TYPE_HELLO_ACK,
|
||||
payload={
|
||||
"server_caps": server_caps,
|
||||
"sync_cursor": sync_cursor,
|
||||
"channels": channels or [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def message(
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
role: str,
|
||||
text: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
reasoning: Optional[str] = None,
|
||||
media: Optional[list] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
tokens: Optional[int] = None,
|
||||
ts: Optional[int] = None,
|
||||
) -> Frame:
|
||||
payload: Dict[str, Any] = {
|
||||
"message_id": message_id,
|
||||
"role": role,
|
||||
"text": text,
|
||||
}
|
||||
if reasoning:
|
||||
payload["reasoning"] = reasoning
|
||||
if media:
|
||||
payload["media"] = media
|
||||
if reply_to:
|
||||
payload["reply_to"] = reply_to
|
||||
if model:
|
||||
payload["model"] = model
|
||||
if tokens is not None:
|
||||
payload["tokens"] = tokens
|
||||
if ts is not None:
|
||||
payload["ts"] = ts
|
||||
return Frame(type=TYPE_MESSAGE, chat_id=chat_id, thread_id=thread_id, payload=payload)
|
||||
|
||||
|
||||
def typing(chat_id: str, on: bool = True, *, thread_id: Optional[str] = None) -> Frame:
|
||||
return Frame(
|
||||
type=TYPE_TYPING,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload={"on": on},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming frames (M2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def message_start(
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
role: str = ROLE_ASSISTANT,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> Frame:
|
||||
"""Open a streaming bubble."""
|
||||
return Frame(
|
||||
type=TYPE_MESSAGE_START,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload={"message_id": message_id, "role": role},
|
||||
)
|
||||
|
||||
|
||||
def message_update(
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
text: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> Frame:
|
||||
"""Replace the live bubble text (full snapshot)."""
|
||||
return Frame(
|
||||
type=TYPE_MESSAGE_UPDATE,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload={"message_id": message_id, "text": text},
|
||||
)
|
||||
|
||||
|
||||
def message_stop(
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
final_text: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
reasoning: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
tokens: Optional[int] = None,
|
||||
ts: Optional[int] = None,
|
||||
) -> Frame:
|
||||
"""Finalize a streaming bubble."""
|
||||
payload: Dict[str, Any] = {
|
||||
"message_id": message_id,
|
||||
"final_text": final_text,
|
||||
}
|
||||
if reasoning:
|
||||
payload["reasoning"] = reasoning
|
||||
if model:
|
||||
payload["model"] = model
|
||||
if tokens is not None:
|
||||
payload["tokens"] = tokens
|
||||
if ts is not None:
|
||||
payload["ts"] = ts
|
||||
return Frame(
|
||||
type=TYPE_MESSAGE_STOP,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool activity frames (M2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tool_start(
|
||||
chat_id: str,
|
||||
index: int,
|
||||
name: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
preview: Optional[str] = None,
|
||||
args: Optional[Dict[str, Any]] = None,
|
||||
) -> Frame:
|
||||
payload: Dict[str, Any] = {"index": index, "name": name}
|
||||
if preview:
|
||||
payload["preview"] = preview
|
||||
if args:
|
||||
payload["args"] = args
|
||||
return Frame(
|
||||
type=TYPE_TOOL_START,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def tool_progress(
|
||||
chat_id: str,
|
||||
index: int,
|
||||
name: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> Frame:
|
||||
payload: Dict[str, Any] = {"index": index, "name": name}
|
||||
if note:
|
||||
payload["note"] = note
|
||||
return Frame(
|
||||
type=TYPE_TOOL_PROGRESS,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def tool_end(
|
||||
chat_id: str,
|
||||
index: int,
|
||||
name: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
ok: bool = True,
|
||||
duration: Optional[float] = None,
|
||||
output_preview: Optional[str] = None,
|
||||
) -> Frame:
|
||||
payload: Dict[str, Any] = {"index": index, "name": name, "ok": ok}
|
||||
if duration is not None:
|
||||
payload["duration"] = duration
|
||||
if output_preview:
|
||||
payload["output_preview"] = output_preview
|
||||
return Frame(
|
||||
type=TYPE_TOOL_END,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commentary frame (M2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def commentary(
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
text: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> Frame:
|
||||
"""An intermediate assistant beat (between tool iterations)."""
|
||||
return Frame(
|
||||
type=TYPE_COMMENTARY,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload={"message_id": message_id, "text": text},
|
||||
)
|
||||
|
||||
|
||||
def error(code: str, message: str, *, id: Optional[int] = None) -> Frame:
|
||||
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
|
||||
|
||||
|
||||
def pong(ts: Optional[int] = None) -> Frame:
|
||||
payload: Dict[str, Any] = {}
|
||||
if ts is not None:
|
||||
payload["ts"] = ts
|
||||
return Frame(type=TYPE_PONG, payload=payload)
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WS test-client harness (docs/13-testing.md §13.2).
|
||||
|
||||
Connects to the REAL running gateway and drives a turn, printing every
|
||||
frame. This is how we empirically confirm the exact frame shapes before /
|
||||
while building the Kotlin client.
|
||||
|
||||
Usage::
|
||||
|
||||
hermes gateway & # with the android plugin
|
||||
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
|
||||
--send "hello"
|
||||
|
||||
Options:
|
||||
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
|
||||
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
|
||||
--device device_id (default: probe-<rand>)
|
||||
--send TEXT send this message after pairing (default: "hello")
|
||||
--timeout S seconds to wait for the final reply (default 120)
|
||||
--authfail expect an auth rejection (wrong token) and exit 0 on it
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError: # pragma: no cover
|
||||
sys.stderr.write("websockets is required (hermes core dep); run inside the hermes venv\n")
|
||||
raise
|
||||
|
||||
|
||||
def _print_frame(raw):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
print(f" <- {raw!r}")
|
||||
return None
|
||||
ftype = data.get("type", "?")
|
||||
chat = data.get("chat_id")
|
||||
fid = data.get("id")
|
||||
payload = data.get("payload", {})
|
||||
# Compact one-line summary + full payload for the interesting frames.
|
||||
extra = ""
|
||||
if ftype == "message":
|
||||
text = (payload.get("text") or "")
|
||||
extra = f" role={payload.get('role')} id={payload.get('message_id')} text={text[:120]!r}"
|
||||
if payload.get("reasoning"):
|
||||
extra += f" reasoning={payload['reasoning'][:80]!r}"
|
||||
elif ftype == "message.start":
|
||||
extra = f" id={payload.get('message_id')} role={payload.get('role')}"
|
||||
elif ftype == "message.update":
|
||||
text = (payload.get("text") or "")
|
||||
extra = f" id={payload.get('message_id')} text={text[:100]!r}"
|
||||
elif ftype == "message.stop":
|
||||
text = (payload.get("final_text") or "")
|
||||
extra = f" id={payload.get('message_id')} text={text[:120]!r}"
|
||||
if payload.get("reasoning"):
|
||||
extra += f" reasoning={payload['reasoning'][:80]!r}"
|
||||
elif ftype == "tool.start":
|
||||
extra = (f" idx={payload.get('index')} name={payload.get('name')!r} "
|
||||
f"preview={str(payload.get('preview'))[:80]!r}")
|
||||
elif ftype == "tool.progress":
|
||||
extra = f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}"
|
||||
elif ftype == "tool.end":
|
||||
extra = (f" idx={payload.get('index')} name={payload.get('name')!r} "
|
||||
f"ok={payload.get('ok')} dur={payload.get('duration')}")
|
||||
elif ftype == "commentary":
|
||||
extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}"
|
||||
elif ftype == "hello.ack":
|
||||
extra = f" caps={payload.get('server_caps')} cursor={payload.get('sync_cursor')}"
|
||||
elif ftype == "error":
|
||||
extra = f" code={payload.get('code')} msg={payload.get('message')!r}"
|
||||
elif ftype == "typing":
|
||||
extra = f" on={payload.get('on')}"
|
||||
elif ftype == "pong":
|
||||
extra = ""
|
||||
scope = f" chat={chat}" if chat else ""
|
||||
idpart = f" id={fid}" if fid is not None else ""
|
||||
print(f" <- {ftype}{idpart}{scope}{extra}")
|
||||
return data
|
||||
|
||||
|
||||
async def run(args) -> int:
|
||||
url = args.url
|
||||
token = args.token
|
||||
device_id = args.device
|
||||
|
||||
print(f"== ws_probe: connecting {url} device={device_id}")
|
||||
try:
|
||||
ws = await websockets.connect(url, open_timeout=10)
|
||||
except Exception as e:
|
||||
print(f"!! connect failed: {e}")
|
||||
return 2
|
||||
|
||||
hello = {
|
||||
"v": 1,
|
||||
"type": "hello",
|
||||
"payload": {
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
"device_name": "ws-probe",
|
||||
"caps": {"min_protocol": 1},
|
||||
},
|
||||
}
|
||||
await ws.send(json.dumps(hello))
|
||||
print(" -> hello")
|
||||
|
||||
# First response must be hello.ack (or an auth error).
|
||||
try:
|
||||
first = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
print("!! no hello.ack within 10s")
|
||||
await ws.close()
|
||||
return 3
|
||||
data = _print_frame(first)
|
||||
if data is None or data.get("type") != "hello.ack":
|
||||
if args.authfail:
|
||||
print("== auth rejected as expected")
|
||||
await ws.close()
|
||||
return 0
|
||||
print("!! expected hello.ack")
|
||||
await ws.close()
|
||||
return 4
|
||||
if args.authfail:
|
||||
print("!! expected auth rejection but got hello.ack")
|
||||
await ws.close()
|
||||
return 5
|
||||
|
||||
if not args.send:
|
||||
print("== paired OK (no --send; exiting)")
|
||||
await ws.close()
|
||||
return 0
|
||||
|
||||
# Drive a turn.
|
||||
msg_id = 1
|
||||
send_frame = {
|
||||
"v": 1,
|
||||
"id": msg_id,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"payload": {"text": args.send},
|
||||
}
|
||||
await ws.send(json.dumps(send_frame))
|
||||
print(f" -> message.send id={msg_id} text={args.send!r}")
|
||||
|
||||
deadline = time.time() + args.timeout
|
||||
got_final = False
|
||||
seen_final_frame = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
|
||||
except asyncio.TimeoutError:
|
||||
print(f"!! timeout after {args.timeout}s waiting for final message")
|
||||
await ws.close()
|
||||
return 6
|
||||
data = _print_frame(raw)
|
||||
if data is None:
|
||||
continue
|
||||
ftype = data.get("type")
|
||||
payload = data.get("payload") or {}
|
||||
# A standalone assistant `message` (non-streaming) is immediately final.
|
||||
if ftype == "message" and payload.get("role") == "assistant":
|
||||
got_final = True
|
||||
break
|
||||
# A `message.stop` finalizes a streaming segment; the turn is done once
|
||||
# typing stops afterwards (multi-segment turns have several stops).
|
||||
if ftype == "message.stop":
|
||||
seen_final_frame = True
|
||||
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
|
||||
got_final = True
|
||||
break
|
||||
await ws.close()
|
||||
if got_final:
|
||||
print("== final assistant message received")
|
||||
return 0
|
||||
print("!! no final assistant message")
|
||||
return 7
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
|
||||
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
|
||||
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
||||
p.add_argument("--send", default="hello")
|
||||
p.add_argument("--timeout", type=float, default=120.0)
|
||||
p.add_argument("--authfail", action="store_true",
|
||||
help="expect an auth rejection (wrong token)")
|
||||
args = p.parse_args()
|
||||
if not args.token and not args.authfail:
|
||||
p.error("--token (or $ANDROID_TOKEN) is required")
|
||||
return asyncio.run(run(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+300
-15
@@ -1,24 +1,309 @@
|
||||
"""WebSocket server, connection registry, and frame routing.
|
||||
|
||||
Runs on the gateway's asyncio loop (started in ``AndroidAdapter.connect()``).
|
||||
Uses the ``websockets`` core dep (v15): ``websockets.serve(handler, host,
|
||||
port, ssl=ctx)``.
|
||||
Uses the ``websockets`` core dep (v15): ``websockets.asyncio.server.serve(
|
||||
handler, host, port, ssl=ctx)``.
|
||||
|
||||
Per-connection handler:
|
||||
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 the connection registry (``device_id ->
|
||||
{ws, caps, fcm_token}``), send ``hello.ack {server_caps, sync_cursor,
|
||||
channels[]}``.
|
||||
1. Await first frame (bounded); 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 the device registry (SQLite) + 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.
|
||||
4. On close: deregister.
|
||||
|
||||
Routing: ``emit(chat_id, frame)`` broadcasts to ALL connected devices
|
||||
(single-user model). Heartbeat via WS ping/pong + app-level ping/pong.
|
||||
Backpressure: bounded per-connection send queue; coalesce ``message.update``
|
||||
under pressure, never drop ``message``/``tool.end``/``notification``.
|
||||
Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
|
||||
model). Heartbeat via WS ping/pong (websockets built-in) + app-level
|
||||
``ping``/``pong`` frames.
|
||||
|
||||
Milestone M1.
|
||||
"""
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from websockets.asyncio.server import ServerConnection, serve
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from . import protocol
|
||||
from .pairing import DeviceRegistry, verify_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long a new socket may take to present its ``hello`` before we drop it.
|
||||
HELLO_TIMEOUT_S = 10.0
|
||||
|
||||
# Close codes (4000-4999 are reserved for applications).
|
||||
CLOSE_AUTH_FAILED = 4401
|
||||
CLOSE_REPLACED = 4402
|
||||
CLOSE_SHUTDOWN = 1001
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceConnection:
|
||||
"""One live, authenticated device socket."""
|
||||
|
||||
device_id: str
|
||||
device_name: str
|
||||
ws: ServerConnection
|
||||
caps: Dict[str, Any] = field(default_factory=dict)
|
||||
fcm_token: Optional[str] = None
|
||||
ntfy_topic: Optional[str] = None
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class WsServer:
|
||||
"""The plugin's WebSocket server + live connection registry."""
|
||||
|
||||
def __init__(self, adapter: Any, devices: DeviceRegistry):
|
||||
self._adapter = adapter
|
||||
self._devices = devices
|
||||
self._server: Optional[Any] = None
|
||||
self._connections: Dict[str, DeviceConnection] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Bind and start serving. Raises on bind failure (adapter maps it
|
||||
to a retryable fatal error)."""
|
||||
adapter = self._adapter
|
||||
ssl_ctx: Optional[ssl.SSLContext] = None
|
||||
if adapter.ws_cert and adapter.ws_key:
|
||||
try:
|
||||
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_ctx.load_cert_chain(adapter.ws_cert, adapter.ws_key)
|
||||
except Exception as e:
|
||||
adapter._set_fatal_error(
|
||||
"tls_config", f"WS TLS cert/key invalid: {e}", retryable=False
|
||||
)
|
||||
raise
|
||||
try:
|
||||
self._server = await serve(
|
||||
self._handler,
|
||||
adapter.host,
|
||||
adapter.port,
|
||||
ssl=ssl_ctx,
|
||||
# Media uploads (M4) are chunked binary frames; allow the
|
||||
# configured max upload size per frame.
|
||||
max_size=adapter.max_upload_bytes,
|
||||
# WS-level heartbeat: dead peers are reaped by websockets.
|
||||
ping_interval=20,
|
||||
ping_timeout=20,
|
||||
open_timeout=10,
|
||||
)
|
||||
except OSError as e:
|
||||
adapter._set_fatal_error(
|
||||
"bind_failed", f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
raise
|
||||
scheme = "wss" if ssl_ctx else "ws"
|
||||
logger.info(
|
||||
"android: WS server listening on %s://%s:%s/ws",
|
||||
scheme, adapter.host, adapter.port,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop serving and close all device sockets."""
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
try:
|
||||
await self._server.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._server = None
|
||||
for conn in list(self._connections.values()):
|
||||
try:
|
||||
await conn.ws.close(code=CLOSE_SHUTDOWN, reason="gateway shutting down")
|
||||
except Exception:
|
||||
pass
|
||||
self._connections.clear()
|
||||
|
||||
# ── Registry ──────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def connections(self) -> Dict[str, DeviceConnection]:
|
||||
return dict(self._connections)
|
||||
|
||||
def has_devices(self) -> bool:
|
||||
return bool(self._connections)
|
||||
|
||||
def device_ids(self) -> list:
|
||||
return list(self._connections.keys())
|
||||
|
||||
# ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
async def broadcast(self, frame: protocol.Frame) -> int:
|
||||
"""Send a frame to every connected device. Returns devices reached.
|
||||
Best-effort: a dead socket is skipped (deregistered on its own close)."""
|
||||
data = frame.to_json()
|
||||
sent = 0
|
||||
for conn in list(self._connections.values()):
|
||||
try:
|
||||
await conn.ws.send(data)
|
||||
sent += 1
|
||||
except Exception:
|
||||
pass
|
||||
return sent
|
||||
|
||||
async def send_to(self, device_id: str, frame: protocol.Frame) -> bool:
|
||||
"""Send a frame to one device (request responses / errors)."""
|
||||
conn = self._connections.get(device_id)
|
||||
if conn is None:
|
||||
return False
|
||||
try:
|
||||
await conn.ws.send(frame.to_json())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── Per-connection handler ────────────────────────────────────────────
|
||||
|
||||
async def _handler(self, ws: ServerConnection) -> None:
|
||||
# 1. hello auth -----------------------------------------------------
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=HELLO_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("android: dropping socket with no hello (timeout)")
|
||||
await self._close_quiet(ws, 1000, "no hello")
|
||||
return
|
||||
except ConnectionClosed:
|
||||
return
|
||||
|
||||
frame = protocol.Frame.from_json(raw)
|
||||
if frame is None or frame.type != protocol.TYPE_HELLO:
|
||||
await self._reject(ws, "first frame must be hello")
|
||||
return
|
||||
|
||||
payload = frame.payload
|
||||
if not verify_token(payload.get("token"), self._adapter.token):
|
||||
peer = getattr(ws, "remote_address", None)
|
||||
logger.warning("android: hello rejected: invalid token (peer=%s)", peer)
|
||||
await self._reject(ws, "invalid token")
|
||||
return
|
||||
|
||||
device_id = str(payload.get("device_id") or "").strip()
|
||||
if not device_id or len(device_id) > 128:
|
||||
await self._reject(ws, "device_id required")
|
||||
return
|
||||
|
||||
if (
|
||||
not self._adapter.allow_all
|
||||
and self._adapter.allowed_users
|
||||
and device_id not in self._adapter.allowed_users
|
||||
):
|
||||
logger.warning("android: hello rejected: device %s not allowlisted", device_id)
|
||||
await self._reject(ws, "device not allowed")
|
||||
return
|
||||
|
||||
device_name = str(payload.get("device_name") or device_id)[:120]
|
||||
caps = payload.get("caps")
|
||||
if not isinstance(caps, dict):
|
||||
caps = {}
|
||||
fcm_token = payload.get("fcm_token")
|
||||
if not isinstance(fcm_token, str):
|
||||
fcm_token = None
|
||||
ntfy_topic = payload.get("ntfy_topic")
|
||||
if not isinstance(ntfy_topic, str):
|
||||
ntfy_topic = None
|
||||
|
||||
# 2. register --------------------------------------------------------
|
||||
try:
|
||||
self._devices.upsert(device_id, device_name, caps, fcm_token, ntfy_topic)
|
||||
except Exception:
|
||||
logger.warning("android: device registry upsert failed", exc_info=True)
|
||||
|
||||
conn = DeviceConnection(
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
ws=ws,
|
||||
caps=caps,
|
||||
fcm_token=fcm_token,
|
||||
ntfy_topic=ntfy_topic,
|
||||
)
|
||||
async with self._lock:
|
||||
old = self._connections.pop(device_id, None)
|
||||
self._connections[device_id] = conn
|
||||
if old is not None:
|
||||
# Same device re-paired from a new socket: the new one wins.
|
||||
try:
|
||||
await old.ws.close(code=CLOSE_REPLACED, reason="replaced by newer connection")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ack = protocol.hello_ack(
|
||||
server_caps=self._adapter.server_caps(),
|
||||
sync_cursor=0, # outbox lands in M3; cursor starts at 0
|
||||
channels=self._adapter.channel_list(),
|
||||
)
|
||||
try:
|
||||
await ws.send(ack.to_json())
|
||||
except Exception:
|
||||
return
|
||||
logger.info("android: device paired: %s (%s)", device_name, device_id)
|
||||
|
||||
# 3. frame loop ------------------------------------------------------
|
||||
try:
|
||||
async for raw in ws:
|
||||
await self._on_frame(ws, device_id, raw)
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
|
||||
finally:
|
||||
async with self._lock:
|
||||
current = self._connections.get(device_id)
|
||||
if current is not None and current.ws is ws:
|
||||
self._connections.pop(device_id, None)
|
||||
logger.info("android: device disconnected: %s", device_id)
|
||||
|
||||
# ── Inbound dispatch ──────────────────────────────────────────────────
|
||||
|
||||
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> None:
|
||||
frame = protocol.Frame.from_json(raw)
|
||||
if frame is None:
|
||||
return # malformed / unknown binary: ignore (forward-compat)
|
||||
|
||||
if frame.type == protocol.TYPE_PING:
|
||||
ts = frame.payload.get("ts")
|
||||
await self._send_quiet(ws, protocol.pong(ts if isinstance(ts, int) else None))
|
||||
elif frame.type == protocol.TYPE_MESSAGE_SEND:
|
||||
await self._adapter.on_message_send(frame, device_id)
|
||||
elif frame.type == "fcm.register":
|
||||
fcm_token = frame.payload.get("fcm_token")
|
||||
ntfy_topic = frame.payload.get("ntfy_topic")
|
||||
if isinstance(fcm_token, str) or isinstance(ntfy_topic, str):
|
||||
try:
|
||||
self._devices.update_push_tokens(
|
||||
device_id,
|
||||
fcm_token=fcm_token if isinstance(fcm_token, str) else None,
|
||||
ntfy_topic=ntfy_topic if isinstance(ntfy_topic, str) else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: fcm.register update failed", exc_info=True)
|
||||
# Unknown types are ignored (forward-compat).
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
|
||||
try:
|
||||
await ws.send(frame.to_json())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _reject(self, ws: ServerConnection, reason: str) -> None:
|
||||
await self._send_quiet(ws, protocol.error(protocol.ERR_AUTH, reason))
|
||||
await self._close_quiet(ws, CLOSE_AUTH_FAILED, "auth failed")
|
||||
|
||||
async def _close_quiet(self, ws: ServerConnection, code: int, reason: str) -> None:
|
||||
try:
|
||||
await ws.close(code=code, reason=reason)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in new issue
Block a user