Commit 00044737 authored by Lisa (Hermes AI)'s avatar Lisa (Hermes AI)

fix: restore node gateway plugin registration

parent 0e5cdbd6
...@@ -192,6 +192,8 @@ class NodeGateway: ...@@ -192,6 +192,8 @@ class NodeGateway:
self.websocket_port = config.get('websocket_port', 8765) self.websocket_port = config.get('websocket_port', 8765)
self.http_port = config.get('http_port', 8766)
self.use_tls = config.get('use_tls', True) self.use_tls = config.get('use_tls', True)
self.cert_dir = Path(config.get('cert_dir', '~/.config/hermes-node-gateway/certs')).expanduser() self.cert_dir = Path(config.get('cert_dir', '~/.config/hermes-node-gateway/certs')).expanduser()
...@@ -218,6 +220,10 @@ class NodeGateway: ...@@ -218,6 +220,10 @@ class NodeGateway:
self._websocket_server = None self._websocket_server = None
self._http_runner = None
self._http_site = None
self._running = False self._running = False
self._loop = None self._loop = None
...@@ -297,25 +303,49 @@ class NodeGateway: ...@@ -297,25 +303,49 @@ class NodeGateway:
def stop(self): def stop(self):
"""Stop the WebSocket server""" """Stop the gateway servers"""
self._running = False self._running = False
if self._websocket_server and self._loop: if self._loop and self._loop.is_running():
try: if self._http_site is not None:
asyncio.run_coroutine_threadsafe( try:
self._websocket_server.wait_closed(), asyncio.run_coroutine_threadsafe(
self._loop self._http_site.stop(),
) self._loop
except Exception as e: ).result(timeout=5)
except Exception as e:
logger.error(f"Error stopping HTTP site: {e}")
if self._http_runner is not None:
try:
asyncio.run_coroutine_threadsafe(
self._http_runner.cleanup(),
self._loop
).result(timeout=5)
except Exception as e:
logger.error(f"Error cleaning up HTTP runner: {e}")
logger.error(f"Error stopping websocket server: {e}") if self._websocket_server is not None:
self._loop.call_soon_threadsafe(self._websocket_server.close)
self._loop.call_soon_threadsafe(self._loop.stop)
if self._websocket_thread: if self._websocket_thread:
...@@ -367,9 +397,9 @@ class NodeGateway: ...@@ -367,9 +397,9 @@ class NodeGateway:
# Create server coroutine and start it # Create server coroutine and start it
async def start_server(): async def start_servers():
return await websockets.serve( websocket_server = await websockets.serve(
self._handle_node_connection, self._handle_node_connection,
...@@ -385,8 +415,36 @@ class NodeGateway: ...@@ -385,8 +415,36 @@ class NodeGateway:
) )
from aiohttp import web
app = web.Application()
app.router.add_get('/nodes', self.http_list_nodes)
app.router.add_get('/nodes/{node_name}/status', self.http_node_status)
app.router.add_post('/nodes/{node_name}/exec', self.http_exec_command)
app.router.add_post('/nodes/{node_name}/browser', self.http_browser_control)
app.router.add_post('/nodes/{node_name}/computer', self.http_computer_control)
app.router.add_post('/nodes/{node_name}/observe', self.http_desktop_observe)
app.router.add_post('/nodes/{node_name}/audio', self.http_audio_control)
self._http_runner = web.AppRunner(app)
await self._http_runner.setup()
self._http_site = web.TCPSite(self._http_runner, self.bind_address, self.http_port)
await self._http_site.start()
return websocket_server
self._websocket_server = self._loop.run_until_complete(start_server()) self._websocket_server = self._loop.run_until_complete(start_servers())
protocol = "wss" if ssl_context else "ws" protocol = "wss" if ssl_context else "ws"
...@@ -397,6 +455,12 @@ class NodeGateway: ...@@ -397,6 +455,12 @@ class NodeGateway:
) )
logger.info(
f"Node gateway HTTP API listening on http://{self.bind_address}:{self.http_port}"
)
# Run forever # Run forever
...@@ -763,6 +827,83 @@ class NodeGateway: ...@@ -763,6 +827,83 @@ class NodeGateway:
self.command_waiters[cmd_id].set_result(cmd) self.command_waiters[cmd_id].set_result(cmd)
async def _handle_desktop_observe_result(self, msg: dict):
"""Handle desktop_observe response from node"""
cmd_id = msg.get("id")
if cmd_id not in self.command_waiters:
logger.warning(f"Response for unknown desktop_observe command: {cmd_id}")
return
with self._commands_lock:
if cmd_id not in self.commands:
return
cmd = self.commands[cmd_id]
success = msg.get("success", False)
cmd.status = "completed" if success else "failed"
cmd.exit_code = 0 if success else -1
cmd.error = msg.get("error")
cmd.completed_at = time.time()
cmd.browser_result = msg
logger.info(f"desktop_observe command {cmd_id} completed: {cmd.status}")
if cmd_id in self.command_waiters:
self.command_waiters[cmd_id].set_result(cmd)
async def _handle_audio_control_result(self, msg: dict):
"""Handle audio_control response from node"""
cmd_id = msg.get("id")
if cmd_id not in self.command_waiters:
logger.warning(f"Response for unknown audio_control command: {cmd_id}")
return
with self._commands_lock:
if cmd_id not in self.commands:
return
cmd = self.commands[cmd_id]
success = msg.get("success", False)
cmd.status = "completed" if success else "failed"
cmd.exit_code = 0 if success else -1
cmd.error = msg.get("error")
cmd.completed_at = time.time()
cmd.browser_result = msg
logger.info(f"audio_control command {cmd_id} completed: {cmd.status}")
if cmd_id in self.command_waiters:
self.command_waiters[cmd_id].set_result(cmd)
async def execute_browser_command( async def execute_browser_command(
...@@ -1049,181 +1190,646 @@ class NodeGateway: ...@@ -1049,181 +1190,646 @@ class NodeGateway:
# -------------------------------------------------------------------------
# Public API (thread-safe) def execute_desktop_observe_command_sync(
# ------------------------------------------------------------------------- self,
node_name: str,
def list_nodes(self) -> Dict[str, Any]: command: Dict[str, Any],
"""List all connected nodes""" timeout: int = 30
with self._nodes_lock: ) -> Dict[str, Any]:
nodes_list = [] """Synchronous wrapper for execute_desktop_observe_command"""
for name, node in self.nodes.items(): if not self._loop:
nodes_list.append({ raise RuntimeError("Node gateway not started")
'name': name, future = asyncio.run_coroutine_threadsafe(
'status': 'connected', self.execute_desktop_observe_command(node_name, command, timeout),
'connected_at': node.connected_at, self._loop
'last_seen': node.last_seen, )
'uptime': int(time.time() - node.connected_at), return future.result(timeout=timeout + 10)
'version': node.version,
'tools': node.tools, async def execute_desktop_observe_command(
'capabilities': node.capabilities self,
}) node_name: str,
command: Dict[str, Any],
return {'nodes': nodes_list} timeout: int = 30
) -> Dict[str, Any]:
def _get_node_tools(self, node) -> List[str]: """Execute a desktop_observe command on a node (async)"""
"""Get tool list for a node; fall back to inferring from capabilities for old nodes."""
if hasattr(node, 'tools') and node.tools: with self._nodes_lock:
return node.tools if node_name not in self.nodes:
# Backward compatibility: infer tools from capabilities raise ValueError(f"Node '{node_name}' is not connected")
tools = ['exec'] node = self.nodes[node_name]
caps = getattr(node, 'capabilities', {}) or {} tools = self._get_node_tools(node)
if isinstance(caps, dict) and caps.get('enable_browser'): if "desktop_observe" not in tools:
tools.append('browser_control') raise ValueError(f"Node '{node_name}' does not support desktop_observe")
if isinstance(caps, dict) and caps.get('enable_computer_control'): cmd_id = f"obs-{uuid.uuid4().hex[:8]}"
tools.append('computer_control') cmd = CommandExecution(
return tools id=cmd_id, node_name=node_name,
command=[json.dumps(command)], status='pending',
started_at=time.time()
def get_node_status(self, node_name: str) -> Dict[str, Any]: )
"""Get status of a specific node""" cmd.browser_result = None
with self._nodes_lock: with self._commands_lock:
if node_name not in self.nodes: self.commands[cmd_id] = cmd
raise ValueError(f"Node '{node_name}' not found") msg = {"type": "desktop_observe", "id": cmd_id, **command}
node = self.nodes[node_name] await node.socket.send(json.dumps(msg))
cmd.status = 'running'
logger.info(f"Sent desktop_observe command to node '{node_name}': {command.get('action')}")
return { future = asyncio.Future()
'name': node_name, self.command_waiters[cmd_id] = future
'status': 'connected', try:
'connected_at': node.connected_at, result = await asyncio.wait_for(future, timeout=timeout)
'last_seen': node.last_seen, return {
'uptime': int(time.time() - node.connected_at), 'id': result.id, 'status': result.status,
'version': node.version, 'error': result.error,
'tools': self._get_node_tools(node), 'duration_ms': int((result.completed_at - result.started_at) * 1000)
'capabilities': node.capabilities if result.started_at and result.completed_at else None,
} **(result.browser_result or {})
}
except asyncio.TimeoutError:
def execute_command_sync( cmd.status = 'failed'
self, cmd.error = 'Gateway timeout'
node_name: str, return {'id': cmd_id, 'status': 'failed', 'error': 'Gateway timeout'}
command: List[str], finally:
timeout: int = 30, self.command_waiters.pop(cmd_id, None)
approved: bool = False
) -> Dict[str, Any]: def execute_audio_control_command_sync(
"""Synchronous wrapper for execute_command""" self,
if not self._loop: node_name: str,
raise RuntimeError("Node gateway not started") command: Dict[str, Any],
timeout: int = 30
future = asyncio.run_coroutine_threadsafe( ) -> Dict[str, Any]:
self.execute_command(node_name, command, timeout, approved), """Synchronous wrapper for execute_audio_control_command"""
self._loop if not self._loop:
) raise RuntimeError("Node gateway not started")
return future.result(timeout=timeout + 10) future = asyncio.run_coroutine_threadsafe(
self.execute_audio_control_command(node_name, command, timeout),
async def execute_command( self._loop
self, )
node_name: str, return future.result(timeout=timeout + 10)
command: List[str],
timeout: int = 30, async def execute_audio_control_command(
approved: bool = False self,
) -> Dict[str, Any]: node_name: str,
"""Execute command on a node (async)""" command: Dict[str, Any],
# Check if node is connected timeout: int = 30
with self._nodes_lock: ) -> Dict[str, Any]:
if node_name not in self.nodes: """Execute an audio_control command on a node (async)"""
raise ValueError(f"Node '{node_name}' is not connected") with self._nodes_lock:
node = self.nodes[node_name] if node_name not in self.nodes:
raise ValueError(f"Node '{node_name}' is not connected")
# Permission check node = self.nodes[node_name]
cmd_str = ' '.join(command) tools = self._get_node_tools(node)
if "audio_control" not in tools:
# Deny list check raise ValueError(f"Node '{node_name}' does not support audio_control")
for pattern in self.permissions.get('deny', []): cmd_id = f"audio-{uuid.uuid4().hex[:8]}"
if self._matches_pattern(cmd_str, pattern): cmd = CommandExecution(
raise PermissionError( id=cmd_id, node_name=node_name,
f"Command denied: matches deny pattern '{pattern}'" command=[json.dumps(command)], status='pending',
started_at=time.time()
)
cmd.browser_result = None
with self._commands_lock:
self.commands[cmd_id] = cmd
msg = {"type": "audio_control", "id": cmd_id, **command}
await node.socket.send(json.dumps(msg))
cmd.status = 'running'
logger.info(f"Sent audio_control command to node '{node_name}': {command.get('action')}")
future = asyncio.Future()
self.command_waiters[cmd_id] = future
try:
result = await asyncio.wait_for(future, timeout=timeout + 5)
return {
'id': result.id, 'status': result.status,
'error': result.error,
'duration_ms': int((result.completed_at - result.started_at) * 1000)
if result.started_at and result.completed_at else None,
**(result.browser_result or {})
}
except asyncio.TimeoutError:
cmd.status = 'failed'
cmd.error = 'Gateway timeout'
return {'id': cmd_id, 'status': 'failed', 'error': 'Gateway timeout'}
finally:
self.command_waiters.pop(cmd_id, None)
# -------------------------------------------------------------------------
# Public API (thread-safe)
# -------------------------------------------------------------------------
def list_nodes(self) -> Dict[str, Any]:
"""List all connected nodes"""
with self._nodes_lock:
nodes_list = []
for name, node in self.nodes.items():
nodes_list.append({
'name': name,
'status': 'connected',
'connected_at': node.connected_at,
'last_seen': node.last_seen,
'uptime': int(time.time() - node.connected_at),
'version': node.version,
'tools': node.tools,
'capabilities': node.capabilities
})
return {'nodes': nodes_list}
def _get_node_tools(self, node) -> List[str]:
"""Get tool list for a node; fall back to inferring from capabilities for old nodes."""
if hasattr(node, 'tools') and node.tools:
return node.tools
# Backward compatibility: infer tools from capabilities
tools = ['exec']
caps = getattr(node, 'capabilities', {}) or {}
if isinstance(caps, dict) and caps.get('enable_browser'):
tools.append('browser_control')
if isinstance(caps, dict) and caps.get('enable_computer_control'):
tools.append('computer_control')
if isinstance(caps, dict) and caps.get('enable_desktop_observe'):
tools.append('desktop_observe')
if isinstance(caps, dict) and caps.get('enable_audio_control'):
tools.append('audio_control')
return tools
async def http_list_nodes(self, request):
"""HTTP handler: list connected nodes."""
from aiohttp import web
return web.json_response(self.list_nodes())
async def http_node_status(self, request):
"""HTTP handler: get one node status."""
from aiohttp import web
node_name = request.match_info['node_name']
try:
return web.json_response(self.get_node_status(node_name))
except ValueError as e:
return web.json_response({'error': str(e)}, status=404)
async def http_exec_command(self, request):
"""HTTP handler: execute command on node."""
from aiohttp import web
node_name = request.match_info['node_name']
try:
payload = await request.json()
except Exception:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
command = payload.get('command')
timeout = payload.get('timeout', 30)
approved = payload.get('approved', False)
if not command:
return web.json_response({'error': "Missing required field 'command'"}, status=400)
try:
result = await self.execute_command(node_name, command, timeout, approved)
return web.json_response(result)
except ValueError as e:
return web.json_response({'error': str(e)}, status=404)
except PermissionError as e:
return web.json_response({'error': str(e)}, status=403)
except Exception as e:
logger.exception("HTTP exec failed")
return web.json_response({'error': str(e)}, status=500)
async def http_browser_control(self, request):
"""HTTP handler: browser control on node."""
from aiohttp import web
node_name = request.match_info['node_name']
try:
payload = await request.json()
except Exception:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
timeout = payload.get('timeout', 30)
try:
result = await self.execute_browser_command(node_name, payload, timeout)
return web.json_response(result)
except ValueError as e:
return web.json_response({'error': str(e)}, status=404)
except Exception as e:
logger.exception("HTTP browser control failed")
return web.json_response({'error': str(e)}, status=500)
async def http_computer_control(self, request):
"""HTTP handler: computer control on node."""
from aiohttp import web
node_name = request.match_info['node_name']
try:
payload = await request.json()
except Exception:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
timeout = payload.get('timeout', 30)
try:
result = await self.execute_computer_control_command(node_name, payload, timeout)
return web.json_response(result)
except ValueError as e:
return web.json_response({'error': str(e)}, status=404)
except Exception as e:
logger.exception("HTTP computer control failed")
return web.json_response({'error': str(e)}, status=500)
async def http_desktop_observe(self, request):
"""HTTP handler: desktop observe on node."""
from aiohttp import web
node_name = request.match_info['node_name']
try:
payload = await request.json()
except Exception:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
timeout = payload.get('timeout', 30)
try:
result = await self.execute_desktop_observe_command(node_name, payload, timeout)
return web.json_response(result)
except ValueError as e:
return web.json_response({'error': str(e)}, status=404)
except Exception as e:
logger.exception("HTTP desktop observe failed")
return web.json_response({'error': str(e)}, status=500)
async def http_audio_control(self, request):
"""HTTP handler: audio control on node."""
from aiohttp import web
node_name = request.match_info['node_name']
try:
payload = await request.json()
except Exception:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
timeout = payload.get('timeout', 30)
try:
result = await self.execute_audio_control_command(node_name, payload, timeout)
return web.json_response(result)
except ValueError as e:
return web.json_response({'error': str(e)}, status=404)
except Exception as e:
logger.exception("HTTP audio control failed")
return web.json_response({'error': str(e)}, status=500)
def get_node_status(self, node_name: str) -> Dict[str, Any]:
"""Get status of a specific node"""
with self._nodes_lock:
if node_name not in self.nodes:
raise ValueError(f"Node '{node_name}' not found")
node = self.nodes[node_name]
return {
'name': node_name,
'status': 'connected',
'connected_at': node.connected_at,
'last_seen': node.last_seen,
'uptime': int(time.time() - node.connected_at),
'version': node.version,
'tools': self._get_node_tools(node),
'capabilities': node.capabilities
}
def execute_command_sync(
self,
node_name: str,
command: List[str],
timeout: int = 30,
approved: bool = False
) -> Dict[str, Any]:
"""Synchronous wrapper for execute_command"""
if not self._loop:
raise RuntimeError("Node gateway not started")
future = asyncio.run_coroutine_threadsafe(
self.execute_command(node_name, command, timeout, approved),
self._loop
)
return future.result(timeout=timeout + 10)
async def execute_command(
self,
node_name: str,
command: List[str],
timeout: int = 30,
approved: bool = False
) -> Dict[str, Any]:
"""Execute command on a node (async)"""
# Check if node is connected
with self._nodes_lock:
if node_name not in self.nodes:
raise ValueError(f"Node '{node_name}' is not connected")
node = self.nodes[node_name]
# Permission check
if isinstance(command, str):
normalized_command = [command]
elif isinstance(command, (list, tuple)):
normalized_command = [str(part) for part in command]
else:
normalized_command = [str(command)]
cmd_str = ' '.join(normalized_command)
# Deny list check
for pattern in self.permissions.get('deny', []):
if self._matches_pattern(cmd_str, pattern):
raise PermissionError(
f"Command denied: matches deny pattern '{pattern}'"
) )
...@@ -1726,6 +2332,142 @@ COMPUTER_CONTROL_SCHEMA = { ...@@ -1726,6 +2332,142 @@ COMPUTER_CONTROL_SCHEMA = {
DESKTOP_OBSERVE_SCHEMA = {
"type": "function",
"function": {
"name": "desktop_observe",
"description": "Read structured desktop state from a remote node. Prefer this over screenshot-driven control when you need active window, screen info, cursor state, clipboard, or targeted screenshots.",
"parameters": {
"type": "object",
"properties": {
"node_name": {
"type": "string",
"description": "Name of the target node with desktop_observe capability"
},
"action": {
"type": "string",
"enum": [
"active_window", "cursor_position", "screen_info", "window_geometry",
"list_windows", "clipboard_get", "screenshot", "region_screenshot"
],
"description": "Observation action to perform"
},
"params": {
"type": "object",
"description": "Action parameters (window_id, x/y/width/height, optional path, etc.)",
"additionalProperties": True
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"default": 30
}
},
"required": ["node_name", "action"]
}
}
}
AUDIO_CONTROL_SCHEMA = {
"type": "function",
"function": {
"name": "audio_control",
"description": "Control audio devices and media playback on a remote node. Speech synthesis/transcription remain gateway-side; this tool is only for device/media actions.",
"parameters": {
"type": "object",
"properties": {
"node_name": {
"type": "string",
"description": "Name of the target node with audio_control capability"
},
"action": {
"type": "string",
"enum": ["list_audio_devices", "get_audio_status", "capture_output", "capture_input", "play_audio"],
"description": "Audio action to perform"
},
"params": {
"type": "object",
"description": "Action parameters (duration, path, format, etc.)",
"additionalProperties": True
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"default": 30
}
},
"required": ["node_name", "action"]
}
}
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tool handlers # Tool handlers
...@@ -1736,32 +2478,71 @@ COMPUTER_CONTROL_SCHEMA = { ...@@ -1736,32 +2478,71 @@ COMPUTER_CONTROL_SCHEMA = {
def tool_node_list(*args, **kwargs) -> Dict[str, Any]: def tool_node_list(*args, **kwargs) -> Dict[str, Any]:
"""List all connected nodes.""" """List all connected nodes."""
gw = _get_gateway() gw = _get_gateway()
_normalize_tool_params(args, kwargs)
return gw.list_nodes() return gw.list_nodes()
def _normalize_tool_params(args, kwargs) -> Dict[str, Any]:
"""Normalize Hermes tool handler params across positional/keyword/wrapped forms."""
params = args[0] if args else kwargs
if isinstance(params, str):
try:
params = json.loads(params)
except Exception:
params = {"value": params}
if not isinstance(params, dict):
return {}
# Some tool bridges wrap the actual payload under common keys.
for key in ("arguments", "args", "input", "payload", "params"):
wrapped = params.get(key)
if isinstance(wrapped, str):
try:
wrapped = json.loads(wrapped)
except Exception:
wrapped = None
if isinstance(wrapped, dict):
merged = dict(params)
merged.update(wrapped)
params = merged
break
return params
def tool_node_status(*args, **kwargs) -> Dict[str, Any]: def tool_node_status(*args, **kwargs) -> Dict[str, Any]:
"""Get status of a specific node.""" """Get status of a specific node."""
gw = _get_gateway() gw = _get_gateway()
params = args[0] if args else kwargs params = _normalize_tool_params(args, kwargs)
node_name = params.get('node_name') node_name = params.get('node_name') or params.get('nodeName') or params.get('node')
if not node_name: if not node_name:
raise ValueError("Missing required parameter: 'node_name'") raise ValueError(f"Missing required parameter: 'node_name' (got keys: {sorted(params.keys())})")
return gw.get_node_status(node_name) return gw.get_node_status(node_name)
def tool_node_exec(*args, **kwargs) -> Dict[str, Any]: def tool_node_exec(*args, **kwargs) -> Dict[str, Any]:
"""Execute command on a node.""" """Execute command on a node."""
gw = _get_gateway() gw = _get_gateway()
params = args[0] if args else kwargs params = _normalize_tool_params(args, kwargs)
node_name = params.get('node_name') node_name = params.get('node_name') or params.get('nodeName') or params.get('node')
command = params.get('command') command = params.get('command') or params.get('argv') or params.get('cmd')
timeout = params.get('timeout', 30) timeout = params.get('timeout', 30)
approved = params.get('approved', False) approved = params.get('approved', False)
if isinstance(command, str):
try:
parsed = json.loads(command)
if isinstance(parsed, list):
command = parsed
except Exception:
command = [command]
if not node_name: if not node_name:
raise ValueError("Missing required parameter: 'node_name'") raise ValueError(f"Missing required parameter: 'node_name' (got keys: {sorted(params.keys())})")
if not command: if not command:
raise ValueError("Missing required parameter: 'command'") raise ValueError(f"Missing required parameter: 'command' (got keys: {sorted(params.keys())})")
with gw._nodes_lock: with gw._nodes_lock:
if node_name not in gw.nodes: if node_name not in gw.nodes:
...@@ -1772,17 +2553,21 @@ def tool_node_exec(*args, **kwargs) -> Dict[str, Any]: ...@@ -1772,17 +2553,21 @@ def tool_node_exec(*args, **kwargs) -> Dict[str, Any]:
def tool_browser_control(*args, **kwargs) -> Dict[str, Any]: def tool_browser_control(*args, **kwargs) -> Dict[str, Any]:
"""Execute browser control command on a node""" """Execute browser control command on a node."""
gw = _get_gateway() gw = _get_gateway()
params = args[0] if args else kwargs params = _normalize_tool_params(args, kwargs)
nodename = params.get('node_name') or params.get('nodeName') or params.get('node') node_name = params.get('node_name') or params.get('nodeName') or params.get('node')
command = params.get('command') command = params.get('command')
layer = params.get('layer', 'high_level') layer = params.get('layer', 'high_level')
page_id = params.get('page_id', 'page_1') page_id = params.get('page_id', 'page_1')
cmd_params = params.get('params') or {} cmd_params = params.get('params') or params.get('arguments') or {}
timeout = params.get('timeout', 30) timeout = params.get('timeout', 30)
tools = params.get('tools') tools = params.get('tools')
return gw.execute_browser_command_sync(nodename, { if not node_name:
raise ValueError(f"Missing required parameter: 'node_name' (got keys: {sorted(params.keys())})")
if not command:
raise ValueError(f"Missing required parameter: 'command' (got keys: {sorted(params.keys())})")
return gw.execute_browser_command_sync(node_name, {
'command': command, 'command': command,
'layer': layer, 'layer': layer,
'page_id': page_id, 'page_id': page_id,
...@@ -1790,18 +2575,54 @@ def tool_browser_control(*args, **kwargs) -> Dict[str, Any]: ...@@ -1790,18 +2575,54 @@ def tool_browser_control(*args, **kwargs) -> Dict[str, Any]:
'timeout': timeout, 'timeout': timeout,
'tools': tools 'tools': tools
}, timeout) }, timeout)
def tool_desktop_observe(*args, **kwargs) -> Dict[str, Any]:
"""Execute desktop observe command on a node."""
gw = _get_gateway()
params = _normalize_tool_params(args, kwargs)
node_name = params.get('node_name') or params.get('nodeName') or params.get('node')
action = params.get('action') or params.get('command')
cmd_params = params.get('params') or params.get('arguments') or {}
timeout = params.get('timeout', 30)
if not node_name:
raise ValueError(f"Missing required parameter: 'node_name' (got keys: {sorted(params.keys())})")
if not action:
raise ValueError(f"Missing required parameter: 'action' (got keys: {sorted(params.keys())})")
return gw.execute_desktop_observe_command_sync(node_name, {
'action': action,
'params': cmd_params
}, timeout)
def tool_audio_control(*args, **kwargs) -> Dict[str, Any]:
"""Execute audio control command on a node."""
gw = _get_gateway()
params = _normalize_tool_params(args, kwargs)
node_name = params.get('node_name') or params.get('nodeName') or params.get('node')
action = params.get('action') or params.get('command')
cmd_params = params.get('params') or params.get('arguments') or {}
timeout = params.get('timeout', 30)
if not node_name:
raise ValueError(f"Missing required parameter: 'node_name' (got keys: {sorted(params.keys())})")
if not action:
raise ValueError(f"Missing required parameter: 'action' (got keys: {sorted(params.keys())})")
return gw.execute_audio_control_command_sync(node_name, {
'action': action,
'params': cmd_params
}, timeout)
def tool_computer_control(*args, **kwargs) -> Dict[str, Any]: def tool_computer_control(*args, **kwargs) -> Dict[str, Any]:
"""Execute computer control command on a node.""" """Execute computer control command on a node."""
gw = _get_gateway() gw = _get_gateway()
params = args[0] if args else kwargs params = _normalize_tool_params(args, kwargs)
node_name = params.get('node_name') or params.get('nodeName') or params.get('node') node_name = params.get('node_name') or params.get('nodeName') or params.get('node')
action = params.get('action') action = params.get('action') or params.get('command')
cmd_params = params.get('params') or {} cmd_params = params.get('params') or params.get('arguments') or {}
timeout = params.get('timeout', 30) timeout = params.get('timeout', 30)
if not node_name: if not node_name:
raise ValueError("Missing required parameter: 'node_name'") raise ValueError(f"Missing required parameter: 'node_name' (got keys: {sorted(params.keys())})")
if not action: if not action:
raise ValueError("Missing required parameter: 'action'") raise ValueError(f"Missing required parameter: 'action' (got keys: {sorted(params.keys())})")
return gw.execute_computer_control_command_sync(node_name, { return gw.execute_computer_control_command_sync(node_name, {
'action': action, 'action': action,
'params': cmd_params 'params': cmd_params
...@@ -1906,6 +2727,25 @@ def register(ctx): ...@@ -1906,6 +2727,25 @@ def register(ctx):
emoji="🖱️", emoji="🖱️",
) )
ctx.register_tool(
name="desktop_observe",
toolset="hermes-node-gateway",
schema=DESKTOP_OBSERVE_SCHEMA,
handler=tool_desktop_observe,
description="Structured desktop observation on a remote node",
emoji="👁️",
)
ctx.register_tool(
name="audio_control",
toolset="hermes-node-gateway",
schema=AUDIO_CONTROL_SCHEMA,
handler=tool_audio_control,
description="Audio device/media control on a remote node",
emoji="🔊",
)
logger.info("Hermes Node Gateway plugin registered successfully") logger.info("Hermes Node Gateway plugin registered successfully")
except Exception as e: except Exception as e:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment