mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-23 05:08:44 +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.
127 lines
7.3 KiB
Python
127 lines
7.3 KiB
Python
"""Single source of truth for the config hot-reload boundary.
|
|
|
|
Bytedance/deer-flow issue #3144: gateway request dependencies resolve
|
|
``AppConfig`` through ``get_app_config()`` on every request, so per-run
|
|
fields take effect on the next message without restarting the gateway.
|
|
The fields listed in this module are the **infrastructure** subset that
|
|
the gateway captures once at startup — engines, singletons, IM clients,
|
|
the logging handler — and that therefore require a process restart to
|
|
change at runtime.
|
|
|
|
The registry covers two kinds of entries:
|
|
|
|
- Top-level ``AppConfig`` fields (``database``, ``checkpointer``,
|
|
``run_events``, ``stream_bridge``, ``sandbox``, ``log_level``). For
|
|
these, :func:`format_field_description` produces the standardised
|
|
``"startup-only: ..."`` prefix that the matching Pydantic
|
|
``Field(description=...)`` carries, so the boundary surfaces in IDE
|
|
hover next to the field itself.
|
|
- Top-level ``config.yaml`` sections that are not part of the
|
|
``AppConfig`` schema (``channels``). These cannot be standardised at
|
|
the schema level, so the registry is their only canonical location.
|
|
|
|
Any future "needs restart" scanner — operator tooling, lint hooks, doc
|
|
generators — should drive off this registry rather than re-parsing
|
|
prose.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
|
|
#: The standardised prefix every restart-required field description starts
|
|
#: with. ``test_reload_boundary`` enforces both directions: registered
|
|
#: fields must use this prefix in the schema, and any schema field using
|
|
#: this prefix must be in the registry.
|
|
STARTUP_ONLY_PREFIX = "startup-only:"
|
|
|
|
|
|
#: Restart-required field paths mapped to the human-readable reason.
|
|
#:
|
|
#: The reason text is what surfaces in ``Field(description=...)``, so it
|
|
#: must explain *what* code captures the snapshot — not just that the
|
|
#: field is restart-required — so an operator changing the value knows
|
|
#: which subsystem to restart.
|
|
STARTUP_ONLY_FIELDS: dict[str, str] = {
|
|
"plugins": ("load_extensions() runs once during create_app() and the process-wide middleware registry is not rebuilt on config.yaml edits; adding, removing or reconfiguring a plugin requires a restart."),
|
|
"database": ("init_engine_from_config() runs once during langgraph_runtime() startup; the SQLAlchemy engine holds the connection pool and is not rebuilt on config.yaml edits."),
|
|
"checkpointer": ("make_checkpointer() binds the persistent checkpointer once at startup, including SQLite WAL / busy_timeout settings."),
|
|
"run_events": ("make_run_event_store() picks the memory- vs SQL-backed implementation at startup and is frozen onto app.state.run_events_config to stay paired with the underlying event store."),
|
|
"agent_storage": ("langgraph_runtime() validates agent_storage.backend against database.backend once at startup, and the db backend's synchronous SQLAlchemy engine is process-cached on first use; switching backend needs a restart."),
|
|
"stream_bridge": ("make_stream_bridge() constructs the stream-bridge singleton once during startup."),
|
|
"sandbox": ("get_sandbox_provider() caches the provider singleton (``_default_sandbox_provider``); a different ``sandbox.use`` class path only takes effect on next process start."),
|
|
"log_level": (
|
|
"apply_logging_level() runs only during app.py startup; it sets the deerflow/app logger levels and may lower root handler thresholds so configured messages can propagate. A freshly reloaded AppConfig does not retrigger it."
|
|
),
|
|
"logging": (
|
|
"configure_logging() runs only during app.py startup; it installs/removes the trace-context filter and the enhanced formatter on root handlers, "
|
|
"and TraceMiddleware captures logging.enhance.enabled once at startup so response X-Trace-Id headers, log trace_id fields, and Langfuse "
|
|
"deerflow_trace_id stay coherent. A freshly reloaded AppConfig does not retrigger any of this."
|
|
),
|
|
# Not part of the AppConfig Pydantic schema — channel credentials are
|
|
# consumed directly by ``start_channel_service()`` once at lifespan
|
|
# startup and the live channel clients are not rebuilt on
|
|
# config.yaml edits.
|
|
"channels": ("start_channel_service() is invoked once during startup; the live IM channel clients (Feishu, Slack, Telegram, DingTalk) are not rebuilt when channels.* changes."),
|
|
"channel_connections": (
|
|
"start_channel_service() wires the connection repository and channel workers once at startup, and the channel-connections router caches the merged provider config on app.state; channel_connections.* edits need a restart."
|
|
),
|
|
"scheduler": (
|
|
"ScheduledTaskService is constructed and started once during Gateway lifespan startup; enabled, poll_interval_seconds, lease_seconds, "
|
|
"and max_concurrent_runs are captured into the service instance and the background poller task is not rebuilt on config.yaml edits."
|
|
),
|
|
"run_ownership": (
|
|
"RunOwnershipConfig is captured once into RunManager at langgraph_runtime() startup; the lease heartbeat background task is created and "
|
|
"started there, and heartbeat_enabled / lease_seconds / grace_seconds are not re-read on config.yaml edits."
|
|
),
|
|
"dedupe_storage": (
|
|
"make_inbound_dedupe_store() resolves the inbound dedupe store once when ChannelService is constructed at startup; the store "
|
|
"(in-process memory or shared Postgres) is captured onto ChannelManager and is not rebuilt on config.yaml edits."
|
|
),
|
|
}
|
|
|
|
|
|
def iter_startup_only_field_paths() -> Iterator[str]:
|
|
"""Yield every registered restart-required field path."""
|
|
return iter(STARTUP_ONLY_FIELDS)
|
|
|
|
|
|
def is_startup_only_field(field_path: str) -> bool:
|
|
"""Return ``True`` when *field_path* is registered as restart-required.
|
|
|
|
Accepts only top-level paths (``"database"``, ``"sandbox"`` etc.);
|
|
nested keys like ``"database.url"`` are not modelled here because the
|
|
boundary is per-section, not per-leaf.
|
|
"""
|
|
return field_path in STARTUP_ONLY_FIELDS
|
|
|
|
|
|
def format_field_description(field_path: str, *, field_doc: str | None = None) -> str:
|
|
"""Build the standardised description for a registered field.
|
|
|
|
Used inside ``AppConfig`` ``Field(description=...)`` so the hover
|
|
text in IDEs matches the registry and the drift tests can pin one
|
|
side against the other.
|
|
|
|
Args:
|
|
field_path: A registered top-level field path (e.g. ``"log_level"``).
|
|
field_doc: Optional human-facing description for the field itself
|
|
(allowed values, semantics, etc.). When supplied, it is
|
|
appended after the ``startup-only:`` marker block separated by
|
|
a blank line so IDE hover shows both the restart-required
|
|
reason *and* the field's normal documentation. Composition
|
|
keeps the marker as the leading token machine-readable tooling
|
|
pivots on while restoring the prose that ``Field(description=)``
|
|
used to carry before the registry took over.
|
|
|
|
Raises:
|
|
KeyError: when *field_path* is not registered. This is deliberate
|
|
— silently returning a placeholder would let a typo bypass
|
|
the drift coverage.
|
|
"""
|
|
reason = STARTUP_ONLY_FIELDS[field_path]
|
|
header = f"{STARTUP_ONLY_PREFIX} {reason}"
|
|
if field_doc is None:
|
|
return header
|
|
return f"{header}\n\n{field_doc.strip()}"
|