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
63 lines
2.0 KiB
Python
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)
|