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:
teknium1
2026-09-12 08:10:30 -07:00
committed by Teknium
parent 24444e52eb
commit 12fe7684e1
2 changed files with 12 additions and 4 deletions

View File

@@ -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")

View File

@@ -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: