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

365 lines
14 KiB
Python

"""Isolating extension middleware failures from the user's run.
Extension middlewares execute inside LangChain's call chain, so an unhandled
exception would abort the user's run. Every contributed middleware is wrapped
so an observation failure degrades to a diagnostic and the call passes through.
The downstream handler is tracked so isolation recovery never adds another
model request or tool side effect: pre-handler extension failures invoke it
once, post-handler failures return its captured result, and handler failures
remain owned by the graph's error policy.
The wrapper must mirror the inner middleware's full interface, not just the
four wrap-call hooks: LangChain discovers capabilities by inspecting the
wrapper — hook participation via class-level identity checks
(`m.__class__.before_model is not AgentMiddleware.before_model`), tools,
state_schema and transformers via instance attributes. Lifecycle mirroring is
exact in both directions. LangChain deliberately treats each sync/async wrap
pair as one capability and wires both execution paths when either side exists,
so the wrapper supplies a silent pass-through counterpart when the inner
implements only one side; otherwise the base class raises
``NotImplementedError`` before isolation can fail open.
All first-version contributions are observational, hence fail-open. A future
intercepting (decision-making) contribution would need to fail closed and must
opt out of this wrapper explicitly.
"""
from __future__ import annotations
import logging
import re
import threading
from collections.abc import Awaitable, Callable
from types import TracebackType
from typing import Any
from langchain.agents.middleware import AgentMiddleware
from langgraph.errors import GraphBubbleUp
from deerflow.extensions.loader import Diagnostic
logger = logging.getLogger(__name__)
_UNSAFE_GRAPH_NAME = re.compile(r"[^A-Za-z0-9_.-]+")
def graph_safe_middleware_name(value: str) -> str:
"""Normalize a middleware identity for LangGraph node names."""
return _UNSAFE_GRAPH_NAME.sub("_", value)
_WRAP_HOOKS = ("wrap_model_call", "awrap_model_call", "wrap_tool_call", "awrap_tool_call")
_WRAP_HOOK_PAIRS = (
("wrap_model_call", "awrap_model_call"),
("wrap_tool_call", "awrap_tool_call"),
)
_LIFECYCLE_HOOKS = (
"before_agent",
"abefore_agent",
"before_model",
"abefore_model",
"after_model",
"aafter_model",
"after_agent",
"aafter_agent",
)
def _implemented_hooks(inner: AgentMiddleware) -> frozenset[str]:
"""The hooks ``inner`` actually overrides, by LangChain's own class-level
identity check — instance-level attributes are invisible to the factory,
so they are invisible here too."""
return frozenset(hook for hook in (*_WRAP_HOOKS, *_LIFECYCLE_HOOKS) if getattr(type(inner), hook, None) is not getattr(AgentMiddleware, hook, None))
def _make_sync_wrap_delegate(hook: str):
def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Any]) -> Any:
return self._invoke_sync(hook, getattr(self._inner, hook), request, handler)
return delegate
def _make_async_wrap_delegate(hook: str):
async def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
return await self._invoke_async(hook, getattr(self._inner, hook), request, handler)
return delegate
def _make_sync_wrap_passthrough():
def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Any]) -> Any:
return handler(request)
return delegate
def _make_async_wrap_passthrough():
async def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
return await handler(request)
return delegate
def _make_sync_lifecycle_delegate(hook: str):
def delegate(self: IsolatedMiddleware, state: Any, runtime: Any) -> Any:
return self._invoke_lifecycle_sync(hook, state, runtime)
return delegate
def _make_async_lifecycle_delegate(hook: str):
async def delegate(self: IsolatedMiddleware, state: Any, runtime: Any) -> Any:
return await self._invoke_lifecycle_async(hook, state, runtime)
return delegate
# Async variants are named explicitly: startswith("a") would also catch the
# sync after_model/after_agent.
_ASYNC_HOOKS = frozenset(hook for hook in (*_WRAP_HOOKS, *_LIFECYCLE_HOOKS) if hook[1:].startswith(("wrap", "before", "after")))
def _delegate_for(hook: str):
if hook in _WRAP_HOOKS:
return _make_async_wrap_delegate(hook) if hook in _ASYNC_HOOKS else _make_sync_wrap_delegate(hook)
return _make_async_lifecycle_delegate(hook) if hook in _ASYNC_HOOKS else _make_sync_lifecycle_delegate(hook)
_subclass_cache: dict[frozenset[str], type[IsolatedMiddleware]] = {}
_subclass_cache_lock = threading.Lock()
def _wrapper_subclass(hooks: frozenset[str]) -> type[IsolatedMiddleware]:
"""A cached IsolatedMiddleware subclass defining ``hooks`` and required
wrap-hook pass-through counterparts.
Per hook set, not per middleware: every inner middleware with the same
implemented-hook combination shares one subclass.
"""
with _subclass_cache_lock:
subclass = _subclass_cache.get(hooks)
if subclass is None:
namespace = {hook: _delegate_for(hook) for hook in hooks}
for sync_hook, async_hook in _WRAP_HOOK_PAIRS:
if sync_hook in hooks and async_hook not in hooks:
namespace[async_hook] = _make_async_wrap_passthrough()
elif async_hook in hooks and sync_hook not in hooks:
namespace[sync_hook] = _make_sync_wrap_passthrough()
subclass = type(IsolatedMiddleware.__name__, (IsolatedMiddleware,), namespace)
_subclass_cache[hooks] = subclass
return subclass
class IsolatedMiddleware(AgentMiddleware):
"""Wrap one extension middleware so its failures cannot break the run.
Instantiation returns a cached subclass that defines exactly the hooks the
inner middleware implements, so LangChain's class-level capability checks
see the same interface on the wrapper as on the inner middleware itself.
"""
def __new__(cls, inner: AgentMiddleware, source: str, on_error: Callable[[Diagnostic], None], *, name: str | None = None):
if cls is IsolatedMiddleware:
cls = _wrapper_subclass(_implemented_hooks(inner))
return super().__new__(cls)
def __init__(
self,
inner: AgentMiddleware,
source: str,
on_error: Callable[[Diagnostic], None],
*,
name: str | None = None,
) -> None:
super().__init__()
self._inner = inner
self._source = source
self._on_error = on_error
if name is None:
inner_name = getattr(inner, "name", type(inner).__name__)
name = f"extension:{source}:{inner_name}"
self._name = graph_safe_middleware_name(name)
# Mirror the declared-contribution attributes LangChain reads off the
# middleware instance (factory.py: m.tools, m.state_schema,
# m.transformers). state_schema is a class attribute on the base but
# must be per-instance here: cached subclasses are shared across
# middlewares whose schemas differ.
self.tools = getattr(inner, "tools", [])
self.transformers = getattr(inner, "transformers", ())
self.state_schema = getattr(inner, "state_schema", AgentMiddleware.state_schema)
@property
def name(self) -> str:
"""Stable graph and trace identity for this isolated contribution."""
return self._name
@property
def inner(self) -> AgentMiddleware:
"""The wrapped middleware. Used by ordering checks and tests."""
return self._inner
@property
def source(self) -> str:
"""Extension this middleware came from. Read by the provenance map."""
return self._source
def _report(self, hook: str, exc: Exception) -> None:
message = f"{type(self._inner).__name__}.{hook} failed and was skipped: {exc}"
logger.exception("Extension %s: %s", self._source, message)
try:
self._on_error(Diagnostic.error(self._source, message))
except Exception: # pragma: no cover - reporting must never raise
logger.exception("Extension %s: diagnostic reporting failed", self._source)
def _invoke_sync(
self,
hook: str,
inner_hook: Callable[[Any, Callable[[Any], Any]], Any],
request: Any,
handler: Callable[[Any], Any],
) -> Any:
handler_called = False
handler_succeeded = False
handler_result: Any = None
handler_error: BaseException | None = None
handler_error_traceback: TracebackType | None = None
duplicate_call_error: RuntimeError | None = None
def tracked_handler(inner_request: Any) -> Any:
nonlocal handler_called, duplicate_call_error
nonlocal handler_error, handler_error_traceback
nonlocal handler_result, handler_succeeded
if handler_called:
duplicate_call_error = RuntimeError(f"{type(self._inner).__name__}.{hook} called the downstream handler more than once")
raise duplicate_call_error
handler_called = True
handler_error = None
handler_error_traceback = None
handler_succeeded = False
try:
# The first contract slice is observational: a contributed
# wrapper may inspect the request but cannot substitute a new
# one after the host's policy/authorization layers have run.
handler_result = handler(request)
except BaseException as exc:
handler_error = exc
handler_error_traceback = exc.__traceback__
raise
else:
handler_succeeded = True
return handler_result
try:
inner_hook(request, tracked_handler)
if handler_error is not None:
raise handler_error.with_traceback(handler_error_traceback)
if duplicate_call_error is not None:
raise duplicate_call_error
if not handler_called:
raise RuntimeError(f"{type(self._inner).__name__}.{hook} did not call the downstream handler")
return handler_result
except GraphBubbleUp as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
if handler_succeeded:
self._report(hook, duplicate_call_error or exc)
return handler_result
raise
except Exception as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
self._report(hook, exc)
if handler_succeeded:
return handler_result
return handler(request)
async def _invoke_async(
self,
hook: str,
inner_hook: Callable[
[Any, Callable[[Any], Awaitable[Any]]],
Awaitable[Any],
],
request: Any,
handler: Callable[[Any], Awaitable[Any]],
) -> Any:
handler_called = False
handler_succeeded = False
handler_result: Any = None
handler_error: BaseException | None = None
handler_error_traceback: TracebackType | None = None
duplicate_call_error: RuntimeError | None = None
async def tracked_handler(inner_request: Any) -> Any:
nonlocal handler_called, duplicate_call_error
nonlocal handler_error, handler_error_traceback
nonlocal handler_result, handler_succeeded
if handler_called:
duplicate_call_error = RuntimeError(f"{type(self._inner).__name__}.{hook} called the downstream handler more than once")
raise duplicate_call_error
handler_called = True
handler_error = None
handler_error_traceback = None
handler_succeeded = False
try:
handler_result = await handler(request)
except BaseException as exc:
handler_error = exc
handler_error_traceback = exc.__traceback__
raise
else:
handler_succeeded = True
return handler_result
try:
await inner_hook(request, tracked_handler)
if handler_error is not None:
raise handler_error.with_traceback(handler_error_traceback)
if duplicate_call_error is not None:
raise duplicate_call_error
if not handler_called:
raise RuntimeError(f"{type(self._inner).__name__}.{hook} did not call the downstream handler")
return handler_result
except GraphBubbleUp as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
if handler_succeeded:
self._report(hook, duplicate_call_error or exc)
return handler_result
raise
except Exception as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
self._report(hook, exc)
if handler_succeeded:
return handler_result
return await handler(request)
def _invoke_lifecycle_sync(self, hook: str, state: Any, runtime: Any) -> Any:
"""Lifecycle hooks have no handler to fall through to: the fail-open
degradation for a failed observation is applying no state update."""
try:
return getattr(self._inner, hook)(state, runtime)
except GraphBubbleUp:
raise
except Exception as exc:
self._report(hook, exc)
return None
async def _invoke_lifecycle_async(self, hook: str, state: Any, runtime: Any) -> Any:
try:
return await getattr(self._inner, hook)(state, runtime)
except GraphBubbleUp:
raise
except Exception as exc:
self._report(hook, exc)
return None