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

226 lines
9.5 KiB
Python

"""Config-driven extension loading.
Entry points are named as `module.path:install`, resolved through the same
`resolve_variable` helper the guardrails provider already uses. Load order is
the config list order — explicit and reproducible, which matters because the
middleware stack is position-sensitive.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal
from deerflow_extension_api import API_VERSION
from pydantic import BaseModel, ConfigDict, Field
from deerflow.extensions.registry import ExtensionRegistry, LoadedExtensions
from deerflow.reflection import resolve_variable
logger = logging.getLogger(__name__)
DiagnosticLevel = Literal["debug", "info", "warning", "error"]
class ExtensionSpec(BaseModel):
"""One entry of the `plugins:` list in config.yaml."""
model_config = ConfigDict(extra="forbid")
use: str = Field(description="Entry point path, e.g. 'my_extension:install'")
config: dict[str, Any] = Field(
default_factory=dict,
description="Extension-private configuration, passed to install() verbatim",
)
required: bool = Field(
default=False,
description="When true, a load failure aborts startup instead of being skipped",
)
@dataclass(frozen=True)
class Diagnostic:
"""A load- or run-time problem attributed to a specific extension.
The repository has no structured diagnostics channel today; this is a
deliberately minimal one whose only job is keeping failures attributable.
"""
level: DiagnosticLevel
source: str
message: str
@classmethod
def error(cls, source: str, message: str) -> Diagnostic:
return cls("error", source, message)
@classmethod
def warning(cls, source: str, message: str) -> Diagnostic:
return cls("warning", source, message)
@classmethod
def info(cls, source: str, message: str) -> Diagnostic:
return cls("info", source, message)
@classmethod
def debug(cls, source: str, message: str) -> Diagnostic:
return cls("debug", source, message)
class ExtensionLoadError(RuntimeError):
"""Raised when an extension marked `required: true` fails to load."""
def _parse_version(version: object) -> tuple[int, ...] | None:
if not isinstance(version, str):
return None
try:
return tuple(int(part) for part in str.split(version, "."))
except ValueError:
return None
def _compatible(declared: str, current: str) -> bool:
"""One-directional, with the semver window for the contract's life stage.
Pre-1.0 the contract surface is observational only and minors may break,
so the window is same major.minor with patches additive: host >= declared.
From 1.0 on contracts only grow within a major, so a newer host stays
compatible with older extensions while an extension written against a
newer minor is refused — it would reach for contract additions the host
does not implement. Unparseable versions are refused, not waved through."""
declared_parts = _parse_version(declared)
current_parts = _parse_version(current)
if not declared_parts or not current_parts:
return False
width = max(len(declared_parts), len(current_parts), 2)
declared_padded = declared_parts + (0,) * (width - len(declared_parts))
current_padded = current_parts + (0,) * (width - len(current_parts))
if declared_padded[0] != current_padded[0]:
return False
if declared_padded[0] == 0 and declared_padded[1] != current_padded[1]:
return False
return current_padded >= declared_padded
def _range_for(declared: str) -> str:
"""The pip window matching ``_compatible``'s rules, for the actionable
refusal message. Falls back to an exact request when the declared version
is unparseable — the message must survive the version that caused it."""
parts = _parse_version(declared)
if not parts:
return f"=={declared}"
if parts[0] == 0:
minor = parts[1] if len(parts) > 1 else 0
return f">={declared},<0.{minor + 1}"
return f">={declared},<{parts[0] + 1}.0"
def load_extensions(specs: Sequence[ExtensionSpec]) -> tuple[LoadedExtensions, list[Diagnostic]]:
"""Resolve and install every configured extension.
Fail-open by default: a broken extension is skipped with a diagnostic so
the Gateway still starts. `required: true` flips that to fail-closed for
extensions whose absence changes behaviour rather than just observability.
"""
registry = ExtensionRegistry()
diagnostics: list[Diagnostic] = []
loaded_sources: list[str] = []
for spec in specs:
try:
install = resolve_variable(spec.use)
except Exception as exc:
message = f"could not resolve extension entry point: {exc}"
diagnostics.append(Diagnostic.error(spec.use, message))
logger.error("Extension %s: %s", spec.use, message)
if spec.required:
raise ExtensionLoadError(f"required extension {spec.use} failed to load") from exc
continue
if not callable(install):
message = f"extension entry point is not callable: {type(install).__name__}"
diagnostics.append(Diagnostic.error(spec.use, message))
logger.error("Extension %s: %s", spec.use, message)
if spec.required:
raise ExtensionLoadError(f"required extension {spec.use} is not callable")
continue
try:
declared = getattr(install, "__deerflow_api__", None)
except Exception as exc:
message = f"could not inspect extension-api version marker: {type(exc).__name__}"
diagnostics.append(Diagnostic.error(spec.use, message))
logger.error("Extension %s: %s", spec.use, message)
if spec.required:
raise ExtensionLoadError(f"required extension {spec.use} could not inspect api marker") from exc
continue
if declared is not None and _parse_version(declared) is None:
message = f"extension declares invalid extension-api version marker of type {type(declared).__name__}; expected a dotted numeric string such as '0.1'"
diagnostics.append(Diagnostic.error(spec.use, message))
logger.error("Extension %s: %s", spec.use, message)
if spec.required:
raise ExtensionLoadError(f"required extension {spec.use} declares invalid api marker")
continue
if declared is not None:
# ``isinstance(..., str)`` also accepts subclasses whose
# ``__str__``/``__format__`` methods can execute plugin code while
# we build an incompatibility diagnostic. Normalize with the base
# implementation before compatibility checks and rendering.
declared = str.__str__(declared)
if declared is not None and not _compatible(declared, API_VERSION):
message = f"extension requires extension-api {declared}, host provides {API_VERSION}. Install a matching version: pip install 'deerflow-extension-api{_range_for(declared)}'"
diagnostics.append(Diagnostic.error(spec.use, message))
logger.error("Extension %s: %s", spec.use, message)
if spec.required:
raise ExtensionLoadError(f"required extension {spec.use} declares incompatible api {declared}")
continue
# Positional rollback, not registry.discard(spec.use): two specs may
# legitimately share the same `use` with different config, and
# discard-by-source would also erase an earlier, successfully
# installed instance that happens to share this spec's `use`.
mark = registry.mark()
try:
with registry.attributed_to(spec.use):
install(registry, _frozen_config(spec.config))
except Exception as exc:
registry.rollback_to(mark)
message = f"install() failed: {exc}"
diagnostics.append(Diagnostic.error(spec.use, message))
logger.exception("Extension %s: install() failed", spec.use)
if spec.required:
raise ExtensionLoadError(f"required extension {spec.use} failed to install") from exc
continue
loaded_sources.append(spec.use)
# Loading third-party code is exactly the event an operator needs positive
# confirmation of, and every other branch here is failure-only — so without
# this line a fully successful load is indistinguishable from a `plugins:`
# block the host never read. The x/y count names the difference between
# "all loaded" and "some were skipped" without repeating the per-failure
# errors already logged above.
if specs:
logger.info("Extensions loaded: %d/%d (%s)", len(loaded_sources), len(specs), ", ".join(loaded_sources) or "none")
else:
# Debug, not info: no configured plugins is the default state for almost
# every deployment, and an unconditional line would be pure boot noise.
logger.debug("No extensions configured")
return registry.build(), diagnostics
def _frozen_config(config: dict[str, Any]) -> Mapping[str, Any]:
"""Hand extensions a shallow copy of their config block.
This is a shallow copy: it stops an extension from reassigning
top-level keys on another extension's (or the caller's) config dict, but
nested structures (lists, dicts) are still shared by reference and can be
mutated in place. Use plain, top-level config values if this guarantee
matters to you.
"""
return dict(config)