fix(plugins): await async handlers in CLI and TUI dispatch

This commit is contained in:
hharry11 2026-04-30 16:26:48 +03:00 committed by Teknium
parent 79cffa9232
commit ca9a61ae38
5 changed files with 96 additions and 4 deletions

9
cli.py
View File

@ -6582,12 +6582,17 @@ class HermesCLI:
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
# Check for plugin-registered slash commands
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
from hermes_cli.plugins import get_plugin_command_handler
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
if plugin_handler:
user_args = cmd_original[len(base_cmd):].strip()
try:
result = plugin_handler(user_args)
result = resolve_plugin_command_result(
plugin_handler(user_args)
)
if result:
_cprint(str(result))
except Exception as e:

View File

@ -33,12 +33,15 @@ so plugin-defined tools appear alongside the built-in tools.
from __future__ import annotations
import asyncio
import importlib
import importlib.metadata
import importlib.util
import inspect
import logging
import os
import sys
import threading
import types
from dataclasses import dataclass, field
from pathlib import Path
@ -1226,6 +1229,46 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]:
return entry["handler"] if entry else None
def resolve_plugin_command_result(result: Any) -> Any:
"""Resolve a plugin command return value, awaiting async handlers when needed.
Sync CLI/TUI dispatch sites call plugin handlers from plain functions.
If a handler is async, await it directly when no loop is running; if
we're already inside an active loop, run it in a helper thread with its
own loop so the caller still gets a concrete result synchronously.
"""
if not inspect.isawaitable(result):
return result
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(result)
outcome: Dict[str, Any] = {}
failure: Dict[str, BaseException] = {}
done = threading.Event()
def _runner() -> None:
try:
outcome["value"] = asyncio.run(result)
except BaseException as exc: # pragma: no cover - re-raised below
failure["exc"] = exc
finally:
done.set()
thread = threading.Thread(
target=_runner,
name="hermes-plugin-command-await",
daemon=True,
)
thread.start()
done.wait()
if "exc" in failure:
raise failure["exc"]
return outcome.get("value")
def get_plugin_commands() -> Dict[str, dict]:
"""Return the full plugin commands dict (name → {handler, description, plugin}).

View File

@ -21,6 +21,7 @@ from hermes_cli.plugins import (
get_plugin_command_handler,
get_plugin_commands,
get_pre_tool_call_block_message,
resolve_plugin_command_result,
discover_plugins,
invoke_hook,
)
@ -1061,6 +1062,27 @@ class TestPluginCommands:
assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b"
class TestPluginCommandResultResolution:
def test_returns_sync_values_unchanged(self):
assert resolve_plugin_command_result("ok") == "ok"
def test_awaits_async_result_without_running_loop(self):
async def _handler():
return "async-ok"
assert resolve_plugin_command_result(_handler()) == "async-ok"
def test_awaits_async_result_with_running_loop(self, monkeypatch):
class _Loop:
pass
async def _handler():
return "threaded-ok"
monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop())
assert resolve_plugin_command_result(_handler()) == "threaded-ok"
# ── TestPluginDispatchTool ────────────────────────────────────────────────

View File

@ -594,6 +594,24 @@ def test_command_dispatch_returns_skill_payload(server):
assert result["name"] == "hermes-agent-dev"
def test_command_dispatch_awaits_async_plugin_handler(server):
async def _handler(arg):
return f"async:{arg}"
with patch(
"hermes_cli.plugins.get_plugin_command_handler",
lambda name: _handler if name == "async-cmd" else None,
):
resp = server.handle_request({
"id": "r-plugin",
"method": "command.dispatch",
"params": {"name": "async-cmd", "arg": "hello"},
})
assert "error" not in resp
assert resp["result"] == {"type": "plugin", "output": "async:hello"}
# ── dispatch(): pool routing for long handlers (#12546) ──────────────

View File

@ -4115,11 +4115,15 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"type": "alias", "target": qc.get("target", "")})
try:
from hermes_cli.plugins import get_plugin_command_handler
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
handler = get_plugin_command_handler(name)
if handler:
return _ok(rid, {"type": "plugin", "output": str(handler(arg) or "")})
result = resolve_plugin_command_result(handler(arg))
return _ok(rid, {"type": "plugin", "output": str(result or "")})
except Exception:
pass