mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 08:00:10 +00:00
* feat(extensions): add in-place upgrade that keeps private config Replace a managed local snapshot or re-pin an already-installed requirement without going through remove, which dropped plugins[].config. * fix(extensions): keep snapshot, enabled, and git re-pin on upgrade Rollback keys off staging_root so a failed snapshot rename cannot rmtree the live tree. Upgrade preserves plugins[].enabled. Re-pin identification uses tool.uv.sources so git upgrades adopt the existing plugin record instead of failing closed after uv already switched the revision. * test(extensions): cover requirement re-pin identification on upgrade Re-pinning deerflow-extension-demo==2.0.0 to ==3.0.0 leaves added_names empty, so identification must take the added_specs fallback. Assert private config/required/enabled survive and the lock records 3.0.0. * fix(extensions): reject upgrade of an uninstalled git source Bare git+ URLs are not named Requirements, so the pre-uv-add installed check was skipped and upgrade acted as install. Resolve them against [tool.uv.sources] in the extensions group before uv add.
358 lines
27 KiB
Markdown
358 lines
27 KiB
Markdown
### Python Extension System (Runtime and Distribution)
|
|
|
|
Third-party Python packages can expose an `install(registry, config)` function and be
|
|
loaded, in deterministic order, from the startup-only top-level `plugins:` list in
|
|
`config.yaml`. Keep this list out of `extensions_config.json`: the latter is writable
|
|
through Gateway APIs, while importing Python entry points is an operator-controlled code
|
|
execution boundary. A plugin marked `required: true` fails Gateway construction when it
|
|
cannot load; optional plugins fail open with attributed diagnostics.
|
|
|
|
Packaged extensions use one PEP 621 entry point in the
|
|
`deerflow.extensions` group, for example
|
|
`example = "deerflow_extension_example:install"`. The operator CLI is dispatched from
|
|
the existing `deerflow` console script to `extensions/cli.py` and exposes only these
|
|
surfaces: `install SOURCE [--yes]`, `upgrade SOURCE [--yes]`, `list`, `enable NAME`,
|
|
`disable NAME`, and `remove NAME`. `NAME` resolves against the entry-point name, distribution name, or
|
|
`module:install` value. The root `make extension-*` targets are convenience wrappers;
|
|
because they execute from `backend/`, documentation should use absolute local source
|
|
paths with `SOURCE=` unless backend-relative behavior is intentional.
|
|
|
|
`ExtensionManager` owns the package/config transaction. Install runs a controlled
|
|
`uv add --project <backend> --group extensions --no-workspace --no-sync -- <source>`, updates the dedicated
|
|
`[dependency-groups].extensions` list and `uv.lock`, discovers exactly one packaging entry
|
|
point, and inserts or adopts one
|
|
managed `plugins:` record with `name`, `package`, `use`, `enabled`, `required`, and
|
|
private `config`. New records are written `required: false`, matching the loader default:
|
|
`required: true` turns any later load failure — a broken wheel, a missing native library, a
|
|
deleted snapshot — into a Gateway startup abort recoverable only through shell access, so
|
|
it is an explicit `install --required` opt-in rather than the managed default. Adoption of
|
|
an existing hand-written record preserves whatever `required` the operator already chose.
|
|
Enable/disable changes only the host-level `enabled` flag and preserves
|
|
private configuration. Remove runs `uv remove --group extensions`, removes the plugin
|
|
record, and deletes its managed source snapshot. Install validates the selected config
|
|
file before running any uv command, because `uv add`/`uv sync` execute the package's build
|
|
backend: a config this manager could never write to must fail before that code runs, not
|
|
afterwards through rollback.
|
|
Failed install/remove operations restore `pyproject.toml` and `uv.lock` and resynchronize
|
|
the restored environment; that second restore runs even when the recovery sync itself fails
|
|
(a recovery sync without `--locked` writes a lock while resolving), and a failing recovery
|
|
sync reports the original failure alongside it. The restore is deliberately not blanket:
|
|
when recovery detects a concurrent external edit to the dependency files or the config it
|
|
preserves that edit and raises instead, and `remove` leaves the plugin deactivated in that
|
|
case rather than reviving a record whose package declaration may already be gone. A
|
|
cancellation skips the recovery sync entirely — the declarations are already restored and
|
|
the next locked startup sync reconciles the environment, whereas blocking an interrupt on a
|
|
full dependency resolve invites a second interrupt that escapes the handler mid-transaction.
|
|
Package mutation is deferred from environment mutation: after `uv add/remove`
|
|
updates the declaration and lock, one `uv sync --locked --all-packages` preserves the same
|
|
config-/environment-detected optional extras as normal startup. All three uv calls pin the
|
|
backend project explicitly and discard UV environment overrides that could redirect the
|
|
project, working directory, sync mode, lock policy, or target environment — including
|
|
`UV_PYTHON`, which would swap the interpreter that then loads the extension entry point,
|
|
and `UV_INSECURE_HOST`, which would remove the TLS validation the HTTPS-only source rule
|
|
depends on; index, proxy, cache, and credential-provider settings remain available.
|
|
The `--no-workspace` boundary requires uv 0.8.0 or newer. The stock Docker paths pin uv
|
|
0.11.1, and the manager fails before mutation when the host uv is older.
|
|
All install/upgrade/remove/enable/disable mutations for a checkout hold the cross-process
|
|
`.deer-flow/extension-manager.lock`; remove deactivates config before changing the package
|
|
declaration, and rollback preserves a concurrent external config edit instead of replacing
|
|
it. Upgrade replaces a managed local snapshot (or re-pins a package requirement that is already
|
|
in the `extensions` group) and adopts the existing `plugins:` record so private `config`,
|
|
`required`, and `enabled` stay put. It fails closed if that local snapshot, requirement, or Git source is not
|
|
already installed; a plain `install` still refuses an already-snapshotted local directory.
|
|
Failed upgrades restore the previous snapshot even when a concurrent dependency-file edit
|
|
blocks lock/pyproject rollback, then leave that operator edit in place.
|
|
|
|
Local-directory installs are snapshots, not editable links. The manager validates the
|
|
source, derives the destination from the normalized distribution name, and copies it to
|
|
`backend/extensions/sources/<distribution>/`. It ignores Git metadata, virtual
|
|
environments, Python caches, and bytecode; rejects symbolic links, path-escaping
|
|
distribution names, and likely credential files; and the root `.dockerignore` explicitly
|
|
re-includes the entire managed tree so package READMEs, native modules, and assets reach
|
|
the backend builder. These checks prevent common packaging accidents, not malicious
|
|
code. Both Python build hooks and imported extension code execute with Gateway
|
|
privileges, so the CLI requires confirmation (or explicit `--yes`) and accepts only
|
|
trusted operator sources; source URLs containing embedded credentials are rejected.
|
|
Remote direct references are limited to HTTPS, and remote Git sources must use public
|
|
Git-over-HTTPS (with loopback HTTP accepted for local tooling). SSH Git URLs are rejected
|
|
because the stock Docker builder does not forward host SSH credentials; relative paths and
|
|
local wheels must use the managed directory snapshot path instead. Git's SCP-like shorthand
|
|
(`git@host:org/repo.git`) carries no URL scheme, so it is detected before the scheme rules
|
|
and reported with the same public-HTTPS correction rather than the local-path message.
|
|
Local wheel and `file://` sources are rejected because they cannot be reproduced inside the
|
|
Docker build context; local code must enter through the directory-snapshot path. Stock
|
|
production builds support public package indexes and public HTTPS Git sources reachable by
|
|
the builder; authenticated source configuration must not be embedded in the recorded URL.
|
|
Source validation alone cannot catch environment-driven resolution (for example a
|
|
`UV_FIND_LINKS` wheelhouse turning a plain package requirement into a local wheel
|
|
reference), so after every `uv add/remove` the manager audits the new lock before syncing
|
|
or enabling anything. Any local reference that the stock backend image build cannot
|
|
reproduce — absolute paths, `file:` URLs, or relative paths outside the project root, its
|
|
exact workspace members, and the managed `extensions/sources/` snapshots — fails the whole
|
|
transaction and rolls back the dependency files, config, snapshot, and environment. A
|
|
loopback URL recorded in the lock is warned about rather than rolled back: `127.0.0.1`
|
|
inside the image builder is a different machine, so the reference is just as
|
|
non-reproducible, but unlike an environment-driven wheelhouse resolution it is a source the
|
|
operator typed deliberately. A private-network index is left alone entirely — a builder on
|
|
that network can reach it. A
|
|
config with duplicate top-level `plugins:` keys is rejected outright rather than managed
|
|
against one block while the Gateway reads another.
|
|
|
|
The managed `plugins:` block is rewritten in place, and both of its boundaries come from
|
|
the YAML parser rather than a key-shaped pattern. `AppConfig` allows extra top-level keys,
|
|
so a neighbouring section may be named anything YAML accepts (`my.key`, `2fa`, `$schema`, a
|
|
non-ASCII word); a pattern that fails to recognize the next key does not fail loudly, it
|
|
reports "no next section" and the rewrite replaces that neighbour and its whole subtree.
|
|
Trailing comments below a file-final block are preserved for the same reason — the manager
|
|
appends `plugins:` at end of file, so that is the steady-state shape.
|
|
|
|
Dependency synchronization has one lock authority: the manager's `uv add/remove` calls
|
|
are the only extension workflow allowed to update `backend/uv.lock`, and each mutation is
|
|
followed by the local-source audit described above. The `extensions`
|
|
group is included in `[tool.uv].default-groups` alongside `dev`. Root/backend install
|
|
targets use `uv sync --locked`; direct backend `make dev`/`make gateway` use
|
|
`uv run --locked`; the local full-stack launcher and Docker-dev entrypoint perform one
|
|
locked sync and then launch with `uv run --no-sync`; the production Docker builder syncs
|
|
the same copied backend project and lock, and both image runtime commands use
|
|
`--no-sync`. Thus production may download locked remote artifacts while building an
|
|
image, but production container startup never resolves or installs an extension from the
|
|
network. Local and Docker-dev pre-start syncs may fetch missing locked artifacts.
|
|
`docker/dev-entrypoint.sh` retries a failed sync once after recreating `.venv`, but keeps
|
|
`--locked` on the retry: that repairs a broken virtualenv, not a stale lock. A second
|
|
failure aborts with recovery instructions instead of starting uvicorn against an
|
|
environment that does not match the lock, because startup must never silently resolve
|
|
dependencies.
|
|
That discipline assumes the uv writing the lock and the uv reading it stay compatible, so
|
|
uv is pinned rather than floating: `backend/Dockerfile`'s `UV_IMAGE` is the single source of
|
|
truth, both compose defaults repeat it, and every `astral-sh/setup-uv` step pins the same
|
|
version so CI exercises the manager against the binary production actually runs. Otherwise a
|
|
newer uv can bump `uv.lock`'s `revision` (or make `uv lock --check` disagree with a lock
|
|
generated elsewhere) while CI stays green, and the pinned uv in the production image then
|
|
fails on the committed lock. `backend/tests/test_ci_uv_version_pin.py` keeps the four
|
|
locations in step, which makes a uv upgrade one deliberate, reviewable change.
|
|
Rebuild the Gateway image after changing the managed set. Every install, upgrade, enable,
|
|
disable, remove, or config mutation also requires a Gateway restart because plugin loading
|
|
is startup-only.
|
|
The root management wrappers bootstrap the checkout environment without the extension group
|
|
via `uv run --frozen --no-group extensions`, so a broken or disappeared extension source cannot
|
|
trigger project validation before the operator can list, disable, or remove it, while a
|
|
fresh checkout can still install the non-extension environment from the existing lock. After CLI
|
|
entry, the manager owns the controlled locked sync.
|
|
|
|
The public package is `packages/extension-api/` and must never import `deerflow` or carry
|
|
framework dependencies. Extensions declare any FastAPI, LangChain, or LangGraph imports
|
|
themselves. Its registry contract exposes seven contribution kinds: middleware
|
|
contributors, task-lifecycle contributors, system-model-call observers, agent-assembly
|
|
observers, context-compaction observers, Gateway-lifetime services, and eager routers. Middleware contributions declare lead/subagent scope, stable
|
|
order, and a semantic placement (`MODEL_LOGICAL`, `MODEL_PHYSICAL`, `TOOL_VISIBLE`,
|
|
`TOOL_RAW`, or `STANDARD`) rather than a fragile list index. `extensions/stack.py` is the
|
|
single final composition point; do not inject inside
|
|
the shared base builder because the lead builder appends more middleware afterward.
|
|
`extensions/ordering.py` owns host ordering invariants and validates the final composed
|
|
stack. Nothing under `extensions/` may import `agents.middlewares` at module scope: the
|
|
middleware layer calls into this one, so a module-scope reference points the dependency
|
|
backwards and closes a cycle as soon as any middleware imports something under
|
|
`extensions/` at module level. Both tables that need middleware classes therefore resolve
|
|
on first use — `ordering.py::core_ordering_constraints()` and `stack.py::_anchors()` —
|
|
which is `assert_ordering` / composition time, already inside the middleware builder.
|
|
Defer by deferring the *call*; do not fake a resolved value with a lazy container
|
|
subclass, which reports one answer when iterated and another when measured.
|
|
|
|
**Agent assembly observation.** `assemble_lead_agent()` returns
|
|
`LeadAgentAssembly(graph, descriptor)`; `make_lead_agent()` remains the
|
|
graph-only LangGraph Server ABI declared in `langgraph.json` and must keep that
|
|
signature. The descriptor
|
|
(`deerflow_extension_api.assembly.AgentAssemblyDescriptor`) captures the
|
|
resolved model, rendered prompt hash, authorization-filtered tool list,
|
|
composed middleware stack with each middleware's declared policy, deferred tool
|
|
names, enabled skills, and effective policies — all of which are decided inside
|
|
the factory and are unrecoverable afterwards. Its `fingerprint` sorts tools and
|
|
skills (assembly order is incidental) but preserves middleware order (stack
|
|
order decides what wraps what). It also excludes `build` and `requested_model`:
|
|
the fingerprint answers "did this agent's assembly change", so folding in the
|
|
host build would move every agent's fingerprint on every redeploy and make that
|
|
finer question unanswerable — `build` stays a reported field a consumer can
|
|
compare directly. Registered `AgentAssemblyObserver`s are notified
|
|
synchronously at the end of construction; failures are contained per observer.
|
|
Gateway `resolve_agent_factory()` now returns `assemble_lead_agent`, so every
|
|
consumer must unwrap `.graph` — a third-party factory returning a bare graph
|
|
stays supported.
|
|
|
|
`SubagentExecutor` publishes the same descriptor kind for each delegated agent
|
|
on `self.assembly_descriptor`. The projection itself lives in
|
|
`deerflow/agents/assembly_descriptor.py`: a middleware that implements
|
|
`release_policy_parameters()` owns its own identity, and probing private
|
|
attributes is the marked fallback for the ones that do not.
|
|
|
|
Because `IsolatedMiddleware`'s cached subclasses all carry the wrapper's own
|
|
class name and module, and the wrapper forwards no `release_policy_parameters`,
|
|
describing a contributed middleware directly would collapse every extension's
|
|
contribution into one identical descriptor and hide policy changes inside them.
|
|
`describe_middleware()` therefore unwraps to `.inner` and records `.source` as
|
|
the descriptor's `extension` field, which participates in the fingerprint. It
|
|
duck-types on those attributes rather than importing `extensions/isolation.py`:
|
|
`extensions/` sits below `agents/`, so importing it there would point the
|
|
dependency backwards.
|
|
|
|
Contributed middlewares are wrapped by `IsolatedMiddleware`: extension failures emit
|
|
diagnostics and fail open without repeating a downstream model/tool side effect. The
|
|
wrapper mirrors lifecycle hooks, tools, transformers, and state schema implemented by
|
|
the inner middleware. LangChain treats each sync/async model or tool wrapper pair as one
|
|
capability, so a single-sided wrapper receives a pass-through counterpart; implement
|
|
both sides when the extension must observe both synchronous and asynchronous execution
|
|
paths.
|
|
|
|
Lead runs and subagents allocate an `ExtensionData` task store only when middleware,
|
|
task-lifecycle, or system-model observation is registered; services and routers are
|
|
app-scoped and do not allocate one. Middleware and system-call sites recover the
|
|
live store through `EXTENSION_TASK_STORE_KEY` / `task_store_from_runtime()`; lifecycle
|
|
contributors receive that same store directly. Each task resolves the immutable
|
|
loaded-extension snapshot once and binds that same object through task-store allocation,
|
|
hooks, and synchronous agent construction, so a concurrent singleton replacement cannot
|
|
mix two extension generations without changing the LangGraph graph-factory ABI. The
|
|
graph-build binding is a ContextVar scoped to synchronous construction, so it has already
|
|
exited by the time the lead agent delegates; the run worker therefore also publishes the
|
|
snapshot on runtime context under the host-internal `EXTENSION_SNAPSHOT_CONTEXT_KEY`,
|
|
`task_tool` reads it back through `resolve_run_extensions()` (type-checked — runtime
|
|
context is caller-mergeable), and `SubagentExecutor` binds it at construction. That key is
|
|
written after the caller merge and popped when the run has none, so a caller-supplied value
|
|
is never authoritative. Absent the key — embedded `DeerFlowClient`, standalone LangGraph
|
|
Server — the executor keeps its `get_loaded_extensions()` fallback.
|
|
|
|
The lead worker awaits `on_task_start` after the run has started and awaits `on_task_stop`
|
|
after completion persistence/hooks but before clearing any active finalizing barrier or
|
|
publishing the stream end. A subagent with a parent `run_id` wraps its execution with the
|
|
same start/stop pair. Outcomes are conservative (`completed`, `aborted`, or `failed`),
|
|
contributors run in registration order within one bounded budget, and notification failures
|
|
are logged and fail open.
|
|
|
|
Fail-open is decided by the *origin* of a failure, not by its base class, because
|
|
`CancelledError` reaches a contributor's `except` for two unrelated reasons. Only a genuine
|
|
cancellation of the host task increments `asyncio.Task.cancelling()`, so `_notify_each`
|
|
propagates on that and contains everything else: a contributor that lets a `CancelledError`
|
|
escape — an extension implementing an internal timeout with cancellation, say — must not
|
|
skip its successors, and must not reach the worker's deferred-interrupt path, which would
|
|
end an otherwise successful run as cancelled. `KeyboardInterrupt` / `SystemExit` still
|
|
propagate.
|
|
|
|
System-model-call observers cover DeerFlow-owned model invocations that do not pass
|
|
through middleware model-call wrappers: goal evaluation, memory extraction, title
|
|
generation, and summarization. They receive a request/result snapshot, duration, and the
|
|
active task store when one exists; detached system work receives an isolated store. All
|
|
three terminal paths are reported without changing the exception the host observes:
|
|
success and failure are awaited inline, while cancellation — routine, since
|
|
interrupt/rollback admission and shutdown both cancel the run task, with the provider
|
|
tokens already spent — is submitted to the notify loop instead of awaited, because a
|
|
repeated cancel would interrupt that await before any observer ran. A deployment with no
|
|
registered notify loop drops the cancellation observation, exactly as the synchronous
|
|
memory bridge does. `SystemModelRequest.messages` normalizes to a tuple at construction: goal and
|
|
memory pass a message list while title and summarization pass one prompt string, and a
|
|
bare `str` is already a `Sequence`, so without normalization an observer iterating it
|
|
would walk characters. Normalizing also copies a live list, which is what makes the frozen
|
|
snapshot immutable in fact rather than only by declaration. Gateway registers one canonical extension-notification loop. Awaited lifecycle
|
|
hooks and async system observations are dispatched to that loop even when the caller is a
|
|
subagent's isolated loop, while synchronous system callbacks submit fire-and-forget work
|
|
there. Shutdown stops accepting detached observations before the memory shutdown flush and
|
|
resets the loop only after in-flight run/subagent drain ordering is complete.
|
|
|
|
`ContextCompactionObserver` reports the one moment a lossy context transform can still be
|
|
described: `DeerFlowSummarizationMiddleware.compact_state()` / `acompact_state()` hash each
|
|
about-to-be-removed message's content before the summary model call, then — once a summary
|
|
is produced and the pre-compaction hooks have run — build a `CompactionEvent` (transform
|
|
kind/version, source content hashes, the produced summary's content hash, and the
|
|
compacted/kept message counts) and call `notify_context_compacted()`. Once
|
|
`_maybe_summarize`/`_amaybe_summarize` remove the source messages from state, that mapping
|
|
cannot be reconstructed, so the event is the only record of it. The event is keyed on
|
|
`canonical_hash(message.content)` directly — never a stringified copy, which would defeat
|
|
`canonical_hash`'s key-order normalization for multimodal (`list[dict]`) content — rather
|
|
than a producer-stamped identity key: nothing currently mints a stable per-message identity
|
|
for compaction's source messages or its summary, so an identity-keyed field would ship
|
|
permanently empty. `notify_context_compacted()` is a
|
|
synchronous, fire-and-forget entry point — both the sync and async compaction paths call it
|
|
without an `await` — that dispatches to the same registered extension-notification loop
|
|
system-model-call cancellation uses, reusing `_notify_each`'s per-observer fail-open
|
|
containment. There is no live task to attach at that call site, so observers receive a
|
|
detached task store, the same fallback `notify_system_model_call` uses when its caller
|
|
supplies none.
|
|
|
|
Gateway services start in registration order after the persistence engine and session
|
|
factory are ready. Each receives the same `ExtensionRuntimeDeps` snapshot containing the
|
|
app store, projected host policy, and session factory. Start failures are attributed and
|
|
fail open. The runtime captures `app.state.extensions` once, registers cleanup before the
|
|
start batch, and stops the attempted service prefix in reverse order after run/subagent
|
|
drain but before store, checkpointer, and engine teardown. Each stop has an independent
|
|
bounded timeout; failures do not starve later cleanup. A service-originated
|
|
`CancelledError` fails open, while a new cancellation of the host task still propagates
|
|
through the exit stack. Runtime diagnostics must be appended through
|
|
`record_runtime_diagnostics()` so `app.state.extension_diagnostics` remains the canonical
|
|
live list.
|
|
|
|
Routers are constructed eagerly during `install()` and mounted only after all host routes,
|
|
so host handlers always win. The Gateway rejects a contributed router atomically when an
|
|
earlier host or extension route provably covers one of its paths for the same HTTP method.
|
|
The conservative matcher proves common shadows through normalized parameter names,
|
|
static-vs-dynamic matching, known built-in-converter containment, supported compound
|
|
segments, full-segment `path` catch-alls, and `Mount` descendants reducible to those same
|
|
rules. Relationships requiring general regex-language inclusion are allowed rather than
|
|
guessed. Host WebSocket routes do not collide with contributed HTTP routes, but contributed
|
|
WebSocket routes are rejected until the host can supply authentication and Origin checks.
|
|
Because `include_router()` recompiles contributed routes, preflight projects the converter
|
|
registry at include time. Nonstandard converters fail closed against reserved security
|
|
paths but otherwise prove a shadow only when their normalized matchers are identical.
|
|
Host authentication- and CSRF-exempt paths are reserved, and contributed Mounts, unsupported
|
|
route items, startup/shutdown hooks, and custom router lifespans are rejected; lifetime
|
|
resources must use `ExtensionService`. Auth and CSRF classify
|
|
`get_request_route_path(request)`, the same root-path-adjusted ASGI path Starlette routes
|
|
match; do not switch those security predicates back to reconstructed `request.url.path`.
|
|
That helper delegates to the private `starlette._utils.get_route_path` on purpose. Its
|
|
requirement is not "strip `root_path` correctly" but "return exactly what the router is
|
|
matching on", so importing the dispatcher's own implementation keeps the two in lockstep by
|
|
construction. Do not vendor a local copy: a private import that disappears fails loudly at
|
|
startup, while a stale copy diverges silently at a security boundary. `starlette` is
|
|
therefore a declared, bounded direct dependency so the bump is visible in review, and
|
|
`tests/test_gateway_request_path.py` pins the agreement independently of the mechanism.
|
|
Any preflight, conflict, or include failure rolls back the whole router without preventing
|
|
later routers from mounting. Do not introduce a framework-bound `RouterContributor`
|
|
contract: the public registry accepts `Sequence[Any]`
|
|
to keep extension-api dependency-free.
|
|
|
|
Contributed routes are session-authenticated and cannot opt out. Within that, an extension
|
|
distinguishes an ordinary user from an administrator through `deerflow_extension_api.auth`:
|
|
`resolve_principal(request)` returns the caller, `require_admin(request)` raises
|
|
`PermissionError` for anyone else and fails closed when identity cannot be determined.
|
|
Extensions receive a projection — user id, admin flag, internal flag, roles — never the
|
|
host's auth context. The host installs the resolver on `app.state` (keyed by
|
|
`EXTENSION_PRINCIPAL_RESOLVER_KEY`) in `app.gateway.app.create_app()`, after
|
|
`AuthMiddleware` is added and before contributed routers are mounted; `resolve_principal`
|
|
reads it back at call time, since the router objects a contribution builds during
|
|
`install()` exist long before any request (or its identity) does. The host's projection
|
|
reads `request.state.user` synchronously (the same field `AuthMiddleware` stamps and
|
|
`require_admin_user` in `app/gateway/deps.py` reads as its primary path) rather than the
|
|
async, exception-based accessors that exist there for tests and alternative ASGI
|
|
compositions — keeping the resolver synchronous keeps it usable from both sync and async
|
|
route handlers.
|
|
|
|
The memory kind reaches those observers through a different shape, and the difference is
|
|
deliberate rather than an oversight to be "aligned" away. DeerMem must stay vendorable and
|
|
cannot import the extension API, so it reports through the `MemoryCallbacks.on_memory_llm_result`
|
|
host hook, which the DeerFlow-side callbacks translate into an observation and submit
|
|
without awaiting. It also guards its provider call with `BaseException` rather than
|
|
`Exception`, which is safe precisely because that whole path runs on a worker thread — the
|
|
debounce timer, or the executor `update_memory` offloads to — where cancelling the awaiting
|
|
side never interrupts the running thread, so `CancelledError` cannot arrive there at all.
|
|
The host hook wrapper around the callback stays at `Exception`: only the hook's own failures
|
|
are non-fatal, and an observability path must not swallow `SystemExit` / `KeyboardInterrupt`.
|
|
|
|
Gateway `create_app()` loads plugins once, stores the immutable registry on `app.state`
|
|
and in the process-wide singleton, mounts contributed routers last, and installs one
|
|
canonical live diagnostics list.
|
|
Changing `plugins` requires a restart. Any future contribution kind must be added to the
|
|
public contract and host runtime in the same slice; never accept a registration method
|
|
that the current host silently ignores.
|
|
|
|
### Extension Manager Test Repositories
|
|
|
|
`test_extension_manager.py` creates temporary Git repositories for local extension sources.
|
|
Temporary commits use an empty repository-local hook directory. They must not run developer or CI Git hooks.
|
|
Tests for hook behavior must create and invoke their own hook fixtures.
|