nanobot-voice-interface/supertonic_gateway.py

321 lines
12 KiB
Python
Raw Normal View History

2026-03-12 09:25:15 -04:00
"""SuperTonic Gateway - nanobot integration for the web UI.
2026-02-28 22:12:04 -05:00
2026-03-05 15:10:14 -05:00
Connects to the already-running nanobot process via a Unix domain socket.
nanobot must be started separately (e.g. ``nanobot gateway``) with the API
channel enabled in its config.
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
Wire protocol (newline-delimited JSON-RPC 2.0)
-----------------------------------------------
Client -> nanobot notifications::
{"jsonrpc": "2.0", "method": "message.send",
"params": {"content": "hello", "chat_id": "web", "metadata": {}}}
{"jsonrpc": "2.0", "method": "card.respond",
"params": {"card_id": "card_123", "value": "Option A"}}
{"jsonrpc": "2.0", "method": "command.execute",
"params": {"command": "reset", "chat_id": "web"}}
nanobot -> client notifications::
{"jsonrpc": "2.0", "method": "message",
"params": {"content": "Hi!", "chat_id": "web", "is_progress": false}}
{"jsonrpc": "2.0", "method": "agent_state",
"params": {"state": "thinking", "chat_id": "web"}}
{"jsonrpc": "2.0", "method": "card",
"params": {"id": "card_123", "kind": "text", "title": "Weather", "lane": "context"}}
2026-03-05 15:10:14 -05:00
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
2026-03-12 09:25:15 -04:00
from typing import Any
2026-02-28 22:12:04 -05:00
2026-03-05 15:10:14 -05:00
from wisper import WisperBus, WisperEvent
2026-02-28 22:12:04 -05:00
2026-03-05 15:10:14 -05:00
DEFAULT_SOCKET_PATH = Path.home() / ".nanobot" / "api.sock"
2026-03-12 09:25:15 -04:00
_JSONRPC_VERSION = "2.0"
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
def _encode(obj: dict[str, Any]) -> bytes:
return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
def _jsonrpc_notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {
"jsonrpc": _JSONRPC_VERSION,
"method": method,
}
if params is not None:
payload["params"] = params
return payload
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
class NanobotApiProcess:
"""Connects to the running nanobot process via its Unix domain socket."""
2026-02-28 22:12:04 -05:00
2026-03-05 15:10:14 -05:00
def __init__(self, bus: WisperBus, socket_path: Path) -> None:
2026-02-28 22:12:04 -05:00
self._bus = bus
2026-03-05 15:10:14 -05:00
self._socket_path = socket_path
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._read_task: asyncio.Task | None = None
self._socket_inode: int | None = None
2026-02-28 22:12:04 -05:00
@property
def running(self) -> bool:
2026-03-05 15:10:14 -05:00
return (
self._writer is not None
and not self._writer.is_closing()
and self._read_task is not None
and not self._read_task.done()
)
2026-02-28 22:12:04 -05:00
def matches_current_socket(self) -> bool:
if self._socket_inode is None:
return False
try:
return self._socket_path.stat().st_ino == self._socket_inode
except FileNotFoundError:
return False
except OSError:
return False
2026-02-28 22:12:04 -05:00
async def start(self) -> None:
if self.running:
2026-03-12 09:25:15 -04:00
await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
2026-02-28 22:12:04 -05:00
return
2026-03-05 15:10:14 -05:00
if not self._socket_path.exists():
2026-02-28 22:12:04 -05:00
await self._bus.publish(
WisperEvent(
role="system",
2026-03-05 15:10:14 -05:00
text=(
f"Nanobot API socket not found at {self._socket_path}. "
"Make sure nanobot is running with the API channel enabled "
"(set channels.api.enabled = true in ~/.nanobot/config.json, "
"then run: nanobot gateway)."
),
2026-02-28 22:12:04 -05:00
)
)
return
try:
2026-03-05 15:10:14 -05:00
self._reader, self._writer = await asyncio.open_unix_connection(
path=str(self._socket_path)
2026-02-28 22:12:04 -05:00
)
self._socket_inode = self._socket_path.stat().st_ino
2026-03-05 15:10:14 -05:00
except OSError as exc:
2026-02-28 22:12:04 -05:00
await self._bus.publish(
2026-03-12 09:25:15 -04:00
WisperEvent(role="system", text=f"Could not connect to nanobot API socket: {exc}")
2026-02-28 22:12:04 -05:00
)
return
2026-03-05 15:10:14 -05:00
self._read_task = asyncio.create_task(self._read_loop(), name="nanobot-api-reader")
await self._bus.publish(WisperEvent(role="system", text="Connected to nanobot."))
2026-03-12 09:25:15 -04:00
async def send(self, text: str, metadata: dict[str, Any] | None = None) -> None:
2026-03-05 15:10:14 -05:00
if not self.running or self._writer is None:
2026-03-12 09:25:15 -04:00
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
raise RuntimeError("Not connected to nanobot.")
2026-03-05 15:10:14 -05:00
try:
2026-03-12 09:25:15 -04:00
await self._send_notification(
"message.send",
{
"content": text,
"chat_id": "web",
"metadata": dict(metadata or {}),
},
)
2026-03-05 15:10:14 -05:00
except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup()
raise RuntimeError(f"Send failed: {exc}") from exc
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
async def send_card_response(self, card_id: str, value: str) -> None:
2026-03-05 15:10:14 -05:00
if not self.running or self._writer is None:
raise RuntimeError("Not connected to nanobot.")
2026-03-05 15:10:14 -05:00
try:
2026-03-12 09:25:15 -04:00
await self._send_notification(
"card.respond",
{
"card_id": card_id,
"value": value,
},
)
2026-03-05 15:10:14 -05:00
except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup()
raise RuntimeError(f"Send failed: {exc}") from exc
2026-03-05 15:10:14 -05:00
async def send_command(self, command: str) -> None:
if not self.running or self._writer is None:
2026-03-12 09:25:15 -04:00
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
raise RuntimeError("Not connected to nanobot.")
2026-02-28 22:12:04 -05:00
try:
2026-03-12 09:25:15 -04:00
await self._send_notification(
"command.execute",
{
"command": command,
"chat_id": "web",
},
)
2026-02-28 22:12:04 -05:00
except OSError as exc:
2026-03-05 15:10:14 -05:00
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup()
raise RuntimeError(f"Send failed: {exc}") from exc
2026-02-28 22:12:04 -05:00
async def stop(self) -> None:
2026-03-05 15:10:14 -05:00
await self._cleanup()
await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot."))
async def _cleanup(self) -> None:
if self._read_task and not self._read_task.done():
self._read_task.cancel()
2026-02-28 22:12:04 -05:00
try:
2026-03-05 15:10:14 -05:00
await self._read_task
except asyncio.CancelledError:
2026-02-28 22:12:04 -05:00
pass
2026-03-05 15:10:14 -05:00
self._read_task = None
2026-02-28 22:12:04 -05:00
2026-03-05 15:10:14 -05:00
if self._writer:
2026-02-28 22:12:04 -05:00
try:
2026-03-05 15:10:14 -05:00
self._writer.close()
await self._writer.wait_closed()
2026-02-28 22:12:04 -05:00
except OSError:
pass
2026-03-05 15:10:14 -05:00
self._writer = None
self._reader = None
self._socket_inode = None
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
assert self._writer is not None
self._writer.write(_encode(_jsonrpc_notification(method, params)))
await self._writer.drain()
2026-03-05 15:10:14 -05:00
async def _read_loop(self) -> None:
assert self._reader is not None
try:
while True:
try:
line = await self._reader.readline()
except OSError:
2026-03-04 08:20:42 -05:00
break
2026-03-05 15:10:14 -05:00
if not line:
2026-03-12 09:25:15 -04:00
break
2026-03-05 15:10:14 -05:00
await self._handle_line(line)
finally:
2026-03-12 09:25:15 -04:00
await self._bus.publish(WisperEvent(role="system", text="Nanobot closed the connection."))
2026-03-05 15:10:14 -05:00
self._writer = None
self._reader = None
2026-02-28 22:12:04 -05:00
2026-03-05 15:10:14 -05:00
async def _handle_line(self, line: bytes) -> None:
raw = line.decode(errors="replace").strip()
if not raw:
return
try:
obj = json.loads(raw)
except json.JSONDecodeError:
2026-02-28 22:12:04 -05:00
await self._bus.publish(
2026-03-05 15:10:14 -05:00
WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}")
2026-02-28 22:12:04 -05:00
)
2026-03-05 15:10:14 -05:00
return
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
if not isinstance(obj, dict) or obj.get("jsonrpc") != _JSONRPC_VERSION:
await self._bus.publish(
WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}")
)
return
if "method" not in obj:
error = obj.get("error")
if isinstance(error, dict):
message = str(error.get("message", "Unknown JSON-RPC error"))
await self._bus.publish(WisperEvent(role="system", text=f"Nanobot error: {message}"))
return
2026-03-04 08:20:42 -05:00
2026-03-12 09:25:15 -04:00
method = str(obj.get("method", "")).strip()
params = obj.get("params", {})
if params is None or not isinstance(params, dict):
params = {}
if method == "message":
content = str(params.get("content", ""))
is_progress = bool(params.get("is_progress", False))
is_tool_hint = bool(params.get("is_tool_hint", False))
2026-03-05 15:10:14 -05:00
if is_progress:
2026-03-12 09:25:15 -04:00
role = "nanobot-tool" if is_tool_hint else "nanobot-progress"
await self._bus.publish(WisperEvent(role=role, text=content))
2026-03-05 15:10:14 -05:00
else:
await self._bus.publish(WisperEvent(role="nanobot", text=content))
await self._bus.publish(WisperEvent(role="nanobot-tts", text=content))
2026-03-12 09:25:15 -04:00
elif method == "agent_state":
state = str(params.get("state", ""))
2026-03-05 15:10:14 -05:00
await self._bus.publish(WisperEvent(role="agent-state", text=state))
2026-03-12 09:25:15 -04:00
elif method == "card":
await self._bus.publish(WisperEvent(role="card", text=json.dumps(params)))
2026-02-28 22:12:04 -05:00
class SuperTonicGateway:
def __init__(self) -> None:
self.bus = WisperBus()
self._lock = asyncio.Lock()
2026-03-05 15:10:14 -05:00
self._process: NanobotApiProcess | None = None
socket_path = Path(os.getenv("NANOBOT_API_SOCKET", str(DEFAULT_SOCKET_PATH))).expanduser()
self._socket_path = socket_path
2026-02-28 22:12:04 -05:00
async def subscribe(self) -> asyncio.Queue[WisperEvent]:
return await self.bus.subscribe()
async def unsubscribe(self, queue: asyncio.Queue[WisperEvent]) -> None:
await self.bus.unsubscribe(queue)
2026-03-12 09:25:15 -04:00
async def connect_nanobot(self) -> None:
2026-02-28 22:12:04 -05:00
async with self._lock:
2026-03-05 15:10:14 -05:00
if self._process and self._process.running:
2026-03-12 09:25:15 -04:00
await self.bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
2026-02-28 22:12:04 -05:00
return
2026-03-05 15:10:14 -05:00
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path)
await self._process.start()
2026-02-28 22:12:04 -05:00
async def _ensure_connected_process(self) -> NanobotApiProcess:
if self._process and self._process.running and self._process.matches_current_socket():
return self._process
if self._process:
await self._process.stop()
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path)
await self._process.start()
if not self._process.running or not self._process.matches_current_socket():
raise RuntimeError("Not connected to nanobot.")
return self._process
2026-03-12 09:25:15 -04:00
async def send_user_message(self, text: str, metadata: dict[str, Any] | None = None) -> None:
2026-02-28 22:12:04 -05:00
message = text.strip()
if not message:
return
await self.bus.publish(WisperEvent(role="user", text=message))
async with self._lock:
process = await self._ensure_connected_process()
await process.send(message, metadata=metadata)
2026-03-05 15:10:14 -05:00
2026-03-12 09:25:15 -04:00
async def send_card_response(self, card_id: str, value: str) -> None:
2026-03-05 15:10:14 -05:00
async with self._lock:
process = await self._ensure_connected_process()
await process.send_card_response(card_id, value)
2026-03-05 15:10:14 -05:00
async def send_command(self, command: str) -> None:
async with self._lock:
process = await self._ensure_connected_process()
await process.send_command(command)
2026-02-28 22:12:04 -05:00
2026-03-12 09:25:15 -04:00
async def disconnect_nanobot(self) -> None:
2026-02-28 22:12:04 -05:00
async with self._lock:
2026-03-05 15:10:14 -05:00
if self._process:
await self._process.stop()
self._process = None
2026-02-28 22:12:04 -05:00
async def shutdown(self) -> None:
2026-03-12 09:25:15 -04:00
await self.disconnect_nanobot()