Files
hermes-agent/tests/agent/test_plugin_context_engine_clone.py
teknium1 9cbc221dcb fix: clone plugin context engines via clone_for_agent(), not blind deepcopy
A general-plugin context engine is one shared instance; agent init copied it per agent
with copy.deepcopy() only. Engines that hold a SQLite connection or lock (hermes-lcm)
already expose clone_for_agent() for exactly this, but it was never called, so every
init logged "could not be safely copied … falling back to built-in compressor" and the
engine was unusable through the plugin system.

ContextEngine grows clone_for_agent() (default: deepcopy, the previous behaviour) and
_select_context_engine calls it; the failure message now names the hook to override.
Docs: context-engine-plugin.md documents the per-agent clone contract.

Test change (existing on main): tests/agent/test_context_engine.py::
test_agent_init_source_deepcopies_singleton_not_aliases was a source-reading pin on the
literal `copy.deepcopy(_candidate)` line, which this fix intentionally replaces. It is
superseded by tests/agent/test_plugin_context_engine_clone.py, which drives the real
_select_context_engine seam and asserts the invariant it guarded (child update_model()
never mutates the shared singleton) plus the new clone_for_agent() path.

Fixes #99640
credit: @stephenschoettler #62374
credit: @686f6c61 #99677
2026-09-21 23:38:16 -07:00

63 lines
2.0 KiB
Python

"""A plugin-registered context engine is one shared instance; agent init hands each agent its own
copy through ``clone_for_agent()`` (default deepcopy), so engines with uncopyable state (locks,
SQLite connections — hermes-lcm) stay selectable and a child's model never leaks into the parent
(#99640, #42449)."""
import threading
from unittest.mock import patch
from agent.agent_init import _select_context_engine
from agent.context_engine import ContextEngine
class _Engine(ContextEngine):
engine_name = "lcm"
@property
def name(self):
return self.engine_name
def update_from_response(self, usage):
pass
def should_compress(self, prompt_tokens=None):
return False
def compress(self, messages, current_tokens=None):
return messages
class _LockedEngine(_Engine):
"""Holds a lock (deepcopy raises) and hands out per-agent clones like hermes-lcm does."""
def __init__(self):
super().__init__()
self._lock = threading.Lock()
self.clones = 0
def clone_for_agent(self):
self.clones += 1
return _LockedEngine()
def _select(engine):
with (patch("plugins.context_engine.load_context_engine", return_value=None),
patch("hermes_cli.plugins.get_plugin_context_engine", return_value=engine)):
return _select_context_engine({"context": {"engine": engine.name}})
def test_engine_with_uncopyable_state_is_selected_via_clone_for_agent():
singleton = _LockedEngine()
selected = _select(singleton)
assert isinstance(selected, _LockedEngine) and selected is not singleton
assert singleton.clones == 1
def test_default_clone_isolates_parent_from_child_update_model():
singleton = _Engine()
singleton.update_model(model="big", context_length=1_000_000)
child = _select(singleton)
child.update_model(model="small", context_length=204_800)
assert child is not singleton
assert (singleton.context_length, child.context_length) == (1_000_000, 204_800)