mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
chore: address copilot comments
This commit is contained in:
parent
5500b51800
commit
850fac14e3
8 changed files with 87 additions and 31 deletions
|
|
@ -34,11 +34,6 @@ dependencies = [
|
|||
"edge-tts>=7.2.7,<8",
|
||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
||||
# Web dashboard (`hermes dashboard` — localhost SPA + API)
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
# Embedded Chat tab (/api/pty) on POSIX — required for PTY, not optional
|
||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
@ -83,7 +78,7 @@ termux = [
|
|||
]
|
||||
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
|
||||
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
|
||||
# Same pins as core; kept so `hermes-agent[web]` stays a no-op alias for older docs/scripts.
|
||||
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
||||
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||
rl = [
|
||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ already passes to ``write``). No JSON-RPC envelope here — the dashboard's
|
|||
|
||||
Failure mode: silent. The agent loop must never block waiting for the
|
||||
sidecar to drain. A dead WS short-circuits all subsequent writes.
|
||||
Actual ``send`` calls run on a daemon thread so the TeeTransport's
|
||||
``write`` returns after enqueueing (best-effort; drop when the queue is full).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -29,15 +32,21 @@ except ImportError: # pragma: no cover - websockets is a required install path
|
|||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_DRAIN_STOP = object()
|
||||
|
||||
_QUEUE_MAX = 256
|
||||
|
||||
|
||||
class WsPublisherTransport:
|
||||
__slots__ = ("_url", "_lock", "_ws", "_dead")
|
||||
__slots__ = ("_url", "_lock", "_ws", "_dead", "_q", "_worker")
|
||||
|
||||
def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None:
|
||||
self._url = url
|
||||
self._lock = threading.Lock()
|
||||
self._ws: Optional[object] = None
|
||||
self._dead = False
|
||||
self._q: queue.Queue[object] = queue.Queue(maxsize=_QUEUE_MAX)
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
|
||||
if ws_connect is None:
|
||||
self._dead = True
|
||||
|
|
@ -51,29 +60,65 @@ class WsPublisherTransport:
|
|||
self._dead = True
|
||||
self._ws = None
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
if self._dead or self._ws is None:
|
||||
return False
|
||||
return
|
||||
|
||||
self._worker = threading.Thread(
|
||||
target=self._drain,
|
||||
name="hermes-ws-pub",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def _drain(self) -> None:
|
||||
while True:
|
||||
item = self._q.get()
|
||||
if item is _DRAIN_STOP:
|
||||
return
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
if self._ws is None:
|
||||
continue
|
||||
try:
|
||||
with self._lock:
|
||||
self._ws.send(json.dumps(obj, ensure_ascii=False)) # type: ignore[union-attr]
|
||||
|
||||
return True
|
||||
if self._ws is not None:
|
||||
self._ws.send(item) # type: ignore[union-attr]
|
||||
except Exception as exc:
|
||||
_log.debug("event publisher write failed: %s", exc)
|
||||
self._dead = True
|
||||
self._ws = None
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
if self._dead or self._ws is None or self._worker is None:
|
||||
return False
|
||||
|
||||
line = json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
self._q.put_nowait(line)
|
||||
|
||||
return True
|
||||
except queue.Full:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
self._dead = True
|
||||
w = self._worker
|
||||
if w is not None and w.is_alive():
|
||||
try:
|
||||
self._q.put_nowait(_DRAIN_STOP)
|
||||
except queue.Full:
|
||||
# Best-effort: if the queue is wedged, the daemon thread
|
||||
# will be torn down with the process.
|
||||
pass
|
||||
w.join(timeout=3.0)
|
||||
self._worker = None
|
||||
|
||||
if self._ws is None:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
if self._ws is not None:
|
||||
self._ws.close() # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -107,12 +107,14 @@ class TeeTransport:
|
|||
self._secondaries = secondaries
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
# Primary first so a slow sidecar (WS publisher) never delays Ink/stdio.
|
||||
ok = self._primary.write(obj)
|
||||
for sec in self._secondaries:
|
||||
try:
|
||||
sec.write(obj)
|
||||
except Exception:
|
||||
pass
|
||||
return self._primary.write(obj)
|
||||
return ok
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
|
|
|
|||
8
uv.lock
generated
8
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-04-17T15:09:44.835508886Z"
|
||||
exclude-newer = "2026-04-17T16:49:45.944715922Z"
|
||||
exclude-newer-span = "P7D"
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1877,7 +1877,6 @@ dependencies = [
|
|||
{ name = "edge-tts" },
|
||||
{ name = "exa-py" },
|
||||
{ name = "fal-client" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fire" },
|
||||
{ name = "firecrawl-py" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
|
|
@ -1885,7 +1884,6 @@ dependencies = [
|
|||
{ name = "openai" },
|
||||
{ name = "parallel-web" },
|
||||
{ name = "prompt-toolkit" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-dotenv" },
|
||||
|
|
@ -1893,7 +1891,6 @@ dependencies = [
|
|||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -2062,7 +2059,6 @@ requires-dist = [
|
|||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
||||
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
||||
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
||||
{ name = "fastapi", specifier = ">=0.104.0,<1" },
|
||||
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
|
||||
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
|
||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
|
||||
|
|
@ -2109,7 +2105,6 @@ requires-dist = [
|
|||
{ name = "openai", specifier = ">=2.21.0,<3" },
|
||||
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
||||
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5,<3" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
|
||||
|
|
@ -2136,7 +2131,6 @@ requires-dist = [
|
|||
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
||||
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
|
||||
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const offState = gw.onState(setState);
|
||||
|
||||
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
|
||||
|
|
@ -111,15 +112,26 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
// sidecar is independent of the PTY pane's session by design — we
|
||||
// only need a sid to drive the model picker's slash.exec calls.
|
||||
gw.connect()
|
||||
.then(() => gw.request<{ session_id: string }>("session.create", {}))
|
||||
.then((created) => {
|
||||
if (created?.session_id) {
|
||||
setSessionId(created.session_id);
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
return gw.request<{ session_id: string }>("session.create", {});
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
.then((created) => {
|
||||
if (cancelled || !created?.session_id) {
|
||||
return;
|
||||
}
|
||||
setSessionId(created.session_id);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) {
|
||||
setError(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offState();
|
||||
offSessionInfo();
|
||||
offError();
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
|
|||
|
|
@ -443,6 +443,10 @@ export default function ChatPage() {
|
|||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
// Suppress banner/terminal side-effects when cleanup() calls `ws.close()`
|
||||
// (React StrictMode remount, route change) so we never write to a
|
||||
// disposed xterm or setState on an unmounted tree.
|
||||
let unmounting = false;
|
||||
|
||||
ws.onopen = () => {
|
||||
setBanner(null);
|
||||
|
|
@ -463,6 +467,9 @@ export default function ChatPage() {
|
|||
|
||||
ws.onclose = (ev) => {
|
||||
wsRef.current = null;
|
||||
if (unmounting) {
|
||||
return;
|
||||
}
|
||||
if (ev.code === 4401) {
|
||||
setBanner("Auth failed. Reload the page to refresh the session token.");
|
||||
return;
|
||||
|
|
@ -539,6 +546,7 @@ export default function ChatPage() {
|
|||
term.focus();
|
||||
|
||||
return () => {
|
||||
unmounting = true;
|
||||
onDataDisposable.dispose();
|
||||
onResizeDisposable.dispose();
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ hermes dashboard --no-open
|
|||
|
||||
## Prerequisites
|
||||
|
||||
The web dashboard requires FastAPI and Uvicorn. The Chat tab additionally needs `ptyprocess` to spawn the embedded TUI behind a pseudo-terminal. Install both with:
|
||||
The default `hermes-agent` install does not ship the HTTP stack or PTY helper — those are optional extras. The **web dashboard** needs FastAPI and Uvicorn (`web` extra). The **Chat** tab also needs `ptyprocess` to spawn the embedded TUI behind a pseudo-terminal (`pty` extra on POSIX). Install both with:
|
||||
|
||||
```bash
|
||||
pip install 'hermes-agent[web,pty]'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue