mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-07 13:29:15 +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.
365 lines
14 KiB
Python
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
|