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

121 lines
3.9 KiB
Python

"""Translating semantic placements into concrete stack indices.
This is the only module that knows the shape of DeerFlow's middleware stack.
Restructuring the stack means updating the anchor table here; extensions,
which declare only what they need to observe, stay untouched.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Literal
_Side = Literal[
"outer",
"inner",
"outer_last",
"inner_last",
"inner_last_after",
"start",
"end",
]
@dataclass(frozen=True)
class AnchorRule:
"""One attempt at locating an insertion index.
``side`` "outer"/"inner" position relative to the first middleware whose
type is in ``types``; "outer_last"/"inner_last" use the last matching
middleware; "inner_last_after" additionally requires that match to follow
the last middleware in ``after_types``. "start"/"end" are the absolute
ends of the stack and ignore ``types``.
"""
side: _Side
types: tuple[type, ...] = ()
after_types: tuple[type, ...] = ()
def resolve(self, middlewares: Sequence[object]) -> int | None:
if self.side == "start":
return 0
if self.side == "end":
return len(middlewares)
if self.side in {"outer_last", "inner_last"}:
for index in range(len(middlewares) - 1, -1, -1):
if isinstance(middlewares[index], self.types):
return index if self.side == "outer_last" else index + 1
return None
if self.side == "inner_last_after":
boundary = next(
(index for index in range(len(middlewares) - 1, -1, -1) if isinstance(middlewares[index], self.after_types)),
None,
)
if boundary is None:
return None
for index in range(len(middlewares) - 1, boundary, -1):
if isinstance(middlewares[index], self.types):
return index + 1
return None
for index, middleware in enumerate(middlewares):
if isinstance(middleware, self.types):
return index if self.side == "outer" else index + 1
return None
@dataclass(frozen=True)
class PlacementAnchor:
"""An ordered fallback chain of anchor rules."""
chain: tuple[AnchorRule, ...]
@classmethod
def of(cls, *anchors: PlacementAnchor) -> PlacementAnchor:
"""Concatenate anchors into one fallback chain."""
rules: list[AnchorRule] = []
for anchor in anchors:
rules.extend(anchor.chain)
return cls(tuple(rules))
def resolve(self, middlewares: Sequence[object]) -> tuple[int, bool]:
"""Return (index, used_primary_rule).
``used_primary_rule`` is False when the first rule did not match, which
the caller reports as a diagnostic — a silently degraded placement
changes what the extension observes with no signal.
"""
for position, rule in enumerate(self.chain):
index = rule.resolve(middlewares)
if index is not None:
return index, position == 0
return len(middlewares), False
def outer_of(*types: type) -> PlacementAnchor:
return PlacementAnchor((AnchorRule("outer", types),))
def inner_of(*types: type) -> PlacementAnchor:
return PlacementAnchor((AnchorRule("inner", types),))
def inner_of_last(*types: type) -> PlacementAnchor:
return PlacementAnchor((AnchorRule("inner_last", types),))
def inner_of_last_after(*types: type, after: tuple[type, ...]) -> PlacementAnchor:
return PlacementAnchor((AnchorRule("inner_last_after", types, after),))
def outer_of_last(*types: type) -> PlacementAnchor:
return PlacementAnchor((AnchorRule("outer_last", types),))
def outermost() -> PlacementAnchor:
return PlacementAnchor((AnchorRule("start"),))
def innermost() -> PlacementAnchor:
return PlacementAnchor((AnchorRule("end"),))