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",
|
"edge-tts>=7.2.7,<8",
|
||||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
"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]
|
[project.optional-dependencies]
|
||||||
|
|
@ -83,7 +78,7 @@ termux = [
|
||||||
]
|
]
|
||||||
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
|
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"]
|
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"]
|
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||||
rl = [
|
rl = [
|
||||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
"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
|
Failure mode: silent. The agent loop must never block waiting for the
|
||||||
sidecar to drain. A dead WS short-circuits all subsequent writes.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import queue
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -29,15 +32,21 @@ except ImportError: # pragma: no cover - websockets is a required install path
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DRAIN_STOP = object()
|
||||||
|
|
||||||
|
_QUEUE_MAX = 256
|
||||||
|
|
||||||
|
|
||||||
class WsPublisherTransport:
|
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:
|
def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None:
|
||||||
self._url = url
|
self._url = url
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._ws: Optional[object] = None
|
self._ws: Optional[object] = None
|
||||||
self._dead = False
|
self._dead = False
|
||||||
|
self._q: queue.Queue[object] = queue.Queue(maxsize=_QUEUE_MAX)
|
||||||
|
self._worker: Optional[threading.Thread] = None
|
||||||
|
|
||||||
if ws_connect is None:
|
if ws_connect is None:
|
||||||
self._dead = True
|
self._dead = True
|
||||||
|
|
@ -51,29 +60,65 @@ class WsPublisherTransport:
|
||||||
self._dead = True
|
self._dead = True
|
||||||
self._ws = None
|
self._ws = None
|
||||||
|
|
||||||
def write(self, obj: dict) -> bool:
|
return
|
||||||
if self._dead or self._ws is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._ws.send(json.dumps(obj, ensure_ascii=False)) # type: ignore[union-attr]
|
if self._ws is not None:
|
||||||
|
self._ws.send(item) # type: ignore[union-attr]
|
||||||
return True
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.debug("event publisher write failed: %s", exc)
|
_log.debug("event publisher write failed: %s", exc)
|
||||||
self._dead = True
|
self._dead = True
|
||||||
self._ws = None
|
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
|
return False
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._dead = True
|
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:
|
if self._ws is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
with self._lock:
|
||||||
|
if self._ws is not None:
|
||||||
self._ws.close() # type: ignore[union-attr]
|
self._ws.close() # type: ignore[union-attr]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,14 @@ class TeeTransport:
|
||||||
self._secondaries = secondaries
|
self._secondaries = secondaries
|
||||||
|
|
||||||
def write(self, obj: dict) -> bool:
|
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:
|
for sec in self._secondaries:
|
||||||
try:
|
try:
|
||||||
sec.write(obj)
|
sec.write(obj)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return self._primary.write(obj)
|
return ok
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
8
uv.lock
generated
8
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
exclude-newer = "2026-04-17T15:09:44.835508886Z"
|
exclude-newer = "2026-04-17T16:49:45.944715922Z"
|
||||||
exclude-newer-span = "P7D"
|
exclude-newer-span = "P7D"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1877,7 +1877,6 @@ dependencies = [
|
||||||
{ name = "edge-tts" },
|
{ name = "edge-tts" },
|
||||||
{ name = "exa-py" },
|
{ name = "exa-py" },
|
||||||
{ name = "fal-client" },
|
{ name = "fal-client" },
|
||||||
{ name = "fastapi" },
|
|
||||||
{ name = "fire" },
|
{ name = "fire" },
|
||||||
{ name = "firecrawl-py" },
|
{ name = "firecrawl-py" },
|
||||||
{ name = "httpx", extra = ["socks"] },
|
{ name = "httpx", extra = ["socks"] },
|
||||||
|
|
@ -1885,7 +1884,6 @@ dependencies = [
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "parallel-web" },
|
{ name = "parallel-web" },
|
||||||
{ name = "prompt-toolkit" },
|
{ name = "prompt-toolkit" },
|
||||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyjwt", extra = ["crypto"] },
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
|
@ -1893,7 +1891,6 @@ dependencies = [
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "tenacity" },
|
{ name = "tenacity" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|
@ -2062,7 +2059,6 @@ requires-dist = [
|
||||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
||||||
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
||||||
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
{ 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 == 'rl'", specifier = ">=0.104.0,<1" },
|
||||||
{ name = "fastapi", marker = "extra == 'web'", 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" },
|
{ 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 = "openai", specifier = ">=2.21.0,<3" },
|
||||||
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
||||||
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" },
|
{ 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 = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5,<3" },
|
{ name = "pydantic", specifier = ">=2.12.5,<3" },
|
||||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
|
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
|
||||||
|
|
@ -2136,7 +2131,6 @@ requires-dist = [
|
||||||
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
||||||
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" },
|
{ 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 = "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 == 'rl'", specifier = ">=0.24.0,<1" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", 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" },
|
{ 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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
const offState = gw.onState(setState);
|
const offState = gw.onState(setState);
|
||||||
|
|
||||||
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
|
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
|
// 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.
|
// only need a sid to drive the model picker's slash.exec calls.
|
||||||
gw.connect()
|
gw.connect()
|
||||||
.then(() => gw.request<{ session_id: string }>("session.create", {}))
|
.then(() => {
|
||||||
.then((created) => {
|
if (cancelled) {
|
||||||
if (created?.session_id) {
|
return;
|
||||||
setSessionId(created.session_id);
|
|
||||||
}
|
}
|
||||||
|
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 () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
offState();
|
offState();
|
||||||
offSessionInfo();
|
offSessionInfo();
|
||||||
offError();
|
offError();
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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()}
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,10 @@ export default function ChatPage() {
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
wsRef.current = ws;
|
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 = () => {
|
ws.onopen = () => {
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
@ -463,6 +467,9 @@ export default function ChatPage() {
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
ws.onclose = (ev) => {
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
|
if (unmounting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ev.code === 4401) {
|
if (ev.code === 4401) {
|
||||||
setBanner("Auth failed. Reload the page to refresh the session token.");
|
setBanner("Auth failed. Reload the page to refresh the session token.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -539,6 +546,7 @@ export default function ChatPage() {
|
||||||
term.focus();
|
term.focus();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
unmounting = true;
|
||||||
onDataDisposable.dispose();
|
onDataDisposable.dispose();
|
||||||
onResizeDisposable.dispose();
|
onResizeDisposable.dispose();
|
||||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ hermes dashboard --no-open
|
||||||
|
|
||||||
## Prerequisites
|
## 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
|
```bash
|
||||||
pip install 'hermes-agent[web,pty]'
|
pip install 'hermes-agent[web,pty]'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue