4b6a969df2
* refactor(agent): make create_cli_agent(config=, chat_model=) pure Re-applies the #183 purity refactor on top of the observation-memory lifecycle that landed in #259, integrating the two cleanly. create_cli_agent gains a pure path: when both `config` and `chat_model` are passed it builds the agent entirely from locals and writes none of the cached module globals (`_config`, `_chat_model`, `_chat_model_key`, `_EvoScientist_agent`). `/model` commits the switch via `set_active_config` / `set_chat_model_instance` only after a successful build, so a failed rebuild leaves the session on the original model (replaces the old snapshot/restore rollback). Supporting changes: - Extract `set_active_config` (write-half of `_ensure_config`), `_apply_env_from_config`, `_build_chat_model`, and `set_chat_model_instance`. - Thread `cfg` / `chat_model` through `_get_default_middleware`, `_build_base_kwargs`, `load_mcp_and_build_kwargs`, `_maybe_swap_async_subagents`, and `_inject_subagent_middleware` so the pure path never falls back to the global-writing `_ensure_config()` / `_ensure_chat_model()`. - Integrate with #259's memory middleware: subagent context-editing middleware binds the threaded `chat_model`, and the configured system prompt / memory controls read the threaded `cfg` (new threading vs the original #183, required because #259 made these paths read config). - Consolidate `cfg` resolution to one `cfg if cfg is not None else _ensure_config()` at the top of each kwargs builder, matching the pattern already used in the other config-aware helpers. * fix(agent): keep pure tool selector off global cache * fix(model): apply config switch in place to preserve reference integrity --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
218 lines
8.1 KiB
Python
218 lines
8.1 KiB
Python
"""Tests for the auxiliary-model resolver and its middleware scoping.
|
|
|
|
Covers ``EvoScientist.EvoScientist._ensure_auxiliary_chat_model`` (fallback to
|
|
the main model when unset) and the wiring in ``_get_default_middleware`` that
|
|
routes the main agent's tool selector to the auxiliary model while keeping
|
|
context editing — and async sub-agents — on the main model.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import EvoScientist.EvoScientist as E
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_model_caches(monkeypatch):
|
|
"""Isolate the module-level model caches per test."""
|
|
monkeypatch.setattr(E, "_chat_model", None, raising=False)
|
|
monkeypatch.setattr(E, "_chat_model_key", None, raising=False)
|
|
monkeypatch.setattr(E, "_auxiliary_chat_model", None, raising=False)
|
|
monkeypatch.setattr(E, "_auxiliary_chat_model_key", None, raising=False)
|
|
|
|
|
|
def _cfg(**over):
|
|
base = {
|
|
"model": "main-m",
|
|
"provider": "main-p",
|
|
"auxiliary_model": "",
|
|
"auxiliary_provider": "",
|
|
}
|
|
base.update(over)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
class TestAuxiliaryResolver:
|
|
def test_empty_returns_main_instance(self, monkeypatch):
|
|
main = object()
|
|
monkeypatch.setattr(E, "_ensure_config", lambda config=None: _cfg())
|
|
monkeypatch.setattr(E, "_ensure_chat_model", lambda: main)
|
|
assert E._ensure_auxiliary_chat_model() is main
|
|
|
|
def test_aux_equal_to_main_reuses_main_instance(self, monkeypatch):
|
|
main = object()
|
|
monkeypatch.setattr(
|
|
E,
|
|
"_ensure_config",
|
|
lambda config=None: _cfg(
|
|
auxiliary_model="main-m", auxiliary_provider="main-p"
|
|
),
|
|
)
|
|
monkeypatch.setattr(E, "_ensure_chat_model", lambda: main)
|
|
assert E._ensure_auxiliary_chat_model() is main
|
|
|
|
def test_set_builds_auxiliary(self, monkeypatch):
|
|
fake = object()
|
|
get_chat_model = MagicMock(return_value=fake)
|
|
monkeypatch.setattr(
|
|
E,
|
|
"_ensure_config",
|
|
lambda config=None: _cfg(
|
|
auxiliary_model="aux-m", auxiliary_provider="aux-p"
|
|
),
|
|
)
|
|
monkeypatch.setattr("EvoScientist.llm.get_chat_model", get_chat_model)
|
|
assert E._ensure_auxiliary_chat_model() is fake
|
|
get_chat_model.assert_called_once_with(model="aux-m", provider="aux-p")
|
|
|
|
def test_empty_provider_falls_back_to_main_provider(self, monkeypatch):
|
|
get_chat_model = MagicMock(return_value=object())
|
|
monkeypatch.setattr(
|
|
E,
|
|
"_ensure_config",
|
|
lambda config=None: _cfg(auxiliary_model="aux-m", auxiliary_provider=""),
|
|
)
|
|
monkeypatch.setattr("EvoScientist.llm.get_chat_model", get_chat_model)
|
|
E._ensure_auxiliary_chat_model()
|
|
get_chat_model.assert_called_once_with(model="aux-m", provider="main-p")
|
|
|
|
def test_set_chat_model_resets_aux_cache(self, monkeypatch):
|
|
monkeypatch.setattr(E, "_auxiliary_chat_model", object(), raising=False)
|
|
monkeypatch.setattr(E, "_auxiliary_chat_model_key", ("x", "y"), raising=False)
|
|
monkeypatch.setattr(
|
|
"EvoScientist.llm.get_chat_model", MagicMock(return_value=object())
|
|
)
|
|
E.set_chat_model("new-m", "new-p")
|
|
assert E._auxiliary_chat_model is None
|
|
assert E._auxiliary_chat_model_key is None
|
|
|
|
|
|
def _mock_cfg():
|
|
cfg = MagicMock()
|
|
cfg.enable_ask_user = False
|
|
cfg.auto_mode = False
|
|
cfg.auto_approve = False
|
|
cfg.model_fallbacks = None
|
|
cfg.auxiliary_model = ""
|
|
cfg.auxiliary_provider = ""
|
|
return cfg
|
|
|
|
|
|
class TestAuxiliaryMiddlewareScope:
|
|
"""``_get_default_middleware`` routes only the right components to aux."""
|
|
|
|
def _capture(self):
|
|
cap: dict[str, object] = {}
|
|
|
|
def fake_tool_selector(*args, model=None, **kwargs):
|
|
cap["tool_selector"] = model
|
|
return [MagicMock()]
|
|
|
|
def fake_context_editing(model=None, *args, **kwargs):
|
|
cap["context_editing"] = model
|
|
return MagicMock()
|
|
|
|
return cap, fake_tool_selector, fake_context_editing
|
|
|
|
def test_main_agent_tool_selector_aux_context_editing_main(self):
|
|
cap, fake_ts, fake_ce = self._capture()
|
|
main_model, aux_model = object(), object()
|
|
with (
|
|
patch.object(E, "_ensure_config", return_value=_mock_cfg()),
|
|
patch.object(E, "_ensure_chat_model", return_value=main_model),
|
|
patch.object(E, "_ensure_auxiliary_chat_model", return_value=aux_model),
|
|
patch(
|
|
"EvoScientist.middleware.create_tool_selector_middleware",
|
|
side_effect=fake_ts,
|
|
),
|
|
patch(
|
|
"EvoScientist.middleware.create_context_editing_middleware",
|
|
side_effect=fake_ce,
|
|
),
|
|
):
|
|
E._get_default_middleware()
|
|
|
|
assert cap["tool_selector"] is aux_model
|
|
assert cap["context_editing"] is main_model
|
|
|
|
def test_async_subagent_tool_selector_stays_main(self):
|
|
cap, fake_ts, fake_ce = self._capture()
|
|
main_model, aux_model = object(), object()
|
|
with (
|
|
patch.object(E, "_ensure_config", return_value=_mock_cfg()),
|
|
patch.object(E, "_ensure_chat_model", return_value=main_model),
|
|
patch.object(E, "_ensure_auxiliary_chat_model", return_value=aux_model),
|
|
patch(
|
|
"EvoScientist.middleware.create_tool_selector_middleware",
|
|
side_effect=fake_ts,
|
|
),
|
|
patch(
|
|
"EvoScientist.middleware.create_context_editing_middleware",
|
|
side_effect=fake_ce,
|
|
),
|
|
):
|
|
E._get_default_middleware(for_async_subagent=True)
|
|
|
|
assert cap["tool_selector"] is main_model
|
|
assert cap["context_editing"] is main_model
|
|
|
|
def test_pure_path_tool_selector_uses_threaded_main_when_aux_empty(self):
|
|
cap, fake_ts, fake_ce = self._capture()
|
|
cfg = _mock_cfg()
|
|
cfg.model = "new-main"
|
|
cfg.provider = "new-provider"
|
|
cfg.auxiliary_model = ""
|
|
cfg.auxiliary_provider = ""
|
|
main_model = object()
|
|
|
|
with (
|
|
patch.object(E, "_ensure_config", side_effect=AssertionError),
|
|
patch.object(E, "_ensure_chat_model", side_effect=AssertionError),
|
|
patch.object(E, "_ensure_auxiliary_chat_model", side_effect=AssertionError),
|
|
patch(
|
|
"EvoScientist.middleware.create_tool_selector_middleware",
|
|
side_effect=fake_ts,
|
|
),
|
|
patch(
|
|
"EvoScientist.middleware.create_context_editing_middleware",
|
|
side_effect=fake_ce,
|
|
),
|
|
):
|
|
E._get_default_middleware(cfg=cfg, chat_model=main_model)
|
|
|
|
assert cap["tool_selector"] is main_model
|
|
assert cap["context_editing"] is main_model
|
|
|
|
def test_pure_path_tool_selector_builds_aux_from_threaded_config(self):
|
|
cap, fake_ts, fake_ce = self._capture()
|
|
cfg = _mock_cfg()
|
|
cfg.model = "new-main"
|
|
cfg.provider = "new-provider"
|
|
cfg.auxiliary_model = "new-aux"
|
|
cfg.auxiliary_provider = "aux-provider"
|
|
main_model, aux_model = object(), object()
|
|
|
|
with (
|
|
patch.object(E, "_ensure_config", side_effect=AssertionError),
|
|
patch.object(E, "_ensure_chat_model", side_effect=AssertionError),
|
|
patch.object(E, "_ensure_auxiliary_chat_model", side_effect=AssertionError),
|
|
patch(
|
|
"EvoScientist.llm.get_chat_model", return_value=aux_model
|
|
) as get_model,
|
|
patch(
|
|
"EvoScientist.middleware.create_tool_selector_middleware",
|
|
side_effect=fake_ts,
|
|
),
|
|
patch(
|
|
"EvoScientist.middleware.create_context_editing_middleware",
|
|
side_effect=fake_ce,
|
|
),
|
|
):
|
|
E._get_default_middleware(cfg=cfg, chat_model=main_model)
|
|
|
|
get_model.assert_called_once_with(model="new-aux", provider="aux-provider")
|
|
assert cap["tool_selector"] is aux_model
|
|
assert cap["context_editing"] is main_model
|