mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-13 08:18:57 +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.
226 lines
9.5 KiB
Python
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)
|