fix(plugins): async-await helper thread runs under the caller's ContextVars
Under a running loop `resolve_plugin_command_result` awaited the coroutine on a raw thread, so an async hook saw the process-default HERMES_HOME and no secret scope (get_secret -> UnscopedSecretError on a secondary profile). Run the thread body through `contextvars.copy_context().run`, matching the bounded hook worker. Also fixes async plugin slash commands the same way.
This commit is contained in:
@@ -11,6 +11,7 @@ and an ``__init__.py`` exposing ``register(ctx)``. Plugins register callbacks fo
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import json
|
||||
@@ -2013,7 +2014,10 @@ def resolve_plugin_command_result(result: Any) -> Any:
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
threading.Thread(target=_runner, name="hermes-plugin-command-await", daemon=True).start()
|
||||
# copy_context: the helper thread must see the caller's profile/secret scope, else an
|
||||
# async hook under a running loop reads the default HERMES_HOME and get_secret raises.
|
||||
threading.Thread(target=contextvars.copy_context().run, args=(_runner,),
|
||||
name="hermes-plugin-command-await", daemon=True).start()
|
||||
if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS):
|
||||
raise TimeoutError("Plugin command async handler did not complete within "
|
||||
f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s")
|
||||
|
||||
@@ -948,20 +948,24 @@ class TestAsyncHookCallbacks:
|
||||
assert results == [{"context": "sync"}, {"context": "async:s1"}]
|
||||
|
||||
def test_async_hook_resolves_under_a_running_loop(self):
|
||||
"""Gateway handlers call invoke_hook from inside asyncio; a bare asyncio.run would raise."""
|
||||
"""Gateway handlers call invoke_hook from inside asyncio; a bare asyncio.run would raise.
|
||||
The helper thread must also carry the caller's ContextVars (profile / secret scope)."""
|
||||
import asyncio
|
||||
import contextvars
|
||||
|
||||
scope = contextvars.ContextVar("hook_scope", default="default")
|
||||
mgr = PluginManager()
|
||||
|
||||
async def async_hook(**kwargs):
|
||||
return "from-async"
|
||||
return f"from-async:{scope.get()}"
|
||||
|
||||
mgr._hooks.setdefault("post_tool_call", []).append(async_hook)
|
||||
|
||||
async def driver():
|
||||
scope.set("profile-b")
|
||||
return mgr.invoke_hook("post_tool_call", tool_name="t", args={}, result="r", duration_ms=1)
|
||||
|
||||
assert asyncio.run(driver()) == ["from-async"]
|
||||
assert asyncio.run(driver()) == ["from-async:profile-b"]
|
||||
|
||||
|
||||
class TestForceReloadSymmetry:
|
||||
|
||||
Reference in New Issue
Block a user