diff --git a/model_tools.py b/model_tools.py index fb16bb745ac..a69f9f84efe 100644 --- a/model_tools.py +++ b/model_tools.py @@ -144,7 +144,15 @@ def _run_async(coro): worker_loop.close() pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) - future = pool.submit(_run_in_worker) + # Propagate the parent thread's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) and approval/sudo callbacks into + # the worker thread. Without this, any async tool that resolves + # get_hermes_home() inside its coroutine falls back to the launch/default + # profile in single-process multi-profile runtimes (desktop tui_gateway), + # leaking one profile's reads/writes into another. + from tools.thread_context import propagate_context_to_thread + + future = pool.submit(propagate_context_to_thread(_run_in_worker)) try: return future.result(timeout=300) except concurrent.futures.TimeoutError: diff --git a/run_agent.py b/run_agent.py index 0116edf876d..37e9a09b73b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1525,13 +1525,22 @@ class AIAgent: keep working. """ from agent.background_review import spawn_background_review_thread + from tools.thread_context import propagate_context_to_thread target, _prompt = spawn_background_review_thread( self, messages_snapshot, review_memory=review_memory, review_skills=review_skills, ) - t = threading.Thread(target=target, daemon=True, name="bg-review") + # Propagate the spawning turn's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) into the review thread. A bare + # threading.Thread starts with an empty context, so the review would + # resolve get_hermes_home() to the launch/default profile and write + # MEMORY.md / skill review into the wrong profile in single-process + # multi-profile runtimes (desktop tui_gateway) — see #54937. + t = threading.Thread( + target=propagate_context_to_thread(target), daemon=True, name="bg-review" + ) t.start() def _build_memory_write_metadata( diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 92f58c83afb..987935df479 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -45,6 +45,8 @@ from concurrent.futures import ThreadPoolExecutor from concurrent.futures.thread import _worker from typing import Any, Callable, Dict, List, Optional +from tools.thread_context import propagate_context_to_thread + logger = logging.getLogger(__name__) @@ -247,7 +249,11 @@ def dispatch_async_delegation( _finalize(delegation_id, result, status) try: - executor.submit(_worker) + # Capture the dispatching turn's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) so the detached child resolves + # get_hermes_home() under the right profile in single-process + # multi-profile runtimes (desktop tui_gateway). + executor.submit(propagate_context_to_thread(_worker)) except Exception as exc: # pragma: no cover — pool submit failure is rare with _records_lock: _records.pop(delegation_id, None) @@ -432,7 +438,10 @@ def dispatch_async_delegation_batch( _finalize_batch(delegation_id, combined, status) try: - executor.submit(_worker) + # Capture the dispatching turn's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) so the detached batch children + # resolve get_hermes_home() under the right profile. + executor.submit(propagate_context_to_thread(_worker)) except Exception as exc: # pragma: no cover with _records_lock: _records.pop(delegation_id, None)