mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-12 15:59:04 +00:00
* 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.
87 lines
2.9 KiB
Python
87 lines
2.9 KiB
Python
"""Tests for the extension registry and its immutable build product."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from deerflow.extensions.registry import EMPTY_EXTENSIONS, ExtensionRegistry
|
|
|
|
|
|
class _Contributor:
|
|
def __init__(self, tag: str = "") -> None:
|
|
self.tag = tag
|
|
|
|
|
|
def test_empty_registry_builds_with_all_flags_false():
|
|
loaded = ExtensionRegistry().build()
|
|
assert loaded.has_middleware_contributors is False
|
|
assert loaded.needs_task_store is False
|
|
|
|
|
|
def test_empty_singleton_matches_an_empty_build():
|
|
assert EMPTY_EXTENSIONS.has_middleware_contributors is False
|
|
assert EMPTY_EXTENSIONS.needs_task_store is False
|
|
|
|
|
|
def test_middleware_contributions_require_a_task_store():
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_Contributor())
|
|
|
|
assert registry.build().needs_task_store is True
|
|
|
|
|
|
def test_entries_carry_their_source():
|
|
registry = ExtensionRegistry()
|
|
contributor = _Contributor("mw")
|
|
with registry.attributed_to("demo_ext:install"):
|
|
registry.middlewares(contributor)
|
|
loaded = registry.build()
|
|
assert loaded.middleware_contributors == (("demo_ext:install", contributor),)
|
|
assert loaded.has_middleware_contributors is True
|
|
|
|
|
|
def test_registration_order_is_preserved():
|
|
registry = ExtensionRegistry()
|
|
first, second = _Contributor("a"), _Contributor("b")
|
|
with registry.attributed_to("a_ext:install"):
|
|
registry.middlewares(first)
|
|
with registry.attributed_to("b_ext:install"):
|
|
registry.middlewares(second)
|
|
loaded = registry.build()
|
|
assert [source for source, _ in loaded.middleware_contributors] == ["a_ext:install", "b_ext:install"]
|
|
|
|
|
|
def test_discard_removes_every_entry_of_one_source():
|
|
"""A partially-registered extension is worse than an absent one: the data
|
|
it produces looks complete but is not."""
|
|
registry = ExtensionRegistry()
|
|
keep, drop = _Contributor("keep"), _Contributor("drop")
|
|
with registry.attributed_to("good:install"):
|
|
registry.middlewares(keep)
|
|
with registry.attributed_to("bad:install"):
|
|
registry.middlewares(drop)
|
|
registry.discard("bad:install")
|
|
loaded = registry.build()
|
|
assert loaded.middleware_contributors == (("good:install", keep),)
|
|
|
|
|
|
def test_registering_outside_attributed_to_raises():
|
|
registry = ExtensionRegistry()
|
|
with pytest.raises(RuntimeError, match="attributed_to"):
|
|
registry.middlewares(_Contributor())
|
|
|
|
|
|
def test_build_result_is_frozen():
|
|
loaded = ExtensionRegistry().build()
|
|
with pytest.raises(Exception):
|
|
loaded.middleware_contributors = () # type: ignore[misc]
|
|
|
|
|
|
def test_app_store_is_created_at_build_time():
|
|
"""The app store must exist before binding so the registration phase and
|
|
the binding phase see the same object."""
|
|
loaded = ExtensionRegistry().build()
|
|
assert loaded.app_store is not None
|
|
assert loaded.app_store.scope_id == "app"
|