mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
fix(acp): pin the session cwd for slash-command handlers too
Slash commands run on the event-loop thread, outside the per-turn contextvars.copy_context() that pins the session cwd for the agent call. /compress reaches agent._build_system_prompt(), whose "Current working directory" line comes from resolve_agent_cwd() — so an unpinned handler rebuilt the prompt against the Hermes install tree and PERSISTED it as the session's cached prompt, re-poisoning every later turn even though the turn itself is now pinned. Pin inside a fresh context copy so the write cannot leak into other concurrent ACP sessions on the shared loop and needs no teardown.
This commit is contained in:
parent
cca2a2fc8d
commit
b6accee0d7
3 changed files with 91 additions and 1 deletions
|
|
@ -2057,8 +2057,26 @@ class HermesACPAgent(acp.Agent):
|
|||
if handler is None:
|
||||
return None # not a known command — let the LLM handle it
|
||||
|
||||
try:
|
||||
# Slash handlers run on the event-loop thread, OUTSIDE the per-turn
|
||||
# contextvars.copy_context() that pins the session cwd for the agent
|
||||
# call. ``/compress`` and ``/model`` reach code that REBUILDS the
|
||||
# system prompt (agent._build_system_prompt -> resolve_agent_cwd), so
|
||||
# an unpinned handler bakes the Hermes install tree into the session's
|
||||
# cached prompt — persisted, and therefore poisoning every later turn
|
||||
# even though the turn itself is pinned. Pin inside a fresh context so
|
||||
# the write can't leak into other concurrent ACP sessions and needs no
|
||||
# teardown.
|
||||
def _dispatch() -> str | None:
|
||||
try:
|
||||
from agent.runtime_cwd import set_session_cwd
|
||||
|
||||
set_session_cwd(state.cwd)
|
||||
except Exception:
|
||||
logger.debug("Could not pin ACP session cwd for slash command", exc_info=True)
|
||||
return handler(args, state)
|
||||
|
||||
try:
|
||||
return contextvars.copy_context().run(_dispatch)
|
||||
except Exception as e:
|
||||
logger.error("Slash command /%s error: %s", cmd, e, exc_info=True)
|
||||
return f"Error executing /{cmd}: {e}"
|
||||
|
|
|
|||
1
contributors/emails/steve.darlow@gmail.com
Normal file
1
contributors/emails/steve.darlow@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
kerpopule
|
||||
|
|
@ -2041,6 +2041,77 @@ class TestSlashCommands:
|
|||
result = agent._handle_slash_command("/nonexistent", state)
|
||||
assert result is None
|
||||
|
||||
def test_slash_handler_pins_the_session_cwd(self, agent, mock_manager, tmp_path):
|
||||
"""A slash handler must resolve the client's cwd, not the install tree.
|
||||
|
||||
Slash commands run on the event-loop thread, outside the per-turn
|
||||
``copy_context()`` that pins the cwd for the agent call. ``/compress``
|
||||
reaches ``agent._build_system_prompt()``, whose "Current working
|
||||
directory" line comes from ``resolve_agent_cwd()`` — and the rebuilt
|
||||
prompt is PERSISTED as the session's cached prompt, so an unpinned
|
||||
handler poisons every later turn even though the turn is pinned.
|
||||
"""
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
state = mock_manager.create_session(cwd=str(workspace))
|
||||
state.cwd = str(workspace)
|
||||
state.agent.model = "test-model"
|
||||
state.agent.provider = "openrouter"
|
||||
state.history = [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
]
|
||||
state.agent.compression_enabled = True
|
||||
state.agent._cached_system_prompt = "system"
|
||||
state.agent.tools = None
|
||||
state.agent._session_db = None
|
||||
|
||||
observed = {}
|
||||
|
||||
def _compress(messages, system_prompt, **kwargs):
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
observed["cwd"] = str(resolve_agent_cwd())
|
||||
return [{"role": "user", "content": "summary"}], "new-system"
|
||||
|
||||
state.agent._compress_context = _compress
|
||||
|
||||
with (
|
||||
patch.object(agent.session_manager, "save_session"),
|
||||
patch(
|
||||
"agent.model_metadata.estimate_request_tokens_rough",
|
||||
side_effect=[40, 12],
|
||||
),
|
||||
):
|
||||
agent._handle_slash_command("/compress", state)
|
||||
|
||||
assert observed.get("cwd") == str(workspace), (
|
||||
"the slash handler resolved "
|
||||
f"{observed.get('cwd')!r} instead of the ACP client's cwd "
|
||||
f"{str(workspace)!r}"
|
||||
)
|
||||
|
||||
def test_slash_handler_cwd_pin_does_not_leak(self, agent, mock_manager, tmp_path):
|
||||
"""The pin is scoped to the handler's own context copy.
|
||||
|
||||
Concurrent ACP sessions share the event loop, so a handler that pinned
|
||||
the ambient context would leave its workspace bound for whatever runs
|
||||
next. Asserting the ambient value is unchanged after dispatch keeps the
|
||||
fix from trading one cross-session leak for another.
|
||||
"""
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
state = mock_manager.create_session(cwd=str(workspace))
|
||||
state.cwd = str(workspace)
|
||||
state.agent.model = "test-model"
|
||||
state.agent.provider = "openrouter"
|
||||
|
||||
before = str(resolve_agent_cwd())
|
||||
agent._handle_slash_command("/help", state)
|
||||
assert str(resolve_agent_cwd()) == before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_command_intercepted_in_prompt(self, agent, mock_manager):
|
||||
"""Slash commands should be handled without calling the LLM."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue