Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ def _cmd_revoke(args: argparse.Namespace) -> int:
return 0


def _cmd_status(args: argparse.Namespace | None = None) -> int:
def _cmd_status() -> int:
"""Show whether the WSS server is listening on the configured port.

Probes the port from the active config via a TCP socket handshake
Expand All @@ -446,7 +446,7 @@ def _cmd_status(args: argparse.Namespace | None = None) -> int:
return 1


def _cmd_restart(args: argparse.Namespace | None = None) -> int:
def _cmd_restart() -> int:
"""POST /admin/restart to drain and restart the WSS server in-place.

The server re-reads the internal auth token from
Expand Down
60 changes: 0 additions & 60 deletions errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,68 +24,8 @@ class TokenStoreError(PluginError):
"""Token store read/write/decrypt failure."""


class AuthError(PluginError):
"""Authentication failure on the WSS server (bad token, wrong node name)."""


class NodeNotConnectedError(PluginError):
"""A call was made to a node that's not currently in the registry.

Surfaces to the caller of :meth:`NodeEnvironment.execute` (and
its sibling ``read``/``write`` methods) when the target node
name is unknown or its WebSocket has dropped. Distinct from
:class:`NodeExecutionError` (the node is fine, but the *call*
failed) and from :class:`asyncio.TimeoutError` (the node is
fine, but the call didn't return in time).
"""


class NodeExecutionError(PluginError):
"""A node returned a structured ``exec_result`` with status=error.

Carries the protocol-level reason and code so callers (Agent)
can decide whether to retry, surface a user-visible message, or
fall back to a different node. The node connection itself
stays open; the failure is per-call.

Attributes:
code: The protocol error code (e.g. 3001 for
``exec_timeout``). May be 0 for shape violations the
server couldn't categorise — the ``str(exc)`` message
is the authoritative description in that case.
"""

def __init__(self, message: str, *, code: int = 0) -> None:
super().__init__(message)
self.code = code


class NodeReadError(PluginError):
"""A node returned a structured ``read_result`` or ``write_result`` with status=error.

Covers the file-I/O surface (``read``/``write`` in PROTOCOL
§3.8-3.11). One exception type for both directions keeps the
tool layer's error handling simple — the agent doesn't need
to distinguish a read failure from a write failure at the
catch site; the message says which.

Attributes:
code: The protocol error code mapped from the node's
``error`` string (PROTOCOL §4). May be 0 for shape
violations the server couldn't categorise.
"""

def __init__(self, message: str, *, code: int = 0) -> None:
super().__init__(message)
self.code = code


__all__ = [
"PluginError",
"ConfigError",
"TokenStoreError",
"AuthError",
"NodeNotConnectedError",
"NodeExecutionError",
"NodeReadError",
]
7 changes: 2 additions & 5 deletions lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@

* :class:`ServerRunner` — owns the uvicorn.Server instance, the
background asyncio task, and the FastAPI app.
* :func:`get_default_runner` / :func:`reset_default_runner` — module
singletons used by :func:`hermes_nodes_plugin.register` to attach
a single runner to the gateway's session lifecycle.
* :func:`get_default_runner` — module singleton set by
* The two thin hook callbacks the plugin's ``register(ctx)`` binds:
they translate Hermes session events into ``runner.start`` /
``runner.drain`` calls.
Expand Down Expand Up @@ -490,8 +488,7 @@ async def _sweep_stale_connections(self) -> None:


# The plugin's ``register(ctx)`` binds a *singleton* runner: there's
# one per Hermes process. Tests call :func:`reset_default_runner` to
# start each test from a clean slate. The runner is constructed
# one per Hermes process. The runner is constructed
# lazily on the first ``start()`` so the "Fernet key missing" error
# is deferred to startup, not to plugin load.
_default_runner: ServerRunner | None = None
Expand Down
4 changes: 2 additions & 2 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,20 @@
PROTOCOL_MAJOR,
_ensure_internal_token,
_internal_token_path,
_read_token_from_disk,
create_app,
run_server,
)
from .wsserver.server import _safe_close

__all__ = [
"create_app",
"run_server",
"CLOSE_AUTH_FAILED",
"CLOSE_PROTOCOL_VERSION",
"CLOSE_MESSAGE_OUT_OF_ORDER",
"CLOSE_RATE_LIMIT_EXCEEDED",
"PROTOCOL_MAJOR",
"_ensure_internal_token",
"_internal_token_path",
"_read_token_from_disk",
"_safe_close",
]
52 changes: 3 additions & 49 deletions tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def handler(args, **kw) -> str:
import time

from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import Any

logger = logging.getLogger(__name__)

Expand All @@ -33,9 +33,6 @@ def handler(args, **kw) -> str:
DEFAULT_EXEC_TIMEOUT_SECONDS = 60.0
MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 MiB

if TYPE_CHECKING:
from .registry import NodeConnection, NodeRegistry


