diff --git a/AGENTS.md b/AGENTS.md
index 42fa3a779..047dd43c8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,7 +66,7 @@ deer-flow/
│ # Managed integration skill packs are global at .deer-flow/integrations/skills/{provider}/
│ # Integration credentials and enabled state remain per-user
├── contracts/ # Cross-component JSON contracts (e.g. subagent status, skill review)
-├── examples/deerflow-extension-example/ # Standalone package demonstrating all extension contribution kinds
+├── examples/deerflow-extension-example/ # Standalone package demonstrating five extension contribution kinds
├── scripts/ # Root orchestration scripts invoked by the Makefile (check, configure, doctor, support_bundle, serve, nginx, docker, deploy, setup_wizard)
├── tests/ # Root-level tests (currently tests/skills/ — public skill tests)
└── docs/ # Cross-cutting docs, plans, and design notes
@@ -75,14 +75,13 @@ deer-flow/
Third-party extensions are loaded from a top-level `plugins:` list in `config.yaml`
(operator-controlled on purpose — that list causes code to be imported, so it is deliberately
kept out of the API-writable `extensions_config.json`). Packaged extensions can contribute
-middleware, task lifecycle, system-model observers, Gateway services, and FastAPI HTTP
-routers; the [reference extension](examples/deerflow-extension-example/) demonstrates all
-five. Manage them with `deerflow extensions install/upgrade/list/enable/disable/remove` or the root
+middleware, lifecycle observers, Gateway services, and FastAPI HTTP routers. Manage them with `deerflow extensions install/upgrade/list/enable/disable/remove` or the root
`make extension-*` wrappers. Every mutation requires a Gateway restart, and both build
hooks and extension code execute with Gateway privileges, so only trusted operator sources
belong in this path. The manager transaction, accepted source forms, lock discipline, and
contribution contract live in
-[the extensions guide](backend/packages/harness/deerflow/extensions/AGENTS.md).
+[the extensions guide](backend/packages/harness/deerflow/extensions/AGENTS.md); the user manual
+is `frontend/src/content/{en,zh}/harness/extensions/`.
Runtime config lives at the **repo root**: copy `config.example.yaml` → `config.yaml`
(main app config) and `extensions_config.example.json` → `extensions_config.json` (MCP
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb2771323..35e393663 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2980,6 +2980,14 @@ This release closes that milestone with **765 merged pull requests**.
change log. The former single page becomes the section index, so existing
page links keep working; deep links to sections of the old page now land
on the index.
+- **docs:** Add an extension developer manual under `harness/extensions/` in
+ both languages, covering the `deerflow-extension-api` 0.2.1 contract: when
+ to write an extension, a quick start, the runtime model, middleware
+ placements, lifecycle and observer hooks, services and routes, the run
+ evidence reader, operating extensions, troubleshooting by error message,
+ and a reference of every public name with the contract's version history.
+ Also correct stale descriptions of the contribution kinds and of run
+ evidence metadata redaction in `AGENTS.md`.
### Internal
diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md
index 8fa8931b9..8df1f120d 100644
--- a/CHANGELOG_zh.md
+++ b/CHANGELOG_zh.md
@@ -2227,6 +2227,11 @@
按症状排查、开发者集成,以及附带 2026 年 6 月至 9 月变更记录的参考附录。原单页
成为该章节的索引页,指向该页面的已有链接保持有效;指向旧页面小节锚点的深链接
会落到索引页。
+- **文档:** 新增中英文扩展开发手册(`harness/extensions/`),覆盖
+ `deerflow-extension-api` 0.2.1 契约:何时编写扩展、快速上手、运行时模型、中间件
+ 放置位置、生命周期与观察者、服务与路由、运行证据读取器、扩展运维、按错误信息排查,
+ 以及列出全部公开名称和契约版本历史的参考章节。同时修正 `AGENTS.md` 中对贡献类型
+ 和运行证据元数据脱敏的过时描述。
### 内部改进
diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md
index 56981a218..db3fd3d9c 100644
--- a/backend/packages/harness/deerflow/extensions/AGENTS.md
+++ b/backend/packages/harness/deerflow/extensions/AGENTS.md
@@ -287,8 +287,9 @@ covers creations and changes to retained rows only; synchronization consumers mu
`get_run_status()` for known runs and treat `None` as absent when deletion reconciliation
is required. A DB run store preserves positions across restarts, while memory only provides
process-lifetime ordering. Per-run events retain the event store's thread-scoped
-`after_seq` semantics; metadata is secret-redacted, but event content is returned unchanged,
-and status comes from the authoritative run store. The reader passes its fixed scope to
+`after_seq` semantics; metadata has only the legacy `auth_token` key removed (there is no
+other redaction), event content is returned unchanged, and status comes from the
+authoritative run store. The reader passes its fixed scope to
event reads explicitly, including global `None`, so ambient request identity cannot
change its visibility. Content and redacted metadata are deep-copied snapshots: DTO
fields are frozen, but nested containers remain locally mutable without touching host
diff --git a/frontend/src/content/en/harness/_meta.ts b/frontend/src/content/en/harness/_meta.ts
index b682b71cb..6a1c63060 100644
--- a/frontend/src/content/en/harness/_meta.ts
+++ b/frontend/src/content/en/harness/_meta.ts
@@ -37,6 +37,9 @@ const meta: MetaRecord = {
mcp: {
title: "MCP Integration",
},
+ extensions: {
+ title: "Extensions",
+ },
customization: {
title: "Customization",
},
diff --git a/frontend/src/content/en/harness/extensions/_meta.ts b/frontend/src/content/en/harness/extensions/_meta.ts
new file mode 100644
index 000000000..312cede71
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/_meta.ts
@@ -0,0 +1,33 @@
+import type { MetaRecord } from "nextra";
+
+const meta: MetaRecord = {
+ "quick-start": {
+ title: "Quick Start",
+ },
+ runtime: {
+ title: "Runtime Model",
+ },
+ middleware: {
+ title: "Middleware Contributions",
+ },
+ observers: {
+ title: "Lifecycle and Observers",
+ },
+ "services-and-routes": {
+ title: "Services and Routes",
+ },
+ "run-evidence": {
+ title: "Run Evidence",
+ },
+ operations: {
+ title: "Operating Extensions",
+ },
+ troubleshooting: {
+ title: "Troubleshooting",
+ },
+ reference: {
+ title: "Reference",
+ },
+};
+
+export default meta;
diff --git a/frontend/src/content/en/harness/extensions/index.mdx b/frontend/src/content/en/harness/extensions/index.mdx
new file mode 100644
index 000000000..c57dd63e1
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/index.mdx
@@ -0,0 +1,126 @@
+---
+title: Extensions
+description: An extension is a separately released Python package that plugs into the DeerFlow Gateway through one install() function. This chapter covers what extensions can contribute, when to use one instead of a tool, MCP server, or skill, how they are loaded, and how to read this manual.
+asIndexPage: true
+---
+
+import { Callout } from "nextra/components";
+
+# Extensions
+
+
+ An extension is an ordinary Python package that DeerFlow imports at Gateway
+ startup. It depends only on the public `deerflow-extension-api` contract, so
+ it can be built, tested, and released without importing DeerFlow itself.
+
+
+Tools, MCP servers, and skills add capabilities the model can call. Extensions add behavior to the **host**: they observe every model and tool call, react when a run starts or stops, run background services next to the Gateway, and serve their own HTTP routes. An extension is the supported way to ship that kind of integration, such as audit logging, cost accounting, or a governance dashboard, without forking DeerFlow.
+
+## How to read this manual
+
+| Reader | Start with |
+| ----------------------- | --------------------------------------------------------------------------------------------------- |
+| Extension authors | This page, [Quick Start](/docs/harness/extensions/quick-start), [Runtime Model](/docs/harness/extensions/runtime), then the chapter for each contribution kind below |
+| Operators | [Operating Extensions](/docs/harness/extensions/operations), [Troubleshooting](/docs/harness/extensions/troubleshooting), [Trust model](#trust-model) |
+| Looking up a name | [Reference](/docs/harness/extensions/reference) |
+| DeerFlow contributors | `backend/packages/harness/deerflow/extensions/AGENTS.md`, which records the host-side design decisions |
+
+## Should this be an extension?
+
+Pick the lightest mechanism that does the job. Each row executes operator-trusted code, but the lower rows reach further into the runtime.
+
+| You want to | Use |
+| ------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
+| Give the model a new callable capability | A [custom tool](/docs/harness/tools) (`tools:` in `config.yaml`) or an [MCP server](/docs/harness/mcp) |
+| Teach the model a workflow or domain procedure | A [skill](/docs/harness/skills) |
+| Add one `AgentMiddleware` class to every agent, configured in place | `extensions.middlewares` in `config.yaml`. See [Customization](/docs/harness/customization) |
+| Ship a versioned package that observes runs, keeps state, runs a service, or serves routes | **An extension** (this manual) |
+
+The two middleware paths are easy to confuse. `extensions.middlewares` inserts a class at one fixed slot and imposes no contract. An extension middleware declares a semantic [placement](/docs/harness/extensions/middleware#placements), runs inside a failure-isolating wrapper, and ships alongside the other contribution kinds below.
+
+## What an extension can contribute
+
+`install(registry, config)` receives a write-only registry. Each registry method registers one contribution kind:
+
+| Registry method | Contribution |
+| --------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| `registry.middlewares(contributor)` | `AgentMiddleware` instances inserted into the Lead Agent and subagent chains at a semantic placement. See [Middleware Contributions](/docs/harness/extensions/middleware) |
+| `registry.task_lifecycle(contributor)` | `on_task_start` / `on_task_stop` for every lead run and every delegated subagent. See [Lifecycle and Observers](/docs/harness/extensions/observers) |
+| `registry.system_model_observer(obs)` | A snapshot of each model call DeerFlow makes for itself: goal evaluation, memory extraction, title generation, summarization. See [Lifecycle and Observers](/docs/harness/extensions/observers) |
+| `registry.agent_assembly_observer(obs)` | A descriptor of every assembled agent: model, prompt hash, tools, middleware stack, skills, and a fingerprint. See [Lifecycle and Observers](/docs/harness/extensions/observers) |
+| `registry.context_compaction_observer(obs)` | A `CompactionEvent` each time summarization removes messages from context. See [Lifecycle and Observers](/docs/harness/extensions/observers) |
+| `registry.service(service)` | An object started after the Gateway's persistence layer is ready and stopped at shutdown. May read runs through the [Run Evidence](/docs/harness/extensions/run-evidence) reader. See [Services and Routes](/docs/harness/extensions/services-and-routes) |
+| `registry.routers(routers)` | FastAPI routers mounted after every host route, behind Gateway authentication. See [Services and Routes](/docs/harness/extensions/services-and-routes) |
+
+A single extension may register any combination. The [bundled example](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example) registers a middleware, a task-lifecycle contributor, a system-model observer, a service, and a router.
+
+## Installing and loading
+
+Operators install extensions with the extension manager, which adds the package to the backend's `extensions` dependency group, updates `uv.lock`, and writes one record under the top-level `plugins:` list in `config.yaml`:
+
+```yaml
+plugins:
+ - name: hello
+ package: deerflow-extension-hello
+ use: deerflow_extension_hello:install
+ enabled: true
+ required: false
+ config: {}
+```
+
+| Field | Meaning |
+| ---------- | ------------------------------------------------------------------------------------------------------------ |
+| `use` | Entry point as `module.path:install` |
+| `enabled` | `false` skips the extension without importing it |
+| `required` | `false` (default): a load failure is logged and the Gateway starts without the extension. `true`: the Gateway refuses to start |
+| `config` | Private configuration passed verbatim to `install()` as its second argument (a shallow copy) |
+
+Loading happens exactly once, while the Gateway builds its application. The Gateway resolves each enabled entry in list order, checks the API version, and calls `install()`. If `install()` raises, the entries it registered are rolled back and the next extension loads normally. The Gateway logs `Extensions loaded: N/M (...)` when it finishes.
+
+Because loading is startup-only, **every change needs a Gateway restart**: install, upgrade, enable, disable, remove, or a hand edit of `plugins:`. `plugins:` lives only in `config.yaml`, never in `extensions_config.json`, because the latter is writable through Gateway APIs and importing a package is code execution.
+
+## Failure model
+
+Extensions are observational, so a broken extension degrades to a log line instead of a broken run:
+
+- A contribution that raises is skipped, and the Gateway logs an error attributed to its entry point (`Extension : ...`).
+- Contributed middleware runs inside an isolating wrapper that never repeats a model call or tool side effect. See [Failure isolation](/docs/harness/extensions/middleware#failure-isolation).
+- Task-lifecycle notifications share a bounded time budget, and observers are notified one by one: a failing observer does not skip the ones after it.
+
+The single exception is `required: true`, which turns any load failure into a startup abort. Use it only when the deployment is wrong without the extension, because recovering from it needs shell access to the config file.
+
+## Trust model
+
+
+ An extension is not sandboxed. Its build hooks run during installation and its
+ code runs inside the Gateway process with the Gateway's privileges, including
+ database access through the session factory handed to services. Install only
+ sources you have reviewed and trust.
+
+
+The extension manager asks for confirmation before installing, rejects source URLs with embedded credentials, and accepts only package requirements, HTTPS sources (including public Git over HTTPS), and local directories, which it copies as a snapshot. SSH Git URLs and local wheels are rejected. These checks prevent packaging accidents, not malicious code.
+
+## Versioning
+
+The contract package is versioned separately from DeerFlow, and a host exposes its version as `deerflow_extension_api.API_VERSION`. This manual covers `deerflow-extension-api` **0.2.1**.
+
+- Before 1.0, a minor release may break extensions and a patch release only adds. From 1.0 on, breaking changes bump the major.
+- Every Protocol method has a default implementation and every optional dataclass field has a default, so additive releases do not break already-released extensions.
+- Decorating `install` with `@extension(api="0.2.0")` declares the version you wrote against. The Gateway refuses the extension, with an actionable message, unless the host is the same `0.minor` and at least the declared patch. Declare the lowest version whose features you use.
+
+Declare the matching range in your package metadata as well, for example `deerflow-extension-api>=0.2,<0.3`.
+
+## Terminology
+
+- **Host**: the DeerFlow Gateway process that loads extensions.
+- **Contribution**: one object registered through the registry: a contributor, observer, service, or router.
+- **Contributor**: an object the host calls back to obtain contributions, such as a `MiddlewareContributor` that returns middleware for each agent it builds.
+- **Scope**: the lifetime a piece of state belongs to. The **app scope** lives as long as the Gateway; a **task scope** lives for one lead run or one subagent execution.
+- **`ExtensionData`**: the typed store attached to a scope, keyed by Python type so two extensions cannot collide.
+- **Diagnostic**: a load-time or run-time problem attributed to one extension and written to the Gateway log.
+
+
+
+
+
+
diff --git a/frontend/src/content/en/harness/extensions/middleware.mdx b/frontend/src/content/en/harness/extensions/middleware.mdx
new file mode 100644
index 000000000..604ca5323
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/middleware.mdx
@@ -0,0 +1,170 @@
+---
+title: Middleware Contributions
+description: How an extension contributes AgentMiddleware to the Lead Agent and subagent chains. Covers the five semantic placements and where each lands, scope and ordering, what a contributed middleware can and cannot change, failure isolation, and reading task state.
+---
+
+import { Callout } from "nextra/components";
+
+# Middleware Contributions
+
+A middleware contribution inserts your `AgentMiddleware` into the chain that wraps every model call and tool call of an agent. It is the right contribution when you need to see **each call**: latency and cost accounting, audit trails, policy telemetry, or tracing. For an introduction to the chain itself, see [Middlewares](/docs/harness/middlewares).
+
+## The contract
+
+Register a contributor in `install()`. The host calls it back each time it assembles an agent:
+
+```python
+from collections.abc import Sequence
+
+from deerflow_extension_api import (
+ AgentBuildContext,
+ AgentScope,
+ ExtensionData,
+ MiddlewarePlacement,
+ Placement,
+)
+
+
+class AuditContributor:
+ def contribute_middlewares(
+ self,
+ app_store: ExtensionData,
+ ctx: AgentBuildContext,
+ ) -> Sequence[MiddlewarePlacement]:
+ return (
+ MiddlewarePlacement(
+ AuditMiddleware(),
+ Placement.MODEL_LOGICAL,
+ scope=AgentScope.BOTH,
+ order=0,
+ ),
+ )
+
+
+def install(registry, config):
+ registry.middlewares(AuditContributor())
+```
+
+| Field | Type | Default | Meaning |
+| -------------- | ---------------- | ----------------- | --------------------------------------------------------------- |
+| `middleware` | `AgentMiddleware` | required | The instance to insert. Any other type is rejected |
+| `placement` | `Placement` | required | The semantic guarantee you need. See [Placements](#placements) |
+| `scope` | `AgentScope` | `AgentScope.BOTH` | Which chains receive it. See [Scope](#scope) |
+| `order` | `int` | `0` | Tie-breaker among contributions at the same point. See [Ordering](#ordering) |
+
+`contribute_middlewares()` receives the app-scoped store and an `AgentBuildContext`:
+
+| Field | Meaning |
+| ------------ | ----------------------------------------------------------------------------------- |
+| `scope` | `AgentScope.LEAD` or `AgentScope.SUBAGENT`: the chain being built right now |
+| `agent_name` | The custom agent or subagent name, when there is one |
+| `model_name` | The resolved model name |
+| `policy` | A `HostPolicySnapshot`: token-budget limits and, for the Lead Agent, `max_subagents_per_run` |
+
+The contributor may return different middleware, or none, depending on the context. It runs on every agent assembly, which normally means once per run and once per delegated subagent, so keep it cheap.
+
+## Placements
+
+A middleware occupies one position in a list, but that position only means something on the hook chains the middleware implements. "Outermost" on the model axis is a different place from "outermost" on the tool axis. So you do not pick an index. You declare an axis, an end, and the guarantee you need, and the host resolves it against its current stack.
+
+| Placement | Axis and end | Guarantee | Typical use |
+| ---------------- | ----------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------- |
+| `MODEL_LOGICAL` | Model, outer end | Outer of retry and error handling. Fires **once per logical model decision**, however many times the host retries underneath | Auditing decisions, counting turns |
+| `MODEL_PHYSICAL` | Model, inner end | Inner of every request-transforming middleware. Fires **once per provider call**; retries re-enter it | Provider latency, cost, the exact final request |
+| `TOOL_VISIBLE` | Tool, outer end | Outer of truncation, sanitization, and error wrapping. Observes **what the model finally sees** | End-to-end tool latency, what the model was told |
+| `TOOL_RAW` | Tool, inner end | Adjacent to the real tool callable. Observes the **raw return** before any processing | Capturing untruncated tool output |
+| `STANDARD` | None | No position requirement. Relative order against other `STANDARD` contributions is not guaranteed | `before_model` / `after_model` state hooks |
+
+### Where each placement lands today
+
+The guarantees above are the contract. The concrete anchors below are how the current host meets them. They can move when the built-in stack changes, and an extension that depends on them is depending on an implementation detail.
+
+| Placement | Lead Agent chain | Subagent chain |
+| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- |
+| `TOOL_VISIBLE` | Outermost, before `InputSanitizationMiddleware` | Same |
+| `MODEL_LOGICAL` | Immediately outer of `LLMErrorHandlingMiddleware` | Same |
+| `STANDARD` | Currently the same anchor as `MODEL_LOGICAL` | Same |
+| `MODEL_PHYSICAL` | Inner of `SafetyFinishReasonMiddleware`, outer of `ClarificationMiddleware` | Inner of `SystemMessageCoalescingMiddleware`, the last middleware |
+| `TOOL_RAW` | Outer of `ClarificationMiddleware` | Innermost |
+
+`ClarificationMiddleware` stays last in the lead chain because it ends the tool loop for `ask_clarification`. It never transforms the result of a tool that actually runs, so `MODEL_PHYSICAL` and `TOOL_RAW` keep their guarantees even though they sit outer of it.
+
+When the primary anchor of a placement is missing from a stack, the host falls back to the next rule and logs a warning, because a silently degraded placement would change what the extension observes:
+
+```text
+Extension : placement TOOL_RAW fell back to a secondary anchor (primary anchor middleware is absent from this stack); ...
+```
+
+
+ The subagent chain has no `ClarificationMiddleware`, so a `TOOL_RAW`
+ contribution with `SUBAGENT` scope always takes the fallback, which is the
+ innermost end. That fallback still meets the `TOOL_RAW` guarantee, but the
+ warning is logged on every subagent build.
+
+
+## Scope
+
+`AgentScope` is a flag: `LEAD`, `SUBAGENT`, or `BOTH` (the default). The host builds each chain separately and includes a contribution only when its scope overlaps the chain being built. To differ by chain, either return two placements with different scopes or branch on `ctx.scope`.
+
+Middleware configured through `create_deerflow_agent(extra_middleware=...)` or `DeerFlowClient(middlewares=...)` does not reach subagents. Extension middleware with `SUBAGENT` scope does.
+
+## Ordering
+
+Contributions are sorted by `order`, then by registration order: extensions load in `plugins:` list order, and contributions keep the order their contributor returned them in. When several contributions resolve to the same point, **the lower `order` ends up outer**. Use `order` only to order your own contributions against each other. Relying on it against another extension couples the two packages.
+
+After inserting contributions, the host validates its ordering invariants on the final stack. A violation is the one hard failure in this system: agent construction fails with an error that names the extension responsible.
+
+## What a contributed middleware can change
+
+Every contribution in this release is **observational**. The host enforces this in the wrapper around your middleware.
+
+- **`wrap_model_call` / `wrap_tool_call` and their async forms.** You may inspect the request and the result. You must call `handler` exactly once. The host always passes the **original** request downstream, even if you call `handler` with a modified one, and always returns the **real** downstream result, whatever your hook returns. You cannot rewrite prompts, veto tool calls, or replace tool output from an extension.
+- **`before_agent` / `before_model` / `after_model` / `after_agent` and their async forms.** These run as in any `AgentMiddleware`, and a returned dict is applied as a state update. If the middleware declares a `state_schema`, the wrapper forwards it.
+
+Because LangChain wires both the sync and the async path when either side of a wrap pair exists, the wrapper supplies a pass-through for the side you did not write. Implement both `wrap_tool_call` and `awrap_tool_call` (or both model forms) if you must observe both paths. Normal Gateway runs are async.
+
+## Failure isolation
+
+The host wraps every contribution in an `IsolatedMiddleware`. The wrapper tracks the downstream handler so that recovering from your failure never adds another model request or tool side effect:
+
+| What goes wrong | What the host does |
+| ------------------------------------------------ | ------------------------------------------------------------------------------- |
+| Your wrap hook raises before calling `handler` | Logs a diagnostic, then calls `handler` once with the original request |
+| Your wrap hook never calls `handler` | Logs a diagnostic, then calls `handler` itself |
+| Your wrap hook raises after `handler` returned | Logs a diagnostic and returns the real result |
+| Your wrap hook calls `handler` a second time | The second call raises `RuntimeError` in your hook; the host returns the first result |
+| `handler` itself raises | The error propagates unchanged; the host's own error policy handles it |
+| A lifecycle hook raises | Logs a diagnostic and applies no state update |
+| `contribute_middlewares()` raises | Logs a diagnostic; this contributor adds nothing to this agent |
+| A returned item is not a valid `MiddlewarePlacement` | Logs a diagnostic and skips that item |
+
+LangGraph interrupts (`GraphBubbleUp`) always propagate. Diagnostics are written to the Gateway log as `Extension : . failed and was skipped: `.
+
+## Reading task state
+
+Middleware instances may be shared by concurrent runs, so do not keep per-run state on `self`. Keep it in the task-scoped `ExtensionData` store, which the host creates for each lead run and each subagent execution and discards when it ends. Inside a hook, recover it from the runtime:
+
+```python
+from deerflow_extension_api import task_store_from_runtime
+
+
+class CountingMiddleware(AgentMiddleware):
+ async def awrap_tool_call(self, request, handler):
+ store = task_store_from_runtime(getattr(request, "runtime", None))
+ if store is not None:
+ store.get_or_init(ToolCallCount, ToolCallCount).value += 1
+ return await handler(request)
+```
+
+`task_store_from_runtime()` returns `None` when there is no live task, for example when the harness runs without a Gateway run around it. Pass through in that case. `ExtensionData` is keyed by type, so define your own class for each value you store: two extensions cannot collide on a key, and you never write to the runtime context directly.
+
+## Identity in traces
+
+LangChain requires unique middleware names and uses them as trace identities and graph node IDs. The host names each wrapper `extension___`, with unsafe characters replaced by underscores. For example, the Quick Start middleware appears as `extension_deerflow_extension_hello_install_ToolTimer_0`.
+
+## Common pitfalls
+
+- **Expecting to modify calls.** Wrap hooks are observe-only in this release. For request-shaping behavior, use `extensions.middlewares` (see [Customization](/docs/harness/customization)), which imposes no wrapper, and accept that it is trusted configuration rather than a contract.
+- **Implementing only `wrap_tool_call`.** Gateway runs take the async path, so a sync-only middleware sees nothing there.
+- **Heavy work in `contribute_middlewares()`.** It runs on every agent assembly. Build expensive clients once in `install()`, or lazily in the app store.
+- **Depending on the concrete anchor.** Choose the placement by the guarantee you need, not by the neighbor it happens to sit next to today.
diff --git a/frontend/src/content/en/harness/extensions/observers.mdx b/frontend/src/content/en/harness/extensions/observers.mdx
new file mode 100644
index 000000000..e1c862861
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/observers.mdx
@@ -0,0 +1,340 @@
+---
+title: Lifecycle and Observers
+description: The four notification contributions. Task lifecycle for every lead run and subagent, system model calls DeerFlow makes for itself, a descriptor and fingerprint of every assembled agent, and an event for every context compaction. Covers timing, payloads, stores, and failure behavior for each.
+---
+
+import { Callout } from "nextra/components";
+
+# Lifecycle and Observers
+
+Middleware sees each model and tool call inside an agent. The four contributions in this chapter see the events around and beside those calls: a task starting and stopping, DeerFlow's own model calls, an agent being assembled, and context being compacted. None of them can change what the host does. All of them fail open under the rules in [Runtime Model](/docs/harness/extensions/runtime).
+
+| Contribution | Registry method | Called | Sync or async | Store it receives |
+| ---------------------------- | -------------------------------- | --------------------------------------- | ------------- | ------------------------ |
+| `TaskLifecycleContributor` | `registry.task_lifecycle()` | Start and stop of every lead run and subagent | async, awaited | The task store |
+| `SystemModelCallObserver` | `registry.system_model_observer()` | After each DeerFlow-owned model call | async | The task store, or detached |
+| `AgentAssemblyObserver` | `registry.agent_assembly_observer()` | At the end of every agent construction | **sync** | The app store only |
+| `ContextCompactionObserver` | `registry.context_compaction_observer()` | After each summarization | async, fire-and-forget | Detached |
+
+All the examples on this page come from one extension that registers all four:
+
+```python
+@extension(api="0.2.0", name="observers")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ registry.task_lifecycle(TaskTimer())
+ registry.system_model_observer(SystemCallLogger())
+ registry.agent_assembly_observer(AssemblyDriftWatcher())
+ registry.context_compaction_observer(CompactionLogger())
+```
+
+## Task lifecycle
+
+```python
+class TaskLifecycleContributor(Protocol):
+ async def on_task_start(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo) -> None: ...
+ async def on_task_stop(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo, outcome: TaskOutcome) -> None: ...
+```
+
+A **task** is one lead run or one subagent execution. `task_store` is created for that task just before `on_task_start` and is the same object passed to its `on_task_stop` and to every middleware call inside it. This makes the pair the natural place to set up and fold up per-task state.
+
+### Timing
+
+For a **lead run**:
+
+1. The run is admitted and marked started. A run cancelled before this point gets neither hook.
+2. `on_task_start` is awaited, before the agent graph is built.
+3. The agent runs, including any goal continuations.
+4. The host persists the run's status and token usage, syncs the thread title and status, and runs its own completion hook.
+5. `on_task_stop` is awaited. The run's finalizing barrier is still held, so a follow-up run on the same thread cannot start its lifecycle until your hook returns.
+6. The barrier is released and the stream end is published to clients.
+
+For a **subagent**, `on_task_start` is awaited before the subagent's first step, and `on_task_stop` in its cleanup path after the sandbox lease is released, whatever the outcome.
+
+Both hooks share the 3-second notification budget described in [Runtime Model](/docs/harness/extensions/runtime). Because `on_task_stop` runs before the stream end, a slow stop hook delays the moment clients see the run finish.
+
+### TaskInfo
+
+| Field | Lead run | Subagent |
+| ---------------- | ------------------------------------------ | --------------------------------------------------------- |
+| `task_id` | The run id | The subagent execution id |
+| `run_id` | The run id | The parent run's id |
+| `thread_id` | The thread id | The parent thread id |
+| `kind` | `"lead"` | `"subagent"` |
+| `parent_task_id` | `None` | The parent run id, which is the lead task's `task_id` |
+| `agent_name` | The assistant or custom agent id | The subagent name, such as `general-purpose` |
+| `resumed` | `False` | `False` |
+
+
+ `resumed` is part of the contract but the current host never sets it to
+ `True`. Do not rely on it to detect continuations yet.
+
+
+For a subagent, `task_store.scope_id` is the delegating tool-call id when there is one, which is not necessarily equal to `info.task_id`. Use `info.task_id` as the task's identity.
+
+A subagent whose executor has no `run_id`, which happens under a standalone LangGraph Server or direct factory calls, skips both hooks and logs a debug line.
+
+### TaskOutcome
+
+| Outcome | Lead run | Subagent |
+| ----------- | -------------------------------------------------- | ----------------------------------------- |
+| `completed` | Status `success` | Status `completed` |
+| `aborted` | The run was stopped, or its status is `interrupted` | Status `cancelled` |
+| `failed` | Anything else, such as `error` | Anything else, including timeouts |
+
+The mapping is deliberately conservative. A subagent that hit its token or turn budget can still be `completed`.
+
+### Example
+
+```python
+@dataclass
+class RunClock:
+ started: float
+
+
+@dataclass
+class OutcomeTally:
+ counts: dict[str, int] = field(default_factory=dict)
+ _lock: Lock = field(default_factory=Lock, repr=False)
+
+ def add(self, kind: str, outcome: TaskOutcome) -> None:
+ with self._lock:
+ key = f"{kind}:{outcome.value}"
+ self.counts[key] = self.counts.get(key, 0) + 1
+
+
+class TaskTimer:
+ async def on_task_start(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo) -> None:
+ import time
+
+ task_store.set(RunClock(time.monotonic()))
+
+ async def on_task_stop(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ info: TaskInfo,
+ outcome: TaskOutcome,
+ ) -> None:
+ import time
+
+ clock = task_store.get(RunClock)
+ elapsed = time.monotonic() - clock.started if clock is not None else float("nan")
+ app_store.get_or_init(OutcomeTally, OutcomeTally).add(info.kind, outcome)
+ logger.info("%s %s in thread %s ended %s after %.2fs", info.kind, info.task_id, info.thread_id, outcome.value, elapsed)
+```
+
+Per-task state goes into `task_store` and disappears with the task; the aggregate goes into `app_store`. Always handle a missing value in `on_task_stop`: if another contributor spent the budget, your `on_task_start` may have been skipped.
+
+## System model calls
+
+```python
+class SystemModelCallObserver(Protocol):
+ async def on_system_model_call(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ kind: SystemOperationKind,
+ request: SystemModelRequest,
+ result: SystemModelResult,
+ ) -> None: ...
+```
+
+DeerFlow makes some model calls for itself, outside the agent's model-call chain, so middleware never sees them. This observer reports them:
+
+| `kind` | Call | How it is reported | Store |
+| --------------- | -------------------------------------------------------- | ---------------------------------------------------- | ------------------- |
+| `goal` | Goal-completion evaluation after an agent turn | Awaited inline | The lead task store |
+| `title` | Thread title generation | Awaited inline | The current task store |
+| `summarization` | Each summary model attempt, including fallback models | Awaited inline | The current task store |
+| `memory` | Memory extraction by the memory worker | Submitted to the notification loop, not awaited | Usually detached |
+
+Only the async path of summarization is observed. The sync `compact_state` path is not reachable from the Gateway runtime and reports nothing.
+
+### Payload
+
+`SystemModelRequest` is taken before the call:
+
+| Field | Meaning |
+| --------------- | ---------------------------------------------------------------------------------------- |
+| `messages` | Always a tuple. Goal and memory pass a message list; title and summarization pass one prompt string, which becomes a one-item tuple |
+| `model_name` | The model the call used, when known |
+| `invoke_config` | The call's runnable config when it is a mapping, else `None` |
+
+`SystemModelResult` is taken after it:
+
+| Field | Meaning |
+| ------------- | ------------------------------------------------------------------ |
+| `response` | The provider response on success, else `None` |
+| `error` | The exception on failure or cancellation, else `None` |
+| `duration_ms` | Wall time of the call |
+
+The `messages` normalization matters: without it, iterating a prompt string would walk its characters.
+
+### Terminal paths
+
+Every terminal path is reported, and the host sees the call's own result or exception unchanged:
+
+- **Success** and **failure** notify the observers inline, after the call returns or raises.
+- **Cancellation** is routine. Stopping a run, or sending a follow-up that interrupts it, cancels in-flight goal and summarization calls after the provider tokens are spent. Awaiting observers at that point would be interrupted by a repeated cancel, so the host submits the notification to the notification loop without waiting and re-raises the cancellation. `result.error` is the `CancelledError`. A host with no registered loop, or one that is shutting down, drops these observations.
+
+
+ Inline notifications for `goal`, `title`, and `summarization` are awaited
+ without a time budget, on the path of the run. A slow observer delays the
+ title, the summary, or the goal decision it observes. Keep this observer to
+ counting and logging, and hand anything slower to a service.
+
+
+### Example
+
+```python
+class SystemCallLogger:
+ async def on_system_model_call(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ kind: SystemOperationKind,
+ request: SystemModelRequest,
+ result: SystemModelResult,
+ ) -> None:
+ status = "failed" if result.error is not None else "ok"
+ logger.info(
+ "system %s call on %s: %s in %.0f ms (%d message(s), scope %s)",
+ kind.value,
+ request.model_name,
+ status,
+ result.duration_ms or 0.0,
+ len(request.messages),
+ task_store.scope_id,
+ )
+```
+
+```text
+system title call on gpt-4o-mini: ok in 612 ms (1 message(s), scope 7f3c...)
+system goal call on gpt-4o-mini: ok in 890 ms (2 message(s), scope 7f3c...)
+```
+
+## Agent assembly
+
+```python
+class AgentAssemblyObserver(Protocol):
+ def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None: ...
+```
+
+When the host builds an agent it decides the effective model, renders the system prompt, filters tools through authorization, and composes the middleware stack, all inside one synchronous call. None of that is recoverable afterwards. The host therefore emits an `AgentAssemblyDescriptor` at the end of every construction, which normally means once per lead run and once per subagent execution.
+
+This is the only **synchronous** contribution: agent construction is synchronous, and there is no event loop to await on. The observer must be cheap and must not block. It receives only the app store. When no assembly observer is registered, the host skips building descriptors entirely.
+
+### Descriptor
+
+| Field | Meaning | In fingerprint |
+| --------------------- | ------------------------------------------------------------------------------------- | -------------- |
+| `namespace` | `"deerflow"` | yes |
+| `agent_name` | `lead-agent`, a custom agent name, `bootstrap`, or the subagent name | yes |
+| `requested_model` | The model the caller asked for, if any | **no** |
+| `effective_model` | The model that reaches the provider | yes |
+| `model_parameters` | Behavior-affecting model settings; identity and presentation fields are excluded | yes |
+| `thinking_enabled`, `reasoning_effort` | The resolved reasoning settings | yes |
+| `base_prompt_hash` | `canonical_hash` of the rendered system prompt | yes |
+| `tools` | One `ToolDescriptor` per bound tool: `name`, `description_hash`, `schema_hash`, `source`, `mcp_server`, `mcp_transport` | yes, sorted by name |
+| `middlewares` | One `MiddlewareDescriptor` per stack entry: `name`, `module`, `policy_parameters`, `extension` | yes, **in stack order** |
+| `deferred_tool_names` | Tools hidden behind tool search | yes, sorted |
+| `enabled_skills` | Enabled skill names | yes, sorted |
+| `effective_policies` | Limits such as recursion limit, prompt template id, and a skill-catalog hash | yes |
+| `build` | `package_version`, `image_digest`, `git_commit` of the host | **no** |
+
+`descriptor.fingerprint` is a SHA-256 over the fields marked yes. It answers "did anything about how this agent behaves change?":
+
+- Tools and skills are sorted because their assembly order is incidental. Middleware keeps stack order because order decides what wraps what.
+- `build` is excluded so a redeploy of an unchanged configuration keeps every fingerprint. Compare `build` directly when you need to know the host changed.
+- `requested_model` is excluded because only `effective_model` reaches the provider.
+- A contributed middleware is described by the class it wraps, and its `extension` field names the contributing entry point, so two extensions' middleware never collapse into one entry.
+
+`image_digest` and `git_commit` come from the `DEER_FLOW_IMAGE_DIGEST` and `DEER_FLOW_GIT_COMMIT` environment variables and read `unknown` when unset.
+
+### Declaring your middleware's policy
+
+By default the host describes a middleware by probing a fixed set of public attributes. Declare the parameters that change your middleware's behavior instead, so a change to them changes the fingerprint:
+
+```python
+class ToolTimer(AgentMiddleware):
+ def __init__(self, slow_ms: float) -> None:
+ super().__init__()
+ self.slow_ms = slow_ms
+
+ def release_policy_parameters(self) -> dict[str, object]:
+ return {"slow_ms": self.slow_ms}
+```
+
+The descriptor then records `MiddlewareDescriptor(name="ToolTimer", ..., policy_parameters={"slow_ms": 200}, extension="deerflow_extension_hello:install")`. Values must be JSON-serializable: hash long text rather than embedding it. The contract package also exports the helpers the host uses, so an extension computes identical hashes:
+
+- `canonical_json(value)`: JSON with sorted keys and no insignificant whitespace. Raises `TypeError` on a value it cannot serialize instead of falling back to `repr`.
+- `canonical_hash(value)`: the SHA-256 hex digest of `canonical_json(value)`.
+- `collect_release_policies(middlewares)`: every declaration in a stack, keyed by class name. A repeated class gets `Name#2` and so on, and a declaration that raises is recorded as `{"error": ""}` instead of being dropped.
+
+### Example
+
+```python
+@dataclass
+class Fingerprints:
+ by_agent: dict[str, str] = field(default_factory=dict)
+ _lock: Lock = field(default_factory=Lock, repr=False)
+
+ def swap(self, agent: str, fingerprint: str) -> str | None:
+ with self._lock:
+ previous = self.by_agent.get(agent)
+ self.by_agent[agent] = fingerprint
+ return previous
+
+
+class AssemblyDriftWatcher:
+ def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None:
+ previous = app_store.get_or_init(Fingerprints, Fingerprints).swap(descriptor.agent_name, descriptor.fingerprint)
+ if previous is not None and previous != descriptor.fingerprint:
+ logger.warning("agent %s changed: %s -> %s", descriptor.agent_name, previous[:12], descriptor.fingerprint[:12])
+```
+
+An exception from the observer is logged and the agent is built anyway.
+
+## Context compaction
+
+```python
+class ContextCompactionObserver(Protocol):
+ async def on_context_compacted(self, app_store: ExtensionData, task_store: ExtensionData, event: CompactionEvent) -> None: ...
+```
+
+Summarization replaces many messages with one summary. Afterwards, nothing in state records which messages became that summary. The host captures that mapping at the only moment it still exists: just before the summary call it hashes each message about to be removed, and after a summary is produced it emits a `CompactionEvent`.
+
+| Field | Meaning |
+| ------------------------- | -------------------------------------------------------------- |
+| `transform_kind` | `"summarization"` |
+| `transform_version` | `"1"` |
+| `source_content_hashes` | `canonical_hash(message.content)` for each removed message, in order |
+| `output_content_hash` | `canonical_hash` of the summary text |
+| `compacted_message_count` | How many messages were removed |
+| `kept_message_count` | How many messages were kept |
+
+To match an event against messages you hold, hash exactly the same way: `canonical_hash(message.content)`, passing the content itself. Never stringify it first, because multimodal content is a list of dicts and `str()` depends on key order. Do not try to rediscover the summary later by hashing what the model is shown: the prompt carries a bounded, escaped rendering of the summary, whose hash will not match `output_content_hash`.
+
+The notification is fire-and-forget. It is dispatched to the notification loop without blocking the model turn, and the observer receives a **detached** store, because there is no live task at that call site. When no compaction observer is registered, the host skips the hashing pass too.
+
+### Example
+
+```python
+class CompactionLogger:
+ async def on_context_compacted(self, app_store: ExtensionData, task_store: ExtensionData, event: CompactionEvent) -> None:
+ logger.info(
+ "%s v%s folded %d message(s) into summary %s, kept %d",
+ event.transform_kind,
+ event.transform_version,
+ event.compacted_message_count,
+ event.output_content_hash[:12],
+ event.kept_message_count,
+ )
+```
+
+## Pitfalls
+
+- **Doing I/O in a hook.** Lifecycle hooks share a 3-second budget; inline system-model notifications have none and sit on the run's path; assembly observers block construction. Buffer in a store and flush from a [service](/docs/harness/extensions/services-and-routes).
+- **Keeping state on a detached store.** It is discarded after the notification. Use the app store for anything that must survive.
+- **Assuming start implies stop.** A skipped start (budget spent) or a run cancelled before it started produce asymmetric sequences. Make `on_task_stop` tolerate missing state.
+- **Treating the fingerprint as a deployment id.** It deliberately ignores `build`. Read `descriptor.build` to identify the host binary.
diff --git a/frontend/src/content/en/harness/extensions/operations.mdx b/frontend/src/content/en/harness/extensions/operations.mdx
new file mode 100644
index 000000000..609475846
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/operations.mdx
@@ -0,0 +1,248 @@
+---
+title: Operating Extensions
+description: How operators install, upgrade, enable, disable, and remove extensions with the extension manager. Covers which config.yaml is used, the plugins record, accepted sources, local snapshots, transactional rollback and locking, deployment with Docker and Helm, and recovering a Gateway that a required extension keeps from starting.
+---
+
+import { Callout } from "nextra/components";
+
+# Operating Extensions
+
+This chapter is for operators. It describes what the extension manager does to a checkout, what each command prints, and how an installed extension reaches a production image. For writing an extension, start with [Quick Start](/docs/harness/extensions/quick-start).
+
+## Commands
+
+Run the `make` wrappers from the root of the DeerFlow checkout. Each one calls the same CLI from `backend/`:
+
+| `make` target | CLI (run from `backend/`) | Effect |
+| -------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
+| `make extension-install SOURCE=` | `deerflow extensions install [--yes] [--required]` | Install a package and add an enabled `plugins:` record |
+| `make extension-upgrade SOURCE=` | `deerflow extensions upgrade [--yes]` | Replace an installed package, keeping its record's settings |
+| `make extension-list` | `deerflow extensions list` | Print configured extensions in load order |
+| `make extension-enable NAME=` | `deerflow extensions enable ` | Set `enabled: true` |
+| `make extension-disable NAME=` | `deerflow extensions disable ` | Set `enabled: false`; package and `config` stay |
+| `make extension-remove NAME=` | `deerflow extensions remove ` | Remove the record, uninstall the package, delete its snapshot |
+
+Invoke the CLI directly as `uv run --frozen --no-group extensions deerflow extensions `, which is what the wrappers do. Running it without the `extensions` dependency group means a broken or missing extension package cannot stop you from listing, disabling, or removing it.
+
+`NAME` matches a record's `name`, its `package` (distribution name, compared after normalization), or its `use` value. Exactly one record must match, otherwise the command fails with `expected exactly one configured extension matching ''`.
+
+**Every command takes effect only after a Gateway restart.** The Gateway reads `plugins:` once while it builds the application.
+
+### Output
+
+`install` and `upgrade` ask for confirmation unless `--yes` is given:
+
+```text
+Warning: a Python extension executes code with Gateway privileges.
+Install this trusted source? [y/N]
+```
+
+Answering anything other than `y` or `yes` prints `Extension installation cancelled.` (or `Extension upgrade cancelled.`) and exits with status 2. Pass `--yes` only in automation that has already reviewed the source: the package's build backend runs during installation, and its code runs inside the Gateway.
+
+On success:
+
+```text
+Installed and enabled hello (deerflow-extension-hello). Restart DeerFlow to load it.
+Upgraded hello (deerflow-extension-hello). Restart DeerFlow to load it.
+Enabled hello. Restart DeerFlow to apply the change.
+Disabled hello. Restart DeerFlow to apply the change.
+Removed hello. Restart DeerFlow to apply the change.
+```
+
+`list` prints a tab-separated table. A hand-written record without `name` or `package` shows its `use` value as the name and `-` as the package:
+
+```text
+NAME STATE PACKAGE ENTRY POINT
+hello enabled deerflow-extension-hello deerflow_extension_hello:install
+my_local_ext:install enabled - my_local_ext:install
+```
+
+Any failure prints `extension command failed: ` to stderr and exits with status 1. The reasons are listed in [Troubleshooting](/docs/harness/extensions/troubleshooting).
+
+## Which config.yaml is used
+
+The manager picks one file and edits only its `plugins:` block:
+
+1. `DEER_FLOW_CONFIG_PATH`, when set.
+2. Otherwise `config.yaml` at the checkout root, when it exists or when `backend/config.yaml` does not.
+3. Otherwise the legacy `backend/config.yaml`.
+
+The checkout root is `DEER_FLOW_PROJECT_ROOT` when set, otherwise the nearest ancestor of the current directory that contains `backend/pyproject.toml`.
+
+The Gateway resolves its config with the same `DEER_FLOW_CONFIG_PATH` override first. If you run the Gateway with a non-default config path, set the same variable when you run the manager, or it will edit a file the Gateway never reads.
+
+The manager validates the file before running any `uv` command. It refuses to continue when the file is missing (`DeerFlow config not found: `), is not valid YAML, has a non-mapping root, has a `plugins` value that is not a list, or contains `plugins:` twice at the top level.
+
+## The plugins record
+
+```yaml
+plugins:
+ - name: hello
+ package: deerflow-extension-hello
+ use: deerflow_extension_hello:install
+ enabled: true
+ required: false
+ config:
+ slow_ms: 200
+ # table_prefix: hello_
+```
+
+| Field | Default | Written by the manager | Meaning |
+| -------------- | -------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
+| `use` | required | yes | Entry point as `module.path:install` |
+| `name` | none | yes | Operator-facing name: the entry-point name from the package metadata |
+| `package` | none | yes | Distribution name. Required for `remove` |
+| `enabled` | `true` | yes | `false` skips the entry without importing it |
+| `required` | `false` | yes (`false` unless `--required`) | `true` aborts Gateway startup when the entry fails to load |
+| `config` | `{}` | yes (`{}`) | Private configuration handed to `install()`. Never overwritten by the manager |
+| `table_prefix` | none | no | Table-name prefix the extension owns in the DeerFlow database. See [Database tables](#database-tables) |
+
+Unknown keys are rejected when the Gateway loads its config, so a typo in a field name stops the Gateway from starting rather than being ignored.
+
+The manager rewrites the whole `plugins:` block through a YAML serializer, so comments and formatting **inside** the block are not preserved. Everything outside it, including comment lines directly below a block at the end of the file, is left untouched. The write is atomic: a temporary file is renamed over the config with the original file mode.
+
+### Hand-written records
+
+You can add records by hand, for example for a module that is already importable in the environment. Such a record needs only `use`. `enable`, `disable`, and `list` work on it; `remove` refuses with `configured extension '' has no managed package metadata`, because there is no package to uninstall. Delete the record yourself instead.
+
+When `install` finds an existing record with the same `use`, it adopts that record: it fills in `name` and `package`, sets `enabled: true`, keeps your `required` value and your `config`.
+
+### Database tables
+
+An extension that persists data in the DeerFlow database under its own SQLAlchemy metadata and migration chain should declare the prefix of its table names in `table_prefix`. DeerFlow then excludes those tables from `alembic revision --autogenerate`, which would otherwise propose to drop them. The prefix is registered even when the record is disabled, because the tables may already exist.
+
+A prefix that would hide one of DeerFlow's own tables always aborts startup, whatever `required` says:
+
+```text
+extension table_prefix 'runs' would hide host-owned table(s) ['runs'] from alembic autogenerate; choose a prefix that is not a prefix of any host table name
+```
+
+An empty `table_prefix: ""` is rejected; omit the key to declare no prefix.
+
+## Required and optional extensions
+
+With `required: false`, which is the default and what the manager writes, any load failure is logged and the Gateway starts without that extension. With `required: true`, the same failure raises `ExtensionLoadError` while the Gateway builds its application, and the Gateway does not start.
+
+Set `required: true` only when the deployment is incorrect without the extension, for example an audit trail you are obliged to keep. Keep in mind that a later broken build, a missing native library, or a deleted snapshot then becomes an outage that needs shell access to fix. `install --required` is the only way the manager writes `true`; `upgrade` and adopting an existing record keep whatever value is already there.
+
+## Accepted sources
+
+| Source | Example | Accepted |
+| ------------------------------------------------------------- | ----------------------------------------------------------------------- | -------- |
+| Package requirement from an index | `deerflow-extension-acme==1.2.3` | Yes |
+| Public Git over HTTPS, pinned to a commit | `git+https://github.com/acme/deerflow-extension-acme.git@` | Yes |
+| HTTPS direct reference | `https://example.com/deerflow_extension_acme-1.2.3-py3-none-any.whl` | Yes |
+| Local directory (absolute path) | `$HOME/src/deerflow-extension-hello` | Yes, as a snapshot |
+| HTTP to `localhost`, `127.0.0.1`, or `::1` | `http://127.0.0.1:8080/pkg.whl` | Yes, with a warning if it ends up in `uv.lock` |
+| Git SSH shorthand | `git@github.com:acme/x.git` | No |
+| SSH URL | `git+ssh://git@github.com/acme/x.git` | No |
+| Plain HTTP to any other host | `http://example.com/x.whl` | No |
+| URL with embedded credentials | `https://user:pw@example.com/x.whl` | No |
+| URL with a credential-like query or fragment key | `...?token=abc`, `...#api_key=...` | No |
+| `file:` URL, relative path, local wheel or other local file | `file:///tmp/x`, `./pkg.whl` | No |
+
+The rules exist because the production image is built from the checkout alone. The stock Docker builder does not forward SSH credentials, cannot reach files outside `backend/`, and must not have secrets baked into the recorded source URL. Authenticate private indexes through uv's own index and credential settings, which the manager leaves in place.
+
+### Local snapshots
+
+A local directory is not linked. It is copied to `backend/extensions/sources//`, which the Docker build context includes. The copy skips `.git`, `.venv`, `venv`, `__pycache__`, and `*.pyc`. The manager refuses a directory that:
+
+- has no `pyproject.toml`, or does not declare `project.name` and **exactly one** entry point in the `deerflow.extensions` group;
+- contains a symbolic link or junction, or anything other than regular files and directories;
+- contains a likely secret: `.env`, `.env.*`, `.npmrc`, `.pypirc`, `credentials.json`, or a file ending in `.key`, `.pem`, `.p12`, or `.pfx`;
+- is already installed. Use `upgrade` to replace it.
+
+These checks catch packaging accidents. They are not a malware scan.
+
+Because the snapshot is a copy, edits to your working directory reach DeerFlow only through `make extension-upgrade SOURCE=` followed by a restart.
+
+## Upgrading
+
+`upgrade` replaces the source of an extension that is already installed and keeps its record: `config`, `required`, and `enabled` stay as they are.
+
+- A local directory must already have a snapshot. The old snapshot is kept aside and restored if the upgrade fails.
+- A package requirement must name a distribution already in the `extensions` group, for example `deerflow-extension-acme==1.3.0`.
+- A Git URL must point to a repository already pinned in the group; pass the new commit.
+
+Anything else fails with `... is not installed; use install`. The new version must keep the same entry-point target (`use`). If the target changed, remove the extension and install it again.
+
+## What an install changes
+
+1. Validates the source and the config file.
+2. Checks that `uv --version` is 0.8.0 or newer.
+3. For a local directory, copies the snapshot.
+4. Runs `uv add --project backend --group extensions --no-workspace --no-sync -- `, which updates the `extensions` list under `[dependency-groups]` in `backend/pyproject.toml` and `backend/uv.lock`.
+5. Audits the new lock (see below).
+6. Runs `uv sync --project backend --all-packages --locked`, plus the same optional extras the normal startup would detect from the config.
+7. Discovers the package's single `deerflow.extensions` entry point in the synced environment and checks that it loads and is callable.
+8. Writes the `plugins:` record. This is the last step, so a failure before it never touches the config.
+
+`remove` works in the other order: it deactivates the record first, then runs `uv remove --group extensions`, audits the lock, moves the snapshot aside, and syncs. If another record uses the same `package`, `remove` only deletes the matching record and leaves the package installed.
+
+Every `uv` call pins the backend project and drops environment variables that could redirect it, including `UV_PROJECT`, `UV_PYTHON`, `UV_FROZEN`, `UV_NO_SYNC`, `UV_PROJECT_ENVIRONMENT`, and `UV_INSECURE_HOST`. Index, proxy, cache, and credential-provider settings remain available.
+
+### Rollback
+
+A failed install, upgrade, or remove restores `backend/pyproject.toml`, `backend/uv.lock`, the snapshot directory, and, for `remove`, the config, then re-syncs the environment to match the restored files. If that recovery sync fails as well, the error reports both failures (`extension operation failed and the restored environment could not be synchronized; original failure: ...`). If the operation is interrupted (Ctrl+C), the files are restored but the recovery sync is skipped; the next locked sync at startup reconciles the environment.
+
+Recovery never overwrites someone else's edit. If a dependency file or the config changed while the operation was running, the manager leaves the concurrent edit in place and fails with `... recovery preserved a concurrent dependency-file edit` (or `concurrent config edit`). A `remove` interrupted this way leaves the extension deactivated.
+
+### Locking
+
+All mutating commands for one checkout hold an exclusive lock on `.deer-flow/extension-manager.lock` at the checkout root, so two operators or two CI jobs cannot interleave installs. A second command waits until the first finishes. `list` does not take the lock.
+
+### Lock audit
+
+A source that passes validation can still resolve to something the image build cannot reproduce, for example when `UV_FIND_LINKS` points uv at a local wheelhouse. After every `uv add` and `uv remove`, the manager scans `uv.lock`. Any absolute path, `file:` URL, or relative path outside the backend project, its workspace members, and `extensions/sources/` fails the whole operation with `uv.lock contains a local dependency source outside the backend Docker build context` and rolls it back. A loopback URL only produces a warning, `uv.lock records a loopback dependency source the backend Docker build cannot reach`, because it is a source you typed deliberately. It still will not resolve inside the image builder.
+
+## Deploying
+
+### Local development
+
+`make dev`, `make install`, and `cd backend && make dev` all use `uv sync --locked` or `uv run --locked`, so they install exactly what `backend/uv.lock` records, including the `extensions` group, which is a default group. They may download locked artifacts that are missing locally. They never re-resolve dependencies.
+
+### Docker
+
+The backend image is built from the checkout's `backend/` directory, including `pyproject.toml`, `uv.lock`, and `extensions/sources/`, with `uv sync --locked`. The containers start the Gateway with `uv run --no-sync`, so **a production container never installs an extension at startup**. After any change to the installed set, rebuild:
+
+```bash
+make up
+```
+
+Commit `backend/pyproject.toml`, `backend/uv.lock`, and `backend/extensions/sources/` to the branch you build images from, and keep the matching `plugins:` records in the `config.yaml` the deployment mounts.
+
+The development container (`make docker-start`) runs `uv sync --locked --all-packages` at startup. If that fails, it recreates `.venv` and retries once, still `--locked`. A second failure stops the container with:
+
+```text
+[startup] uv sync --locked failed again after recreating .venv.
+[startup] backend/uv.lock does not match backend/pyproject.toml, or a locked artifact is unreachable.
+```
+
+### Helm
+
+The chart does not install extensions. Build the Gateway image from a checkout where the extension is installed, as above, and add the `plugins:` records to the `config` value that the chart renders into `config.yaml`. The chart's `extensionsConfig` value renders `extensions_config.json`, which holds MCP servers and skills; `plugins:` placed there is ignored.
+
+### uv version
+
+The manager needs uv 0.8.0 or newer (`extension installation requires uv 0.8.0 or newer`). Production pins one exact version: the `UV_IMAGE` build argument in `backend/Dockerfile` (currently `ghcr.io/astral-sh/uv:0.11.1`). The compose files and CI use the same version, and `backend/tests/test_ci_uv_version_pin.py` keeps them in step. Use the same uv locally when you install or upgrade extensions: a newer uv can write a `uv.lock` that the pinned uv in the image cannot read.
+
+## Recovering from a required extension that blocks startup
+
+
+ A `required: true` extension that fails to load stops the Gateway with
+ `ExtensionLoadError: required extension failed to load` (or
+ `... failed to install`, `... declares incompatible api ...`). The web UI is
+ unavailable until you fix it from a shell.
+
+
+1. Read the error line logged just before it: `Extension : `. It names the cause.
+2. Let the Gateway start without the extension. Either disable it:
+
+ ```bash
+ make extension-disable NAME=
+ ```
+
+ or edit the record in `config.yaml` and set `required: false`, which keeps it loading when it works and skipping it when it does not. The management wrappers run without the `extensions` dependency group, so they work even when the extension's package itself is broken.
+3. Restart the Gateway, fix the cause (for example `make extension-upgrade SOURCE=...`), re-enable, and restart again.
+
+A `table_prefix` collision aborts startup regardless of `required`; change or remove the prefix.
diff --git a/frontend/src/content/en/harness/extensions/quick-start.mdx b/frontend/src/content/en/harness/extensions/quick-start.mdx
new file mode 100644
index 000000000..f2c7c0bf8
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/quick-start.mdx
@@ -0,0 +1,272 @@
+---
+title: Quick Start
+description: Build, test, install, and remove a working extension in fifteen minutes. The example logs how long every tool call takes, for the Lead Agent and every subagent.
+---
+
+import { Callout, Steps } from "nextra/components";
+
+# Quick Start
+
+This chapter walks through a complete extension named `hello`. It contributes one middleware that times every tool call and logs a warning when a call is slower than a configured threshold. By the end you will have packaged it, unit-tested it without DeerFlow, installed it into a checkout, and seen it run.
+
+## Prerequisites
+
+- A DeerFlow checkout that runs with `make dev`. See [Quick Start](/docs/application/quick-start).
+- Python 3.12 or newer and [uv](https://docs.astral.sh/uv/) 0.8.0 or newer. The extension manager refuses older uv.
+- Shell access to the machine running the Gateway. Installing an extension is an operator action, not something the web UI does.
+
+
+
+### Create the package
+
+An extension is a normal Python package. Create it **outside** the DeerFlow checkout, for example in `~/src/deerflow-extension-hello`:
+
+```text
+deerflow-extension-hello/
+├── pyproject.toml
+├── deerflow_extension_hello/
+│ └── __init__.py
+└── tests/
+ └── test_hello.py
+```
+
+`pyproject.toml` declares the contract range, every framework the code imports, and exactly one entry point in the `deerflow.extensions` group:
+
+```toml filename="pyproject.toml"
+[project]
+name = "deerflow-extension-hello"
+version = "0.1.0"
+requires-python = ">=3.12"
+dependencies = [
+ "deerflow-extension-api>=0.2,<0.3",
+ "langchain>=1.3,<2",
+]
+
+[project.entry-points."deerflow.extensions"]
+hello = "deerflow_extension_hello:install"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["deerflow_extension_hello"]
+```
+
+The entry-point name, `hello`, becomes the operator-facing name used by `enable`, `disable`, and `remove`.
+
+
+ `deerflow-extension-api` deliberately has no dependencies. If your code imports
+ LangChain, LangGraph, or FastAPI, declare them yourself, as `langchain` is
+ declared here. Never import `deerflow.*` or `app.*`: those are host internals
+ with no compatibility promise.
+
+
+### Write install()
+
+```python filename="deerflow_extension_hello/__init__.py"
+"""Log how long each tool call takes, as the model sees it."""
+
+from __future__ import annotations
+
+import logging
+import time
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from deerflow_extension_api import (
+ AgentBuildContext,
+ AgentScope,
+ ExtensionData,
+ ExtensionRegistry,
+ MiddlewarePlacement,
+ Placement,
+ extension,
+)
+from langchain.agents.middleware import AgentMiddleware
+
+logger = logging.getLogger(__name__)
+
+
+class ToolTimer(AgentMiddleware):
+ def __init__(self, slow_ms: float) -> None:
+ super().__init__()
+ self.slow_ms = slow_ms
+
+ def _report(self, request: Any, started: float) -> None:
+ elapsed_ms = (time.perf_counter() - started) * 1000
+ level = logging.WARNING if elapsed_ms >= self.slow_ms else logging.INFO
+ logger.log(level, "tool %s took %.1f ms", request.tool_call.get("name"), elapsed_ms)
+
+ def wrap_tool_call(self, request, handler):
+ started = time.perf_counter()
+ try:
+ return handler(request)
+ finally:
+ self._report(request, started)
+
+ async def awrap_tool_call(self, request, handler):
+ started = time.perf_counter()
+ try:
+ return await handler(request)
+ finally:
+ self._report(request, started)
+
+
+class ToolTimerContributor:
+ def __init__(self, slow_ms: float) -> None:
+ self.slow_ms = slow_ms
+
+ def contribute_middlewares(
+ self,
+ app_store: ExtensionData,
+ ctx: AgentBuildContext,
+ ) -> Sequence[MiddlewarePlacement]:
+ return (MiddlewarePlacement(ToolTimer(self.slow_ms), Placement.TOOL_VISIBLE, AgentScope.BOTH),)
+
+
+@extension(api="0.2.0", name="hello")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ registry.middlewares(ToolTimerContributor(float(config.get("slow_ms", 1000))))
+```
+
+Three things to notice:
+
+- `install()` only **registers**. It runs once at Gateway startup, before any agent exists. The host calls `contribute_middlewares()` later, each time it assembles an agent.
+- `Placement.TOOL_VISIBLE` asks for the outer end of the tool chain, so the timing includes output truncation and error wrapping: what the model finally waits for. `AgentScope.BOTH` installs the middleware into the Lead Agent and into every subagent. Both are explained in [Middleware Contributions](/docs/harness/extensions/middleware).
+- The middleware implements both `wrap_tool_call` and `awrap_tool_call`. If you implement only one side, the other execution path passes through without observing anything.
+
+### Test it without DeerFlow
+
+The contract is plain Python, so a stand-in registry is enough to test registration, and the middleware can be called directly:
+
+```python filename="tests/test_hello.py"
+import asyncio
+import logging
+from types import SimpleNamespace
+
+from deerflow_extension_api import AgentBuildContext, AgentScope, ExtensionData, Placement
+
+from deerflow_extension_hello import ToolTimer, install
+
+
+class RecordingRegistry:
+ def __init__(self):
+ self.contributors = []
+
+ def middlewares(self, contributor):
+ self.contributors.append(contributor)
+
+
+def test_install_registers_one_tool_visible_middleware():
+ registry = RecordingRegistry()
+ install(registry, {"slow_ms": 50})
+
+ (contributor,) = registry.contributors
+ ctx = AgentBuildContext(scope=AgentScope.LEAD)
+ (placement,) = contributor.contribute_middlewares(ExtensionData("app"), ctx)
+ assert placement.placement is Placement.TOOL_VISIBLE
+ assert placement.middleware.slow_ms == 50
+
+
+def test_slow_tool_calls_log_a_warning(caplog):
+ timer = ToolTimer(slow_ms=0)
+ request = SimpleNamespace(tool_call={"name": "web_search"})
+
+ async def handler(req):
+ return "result"
+
+ with caplog.at_level(logging.INFO):
+ assert asyncio.run(timer.awrap_tool_call(request, handler)) == "result"
+ assert "tool web_search took" in caplog.text
+ assert caplog.records[-1].levelno == logging.WARNING
+```
+
+The contract package is currently sourced from the DeerFlow checkout, so install it from there in editable mode:
+
+```bash
+cd ~/src/deerflow-extension-hello
+uv venv --python 3.12
+uv pip install -e /path/to/deer-flow/backend/packages/extension-api -e . pytest
+uv run --no-project pytest -q
+```
+
+### Install it into DeerFlow
+
+From the root of the DeerFlow checkout, pass the package directory as an **absolute** path. The Make wrapper runs the manager from `backend/`, so a relative path would resolve against the wrong directory:
+
+```bash
+make extension-install SOURCE="$HOME/src/deerflow-extension-hello"
+```
+
+The manager warns that the extension will execute with Gateway privileges and asks `Install this trusted source? [y/N]`. After you confirm, it:
+
+1. copies a snapshot of the directory to `backend/extensions/sources/deerflow-extension-hello/`. Later edits to your working copy are not picked up until you run `make extension-upgrade`;
+2. adds the snapshot to the `extensions` dependency group in `backend/pyproject.toml` and updates `backend/uv.lock`;
+3. syncs the locked environment;
+4. appends an enabled `plugins:` record to `config.yaml`.
+
+It then prints `Installed and enabled hello (deerflow-extension-hello). Restart DeerFlow to load it.` Check the record:
+
+```bash
+make extension-list
+```
+
+```text
+NAME STATE PACKAGE ENTRY POINT
+hello enabled deerflow-extension-hello deerflow_extension_hello:install
+```
+
+### Configure and restart
+
+The manager writes an empty private `config: {}`. To lower the slow-call threshold, edit the record in `config.yaml`. The manager preserves this block across enable, disable, and upgrade:
+
+```yaml filename="config.yaml"
+plugins:
+ - name: hello
+ package: deerflow-extension-hello
+ use: deerflow_extension_hello:install
+ enabled: true
+ required: false
+ config:
+ slow_ms: 200
+```
+
+Extensions load only while the Gateway starts, so restart it:
+
+```bash
+make dev
+```
+
+The Gateway log confirms the load:
+
+```text
+Extensions loaded: 1/1 (deerflow_extension_hello:install)
+```
+
+If the entry point cannot be imported, is incompatible, or `install()` raises, the count reads `0/1` and an error line starting with `Extension deerflow_extension_hello:install:` explains why. The Gateway still starts, because the record has `required: false`.
+
+### See it run
+
+Send a message that makes the agent use a tool, such as a web search. The Gateway log shows one line per tool call, from the Lead Agent and from any subagent it delegates to. The exact prefix depends on your logging format:
+
+```text
+WARNING deerflow_extension_hello: tool web_search took 1432.7 ms
+```
+
+### Disable or remove it
+
+```bash
+make extension-disable NAME=hello # keep the package and config, stop loading it
+make extension-enable NAME=hello
+make extension-remove NAME=hello # uninstall, delete the record and the snapshot
+```
+
+Every command takes effect after the next restart. If you deploy with Docker, rebuild the Gateway image after changing the installed set; a built production container never installs extensions at startup.
+
+
+
+## Next steps
+
+- [Middleware Contributions](/docs/harness/extensions/middleware): the five placements, scope and ordering, and what an extension middleware may and may not change.
+- The [bundled example](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example) combines middleware with task-lifecycle state, a system-model observer, a service, and an HTTP route.
diff --git a/frontend/src/content/en/harness/extensions/reference.mdx b/frontend/src/content/en/harness/extensions/reference.mdx
new file mode 100644
index 000000000..0c113a45f
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/reference.mdx
@@ -0,0 +1,413 @@
+---
+title: Reference
+description: Every public name in deerflow-extension-api 0.2.1, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history.
+---
+
+# Reference
+
+This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.1**. Import them from the package root:
+
+```python
+from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement
+```
+
+The package has no dependencies and never imports DeerFlow. Types written as `Any` below, such as a middleware or a router, are validated by the host at runtime rather than by the contract.
+
+## Entry point and registry
+
+### `ExtensionInstall`
+
+```python
+ExtensionInstall = Callable[[ExtensionRegistry, Mapping[str, Any]], None]
+```
+
+The signature of the function named by a `plugins:` record's `use`. The second argument is a shallow copy of the record's `config`.
+
+### `extension(*, api, name=None)`
+
+Decorator that stamps an install function with the contract version it was written against (`__deerflow_api__`) and an optional name (`__deerflow_name__`). Optional; see [Compatibility rules](#compatibility-rules).
+
+```python
+@extension(api="0.2.0", name="hello")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ...
+```
+
+### `ExtensionRegistry`
+
+A `runtime_checkable` Protocol: the write-only surface passed to `install()`. Every method has a default implementation that registers nothing. A host whose registry predates a method inherits that default, so the call succeeds but the contribution is not registered. The version marker and package metadata are how you prevent that.
+
+| Method | Returns | Registers |
+| ---------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- |
+| `middlewares(contributor: MiddlewareContributor)` | `None` | A middleware contributor |
+| `task_lifecycle(contributor: TaskLifecycleContributor)` | `None` | Start and stop hooks for lead runs and subagents |
+| `system_model_observer(observer: SystemModelCallObserver)` | `None` | An observer of DeerFlow-owned model calls |
+| `agent_assembly_observer(observer: AgentAssemblyObserver)` | `None` | An observer of assembled agents |
+| `context_compaction_observer(observer: ContextCompactionObserver)` | `None` | An observer of summarization |
+| `service(service: ExtensionService)` | `None` | A Gateway-lifetime service |
+| `routers(routers: Sequence[Any])` | `None` | FastAPI routers, built during `install()` |
+
+## Middleware
+
+### `MiddlewareContributor`
+
+```python
+class MiddlewareContributor(Protocol):
+ def contribute_middlewares(
+ self, app_store: ExtensionData, ctx: AgentBuildContext
+ ) -> Sequence[MiddlewarePlacement]: ...
+```
+
+Called on every agent assembly. Default returns `()`.
+
+### `MiddlewarePlacement`
+
+Frozen dataclass.
+
+| Field | Type | Default |
+| ------------ | ------------ | ----------------- |
+| `middleware` | `Any` (must be a LangChain `AgentMiddleware`) | required |
+| `placement` | `Placement` | required |
+| `scope` | `AgentScope` | `AgentScope.BOTH` |
+| `order` | `int` | `0` |
+
+### `Placement`
+
+`StrEnum`: `MODEL_LOGICAL = "model_logical"`, `MODEL_PHYSICAL = "model_physical"`, `TOOL_VISIBLE = "tool_visible"`, `TOOL_RAW = "tool_raw"`, `STANDARD = "standard"`. The guarantee each one makes is in [Middleware Contributions](/docs/harness/extensions/middleware).
+
+### `AgentScope`
+
+`Flag`: `LEAD`, `SUBAGENT`, `BOTH = LEAD | SUBAGENT`.
+
+### `AgentBuildContext`
+
+Frozen dataclass passed to `contribute_middlewares()`.
+
+| Field | Type | Default |
+| ------------ | -------------------- | ---------------------- |
+| `scope` | `AgentScope` | required |
+| `agent_name` | `str \| None` | `None` |
+| `model_name` | `str \| None` | `None` |
+| `policy` | `HostPolicySnapshot` | `HostPolicySnapshot()` |
+
+### `HostPolicySnapshot`
+
+Frozen dataclass: the limits the host enforces, projected so extensions do not depend on DeerFlow's config types. Every field has a default.
+
+| Field | Type | Default |
+| ----------------------- | --------------- | ------- |
+| `token_budget_enabled` | `bool` | `False` |
+| `max_input_tokens` | `int \| None` | `None` |
+| `max_output_tokens` | `int \| None` | `None` |
+| `max_total_tokens` | `int \| None` | `None` |
+| `budget_warn_fraction` | `float \| None` | `None` |
+| `budget_hard_fraction` | `float \| None` | `None` |
+| `max_subagents_per_run` | `int \| None` | `None` |
+
+## State
+
+### `ExtensionData`
+
+Typed, thread-safe store attached to one host scope (the app, or one task). Keyed by Python type, so two extensions cannot collide.
+
+| Member | Description |
+| ---------------------------------------------- | ---------------------------------------------------------------------------------- |
+| `ExtensionData(scope_id: str)` | Constructor. The host creates stores; construct one yourself only in tests |
+| `scope_id: str` | Host identity of the scope |
+| `get(typ: type[T]) -> T \| None` | The stored instance of `typ`, or `None` |
+| `get_or_init(typ: type[T], init: Callable[[], T]) -> T` | The stored instance, created by `init()` when absent. `init` runs under the store's lock |
+| `set(value: T) -> None` | Store `value` under `type(value)`, replacing any previous one |
+| `remove(typ: type[T]) -> T \| None` | Remove and return the stored instance |
+
+### `task_store_from_runtime(runtime: object) -> ExtensionData | None`
+
+Return the task-scoped store from a LangGraph runtime (`request.runtime` in a wrap hook, the `runtime` argument of a lifecycle hook), or `None` when there is no live task.
+
+### `EXTENSION_TASK_STORE_KEY`
+
+`"__deerflow_extension_task_store"`. The host-owned runtime-context key behind `task_store_from_runtime()`. Read it only through that helper and never write it.
+
+## Task lifecycle
+
+### `TaskLifecycleContributor`
+
+```python
+class TaskLifecycleContributor(Protocol):
+ async def on_task_start(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo) -> None: ...
+ async def on_task_stop(
+ self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo, outcome: TaskOutcome
+ ) -> None: ...
+```
+
+### `TaskInfo`
+
+Frozen dataclass.
+
+| Field | Type | Default |
+| ---------------- | ----------------------------- | ------- |
+| `task_id` | `str` | required |
+| `run_id` | `str` | required |
+| `thread_id` | `str` | required |
+| `kind` | `Literal["lead", "subagent"]` | required |
+| `parent_task_id` | `str \| None` | `None` |
+| `agent_name` | `str \| None` | `None` |
+| `resumed` | `bool` | `False` |
+
+### `TaskOutcome`
+
+`StrEnum`: `COMPLETED = "completed"`, `ABORTED = "aborted"`, `FAILED = "failed"`.
+
+## System model calls
+
+### `SystemModelCallObserver`
+
+```python
+class SystemModelCallObserver(Protocol):
+ async def on_system_model_call(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ kind: SystemOperationKind,
+ request: SystemModelRequest,
+ result: SystemModelResult,
+ ) -> None: ...
+```
+
+### `SystemOperationKind`
+
+`StrEnum`: `GOAL = "goal"`, `MEMORY = "memory"`, `TITLE = "title"`, `SUMMARIZATION = "summarization"`.
+
+### `SystemModelRequest`
+
+Frozen dataclass, a read-only snapshot taken before the call.
+
+| Field | Type | Default |
+| --------------- | ----------------------------- | ------- |
+| `messages` | `Sequence[Any]` | `()` |
+| `model_name` | `str \| None` | `None` |
+| `invoke_config` | `Mapping[str, Any] \| None` | `None` |
+
+`messages` is normalized to a tuple on construction. A single prompt string becomes a one-element tuple rather than a sequence of characters.
+
+### `SystemModelResult`
+
+Frozen dataclass: `response: Any | None = None`, `error: BaseException | None = None`, `duration_ms: float | None = None`.
+
+## Agent assembly
+
+### `AgentAssemblyObserver`
+
+```python
+class AgentAssemblyObserver(Protocol):
+ def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None: ...
+```
+
+Synchronous, called at the end of agent construction. Must be cheap and must not raise.
+
+### `AgentAssemblyDescriptor`
+
+Frozen dataclass.
+
+| Field | Type | Default |
+| --------------------- | --------------------------------- | ------- |
+| `namespace` | `str` | required |
+| `agent_name` | `str` | required |
+| `requested_model` | `str \| None` | required |
+| `effective_model` | `str` | required |
+| `model_parameters` | `dict[str, Any]` | required |
+| `thinking_enabled` | `bool` | required |
+| `reasoning_effort` | `Any` | required |
+| `base_prompt_hash` | `str` | required |
+| `tools` | `tuple[ToolDescriptor, ...]` | required |
+| `middlewares` | `tuple[MiddlewareDescriptor, ...]` | required |
+| `deferred_tool_names` | `tuple[str, ...]` | required |
+| `enabled_skills` | `tuple[str, ...]` | required |
+| `effective_policies` | `dict[str, Any]` | required |
+| `build` | `dict[str, Any]` | `{}` |
+
+`fingerprint: str` (cached property) is a `canonical_hash` of everything that changes behavior. Tools, deferred tool names, and skills are sorted; middleware order is preserved because it decides what wraps what. `build` and `requested_model` are excluded, so a redeploy of the same assembly keeps the same fingerprint.
+
+### `ToolDescriptor`
+
+Frozen dataclass: `name: str`, `description_hash: str`, `schema_hash: str`, `source: str`, `mcp_server: str | None = None`, `mcp_transport: str | None = None`.
+
+### `MiddlewareDescriptor`
+
+Frozen dataclass: `name: str`, `module: str`, `policy_parameters: dict[str, Any] = {}`, `extension: str | None = None`. `extension` names the contributing extension; it is `None` for host middleware.
+
+## Context compaction
+
+### `ContextCompactionObserver`
+
+```python
+class ContextCompactionObserver(Protocol):
+ async def on_context_compacted(
+ self, app_store: ExtensionData, task_store: ExtensionData, event: CompactionEvent
+ ) -> None: ...
+```
+
+### `CompactionEvent`
+
+Frozen dataclass, captured while both sides of the transform still exist.
+
+| Field | Type |
+| ------------------------- | ----------------- |
+| `transform_kind` | `str` |
+| `transform_version` | `str` |
+| `source_content_hashes` | `tuple[str, ...]` |
+| `output_content_hash` | `str` |
+| `compacted_message_count` | `int` |
+| `kept_message_count` | `int` |
+
+Hashes are `canonical_hash(message.content)` of the content passed directly, never a stringified copy. To match a message to an event, hash its `content` the same way.
+
+## Services
+
+### `ExtensionService`
+
+```python
+class ExtensionService(Protocol):
+ async def start(self, deps: ExtensionRuntimeDeps) -> None: ...
+ async def stop(self) -> None: ...
+```
+
+### `ExtensionRuntimeDeps`
+
+Frozen dataclass passed to `start()`.
+
+| Field | Type | Default |
+| --------------------- | --------------------------- | ---------------------- |
+| `app_store` | `ExtensionData \| None` | `None` |
+| `policy` | `HostPolicySnapshot` | `HostPolicySnapshot()` |
+| `session_factory` | `Any \| None` | `None` |
+| `run_evidence_reader` | `RunEvidenceReader \| None` | `None` |
+
+`run_evidence_reader` is `None` on a host that does not provide one.
+
+## Run evidence
+
+### `RunEvidenceReader`
+
+A read-only Protocol. Its default methods raise `NotImplementedError`.
+
+| Method | Returns |
+| ------------------------------------------------------------------------------------------------ | ------------------------- |
+| `async list_changed_runs(*, cursor: str \| None, limit: int)` | `RunPage` |
+| `async list_run_events(*, thread_id: str, run_id: str, after_seq: int \| None, limit: int)` | `RunEventPage` |
+| `async get_run_status(*, thread_id: str, run_id: str)` | `RunStatusView \| None` |
+
+The Gateway's implementation accepts `limit` from 1 to 2000 and a non-negative `after_seq`, raising `ValueError` otherwise. Deletions produce no tombstone: `get_run_status()` returning `None` means the run is absent or not visible. See [Run Evidence](/docs/harness/extensions/run-evidence).
+
+### `RunStatusView`
+
+Frozen dataclass: `thread_id`, `run_id`, `status`, `created_at`, `updated_at` (all `str = ""`), `error: str | None = None`, `stop_reason: str | None = None`.
+
+### `RunEventView`
+
+Frozen dataclass: `thread_id: str = ""`, `run_id: str = ""`, `seq: int = 0` (monotonic within a thread), `event_type: str = ""`, `category: str = ""`, `content: Any = None`, `metadata: dict[str, Any] = {}`, `created_at: str = ""`. Content and metadata are detached copies. Content is returned unchanged; metadata has only the legacy `auth_token` key removed, with no other redaction.
+
+### `RunPage` / `RunEventPage`
+
+Frozen dataclasses. `RunPage`: `items: tuple[RunStatusView, ...] = ()`, `next_cursor: str | None = None`, `has_more: bool = False`. `RunEventPage`: `items: tuple[RunEventView, ...] = ()`, `next_after_seq: int | None = None`, `has_more: bool = False`.
+
+### `InvalidRunEvidenceCursor`
+
+Subclass of `ValueError`, raised for a malformed or unsupported cursor, or a cursor from another scope.
+
+## Identity
+
+### `ExtensionPrincipal`
+
+Frozen dataclass: `user_id: str`, `is_admin: bool = False`, `is_internal: bool = False`, `roles: tuple[str, ...] = ()`.
+
+### `resolve_principal(request: object) -> ExtensionPrincipal | None`
+
+The authenticated caller of a contributed route, or `None` when it cannot be determined. `request` is duck-typed, so a Starlette `Request` works without the contract depending on Starlette.
+
+### `require_admin(request: object) -> ExtensionPrincipal`
+
+Return the principal if it is an administrator, otherwise raise `PermissionError("this endpoint requires an administrator account")`. It fails closed when identity cannot be determined.
+
+### `EXTENSION_PRINCIPAL_RESOLVER_KEY`
+
+`"deerflow_extension_principal_resolver"`. The `app.state` attribute the host installs its resolver under. Host-owned.
+
+## Message provenance
+
+Middleware that injects messages stamps them, so an observer can tell an injected message from the user's own without matching on wording.
+
+| Constant | Value |
+| -------------------------------- | ------------------------------- |
+| `MESSAGE_CONTENT_KIND_KEY` | `"deerflow_content_kind"` |
+| `MESSAGE_PRODUCER_KIND_KEY` | `"deerflow_producer_kind"` |
+| `MESSAGE_PRODUCER_ENTITY_ID_KEY` | `"deerflow_producer_entity_id"` |
+| `PROVENANCE_KEYS` | `frozenset` of the three keys. The host treats them as server-owned and strips caller-supplied values from untrusted input |
+
+### `ContentKind`
+
+`StrEnum`: `MIDDLEWARE_INJECTION = "middleware_injection"`, `MEMORY = "memory"`, `DURABLE_CONTEXT = "durable_context"`, `SKILL_BODY = "skill_body"`, `IMAGE_PAYLOAD = "image_payload"`. Stamped values are plain strings, so a kind added by a newer host arrives as an unrecognized string rather than an error.
+
+### `MessageProvenance`
+
+Frozen dataclass: `content_kind: str`, `producer_kind: str`, `producer_entity_id: str | None = None`.
+
+### `provenance_kwargs(content_kind, producer_kind, *, producer_entity_id=None) -> dict[str, str]`
+
+The `additional_kwargs` fragment to merge into a message you produce. `producer_entity_id` is omitted when `None`.
+
+### `read_provenance(message: object) -> MessageProvenance | None`
+
+Read a stamp from `message.additional_kwargs`. Returns `None` when either required key is missing or not a string.
+
+## Release policies and hashing
+
+### `ReleasePolicyProvider`
+
+```python
+@runtime_checkable
+class ReleasePolicyProvider(Protocol):
+ def release_policy_parameters(self) -> dict[str, object]: ...
+```
+
+Implement it on a middleware to declare its behavior-affecting parameters. They appear in `MiddlewareDescriptor.policy_parameters` and in the assembly fingerprint. Values must be JSON-serializable; hash long text instead of embedding it.
+
+### `collect_release_policies(middlewares: Sequence[object]) -> dict[str, dict[str, object]]`
+
+Gather declarations from a stack, keyed by class name (`Name`, `Name#2`, ... for repeats), unwrapping isolation wrappers. A declaration that raises is recorded as `{"error": ""}`, and one that returns a non-mapping as `{"error": "NonMappingDeclaration"}`.
+
+### `canonical_json(value: object) -> str`
+
+Deterministic JSON: sorted keys, `(",", ":")` separators, `ensure_ascii=False`. Raises `TypeError` for values that are not JSON-serializable.
+
+### `canonical_hash(value: object) -> str`
+
+SHA-256 hex digest of `canonical_json(value)` encoded as UTF-8.
+
+## Version constant
+
+### `API_VERSION`
+
+The host's contract version as a dotted string, `"0.2.1"` for the contract this page describes. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`.
+
+## Compatibility rules
+
+- **Additive growth.** Every Protocol method has a default implementation and every optional dataclass field has a default. A contract release that adds a method or field does not break extensions built against an earlier one.
+- **Before 1.0**, a minor release may break extensions and a patch release is additive. **From 1.0 on**, breaking changes bump the major.
+- **The `@extension(api=...)` check.** When an install function carries a marker, the host refuses it unless:
+ - before 1.0: the same major and minor, and the host's version is at least the declared one (a `0.2.1` host accepts `0.2.0` and `0.2.1`, and rejects `0.2.2`, `0.1.x`, and `0.3.x`);
+ - from 1.0: the same major, and the host's version is at least the declared one.
+
+ A marker that is not a dotted numeric string is refused. An install function without a marker is not checked.
+- **Package metadata** is the primary mechanism: declare `deerflow-extension-api>=0.2,<0.3` so the resolver rejects a mismatched host before anything loads. The marker covers installs that bypass resolution.
+- **Declare frameworks yourself.** The contract depends on nothing. An extension that imports LangChain, LangGraph, or FastAPI declares them.
+
+## Version history
+
+| Version | PR | Added |
+| ------- | -- | ----- |
+| 0.1.0 | [#4636](https://github.com/bytedance/deer-flow/pull/4636) | The foundation: `install()` and `@extension`, `ExtensionRegistry.middlewares`, `MiddlewareContributor`, `MiddlewarePlacement`, `Placement`, `AgentScope`, `AgentBuildContext`, `HostPolicySnapshot`, `ExtensionData`, `task_store_from_runtime`, `EXTENSION_TASK_STORE_KEY`, `API_VERSION` |
+| 0.1.1 | [#4684](https://github.com/bytedance/deer-flow/pull/4684) | `task_lifecycle` and `system_model_observer` registrations with `TaskLifecycleContributor`, `TaskInfo`, `TaskOutcome`, `SystemModelCallObserver`, `SystemModelRequest`, `SystemModelResult`, `SystemOperationKind` |
+| 0.1.2 | [#4780](https://github.com/bytedance/deer-flow/pull/4780) | `service` and `routers` registrations with `ExtensionService` and `ExtensionRuntimeDeps`; the packaged-extension manager |
+| 0.2.0 | [#4863](https://github.com/bytedance/deer-flow/pull/4863) | `agent_assembly_observer` and `context_compaction_observer` with `AgentAssemblyDescriptor`, `ToolDescriptor`, `MiddlewareDescriptor`, `CompactionEvent`; message provenance; release policies and canonical hashing; `ExtensionPrincipal`, `resolve_principal`, `require_admin` |
+| 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader` with `RunEvidenceReader`, `RunPage`, `RunEventPage`, `RunStatusView`, `RunEventView`, `InvalidRunEvidenceCursor` |
+
+No release has removed a public name.
diff --git a/frontend/src/content/en/harness/extensions/run-evidence.mdx b/frontend/src/content/en/harness/extensions/run-evidence.mdx
new file mode 100644
index 000000000..4c2afa39f
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/run-evidence.mdx
@@ -0,0 +1,240 @@
+---
+title: Run Evidence
+description: The read-only reader that lets a service find runs that changed and read their persisted events. Covers getting the reader, its three methods and their return types, cursor semantics, deletions, redaction, backend differences, and a polling service.
+---
+
+import { Callout } from "nextra/components";
+
+# Run Evidence
+
+Middleware and lifecycle hooks see a run while it happens. A **run evidence reader** lets an extension look at runs afterwards, from the records the Gateway has already persisted: which runs exist, what state they ended in, and the event stream each one wrote. It is how an extension builds an export, an audit index, or a dashboard without hooking into every call.
+
+The reader is read-only. No method writes to the host.
+
+## Getting the reader
+
+The Gateway hands one reader to every service, as `ExtensionRuntimeDeps.run_evidence_reader`:
+
+```python
+class MyService:
+ async def start(self, deps):
+ reader = deps.run_evidence_reader
+ if reader is None:
+ return # this host does not provide run evidence
+```
+
+The Gateway always provides it. `None` means the extension is running on a host that does not implement the reader. Treat that as "unsupported", never as "no runs". Service lifetime and ordering are covered in [Services and Routes](/docs/harness/extensions/services-and-routes); the reader is usable from the moment `start()` is called until `stop()` returns.
+
+
+ The reader has **global** visibility: it sees every user's runs and events.
+ Services have no request principal, so the Gateway binds this reader to no
+ user on purpose. Event content is returned as stored. Never return data from
+ it to the caller of a route.
+
+
+## The reader interface
+
+`RunEvidenceReader` has three async methods, all keyword-only:
+
+| Method | Returns | Use |
+| --------------------------------------------------------------- | ----------------------- | ---------------------------------------------------- |
+| `list_changed_runs(cursor=..., limit=...)` | `RunPage` | Discover runs created or changed since a cursor |
+| `list_run_events(thread_id=..., run_id=..., after_seq=..., limit=...)` | `RunEventPage` | Read one run's persisted events, forward from a sequence number |
+| `get_run_status(thread_id=..., run_id=...)` | `RunStatusView \| None` | Read the authoritative status of one known run |
+
+`limit` must be an integer from 1 to 2000; anything else raises `ValueError`. `after_seq` must be `None` or a non-negative integer.
+
+### Return types
+
+All return types are frozen dataclasses from `deerflow_extension_api`.
+
+**`RunStatusView`**: one run's lifecycle state.
+
+| Field | Meaning |
+| ------------- | ---------------------------------------------------------------------------------- |
+| `thread_id`, `run_id` | The run's identity |
+| `status` | `pending`, `running`, `success`, `error`, `timeout`, or `interrupted` |
+| `created_at`, `updated_at` | Timestamps as strings |
+| `error` | The error message, when the run failed |
+| `stop_reason` | Why the run stopped early, when it did |
+
+**`RunEventView`**: one persisted event.
+
+| Field | Meaning |
+| ---------------- | -------------------------------------------------------------------- |
+| `thread_id`, `run_id` | The run the event belongs to |
+| `seq` | Sequence number, increasing within the **thread** |
+| `event_type`, `category` | The event's kind, as the event store recorded it |
+| `content` | The event payload, unchanged |
+| `metadata` | Event metadata, with the legacy `auth_token` key removed |
+| `created_at` | Timestamp as a string |
+
+**`RunPage`**: `items` (a tuple of `RunStatusView`), `next_cursor`, and `has_more`.
+
+**`RunEventPage`**: `items` (a tuple of `RunEventView`), `next_after_seq`, and `has_more`.
+
+`content` and `metadata` are deep copies. The dataclass fields are frozen, but you may modify the nested dicts and lists you receive without affecting host storage.
+
+## Discovering changed runs
+
+`list_changed_runs` returns runs in a stable order, oldest change first. Page through it by passing each page's `next_cursor` into the next call:
+
+- `cursor=None` starts from the beginning.
+- `has_more=True` means another page is available right now.
+- An empty page means you are caught up. Its `next_cursor` is the cursor you passed in, so keep it and poll again later.
+
+Only agent runs appear. Other operations the host records against a thread, such as checkpoint writes, artifact writes, branching, and deletion, are excluded from the feed and from `get_run_status`.
+
+A run appears again every time it changes. The feed does not contain one entry per run; it contains the **current** state of each run that changed after your cursor. A run created, started, and finished between two polls shows up once, already finished.
+
+### What counts as a change
+
+Each run has a change position that the host advances when the run is created, changes lifecycle state, is cancelled, or has its model name updated. Progress snapshots and lease heartbeats do not advance it, so a long-running run does not flood the feed while it works.
+
+Runs that existed before change tracking was added have position zero. They come first, ordered by run ID.
+
+### Cursor rules
+
+The cursor is an opaque string. Store it, pass it back, and do not parse it.
+
+- **Replay, never skip.** Reusing a cursor is always valid and may return runs you have seen before. A run that changes after you received it comes back with its new state. A run that you have not received yet cannot be skipped.
+- **Commit after output.** Save `next_cursor` only after the work for that page is durable. If you crash in between, the next poll replays the page. Make your processing idempotent, for example by overwriting a per-run record instead of appending to it.
+- **Scope-bound.** A cursor belongs to the reader that issued it. Passing a cursor to a reader with a different visibility scope, a malformed cursor, or one from an unsupported version raises `InvalidRunEvidenceCursor`, a subclass of `ValueError`. Recover by starting again from `None`, which replays everything visible.
+
+### Deletions
+
+The feed does **not** report deletions. A deleted run disappears from future pages and from `get_run_status`, but nothing tells you it is gone. If your extension mirrors runs and must drop deleted ones, periodically call `get_run_status` for the runs you know about and treat `None` as deleted.
+
+`get_run_status` returns `None` whenever the run is not visible: it does not exist, it was deleted, the `thread_id` does not match the run, or it is outside the reader's scope.
+
+## Reading a run's events
+
+`list_run_events` pages forward through one run's persisted events:
+
+- `after_seq=None` starts at the first event. Pass each page's `next_after_seq` to continue.
+- `seq` values are increasing but not contiguous within a run, because the counter is shared by every run in the thread. Always continue from `next_after_seq` rather than computing the next number.
+- A run that does not exist or is not visible returns an empty page, never an error, so a caller cannot probe for run IDs outside its scope.
+
+Status always comes from the run store, which is authoritative. Do not infer a run's final state from its last event.
+
+## Storage backends
+
+| Setting | Run positions and cursors |
+| ---------------------------------------- | ----------------------------------------------------------------------------------------- |
+| `database.backend: sqlite` or `postgres` | Stored in the database. Positions and cursors stay valid across Gateway restarts |
+| `database.backend: memory` | Kept in process memory. All runs and positions are lost on restart |
+
+Events come from the configured `run_events` store (`memory`, `db`, or `jsonl`) and use its own `seq` numbering.
+
+
+ With the memory backend, **do not persist a cursor across restarts**. A new
+ process numbers changes from zero again, and an old cursor points past all of
+ them, so the reader returns empty pages until the new process catches up with
+ the saved position. Start from `None` after every restart, or keep the cursor
+ in memory only.
+
+
+## Example: a run digest service
+
+This service polls the reader and records, for every finished run, how many events of each type it persisted. It keeps its cursor in memory, so it rescans from the beginning after a restart. A real exporter would store the cursor next to its output.
+
+```python filename="deerflow_extension_digest/__init__.py"
+"""Count persisted event types for every finished run."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections import Counter
+from collections.abc import Mapping
+from typing import Any
+
+from deerflow_extension_api import (
+ ExtensionRegistry,
+ ExtensionRuntimeDeps,
+ InvalidRunEvidenceCursor,
+ RunEvidenceReader,
+ extension,
+)
+
+logger = logging.getLogger(__name__)
+
+TERMINAL = {"success", "error", "timeout", "interrupted"}
+
+
+class RunDigestService:
+ def __init__(self, interval_seconds: float) -> None:
+ self.interval_seconds = interval_seconds
+ self.cursor: str | None = None
+ self.digests: dict[str, Counter[str]] = {}
+ self._task: asyncio.Task[None] | None = None
+
+ async def start(self, deps: ExtensionRuntimeDeps) -> None:
+ reader = deps.run_evidence_reader
+ if reader is None:
+ logger.warning("run evidence is not available on this host; digest disabled")
+ return
+ self._task = asyncio.create_task(self._poll(reader))
+
+ async def stop(self) -> None:
+ if self._task is not None:
+ self._task.cancel()
+ await asyncio.gather(self._task, return_exceptions=True)
+ self._task = None
+
+ async def _poll(self, reader: RunEvidenceReader) -> None:
+ while True:
+ try:
+ await self.sync_once(reader)
+ except Exception:
+ logger.exception("run digest sync failed; retrying")
+ await asyncio.sleep(self.interval_seconds)
+
+ async def sync_once(self, reader: RunEvidenceReader) -> None:
+ while True:
+ try:
+ page = await reader.list_changed_runs(cursor=self.cursor, limit=100)
+ except InvalidRunEvidenceCursor:
+ logger.warning("stored cursor rejected; rescanning from the beginning")
+ self.cursor = None
+ continue
+ for run in page.items:
+ if run.status in TERMINAL:
+ self.digests[run.run_id] = await self._count_events(reader, run.thread_id, run.run_id)
+ # Advance only after this page's output is recorded. Replaying a
+ # page is harmless because each digest is recomputed, not appended.
+ self.cursor = page.next_cursor
+ if not page.has_more:
+ return
+
+ async def _count_events(self, reader: RunEvidenceReader, thread_id: str, run_id: str) -> Counter[str]:
+ counts: Counter[str] = Counter()
+ after_seq: int | None = None
+ while True:
+ page = await reader.list_run_events(thread_id=thread_id, run_id=run_id, after_seq=after_seq, limit=500)
+ counts.update(event.event_type for event in page.items)
+ after_seq = page.next_after_seq
+ if not page.has_more:
+ return counts
+
+
+@extension(api="0.2.0", name="digest")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ registry.service(RunDigestService(float(config.get("interval_seconds", 30))))
+```
+
+Points worth copying:
+
+- `start()` only creates the polling task and returns, so Gateway startup is not held up.
+- `stop()` cancels the task and waits for it, well within the 30-second stop budget.
+- The digest for a run is recomputed each time the run appears, so replayed pages cause no double counting.
+- `InvalidRunEvidenceCursor` resets the cursor to `None` instead of stopping the service.
+- An exception in one sync is logged, and the next poll resumes from the last committed cursor.
+
+## Common pitfalls
+
+- **Treating an empty page as unsupported.** Empty means caught up. Unsupported is `run_evidence_reader is None`.
+- **Advancing the cursor before the work is saved.** A crash then skips a page permanently. Save after.
+- **Expecting one entry per run.** A run reappears each time it changes. Key your output by `run_id`.
+- **Waiting for a deletion event.** There is none. Reconcile with `get_run_status`.
+- **Serving reader data to users.** The reader sees every user. Never return its data from a route.
diff --git a/frontend/src/content/en/harness/extensions/runtime.mdx b/frontend/src/content/en/harness/extensions/runtime.mdx
new file mode 100644
index 000000000..ca8f7fe34
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/runtime.mdx
@@ -0,0 +1,203 @@
+---
+title: Runtime Model
+description: How the Gateway loads extensions at startup, how one immutable snapshot of them follows each run into its subagents, the app and task scopes and their ExtensionData stores, the notification loop and fail-open rules, and where diagnostics go.
+---
+
+import { Callout } from "nextra/components";
+
+# Runtime Model
+
+This chapter describes what the host does with an extension after `install()` returns: when each piece of state exists, which snapshot a run uses, and what happens when a callback is slow or fails. Read it before writing any contribution that keeps state.
+
+## Loading
+
+Extensions load exactly once, while the Gateway constructs its FastAPI application. The sequence is:
+
+1. The Gateway reads the `plugins:` list from `config.yaml`. A `config.yaml` that exists but does not validate is a configuration error and stops startup; only a missing file is tolerated, and it loads no extensions.
+2. For each entry, in list order:
+ 1. If the entry declares `table_prefix`, the prefix is registered first, even when the entry is disabled. See [Table prefixes](#table-prefixes).
+ 2. If `enabled` is `false`, the entry is skipped without importing anything.
+ 3. The `use` path is imported and must resolve to a callable.
+ 4. The `@extension(api=...)` marker, if present, is checked against the host's `API_VERSION`. See [Version check](#version-check).
+ 5. `install(registry, config)` runs with a shallow copy of the entry's `config`.
+3. The finished registry is frozen into an immutable `LoadedExtensions` value, published as the process-wide set, and stored on `app.state.extensions`.
+4. After every host route is mounted, contributed routers are mounted.
+
+The Gateway logs one summary line when loading finishes:
+
+```text
+Extensions loaded: 2/3 (acme_audit:install, acme_costs:install)
+```
+
+The count includes disabled entries in the total, so `2/3` can mean one failure or one disabled entry. Each failure has its own `Extension : ...` error line.
+
+### Positional rollback
+
+Everything an `install()` registers is attributed to its entry. If `install()` raises partway through, the host removes exactly the registrations made since that call started and moves on to the next entry. A half-registered extension would be worse than an absent one, because the data it produced would look complete.
+
+Rollback is positional rather than by name, so two entries may share the same `use` with different `config`. A failing second instance never removes the first instance's registrations.
+
+### `required`
+
+With the default `required: false`, every failure in steps 2.3 to 2.5 is logged and the extension is skipped. With `required: true`, the same failure raises `ExtensionLoadError` and the Gateway does not start:
+
+```text
+ExtensionLoadError: required extension acme_audit:install failed to install
+```
+
+Reserve `required: true` for extensions whose absence makes the deployment wrong, such as a mandatory audit trail. Recovering needs shell access to edit `config.yaml`.
+
+### Version check
+
+A missing marker skips the check. Otherwise the rule before 1.0 is: same major and minor as the host, and a patch no newer than the host's. From 1.0 on it will be: same major, and a minor and patch no newer than the host's. With the host at `0.2.1`:
+
+| `@extension(api=...)` | Result |
+| --------------------- | ------------------------------------------------------------------------------- |
+| not decorated | Loaded |
+| `"0.2"`, `"0.2.0"`, `"0.2.1"` | Loaded |
+| `"0.2.2"` | Refused: the extension may use additions this host lacks |
+| `"0.1.9"`, `"0.3.0"`, `"1.0"` | Refused: a different pre-1.0 minor or a different major |
+| `"0.2.x"` | Refused: not a dotted numeric version |
+
+A refusal names the range to install:
+
+```text
+Extension acme_audit:install: extension requires extension-api 0.2.2, host provides 0.2.1. Install a matching version: pip install 'deerflow-extension-api>=0.2.2,<0.3'
+```
+
+The marker is a safety net for `--no-deps` installs and editable checkouts. Your package's `deerflow-extension-api` dependency range is the primary compatibility mechanism.
+
+### Table prefixes
+
+An extension that keeps its own database tables, under its own SQLAlchemy `MetaData` and migration chain, declares the prefix it owns:
+
+```yaml filename="config.yaml"
+plugins:
+ - name: acme
+ use: acme_audit:install
+ table_prefix: acme_
+```
+
+The host excludes tables with that prefix from `alembic revision --autogenerate`, so a host migration never proposes to drop them. The prefix is registered even for a disabled or failing entry, because the tables may already exist. An empty prefix is rejected when the config is validated. A prefix that collides with a host table name always aborts startup, whatever `required` says.
+
+## Snapshots
+
+`LoadedExtensions` is immutable. Each lead run resolves it once when the run starts and uses that same object for its task store, its lifecycle notifications, and agent construction. The run also publishes the snapshot on its runtime context, and the `task` tool reads it back, so every subagent the run delegates to is built from the same extension generation as the Lead Agent. A caller-supplied value under that host-internal key is never trusted.
+
+Callers that do not publish a snapshot, such as the embedded `DeerFlowClient` or a standalone LangGraph Server, fall back to the process-wide set.
+
+Because loading is startup-only, the process-wide set does not change while a Gateway runs. The per-run binding matters for hosts and tests that replace it, and it guarantees that one run never mixes two generations.
+
+## Scopes and stores
+
+The host hands extensions `ExtensionData` stores instead of letting them keep global state. Each store belongs to one scope and is discarded when that scope ends.
+
+| Scope | `scope_id` | Created | Dropped |
+| -------- | -------------------------------------------------- | ---------------------------------------------------- | ------------------------------- |
+| App | `"app"` | When the registry is frozen at startup | With the Gateway process |
+| Lead task | The run id | When the run starts, before `on_task_start` | After `on_task_stop` |
+| Subagent task | The delegating tool-call id, or the subagent execution id when there is none | Before the subagent's `on_task_start` | After its `on_task_stop` |
+| Detached | `"detached"` | For one notification with no live task | After that notification |
+
+Every callback receives the store for the current scope, so you never need to capture one or check whether it is stale.
+
+A task store is allocated only when at least one middleware contributor, task-lifecycle contributor, system-model observer, or context-compaction observer is registered. An extension that registers only services, routers, or assembly observers costs a run nothing.
+
+The app store is shared by every extension in the process. It is safe because stores are keyed by type, as described below.
+
+### Detached stores
+
+Some notifications have no live task. Memory extraction normally runs on a background thread after the run, and compaction observers are always dispatched outside the turn. These receive a fresh store whose `scope_id` is `"detached"`. Anything written to it is discarded as soon as the notification finishes, so write to the app store if the value must outlive the call.
+
+## ExtensionData
+
+```python
+class ExtensionData:
+ scope_id: str
+ def get(self, typ: type[T]) -> T | None
+ def get_or_init(self, typ: type[T], init: Callable[[], T]) -> T
+ def set(self, value: T) -> None
+ def remove(self, typ: type[T]) -> T | None
+```
+
+- **Keyed by type.** `set(value)` stores under `type(value)` exactly; subclasses are separate keys. Define a small class for each value you keep. Two extensions cannot collide unless they share a class.
+- **Thread-safe.** Every method holds a re-entrant lock, so the same store can be used from middleware, lifecycle hooks, and observers on different threads.
+- **`init` runs under the lock.** `get_or_init` calls `init()` while holding the lock, so two concurrent callers never create two values. Keep `init` cheap: construct an empty container and do heavy lazy work inside it.
+- **Values are yours to synchronize.** The store protects its own map, not the objects in it. A counter updated from concurrent tool calls needs its own lock, as the [bundled example](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example) does.
+
+```python
+from dataclasses import dataclass, field
+from threading import Lock
+
+
+@dataclass
+class ToolCallCount:
+ value: int = 0
+ _lock: Lock = field(default_factory=Lock, repr=False)
+
+ def increment(self) -> None:
+ with self._lock:
+ self.value += 1
+
+
+counts = task_store.get_or_init(ToolCallCount, ToolCallCount)
+counts.increment()
+```
+
+## Host policy
+
+`HostPolicySnapshot` is a narrow projection of the limits the host enforces. It reaches extensions through `AgentBuildContext.policy` and `ExtensionRuntimeDeps.policy`. Every field has a default, so later additions stay compatible.
+
+| Field | Source in `config.yaml` | When not enabled |
+| ----------------------- | ------------------------------------------------ | ---------------- |
+| `token_budget_enabled` | `token_budget.enabled` | `False` |
+| `max_input_tokens` | `token_budget.max_input_tokens` | `None` |
+| `max_output_tokens` | `token_budget.max_output_tokens` | `None` |
+| `max_total_tokens` | `token_budget.max_tokens` | `None` |
+| `budget_warn_fraction` | `token_budget.warn_threshold` | `None` |
+| `budget_hard_fraction` | `token_budget.hard_stop_threshold` | `None` |
+| `max_subagents_per_run` | The effective per-run delegation total for the Lead Agent | `None` for subagents |
+
+The five token fields are `None` whenever the token budget is disabled. A subagent's build context projects the subagent budget (`subagents.token_budget`, or the per-agent override when one is configured) instead of the Lead Agent's.
+
+## Notifications and fail-open
+
+Task-lifecycle hooks and observers are notified through one helper with the same rules everywhere:
+
+- **Registration order.** Contributors run one after another in the order they were registered.
+- **Isolation.** An exception from one contributor is logged with its entry point and does not skip the next one.
+- **One shared budget.** Task-lifecycle notifications get **3 seconds** for all contributors together, for both lead runs and subagents. A contributor still running when the budget runs out is cancelled, and every contributor after it is skipped with a warning:
+
+ ```text
+ Extension acme_audit:install: on_task_start timed out for task 7f3c...; the 3.0s notification budget was spent
+ Extension acme_costs:install: on_task_start skipped for task 7f3c...; the 3.0s notification budget was spent
+ ```
+
+ The budget applies to each of `on_task_start` and `on_task_stop` separately. Keep lifecycle hooks to bookkeeping and hand slow work to a [service](/docs/harness/extensions/services-and-routes).
+- **One loop.** The Gateway registers its serving event loop as the extension notification loop. Subagents can run on their own event loop, but their lifecycle hooks and observer notifications are dispatched to the Gateway loop. That way an extension only ever touches the resources it started, such as clients and connection pools, on the loop that owns them.
+
+### Cancellation
+
+`asyncio.CancelledError` reaches a contributor for two unrelated reasons, and the host tells them apart by where it came from:
+
+- **The host task is being cancelled**, for example because the user pressed stop or the Gateway is shutting down. The error propagates and the remaining contributors are not called.
+- **The contributor raised it itself**, for example through an internal timeout built on cancellation. The error is contained like any other exception, logged as `raised CancelledError`, and the next contributor still runs. A self-inflicted cancellation cannot end an otherwise successful run as cancelled.
+
+`KeyboardInterrupt` and `SystemExit` always propagate.
+
+### Shutdown
+
+At shutdown the Gateway stops accepting new fire-and-forget observations, such as memory, compaction, and cancelled system calls, before it flushes memory. It keeps the notification loop until in-flight runs and subagents have drained, so their `on_task_stop` hooks still run.
+
+## Diagnostics
+
+A diagnostic is a problem attributed to one extension. Every diagnostic is written to the Gateway log with the entry point as its prefix. Load failures, middleware construction and isolation failures, router mount rejections, and service start and stop failures are also collected in a live list on `app.state.extension_diagnostics`, which keeps the most recent **1000** entries. There is no HTTP endpoint for it, so operators read the Gateway log.
+
+Failures of task-lifecycle hooks and observers are logged but not added to that list.
+
+
+ To confirm an extension loaded, look for the `Extensions loaded: N/M (...)`
+ line at startup. Its absence means the Gateway never read a `plugins:` list,
+ for example because the entries are in `extensions_config.json` instead of
+ `config.yaml`.
+
diff --git a/frontend/src/content/en/harness/extensions/services-and-routes.mdx b/frontend/src/content/en/harness/extensions/services-and-routes.mdx
new file mode 100644
index 000000000..40106d7f2
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/services-and-routes.mdx
@@ -0,0 +1,254 @@
+---
+title: Services and Routes
+description: How an extension runs code for the lifetime of the Gateway and serves its own HTTP API. Covers the service start and stop order, the dependencies a service receives, failure and timeout behavior, how contributed routers are validated and mounted, authentication and CSRF, and identifying the caller.
+---
+
+import { Callout } from "nextra/components";
+
+# Services and Routes
+
+A **service** is an object the Gateway starts once its persistence layer is ready and stops at shutdown. A **router** is a FastAPI `APIRouter` the Gateway mounts next to its own API. They are separate contributions, but most extensions that serve HTTP need both: the router declares the paths, and the service holds whatever those paths read at runtime.
+
+Both are app-scoped. They exist once per Gateway process, not once per run. For per-run behavior, see [Middleware Contributions](/docs/harness/extensions/middleware).
+
+## Services
+
+### The contract
+
+```python
+from deerflow_extension_api import ExtensionRuntimeDeps
+
+
+class MyService:
+ async def start(self, deps: ExtensionRuntimeDeps) -> None: ...
+
+ async def stop(self) -> None: ...
+
+
+def install(registry, config):
+ registry.service(MyService())
+```
+
+Both methods are async and both have defaults in the protocol, so a service may implement only the one it needs.
+
+### What start() receives
+
+Every service receives the same `ExtensionRuntimeDeps` snapshot:
+
+| Field | Type | Meaning |
+| --------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `app_store` | `ExtensionData` | The app-scoped typed store, the same object middleware contributors and lifecycle hooks receive as `app_store` |
+| `policy` | `HostPolicySnapshot` | The limits the host enforces: token-budget settings (populated only when `token_budget.enabled`) and `max_subagents_per_run` from `subagents.max_total_per_run` |
+| `session_factory` | SQLAlchemy `async_sessionmaker` or `None` | The Gateway's database session factory. `None` when `database.backend` is `memory` |
+| `run_evidence_reader` | `RunEvidenceReader` or `None` | A read-only view of every user's runs and persisted events. Never return its data from a route. See [Run Evidence](/docs/harness/extensions/run-evidence) |
+
+`session_factory` is the host's own database connection, not a sandboxed one. A service that uses it can read and write every host table. If your extension keeps its own tables, declare a `table_prefix` in its `plugins:` record so that `alembic revision --autogenerate` leaves them alone.
+
+### When services start and stop
+
+Services start during Gateway startup in registration order, which follows the `plugins:` list and, within one extension, the order of `registry.service()` calls. The position in the startup sequence is fixed:
+
+1. The database engine, checkpointer, and store are initialized.
+2. The run store and run event store are created, and the evidence reader is bound to them.
+3. **Services start**, one at a time, each awaited before the next.
+4. The rest of the runtime comes up: thread store, run manager, recovery of interrupted runs, the lease heartbeat. Only then does the Gateway accept requests.
+
+Shutdown runs in the opposite direction:
+
+1. In-flight runs and subagents are drained.
+2. **Services stop in reverse registration order.**
+3. Stores, the checkpointer, and the database engine are closed.
+
+So a service can use `session_factory` and `run_evidence_reader` from `start()` until `stop()` returns, and no run is executing when `stop()` is called.
+
+### Failure and timeouts
+
+| What happens | Result |
+| ------------------------------------- | ---------------------------------------------------------------------------------------- |
+| `start()` raises | Logged as `Extension : service start() failed; continuing without it: ...`. The next service starts normally |
+| `start()` raises `CancelledError` itself | Treated like any other failure. A real cancellation of Gateway startup still propagates |
+| `start()` never returns | There is no start timeout. The Gateway waits, and startup does not complete |
+| `stop()` raises | Logged; the remaining services still stop |
+| `stop()` takes longer than 30 seconds | Cancelled and logged as `service stop() timed out after 30.0s; continuing shutdown`. Each service has its own 30-second budget |
+
+A service whose `start()` failed still gets `stop()` at shutdown, because `start()` may have acquired resources before failing. Write `stop()` so it is safe to call on a half-started service.
+
+
+ Do long-running work in a background task that `start()` creates, not inside
+ `start()` itself. A `start()` that blocks on a slow network call holds up
+ Gateway startup for as long as it waits.
+
+
+## Routers
+
+### Registering a router
+
+Build routers **inside `install()`** and register them eagerly:
+
+```python
+def install(registry, config):
+ service = MyService()
+ registry.service(service)
+ registry.routers((build_router(service),))
+```
+
+`registry.routers()` takes a sequence of `APIRouter` objects. The contract types them as `Any` so the contract package has no FastAPI dependency; declare `fastapi` in your own package metadata.
+
+The router exists before the service starts and before any request arrives, so the path set is fixed at startup. Route handlers reach runtime state through the service object they close over.
+
+### How routers are mounted
+
+The Gateway mounts contributed routers **after every host route**, so a host handler always wins a match. Before mounting each router it checks every route. A router with any rejected route is rejected **as a whole**; other routers, including other routers from the same extension, still mount.
+
+| Rejected | Operator sees |
+| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| A WebSocket route | `contributed WebSocket routes are not supported until the host can apply authentication and Origin checks` |
+| A Starlette `Mount` | `contributed router contains a Starlette Mount, which FastAPI.include_router() ignores` |
+| `on_startup` / `on_shutdown` hooks or a custom `lifespan` on the router | `contributed router lifecycle hooks are not supported; register an ExtensionService instead` |
+| A path that can reach a host public namespace: `/health`, `/docs`, `/redoc`, `/openapi.json`, `/api/v1/auth/oauth/`, `/api/v1/auth/callback/`, `/api/webhooks/` | `contributed route can enter a host public namespace` |
+| A path that can reach a host auth endpoint that skips authentication or CSRF, such as `/api/v1/auth/register`, or a state-changing method on `/api/v1/auth/me` | `contributed route can enter a host-reserved exact path` |
+| A path and method already served by the host or an earlier extension | `router path is already served by ; this router was not mounted` |
+
+Each message is logged as an error prefixed with `Extension :`. When mounting succeeds the Gateway logs `Extension routers mounted: -> ; ...`.
+
+The shadow check is conservative. It rejects a route only when an earlier route **provably** covers it for the same method: identical paths, or parameter segments whose converter matches every value the new route could receive. A pattern it cannot prove either way is allowed. A catch-all such as `/api/{name}` therefore mounts, but it only receives requests no host route matched. Prefix your paths with a namespace you own, such as `/api//`.
+
+### Authentication and CSRF
+
+Contributed routes sit behind the same middleware as the host API, and they cannot opt out:
+
+- **Authentication.** An unauthenticated request gets `401` before your handler runs. A personal access token (PAT) cannot access contributed routes: even a valid PAT gets `403 {"detail": "PAT credentials are not permitted on this route"}` from the host before your handler runs.
+- **CSRF.** A `POST`, `PUT`, `PATCH`, or `DELETE` from a browser session must carry the `csrf_token` cookie value in an `X-CSRF-Token` header, as the DeerFlow frontend already does. Without it the request gets `403` before your handler runs. A Bearer header skips the CSRF check, but does not grant PAT access to contributed routes.
+
+When the Gateway runs with `DEER_FLOW_AUTH_DISABLED=1`, a local-development switch that is ignored when `DEER_FLOW_ENV` or `ENVIRONMENT` is `prod` or `production`, every request runs as a synthetic admin user and neither check applies.
+
+### Identifying the caller
+
+Handlers never see the host's auth objects. Instead, `deerflow_extension_api` gives them a projection:
+
+```python
+@dataclass(frozen=True)
+class ExtensionPrincipal:
+ user_id: str
+ is_admin: bool = False
+ is_internal: bool = False
+ roles: tuple[str, ...] = ()
+```
+
+| Helper | Returns |
+| --------------------------- | ------------------------------------------------------------------------------------------ |
+| `resolve_principal(request)` | The caller's `ExtensionPrincipal`, or `None` when the host cannot determine it |
+| `require_admin(request)` | The principal when it is an admin. Otherwise raises `PermissionError`, including when the identity is unknown |
+
+`roles` holds the caller's single system role, such as `("admin",)` or `("user",)`. `is_internal` is `true` for requests the Gateway's own components send with its internal service token, such as the IM channel bridge; those callers carry the role `internal` and are not admins. PAT requests are rejected before contributed route handlers run, so these helpers do not receive a PAT principal here.
+
+Both helpers are synchronous, so they work in sync and async handlers alike. They are framework-neutral, so map their results to HTTP status codes yourself: `None` to `401`, `PermissionError` to `403`.
+
+## Example: a status API
+
+This extension serves two routes. `GET /api/ext-status/me` is open to every signed-in user. `POST /api/ext-status/reset` requires an admin. Both return `503` until the service has started.
+
+```python filename="deerflow_extension_status/__init__.py"
+"""Expose a small status API backed by a Gateway-lifetime service."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from deerflow_extension_api import (
+ ExtensionPrincipal,
+ ExtensionRegistry,
+ ExtensionRuntimeDeps,
+ extension,
+ require_admin,
+ resolve_principal,
+)
+from fastapi import APIRouter, Depends, HTTPException, Request
+
+
+class StatusService:
+ def __init__(self) -> None:
+ self._deps: ExtensionRuntimeDeps | None = None
+ self.resets = 0
+
+ async def start(self, deps: ExtensionRuntimeDeps) -> None:
+ self._deps = deps
+
+ async def stop(self) -> None:
+ self._deps = None
+
+ def require_running(self) -> ExtensionRuntimeDeps:
+ if self._deps is None:
+ raise HTTPException(status_code=503, detail="status extension is not running")
+ return self._deps
+
+
+def caller(request: Request) -> ExtensionPrincipal:
+ principal = resolve_principal(request)
+ if principal is None:
+ raise HTTPException(status_code=401, detail="unknown caller")
+ return principal
+
+
+def admin(request: Request) -> ExtensionPrincipal:
+ try:
+ return require_admin(request)
+ except PermissionError as exc:
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
+
+
+def build_router(service: StatusService) -> APIRouter:
+ router = APIRouter(prefix="/api/ext-status", tags=["ext-status"])
+
+ @router.get("/me")
+ async def me(
+ principal: ExtensionPrincipal = Depends(caller),
+ deps: ExtensionRuntimeDeps = Depends(service.require_running),
+ ) -> dict[str, Any]:
+ return {
+ "user_id": principal.user_id,
+ "is_admin": principal.is_admin,
+ "database": deps.session_factory is not None,
+ "evidence": deps.run_evidence_reader is not None,
+ }
+
+ @router.post("/reset")
+ async def reset(
+ principal: ExtensionPrincipal = Depends(admin),
+ deps: ExtensionRuntimeDeps = Depends(service.require_running),
+ ) -> dict[str, int]:
+ service.resets += 1
+ return {"resets": service.resets}
+
+ return router
+
+
+@extension(api="0.2.0", name="status")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ service = StatusService()
+ registry.service(service)
+ registry.routers((build_router(service),))
+```
+
+The `503` guard is not dead code. If `start()` fails, the Gateway starts without the service, but the router is still mounted. The routes then answer `503` instead of failing on missing state.
+
+Against a Gateway with the memory database backend, the routes answer:
+
+| Request | Response |
+| ------------------------------------------------------ | ------------------------------------------------------------------------ |
+| `GET /me`, no session | `401 {"detail": {"code": "not_authenticated", ...}}` from the host |
+| `POST /reset`, signed in, no `X-CSRF-Token` | `403 {"detail": "CSRF token missing. Include X-CSRF-Token header."}` from the host |
+| `GET /me`, signed in as a regular user | `200 {"user_id": "...", "is_admin": false, "database": false, "evidence": true}` |
+| `POST /reset`, regular user | `403 {"detail": "this endpoint requires an administrator account"}` |
+| `GET /me` or `POST /reset`, personal access token | `403 {"detail": "PAT credentials are not permitted on this route"}` from the host |
+| `POST /reset`, admin session with CSRF header | `200 {"resets": 1}` |
+
+## Common pitfalls
+
+- **Opening resources in `install()`.** `install()` runs while the Gateway builds its application, before the database exists. Build routers there, but open connections and start tasks in `start()`.
+- **Router lifespan hooks.** They are rejected. Anything with a lifetime belongs in a service.
+- **Generic paths.** Anything under a host namespace is rejected, and a path another extension registered first wins. Use a prefix you own.
+- **Treating `resolve_principal` as authentication.** The host already rejected unauthenticated requests. Use the principal for authorization within your routes, and fail closed when it is `None`.
+- **Assuming admin from a token.** Personal access tokens never grant admin through `require_admin`, by design.
+- **Returning run evidence from a route.** The service reader is global: it sees every user's runs.
diff --git a/frontend/src/content/en/harness/extensions/troubleshooting.mdx b/frontend/src/content/en/harness/extensions/troubleshooting.mdx
new file mode 100644
index 000000000..b862439db
--- /dev/null
+++ b/frontend/src/content/en/harness/extensions/troubleshooting.mdx
@@ -0,0 +1,181 @@
+---
+title: Troubleshooting
+description: A symptom-indexed guide to extension problems. Each entry quotes the exact log line or error message, explains the cause, and gives the fix, from installation and startup to middleware, lifecycle hooks, services, and routes.
+---
+
+import { Callout } from "nextra/components";
+
+# Troubleshooting
+
+Every extension diagnostic in the Gateway log starts with `Extension :`, where `` is the record's entry point, for example `Extension deerflow_extension_hello:install:`. Search the log for that prefix first. Under `make dev` the Gateway writes to `logs/gateway.log`; in Docker, use `make docker-logs` or `docker compose logs gateway`.
+
+Manager commands report failures on stderr as `extension command failed: ` with exit status 1.
+
+## The extension does not seem to load
+
+### No `Extensions loaded` line at all
+
+The Gateway logs `Extensions loaded: N/M (...)` at INFO whenever `plugins:` has at least one entry. Without that line the Gateway read no `plugins:` list:
+
+- **The Gateway was not restarted.** Extensions load only at startup.
+- **The Gateway reads a different `config.yaml`.** The manager and the Gateway both honor `DEER_FLOW_CONFIG_PATH`; if one process has it set and the other does not, they use different files. See [Operating Extensions](/docs/harness/extensions/operations).
+- **The record was put in `extensions_config.json`.** `plugins:` is read only from `config.yaml`.
+
+### `Extensions loaded: 0/1 (none)`
+
+The entry was read but did not load. The error line just above says why; the entries below cover each one. The Gateway keeps running without the extension unless the record has `required: true`.
+
+### `could not resolve extension entry point: Could not import module . Missing dependency ''...`
+
+The module is not installed in the Gateway's environment. Install the package through the manager instead of `pip install`, so that it lands in `backend/uv.lock` and in images built from it. If you installed it and still see this, check that the `use` value spells the import path, not the distribution name: `deerflow_extension_hello:install`, not `deerflow-extension-hello:install`.
+
+### `could not resolve extension entry point: Module does not define a attribute/class`
+
+The module imports, but the function after the colon does not exist. Correct `use`.
+
+### `could not resolve extension entry point: doesn't look like a variable path`
+
+`use` has no `:`. It must be `module.path:install`.
+
+### `extension entry point is not callable: `
+
+`use` points at something other than a function, such as a module-level constant.
+
+### `extension requires extension-api , host provides `
+
+The `@extension(api=...)` marker is outside what this host accepts. Before 1.0 the host accepts the same `0.minor` at a patch equal to or lower than its own.
+
+- Declared version **newer** than the host (for example `0.3.0` on a `0.2.1` host): upgrade DeerFlow, or install an extension release built for this host.
+- Declared version with a **newer patch** than the host (for example `0.2.2` on a `0.2.1` host): upgrade DeerFlow, or install the extension release that declares `0.2.1` or lower.
+- Declared version on an **older** minor (for example `0.1.0` on a `0.2.1` host): upgrade the extension to a release written against the host's minor.
+
+The message suggests `pip install 'deerflow-extension-api>=,<...'`. The host's contract version is fixed by its own `uv.lock`, so for an older extension that suggestion does not apply: fix the extension, not the host.
+
+### `extension declares invalid extension-api version marker of type ; expected a dotted numeric string such as '0.1'`
+
+`__deerflow_api__` was set to something other than a string like `"0.2.0"`. Use the `@extension(api="0.2.0")` decorator.
+
+### `install() failed: `
+
+Your `install()` raised. Anything it registered before raising is rolled back and the Gateway continues with the next extension. The traceback follows the line. Keep `install()` to registration only: open connections and start background work in an [ExtensionService](/docs/harness/extensions/services-and-routes).
+
+### My code changes have no effect
+
+Local directories are installed as snapshots in `backend/extensions/sources/`, not as editable links. Run `make extension-upgrade SOURCE=` and restart. In Docker, also rebuild the image.
+
+## The Gateway does not start
+
+### `ExtensionLoadError: required extension failed to load`
+
+A record with `required: true` failed. The same message ends in `is not callable`, `could not inspect api marker`, `declares invalid api marker`, `declares incompatible api `, or `failed to install` depending on the step. The line logged just before it has the cause. Recover with `make extension-disable NAME=` or by setting `required: false`, then restart. See [Operating Extensions](/docs/harness/extensions/operations).
+
+### `extension table_prefix '' would hide host-owned table(s) [...] from alembic autogenerate`
+
+The record's `table_prefix` is a prefix of a DeerFlow table name. This always aborts startup, even for a disabled or optional record. Choose a more specific prefix, such as `acme_audit_`.
+
+### A validation error for `plugins` when loading `config.yaml`
+
+For example `Extra inputs are not permitted` or `String should have at least 1 character` under `table_prefix`. Records reject unknown keys and an empty `table_prefix`. Fix the record; this is a config error, so it stops the Gateway whatever `required` says.
+
+## Installation and management commands fail
+
+| Message after `extension command failed:` | Cause and fix |
+| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `extension installation requires uv 0.8.0 or newer` | Upgrade uv, ideally to the version pinned in `backend/Dockerfile` |
+| `extension has no pyproject.toml: ` | The directory is not a package root |
+| `extension pyproject.toml must declare project.name` | Add `[project] name = ...` |
+| `extension must declare exactly one 'deerflow.extensions' entry point` | Declare one entry under `[project.entry-points."deerflow.extensions"]` |
+| `invalid 'deerflow.extensions' entry point target` | The entry-point value must be `module.path:function` |
+| `distribution '' must expose exactly one 'deerflow.extensions' entry point` | A package from an index or Git declares none or several entry points in that group |
+| `distribution '' extension entry point could not be loaded` | Importing the entry point raised in the synced environment, or it is not callable. Test `import` locally |
+| `local extension snapshot contains a likely sensitive file: ` | Delete the `.env`, key, or credential file from the directory, or build from a clean copy |
+| `local extension snapshots cannot contain symbolic links or junctions` | Replace links with real files |
+| `local extension sources must be directories so they can be snapshotted for deployment` | You passed a local file, such as a wheel. Pass the package directory |
+| `extension source is already installed: ` | Use `make extension-upgrade` |
+| `extension source is not installed: ; use install` / `extension '' is not installed; use install` | `upgrade` only replaces existing installs |
+| `Git SSH shorthand is not deployable; ...` / `remote Git sources must use public HTTPS; ...` | Use `git+https://host/org/repo.git@` |
+| `remote extension sources must use HTTPS` | Plain HTTP is accepted only for loopback hosts |
+| `extension source URLs cannot contain embedded credentials` / `... credential-like query parameters` | Configure index credentials in uv instead of the URL |
+| `file URLs are not deployable; ...` / `local path references are not deployable; ...` | Pass a local directory instead |
+| `uv.lock contains a local dependency source outside the backend Docker build context` | Resolution picked up a local file, often through `UV_FIND_LINKS` or a local index. The operation was rolled back; remove that setting |
+| `expected exactly one configured extension matching ''` | No record, or several, match `NAME`. Check `make extension-list` |
+| `configured extension '' has no managed package metadata` | A hand-written record without `package`; delete it from `config.yaml` yourself |
+| `multiple configured plugins conflict with extension ''` | Another record already uses this `name` or `package` with a different `use`. Remove the stale record. During `upgrade`, this also means the new version changed its entry-point target: remove and reinstall |
+| `config.yaml contains duplicate top-level plugins keys` | Merge the two `plugins:` blocks |
+| `DeerFlow config not found: ` | Run `make config`, or point `DEER_FLOW_CONFIG_PATH` at the right file |
+| `extension installation recovery preserved a concurrent dependency-file edit` (or `removal`, `config edit`) | Someone else changed the files mid-operation. Review `git diff` and retry |
+| `extension operation failed and the restored environment could not be synchronized; original failure: ...` | The rollback restored the files but `uv sync` failed. Run `cd backend && uv sync --locked --all-packages` |
+
+A manager command that seems to hang is usually waiting for another command holding `.deer-flow/extension-manager.lock`.
+
+## Middleware problems
+
+### `placement fell back to a secondary anchor (primary anchor middleware is absent from this stack); ...`
+
+A warning. The middleware the placement normally anchors to is missing from this particular chain, so the host used its next rule. For `TOOL_RAW` with subagent scope this happens on every subagent build and the fallback still meets the guarantee. For any other placement, check whether you still observe what you expect. See [Middleware Contributions](/docs/harness/extensions/middleware).
+
+### `. failed and was skipped: `
+
+Your hook raised. The host recovered without repeating the model or tool call. Common variants:
+
+- `... did not call the downstream handler`: your wrap hook returned without calling `handler`. The host called it for you.
+- `... called the downstream handler more than once`: your wrap hook retried. Only the first call counts.
+
+### `contribute_middlewares() failed: `
+
+Your contributor raised, so this agent was built without your middleware. It is called on every agent assembly; check for per-call assumptions such as a missing `agent_name`.
+
+### `contribution must be a MiddlewarePlacement, got ` (or `has invalid scope`, `invalid placement`, `invalid order`, `middleware must be an AgentMiddleware`)
+
+An item returned by your contributor has the wrong type. `order` must be an `int` (not a `bool`), and `middleware` must be a LangChain `AgentMiddleware` instance.
+
+### `Middleware ordering constraint violated: ... Contributed by: .`
+
+A hard failure: the agent cannot be built. The final stack broke one of the host's ordering invariants. Report it with the full message; extension contributions only land at placement anchors, so this should not happen with the public contract.
+
+### My middleware modifies the request or result, but nothing changes
+
+Expected. Extension wrap hooks are observe-only in this release: the host always forwards the original request and returns the real result.
+
+### My middleware sees nothing in normal runs
+
+It probably implements only the sync `wrap_tool_call` or `wrap_model_call`. Gateway runs are async; implement `awrap_tool_call` or `awrap_model_call` too.
+
+## Lifecycle hooks and observers
+
+### `Extension : on_task_start timed out for task ; the 3.0s notification budget was spent`
+
+All task-lifecycle contributors share a 3-second budget per notification (`on_task_start` and `on_task_stop` each get their own). A slow contributor consumes it. The ones after it are then logged as `... skipped for task ; the 3.0s notification budget was spent`. Move slow work out of the hook, for example into a queue drained by a service.
+
+### `Extension : on_task_stop failed for task `
+
+The hook raised. The run's outcome is not affected, and the next contributor still runs.
+
+### Lifecycle hooks never fire outside the Gateway
+
+Under LangGraph Server, `langgraph dev`, or a direct harness call without a `run_id`, task-lifecycle notifications are skipped.
+
+### `No running loop registered for extension observations; ... dropped`
+
+A system-model or compaction observation arrived while no Gateway notification loop was running, for example in an embedded harness or during shutdown. The observation is dropped.
+
+### `Extension : on_agent_assembled failed for AgentAssemblyDescriptor`
+
+Your assembly observer raised. It runs synchronously during agent construction; keep it cheap and non-raising.
+
+## Services and routes
+
+| Log line after `Extension :` | Cause and fix |
+| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
+| `service start() failed; continuing without it: ` | `start()` raised. The service is not running; routes that depend on it should report that (the bundled example returns 503) |
+| `service stop() timed out after 30.0s; continuing shutdown` | `stop()` took longer than its 30-second budget |
+| `service stop() failed; continuing shutdown: ` | `stop()` raised; shutdown continued |
+| `router path is already served by host; this router was not mounted` | A host route already handles that path and method. The whole router is skipped. Use your own prefix, such as `/api//...` |
+| `router path is already served by ; this router was not mounted` | Another extension, loaded earlier, owns the path |
+| `router could not be mounted; continuing without it: contributed WebSocket routes are not supported ...` | WebSocket routes are not accepted yet |
+| `router could not be mounted; continuing without it: contributed router lifecycle hooks are not supported; register an ExtensionService instead` | Remove `on_startup`/`on_shutdown` or `lifespan` from the router |
+| `router could not be mounted; continuing without it: contributed router contains a Starlette Mount ...` | Mounts are not supported; declare the routes directly |
+| `router could not be mounted; continuing without it: contributed route can enter a host public namespace` (or `a host-reserved exact path`) | The path could reach an authentication- or CSRF-exempt host path. Pick a different path |
+| `router could not be mounted; continuing without it: contributed router exposes no routes: ...` | The router is empty |
+
+Successfully mounted routes are logged at INFO as `Extension routers mounted: -> , ...`. Contributed routes always sit behind Gateway authentication; a 401 from them means the request is not authenticated, not that the route is missing.
diff --git a/frontend/src/content/zh/harness/_meta.ts b/frontend/src/content/zh/harness/_meta.ts
index e1934f5ad..4117826eb 100644
--- a/frontend/src/content/zh/harness/_meta.ts
+++ b/frontend/src/content/zh/harness/_meta.ts
@@ -37,6 +37,9 @@ const meta: MetaRecord = {
mcp: {
title: "MCP 集成",
},
+ extensions: {
+ title: "扩展包",
+ },
customization: {
title: "自定义与扩展",
},
diff --git a/frontend/src/content/zh/harness/extensions/_meta.ts b/frontend/src/content/zh/harness/extensions/_meta.ts
new file mode 100644
index 000000000..b9f2ba678
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/_meta.ts
@@ -0,0 +1,33 @@
+import type { MetaRecord } from "nextra";
+
+const meta: MetaRecord = {
+ "quick-start": {
+ title: "快速上手",
+ },
+ runtime: {
+ title: "运行时模型",
+ },
+ middleware: {
+ title: "中间件贡献",
+ },
+ observers: {
+ title: "生命周期与观察者",
+ },
+ "services-and-routes": {
+ title: "服务与路由",
+ },
+ "run-evidence": {
+ title: "运行证据",
+ },
+ operations: {
+ title: "运维扩展",
+ },
+ troubleshooting: {
+ title: "故障排查",
+ },
+ reference: {
+ title: "参考",
+ },
+};
+
+export default meta;
diff --git a/frontend/src/content/zh/harness/extensions/index.mdx b/frontend/src/content/zh/harness/extensions/index.mdx
new file mode 100644
index 000000000..ca81057a0
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/index.mdx
@@ -0,0 +1,123 @@
+---
+title: 扩展包
+description: 扩展包是独立发布的 Python 包,通过一个 install() 函数接入 DeerFlow Gateway。本章介绍扩展能贡献什么、什么时候该用扩展而不是工具、MCP 或技能、扩展如何加载,以及如何阅读本手册。
+asIndexPage: true
+---
+
+import { Callout } from "nextra/components";
+
+# 扩展包
+
+
+ 扩展包是 DeerFlow 在 Gateway 启动时导入的普通 Python 包。它只依赖公开的
+ `deerflow-extension-api` 契约,因此构建、测试和发布都不需要导入 DeerFlow 本身。
+
+
+工具、MCP 服务器和技能给模型增加可调用的能力;扩展包给**宿主**增加行为:观察每一次模型调用和工具调用,在运行开始和结束时做出响应,在 Gateway 旁运行后台服务,并提供自己的 HTTP 路由。审计日志、成本核算、治理看板这类集成,扩展包是不 fork DeerFlow 就能交付的受支持方式。
+
+## 如何阅读本手册
+
+| 读者 | 从这里开始 |
+| ------------------ | --------------------------------------------------------------------------------------------------- |
+| 扩展作者 | 本页、[快速上手](/docs/harness/extensions/quick-start)、[运行时模型](/docs/harness/extensions/runtime),然后按下文每种贡献类型阅读对应章节 |
+| 运维人员 | [运维扩展](/docs/harness/extensions/operations)、[故障排查](/docs/harness/extensions/troubleshooting)、[信任模型](#信任模型) |
+| 查某个名称 | [参考](/docs/harness/extensions/reference) |
+| DeerFlow 贡献者 | `backend/packages/harness/deerflow/extensions/AGENTS.md`,其中记录了宿主侧的设计决策 |
+
+## 该不该做成扩展包?
+
+选能完成任务的最轻机制。下表每一行都会执行运维方信任的代码,但越往下,接入运行时越深。
+
+| 你想要 | 使用 |
+| -------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| 给模型一个新的可调用能力 | [自定义工具](/docs/harness/tools)(`config.yaml` 的 `tools:`)或 [MCP 服务器](/docs/harness/mcp) |
+| 教模型一套工作流程或领域方法 | [技能](/docs/harness/skills) |
+| 在配置里直接给所有 Agent 加一个 `AgentMiddleware` 类 | `config.yaml` 的 `extensions.middlewares`,见[自定义与扩展](/docs/harness/customization) |
+| 交付一个带版本的包,用来观察运行、保存状态、运行服务或提供路由 | **扩展包**(本手册) |
+
+两条中间件路径容易混淆。`extensions.middlewares` 把一个类插到固定位置,不附带任何契约;扩展包的中间件声明语义化的[放置位置](/docs/harness/extensions/middleware#放置位置),运行在隔离故障的包装器里,并且可以和下面其他贡献类型一起发布。
+
+## 扩展能贡献什么
+
+`install(registry, config)` 收到一个只写的注册表,每个方法注册一种贡献:
+
+| 注册方法 | 贡献 |
+| ------------------------------------------- | -------------------------------------------------------------------------------------------- |
+| `registry.middlewares(contributor)` | 按语义位置插入 Lead Agent 和子 Agent 链的 `AgentMiddleware`。见[中间件贡献](/docs/harness/extensions/middleware) |
+| `registry.task_lifecycle(contributor)` | 每次 Lead 运行和每个被委派的子 Agent 的 `on_task_start` / `on_task_stop`。见[生命周期与观察者](/docs/harness/extensions/observers) |
+| `registry.system_model_observer(obs)` | DeerFlow 自己发起的模型调用快照:目标评估、记忆抽取、标题生成、摘要压缩。见[生命周期与观察者](/docs/harness/extensions/observers) |
+| `registry.agent_assembly_observer(obs)` | 每个组装完成的 Agent 的描述:模型、提示词哈希、工具、中间件栈、技能和指纹。见[生命周期与观察者](/docs/harness/extensions/observers) |
+| `registry.context_compaction_observer(obs)` | 每次摘要压缩从上下文中移除消息时的 `CompactionEvent`。见[生命周期与观察者](/docs/harness/extensions/observers) |
+| `registry.service(service)` | 在 Gateway 持久化层就绪后启动、关闭时停止的对象,可通过[运行证据](/docs/harness/extensions/run-evidence)读取器读取运行。见[服务与路由](/docs/harness/extensions/services-and-routes) |
+| `registry.routers(routers)` | 在所有宿主路由之后挂载、受 Gateway 认证保护的 FastAPI 路由。见[服务与路由](/docs/harness/extensions/services-and-routes) |
+
+一个扩展可以注册任意组合。[内置示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example)注册了一个中间件、一个任务生命周期贡献者、一个系统模型观察者、一个服务和一个路由。
+
+## 安装与加载
+
+运维人员用扩展管理器安装扩展。它把包加入后端的 `extensions` 依赖组,更新 `uv.lock`,并在 `config.yaml` 顶层的 `plugins:` 列表里写入一条记录:
+
+```yaml
+plugins:
+ - name: hello
+ package: deerflow-extension-hello
+ use: deerflow_extension_hello:install
+ enabled: true
+ required: false
+ config: {}
+```
+
+| 字段 | 含义 |
+| ---------- | -------------------------------------------------------------------------------------------- |
+| `use` | 入口点,写作 `module.path:install` |
+| `enabled` | `false` 时跳过该扩展,不导入 |
+| `required` | `false`(默认):加载失败只记日志,Gateway 不带该扩展照常启动。`true`:Gateway 拒绝启动 |
+| `config` | 私有配置,作为第二个参数原样(浅拷贝)传给 `install()` |
+
+加载只发生一次,就在 Gateway 构建应用的时候。Gateway 按列表顺序解析每个启用的条目,检查 API 版本,然后调用 `install()`。如果 `install()` 抛出异常,它已注册的内容会被回滚,下一个扩展照常加载。加载结束时 Gateway 会打印 `Extensions loaded: N/M (...)`。
+
+因为只在启动时加载,**任何改动都需要重启 Gateway**:安装、升级、启用、禁用、移除,或手工编辑 `plugins:`。`plugins:` 只能写在 `config.yaml` 里,不能写在 `extensions_config.json` 里,因为后者可以通过 Gateway API 修改,而导入一个包就等于执行代码。
+
+## 故障模型
+
+扩展是观察性的,所以一个坏掉的扩展只会退化成一条日志,而不会搞坏一次运行:
+
+- 抛出异常的贡献会被跳过,Gateway 记录一条归属到其入口点的错误(`Extension : ...`)。
+- 贡献的中间件运行在隔离包装器里,包装器绝不会重复一次模型调用或工具副作用。见[故障隔离](/docs/harness/extensions/middleware#故障隔离)。
+- 任务生命周期通知共享一个有上限的时间预算;观察者逐个通知,一个观察者失败不会跳过后面的观察者。
+
+唯一的例外是 `required: true`,它把任何加载失败都变成启动中止。只在缺了这个扩展部署就是错的情况下使用它,因为从这种失败中恢复需要有 shell 权限去改配置文件。
+
+## 信任模型
+
+
+ 扩展包没有沙箱。它的构建钩子在安装时运行,它的代码在 Gateway 进程内以 Gateway
+ 的权限运行,包括通过交给服务的 session factory 访问数据库。只安装你审查过并信任的来源。
+
+
+扩展管理器在安装前会要求确认,拒绝内嵌凭据的来源 URL,只接受包依赖声明、HTTPS 来源(包括通过 HTTPS 访问的公开 Git)和本地目录,本地目录会被复制成快照。SSH Git URL 和本地 wheel 会被拒绝。这些检查防的是打包失误,不是恶意代码。
+
+## 版本
+
+契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.1**。
+
+- 1.0 之前,minor 版本可以有不兼容变更,patch 版本只做增量。1.0 之后,不兼容变更会升 major。
+- 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值,所以增量发布不会破坏已经发布的扩展。
+- 用 `@extension(api="0.2.0")` 装饰 `install`,声明你编写时所用的版本。除非宿主是相同的 `0.minor` 且不低于声明的 patch,否则 Gateway 会拒绝该扩展,并给出可操作的提示。请声明你用到的功能所需的最低版本。
+
+同时在包元数据里声明对应的范围,例如 `deerflow-extension-api>=0.2,<0.3`。
+
+## 术语
+
+- **宿主**:加载扩展的 DeerFlow Gateway 进程。
+- **贡献**:通过注册表注册的一个对象:贡献者、观察者、服务或路由。
+- **贡献者**:宿主回调以获取贡献的对象,例如 `MiddlewareContributor`,每构建一个 Agent 就返回一次中间件。
+- **作用域**:一份状态所属的生命周期。**应用作用域**与 Gateway 同寿;**任务作用域**只存活一次 Lead 运行或一次子 Agent 执行。
+- **`ExtensionData`**:挂在某个作用域上的类型化存储,以 Python 类型为键,两个扩展不会冲突。
+- **诊断**:归属到某个扩展的加载期或运行期问题,写入 Gateway 日志。
+
+
+
+
+
+
diff --git a/frontend/src/content/zh/harness/extensions/middleware.mdx b/frontend/src/content/zh/harness/extensions/middleware.mdx
new file mode 100644
index 000000000..2ccd5586c
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/middleware.mdx
@@ -0,0 +1,169 @@
+---
+title: 中间件贡献
+description: 扩展如何向 Lead Agent 和子 Agent 链贡献 AgentMiddleware。涵盖五种语义放置位置及其当前落点、作用域与排序、贡献的中间件能改什么和不能改什么、故障隔离,以及如何读取任务状态。
+---
+
+import { Callout } from "nextra/components";
+
+# 中间件贡献
+
+中间件贡献把你的 `AgentMiddleware` 插入包裹 Agent 每次模型调用和工具调用的链中。需要看到**每一次调用**时就用它:延迟和成本核算、审计轨迹、策略遥测或链路追踪。中间件链本身的介绍见[中间件](/docs/harness/middlewares)。
+
+## 契约
+
+在 `install()` 中注册一个贡献者。宿主每组装一个 Agent 就回调它一次:
+
+```python
+from collections.abc import Sequence
+
+from deerflow_extension_api import (
+ AgentBuildContext,
+ AgentScope,
+ ExtensionData,
+ MiddlewarePlacement,
+ Placement,
+)
+
+
+class AuditContributor:
+ def contribute_middlewares(
+ self,
+ app_store: ExtensionData,
+ ctx: AgentBuildContext,
+ ) -> Sequence[MiddlewarePlacement]:
+ return (
+ MiddlewarePlacement(
+ AuditMiddleware(),
+ Placement.MODEL_LOGICAL,
+ scope=AgentScope.BOTH,
+ order=0,
+ ),
+ )
+
+
+def install(registry, config):
+ registry.middlewares(AuditContributor())
+```
+
+| 字段 | 类型 | 默认值 | 含义 |
+| ------------ | ----------------- | ----------------- | ------------------------------------------------------- |
+| `middleware` | `AgentMiddleware` | 必填 | 要插入的实例。其他类型会被拒绝 |
+| `placement` | `Placement` | 必填 | 你需要的语义保证。见[放置位置](#放置位置) |
+| `scope` | `AgentScope` | `AgentScope.BOTH` | 哪些链会收到它。见[作用域](#作用域) |
+| `order` | `int` | `0` | 落在同一位置的贡献之间的排序依据。见[排序](#排序) |
+
+`contribute_middlewares()` 收到应用作用域的存储和一个 `AgentBuildContext`:
+
+| 字段 | 含义 |
+| ------------ | --------------------------------------------------------------------------- |
+| `scope` | `AgentScope.LEAD` 或 `AgentScope.SUBAGENT`:当前正在构建的链 |
+| `agent_name` | 自定义 Agent 或子 Agent 的名称(如果有) |
+| `model_name` | 解析后的模型名 |
+| `policy` | 一个 `HostPolicySnapshot`:token 预算限制,以及 Lead Agent 的 `max_subagents_per_run` |
+
+贡献者可以根据上下文返回不同的中间件,或者什么都不返回。每次组装 Agent 都会调用它,通常是每次运行一次、每个被委派的子 Agent 一次,所以要保持轻量。
+
+## 放置位置
+
+中间件在列表里占一个位置,但这个位置只在它实现的那条钩子链上才有意义。模型轴上的"最外层"和工具轴上的"最外层"是两个不同的地方。所以你不选下标,而是声明轴、端和你需要的保证,由宿主对照当前的栈解析出位置。
+
+| 放置位置 | 轴与端 | 保证 | 典型用途 |
+| ---------------- | -------------- | -------------------------------------------------------------------------------------- | -------------------------------- |
+| `MODEL_LOGICAL` | 模型轴,外层端 | 位于重试和错误处理之外。**每个逻辑模型决策触发一次**,无论宿主在下面重试多少次 | 审计决策、统计轮次 |
+| `MODEL_PHYSICAL` | 模型轴,内层端 | 位于所有改写请求的中间件之内。**每次调用提供商触发一次**,重试会再次进入 | 提供商延迟、成本、最终的确切请求 |
+| `TOOL_VISIBLE` | 工具轴,外层端 | 位于截断、清洗和错误包装之外。观察的是**模型最终看到的内容** | 端到端工具延迟、模型被告知了什么 |
+| `TOOL_RAW` | 工具轴,内层端 | 紧贴真实的工具调用边界。观察任何处理之前的**原始返回** | 捕获未截断的工具输出 |
+| `STANDARD` | 无 | 没有位置要求。与其他 `STANDARD` 贡献之间的相对顺序不保证 | `before_model` / `after_model` 状态钩子 |
+
+### 各放置位置当前的落点
+
+上面的保证才是契约。下面的具体锚点是当前宿主兑现这些保证的方式。内置栈变化时锚点可能移动,依赖它们就是依赖实现细节。
+
+| 放置位置 | Lead Agent 链 | 子 Agent 链 |
+| ---------------- | -------------------------------------------------------------------- | ------------------------------------------------- |
+| `TOOL_VISIBLE` | 最外层,在 `InputSanitizationMiddleware` 之前 | 相同 |
+| `MODEL_LOGICAL` | 紧挨在 `LLMErrorHandlingMiddleware` 外层 | 相同 |
+| `STANDARD` | 目前与 `MODEL_LOGICAL` 是同一个锚点 | 相同 |
+| `MODEL_PHYSICAL` | 在 `SafetyFinishReasonMiddleware` 之内、`ClarificationMiddleware` 之外 | 在最后一个中间件 `SystemMessageCoalescingMiddleware` 之内 |
+| `TOOL_RAW` | 在 `ClarificationMiddleware` 之外 | 最内层 |
+
+`ClarificationMiddleware` 始终在 Lead 链的最后,因为它负责为 `ask_clarification` 结束工具循环。它从不改写真正执行的工具的结果,所以 `MODEL_PHYSICAL` 和 `TOOL_RAW` 虽然位于它外层,保证依然成立。
+
+当某个放置位置的主锚点不在栈里时,宿主会回退到下一条规则并记录一条警告,因为悄无声息的降级会改变扩展观察到的内容:
+
+```text
+Extension : placement TOOL_RAW fell back to a secondary anchor (primary anchor middleware is absent from this stack); ...
+```
+
+
+ 子 Agent 链里没有 `ClarificationMiddleware`,所以作用域包含 `SUBAGENT` 的
+ `TOOL_RAW` 贡献总会走回退规则,落在最内层。这个回退位置仍然满足 `TOOL_RAW`
+ 的保证,但每次构建子 Agent 都会记录这条警告。
+
+
+## 作用域
+
+`AgentScope` 是一个标志位:`LEAD`、`SUBAGENT` 或 `BOTH`(默认)。宿主分别构建每条链,只有作用域与正在构建的链有交集时才纳入该贡献。要按链区分,可以返回两个作用域不同的放置,或者根据 `ctx.scope` 分支。
+
+通过 `create_deerflow_agent(extra_middleware=...)` 或 `DeerFlowClient(middlewares=...)` 配置的中间件不会进入子 Agent;作用域包含 `SUBAGENT` 的扩展中间件会。
+
+## 排序
+
+贡献先按 `order` 排序,再按注册顺序排序:扩展按 `plugins:` 列表顺序加载,贡献保持贡献者返回时的顺序。多个贡献解析到同一位置时,**`order` 较小的在外层**。`order` 只用来排你自己的贡献;拿它去和别的扩展比先后,会把两个包耦合在一起。
+
+插入贡献之后,宿主会在最终的栈上校验自己的排序不变量。违反不变量是这套系统里唯一的硬失败:Agent 构建会失败,错误信息会点名负责的扩展。
+
+## 贡献的中间件能改什么
+
+本版本的所有贡献都是**观察性**的。宿主通过包裹你的中间件的包装器强制执行这一点。
+
+- **`wrap_model_call` / `wrap_tool_call` 及其异步形式。** 你可以查看请求和结果,必须恰好调用一次 `handler`。宿主始终把**原始**请求传给下游,即使你用修改过的请求调用 `handler`;也始终返回**真实**的下游结果,无论你的钩子返回什么。扩展无法改写提示词、否决工具调用或替换工具输出。
+- **`before_agent` / `before_model` / `after_model` / `after_agent` 及其异步形式。** 它们与普通 `AgentMiddleware` 一样运行,返回的 dict 会作为状态更新应用。如果中间件声明了 `state_schema`,包装器会转发它。
+
+只要包装钩子对中的任一侧存在,LangChain 就会同时接好同步和异步两条路径,所以包装器会为你没写的那一侧补一个透传实现。如果两条路径都必须观察,请同时实现 `wrap_tool_call` 和 `awrap_tool_call`(或模型调用的两种形式)。常规 Gateway 运行走异步路径。
+
+## 故障隔离
+
+宿主把每个贡献包在一个 `IsolatedMiddleware` 里。包装器会追踪下游 handler,保证从你的故障中恢复时绝不会多出一次模型请求或工具副作用:
+
+| 出了什么问题 | 宿主怎么做 |
+| --------------------------------------------- | ------------------------------------------------------------------- |
+| 包装钩子在调用 `handler` 之前抛异常 | 记录诊断,然后用原始请求调用一次 `handler` |
+| 包装钩子始终没有调用 `handler` | 记录诊断,然后由宿主自己调用 `handler` |
+| 包装钩子在 `handler` 返回之后抛异常 | 记录诊断,返回真实结果 |
+| 包装钩子第二次调用 `handler` | 第二次调用在你的钩子里抛出 `RuntimeError`;宿主返回第一次的结果 |
+| `handler` 本身抛异常 | 异常原样传播,由宿主自己的错误策略处理 |
+| 生命周期钩子抛异常 | 记录诊断,不应用任何状态更新 |
+| `contribute_middlewares()` 抛异常 | 记录诊断;这个贡献者本次不向该 Agent 添加任何东西 |
+| 返回的某一项不是合法的 `MiddlewarePlacement` | 记录诊断并跳过该项 |
+
+LangGraph 中断(`GraphBubbleUp`)总是会传播。诊断以 `Extension : . failed and was skipped: ` 的形式写入 Gateway 日志。
+
+## 读取任务状态
+
+中间件实例可能被并发的运行共享,所以不要把单次运行的状态存在 `self` 上。把它放进任务作用域的 `ExtensionData` 存储:宿主为每次 Lead 运行和每次子 Agent 执行创建一个,结束时丢弃。在钩子里从 runtime 取回它:
+
+```python
+from deerflow_extension_api import task_store_from_runtime
+
+
+class CountingMiddleware(AgentMiddleware):
+ async def awrap_tool_call(self, request, handler):
+ store = task_store_from_runtime(getattr(request, "runtime", None))
+ if store is not None:
+ store.get_or_init(ToolCallCount, ToolCallCount).value += 1
+ return await handler(request)
+```
+
+没有活跃任务时 `task_store_from_runtime()` 返回 `None`,例如 harness 在没有 Gateway 运行包裹的情况下执行时。这种情况下直接透传。`ExtensionData` 以类型为键,所以为每个要存的值定义你自己的类:两个扩展不会在键上冲突,你也永远不需要直接写 runtime context。
+
+## 追踪中的标识
+
+LangChain 要求中间件名称唯一,并把它用作追踪标识和图节点 ID。宿主把每个包装器命名为 `extension_<入口点>_<类名>_`,不安全字符替换为下划线。例如快速上手里的中间件显示为 `extension_deerflow_extension_hello_install_ToolTimer_0`。
+
+## 常见误区
+
+- **以为能修改调用。** 本版本的包装钩子只能观察。需要改写请求的行为请用 `extensions.middlewares`(见[自定义与扩展](/docs/harness/customization)),它不套包装器,但要接受它是受信任的配置而不是契约。
+- **只实现 `wrap_tool_call`。** Gateway 运行走异步路径,只有同步实现的中间件在那里什么也看不到。
+- **在 `contribute_middlewares()` 里做重活。** 每次组装 Agent 都会调用它。昂贵的客户端请在 `install()` 中构建一次,或在应用存储里惰性创建。
+- **依赖具体锚点。** 按你需要的保证选择放置位置,而不是按它今天恰好挨着哪个中间件。
diff --git a/frontend/src/content/zh/harness/extensions/observers.mdx b/frontend/src/content/zh/harness/extensions/observers.mdx
new file mode 100644
index 000000000..5641dce40
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/observers.mdx
@@ -0,0 +1,337 @@
+---
+title: 生命周期与观察者
+description: 四种通知类贡献:每次 Lead 运行和子 Agent 的任务生命周期、DeerFlow 自己发起的系统模型调用、每个组装完成的 Agent 的描述与指纹,以及每次上下文压缩的事件。涵盖各自的时机、载荷、存储和失败行为。
+---
+
+import { Callout } from "nextra/components";
+
+# 生命周期与观察者
+
+中间件看到的是 Agent 内部的每次模型调用和工具调用。本章的四种贡献看到的是这些调用周围和旁边的事件:任务开始与结束、DeerFlow 自己的模型调用、Agent 被组装、上下文被压缩。它们都无法改变宿主的行为,并且都按[运行时模型](/docs/harness/extensions/runtime)中的规则故障放行。
+
+| 贡献 | 注册方法 | 调用时机 | 同步还是异步 | 收到的存储 |
+| ---------------------------- | ---------------------------------------- | ---------------------------------- | ------------------ | ------------------- |
+| `TaskLifecycleContributor` | `registry.task_lifecycle()` | 每次 Lead 运行和子 Agent 的开始与结束 | 异步,被等待 | 任务存储 |
+| `SystemModelCallObserver` | `registry.system_model_observer()` | 每次 DeerFlow 自有模型调用之后 | 异步 | 任务存储,或分离存储 |
+| `AgentAssemblyObserver` | `registry.agent_assembly_observer()` | 每次 Agent 构建结束时 | **同步** | 仅应用存储 |
+| `ContextCompactionObserver` | `registry.context_compaction_observer()` | 每次摘要压缩之后 | 异步,即发即弃 | 分离存储 |
+
+本页所有示例都来自同一个注册了全部四种贡献的扩展:
+
+```python
+@extension(api="0.2.0", name="observers")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ registry.task_lifecycle(TaskTimer())
+ registry.system_model_observer(SystemCallLogger())
+ registry.agent_assembly_observer(AssemblyDriftWatcher())
+ registry.context_compaction_observer(CompactionLogger())
+```
+
+## 任务生命周期
+
+```python
+class TaskLifecycleContributor(Protocol):
+ async def on_task_start(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo) -> None: ...
+ async def on_task_stop(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo, outcome: TaskOutcome) -> None: ...
+```
+
+一个**任务**就是一次 Lead 运行或一次子 Agent 执行。`task_store` 在 `on_task_start` 之前为该任务创建,与传给它的 `on_task_stop` 以及其中每次中间件调用的是同一个对象。所以这一对钩子天然适合建立和收拢单个任务的状态。
+
+### 时机
+
+**Lead 运行**:
+
+1. 运行被接纳并标记为已开始。在此之前就被取消的运行两个钩子都不会收到。
+2. 等待 `on_task_start`,此时 Agent 图尚未构建。
+3. Agent 运行,包括所有目标续跑。
+4. 宿主持久化运行状态和 token 用量,同步线程标题和状态,并执行它自己的完成钩子。
+5. 等待 `on_task_stop`。此时运行的 finalizing 屏障仍然持有,所以同一线程上的后续运行要等你的钩子返回后才能开始它的生命周期。
+6. 释放屏障,并向客户端发布流结束。
+
+**子 Agent**:`on_task_start` 在子 Agent 第一步之前被等待;`on_task_stop` 在其清理路径中、释放沙箱租约之后被等待,无论结果如何。
+
+两个钩子都受[运行时模型](/docs/harness/extensions/runtime)中所述的 3 秒通知预算约束。因为 `on_task_stop` 在流结束之前执行,慢的停止钩子会推迟客户端看到运行结束的时间。
+
+### TaskInfo
+
+| 字段 | Lead 运行 | 子 Agent |
+| ---------------- | -------------------------- | ------------------------------------------ |
+| `task_id` | 运行 id | 子 Agent 执行 id |
+| `run_id` | 运行 id | 父运行的 id |
+| `thread_id` | 线程 id | 父线程 id |
+| `kind` | `"lead"` | `"subagent"` |
+| `parent_task_id` | `None` | 父运行 id,也就是 Lead 任务的 `task_id` |
+| `agent_name` | assistant 或自定义 Agent id | 子 Agent 名称,例如 `general-purpose` |
+| `resumed` | `False` | `False` |
+
+
+ `resumed` 属于契约的一部分,但当前宿主从不把它设为 `True`。暂时不要依赖它来识别续跑。
+
+
+对子 Agent 来说,`task_store.scope_id` 在有发起委派的 tool-call id 时就是它,不一定等于 `info.task_id`。请用 `info.task_id` 作为任务标识。
+
+执行器没有 `run_id` 的子 Agent(独立 LangGraph Server 或直接调用工厂时会出现)会跳过两个钩子,并记录一条 debug 日志。
+
+### TaskOutcome
+
+| 结果 | Lead 运行 | 子 Agent |
+| ----------- | ------------------------------------------ | ------------------------------ |
+| `completed` | 状态为 `success` | 状态为 `completed` |
+| `aborted` | 运行被停止,或状态为 `interrupted` | 状态为 `cancelled` |
+| `failed` | 其他情况,例如 `error` | 其他情况,包括超时 |
+
+这个映射刻意偏保守。触达 token 或轮次预算的子 Agent 仍可能是 `completed`。
+
+### 示例
+
+```python
+@dataclass
+class RunClock:
+ started: float
+
+
+@dataclass
+class OutcomeTally:
+ counts: dict[str, int] = field(default_factory=dict)
+ _lock: Lock = field(default_factory=Lock, repr=False)
+
+ def add(self, kind: str, outcome: TaskOutcome) -> None:
+ with self._lock:
+ key = f"{kind}:{outcome.value}"
+ self.counts[key] = self.counts.get(key, 0) + 1
+
+
+class TaskTimer:
+ async def on_task_start(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo) -> None:
+ import time
+
+ task_store.set(RunClock(time.monotonic()))
+
+ async def on_task_stop(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ info: TaskInfo,
+ outcome: TaskOutcome,
+ ) -> None:
+ import time
+
+ clock = task_store.get(RunClock)
+ elapsed = time.monotonic() - clock.started if clock is not None else float("nan")
+ app_store.get_or_init(OutcomeTally, OutcomeTally).add(info.kind, outcome)
+ logger.info("%s %s in thread %s ended %s after %.2fs", info.kind, info.task_id, info.thread_id, outcome.value, elapsed)
+```
+
+单个任务的状态放进 `task_store`,随任务消失;汇总放进 `app_store`。`on_task_stop` 里一定要处理值缺失的情况:如果别的贡献者耗尽了预算,你的 `on_task_start` 可能被跳过了。
+
+## 系统模型调用
+
+```python
+class SystemModelCallObserver(Protocol):
+ async def on_system_model_call(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ kind: SystemOperationKind,
+ request: SystemModelRequest,
+ result: SystemModelResult,
+ ) -> None: ...
+```
+
+DeerFlow 会为自己发起一些模型调用,这些调用不经过 Agent 的模型调用链,所以中间件看不到。这个观察者报告它们:
+
+| `kind` | 调用 | 报告方式 | 存储 |
+| --------------- | -------------------------------------- | -------------------------------- | ----------------- |
+| `goal` | Agent 一轮结束后的目标完成度评估 | 内联等待 | Lead 任务存储 |
+| `title` | 线程标题生成 | 内联等待 | 当前任务存储 |
+| `summarization` | 每一次摘要模型尝试,包括回退模型 | 内联等待 | 当前任务存储 |
+| `memory` | 记忆工作线程执行的记忆抽取 | 提交到通知循环,不等待 | 通常为分离存储 |
+
+只有摘要压缩的异步路径会被观察。同步的 `compact_state` 路径在 Gateway 运行时里走不到,不会报告任何东西。
+
+### 载荷
+
+`SystemModelRequest` 在调用之前采集:
+
+| 字段 | 含义 |
+| --------------- | -------------------------------------------------------------------------------- |
+| `messages` | 总是 tuple。目标评估和记忆传入消息列表;标题和摘要传入一个提示词字符串,会变成只有一项的 tuple |
+| `model_name` | 调用所用的模型(如果已知) |
+| `invoke_config` | 调用的 runnable config(是 mapping 时),否则为 `None` |
+
+`SystemModelResult` 在调用之后采集:
+
+| 字段 | 含义 |
+| ------------- | ---------------------------------------- |
+| `response` | 成功时为提供商响应,否则为 `None` |
+| `error` | 失败或取消时为该异常,否则为 `None` |
+| `duration_ms` | 调用的墙钟耗时 |
+
+对 `messages` 的规范化很重要:否则遍历一个提示词字符串会逐字符遍历。
+
+### 终止路径
+
+每条终止路径都会报告,而宿主看到的仍是调用本身的结果或异常,不受影响:
+
+- **成功**和**失败**在调用返回或抛出之后内联通知观察者。
+- **取消**很常见。停止运行,或发送打断它的后续消息,都会在提供商 token 已经花掉之后取消进行中的目标评估和摘要调用。此时等待观察者会被再次取消打断,所以宿主把通知提交到通知循环而不等待,然后重新抛出取消。`result.error` 就是那个 `CancelledError`。没有注册循环或正在关闭的宿主会丢弃这类观察。
+
+
+ `goal`、`title` 和 `summarization` 的内联通知是在运行路径上等待的,而且没有时间预算。
+ 慢的观察者会推迟它所观察的标题、摘要或目标判定。这个观察者只做计数和日志,更慢的工作交给服务。
+
+
+### 示例
+
+```python
+class SystemCallLogger:
+ async def on_system_model_call(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ kind: SystemOperationKind,
+ request: SystemModelRequest,
+ result: SystemModelResult,
+ ) -> None:
+ status = "failed" if result.error is not None else "ok"
+ logger.info(
+ "system %s call on %s: %s in %.0f ms (%d message(s), scope %s)",
+ kind.value,
+ request.model_name,
+ status,
+ result.duration_ms or 0.0,
+ len(request.messages),
+ task_store.scope_id,
+ )
+```
+
+```text
+system title call on gpt-4o-mini: ok in 612 ms (1 message(s), scope 7f3c...)
+system goal call on gpt-4o-mini: ok in 890 ms (2 message(s), scope 7f3c...)
+```
+
+## Agent 组装
+
+```python
+class AgentAssemblyObserver(Protocol):
+ def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None: ...
+```
+
+宿主构建 Agent 时,会在一次同步调用里确定实际模型、渲染系统提示词、按授权过滤工具、组合中间件栈。这些信息事后都无法还原。所以宿主在每次构建结束时发出一个 `AgentAssemblyDescriptor`,通常是每次 Lead 运行一次、每次子 Agent 执行一次。
+
+这是唯一的**同步**贡献:Agent 构建本身是同步的,没有事件循环可以等待。观察者必须轻量且不能阻塞。它只收到应用存储。没有注册组装观察者时,宿主完全不构建描述。
+
+### 描述
+
+| 字段 | 含义 | 计入指纹 |
+| --------------------- | ------------------------------------------------------------------------------ | -------- |
+| `namespace` | `"deerflow"` | 是 |
+| `agent_name` | `lead-agent`、自定义 Agent 名、`bootstrap` 或子 Agent 名 | 是 |
+| `requested_model` | 调用方请求的模型(如果有) | **否** |
+| `effective_model` | 实际发给提供商的模型 | 是 |
+| `model_parameters` | 影响行为的模型设置;不含身份和展示类字段 | 是 |
+| `thinking_enabled`、`reasoning_effort` | 解析后的推理设置 | 是 |
+| `base_prompt_hash` | 渲染后系统提示词的 `canonical_hash` | 是 |
+| `tools` | 每个绑定工具一个 `ToolDescriptor`:`name`、`description_hash`、`schema_hash`、`source`、`mcp_server`、`mcp_transport` | 是,按名称排序 |
+| `middlewares` | 栈中每项一个 `MiddlewareDescriptor`:`name`、`module`、`policy_parameters`、`extension` | 是,**保持栈顺序** |
+| `deferred_tool_names` | 藏在工具搜索后面的工具 | 是,排序 |
+| `enabled_skills` | 启用的技能名 | 是,排序 |
+| `effective_policies` | 递归上限、提示词模板 id、技能目录哈希等限制 | 是 |
+| `build` | 宿主的 `package_version`、`image_digest`、`git_commit` | **否** |
+
+`descriptor.fingerprint` 是对标为"是"的字段计算的 SHA-256,回答的是"这个 Agent 的行为方式有没有变?":
+
+- 工具和技能要排序,因为它们的组装顺序是偶然的。中间件保持栈顺序,因为顺序决定谁包裹谁。
+- 排除 `build`,这样配置不变的重新部署不会改变任何指纹。需要知道宿主是否变化时,直接比较 `build`。
+- 排除 `requested_model`,因为只有 `effective_model` 会到达提供商。
+- 贡献的中间件按它包裹的类来描述,`extension` 字段写明贡献它的入口点,所以两个扩展的中间件不会合并成一项。
+
+`image_digest` 和 `git_commit` 来自环境变量 `DEER_FLOW_IMAGE_DIGEST` 和 `DEER_FLOW_GIT_COMMIT`,未设置时为 `unknown`。
+
+### 声明中间件的策略
+
+默认情况下,宿主通过探测一组固定的公开属性来描述中间件。更好的做法是声明真正影响中间件行为的参数,这样它们一变,指纹就变:
+
+```python
+class ToolTimer(AgentMiddleware):
+ def __init__(self, slow_ms: float) -> None:
+ super().__init__()
+ self.slow_ms = slow_ms
+
+ def release_policy_parameters(self) -> dict[str, object]:
+ return {"slow_ms": self.slow_ms}
+```
+
+描述里就会记录 `MiddlewareDescriptor(name="ToolTimer", ..., policy_parameters={"slow_ms": 200}, extension="deerflow_extension_hello:install")`。值必须可 JSON 序列化:长文本请先哈希,而不是直接嵌入。契约包也导出了宿主使用的辅助函数,让扩展能算出完全相同的哈希:
+
+- `canonical_json(value)`:键排序、无多余空白的 JSON。遇到无法序列化的值会抛出 `TypeError`,而不是退回 `repr`。
+- `canonical_hash(value)`:`canonical_json(value)` 的 SHA-256 十六进制摘要。
+- `collect_release_policies(middlewares)`:栈中所有声明,以类名为键。重复的类得到 `Name#2` 等键;抛出异常的声明记为 `{"error": ""}` 而不是被丢弃。
+
+### 示例
+
+```python
+@dataclass
+class Fingerprints:
+ by_agent: dict[str, str] = field(default_factory=dict)
+ _lock: Lock = field(default_factory=Lock, repr=False)
+
+ def swap(self, agent: str, fingerprint: str) -> str | None:
+ with self._lock:
+ previous = self.by_agent.get(agent)
+ self.by_agent[agent] = fingerprint
+ return previous
+
+
+class AssemblyDriftWatcher:
+ def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None:
+ previous = app_store.get_or_init(Fingerprints, Fingerprints).swap(descriptor.agent_name, descriptor.fingerprint)
+ if previous is not None and previous != descriptor.fingerprint:
+ logger.warning("agent %s changed: %s -> %s", descriptor.agent_name, previous[:12], descriptor.fingerprint[:12])
+```
+
+观察者抛出的异常会被记入日志,Agent 照常构建。
+
+## 上下文压缩
+
+```python
+class ContextCompactionObserver(Protocol):
+ async def on_context_compacted(self, app_store: ExtensionData, task_store: ExtensionData, event: CompactionEvent) -> None: ...
+```
+
+摘要压缩把许多消息替换成一条摘要。之后,状态里再也没有记录哪些消息变成了这条摘要。宿主在这个映射还存在的唯一时刻把它记下来:在摘要调用之前对每条将被移除的消息做哈希,在摘要产出之后发出一个 `CompactionEvent`。
+
+| 字段 | 含义 |
+| ------------------------- | ------------------------------------------------------ |
+| `transform_kind` | `"summarization"` |
+| `transform_version` | `"1"` |
+| `source_content_hashes` | 每条被移除消息的 `canonical_hash(message.content)`,按顺序 |
+| `output_content_hash` | 摘要文本的 `canonical_hash` |
+| `compacted_message_count` | 移除了多少条消息 |
+| `kept_message_count` | 保留了多少条消息 |
+
+要把事件和你手里的消息对应起来,必须用完全相同的方式哈希:`canonical_hash(message.content)`,直接传入 content 本身。绝不要先转成字符串,因为多模态 content 是 dict 列表,`str()` 的结果取决于键顺序。也不要试图事后通过对模型看到的内容做哈希来找回摘要:提示词里带的是摘要经过截断和转义的渲染版本,它的哈希不会等于 `output_content_hash`。
+
+这个通知是即发即弃的。它被分派到通知循环,不会阻塞模型这一轮;观察者收到的是**分离**存储,因为在这个调用点没有活跃任务。没有注册压缩观察者时,宿主连哈希这一步也会跳过。
+
+### 示例
+
+```python
+class CompactionLogger:
+ async def on_context_compacted(self, app_store: ExtensionData, task_store: ExtensionData, event: CompactionEvent) -> None:
+ logger.info(
+ "%s v%s folded %d message(s) into summary %s, kept %d",
+ event.transform_kind,
+ event.transform_version,
+ event.compacted_message_count,
+ event.output_content_hash[:12],
+ event.kept_message_count,
+ )
+```
+
+## 常见误区
+
+- **在钩子里做 I/O。** 生命周期钩子共享 3 秒预算;内联的系统模型通知没有预算,而且在运行路径上;组装观察者会阻塞构建。先缓冲到存储里,再由[服务](/docs/harness/extensions/services-and-routes)刷出。
+- **把状态放在分离存储上。** 通知结束后它就被丢弃。需要留存的东西请用应用存储。
+- **以为有开始就一定有结束。** 开始被跳过(预算耗尽)或运行在开始之前就被取消,都会产生不对称的序列。让 `on_task_stop` 能容忍状态缺失。
+- **把指纹当作部署 id。** 它刻意忽略了 `build`。要识别宿主二进制,请读 `descriptor.build`。
diff --git a/frontend/src/content/zh/harness/extensions/operations.mdx b/frontend/src/content/zh/harness/extensions/operations.mdx
new file mode 100644
index 000000000..8fbcad5b9
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/operations.mdx
@@ -0,0 +1,248 @@
+---
+title: 运维扩展
+description: 运维人员如何用扩展管理器安装、升级、启用、禁用和移除扩展。涵盖使用哪个 config.yaml、plugins 记录、可接受的来源、本地快照、事务回滚与加锁、用 Docker 和 Helm 部署,以及当必需扩展阻止 Gateway 启动时如何恢复。
+---
+
+import { Callout } from "nextra/components";
+
+# 运维扩展
+
+本章面向运维人员,说明扩展管理器会对 checkout 做什么、每条命令输出什么,以及已安装的扩展如何进入生产镜像。编写扩展请从[快速上手](/docs/harness/extensions/quick-start)开始。
+
+## 命令
+
+在 DeerFlow checkout 根目录运行 `make` 包装命令,它们都会在 `backend/` 下调用同一个 CLI:
+
+| `make` 目标 | CLI(在 `backend/` 下运行) | 作用 |
+| -------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------- |
+| `make extension-install SOURCE=` | `deerflow extensions install [--yes] [--required]` | 安装一个包并添加一条已启用的 `plugins:` 记录 |
+| `make extension-upgrade SOURCE=` | `deerflow extensions upgrade [--yes]` | 替换已安装的包,保留记录中的设置 |
+| `make extension-list` | `deerflow extensions list` | 按加载顺序列出已配置的扩展 |
+| `make extension-enable NAME=` | `deerflow extensions enable ` | 设置 `enabled: true` |
+| `make extension-disable NAME=` | `deerflow extensions disable ` | 设置 `enabled: false`;包和 `config` 保留 |
+| `make extension-remove NAME=` | `deerflow extensions remove ` | 删除记录、卸载包、删除其快照 |
+
+直接调用 CLI 的写法是 `uv run --frozen --no-group extensions deerflow extensions `,包装命令就是这么做的。不带 `extensions` 依赖组运行,意味着一个坏掉或缺失的扩展包不会妨碍你列出、禁用或移除它。
+
+`NAME` 可以匹配记录的 `name`、`package`(发行包名,规范化后比较)或 `use`。必须恰好匹配一条记录,否则命令会以 `expected exactly one configured extension matching ''` 失败。
+
+**每条命令都要在 Gateway 重启后才生效。** Gateway 只在构建应用时读取一次 `plugins:`。
+
+### 输出
+
+除非传了 `--yes`,`install` 和 `upgrade` 会要求确认:
+
+```text
+Warning: a Python extension executes code with Gateway privileges.
+Install this trusted source? [y/N]
+```
+
+回答 `y` 或 `yes` 以外的任何内容都会打印 `Extension installation cancelled.`(或 `Extension upgrade cancelled.`)并以状态码 2 退出。只在已经审查过来源的自动化流程里传 `--yes`:安装时会运行包的构建后端,它的代码会在 Gateway 内运行。
+
+成功时:
+
+```text
+Installed and enabled hello (deerflow-extension-hello). Restart DeerFlow to load it.
+Upgraded hello (deerflow-extension-hello). Restart DeerFlow to load it.
+Enabled hello. Restart DeerFlow to apply the change.
+Disabled hello. Restart DeerFlow to apply the change.
+Removed hello. Restart DeerFlow to apply the change.
+```
+
+`list` 打印一张以制表符分隔的表。没有 `name` 或 `package` 的手写记录,名称显示为 `use` 的值,包显示为 `-`:
+
+```text
+NAME STATE PACKAGE ENTRY POINT
+hello enabled deerflow-extension-hello deerflow_extension_hello:install
+my_local_ext:install enabled - my_local_ext:install
+```
+
+任何失败都会向 stderr 打印 `extension command failed: <原因>` 并以状态码 1 退出。各种原因见[故障排查](/docs/harness/extensions/troubleshooting)。
+
+## 使用哪个 config.yaml
+
+管理器选定一个文件,只编辑其中的 `plugins:` 段:
+
+1. 设置了 `DEER_FLOW_CONFIG_PATH` 时用它。
+2. 否则用 checkout 根目录的 `config.yaml`,前提是它存在,或者 `backend/config.yaml` 不存在。
+3. 否则用旧位置 `backend/config.yaml`。
+
+checkout 根目录在设置了 `DEER_FLOW_PROJECT_ROOT` 时就是它,否则是当前目录向上最近一个包含 `backend/pyproject.toml` 的目录。
+
+Gateway 解析配置时同样先看 `DEER_FLOW_CONFIG_PATH`。如果你用非默认的配置路径运行 Gateway,运行管理器时也要设置同一个变量,否则它会编辑一个 Gateway 根本不读的文件。
+
+管理器在运行任何 `uv` 命令之前先校验这个文件。文件不存在(`DeerFlow config not found: `)、不是合法的 YAML、根节点不是映射、`plugins` 不是列表,或者顶层出现两次 `plugins:` 时,它都会拒绝继续。
+
+## plugins 记录
+
+```yaml
+plugins:
+ - name: hello
+ package: deerflow-extension-hello
+ use: deerflow_extension_hello:install
+ enabled: true
+ required: false
+ config:
+ slow_ms: 200
+ # table_prefix: hello_
+```
+
+| 字段 | 默认值 | 管理器是否写入 | 含义 |
+| -------------- | -------- | ---------------------- | ------------------------------------------------------------------------------------- |
+| `use` | 必填 | 是 | 入口点,写作 `module.path:install` |
+| `name` | 无 | 是 | 面向运维的名称:包元数据中的入口点名称 |
+| `package` | 无 | 是 | 发行包名。`remove` 需要它 |
+| `enabled` | `true` | 是 | `false` 时跳过该条目,不导入 |
+| `required` | `false` | 是(除非 `--required`,否则为 `false`) | `true` 时该条目加载失败会中止 Gateway 启动 |
+| `config` | `{}` | 是(`{}`) | 交给 `install()` 的私有配置。管理器从不覆盖它 |
+| `table_prefix` | 无 | 否 | 扩展在 DeerFlow 数据库中拥有的表名前缀。见[数据库表](#数据库表) |
+
+Gateway 加载配置时会拒绝未知的键,所以字段名拼错会让 Gateway 无法启动,而不是被悄悄忽略。
+
+管理器通过 YAML 序列化器重写整个 `plugins:` 段,所以段**内**的注释和格式不会保留。段外的一切都不动,包括位于文件末尾的段下方紧跟的注释行。写入是原子的:先写临时文件,再保留原文件权限重命名覆盖配置。
+
+### 手写记录
+
+你可以手工添加记录,例如对于环境里已经可以导入的模块。这样的记录只需要 `use`。`enable`、`disable`、`list` 都能处理它;`remove` 会以 `configured extension '' has no managed package metadata` 拒绝,因为没有可卸载的包。请自己删除这条记录。
+
+`install` 如果发现已有一条 `use` 相同的记录,会接管它:补上 `name` 和 `package`,设置 `enabled: true`,保留你的 `required` 值和 `config`。
+
+### 数据库表
+
+如果扩展用自己的 SQLAlchemy metadata 和迁移链在 DeerFlow 数据库中持久化数据,应当在 `table_prefix` 中声明其表名前缀。DeerFlow 会把这些表排除在 `alembic revision --autogenerate` 之外,否则 autogenerate 会提议删除它们。即使记录被禁用,前缀也会注册,因为这些表可能已经存在。
+
+会遮住 DeerFlow 自己某张表的前缀总会中止启动,无论 `required` 是什么:
+
+```text
+extension table_prefix 'runs' would hide host-owned table(s) ['runs'] from alembic autogenerate; choose a prefix that is not a prefix of any host table name
+```
+
+空的 `table_prefix: ""` 会被拒绝;不声明前缀就直接省略这个键。
+
+## 必需与可选扩展
+
+`required: false` 是默认值,也是管理器写入的值,此时任何加载失败都只记日志,Gateway 不带该扩展照常启动。`required: true` 时,同样的失败会在 Gateway 构建应用时抛出 `ExtensionLoadError`,Gateway 无法启动。
+
+只在缺了这个扩展部署就是错的情况下设置 `required: true`,例如你有义务保留的审计轨迹。要清楚,之后一次构建出错、一个缺失的原生库或一个被删除的快照,都会变成需要 shell 权限才能修复的故障。`install --required` 是管理器写入 `true` 的唯一方式;`upgrade` 和接管已有记录都会保留原来的值。
+
+## 可接受的来源
+
+| 来源 | 示例 | 是否接受 |
+| --------------------------------------------------------- | ----------------------------------------------------------------------- | -------- |
+| 来自索引的包依赖声明 | `deerflow-extension-acme==1.2.3` | 是 |
+| 通过 HTTPS 访问的公开 Git,固定到某个提交 | `git+https://github.com/acme/deerflow-extension-acme.git@` | 是 |
+| HTTPS 直接引用 | `https://example.com/deerflow_extension_acme-1.2.3-py3-none-any.whl` | 是 |
+| 本地目录(绝对路径) | `$HOME/src/deerflow-extension-hello` | 是,作为快照 |
+| 指向 `localhost`、`127.0.0.1` 或 `::1` 的 HTTP | `http://127.0.0.1:8080/pkg.whl` | 是;如果写进 `uv.lock` 会有警告 |
+| Git SSH 简写 | `git@github.com:acme/x.git` | 否 |
+| SSH URL | `git+ssh://git@github.com/acme/x.git` | 否 |
+| 指向其他主机的普通 HTTP | `http://example.com/x.whl` | 否 |
+| 内嵌凭据的 URL | `https://user:pw@example.com/x.whl` | 否 |
+| 查询串或片段中含类似凭据键的 URL | `...?token=abc`、`...#api_key=...` | 否 |
+| `file:` URL、相对路径、本地 wheel 或其他本地文件 | `file:///tmp/x`、`./pkg.whl` | 否 |
+
+这些规则的原因是生产镜像只从 checkout 构建。标准 Docker 构建器不转发 SSH 凭据,访问不到 `backend/` 之外的文件,也不能把机密固化在记录下来的来源 URL 里。私有索引请通过 uv 自己的索引和凭据设置认证,管理器会保留这些设置。
+
+### 本地快照
+
+本地目录不会被链接,而是被复制到 `backend/extensions/sources/<规范化的发行包名>/`,Docker 构建上下文会包含这个目录。复制时跳过 `.git`、`.venv`、`venv`、`__pycache__` 和 `*.pyc`。以下情况管理器会拒绝该目录:
+
+- 没有 `pyproject.toml`,或者没有声明 `project.name` 以及 `deerflow.extensions` 组中**恰好一个**入口点;
+- 包含符号链接或 junction,或者包含普通文件和目录之外的东西;
+- 包含疑似机密:`.env`、`.env.*`、`.npmrc`、`.pypirc`、`credentials.json`,或以 `.key`、`.pem`、`.p12`、`.pfx` 结尾的文件;
+- 已经安装过。请用 `upgrade` 替换。
+
+这些检查拦的是打包失误,不是恶意软件扫描。
+
+因为快照是一份拷贝,对工作目录的修改只能通过 `make extension-upgrade SOURCE=<同一路径>` 再重启才能到达 DeerFlow。
+
+## 升级
+
+`upgrade` 替换一个已安装扩展的来源,并保留它的记录:`config`、`required`、`enabled` 都保持不变。
+
+- 本地目录必须已有快照。旧快照会先挪到一边,升级失败时恢复。
+- 包依赖声明必须指向 `extensions` 组里已有的发行包,例如 `deerflow-extension-acme==1.3.0`。
+- Git URL 必须指向组里已经固定的仓库;传入新的提交。
+
+其他情况会以 `... is not installed; use install` 失败。新版本必须保持相同的入口点目标(`use`)。如果目标变了,请先移除扩展再重新安装。
+
+## 一次安装会改动什么
+
+1. 校验来源和配置文件。
+2. 检查 `uv --version` 不低于 0.8.0。
+3. 如果是本地目录,复制快照。
+4. 运行 `uv add --project backend --group extensions --no-workspace --no-sync -- `,更新 `backend/pyproject.toml` 中 `[dependency-groups]` 下的 `extensions` 列表和 `backend/uv.lock`。
+5. 审计新的锁文件(见下文)。
+6. 运行 `uv sync --project backend --all-packages --locked`,并带上常规启动会从配置中检测出的同样的可选 extras。
+7. 在同步后的环境中找到包唯一的 `deerflow.extensions` 入口点,检查它能加载且可调用。
+8. 写入 `plugins:` 记录。这是最后一步,所以此前的任何失败都不会动配置。
+
+`remove` 顺序相反:先停用记录,然后运行 `uv remove --group extensions`,审计锁文件,把快照挪到一边,再同步。如果还有别的记录使用同一个 `package`,`remove` 只删除匹配的那条记录,包保持安装。
+
+每次调用 `uv` 都会固定 backend 项目,并去掉可能改变其行为的环境变量,包括 `UV_PROJECT`、`UV_PYTHON`、`UV_FROZEN`、`UV_NO_SYNC`、`UV_PROJECT_ENVIRONMENT` 和 `UV_INSECURE_HOST`。索引、代理、缓存和凭据提供方设置保持可用。
+
+### 回滚
+
+安装、升级或移除失败时,会恢复 `backend/pyproject.toml`、`backend/uv.lock`、快照目录,对 `remove` 还会恢复配置,然后重新同步环境以匹配恢复后的文件。如果这次恢复同步也失败,错误会同时报告两个失败(`extension operation failed and the restored environment could not be synchronized; original failure: ...`)。如果操作被中断(Ctrl+C),文件会恢复,但跳过恢复同步;下次启动时的锁定同步会让环境重新一致。
+
+恢复绝不覆盖别人的修改。如果某个依赖文件或配置在操作期间被改动,管理器会保留这个并发修改,并以 `... recovery preserved a concurrent dependency-file edit`(或 `concurrent config edit`)失败。以这种方式中断的 `remove` 会让扩展保持停用状态。
+
+### 加锁
+
+同一个 checkout 的所有写操作命令都会持有 checkout 根目录下 `.deer-flow/extension-manager.lock` 的排他锁,所以两个运维人员或两个 CI 任务的安装不会交错。第二条命令会等到第一条结束。`list` 不加锁。
+
+### 锁文件审计
+
+一个通过了校验的来源,仍可能解析成镜像构建无法复现的东西,例如 `UV_FIND_LINKS` 让 uv 指向本地的 wheel 目录。每次 `uv add` 和 `uv remove` 之后,管理器都会扫描 `uv.lock`。任何绝对路径、`file:` URL,或位于 backend 项目、其 workspace 成员和 `extensions/sources/` 之外的相对路径,都会让整个操作以 `uv.lock contains a local dependency source outside the backend Docker build context` 失败并回滚。回环 URL 只产生一条警告 `uv.lock records a loopback dependency source the backend Docker build cannot reach`,因为那是你有意输入的来源。它在镜像构建器里仍然无法解析。
+
+## 部署
+
+### 本地开发
+
+`make dev`、`make install` 和 `cd backend && make dev` 都使用 `uv sync --locked` 或 `uv run --locked`,因此安装的正是 `backend/uv.lock` 记录的内容,包括作为默认组的 `extensions` 组。它们可能下载本地缺失的已锁定制品,但从不重新解析依赖。
+
+### Docker
+
+后端镜像从 checkout 的 `backend/` 目录构建,包括 `pyproject.toml`、`uv.lock` 和 `extensions/sources/`,使用 `uv sync --locked`。容器以 `uv run --no-sync` 启动 Gateway,所以**生产容器在启动时从不安装扩展**。改动已安装集合之后要重新构建:
+
+```bash
+make up
+```
+
+把 `backend/pyproject.toml`、`backend/uv.lock` 和 `backend/extensions/sources/` 提交到你构建镜像所用的分支,并在部署挂载的 `config.yaml` 里保留对应的 `plugins:` 记录。
+
+开发容器(`make docker-start`)在启动时运行 `uv sync --locked --all-packages`。失败时它会重建 `.venv` 并重试一次,仍然带 `--locked`。第二次失败会让容器停止并输出:
+
+```text
+[startup] uv sync --locked failed again after recreating .venv.
+[startup] backend/uv.lock does not match backend/pyproject.toml, or a locked artifact is unreachable.
+```
+
+### Helm
+
+chart 不负责安装扩展。按上面的方式,从已安装该扩展的 checkout 构建 Gateway 镜像,并把 `plugins:` 记录加到 chart 渲染成 `config.yaml` 的 `config` 值里。chart 的 `extensionsConfig` 值渲染的是 `extensions_config.json`,其中存放 MCP 服务器和技能;放在那里的 `plugins:` 会被忽略。
+
+### uv 版本
+
+管理器需要 uv 0.8.0 或更新版本(`extension installation requires uv 0.8.0 or newer`)。生产环境固定一个确切版本:`backend/Dockerfile` 中的 `UV_IMAGE` 构建参数(当前为 `ghcr.io/astral-sh/uv:0.11.1`)。compose 文件和 CI 使用同一版本,`backend/tests/test_ci_uv_version_pin.py` 让它们保持一致。本地安装或升级扩展时请使用同一个 uv:更新的 uv 可能写出镜像中固定版本的 uv 读不了的 `uv.lock`。
+
+## 必需扩展阻止启动时如何恢复
+
+
+ 加载失败的 `required: true` 扩展会以
+ `ExtensionLoadError: required extension failed to load`(或
+ `... failed to install`、`... declares incompatible api ...`)让 Gateway 停止。
+ 在你从 shell 修复之前,Web 界面不可用。
+
+
+1. 查看紧挨在它之前记录的错误行:`Extension : <原因>`,它写明了原因。
+2. 让 Gateway 不带这个扩展启动。可以禁用它:
+
+ ```bash
+ make extension-disable NAME=
+ ```
+
+ 也可以编辑 `config.yaml` 中的记录,设置 `required: false`,这样它能用时照常加载,出错时被跳过。管理包装命令在不带 `extensions` 依赖组的情况下运行,所以即使扩展包本身坏了也能用。
+3. 重启 Gateway,修复原因(例如 `make extension-upgrade SOURCE=...`),重新启用,再重启。
+
+`table_prefix` 冲突无论 `required` 为何都会中止启动;请修改或删除该前缀。
diff --git a/frontend/src/content/zh/harness/extensions/quick-start.mdx b/frontend/src/content/zh/harness/extensions/quick-start.mdx
new file mode 100644
index 000000000..dc050e44b
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/quick-start.mdx
@@ -0,0 +1,271 @@
+---
+title: 快速上手
+description: 十五分钟内构建、测试、安装并移除一个可用的扩展。示例会记录每次工具调用的耗时,覆盖 Lead Agent 和所有子 Agent。
+---
+
+import { Callout, Steps } from "nextra/components";
+
+# 快速上手
+
+本章完整走一遍一个名为 `hello` 的扩展。它贡献一个中间件,为每次工具调用计时,调用慢于配置阈值时记录一条警告。读完你会把它打包、在不依赖 DeerFlow 的情况下做单元测试、安装进一个 checkout,并看到它运行。
+
+## 前提
+
+- 一个能用 `make dev` 运行的 DeerFlow checkout。见[快速上手](/docs/application/quick-start)。
+- Python 3.12 及以上,以及 [uv](https://docs.astral.sh/uv/) 0.8.0 及以上。扩展管理器会拒绝更旧的 uv。
+- 运行 Gateway 那台机器的 shell 权限。安装扩展是运维操作,Web 界面不能做。
+
+
+
+### 创建包
+
+扩展就是一个普通的 Python 包。把它建在 DeerFlow checkout **之外**,例如 `~/src/deerflow-extension-hello`:
+
+```text
+deerflow-extension-hello/
+├── pyproject.toml
+├── deerflow_extension_hello/
+│ └── __init__.py
+└── tests/
+ └── test_hello.py
+```
+
+`pyproject.toml` 声明契约版本范围、代码导入的每一个框架,以及 `deerflow.extensions` 组里唯一的一个入口点:
+
+```toml filename="pyproject.toml"
+[project]
+name = "deerflow-extension-hello"
+version = "0.1.0"
+requires-python = ">=3.12"
+dependencies = [
+ "deerflow-extension-api>=0.2,<0.3",
+ "langchain>=1.3,<2",
+]
+
+[project.entry-points."deerflow.extensions"]
+hello = "deerflow_extension_hello:install"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["deerflow_extension_hello"]
+```
+
+入口点名称 `hello` 就是运维人员在 `enable`、`disable`、`remove` 中使用的名字。
+
+
+ `deerflow-extension-api` 刻意不带任何依赖。如果你的代码导入了 LangChain、LangGraph
+ 或 FastAPI,需要自己声明,就像这里声明了 `langchain`。不要导入 `deerflow.*` 或
+ `app.*`:那是宿主内部实现,没有兼容性承诺。
+
+
+### 编写 install()
+
+```python filename="deerflow_extension_hello/__init__.py"
+"""Log how long each tool call takes, as the model sees it."""
+
+from __future__ import annotations
+
+import logging
+import time
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from deerflow_extension_api import (
+ AgentBuildContext,
+ AgentScope,
+ ExtensionData,
+ ExtensionRegistry,
+ MiddlewarePlacement,
+ Placement,
+ extension,
+)
+from langchain.agents.middleware import AgentMiddleware
+
+logger = logging.getLogger(__name__)
+
+
+class ToolTimer(AgentMiddleware):
+ def __init__(self, slow_ms: float) -> None:
+ super().__init__()
+ self.slow_ms = slow_ms
+
+ def _report(self, request: Any, started: float) -> None:
+ elapsed_ms = (time.perf_counter() - started) * 1000
+ level = logging.WARNING if elapsed_ms >= self.slow_ms else logging.INFO
+ logger.log(level, "tool %s took %.1f ms", request.tool_call.get("name"), elapsed_ms)
+
+ def wrap_tool_call(self, request, handler):
+ started = time.perf_counter()
+ try:
+ return handler(request)
+ finally:
+ self._report(request, started)
+
+ async def awrap_tool_call(self, request, handler):
+ started = time.perf_counter()
+ try:
+ return await handler(request)
+ finally:
+ self._report(request, started)
+
+
+class ToolTimerContributor:
+ def __init__(self, slow_ms: float) -> None:
+ self.slow_ms = slow_ms
+
+ def contribute_middlewares(
+ self,
+ app_store: ExtensionData,
+ ctx: AgentBuildContext,
+ ) -> Sequence[MiddlewarePlacement]:
+ return (MiddlewarePlacement(ToolTimer(self.slow_ms), Placement.TOOL_VISIBLE, AgentScope.BOTH),)
+
+
+@extension(api="0.2.0", name="hello")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ registry.middlewares(ToolTimerContributor(float(config.get("slow_ms", 1000))))
+```
+
+注意三点:
+
+- `install()` 只负责**注册**。它在 Gateway 启动时运行一次,那时还没有任何 Agent。宿主之后每组装一个 Agent,就调用一次 `contribute_middlewares()`。
+- `Placement.TOOL_VISIBLE` 要求位于工具链的外层端,所以计时包含输出截断和错误包装,也就是模型最终等待的时间。`AgentScope.BOTH` 把中间件装进 Lead Agent 和每个子 Agent。两者详见[中间件贡献](/docs/harness/extensions/middleware)。
+- 中间件同时实现了 `wrap_tool_call` 和 `awrap_tool_call`。如果只实现一侧,另一条执行路径会直接透传,什么也观察不到。
+
+### 不依赖 DeerFlow 进行测试
+
+契约就是普通 Python,所以用一个替身注册表就能测试注册逻辑,中间件也可以直接调用:
+
+```python filename="tests/test_hello.py"
+import asyncio
+import logging
+from types import SimpleNamespace
+
+from deerflow_extension_api import AgentBuildContext, AgentScope, ExtensionData, Placement
+
+from deerflow_extension_hello import ToolTimer, install
+
+
+class RecordingRegistry:
+ def __init__(self):
+ self.contributors = []
+
+ def middlewares(self, contributor):
+ self.contributors.append(contributor)
+
+
+def test_install_registers_one_tool_visible_middleware():
+ registry = RecordingRegistry()
+ install(registry, {"slow_ms": 50})
+
+ (contributor,) = registry.contributors
+ ctx = AgentBuildContext(scope=AgentScope.LEAD)
+ (placement,) = contributor.contribute_middlewares(ExtensionData("app"), ctx)
+ assert placement.placement is Placement.TOOL_VISIBLE
+ assert placement.middleware.slow_ms == 50
+
+
+def test_slow_tool_calls_log_a_warning(caplog):
+ timer = ToolTimer(slow_ms=0)
+ request = SimpleNamespace(tool_call={"name": "web_search"})
+
+ async def handler(req):
+ return "result"
+
+ with caplog.at_level(logging.INFO):
+ assert asyncio.run(timer.awrap_tool_call(request, handler)) == "result"
+ assert "tool web_search took" in caplog.text
+ assert caplog.records[-1].levelno == logging.WARNING
+```
+
+契约包目前从 DeerFlow checkout 获取,所以从那里以 editable 模式安装:
+
+```bash
+cd ~/src/deerflow-extension-hello
+uv venv --python 3.12
+uv pip install -e /path/to/deer-flow/backend/packages/extension-api -e . pytest
+uv run --no-project pytest -q
+```
+
+### 安装到 DeerFlow
+
+在 DeerFlow checkout 根目录下,以**绝对**路径传入包目录。Make 包装命令是在 `backend/` 下运行管理器的,相对路径会解析到错误的目录:
+
+```bash
+make extension-install SOURCE="$HOME/src/deerflow-extension-hello"
+```
+
+管理器会警告扩展将以 Gateway 权限执行,并询问 `Install this trusted source? [y/N]`。确认后,它会:
+
+1. 把目录快照复制到 `backend/extensions/sources/deerflow-extension-hello/`。之后对工作副本的修改不会生效,直到你运行 `make extension-upgrade`;
+2. 把快照加入 `backend/pyproject.toml` 的 `extensions` 依赖组,并更新 `backend/uv.lock`;
+3. 同步锁定的环境;
+4. 在 `config.yaml` 中追加一条已启用的 `plugins:` 记录。
+
+然后打印 `Installed and enabled hello (deerflow-extension-hello). Restart DeerFlow to load it.`。检查记录:
+
+```bash
+make extension-list
+```
+
+```text
+NAME STATE PACKAGE ENTRY POINT
+hello enabled deerflow-extension-hello deerflow_extension_hello:install
+```
+
+### 配置并重启
+
+管理器写入的私有配置是空的 `config: {}`。要调低慢调用阈值,编辑 `config.yaml` 中的这条记录。启用、禁用和升级都会保留这一段:
+
+```yaml filename="config.yaml"
+plugins:
+ - name: hello
+ package: deerflow-extension-hello
+ use: deerflow_extension_hello:install
+ enabled: true
+ required: false
+ config:
+ slow_ms: 200
+```
+
+扩展只在 Gateway 启动时加载,所以要重启:
+
+```bash
+make dev
+```
+
+Gateway 日志会确认加载成功:
+
+```text
+Extensions loaded: 1/1 (deerflow_extension_hello:install)
+```
+
+如果入口点无法导入、版本不兼容,或者 `install()` 抛出异常,计数会显示 `0/1`,并有一行以 `Extension deerflow_extension_hello:install:` 开头的错误说明原因。因为记录是 `required: false`,Gateway 仍会启动。
+
+### 看它运行
+
+发一条会让 Agent 使用工具的消息,比如网页搜索。Gateway 日志里每次工具调用都有一行,包括 Lead Agent 和它委派的子 Agent。具体前缀取决于你的日志格式:
+
+```text
+WARNING deerflow_extension_hello: tool web_search took 1432.7 ms
+```
+
+### 禁用或移除
+
+```bash
+make extension-disable NAME=hello # 保留包和配置,停止加载
+make extension-enable NAME=hello
+make extension-remove NAME=hello # 卸载,删除记录和快照
+```
+
+每条命令都在下次重启后生效。如果用 Docker 部署,修改已安装的扩展集合后要重新构建 Gateway 镜像;构建好的生产容器在启动时不会安装扩展。
+
+
+
+## 下一步
+
+- [中间件贡献](/docs/harness/extensions/middleware):五种放置位置、作用域和排序,以及扩展中间件能改什么、不能改什么。
+- [内置示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example)把中间件与任务生命周期状态、系统模型观察者、服务和 HTTP 路由组合在一起。
diff --git a/frontend/src/content/zh/harness/extensions/reference.mdx b/frontend/src/content/zh/harness/extensions/reference.mdx
new file mode 100644
index 000000000..c74c803bb
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/reference.mdx
@@ -0,0 +1,413 @@
+---
+title: 参考
+description: deerflow-extension-api 0.2.1 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。
+---
+
+# 参考
+
+本页列出契约版本 **0.2.1** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入:
+
+```python
+from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement
+```
+
+这个包没有任何依赖,也从不导入 DeerFlow。下文写成 `Any` 的类型(例如中间件或路由器)由宿主在运行时校验,而不是由契约校验。
+
+## 入口点与注册表
+
+### `ExtensionInstall`
+
+```python
+ExtensionInstall = Callable[[ExtensionRegistry, Mapping[str, Any]], None]
+```
+
+`plugins:` 记录的 `use` 所指函数的签名。第二个参数是记录中 `config` 的浅拷贝。
+
+### `extension(*, api, name=None)`
+
+装饰器,为 install 函数标记编写时所用的契约版本(`__deerflow_api__`)和一个可选名称(`__deerflow_name__`)。可选;见[兼容性规则](#兼容性规则)。
+
+```python
+@extension(api="0.2.0", name="hello")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ...
+```
+
+### `ExtensionRegistry`
+
+一个 `runtime_checkable` 的 Protocol:传给 `install()` 的只写接口。每个方法都有一个什么都不注册的默认实现。如果宿主的注册表早于某个方法,就会继承这个默认实现,于是调用成功,但贡献并没有被注册。要靠版本标记和包元数据来防止这种情况。
+
+| 方法 | 返回值 | 注册内容 |
+| ---------------------------------------------------------- | ------- | ---------------------------------------------------------------------------- |
+| `middlewares(contributor: MiddlewareContributor)` | `None` | 一个中间件贡献者 |
+| `task_lifecycle(contributor: TaskLifecycleContributor)` | `None` | Lead 运行和子 Agent 的开始、结束钩子 |
+| `system_model_observer(observer: SystemModelCallObserver)` | `None` | DeerFlow 自有模型调用的观察者 |
+| `agent_assembly_observer(observer: AgentAssemblyObserver)` | `None` | 已组装 Agent 的观察者 |
+| `context_compaction_observer(observer: ContextCompactionObserver)` | `None` | 摘要压缩的观察者 |
+| `service(service: ExtensionService)` | `None` | 与 Gateway 同寿的服务 |
+| `routers(routers: Sequence[Any])` | `None` | 在 `install()` 期间构建的 FastAPI 路由器 |
+
+## 中间件
+
+### `MiddlewareContributor`
+
+```python
+class MiddlewareContributor(Protocol):
+ def contribute_middlewares(
+ self, app_store: ExtensionData, ctx: AgentBuildContext
+ ) -> Sequence[MiddlewarePlacement]: ...
+```
+
+每次组装 Agent 都会调用。默认返回 `()`。
+
+### `MiddlewarePlacement`
+
+冻结的 dataclass。
+
+| 字段 | 类型 | 默认值 |
+| ------------ | ------------ | ----------------- |
+| `middleware` | `Any`(必须是 LangChain `AgentMiddleware`) | 必填 |
+| `placement` | `Placement` | 必填 |
+| `scope` | `AgentScope` | `AgentScope.BOTH` |
+| `order` | `int` | `0` |
+
+### `Placement`
+
+`StrEnum`:`MODEL_LOGICAL = "model_logical"`、`MODEL_PHYSICAL = "model_physical"`、`TOOL_VISIBLE = "tool_visible"`、`TOOL_RAW = "tool_raw"`、`STANDARD = "standard"`。各自的保证见[中间件贡献](/docs/harness/extensions/middleware)。
+
+### `AgentScope`
+
+`Flag`:`LEAD`、`SUBAGENT`、`BOTH = LEAD | SUBAGENT`。
+
+### `AgentBuildContext`
+
+传给 `contribute_middlewares()` 的冻结 dataclass。
+
+| 字段 | 类型 | 默认值 |
+| ------------ | -------------------- | ---------------------- |
+| `scope` | `AgentScope` | 必填 |
+| `agent_name` | `str \| None` | `None` |
+| `model_name` | `str \| None` | `None` |
+| `policy` | `HostPolicySnapshot` | `HostPolicySnapshot()` |
+
+### `HostPolicySnapshot`
+
+冻结的 dataclass:宿主实际执行的限制,以投影的形式提供,使扩展不依赖 DeerFlow 的配置类型。每个字段都有默认值。
+
+| 字段 | 类型 | 默认值 |
+| ----------------------- | --------------- | ------- |
+| `token_budget_enabled` | `bool` | `False` |
+| `max_input_tokens` | `int \| None` | `None` |
+| `max_output_tokens` | `int \| None` | `None` |
+| `max_total_tokens` | `int \| None` | `None` |
+| `budget_warn_fraction` | `float \| None` | `None` |
+| `budget_hard_fraction` | `float \| None` | `None` |
+| `max_subagents_per_run` | `int \| None` | `None` |
+
+## 状态
+
+### `ExtensionData`
+
+挂在某个宿主作用域(应用或一个任务)上的类型化、线程安全的存储。以 Python 类型为键,两个扩展不会冲突。
+
+| 成员 | 说明 |
+| ---------------------------------------------- | ------------------------------------------------------------------------- |
+| `ExtensionData(scope_id: str)` | 构造函数。存储由宿主创建;只在测试中自己构造 |
+| `scope_id: str` | 该作用域在宿主中的标识 |
+| `get(typ: type[T]) -> T \| None` | `typ` 的已存实例,或 `None` |
+| `get_or_init(typ: type[T], init: Callable[[], T]) -> T` | 已存实例;不存在时由 `init()` 创建。`init` 在存储的锁内运行 |
+| `set(value: T) -> None` | 以 `type(value)` 为键存入 `value`,替换之前的值 |
+| `remove(typ: type[T]) -> T \| None` | 移除并返回已存实例 |
+
+### `task_store_from_runtime(runtime: object) -> ExtensionData | None`
+
+从 LangGraph runtime(包装钩子中的 `request.runtime`,生命周期钩子的 `runtime` 参数)取回任务作用域的存储;没有活跃任务时返回 `None`。
+
+### `EXTENSION_TASK_STORE_KEY`
+
+`"__deerflow_extension_task_store"`。`task_store_from_runtime()` 背后由宿主拥有的 runtime context 键。只通过该辅助函数读取,永远不要写入。
+
+## 任务生命周期
+
+### `TaskLifecycleContributor`
+
+```python
+class TaskLifecycleContributor(Protocol):
+ async def on_task_start(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo) -> None: ...
+ async def on_task_stop(
+ self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo, outcome: TaskOutcome
+ ) -> None: ...
+```
+
+### `TaskInfo`
+
+冻结的 dataclass。
+
+| 字段 | 类型 | 默认值 |
+| ---------------- | ----------------------------- | ------- |
+| `task_id` | `str` | 必填 |
+| `run_id` | `str` | 必填 |
+| `thread_id` | `str` | 必填 |
+| `kind` | `Literal["lead", "subagent"]` | 必填 |
+| `parent_task_id` | `str \| None` | `None` |
+| `agent_name` | `str \| None` | `None` |
+| `resumed` | `bool` | `False` |
+
+### `TaskOutcome`
+
+`StrEnum`:`COMPLETED = "completed"`、`ABORTED = "aborted"`、`FAILED = "failed"`。
+
+## 系统模型调用
+
+### `SystemModelCallObserver`
+
+```python
+class SystemModelCallObserver(Protocol):
+ async def on_system_model_call(
+ self,
+ app_store: ExtensionData,
+ task_store: ExtensionData,
+ kind: SystemOperationKind,
+ request: SystemModelRequest,
+ result: SystemModelResult,
+ ) -> None: ...
+```
+
+### `SystemOperationKind`
+
+`StrEnum`:`GOAL = "goal"`、`MEMORY = "memory"`、`TITLE = "title"`、`SUMMARIZATION = "summarization"`。
+
+### `SystemModelRequest`
+
+冻结的 dataclass,调用前拍下的只读快照。
+
+| 字段 | 类型 | 默认值 |
+| --------------- | ----------------------------- | ------- |
+| `messages` | `Sequence[Any]` | `()` |
+| `model_name` | `str \| None` | `None` |
+| `invoke_config` | `Mapping[str, Any] \| None` | `None` |
+
+`messages` 在构造时规范化为 tuple。单个提示字符串会变成只有一个元素的 tuple,而不是字符序列。
+
+### `SystemModelResult`
+
+冻结的 dataclass:`response: Any | None = None`、`error: BaseException | None = None`、`duration_ms: float | None = None`。
+
+## Agent 组装
+
+### `AgentAssemblyObserver`
+
+```python
+class AgentAssemblyObserver(Protocol):
+ def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None: ...
+```
+
+同步调用,发生在 Agent 构建的末尾。必须轻量,且不得抛出异常。
+
+### `AgentAssemblyDescriptor`
+
+冻结的 dataclass。
+
+| 字段 | 类型 | 默认值 |
+| --------------------- | ---------------------------------- | ------- |
+| `namespace` | `str` | 必填 |
+| `agent_name` | `str` | 必填 |
+| `requested_model` | `str \| None` | 必填 |
+| `effective_model` | `str` | 必填 |
+| `model_parameters` | `dict[str, Any]` | 必填 |
+| `thinking_enabled` | `bool` | 必填 |
+| `reasoning_effort` | `Any` | 必填 |
+| `base_prompt_hash` | `str` | 必填 |
+| `tools` | `tuple[ToolDescriptor, ...]` | 必填 |
+| `middlewares` | `tuple[MiddlewareDescriptor, ...]` | 必填 |
+| `deferred_tool_names` | `tuple[str, ...]` | 必填 |
+| `enabled_skills` | `tuple[str, ...]` | 必填 |
+| `effective_policies` | `dict[str, Any]` | 必填 |
+| `build` | `dict[str, Any]` | `{}` |
+
+`fingerprint: str`(缓存属性)是对一切影响行为的内容做的 `canonical_hash`。工具、延迟工具名和技能会排序;中间件顺序保持不变,因为它决定谁包裹谁。`build` 和 `requested_model` 被排除,所以同一组装重新部署后指纹不变。
+
+### `ToolDescriptor`
+
+冻结的 dataclass:`name: str`、`description_hash: str`、`schema_hash: str`、`source: str`、`mcp_server: str | None = None`、`mcp_transport: str | None = None`。
+
+### `MiddlewareDescriptor`
+
+冻结的 dataclass:`name: str`、`module: str`、`policy_parameters: dict[str, Any] = {}`、`extension: str | None = None`。`extension` 是贡献它的扩展;宿主中间件为 `None`。
+
+## 上下文压缩
+
+### `ContextCompactionObserver`
+
+```python
+class ContextCompactionObserver(Protocol):
+ async def on_context_compacted(
+ self, app_store: ExtensionData, task_store: ExtensionData, event: CompactionEvent
+ ) -> None: ...
+```
+
+### `CompactionEvent`
+
+冻结的 dataclass,在变换两侧都还存在时捕获。
+
+| 字段 | 类型 |
+| ------------------------- | ----------------- |
+| `transform_kind` | `str` |
+| `transform_version` | `str` |
+| `source_content_hashes` | `tuple[str, ...]` |
+| `output_content_hash` | `str` |
+| `compacted_message_count` | `int` |
+| `kept_message_count` | `int` |
+
+哈希是对直接传入的内容计算的 `canonical_hash(message.content)`,从不是字符串化后的副本。要把某条消息与事件对应起来,请用同样的方式哈希它的 `content`。
+
+## 服务
+
+### `ExtensionService`
+
+```python
+class ExtensionService(Protocol):
+ async def start(self, deps: ExtensionRuntimeDeps) -> None: ...
+ async def stop(self) -> None: ...
+```
+
+### `ExtensionRuntimeDeps`
+
+传给 `start()` 的冻结 dataclass。
+
+| 字段 | 类型 | 默认值 |
+| --------------------- | --------------------------- | ---------------------- |
+| `app_store` | `ExtensionData \| None` | `None` |
+| `policy` | `HostPolicySnapshot` | `HostPolicySnapshot()` |
+| `session_factory` | `Any \| None` | `None` |
+| `run_evidence_reader` | `RunEvidenceReader \| None` | `None` |
+
+宿主不提供读取器时,`run_evidence_reader` 为 `None`。
+
+## 运行证据
+
+### `RunEvidenceReader`
+
+只读 Protocol。其默认方法会抛出 `NotImplementedError`。
+
+| 方法 | 返回值 |
+| ------------------------------------------------------------------------------------------------ | ------------------------- |
+| `async list_changed_runs(*, cursor: str \| None, limit: int)` | `RunPage` |
+| `async list_run_events(*, thread_id: str, run_id: str, after_seq: int \| None, limit: int)` | `RunEventPage` |
+| `async get_run_status(*, thread_id: str, run_id: str)` | `RunStatusView \| None` |
+
+Gateway 的实现接受 1 到 2000 的 `limit` 和非负的 `after_seq`,否则抛出 `ValueError`。删除不会产生墓碑记录:`get_run_status()` 返回 `None` 表示该运行不存在或不可见。见[运行证据](/docs/harness/extensions/run-evidence)。
+
+### `RunStatusView`
+
+冻结的 dataclass:`thread_id`、`run_id`、`status`、`created_at`、`updated_at`(均为 `str = ""`),`error: str | None = None`,`stop_reason: str | None = None`。
+
+### `RunEventView`
+
+冻结的 dataclass:`thread_id: str = ""`、`run_id: str = ""`、`seq: int = 0`(在线程内单调递增)、`event_type: str = ""`、`category: str = ""`、`content: Any = None`、`metadata: dict[str, Any] = {}`、`created_at: str = ""`。内容和元数据是脱离宿主存储的副本。内容原样返回;元数据只去掉了旧的 `auth_token` 键,没有做其他脱敏。
+
+### `RunPage` / `RunEventPage`
+
+冻结的 dataclass。`RunPage`:`items: tuple[RunStatusView, ...] = ()`、`next_cursor: str | None = None`、`has_more: bool = False`。`RunEventPage`:`items: tuple[RunEventView, ...] = ()`、`next_after_seq: int | None = None`、`has_more: bool = False`。
+
+### `InvalidRunEvidenceCursor`
+
+`ValueError` 的子类,游标格式错误、不受支持或属于另一个作用域时抛出。
+
+## 身份
+
+### `ExtensionPrincipal`
+
+冻结的 dataclass:`user_id: str`、`is_admin: bool = False`、`is_internal: bool = False`、`roles: tuple[str, ...] = ()`。
+
+### `resolve_principal(request: object) -> ExtensionPrincipal | None`
+
+贡献路由的已认证调用者;无法确定时返回 `None`。`request` 是鸭子类型,所以传 Starlette 的 `Request` 即可,契约本身不依赖 Starlette。
+
+### `require_admin(request: object) -> ExtensionPrincipal`
+
+主体是管理员时返回它,否则抛出 `PermissionError("this endpoint requires an administrator account")`。无法确定身份时按失败处理(fail closed)。
+
+### `EXTENSION_PRINCIPAL_RESOLVER_KEY`
+
+`"deerflow_extension_principal_resolver"`。宿主安装解析器所用的 `app.state` 属性名,由宿主拥有。
+
+## 消息来源标记
+
+注入消息的中间件会给消息打上标记,这样观察者无需匹配措辞就能区分注入的消息和用户自己的消息。
+
+| 常量 | 值 |
+| -------------------------------- | ------------------------------- |
+| `MESSAGE_CONTENT_KIND_KEY` | `"deerflow_content_kind"` |
+| `MESSAGE_PRODUCER_KIND_KEY` | `"deerflow_producer_kind"` |
+| `MESSAGE_PRODUCER_ENTITY_ID_KEY` | `"deerflow_producer_entity_id"` |
+| `PROVENANCE_KEYS` | 这三个键组成的 `frozenset`。宿主把它们视为服务端所有,并从不可信输入中剥离调用方提供的值 |
+
+### `ContentKind`
+
+`StrEnum`:`MIDDLEWARE_INJECTION = "middleware_injection"`、`MEMORY = "memory"`、`DURABLE_CONTEXT = "durable_context"`、`SKILL_BODY = "skill_body"`、`IMAGE_PAYLOAD = "image_payload"`。标记的值是普通字符串,所以更新的宿主新增的种类会以无法识别的字符串到达,而不会报错。
+
+### `MessageProvenance`
+
+冻结的 dataclass:`content_kind: str`、`producer_kind: str`、`producer_entity_id: str | None = None`。
+
+### `provenance_kwargs(content_kind, producer_kind, *, producer_entity_id=None) -> dict[str, str]`
+
+要合并进你所生成消息的 `additional_kwargs` 片段。`producer_entity_id` 为 `None` 时省略。
+
+### `read_provenance(message: object) -> MessageProvenance | None`
+
+从 `message.additional_kwargs` 读取标记。任一必需键缺失或不是字符串时返回 `None`。
+
+## 发布策略与哈希
+
+### `ReleasePolicyProvider`
+
+```python
+@runtime_checkable
+class ReleasePolicyProvider(Protocol):
+ def release_policy_parameters(self) -> dict[str, object]: ...
+```
+
+在中间件上实现它,以声明其影响行为的参数。它们会出现在 `MiddlewareDescriptor.policy_parameters` 和组装指纹中。值必须可 JSON 序列化;长文本请哈希而不要直接嵌入。
+
+### `collect_release_policies(middlewares: Sequence[object]) -> dict[str, dict[str, object]]`
+
+从一个栈中收集声明,以类名为键(重复时为 `Name`、`Name#2`……),并会解开隔离包装器。抛出异常的声明记为 `{"error": "<异常类型>"}`,返回非映射的记为 `{"error": "NonMappingDeclaration"}`。
+
+### `canonical_json(value: object) -> str`
+
+确定性 JSON:键排序,分隔符为 `(",", ":")`,`ensure_ascii=False`。对不可 JSON 序列化的值抛出 `TypeError`。
+
+### `canonical_hash(value: object) -> str`
+
+对 `canonical_json(value)` 以 UTF-8 编码后计算的 SHA-256 十六进制摘要。
+
+## 版本常量
+
+### `API_VERSION`
+
+宿主的契约版本,为点分字符串,本页描述的契约是 `"0.2.1"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。
+
+## 兼容性规则
+
+- **增量演进。** 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值。新增方法或字段的契约发布不会破坏基于更早版本构建的扩展。
+- **1.0 之前**,minor 版本可以有不兼容变更,patch 版本只做增量。**1.0 之后**,不兼容变更会升 major。
+- **`@extension(api=...)` 检查。** install 函数带有标记时,宿主只在以下情况接受它:
+ - 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.1` 宿主接受 `0.2.0` 和 `0.2.1`,拒绝 `0.2.2`、`0.1.x` 和 `0.3.x`);
+ - 1.0 之后:major 相同,且宿主版本不低于声明版本。
+
+ 不是点分数字字符串的标记会被拒绝。没有标记的 install 函数不做检查。
+- **包元数据**是主要机制:声明 `deerflow-extension-api>=0.2,<0.3`,让依赖解析器在任何东西加载之前就拒绝不匹配的宿主。标记用来覆盖绕过依赖解析的安装方式。
+- **自己声明框架依赖。** 契约不依赖任何东西。导入 LangChain、LangGraph 或 FastAPI 的扩展需要自己声明它们。
+
+## 版本历史
+
+| 版本 | PR | 新增内容 |
+| ----- | -- | -------- |
+| 0.1.0 | [#4636](https://github.com/bytedance/deer-flow/pull/4636) | 基础部分:`install()` 与 `@extension`、`ExtensionRegistry.middlewares`、`MiddlewareContributor`、`MiddlewarePlacement`、`Placement`、`AgentScope`、`AgentBuildContext`、`HostPolicySnapshot`、`ExtensionData`、`task_store_from_runtime`、`EXTENSION_TASK_STORE_KEY`、`API_VERSION` |
+| 0.1.1 | [#4684](https://github.com/bytedance/deer-flow/pull/4684) | `task_lifecycle` 和 `system_model_observer` 注册,以及 `TaskLifecycleContributor`、`TaskInfo`、`TaskOutcome`、`SystemModelCallObserver`、`SystemModelRequest`、`SystemModelResult`、`SystemOperationKind` |
+| 0.1.2 | [#4780](https://github.com/bytedance/deer-flow/pull/4780) | `service` 和 `routers` 注册,以及 `ExtensionService` 和 `ExtensionRuntimeDeps`;打包扩展管理器 |
+| 0.2.0 | [#4863](https://github.com/bytedance/deer-flow/pull/4863) | `agent_assembly_observer` 和 `context_compaction_observer`,以及 `AgentAssemblyDescriptor`、`ToolDescriptor`、`MiddlewareDescriptor`、`CompactionEvent`;消息来源标记;发布策略与规范哈希;`ExtensionPrincipal`、`resolve_principal`、`require_admin` |
+| 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader`,以及 `RunEvidenceReader`、`RunPage`、`RunEventPage`、`RunStatusView`、`RunEventView`、`InvalidRunEvidenceCursor` |
+
+至今没有任何版本移除过公开名称。
diff --git a/frontend/src/content/zh/harness/extensions/run-evidence.mdx b/frontend/src/content/zh/harness/extensions/run-evidence.mdx
new file mode 100644
index 000000000..c03b4c3b3
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/run-evidence.mdx
@@ -0,0 +1,237 @@
+---
+title: 运行证据
+description: 让服务发现有变化的运行并读取其持久化事件的只读读取器。涵盖如何获取读取器、它的三个方法及返回类型、游标语义、删除、脱敏、后端差异,以及一个轮询服务。
+---
+
+import { Callout } from "nextra/components";
+
+# 运行证据
+
+中间件和生命周期钩子在运行发生时看到它。**运行证据读取器**让扩展在事后查看运行,依据的是 Gateway 已经持久化的记录:有哪些运行、它们最终处于什么状态、每个运行写下了怎样的事件流。扩展可以借此构建导出、审计索引或看板,而不必挂钩每一次调用。
+
+读取器是只读的,没有任何方法会写入宿主。
+
+## 获取读取器
+
+Gateway 通过 `ExtensionRuntimeDeps.run_evidence_reader` 把一个读取器交给每个服务:
+
+```python
+class MyService:
+ async def start(self, deps):
+ reader = deps.run_evidence_reader
+ if reader is None:
+ return # this host does not provide run evidence
+```
+
+Gateway 总会提供它。`None` 表示扩展运行在一个没有实现读取器的宿主上,应当视为"不支持",而不是"没有运行"。服务的生命周期和顺序见[服务与路由](/docs/harness/extensions/services-and-routes);从 `start()` 被调用到 `stop()` 返回,读取器都可以使用。
+
+
+ 读取器具有**全局**可见性:它能看到所有用户的运行和事件。服务没有请求
+ principal,所以 Gateway 有意不把这个读取器绑定到任何用户。事件内容按存储原样返回。
+ 永远不要把它的数据返回给路由的调用方。
+
+
+## 读取器接口
+
+`RunEvidenceReader` 有三个异步方法,全部只接受关键字参数:
+
+| 方法 | 返回 | 用途 |
+| ----------------------------------------------------------------------- | ----------------------- | ---------------------------------------- |
+| `list_changed_runs(cursor=..., limit=...)` | `RunPage` | 发现自某个游标以来创建或变化的运行 |
+| `list_run_events(thread_id=..., run_id=..., after_seq=..., limit=...)` | `RunEventPage` | 从某个序号向后读取一个运行的持久化事件 |
+| `get_run_status(thread_id=..., run_id=...)` | `RunStatusView \| None` | 读取一个已知运行的权威状态 |
+
+`limit` 必须是 1 到 2000 之间的整数,否则抛出 `ValueError`。`after_seq` 必须是 `None` 或非负整数。
+
+### 返回类型
+
+所有返回类型都是 `deerflow_extension_api` 中的冻结 dataclass。
+
+**`RunStatusView`**:一个运行的生命周期状态。
+
+| 字段 | 含义 |
+| -------------------------- | ------------------------------------------------------------------ |
+| `thread_id`、`run_id` | 运行的标识 |
+| `status` | `pending`、`running`、`success`、`error`、`timeout` 或 `interrupted` |
+| `created_at`、`updated_at` | 字符串形式的时间戳 |
+| `error` | 运行失败时的错误信息 |
+| `stop_reason` | 运行提前停止时的原因 |
+
+**`RunEventView`**:一条持久化事件。
+
+| 字段 | 含义 |
+| -------------------------- | -------------------------------------------------- |
+| `thread_id`、`run_id` | 事件所属的运行 |
+| `seq` | 序号,在**线程**内递增 |
+| `event_type`、`category` | 事件的种类,与事件存储记录的一致 |
+| `content` | 事件负载,原样返回 |
+| `metadata` | 事件元数据,已移除遗留的 `auth_token` 键 |
+| `created_at` | 字符串形式的时间戳 |
+
+**`RunPage`**:`items`(`RunStatusView` 元组)、`next_cursor` 和 `has_more`。
+
+**`RunEventPage`**:`items`(`RunEventView` 元组)、`next_after_seq` 和 `has_more`。
+
+`content` 和 `metadata` 是深拷贝。dataclass 字段是冻结的,但你收到的嵌套 dict 和 list 可以随意修改,不会影响宿主存储。
+
+## 发现有变化的运行
+
+`list_changed_runs` 按稳定的顺序返回运行,最早的变化排在最前。把每页的 `next_cursor` 传给下一次调用即可翻页:
+
+- `cursor=None` 从头开始。
+- `has_more=True` 表示此刻还有下一页。
+- 空页表示已经追平。它的 `next_cursor` 就是你传入的游标,保留它,稍后再轮询。
+
+只有 Agent 运行会出现。宿主针对线程记录的其他操作,例如 checkpoint 写入、产物写入、分支和删除,既不出现在这个流里,也查不到 `get_run_status`。
+
+运行每次变化都会再次出现。这个流不是每个运行一条记录,而是每个在你的游标之后发生过变化的运行的**当前**状态。在两次轮询之间被创建、启动并完成的运行只出现一次,且已经是完成状态。
+
+### 什么算变化
+
+每个运行都有一个变化位置。运行被创建、生命周期状态改变、被取消或模型名被更新时,宿主会推进这个位置。进度快照和租约心跳不会推进它,所以长时间运行的任务在工作期间不会刷屏。
+
+在引入变化追踪之前就存在的运行,位置为零。它们排在最前,按运行 ID 排序。
+
+### 游标规则
+
+游标是不透明的字符串。保存它、传回它,不要解析它。
+
+- **会重放,不会跳过。** 重用游标总是合法的,可能会再次返回你见过的运行。你拿到之后又变化的运行会以新状态再次出现。你还没拿到的运行不会被跳过。
+- **先产出,后提交。** 只有在这一页的工作已经持久化之后才保存 `next_cursor`。如果中途崩溃,下一次轮询会重放这一页。让你的处理具备幂等性,例如覆盖每个运行的记录,而不是追加。
+- **绑定作用域。** 游标属于签发它的读取器。把游标传给可见性范围不同的读取器,或者传入格式错误、版本不受支持的游标,都会抛出 `InvalidRunEvidenceCursor`(`ValueError` 的子类)。从 `None` 重新开始即可恢复,这会重放所有可见内容。
+
+### 删除
+
+这个流**不会**报告删除。被删除的运行会从后续页面和 `get_run_status` 中消失,但没有任何东西告诉你它没了。如果你的扩展镜像了运行并且必须清掉已删除的运行,请定期对已知运行调用 `get_run_status`,把 `None` 视为已删除。
+
+只要运行不可见,`get_run_status` 就返回 `None`:运行不存在、已被删除、`thread_id` 与运行不匹配,或者不在读取器的作用域内。
+
+## 读取运行的事件
+
+`list_run_events` 在一个运行的持久化事件中向后翻页:
+
+- `after_seq=None` 从第一条事件开始。把每页的 `next_after_seq` 传入以继续。
+- `seq` 递增,但在一个运行内并不连续,因为计数器由线程里的所有运行共享。请始终从 `next_after_seq` 继续,而不要自己推算下一个数字。
+- 不存在或不可见的运行返回空页而不是错误,这样调用方无法探测其作用域之外的运行 ID。
+
+状态始终来自运行存储,它是权威来源。不要根据最后一条事件推断运行的最终状态。
+
+## 存储后端
+
+| 设置 | 运行位置与游标 |
+| ---------------------------------------- | ------------------------------------------------------------------------ |
+| `database.backend: sqlite` 或 `postgres` | 存储在数据库中。位置和游标在 Gateway 重启后依然有效 |
+| `database.backend: memory` | 保存在进程内存中。重启后所有运行和位置都会丢失 |
+
+事件来自配置的 `run_events` 存储(`memory`、`db` 或 `jsonl`),使用它自己的 `seq` 编号。
+
+
+ 使用内存后端时,**不要跨重启持久化游标**。新进程会从零开始重新编号变化,旧游标指向
+ 所有这些变化之后的位置,于是读取器会一直返回空页,直到新进程追上保存的位置。
+ 每次重启后请从 `None` 开始,或者只把游标保存在内存里。
+
+
+## 示例:运行摘要服务
+
+这个服务轮询读取器,为每个已完成的运行记录它持久化了多少条各类型的事件。游标只保存在内存里,所以重启后会从头重新扫描。真正的导出器会把游标和它的产出存放在一起。
+
+```python filename="deerflow_extension_digest/__init__.py"
+"""Count persisted event types for every finished run."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections import Counter
+from collections.abc import Mapping
+from typing import Any
+
+from deerflow_extension_api import (
+ ExtensionRegistry,
+ ExtensionRuntimeDeps,
+ InvalidRunEvidenceCursor,
+ RunEvidenceReader,
+ extension,
+)
+
+logger = logging.getLogger(__name__)
+
+TERMINAL = {"success", "error", "timeout", "interrupted"}
+
+
+class RunDigestService:
+ def __init__(self, interval_seconds: float) -> None:
+ self.interval_seconds = interval_seconds
+ self.cursor: str | None = None
+ self.digests: dict[str, Counter[str]] = {}
+ self._task: asyncio.Task[None] | None = None
+
+ async def start(self, deps: ExtensionRuntimeDeps) -> None:
+ reader = deps.run_evidence_reader
+ if reader is None:
+ logger.warning("run evidence is not available on this host; digest disabled")
+ return
+ self._task = asyncio.create_task(self._poll(reader))
+
+ async def stop(self) -> None:
+ if self._task is not None:
+ self._task.cancel()
+ await asyncio.gather(self._task, return_exceptions=True)
+ self._task = None
+
+ async def _poll(self, reader: RunEvidenceReader) -> None:
+ while True:
+ try:
+ await self.sync_once(reader)
+ except Exception:
+ logger.exception("run digest sync failed; retrying")
+ await asyncio.sleep(self.interval_seconds)
+
+ async def sync_once(self, reader: RunEvidenceReader) -> None:
+ while True:
+ try:
+ page = await reader.list_changed_runs(cursor=self.cursor, limit=100)
+ except InvalidRunEvidenceCursor:
+ logger.warning("stored cursor rejected; rescanning from the beginning")
+ self.cursor = None
+ continue
+ for run in page.items:
+ if run.status in TERMINAL:
+ self.digests[run.run_id] = await self._count_events(reader, run.thread_id, run.run_id)
+ # Advance only after this page's output is recorded. Replaying a
+ # page is harmless because each digest is recomputed, not appended.
+ self.cursor = page.next_cursor
+ if not page.has_more:
+ return
+
+ async def _count_events(self, reader: RunEvidenceReader, thread_id: str, run_id: str) -> Counter[str]:
+ counts: Counter[str] = Counter()
+ after_seq: int | None = None
+ while True:
+ page = await reader.list_run_events(thread_id=thread_id, run_id=run_id, after_seq=after_seq, limit=500)
+ counts.update(event.event_type for event in page.items)
+ after_seq = page.next_after_seq
+ if not page.has_more:
+ return counts
+
+
+@extension(api="0.2.0", name="digest")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ registry.service(RunDigestService(float(config.get("interval_seconds", 30))))
+```
+
+值得借鉴的几点:
+
+- `start()` 只创建轮询任务就返回,不会拖慢 Gateway 启动。
+- `stop()` 取消任务并等待它结束,远在 30 秒的停止预算之内。
+- 每次运行出现时都会重新计算它的摘要,所以重放的页面不会造成重复计数。
+- 遇到 `InvalidRunEvidenceCursor` 时把游标重置为 `None`,而不是让服务停下。
+- 一次同步中的异常会被记录,下一次轮询从最后提交的游标继续。
+
+## 常见误区
+
+- **把空页当作不支持。** 空页表示已追平。不支持是 `run_evidence_reader is None`。
+- **在工作保存之前推进游标。** 一旦崩溃,这一页会被永久跳过。请先保存再推进。
+- **以为每个运行只有一条记录。** 运行每次变化都会再次出现。请以 `run_id` 为键组织产出。
+- **等待删除事件。** 没有这种事件。请用 `get_run_status` 对账。
+- **把读取器的数据提供给用户。** 读取器能看到所有用户。永远不要在路由中返回它的数据。
diff --git a/frontend/src/content/zh/harness/extensions/runtime.mdx b/frontend/src/content/zh/harness/extensions/runtime.mdx
new file mode 100644
index 000000000..b0b19d363
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/runtime.mdx
@@ -0,0 +1,202 @@
+---
+title: 运行时模型
+description: Gateway 如何在启动时加载扩展、同一份不可变快照如何跟随每次运行进入其子 Agent、应用与任务作用域及其 ExtensionData 存储、通知循环与故障放行规则,以及诊断信息的去向。
+---
+
+import { Callout } from "nextra/components";
+
+# 运行时模型
+
+本章说明 `install()` 返回之后宿主如何对待一个扩展:每份状态在什么时候存在、一次运行使用哪份快照、回调太慢或失败时会发生什么。编写任何要保存状态的贡献之前,请先读完本章。
+
+## 加载
+
+扩展只加载一次,发生在 Gateway 构建 FastAPI 应用的时候。顺序如下:
+
+1. Gateway 从 `config.yaml` 读取 `plugins:` 列表。`config.yaml` 存在但校验失败属于配置错误,会中止启动;只有文件缺失是被容忍的,此时不加载任何扩展。
+2. 按列表顺序处理每个条目:
+ 1. 如果条目声明了 `table_prefix`,先注册该前缀,即使条目已禁用。见[表前缀](#表前缀)。
+ 2. 如果 `enabled` 为 `false`,跳过该条目,不导入任何东西。
+ 3. 导入 `use` 路径,它必须解析为一个可调用对象。
+ 4. 如果带有 `@extension(api=...)` 标记,就对照宿主的 `API_VERSION` 检查。见[版本检查](#版本检查)。
+ 5. 以条目 `config` 的浅拷贝调用 `install(registry, config)`。
+3. 完成的注册表被冻结成不可变的 `LoadedExtensions`,发布为进程级集合,并保存到 `app.state.extensions`。
+4. 所有宿主路由挂载完毕之后,再挂载扩展贡献的路由。
+
+加载结束时 Gateway 打印一行汇总:
+
+```text
+Extensions loaded: 2/3 (acme_audit:install, acme_costs:install)
+```
+
+总数包含已禁用的条目,所以 `2/3` 既可能是一个失败,也可能是一个被禁用。每个失败都有自己的一行 `Extension : ...` 错误。
+
+### 按位置回滚
+
+`install()` 注册的一切都归属到它的条目。如果 `install()` 中途抛异常,宿主会精确移除这次调用开始以来的注册,然后继续处理下一个条目。注册了一半的扩展比没有更危险,因为它产出的数据看起来是完整的。
+
+回滚按位置而不是按名称进行,所以两个条目可以共用同一个 `use`、配不同的 `config`。第二个实例失败时,绝不会移除第一个实例的注册。
+
+### `required`
+
+在默认的 `required: false` 下,第 2.3 到 2.5 步的任何失败都只记日志并跳过该扩展。在 `required: true` 下,同样的失败会抛出 `ExtensionLoadError`,Gateway 不会启动:
+
+```text
+ExtensionLoadError: required extension acme_audit:install failed to install
+```
+
+`required: true` 只留给缺了就会让部署出错的扩展,例如强制审计轨迹。从这种失败中恢复需要 shell 权限去编辑 `config.yaml`。
+
+### 版本检查
+
+没有标记时跳过检查。否则,1.0 之前的规则是:major 和 minor 与宿主相同,且 patch 不高于宿主;1.0 之后将是:major 相同,且 minor 和 patch 不高于宿主。以宿主 `0.2.1` 为例:
+
+| `@extension(api=...)` | 结果 |
+| ----------------------------- | -------------------------------------------------- |
+| 未装饰 | 加载 |
+| `"0.2"`、`"0.2.0"`、`"0.2.1"` | 加载 |
+| `"0.2.2"` | 拒绝:扩展可能用到本宿主没有的新增内容 |
+| `"0.1.9"`、`"0.3.0"`、`"1.0"` | 拒绝:1.0 之前的 minor 不同,或 major 不同 |
+| `"0.2.x"` | 拒绝:不是点分数字版本 |
+
+拒绝信息会给出应安装的范围:
+
+```text
+Extension acme_audit:install: extension requires extension-api 0.2.2, host provides 0.2.1. Install a matching version: pip install 'deerflow-extension-api>=0.2.2,<0.3'
+```
+
+这个标记是为 `--no-deps` 安装和 editable checkout 准备的安全网。包里对 `deerflow-extension-api` 声明的依赖范围才是主要的兼容性机制。
+
+### 表前缀
+
+使用自己的数据库表(有自己的 SQLAlchemy `MetaData` 和迁移链)的扩展,要声明它拥有的前缀:
+
+```yaml filename="config.yaml"
+plugins:
+ - name: acme
+ use: acme_audit:install
+ table_prefix: acme_
+```
+
+宿主会把带这个前缀的表排除在 `alembic revision --autogenerate` 之外,所以宿主迁移永远不会提议删除它们。即使条目被禁用或加载失败,前缀也会注册,因为这些表可能已经存在。空前缀在配置校验时就会被拒绝。与宿主表名冲突的前缀总是中止启动,无论 `required` 是什么。
+
+## 快照
+
+`LoadedExtensions` 是不可变的。每次 Lead 运行在开始时解析它一次,并把同一个对象用于任务存储、生命周期通知和 Agent 构建。运行还会把快照发布到运行时上下文,`task` 工具再读回来,所以它委派的每个子 Agent 都用与 Lead Agent 相同的扩展版本构建。调用方在这个宿主内部键下提供的值永远不会被信任。
+
+不发布快照的调用方,例如嵌入式 `DeerFlowClient` 或独立的 LangGraph Server,会回退到进程级集合。
+
+因为只在启动时加载,Gateway 运行期间进程级集合不会变化。按运行绑定对替换该集合的宿主和测试才有意义,它保证一次运行永远不会混用两个版本。
+
+## 作用域与存储
+
+宿主给扩展的是 `ExtensionData` 存储,而不是让它们自己保存全局状态。每个存储属于一个作用域,作用域结束时被丢弃。
+
+| 作用域 | `scope_id` | 创建时机 | 丢弃时机 |
+| ---------- | ------------------------------------------------ | ---------------------------------------- | ------------------------ |
+| 应用 | `"app"` | 启动时注册表冻结之时 | 随 Gateway 进程 |
+| Lead 任务 | 运行 id | 运行开始、`on_task_start` 之前 | `on_task_stop` 之后 |
+| 子 Agent 任务 | 发起委派的 tool-call id;没有时为子 Agent 执行 id | 子 Agent 的 `on_task_start` 之前 | 其 `on_task_stop` 之后 |
+| 分离 | `"detached"` | 为一次没有活跃任务的通知创建 | 该通知结束之后 |
+
+每个回调收到的都是当前作用域的存储,所以你永远不需要自己保存一个,也不需要检查它是否过期。
+
+只有注册了至少一个中间件贡献者、任务生命周期贡献者、系统模型观察者或上下文压缩观察者时,才会分配任务存储。只注册服务、路由或组装观察者的扩展不会给运行增加任何开销。
+
+应用存储由进程内所有扩展共享。这是安全的,因为存储以类型为键,见下文。
+
+### 分离存储
+
+有些通知没有活跃任务。记忆抽取通常在运行结束后于后台线程执行,上下文压缩观察者总是在轮次之外分派。它们会收到一个全新的存储,`scope_id` 为 `"detached"`。写进去的东西在通知结束后立即丢弃,所以需要比调用活得更久的值请写进应用存储。
+
+## ExtensionData
+
+```python
+class ExtensionData:
+ scope_id: str
+ def get(self, typ: type[T]) -> T | None
+ def get_or_init(self, typ: type[T], init: Callable[[], T]) -> T
+ def set(self, value: T) -> None
+ def remove(self, typ: type[T]) -> T | None
+```
+
+- **以类型为键。** `set(value)` 严格按 `type(value)` 存储,子类是不同的键。为你要保存的每个值定义一个小类。除非两个扩展共用同一个类,否则不会冲突。
+- **线程安全。** 每个方法都持有一把可重入锁,所以同一个存储可以在不同线程上被中间件、生命周期钩子和观察者使用。
+- **`init` 在锁内执行。** `get_or_init` 在持锁状态下调用 `init()`,所以两个并发调用方不会创建出两个值。保持 `init` 轻量:构造一个空容器,把重的惰性工作放进容器内部。
+- **值的同步由你负责。** 存储保护的是它自己的映射,而不是里面的对象。被并发工具调用更新的计数器需要自己的锁,[内置示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example)就是这么做的。
+
+```python
+from dataclasses import dataclass, field
+from threading import Lock
+
+
+@dataclass
+class ToolCallCount:
+ value: int = 0
+ _lock: Lock = field(default_factory=Lock, repr=False)
+
+ def increment(self) -> None:
+ with self._lock:
+ self.value += 1
+
+
+counts = task_store.get_or_init(ToolCallCount, ToolCallCount)
+counts.increment()
+```
+
+## 宿主策略
+
+`HostPolicySnapshot` 是宿主实际执行的限制的一个窄投影,通过 `AgentBuildContext.policy` 和 `ExtensionRuntimeDeps.policy` 交给扩展。每个字段都有默认值,所以之后新增字段仍然兼容。
+
+| 字段 | `config.yaml` 中的来源 | 未启用时 |
+| ----------------------- | --------------------------------------- | ----------------- |
+| `token_budget_enabled` | `token_budget.enabled` | `False` |
+| `max_input_tokens` | `token_budget.max_input_tokens` | `None` |
+| `max_output_tokens` | `token_budget.max_output_tokens` | `None` |
+| `max_total_tokens` | `token_budget.max_tokens` | `None` |
+| `budget_warn_fraction` | `token_budget.warn_threshold` | `None` |
+| `budget_hard_fraction` | `token_budget.hard_stop_threshold` | `None` |
+| `max_subagents_per_run` | Lead Agent 实际生效的每次运行委派总数 | 子 Agent 为 `None` |
+
+token 预算未启用时,五个 token 字段都是 `None`。子 Agent 的构建上下文投影的是子 Agent 的预算(`subagents.token_budget`,配置了按 Agent 覆盖时用覆盖值),而不是 Lead Agent 的。
+
+## 通知与故障放行
+
+任务生命周期钩子和观察者都通过同一个辅助函数通知,规则处处相同:
+
+- **注册顺序。** 贡献者按注册顺序逐个运行。
+- **隔离。** 一个贡献者抛出的异常会连同其入口点记入日志,不会跳过下一个。
+- **共享一个预算。** 任务生命周期通知给所有贡献者合计 **3 秒**,Lead 运行和子 Agent 都一样。预算耗尽时仍在运行的贡献者会被取消,它之后的每个贡献者都被跳过并记录警告:
+
+ ```text
+ Extension acme_audit:install: on_task_start timed out for task 7f3c...; the 3.0s notification budget was spent
+ Extension acme_costs:install: on_task_start skipped for task 7f3c...; the 3.0s notification budget was spent
+ ```
+
+ `on_task_start` 和 `on_task_stop` 各自单独计算预算。生命周期钩子只做簿记,慢的工作交给[服务](/docs/harness/extensions/services-and-routes)。
+- **同一个循环。** Gateway 把它的服务事件循环注册为扩展通知循环。子 Agent 可能运行在自己的事件循环上,但它们的生命周期钩子和观察者通知都会分派到 Gateway 循环。这样扩展只会在拥有这些资源的循环上使用它启动的资源,例如客户端和连接池。
+
+### 取消
+
+`asyncio.CancelledError` 到达贡献者有两种互不相关的原因,宿主按其来源区分:
+
+- **宿主任务正在被取消**,例如用户按了停止或 Gateway 正在关闭。异常会传播,剩下的贡献者不再调用。
+- **贡献者自己抛出了它**,例如基于取消实现的内部超时。异常会像其他异常一样被兜住,记为 `raised CancelledError`,下一个贡献者照常运行。自己引发的取消不会把一次本来成功的运行变成已取消。
+
+`KeyboardInterrupt` 和 `SystemExit` 总是会传播。
+
+### 关闭
+
+关闭时,Gateway 在刷写记忆之前就停止接受新的即发即弃观察(记忆、上下文压缩、被取消的系统调用)。通知循环会保留到进行中的运行和子 Agent 排空为止,所以它们的 `on_task_stop` 钩子仍会执行。
+
+## 诊断
+
+诊断是归属到某个扩展的问题。每条诊断都会以入口点为前缀写入 Gateway 日志。加载失败、中间件构建与隔离失败、路由挂载被拒,以及服务启动与停止失败,还会被收集到 `app.state.extension_diagnostics` 上的一个实时列表里,只保留最近的 **1000** 条。它没有 HTTP 端点,运维人员请查看 Gateway 日志。
+
+任务生命周期钩子和观察者的失败只记日志,不会进入这个列表。
+
+
+ 要确认扩展已加载,找启动时的 `Extensions loaded: N/M (...)` 这一行。没有这一行说明
+ Gateway 根本没读到 `plugins:` 列表,例如条目写进了 `extensions_config.json`
+ 而不是 `config.yaml`。
+
diff --git a/frontend/src/content/zh/harness/extensions/services-and-routes.mdx b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx
new file mode 100644
index 000000000..0be05dd08
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx
@@ -0,0 +1,253 @@
+---
+title: 服务与路由
+description: 扩展如何在 Gateway 的整个生命周期内运行代码,并提供自己的 HTTP API。涵盖服务的启动与停止顺序、服务收到的依赖、失败与超时行为、贡献路由如何校验和挂载、认证与 CSRF,以及如何识别调用方。
+---
+
+import { Callout } from "nextra/components";
+
+# 服务与路由
+
+**服务**是 Gateway 在持久化层就绪后启动、关闭时停止的对象。**路由**是 Gateway 挂载在自身 API 旁边的 FastAPI `APIRouter`。两者是独立的贡献,但大多数提供 HTTP 的扩展两者都需要:路由声明路径,服务持有这些路径在运行时要读取的东西。
+
+两者都属于应用作用域,每个 Gateway 进程只有一份,而不是每次运行一份。需要按运行生效的行为,见[中间件贡献](/docs/harness/extensions/middleware)。
+
+## 服务
+
+### 契约
+
+```python
+from deerflow_extension_api import ExtensionRuntimeDeps
+
+
+class MyService:
+ async def start(self, deps: ExtensionRuntimeDeps) -> None: ...
+
+ async def stop(self) -> None: ...
+
+
+def install(registry, config):
+ registry.service(MyService())
+```
+
+两个方法都是异步的,并且在协议里都有默认实现,所以服务可以只实现需要的那一个。
+
+### start() 收到什么
+
+每个服务收到同一份 `ExtensionRuntimeDeps` 快照:
+
+| 字段 | 类型 | 含义 |
+| --------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------- |
+| `app_store` | `ExtensionData` | 应用作用域的类型化存储,与中间件贡献者和生命周期钩子收到的 `app_store` 是同一个对象 |
+| `policy` | `HostPolicySnapshot` | 宿主实际执行的限制:token 预算设置(仅在 `token_budget.enabled` 时填充),以及来自 `subagents.max_total_per_run` 的 `max_subagents_per_run` |
+| `session_factory` | SQLAlchemy `async_sessionmaker` 或 `None` | Gateway 的数据库会话工厂。`database.backend` 为 `memory` 时为 `None` |
+| `run_evidence_reader` | `RunEvidenceReader` 或 `None` | 对所有用户的运行及其持久化事件的只读视图。永远不要在路由中返回它的数据。见[运行证据](/docs/harness/extensions/run-evidence) |
+
+`session_factory` 就是宿主自己的数据库连接,没有任何沙箱。用它的服务可以读写宿主的每一张表。如果扩展有自己的表,请在它的 `plugins:` 记录里声明 `table_prefix`,这样 `alembic revision --autogenerate` 就不会去动它们。
+
+### 服务何时启动和停止
+
+服务在 Gateway 启动期间按注册顺序启动:先按 `plugins:` 列表顺序,同一个扩展内按 `registry.service()` 的调用顺序。它在启动序列中的位置是固定的:
+
+1. 初始化数据库引擎、checkpointer 和 store。
+2. 创建运行存储和运行事件存储,并把证据读取器绑定到它们上。
+3. **服务启动**,逐个进行,每个都等待完成后再启动下一个。
+4. 运行时的其余部分启动:线程存储、运行管理器、中断运行的恢复、租约心跳。之后 Gateway 才开始接收请求。
+
+关闭按相反方向进行:
+
+1. 排空进行中的运行和子 Agent。
+2. **服务按注册顺序的逆序停止。**
+3. 关闭存储、checkpointer 和数据库引擎。
+
+因此从 `start()` 被调用到 `stop()` 返回,服务都可以使用 `session_factory` 和 `run_evidence_reader`,而调用 `stop()` 时已没有运行在执行。
+
+### 失败与超时
+
+| 发生了什么 | 结果 |
+| ---------------------------------- | -------------------------------------------------------------------------------------- |
+| `start()` 抛出异常 | 记录为 `Extension : service start() failed; continuing without it: ...`,下一个服务照常启动 |
+| `start()` 自己抛出 `CancelledError` | 与其他失败同样处理。Gateway 启动真正被取消时,取消仍会传播 |
+| `start()` 一直不返回 | 没有启动超时。Gateway 会一直等待,启动无法完成 |
+| `stop()` 抛出异常 | 记录日志,其余服务照常停止 |
+| `stop()` 超过 30 秒 | 被取消并记录为 `service stop() timed out after 30.0s; continuing shutdown`。每个服务各有独立的 30 秒预算 |
+
+`start()` 失败的服务在关闭时仍会收到 `stop()`,因为 `start()` 可能在失败前已经申请了资源。请把 `stop()` 写成对只启动了一半的服务也能安全调用。
+
+
+ 长时间运行的工作放到 `start()` 创建的后台任务里,不要放在 `start()` 本身。
+ 在 `start()` 里阻塞等待一个慢速网络调用,会让 Gateway 启动一直卡到它返回为止。
+
+
+## 路由
+
+### 注册路由
+
+**在 `install()` 内**构建路由并立即注册:
+
+```python
+def install(registry, config):
+ service = MyService()
+ registry.service(service)
+ registry.routers((build_router(service),))
+```
+
+`registry.routers()` 接收一个 `APIRouter` 序列。契约把类型标为 `Any`,这样契约包不依赖 FastAPI;请在你自己的包元数据里声明 `fastapi`。
+
+路由在服务启动之前、任何请求到来之前就已存在,所以路径集合在启动时就固定了。路由处理函数通过它闭包引用的服务对象访问运行时状态。
+
+### 路由如何挂载
+
+Gateway 在**所有宿主路由之后**挂载贡献的路由,所以匹配时总是宿主的处理函数优先。挂载每个路由器之前,它会检查其中每一条路由。只要有一条路由被拒绝,整个路由器**一起**被拒绝;其他路由器,包括同一个扩展的其他路由器,照常挂载。
+
+| 被拒绝的情况 | 运维人员看到 |
+| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| WebSocket 路由 | `contributed WebSocket routes are not supported until the host can apply authentication and Origin checks` |
+| Starlette `Mount` | `contributed router contains a Starlette Mount, which FastAPI.include_router() ignores` |
+| 路由器上的 `on_startup` / `on_shutdown` 钩子或自定义 `lifespan` | `contributed router lifecycle hooks are not supported; register an ExtensionService instead` |
+| 能进入宿主公开命名空间的路径:`/health`、`/docs`、`/redoc`、`/openapi.json`、`/api/v1/auth/oauth/`、`/api/v1/auth/callback/`、`/api/webhooks/` | `contributed route can enter a host public namespace` |
+| 能进入跳过认证或 CSRF 的宿主认证端点的路径,例如 `/api/v1/auth/register`,或 `/api/v1/auth/me` 上的状态变更方法 | `contributed route can enter a host-reserved exact path` |
+| 宿主或更早的扩展已经提供的路径和方法 | `router path is already served by ; this router was not mounted` |
+
+每条消息都以 `Extension :` 为前缀记录为错误。挂载成功时 Gateway 记录 `Extension routers mounted: -> ; ...`。
+
+遮蔽检查是保守的。只有当更早的路由对同一方法**可证明地**覆盖新路由时才拒绝:路径完全相同,或者参数段的转换器能匹配新路由可能收到的每一个值。无法证明的情况一律放行。因此 `/api/{name}` 这样的通配路由可以挂载,但它只会收到没有任何宿主路由匹配的请求。请给路径加上你自己拥有的命名空间前缀,例如 `/api/<扩展名>/`。
+
+### 认证与 CSRF
+
+贡献的路由与宿主 API 共用同一套中间件,而且无法退出:
+
+- **认证。** 未认证的请求在你的处理函数运行之前就会得到 `401`。个人访问令牌(PAT)不能访问扩展贡献的路由:即使令牌有效,宿主也会在处理函数运行之前返回 `403 {"detail": "PAT credentials are not permitted on this route"}`。
+- **CSRF。** 来自浏览器会话的 `POST`、`PUT`、`PATCH` 或 `DELETE` 必须在 `X-CSRF-Token` 头里带上 `csrf_token` cookie 的值,DeerFlow 前端已经这样做了。缺少时请求在你的处理函数运行之前就会得到 `403`。Bearer 头会跳过 CSRF 检查,但不会让 PAT 获得访问扩展贡献路由的权限。
+
+当 Gateway 以 `DEER_FLOW_AUTH_DISABLED=1` 运行时(本地开发开关,`DEER_FLOW_ENV` 或 `ENVIRONMENT` 为 `prod` 或 `production` 时无效),每个请求都以一个合成的管理员用户运行,两项检查都不生效。
+
+### 识别调用方
+
+处理函数永远看不到宿主的认证对象,`deerflow_extension_api` 只提供一个投影:
+
+```python
+@dataclass(frozen=True)
+class ExtensionPrincipal:
+ user_id: str
+ is_admin: bool = False
+ is_internal: bool = False
+ roles: tuple[str, ...] = ()
+```
+
+| 辅助函数 | 返回 |
+| ---------------------------- | ------------------------------------------------------------------------------------------- |
+| `resolve_principal(request)` | 调用方的 `ExtensionPrincipal`;宿主无法确定时返回 `None` |
+| `require_admin(request)` | 调用方是管理员时返回其 principal,否则抛出 `PermissionError`,身份未知时也是如此 |
+
+`roles` 保存调用方唯一的系统角色,例如 `("admin",)` 或 `("user",)`。对于 Gateway 自身组件用内部服务令牌发出的请求(例如 IM 渠道桥接),`is_internal` 为 `true`;这类调用方的角色是 `internal`,不是管理员。PAT 请求在到达扩展贡献路由的处理函数之前就会被拒绝,因此这里的辅助函数不会收到 PAT principal。
+
+两个辅助函数都是同步的,在同步和异步处理函数里都能用。它们与框架无关,所以要自己把结果映射成 HTTP 状态码:`None` 映射为 `401`,`PermissionError` 映射为 `403`。
+
+## 示例:一个状态 API
+
+这个扩展提供两条路由。`GET /api/ext-status/me` 对所有已登录用户开放,`POST /api/ext-status/reset` 需要管理员。服务启动之前两者都返回 `503`。
+
+```python filename="deerflow_extension_status/__init__.py"
+"""Expose a small status API backed by a Gateway-lifetime service."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from deerflow_extension_api import (
+ ExtensionPrincipal,
+ ExtensionRegistry,
+ ExtensionRuntimeDeps,
+ extension,
+ require_admin,
+ resolve_principal,
+)
+from fastapi import APIRouter, Depends, HTTPException, Request
+
+
+class StatusService:
+ def __init__(self) -> None:
+ self._deps: ExtensionRuntimeDeps | None = None
+ self.resets = 0
+
+ async def start(self, deps: ExtensionRuntimeDeps) -> None:
+ self._deps = deps
+
+ async def stop(self) -> None:
+ self._deps = None
+
+ def require_running(self) -> ExtensionRuntimeDeps:
+ if self._deps is None:
+ raise HTTPException(status_code=503, detail="status extension is not running")
+ return self._deps
+
+
+def caller(request: Request) -> ExtensionPrincipal:
+ principal = resolve_principal(request)
+ if principal is None:
+ raise HTTPException(status_code=401, detail="unknown caller")
+ return principal
+
+
+def admin(request: Request) -> ExtensionPrincipal:
+ try:
+ return require_admin(request)
+ except PermissionError as exc:
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
+
+
+def build_router(service: StatusService) -> APIRouter:
+ router = APIRouter(prefix="/api/ext-status", tags=["ext-status"])
+
+ @router.get("/me")
+ async def me(
+ principal: ExtensionPrincipal = Depends(caller),
+ deps: ExtensionRuntimeDeps = Depends(service.require_running),
+ ) -> dict[str, Any]:
+ return {
+ "user_id": principal.user_id,
+ "is_admin": principal.is_admin,
+ "database": deps.session_factory is not None,
+ "evidence": deps.run_evidence_reader is not None,
+ }
+
+ @router.post("/reset")
+ async def reset(
+ principal: ExtensionPrincipal = Depends(admin),
+ deps: ExtensionRuntimeDeps = Depends(service.require_running),
+ ) -> dict[str, int]:
+ service.resets += 1
+ return {"resets": service.resets}
+
+ return router
+
+
+@extension(api="0.2.0", name="status")
+def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
+ service = StatusService()
+ registry.service(service)
+ registry.routers((build_router(service),))
+```
+
+`503` 守卫不是死代码。如果 `start()` 失败,Gateway 会不带这个服务启动,但路由器仍然挂载着。此时路由返回 `503`,而不是因为缺少状态而出错。
+
+在使用内存数据库后端的 Gateway 上,这些路由的响应是:
+
+| 请求 | 响应 |
+| ------------------------------------------------ | -------------------------------------------------------------------------- |
+| `GET /me`,没有会话 | 宿主返回 `401 {"detail": {"code": "not_authenticated", ...}}` |
+| `POST /reset`,已登录但没有 `X-CSRF-Token` | 宿主返回 `403 {"detail": "CSRF token missing. Include X-CSRF-Token header."}` |
+| `GET /me`,以普通用户登录 | `200 {"user_id": "...", "is_admin": false, "database": false, "evidence": true}` |
+| `POST /reset`,普通用户 | `403 {"detail": "this endpoint requires an administrator account"}` |
+| `GET /me` 或 `POST /reset`,个人访问令牌 | 宿主返回 `403 {"detail": "PAT credentials are not permitted on this route"}` |
+| `POST /reset`,管理员会话并带 CSRF 头 | `200 {"resets": 1}` |
+
+## 常见误区
+
+- **在 `install()` 里打开资源。** `install()` 在 Gateway 构建应用时运行,那时数据库还不存在。在那里构建路由,但连接和任务要在 `start()` 里打开。
+- **路由器的 lifespan 钩子。** 会被拒绝。凡是有生命周期的东西都属于服务。
+- **通用路径。** 宿主命名空间下的路径会被拒绝,别的扩展先注册的路径会胜出。请使用你自己拥有的前缀。
+- **把 `resolve_principal` 当作认证。** 宿主已经拒绝了未认证的请求。principal 用于你路由内部的授权,为 `None` 时要按拒绝处理。
+- **以为令牌能带来管理员权限。** 个人访问令牌按设计永远无法通过 `require_admin`。
+- **在路由中返回运行证据。** 服务读取器是全局的:它能看到所有用户的运行。
diff --git a/frontend/src/content/zh/harness/extensions/troubleshooting.mdx b/frontend/src/content/zh/harness/extensions/troubleshooting.mdx
new file mode 100644
index 000000000..431038e7d
--- /dev/null
+++ b/frontend/src/content/zh/harness/extensions/troubleshooting.mdx
@@ -0,0 +1,181 @@
+---
+title: 故障排查
+description: 按症状索引的扩展问题指南。每一条都引用确切的日志行或错误信息,说明原因并给出修复方法,涵盖安装与启动、中间件、生命周期钩子、服务和路由。
+---
+
+import { Callout } from "nextra/components";
+
+# 故障排查
+
+Gateway 日志中的每条扩展诊断都以 `Extension :` 开头,其中 `` 是记录的入口点,例如 `Extension deerflow_extension_hello:install:`。先在日志里搜索这个前缀。`make dev` 下 Gateway 写入 `logs/gateway.log`;Docker 下使用 `make docker-logs` 或 `docker compose logs gateway`。
+
+管理器命令的失败会以 `extension command failed: <原因>` 输出到 stderr,退出状态码为 1。
+
+## 扩展似乎没有加载
+
+### 完全没有 `Extensions loaded` 这一行
+
+只要 `plugins:` 里至少有一个条目,Gateway 就会在 INFO 级别记录 `Extensions loaded: N/M (...)`。没有这一行说明 Gateway 没读到任何 `plugins:` 列表:
+
+- **Gateway 没有重启。** 扩展只在启动时加载。
+- **Gateway 读的是另一个 `config.yaml`。** 管理器和 Gateway 都遵循 `DEER_FLOW_CONFIG_PATH`;如果一个进程设置了而另一个没有,它们用的就是不同的文件。见[运维扩展](/docs/harness/extensions/operations)。
+- **记录被写进了 `extensions_config.json`。** `plugins:` 只从 `config.yaml` 读取。
+
+### `Extensions loaded: 0/1 (none)`
+
+条目被读到了,但没有加载成功。紧挨在上面的错误行说明了原因,下面的条目逐一介绍。除非记录设置了 `required: true`,Gateway 会不带该扩展继续运行。
+
+### `could not resolve extension entry point: Could not import module . Missing dependency ''...`
+
+Gateway 的环境里没有安装这个模块。请通过管理器安装,而不是 `pip install`,这样它才会进入 `backend/uv.lock` 以及基于它构建的镜像。如果已经安装了还看到这条,检查 `use` 写的是导入路径而不是发行包名:`deerflow_extension_hello:install`,而不是 `deerflow-extension-hello:install`。
+
+### `could not resolve extension entry point: Module does not define a attribute/class`
+
+模块可以导入,但冒号后面的函数不存在。修正 `use`。
+
+### `could not resolve extension entry point: doesn't look like a variable path`
+
+`use` 里没有 `:`。它必须写作 `module.path:install`。
+
+### `extension entry point is not callable: `
+
+`use` 指向的不是函数,例如一个模块级常量。
+
+### `extension requires extension-api , host provides `
+
+`@extension(api=...)` 标记超出了这个宿主接受的范围。1.0 之前,宿主接受相同的 `0.minor`,且 patch 不高于宿主自身。
+
+- 声明的版本**高于**宿主(例如在 `0.2.1` 宿主上声明 `0.3.0`):升级 DeerFlow,或安装为这个宿主构建的扩展版本。
+- 声明的 patch 版本**高于**宿主(例如在 `0.2.1` 宿主上声明 `0.2.2`):升级 DeerFlow,或安装声明 `0.2.1` 及以下版本的扩展。
+- 声明的版本属于**更旧**的 minor(例如在 `0.2.1` 宿主上声明 `0.1.0`):把扩展升级到针对宿主 minor 编写的版本。
+
+这条消息会建议 `pip install 'deerflow-extension-api>=,<...'`。宿主的契约版本由它自己的 `uv.lock` 固定,所以对于旧扩展这个建议并不适用:要修的是扩展,不是宿主。
+
+### `extension declares invalid extension-api version marker of type ; expected a dotted numeric string such as '0.1'`
+
+`__deerflow_api__` 被设成了 `"0.2.0"` 这类字符串以外的值。请使用 `@extension(api="0.2.0")` 装饰器。
+
+### `install() failed: `
+
+你的 `install()` 抛出了异常。它在抛出前注册的内容都会被回滚,Gateway 继续加载下一个扩展。这一行后面跟着 traceback。让 `install()` 只做注册:打开连接和启动后台任务请放到 [ExtensionService](/docs/harness/extensions/services-and-routes) 中。
+
+### 我的代码修改没有生效
+
+本地目录是以快照形式安装在 `backend/extensions/sources/` 中的,不是 editable 链接。运行 `make extension-upgrade SOURCE=<同一个绝对路径>` 并重启。Docker 下还要重新构建镜像。
+
+## Gateway 无法启动
+
+### `ExtensionLoadError: required extension failed to load`
+
+一条 `required: true` 的记录失败了。根据失败的步骤,这条消息也可能以 `is not callable`、`could not inspect api marker`、`declares invalid api marker`、`declares incompatible api ` 或 `failed to install` 结尾。紧挨在它之前记录的那一行写明了原因。用 `make extension-disable NAME=` 或把 `required` 改为 `false` 来恢复,然后重启。见[运维扩展](/docs/harness/extensions/operations)。
+
+### `extension table_prefix '' would hide host-owned table(s) [...] from alembic autogenerate`
+
+记录的 `table_prefix` 是某张 DeerFlow 表名的前缀。这总会中止启动,即使记录被禁用或是可选的。请选一个更具体的前缀,例如 `acme_audit_`。
+
+### 加载 `config.yaml` 时出现针对 `plugins` 的校验错误
+
+例如 `Extra inputs are not permitted`,或 `table_prefix` 下的 `String should have at least 1 character`。记录会拒绝未知的键和空的 `table_prefix`。修正记录即可;这是配置错误,所以无论 `required` 为何都会让 Gateway 停止。
+
+## 安装和管理命令失败
+
+| `extension command failed:` 之后的消息 | 原因与修复 |
+| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `extension installation requires uv 0.8.0 or newer` | 升级 uv,最好升到 `backend/Dockerfile` 中固定的版本 |
+| `extension has no pyproject.toml: ` | 该目录不是包的根目录 |
+| `extension pyproject.toml must declare project.name` | 添加 `[project] name = ...` |
+| `extension must declare exactly one 'deerflow.extensions' entry point` | 在 `[project.entry-points."deerflow.extensions"]` 下声明一个条目 |
+| `invalid 'deerflow.extensions' entry point target` | 入口点的值必须是 `module.path:function` |
+| `distribution '' must expose exactly one 'deerflow.extensions' entry point` | 来自索引或 Git 的包在该组中没有声明入口点,或声明了多个 |
+| `distribution '' extension entry point could not be loaded` | 在同步后的环境中导入入口点时抛出异常,或它不可调用。先在本地测试 `import` |
+| `local extension snapshot contains a likely sensitive file: ` | 从目录中删除 `.env`、密钥或凭据文件,或者从一份干净的副本构建 |
+| `local extension snapshots cannot contain symbolic links or junctions` | 把链接替换成真实文件 |
+| `local extension sources must be directories so they can be snapshotted for deployment` | 你传入的是本地文件(例如 wheel)。请传入包目录 |
+| `extension source is already installed: ` | 使用 `make extension-upgrade` |
+| `extension source is not installed: ; use install` / `extension '' is not installed; use install` | `upgrade` 只能替换已有的安装 |
+| `Git SSH shorthand is not deployable; ...` / `remote Git sources must use public HTTPS; ...` | 使用 `git+https://host/org/repo.git@` |
+| `remote extension sources must use HTTPS` | 普通 HTTP 只接受回环主机 |
+| `extension source URLs cannot contain embedded credentials` / `... credential-like query parameters` | 在 uv 中配置索引凭据,不要写进 URL |
+| `file URLs are not deployable; ...` / `local path references are not deployable; ...` | 改为传入本地目录 |
+| `uv.lock contains a local dependency source outside the backend Docker build context` | 解析时引入了本地文件,常见原因是 `UV_FIND_LINKS` 或本地索引。操作已回滚;去掉该设置 |
+| `expected exactly one configured extension matching ''` | 没有记录或有多条记录匹配 `NAME`。用 `make extension-list` 检查 |
+| `configured extension '' has no managed package metadata` | 没有 `package` 的手写记录;请自己从 `config.yaml` 中删除 |
+| `multiple configured plugins conflict with extension ''` | 另一条记录已使用这个 `name` 或 `package`,但 `use` 不同。删除过时的记录。在 `upgrade` 期间出现,也意味着新版本改了入口点目标:请移除后重新安装 |
+| `config.yaml contains duplicate top-level plugins keys` | 合并两个 `plugins:` 段 |
+| `DeerFlow config not found: ` | 运行 `make config`,或让 `DEER_FLOW_CONFIG_PATH` 指向正确的文件 |
+| `extension installation recovery preserved a concurrent dependency-file edit`(或 `removal`、`config edit`) | 操作期间有人修改了这些文件。检查 `git diff` 后重试 |
+| `extension operation failed and the restored environment could not be synchronized; original failure: ...` | 回滚恢复了文件,但 `uv sync` 失败。运行 `cd backend && uv sync --locked --all-packages` |
+
+管理器命令看起来卡住时,通常是在等待另一条持有 `.deer-flow/extension-manager.lock` 的命令。
+
+## 中间件问题
+
+### `placement fell back to a secondary anchor (primary anchor middleware is absent from this stack); ...`
+
+这是一条警告。该放置位置通常锚定的中间件不在这条链里,所以宿主使用了下一条规则。作用域包含子 Agent 的 `TOOL_RAW` 在每次构建子 Agent 时都会出现这条警告,而回退位置仍然满足其保证。对其他放置位置,请检查你是否仍然观察到了预期的内容。见[中间件贡献](/docs/harness/extensions/middleware)。
+
+### `. failed and was skipped: `
+
+你的钩子抛出了异常。宿主已恢复,且没有重复模型或工具调用。常见变体:
+
+- `... did not call the downstream handler`:你的包装钩子没有调用 `handler` 就返回了。宿主替你调用了它。
+- `... called the downstream handler more than once`:你的包装钩子做了重试。只有第一次调用算数。
+
+### `contribute_middlewares() failed: `
+
+你的贡献者抛出了异常,所以这个 Agent 构建时没有你的中间件。每次组装 Agent 都会调用它;检查是否有按调用而变的假设,例如缺少 `agent_name`。
+
+### `contribution must be a MiddlewarePlacement, got `(或 `has invalid scope`、`invalid placement`、`invalid order`、`middleware must be an AgentMiddleware`)
+
+贡献者返回的某一项类型不对。`order` 必须是 `int`(不能是 `bool`),`middleware` 必须是 LangChain 的 `AgentMiddleware` 实例。
+
+### `Middleware ordering constraint violated: ... Contributed by: .`
+
+硬失败:Agent 无法构建。最终的栈违反了宿主的某个排序不变量。请附上完整消息报告;扩展贡献只会落在放置锚点上,所以使用公开契约时不应该出现这种情况。
+
+### 我的中间件修改了请求或结果,但什么都没变
+
+这是预期行为。本版本中扩展的包装钩子只能观察:宿主始终转发原始请求并返回真实结果。
+
+### 常规运行中我的中间件什么也看不到
+
+它很可能只实现了同步的 `wrap_tool_call` 或 `wrap_model_call`。Gateway 运行走异步路径;请同时实现 `awrap_tool_call` 或 `awrap_model_call`。
+
+## 生命周期钩子与观察者
+
+### `Extension : on_task_start timed out for task ; the 3.0s notification budget was spent`
+
+所有任务生命周期贡献者在每次通知中共享 3 秒预算(`on_task_start` 和 `on_task_stop` 各自一份)。一个慢的贡献者会把它耗尽,之后的贡献者会被记录为 `... skipped for task ; the 3.0s notification budget was spent`。把慢操作移出钩子,例如放进由服务消费的队列。
+
+### `Extension : on_task_stop failed for task `
+
+钩子抛出了异常。运行结果不受影响,下一个贡献者照常运行。
+
+### 在 Gateway 之外生命周期钩子从不触发
+
+在 LangGraph Server、`langgraph dev` 或没有 `run_id` 的直接 harness 调用下,任务生命周期通知会被跳过。
+
+### `No running loop registered for extension observations; ... dropped`
+
+系统模型或压缩观察到达时没有运行中的 Gateway 通知循环,例如在嵌入式 harness 中或关闭期间。该观察会被丢弃。
+
+### `Extension : on_agent_assembled failed for AgentAssemblyDescriptor`
+
+你的组装观察者抛出了异常。它在 Agent 构建期间同步运行;保持轻量并且不要抛异常。
+
+## 服务与路由
+
+| `Extension :` 之后的日志行 | 原因与修复 |
+| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `service start() failed; continuing without it: ` | `start()` 抛出了异常。服务没有运行;依赖它的路由应当报告这一点(内置示例返回 503) |
+| `service stop() timed out after 30.0s; continuing shutdown` | `stop()` 超出了 30 秒预算 |
+| `service stop() failed; continuing shutdown: ` | `stop()` 抛出了异常;关闭继续进行 |
+| `router path is already served by host; this router was not mounted` | 某个宿主路由已经处理该路径和方法。整个路由器被跳过。请使用自己的前缀,例如 `/api//...` |
+| `router path is already served by ; this router was not mounted` | 另一个更早加载的扩展占用了该路径 |
+| `router could not be mounted; continuing without it: contributed WebSocket routes are not supported ...` | 目前还不接受 WebSocket 路由 |
+| `router could not be mounted; continuing without it: contributed router lifecycle hooks are not supported; register an ExtensionService instead` | 从路由器中去掉 `on_startup`/`on_shutdown` 或 `lifespan` |
+| `router could not be mounted; continuing without it: contributed router contains a Starlette Mount ...` | 不支持 Mount;请直接声明路由 |
+| `router could not be mounted; continuing without it: contributed route can enter a host public namespace`(或 `a host-reserved exact path`) | 该路径可能进入宿主免认证或免 CSRF 的路径。请换一个路径 |
+| `router could not be mounted; continuing without it: contributed router exposes no routes: ...` | 路由器是空的 |
+
+成功挂载的路由会在 INFO 级别记录为 `Extension routers mounted: -> , ...`。贡献的路由始终位于 Gateway 认证之后;它们返回 401 说明请求未认证,而不是路由不存在。