deer-flow/backend/tests/test_configured_middleware_scope.py
derrickLzw 51f0e2de1c
feat(extensions): scope configured middlewares to lead or subagent (#7661)
* feat(extensions): scope configured middlewares to lead or subagent

* fix(helm): synchronize configured middleware config schema version

* fix(extensions): require explicit configured middleware scope
2026-10-31 08:46:16 +08:00

137 lines
6.7 KiB
Python

"""Scope selection for operator-configured middleware, through real builders."""
from types import SimpleNamespace
import pytest
from langchain.agents.middleware import AgentMiddleware
from pydantic import ValidationError
from deerflow.agents.middlewares.configured_extensions import load_configured_extension_middlewares
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ConfiguredMiddlewareSpec, ExtensionsConfig
class ScopedMiddleware(AgentMiddleware):
def __init__(self, label="legacy"):
self.label = label
class BrokenMiddleware(AgentMiddleware):
def __init__(self):
raise RuntimeError("constructor executed")
def _entry(label, scope):
return {"class": f"{__name__}:ScopedMiddleware", "kwargs": {"label": label}, "apply_to": scope}
def _config(entries):
return SimpleNamespace(extensions=ExtensionsConfig.model_validate({"middlewares": entries}))
@pytest.mark.parametrize("scope", ["both", "lead", "subagent"])
def test_scope_survives_config_serialization(scope):
config = ExtensionsConfig.model_validate({"middlewares": [_entry("configured", scope)]})
restored = ExtensionsConfig.model_validate_json(config.model_dump_json(by_alias=True))
assert restored.middlewares[0].apply_to == scope
assert restored.middlewares[0].kwargs == {"label": "configured"}
@pytest.mark.parametrize("invalid", [None, "", "worker", "LEAD", ["lead"], {}, True, 1])
def test_invalid_scope_is_rejected_even_for_an_unavailable_class(invalid):
with pytest.raises(ValidationError, match="apply_to"):
ExtensionsConfig.model_validate({"middlewares": [{"class": "unavailable_module:Middleware", "apply_to": invalid}]})
@pytest.mark.parametrize("scope", ["lead", "subagent"])
def test_legacy_entries_and_order_are_preserved(scope):
config = _config(
[
f"{__name__}:ScopedMiddleware",
{"class": f"{__name__}:ScopedMiddleware", "kwargs": {"label": "object-default"}},
_entry("lead", "lead"),
_entry("both", "both"),
_entry("subagent", "subagent"),
]
)
loaded = load_configured_extension_middlewares(config, scope=scope)
expected = ["legacy", "object-default", "lead", "both"] if scope == "lead" else ["legacy", "object-default", "both", "subagent"]
assert [mw.label for mw in loaded] == expected
again = load_configured_extension_middlewares(config, scope=scope)
assert all(first is not second for first, second in zip(loaded, again, strict=True))
assert config.extensions.middlewares[2].kwargs == {"label": "lead"}
@pytest.mark.parametrize("scope,excluded", [("lead", "subagent"), ("subagent", "lead")])
@pytest.mark.parametrize("class_path", ["unavailable_module:Middleware", f"{__name__}:BrokenMiddleware"])
def test_excluded_entries_are_not_imported_or_constructed(scope, excluded, class_path):
config = _config([{"class": class_path, "apply_to": excluded}, _entry("selected", scope)])
loaded = load_configured_extension_middlewares(config, scope=scope)
assert [mw.label for mw in loaded] == ["selected"]
@pytest.mark.parametrize("scope", ["lead", "subagent"])
def test_selected_constructor_failure_still_propagates(scope):
config = _config([{"class": f"{__name__}:BrokenMiddleware", "apply_to": scope}])
with pytest.raises(RuntimeError, match="constructor executed"):
load_configured_extension_middlewares(config, scope=scope)
@pytest.mark.parametrize("scope", ["lead", "subagent"])
@pytest.mark.parametrize("class_path", ["unavailable_module:Middleware", "types:SimpleNamespace"])
def test_selected_import_and_subclass_failures_still_propagate(scope, class_path):
config = _config([{"class": class_path, "apply_to": scope}])
with pytest.raises((ImportError, ValueError)):
load_configured_extension_middlewares(config, scope=scope)
def test_raw_dict_scope_is_validated_before_skipping():
raw = {"class": "unavailable_module:Middleware", "apply_to": "subagent", "unexpected": True}
config = SimpleNamespace(extensions=SimpleNamespace(middlewares=[raw]))
with pytest.raises(ValidationError, match="unexpected"):
load_configured_extension_middlewares(config, scope="lead")
def test_raw_dict_and_model_entries_are_filtered():
config = SimpleNamespace(extensions=SimpleNamespace(middlewares=[_entry("lead", "lead"), ConfiguredMiddlewareSpec.model_validate(_entry("subagent", "subagent"))]))
assert [mw.label for mw in load_configured_extension_middlewares(config, scope="lead")] == ["lead"]
@pytest.mark.parametrize("scope", ["both", "invalid", None])
def test_loader_requires_a_concrete_host_scope(scope):
with pytest.raises(ValueError, match="scope"):
load_configured_extension_middlewares(_config([]), scope=scope)
def test_loader_requires_explicit_scope():
config = _config([_entry("lead", "lead"), _entry("subagent", "subagent")])
with pytest.raises(TypeError, match="required keyword-only argument: 'scope'"):
load_configured_extension_middlewares(config)
def test_real_lead_and_subagent_builders_select_their_own_scope(tmp_path, monkeypatch):
from deerflow.agents.lead_agent.agent import build_middlewares
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
from deerflow.extensions import get_agent_build_extensions
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "runtime"))
app_config = AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [],
"memory": {"enabled": False},
"summarization": {"enabled": False},
"extensions": {"middlewares": [_entry("lead", "lead"), _entry("shared", "both"), _entry("subagent", "subagent")]},
}
)
extensions = get_agent_build_extensions()
lead = build_middlewares({}, None, app_config=app_config, memory_enabled=False, available_skills=set(), extensions=extensions)
subagent = build_subagent_runtime_middlewares(app_config=app_config, available_skills=set(), extensions=extensions)
assert [mw.label for mw in lead if isinstance(mw, ScopedMiddleware)] == ["lead", "shared"]
assert [mw.label for mw in subagent if isinstance(mw, ScopedMiddleware)] == ["shared", "subagent"]
assert next(mw for mw in lead if isinstance(mw, ScopedMiddleware) and mw.label == "shared") is not next(mw for mw in subagent if isinstance(mw, ScopedMiddleware) and mw.label == "shared")
for chain in (lead, subagent):
configured_end = max(i for i, mw in enumerate(chain) if isinstance(mw, ScopedMiddleware))
length_guard = next(i for i, mw in enumerate(chain) if type(mw).__name__ == "ModelLengthFinishReasonMiddleware")
assert configured_end < length_guard