# ---------------------------------------------------------------------------
# Retry helper
Expand Down Expand Up @@ -172,7 +169,6 @@ def _node_exec_impl(
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout_ms: int | None = None,
registry: "NodeRegistry | None" = None,
) -> str:
"""Run ``command`` on the named node.

Expand Down Expand Up @@ -238,7 +234,6 @@ def _node_read_impl(
path: str,
*,
timeout_ms: int | None = None,
registry: "NodeRegistry | None" = None,
) -> str:
"""Read a file from the named node."""
if not target:
Expand Down Expand Up @@ -299,7 +294,6 @@ def _node_write_impl(
*,
mode: str = "overwrite",
timeout_ms: int | None = None,
registry: "NodeRegistry | None" = None,
) -> str:
"""Write text to a file on the named node."""
if not target:
Expand Down Expand Up @@ -351,10 +345,7 @@ def _node_write_impl(
return json.dumps({"error": f"node_write failed: {e}"})


def _node_list_impl(
*,
registry: "NodeRegistry | None" = None,
) -> str:
def _node_list_impl() -> str:
"""List paired nodes with their current connection state.

Hits the HTTP /nodes/status endpoint. This is always queried over
Expand Down Expand Up @@ -417,7 +408,6 @@ def node_exec(args: dict, **kw: Any) -> str:
cwd=args.get("cwd"),
env=args.get("env"),
timeout_ms=args.get("timeout_ms"),
registry=args.get("registry"),
)


Expand All @@ -427,7 +417,6 @@ def node_read(args: dict, **kw: Any) -> str:
target=args.get("target"),
path=args.get("path"),
timeout_ms=args.get("timeout_ms"),
registry=args.get("registry"),
)


Expand All @@ -439,47 +428,12 @@ def node_write(args: dict, **kw: Any) -> str:
content=args.get("content"),
mode=args.get("mode", "overwrite"),
timeout_ms=args.get("timeout_ms"),
registry=args.get("registry"),
)


def node_list(args: dict, **kw: Any) -> str:
"""Hermes tool handler — dispatches to _node_list_impl."""
return _node_list_impl(registry=args.get("registry"))


# ---------------------------------------------------------------------------
# Internal helpers (kept for API compatibility with existing callers)
# ---------------------------------------------------------------------------


def _resolve_registry(override: "NodeRegistry | None") -> "NodeRegistry":
"""Return ``override`` if given, else a fresh registry.

Note: the tools now use httpx WebSocket to talk to the local server
directly, so this function is no longer used for exec/read/write.
It is kept for API compatibility with any external callers that
pass an explicit registry.
"""
if override is not None:
return override
from .registry import NodeRegistry

return NodeRegistry()


def _connection_summary(conn: "NodeConnection") -> dict[str, Any]:
"""Render a :class:`NodeConnection` as a JSON-serialisable dict."""
return {
"name": conn.name,
"connected": True,
"connected_at": conn.connected_at.isoformat(),
"last_heartbeat": conn.last_heartbeat.isoformat()
if conn.last_heartbeat is not None
else None,
"session_id": conn.session_id,
"remote_addr": conn.remote_addr,
}
return _node_list_impl()


# Public symbols.
Expand Down
4 changes: 2 additions & 2 deletions wsserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
result handling and the waiter/future completion logic).
"""

from .server import create_app, run_server
from .server import create_app

__all__ = ["create_app", "run_server"]
__all__ = ["create_app"]
43 changes: 5 additions & 38 deletions wsserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,49 +842,16 @@ async def admin_restart(
return app


# ---------------------------------------------------------------------------
# Convenience: run the server with uvicorn
# ---------------------------------------------------------------------------


def run_server(
config: NodeServerConfig,
*,
token_store: TokenStore | None = None,
registry: NodeRegistry | None = None,
) -> None:
"""Run the WSS server on ``config.host:config.port`` until interrupted.

Production entry point. Test code should call :func:`create_app`
and drive the ASGI app through ``httpx`` / ``websockets`` instead
of binding a real socket.
"""
import uvicorn

app = create_app(
token_store=token_store, registry=registry, config=config
)
ssl_kwargs: dict[str, Any] = {}
if config.uses_tls():
ssl_kwargs = {
"ssl_certfile": config.tls_cert_path,
"ssl_keyfile": config.tls_key_path,
}
uvicorn.run(
app,
host=config.host,
port=config.port,
log_level="info",
**ssl_kwargs,
)


__all__ = [
"create_app",
"run_server",
"CLOSE_AUTH_FAILED",
"CLOSE_PROTOCOL_VERSION",
"CLOSE_MESSAGE_OUT_OF_ORDER",
"CLOSE_RATE_LIMIT_EXCEEDED",
"CLOSE_HANDSHAKE_TIMEOUT",
"PROTOCOL_MAJOR",
"_ensure_internal_token",
"_internal_token_path",
"_safe_close",
"_read_token_from_disk",
]
Loading