deer-flow/backend/tests/test_extension_placement_guarantees.py
Nan Gao 1f792d0f4b
feat(extensions): add middleware plugin foundation (#4636)
* feat(extensions): add middleware plugin foundation

* fix(extensions): stop config resolution from masking extension loading

`create_app()` resolved the configured plugin list inside the fail-open
guard around `load_extensions()`. CI has no `config.yaml` (gitignored and
never generated by the workflow), so `get_app_config()` raised
`FileNotFoundError` there and was swallowed as an extension failure --
`load_extensions()` never ran at all, and the four `create_app()` tests in
`test_extension_app_loading.py` passed locally but failed on every runner.

Resolve the plugin list before the guard. Only an absent `config.yaml` is
tolerated, mirroring `_resolve_trace_enabled_for_app_construction()`:
`create_app()` runs at import time, and lifespan still performs strict
config loading before serving. A `config.yaml` that exists but fails to
parse or validate now propagates instead of being reported as an extension
failure -- reporting it as the latter silently dropped a `required: true`
extension rather than failing the boot.

Make the tests config-independent with an autouse `stub_app_config`
fixture, following the existing pattern in `test_gateway_lifespan_shutdown.py`,
and cover both new branches of the config-resolution boundary.

* fix(extensions): bind the run's extension snapshot through subagent delegation

The lead-agent path resolves one immutable loaded-extension snapshot per run
and binds it through task-store allocation and graph construction, but the
subagent path re-read the process-wide singleton at execution time. In
production both are the same object, yet a `set_loaded_extensions()` between
the lead run's start and a subagent's execution (test teardown, a future
hot-reload path) would let one run mix two extension generations — exactly what
the documented invariant exists to prevent.

The graph-build binding is a ContextVar scoped to synchronous construction, so
it has already exited by the time a tool delegates; the snapshot has to travel
through runtime context instead. The run worker publishes it under the
host-internal `EXTENSION_SNAPSHOT_CONTEXT_KEY` (written after the caller merge,
popped when the run has none, so a caller-supplied value is never
authoritative), `task_tool` reads it back through the type-checking
`resolve_run_extensions()`, and `SubagentExecutor` binds it at construction.

Callers outside the Gateway run path — embedded `DeerFlowClient`, standalone
LangGraph Server — install no snapshot and keep the existing
`get_loaded_extensions()` fallback.

* refactor(extensions): defer the ordering table by call, not by a lying tuple

`CORE_ORDERING_CONSTRAINTS` was a `tuple` subclass that overrode only
`__iter__` and resolved into a class-level `_resolved` side channel. A tuple
cannot populate its own storage after construction, so the instance stayed the
empty tuple it was built as: `len()` was 0, `bool()` was False, `in` was always
False, indexing raised, slicing and `reversed()` came back empty, and it
compared unequal to the plain tuples tests substitute for it — all while
iteration yielded the real constraints. Only `assert_ordering` consumed it, and
only by iterating, so the split went unnoticed.

The sibling `_AnchorTable(dict)` uses the same idea soundly because dict is
mutable: `self.update()` fills the real storage, making every inherited
operation correct. That trick does not survive the port to an immutable type.

Replace it with `core_ordering_constraints()`, matching how `stack.py` defers
the same kind of table via `_anchors()`. The deferral is kept — it is about
dependency direction, not just cycles: `extensions/` is the layer the
middleware layer calls into, so a module-scope `agents.middlewares` import here
points the dependency backwards and closes a cycle as soon as any middleware
imports something under `extensions/` at module level. Resolution stays at
`assert_ordering` time, which already runs inside the middleware builder.

Tests pin both halves: the returned value is a plain tuple whose len/bool/
membership/indexing/reversal/equality agree with iteration, and a subprocess
probe asserts importing `extensions.ordering` does not load the middleware
layer while calling the function does.
2026-08-04 22:33:26 +08:00

263 lines
12 KiB
Python

"""The guarantee each Placement makes must be met by the real stack.
Neither the type system nor the version number can catch a broken guarantee:
if a new request-transforming middleware is appended inner of the
MODEL_PHYSICAL anchor, the anchor table, the types and the pip constraints all
stay valid while the promise silently stops holding. These tests are the only
thing standing between that change and a released extension observing the wrong
data.
"""
from __future__ import annotations
from deerflow_extension_api import AgentScope, MiddlewarePlacement, Placement
from langchain.agents.middleware import AgentMiddleware
from deerflow.agents.lead_agent.agent import build_middlewares
from deerflow.agents.middlewares.tool_error_handling_middleware import (
build_subagent_runtime_middlewares,
)
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.extensions.isolation import IsolatedMiddleware
from deerflow.extensions.registry import ExtensionRegistry
from deerflow.extensions.stack import middleware_implements
class _Probe(AgentMiddleware):
def __init__(self, tag: str) -> None:
super().__init__()
self.tag = tag
def _extensions(*placements: MiddlewarePlacement):
class _C:
def contribute_middlewares(self, app_store, ctx):
return placements
registry = ExtensionRegistry()
with registry.attributed_to("probe:install"):
registry.middlewares(_C())
return registry.build()
def _stack_with(*placements: MiddlewarePlacement):
return build_middlewares(
config={"configurable": {}},
model_name="gpt-4o",
app_config=AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider")),
extensions=_extensions(*placements),
)
def _subagent_stack_with(*placements: MiddlewarePlacement):
return build_subagent_runtime_middlewares(
app_config=AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider")),
model_name="gpt-4o",
extensions=_extensions(*placements),
)
def _index_of_probe(stack, tag: str) -> int:
for index, middleware in enumerate(stack):
target = middleware.inner if isinstance(middleware, IsolatedMiddleware) else middleware
if isinstance(target, _Probe) and target.tag == tag:
return index
raise AssertionError(f"probe {tag!r} not found in stack")
def _unwrap(middleware):
return middleware.inner if isinstance(middleware, IsolatedMiddleware) else middleware
def test_model_physical_sees_the_final_request():
"""Nothing inner of MODEL_PHYSICAL may transform the model request."""
stack = _stack_with(MiddlewarePlacement(_Probe("physical"), Placement.MODEL_PHYSICAL))
index = _index_of_probe(stack, "physical")
offenders = [type(_unwrap(m)).__name__ for m in stack[index + 1 :] if middleware_implements(_unwrap(m), "wrap_model_call")]
assert offenders == [], (
f"these middlewares sit inner of the MODEL_PHYSICAL anchor and wrap model calls, breaking its documented guarantee: {offenders}. Either move them outer of the anchor or update the anchor table in deerflow/extensions/stack.py."
)
def test_lead_model_physical_uses_the_innermost_tail_anchor():
"""A configured duplicate must not capture the lead's type-based anchor."""
app_config = AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
app_config.extensions = ExtensionsConfig(middlewares=["deerflow.agents.middlewares.safety_finish_reason_middleware:SafetyFinishReasonMiddleware"])
stack = build_middlewares(
config={"configurable": {}},
model_name="gpt-4o",
app_config=app_config,
extensions=_extensions(
MiddlewarePlacement(
_Probe("physical"),
Placement.MODEL_PHYSICAL,
scope=AgentScope.LEAD,
)
),
)
index = _index_of_probe(stack, "physical")
offenders = [type(_unwrap(m)).__name__ for m in stack[index + 1 :] if middleware_implements(_unwrap(m), "wrap_model_call")]
assert offenders == []
def test_lead_model_physical_reports_when_the_safety_anchor_is_disabled():
from deerflow.extensions import get_runtime_diagnostics, reset_runtime_diagnostics
app_config = AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
app_config.safety_finish_reason.enabled = False
reset_runtime_diagnostics()
try:
build_middlewares(
config={"configurable": {}},
model_name="gpt-4o",
app_config=app_config,
extensions=_extensions(
MiddlewarePlacement(
_Probe("physical"),
Placement.MODEL_PHYSICAL,
scope=AgentScope.LEAD,
)
),
)
diagnostics = get_runtime_diagnostics()
finally:
reset_runtime_diagnostics()
assert any("MODEL_PHYSICAL fell back to a secondary anchor" in diagnostic.message for diagnostic in diagnostics)
def test_subagent_model_physical_sees_the_final_request():
"""Subagents provide the same final-request guarantee as the lead agent."""
stack = _subagent_stack_with(
MiddlewarePlacement(
_Probe("physical"),
Placement.MODEL_PHYSICAL,
scope=AgentScope.SUBAGENT,
)
)
index = _index_of_probe(stack, "physical")
offenders = [type(_unwrap(m)).__name__ for m in stack[index + 1 :] if middleware_implements(_unwrap(m), "wrap_model_call")]
assert offenders == [], f"these subagent middlewares sit inner of the MODEL_PHYSICAL anchor and wrap model calls, breaking its documented guarantee: {offenders}"
def test_subagent_model_physical_uses_the_innermost_core_coalescer():
"""A configured duplicate must not capture the type-based primary anchor."""
app_config = AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
app_config.extensions = ExtensionsConfig(middlewares=["deerflow.agents.middlewares.system_message_coalescing_middleware:SystemMessageCoalescingMiddleware"])
stack = build_subagent_runtime_middlewares(
app_config=app_config,
model_name="gpt-4o",
extensions=_extensions(
MiddlewarePlacement(
_Probe("physical"),
Placement.MODEL_PHYSICAL,
scope=AgentScope.SUBAGENT,
)
),
)
index = _index_of_probe(stack, "physical")
offenders = [type(_unwrap(m)).__name__ for m in stack[index + 1 :] if middleware_implements(_unwrap(m), "wrap_model_call")]
assert offenders == []
#: The single documented middleware allowed inner of TOOL_RAW.
#:
#: ClarificationMiddleware must stay last — it short-circuits the tool loop with
#: Command(goto=END) — and it only intercepts ask_clarification, never
#: transforming the result of a tool that actually executes. So TOOL_RAW still
#: sees raw results. This carve-out is named explicitly rather than the
#: assertion being loosened: any OTHER middleware appearing here is a real
#: regression, and adding to this set must be a deliberate, argued change.
_TOOL_RAW_CARVE_OUT = {"ClarificationMiddleware"}
def test_tool_raw_is_adjacent_to_the_callable_boundary():
"""Nothing inner of TOOL_RAW may wrap tool calls, bar one documented case."""
stack = _stack_with(MiddlewarePlacement(_Probe("raw"), Placement.TOOL_RAW))
index = _index_of_probe(stack, "raw")
offenders = [name for m in stack[index + 1 :] if middleware_implements(_unwrap(m), "wrap_tool_call") and (name := type(_unwrap(m)).__name__) not in _TOOL_RAW_CARVE_OUT]
assert offenders == [], (
f"these middlewares sit inner of TOOL_RAW and wrap tool calls: {offenders}. "
"Either move them outer of the anchor or update the anchor table in "
"deerflow/extensions/stack.py — do not add them to the carve-out without "
"an argument for why TOOL_RAW still sees raw results."
)
def test_the_tool_raw_carve_out_is_actually_needed():
"""Guards the carve-out itself: if ClarificationMiddleware ever stops
sitting inner of TOOL_RAW, the exemption is dead and must be deleted rather
than quietly hiding a future regression."""
stack = _stack_with(MiddlewarePlacement(_Probe("raw"), Placement.TOOL_RAW))
index = _index_of_probe(stack, "raw")
inner = {type(_unwrap(m)).__name__ for m in stack[index + 1 :]}
assert _TOOL_RAW_CARVE_OUT <= inner, f"the carve-out lists middlewares that are no longer inner of TOOL_RAW: {_TOOL_RAW_CARVE_OUT - inner}"
def test_tool_visible_sees_the_final_result():
"""Nothing outer of TOOL_VISIBLE may wrap tool calls."""
stack = _stack_with(MiddlewarePlacement(_Probe("visible"), Placement.TOOL_VISIBLE))
index = _index_of_probe(stack, "visible")
offenders = [type(_unwrap(m)).__name__ for m in stack[:index] if middleware_implements(_unwrap(m), "wrap_tool_call")]
assert offenders == [], f"these middlewares sit outer of TOOL_VISIBLE and wrap tool calls: {offenders}"
def test_model_logical_is_outer_of_the_retry_loop():
"""One logical decision must stay one event across provider retries."""
from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware
stack = _stack_with(MiddlewarePlacement(_Probe("logical"), Placement.MODEL_LOGICAL))
logical = _index_of_probe(stack, "logical")
retry = next(index for index, m in enumerate(stack) if isinstance(_unwrap(m), LLMErrorHandlingMiddleware))
assert logical < retry, "MODEL_LOGICAL must be outer of the retry middleware"
def test_logical_and_physical_nest_in_the_right_order():
"""Step:Attempt is 1:N — the logical probe must enclose the physical one."""
stack = _stack_with(
MiddlewarePlacement(_Probe("logical"), Placement.MODEL_LOGICAL),
MiddlewarePlacement(_Probe("physical"), Placement.MODEL_PHYSICAL),
)
assert _index_of_probe(stack, "logical") < _index_of_probe(stack, "physical")
def test_visible_and_raw_bracket_the_tool_chain():
"""Visible/raw form a pair around truncation and sanitization; that gap is
what lets an extension see how much a tool result was altered."""
stack = _stack_with(
MiddlewarePlacement(_Probe("visible"), Placement.TOOL_VISIBLE),
MiddlewarePlacement(_Probe("raw"), Placement.TOOL_RAW),
)
visible = _index_of_probe(stack, "visible")
raw = _index_of_probe(stack, "raw")
assert visible < raw
between = [type(_unwrap(m)).__name__ for m in stack[visible + 1 : raw] if middleware_implements(_unwrap(m), "wrap_tool_call")]
assert between, "the tool-processing chain must sit between the two probes"
def test_all_four_probes_coexist_without_violating_ordering():
stack = _stack_with(
MiddlewarePlacement(_Probe("visible"), Placement.TOOL_VISIBLE),
MiddlewarePlacement(_Probe("logical"), Placement.MODEL_LOGICAL),
MiddlewarePlacement(_Probe("physical"), Placement.MODEL_PHYSICAL),
MiddlewarePlacement(_Probe("raw"), Placement.TOOL_RAW),
)
indices = [_index_of_probe(stack, tag) for tag in ("visible", "logical", "physical", "raw")]
assert len(set(indices)) == 4
def test_middleware_implements_detects_overrides():
class _Wraps(AgentMiddleware):
def wrap_tool_call(self, request, handler):
return handler(request)
class _Plain(AgentMiddleware):
pass
assert middleware_implements(_Wraps(), "wrap_tool_call") is True
assert middleware_implements(_Plain(), "wrap_tool_call") is False