From 4d2bb976d7a249a4cb909f793c043cf827eea301 Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Wed, 23 Sep 2026 15:57:32 +0800 Subject: [PATCH 1/4] docs(extensions): add an eleven-page extension developer manual Add a harness/extensions/ section in English and Chinese documenting the deerflow-extension-api 0.2.3 contract: index when to write an extension vs a tool, MCP server or skill; contribution kinds; loading; failure and trust model; versioning quick-start build, test, install and remove a working extension runtime load sequence, API version rules, scopes and ExtensionData, fail-open and cancellation, diagnostics middleware placements and where each lands, scope, ordering, observe-only wrap hooks, failure isolation observers task lifecycle, system model calls, agent assembly and fingerprint, context compaction services-and-routes service lifecycle, router rejection rules, auth, principals run-evidence service and request-scoped readers (#5727), cursor semantics, deletions, redaction, store differences plugins experimental full-stack plugins, inline modules and manifest-based browser assets (#5685) operations extension manager CLI, sources, upgrade, rollback, Docker, Helm, recovery troubleshooting indexed by the exact log and error strings reference every public name and the contract version history Also fix stale descriptions: the contribution kinds listed in AGENTS.md and deerflow/extensions/AGENTS.md (now eight, including plugins), the run-evidence redaction claim (only auth_token is removed), and the plugin mount return value in docs/full-stack-plugins.md ({ dispose }, not a function). Changelog entries added in both languages. --- AGENTS.md | 15 +- CHANGELOG.md | 9 + CHANGELOG_zh.md | 6 + .../harness/deerflow/extensions/AGENTS.md | 10 +- docs/full-stack-plugins.md | 2 +- frontend/src/content/en/harness/_meta.ts | 3 + .../content/en/harness/extensions/_meta.ts | 36 ++ .../content/en/harness/extensions/index.mdx | 127 ++++ .../en/harness/extensions/middleware.mdx | 170 ++++++ .../en/harness/extensions/observers.mdx | 340 +++++++++++ .../en/harness/extensions/operations.mdx | 260 ++++++++ .../content/en/harness/extensions/plugins.mdx | 557 ++++++++++++++++++ .../en/harness/extensions/quick-start.mdx | 272 +++++++++ .../en/harness/extensions/reference.mdx | 511 ++++++++++++++++ .../en/harness/extensions/run-evidence.mdx | 338 +++++++++++ .../content/en/harness/extensions/runtime.mdx | 203 +++++++ .../extensions/services-and-routes.mdx | 257 ++++++++ .../en/harness/extensions/troubleshooting.mdx | 205 +++++++ frontend/src/content/zh/harness/_meta.ts | 3 + .../content/zh/harness/extensions/_meta.ts | 36 ++ .../content/zh/harness/extensions/index.mdx | 124 ++++ .../zh/harness/extensions/middleware.mdx | 169 ++++++ .../zh/harness/extensions/observers.mdx | 337 +++++++++++ .../zh/harness/extensions/operations.mdx | 260 ++++++++ .../content/zh/harness/extensions/plugins.mdx | 554 +++++++++++++++++ .../zh/harness/extensions/quick-start.mdx | 271 +++++++++ .../zh/harness/extensions/reference.mdx | 510 ++++++++++++++++ .../zh/harness/extensions/run-evidence.mdx | 335 +++++++++++ .../content/zh/harness/extensions/runtime.mdx | 202 +++++++ .../extensions/services-and-routes.mdx | 256 ++++++++ .../zh/harness/extensions/troubleshooting.mdx | 205 +++++++ 31 files changed, 6573 insertions(+), 10 deletions(-) create mode 100644 frontend/src/content/en/harness/extensions/_meta.ts create mode 100644 frontend/src/content/en/harness/extensions/index.mdx create mode 100644 frontend/src/content/en/harness/extensions/middleware.mdx create mode 100644 frontend/src/content/en/harness/extensions/observers.mdx create mode 100644 frontend/src/content/en/harness/extensions/operations.mdx create mode 100644 frontend/src/content/en/harness/extensions/plugins.mdx create mode 100644 frontend/src/content/en/harness/extensions/quick-start.mdx create mode 100644 frontend/src/content/en/harness/extensions/reference.mdx create mode 100644 frontend/src/content/en/harness/extensions/run-evidence.mdx create mode 100644 frontend/src/content/en/harness/extensions/runtime.mdx create mode 100644 frontend/src/content/en/harness/extensions/services-and-routes.mdx create mode 100644 frontend/src/content/en/harness/extensions/troubleshooting.mdx create mode 100644 frontend/src/content/zh/harness/extensions/_meta.ts create mode 100644 frontend/src/content/zh/harness/extensions/index.mdx create mode 100644 frontend/src/content/zh/harness/extensions/middleware.mdx create mode 100644 frontend/src/content/zh/harness/extensions/observers.mdx create mode 100644 frontend/src/content/zh/harness/extensions/operations.mdx create mode 100644 frontend/src/content/zh/harness/extensions/plugins.mdx create mode 100644 frontend/src/content/zh/harness/extensions/quick-start.mdx create mode 100644 frontend/src/content/zh/harness/extensions/reference.mdx create mode 100644 frontend/src/content/zh/harness/extensions/run-evidence.mdx create mode 100644 frontend/src/content/zh/harness/extensions/runtime.mdx create mode 100644 frontend/src/content/zh/harness/extensions/services-and-routes.mdx create mode 100644 frontend/src/content/zh/harness/extensions/troubleshooting.mdx diff --git a/AGENTS.md b/AGENTS.md index 42fa3a7799e..b8239117150 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,8 @@ 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 +├── examples/deerflow-extension-bookmarks/ # Full-stack plugin example: browser page, conversation action, backend actions ├── 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 +76,18 @@ 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, task lifecycle, system-model, agent-assembly and context-compaction observers, +Gateway services, FastAPI HTTP routers, and experimental full-stack plugins. The +[reference extension](examples/deerflow-extension-example/) demonstrates middleware, task +lifecycle, a system-model observer, a service, and a router; the +[bookmarks extension](examples/deerflow-extension-bookmarks/) demonstrates a plugin. Manage +extensions 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-facing +extension manual lives at `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 eb277132303..46de4f094c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2980,6 +2980,15 @@ 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.3 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, experimental full-stack plugins, 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 in `AGENTS.md` and of the plugin `mount` return + value in `docs/full-stack-plugins.md`. ### Internal diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 8fa8931b9fc..fa155953547 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2227,6 +2227,12 @@ 按症状排查、开发者集成,以及附带 2026 年 6 月至 9 月变更记录的参考附录。原单页 成为该章节的索引页,指向该页面的已有链接保持有效;指向旧页面小节锚点的深链接 会落到索引页。 +- **文档:** 新增中英文扩展开发手册(`harness/extensions/`),覆盖 + `deerflow-extension-api` 0.2.3 契约:何时编写扩展、快速上手、运行时模型、中间件 + 放置位置、生命周期与观察者、服务与路由、运行证据读取器、实验性的全栈插件、扩展 + 运维、按错误信息排查,以及列出全部公开名称和契约版本历史的参考章节。同时修正 + `AGENTS.md` 中对贡献类型的过时描述,以及 `docs/full-stack-plugins.md` 中插件 + `mount` 返回值的描述。 ### 内部改进 diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index 56981a2184f..5df2f83b04a 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -141,9 +141,10 @@ entry, the manager owns the controlled locked sync. The public package is `packages/extension-api/` and must never import `deerflow` or carry framework dependencies. Extensions declare any FastAPI, LangChain, or LangGraph imports -themselves. Its registry contract exposes seven contribution kinds: middleware +themselves. Its registry contract exposes eight contribution kinds: middleware contributors, task-lifecycle contributors, system-model-call observers, agent-assembly -observers, context-compaction observers, Gateway-lifetime services, and eager routers. Middleware contributions declare lead/subagent scope, stable +observers, context-compaction observers, Gateway-lifetime services, eager routers, and +experimental full-stack plugins (`registry.plugin()`, see `docs/full-stack-plugins.md`). Middleware contributions declare lead/subagent scope, stable order, and a semantic placement (`MODEL_LOGICAL`, `MODEL_PHYSICAL`, `TOOL_VISIBLE`, `TOOL_RAW`, or `STANDARD`) rather than a fragile list index. `extensions/stack.py` is the single final composition point; do not inject inside @@ -287,8 +288,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/docs/full-stack-plugins.md b/docs/full-stack-plugins.md index 546ec5bdfa2..cb60358f9c9 100644 --- a/docs/full-stack-plugins.md +++ b/docs/full-stack-plugins.md @@ -79,7 +79,7 @@ allow credentialed CORS from the frontend, as for other host API calls. A page surface declares `id`, `slot: "page"`, `title`, `mount(root, context)` and optional `navigation: { label, labelZh?, icon? }`. The host generates the URL `/workspace/extensions/{namespace}/{id}` and mounts only that registered page. -`mount` returns a synchronous `dispose()` callback. The context includes locale, +`mount` runs synchronously and returns an object `{ dispose }`, whose `dispose()` is called on unmount. The context includes locale, public settings, an abort signal and a namespace-bound `callBackend` helper. The optional `openConversation(threadId)` host helper reads current conversation metadata through the authenticated API and uses the host's normal/custom-agent diff --git a/frontend/src/content/en/harness/_meta.ts b/frontend/src/content/en/harness/_meta.ts index b682b71cb41..6a1c63060c5 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 00000000000..3a2189030fc --- /dev/null +++ b/frontend/src/content/en/harness/extensions/_meta.ts @@ -0,0 +1,36 @@ +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", + }, + plugins: { + title: "Full-Stack Plugins", + }, + 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 00000000000..0d5c346ec3c --- /dev/null +++ b/frontend/src/content/en/harness/extensions/index.mdx @@ -0,0 +1,127 @@ +--- +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) | +| `registry.plugin(contribution)` | Experimental. Browser modules, authenticated backend actions, and model tools. Returns `False` on a host without plugin support. See [Full-Stack Plugins](/docs/harness/extensions/plugins) | + +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. The current version is `deerflow-extension-api` **0.2.3**, exposed as `deerflow_extension_api.API_VERSION`. + +- 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, router, or plugin. +- **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 00000000000..604ca532394 --- /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 00000000000..e1c862861b8 --- /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 00000000000..82f505f3536 --- /dev/null +++ b/frontend/src/content/en/harness/extensions/operations.mdx @@ -0,0 +1,260 @@ +--- +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. + +### Plugins with browser assets + +A plugin that declares `BrowserAssets` ships a `ui_manifest.json` and the static files it lists inside its Python package. The [bookmarks example](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks) keeps them under `deerflow_extension_bookmarks/static/dist/`. For operators this means: + +- **The files must be in what gets installed.** A local directory is snapshotted as-is, so build any JavaScript bundle before `install` or `upgrade`: the manager runs the Python build backend, not a JavaScript build. For an index or Git source, the files must be inside the built wheel. +- **The snapshot rules still apply.** A local directory containing symlinks or secret-looking files is rejected by the manager before the Gateway ever reads the manifest. +- **The Gateway reads the files once**, when `install()` registers the plugin at startup, and serves them from memory under a content-derived revision. Changing an asset needs `upgrade` and a restart, like any code change. Browsers may keep cached code from the previous revision until the page is reloaded. +- **Listed files are readable by any signed-in user**, including source maps, even while the plugin is disabled. Do not ship private sources or secrets in the manifest. +- Deploy the Gateway and the frontend from the same release. An older frontend does not understand the `assets-v1` transport. + +Manifest errors are listed in [Troubleshooting](/docs/harness/extensions/troubleshooting). + +## 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/plugins.mdx b/frontend/src/content/en/harness/extensions/plugins.mdx new file mode 100644 index 00000000000..0cb8f74bf9a --- /dev/null +++ b/frontend/src/content/en/harness/extensions/plugins.mdx @@ -0,0 +1,557 @@ +--- +title: Full-Stack Plugins +description: Experimental. One PluginContribution can ship browser code with workspace pages and conversation actions, authenticated backend actions, and model tools. Covers every field and validation rule, the two browser transports (inline module and manifest-listed static assets), the browser API, the HTTP endpoints and caching, the security model, and a walkthrough of the bookmarks example. +--- + +import { Callout } from "nextra/components"; + +# Full-Stack Plugins + + + **Experimental.** The plugin contract is `api_version` 1. The inline + `BrowserModule` transport arrived in `deerflow-extension-api` 0.2.2, and the + manifest-based `BrowserAssets` transport in **0.2.3**. The page and action + interfaces are shared by both and may still change before 1.0. + + +The other contribution kinds change what the host does behind the scenes. A **plugin** adds a feature users can see and use: a page in the workspace, an entry in the conversation menu, backend actions that page can call, and tools the model can call. All of it comes from one Python package installed through the ordinary extension workflow, so there is no DeerFlow frontend rebuild and no host code specific to the plugin. + +## What users see + +| Surface | Where it appears | +| -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| **Extensions** tab | Capability Center (`/workspace/capabilities?tab=extensions`). A read-only card per installed plugin: title, description, and "Enabled · Managed by your administrator" or "Disabled · ..." | +| Workspace page | `/workspace/extensions/{namespace}/{surface id}`, with an optional sidebar entry. The host builds the URL; a plugin cannot claim arbitrary paths | +| Conversation action | A menu group in the chat toolbar and in each sidebar conversation's menu | +| Model tool | Part of the agent's ordinary tool set, subject to the same group filtering and policies as any other tool | + +Nothing in the UI installs, enables, or configures a plugin. The catalog is read-only for everyone, administrators included. Browsers keep the plugin set they discovered until the page is reloaded manually. + +## The contribution + +`install()` builds one `PluginContribution` and passes it to `registry.plugin()`: + +| Field | Type | Default | Meaning | +| ------------- | ------------------------ | -------------- | ------------------------------------------------------------------------------------------ | +| `namespace` | `str` | required | Unique identity, matching `[a-z][a-z0-9_.-]{0,95}`, such as `community.bookmarks`. Used in URLs and tool names | +| `title` | `str` | required | Card and page title. Must be non-empty | +| `description` | `str` | `""` | Card text | +| `enabled` | `bool` | **`False`** | The plugin's on switch. Actions and tools refuse to run while it is `False`. See [Enabling](#enabling-a-plugin) | +| `fields` | `tuple[SettingsField]` | `()` | Non-secret deployment settings. See [Settings](#settings) | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | The browser code. See [Browser code](#browser-code) | +| `backend` | `tuple[BackendAction]` | `()` | Actions the browser code can call | +| `tools` | `tuple[ModelTool]` | `()` | Tools the model can call | +| `api_version` | `int` | `1` | Must be `1` | + +A contribution must supply at least one of `frontend`, `backend`, or `tools`. The registry validates the whole contribution before storing any of it, and raises `ValueError` on the first violation: + +| Rule | Error message | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `api_version` is `1` | `Unsupported plugin contract` | +| At least one browser module, backend action, or tool | `A plugin must contribute a browser module, backend action or tool` | +| Tool names match `[a-z][a-z0-9_]{0,63}`, are unique, have a description and an `async` handler | `Model tools require unique names, descriptions and async handlers` | +| Tool schemas are valid, inline, object-typed JSON Schema (see [Model tools](#model-tools)) | `Invalid plugin object schema` | +| Action names match `[a-z][a-z0-9_-]{0,63}`, are unique, and have an `async` handler | `Backend actions require unique names and async handlers` | +| `frontend` is a `BrowserModule`, a `BrowserAssets`, or `None` | `Unsupported browser transport` | +| Inline code is non-empty and at most 512 KiB of UTF-8 | `Browser code must be nonempty and at most 512 KiB` | +| An assets manifest and every listed file pass the [asset rules](#manifest-rules) | `Unsupported browser asset manifest; ...`, `Browser assets must not contain symlinks`, ... | +| Namespace, settings fields, and module identifier are valid (see [Settings](#settings)) | `Invalid settings contribution or namespace`, `Invalid or duplicate settings field`, ... | +| Namespace and browser module identifier are unique across all loaded plugins | `Duplicate plugin namespace`, `Duplicate browser module` | + +Any exception escaping `install()`, including these `ValueError`s and the `FileNotFoundError` raised for a manifest-listed file that does not exist, fails that extension's load as a whole: everything it registered is rolled back and the Gateway logs the reason (see [Runtime](/docs/harness/extensions/runtime)). + +### Check the return value + +`registry.plugin()` returns `True` when the host accepted the contribution. The contract's default implementation returns `False`, which is what an older host without plugin support does. A plugin that is meaningless without its UI should fail loudly instead of loading half-installed: + +```python +if registry.plugin(contribution) is not True: + raise RuntimeError("this extension requires a host with full-stack plugin support") +``` + +## Enabling a plugin + +A plugin has **two** switches, and both must be on: + +- The `plugins:` record's `enabled` controls whether the Gateway imports the package at all. The extension manager's `enable` and `disable` commands flip it. +- `PluginContribution.enabled` controls the contribution. It defaults to `False`, and there is no runtime override store, so a plugin that never sets it stays disabled: `GET /api/plugins` reports `enabled: false`, the browser does not load its module, actions return `403 Plugin disabled by administrator.`, and its tools are left out of every agent. + +The convention is to read the second switch from the package's private config, so operators control it in `config.yaml`: + +```python +enabled=config.get("enabled", False) is True, +``` + +```yaml +plugins: + - name: notes + use: acme_notes:install + enabled: true # load the package + config: + enabled: true # turn the contribution on +``` + +Both are read only at startup, so a change to either needs a Gateway restart. + +## Settings + +`SettingsField` declares a non-secret deployment value that actions and tools receive, and that the browser can optionally see: + +| Field | Default | Meaning | +| --------------------- | -------- | ---------------------------------------------------------- | +| `key` | required | Matches `[a-z][a-z0-9_]{0,63}`. `enabled` is reserved | +| `title` | required | Display label | +| `kind` | required | `"boolean"`, `"integer"`, or `"string"` | +| `default` | required | The value. Must match `kind` and the bounds below | +| `description` | `""` | | +| `minimum` / `maximum` | `None` | Integer bounds | +| `max_length` | `256` | String bound, 1 to 4096 | + +The host adds the boolean `enabled` field itself. With it, a plugin can have at most 32 fields, which leaves 31 of your own. Values come only from the package: `default` is the value every action, tool, and browser receives. There is no settings API and no online editing, so derive defaults from `config` in `install()` when operators need to change them. + + + Settings are not a secret store. Never put credentials in a `SettingsField`. + Read secrets in `install()` from the environment or the private `config`, and + keep them in your own Python objects. + + +The browser sees only `enabled` plus the keys listed in the browser declaration's `public_fields` (`BrowserModule` and `BrowserAssets` both have it). Listing a key that is not a declared field fails validation. + +## Backend actions + +```python +BackendAction(name: str, handler: async (payload, context) -> Any) +``` + +The browser calls an action through the host, and the handler receives: + +- `payload`: a read-only mapping parsed from the JSON request body. The host guarantees only that it is a JSON **object** of at most 256 KiB. Validate every field yourself. +- `context`: an `ActionContext` with `principal`, the authenticated caller as an `ExtensionPrincipal` (`user_id`, `is_admin`, `is_internal`, `roles`), and `settings`, the read-only settings mapping. + +The return value must be JSON-serializable. The host maps outcomes to HTTP statuses and does not expose your exception text: + +| Outcome | Status | Body `detail` | +| ----------------------------------------- | ------ | ---------------------------------------------- | +| Handler returns | `200` | Your return value | +| Handler raises `ValueError` | `422` | `Invalid plugin action input.` | +| Handler raises anything else | `502` | `Plugin action failed.` (logged with the exception type) | +| Handler runs longer than 30 seconds | `504` | `Plugin action timed out.` | +| Body is not a JSON object | `422` | `Plugin action requires a JSON object.` | +| Body exceeds 256 KiB | `413` | `Plugin action input exceeds 256 KiB.` | +| Plugin disabled | `403` | `Plugin disabled by administrator.` | +| Unknown namespace or action | `404` | `Plugin is not installed.` / `Plugin action is not installed.` | +| Viewer header does not match the session | `409` | `Account changed; reload this plugin view.` | +| No authenticated principal | `401` | `Authentication required.` | + +A handler cannot choose its own status code, because even a raised `HTTPException` becomes `502`. Raise `ValueError` for bad input and return error details in the body when the browser needs them. A timeout cancels the handler, but it does not roll back external effects or work already running in a worker thread. + +**Authorization is yours.** The host authenticates the caller. It does not know which records belong to whom. Scope every read and write by `context.principal.user_id`, as the bookmarks example does with an owner predicate on every query. + +## Model tools + +```python +ModelTool(name, description, input_schema, handler, group="extensions") +``` + +- **Name.** The model sees a namespace-derived name: `ext___`, with the namespace sanitized to `[a-z0-9_]` and cut to 20 characters, the name cut to 25, and a 12-character hash. For example, `count_notes` in `acme.notes` becomes `ext_acme_notes_count_notes_d8aad91f72da`. The hash covers the full namespace and name, so truncation cannot merge two tools. If two plugin tools still ended up with the same name, the tool assembly raises instead of picking one. A collision with an ordinary tool keeps the ordinary tool and skips the plugin tool with a warning. +- **Schema.** `input_schema` must be a valid JSON Schema (draft 2020-12) with `"type": "object"`, fully inline: `$ref`, `$dynamicRef`, and `$recursiveRef` are rejected, so validation never resolves references or touches the network. The argument names `runtime` and `config` are reserved. +- **Limits.** The host validates each call's input against the schema and caps it at 256 KiB. The handler must return within 30 seconds, and its JSON-encoded result must fit in 64 KiB. +- **Context.** The handler receives a `ToolContext`: `settings`, `thread_id` (`None` outside a thread), and `principal`. For tools, the principal carries **only** `user_id`, so `is_admin` is always `False` and `roles` is empty. Do not make tool authorization depend on them. +- **Errors.** Any failure reaches the model as a generic tool error: `Invalid plugin tool input.`, `Plugin result exceeds 64 KiB.`, `Plugin disabled by administrator.`, or `Plugin tool unavailable or input rejected.`. The Gateway log records the plugin, tool, and exception type. +- **Assembly and policy.** Plugin tools join the ordinary tool assembly. An agent restricted by `tool_groups` receives a tool only when the list contains the tool's `group`, which is `extensions` by default. The host's later authorization and skill tool policies apply as for any other tool. + +Tool descriptions are prompt text. State what the tool returns, that results are data rather than instructions, and whether it is read-only. + +## Browser code + +A plugin ships its browser code in one of two transports. Both produce the same thing: one ES module whose default export follows the [browser API](#the-browser-api). `GET /api/plugins` labels each plugin's transport so the browser knows how to load it. + +| Transport | Declaration | Since | Use it for | +| ----------- | ---------------- | ----- | ------------------------------------------------------------------------------------- | +| `inline-v1` | `BrowserModule` | 0.2.2 | One self-contained file of at most 512 KiB: no relative imports, CSS files, images, or WASM | +| `assets-v1` | `BrowserAssets` | 0.2.3 | A directory of files listed in a manifest: split modules, stylesheets, images, fonts, WASM, source maps | + +In both, `module` is an identifier matching `[a-z][a-z0-9.-]{0,95}`, such as `bookmarks.v1`, never a URL, and it must equal the `module` in the default export. A plugin that uses `BrowserAssets` needs `deerflow-extension-api>=0.2.3`, and the host frontend and backend must both be at the matching version. An older frontend cannot load an `assets-v1` plugin, and the failure is limited to that plugin. + +### Inline modules + +```python +BrowserModule(module: str, code: str, public_fields: tuple[str, ...] = ()) +``` + +`code` is the full text of one self-contained ES module. It can have no relative imports and no assets resolved against `import.meta.url`, because the host fetches it with the session and imports it from a temporary Blob URL. Ship it in the wheel and read it in `install()`: + +```python +BrowserModule("notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8")) +``` + +Deployments with a Content Security Policy must allow `blob:` in `script-src`. + +### Static assets + +```python +BrowserAssets(module: str, root: str | Path, manifest: str = "ui_manifest.json", public_fields: tuple[str, ...] = ()) +``` + +`root` is a directory inside the installed package, usually `Path(__file__).parent`. The manifest, relative to `root`, lists every file the browser may load: + +```json filename="ui_manifest.json" +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": [ + "static/dist/index.mjs", + "static/dist/chunks/page.mjs", + "static/dist/notes.css" + ] +} +``` + +The entry module can import its siblings with relative paths and locate resources with `new URL(..., import.meta.url)`: + +```js filename="static/dist/index.mjs" +import { mountNotes } from "./chunks/page.mjs"; + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], +}; +``` + +```js filename="static/dist/chunks/page.mjs" +export function mountNotes(root, context) { + const style = document.createElement("link"); + style.rel = "stylesheet"; + style.crossOrigin = "use-credentials"; + style.href = new URL("../notes.css", import.meta.url).href; + const list = document.createElement("ul"); + root.append(style, list); + context + .callBackend("list", {}) + .then(({ notes }) => { + for (const note of notes) { + const item = document.createElement("li"); + item.textContent = note; + list.append(item); + } + }) + .catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} +``` + +```python +frontend=BrowserAssets("notes.v1", Path(__file__).parent), +``` + +Include the manifest and every listed file in the wheel. A hatchling wheel that packages the Python package directory picks them up without extra configuration. Bundle third-party dependencies: bare npm imports are not resolved and there is no shared host React instance. Configure your bundler to emit relative URLs rather than absolute `/assets/...` paths. + +#### Manifest rules + +The registry reads, validates, and snapshots every listed file into memory during `install()`. Any violation fails the extension's load: + +| Rule | Error message | +| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | +| The manifest is a JSON object with exactly `schema_version` (the integer `1`), `entry`, and `files` | `Unsupported browser asset manifest; expected schema_version 1` | +| No duplicate keys | `Duplicate browser manifest key` | +| `files` lists 1 to 256 unique, valid paths | `Browser manifest must list unique asset paths` | +| `entry` is one of `files` and ends in `.js` or `.mjs` | `Browser manifest entry must be a listed JavaScript module` | +| Every file has a supported type (below) | `Unsupported browser asset file type` | +| `root` is not a symlink, and no component of any listed path is a symlink | `Browser asset root must not be a symlink`, `Browser assets must not contain symlinks` | +| Every listed file is a regular file inside `root` | `Browser asset must be a regular file inside its package` (a missing file raises `FileNotFoundError`) | +| The manifest is at most 64 KiB, each file at most 4 MiB, and all files together at most 16 MiB | `Browser asset size limit exceeded` | + +A valid path is at most 512 characters of `/`-separated segments, where each segment uses ASCII letters, digits, `_`, `-`, and `.` and starts with a letter, digit, `_`, or `-`. This rules out dotfiles, `.` and `..` segments, empty segments, backslashes, percent encoding, and query strings. + +Supported types, served with the MIME type in parentheses: `.js` and `.mjs` (`text/javascript`), `.css`, `.json` and `.map` (`application/json`), `.wasm`, `.png`, `.jpg`/`.jpeg`, `.gif`, `.webp`, `.svg`, `.ico`, `.woff`, `.woff2`, `.ttf`, and `.otf`. HTML and server-side files are never served. + +#### Revisions and caching + +The host computes a SHA-256 **revision** over the manifest and the content hash of every file. Every URL contains it: `/api/plugins/{namespace}/assets/{revision}/{path}`. Changing any file changes the revision of all of them. Requests are answered from the startup snapshot, never from the filesystem, so editing the installed files has no effect until a restart. + +Asset responses carry `Cache-Control: private, max-age=31536000, immutable` and `Vary: Cookie, Authorization`. An unknown path or revision returns `404` with `private, no-store`. The host keeps no historical snapshots, and there is no public CDN contract. After an upgrade, a page still holding the old revision gets `404`s for anything it has not cached and must be reloaded. Conversely, a browser can keep using code it already cached after a plugin is removed, so removal is not instant cache revocation. + +#### How the browser loads assets + +The host inserts a native module script with `crossorigin="use-credentials"` pointing at the entry URL, then reads the exports from the document's module map. Relative static and dynamic imports therefore resolve inside the same revision and carry the session cookie. URLs honor `NEXT_PUBLIC_BACKEND_BASE_URL`, including path prefixes. + +- **Timeout.** The host stops waiting after 30 seconds and marks that plugin unavailable. This is a deadline for waiting, not cancellation: the module can still finish evaluating later. Keep top-level code free of side effects and start UI work in `mount`. +- **Module state is shared.** A module instance is shared by the whole document. Keep per-viewer data in per-mount state and clear it in `dispose()`. Never cache principals or private results at module level across account changes. +- **Other resources are your job.** Set `crossOrigin = "use-credentials"` on stylesheets and images you create, as `page.mjs` does, and use `fetch(url, { credentials: "include" })` for JSON, WASM, or binary data. Font and background URLs inside CSS do not reliably carry cross-origin cookies. For those, prefer a same-origin deployment, or fetch with credentials and build a `FontFace` or Blob URL that you release on dispose. +- **CSP and CORS.** The backend origin must be allowed in the relevant `script-src`, `style-src`, `img-src`, `font-src`, and `connect-src` directives. Split-origin deployments need exact-origin credentialed CORS and working session cookies. + +### The browser API + +The module's default export must match the host's browser API v1: + +```js +export default { + apiVersion: 1, // required + module: "notes.v1", // required, equal to the declaration's module + icon: "file-text", // optional + surfaces: [/* page surfaces, at most 16 */], + conversationActions(t, locale) {/* returns one action group */}, +}; +``` + +The host rejects the module, and shows "Page module unavailable" on its card, if `apiVersion` or `module` does not match or any surface or navigation entry is malformed. + +### Page surfaces + +| Field | Rule | +| ------------ | --------------------------------------------------------------------- | +| `id` | `[a-z][a-z0-9-]{0,63}`, unique within the module | +| `slot` | `"page"` (the only slot today) | +| `title` | Non-empty string | +| `navigation` | Optional `{ label, labelZh?, icon? }` adds a sidebar entry. Labels are at most 120 characters | +| `mount` | `(root, context) => { dispose }`, called synchronously | + +The host mounts the page into a Shadow DOM root and passes a context: + +| Context member | Meaning | +| ------------------------------ | ------------------------------------------------------------------------------- | +| `namespace`, `locale` | The plugin namespace and the UI locale, such as `en-US` or `zh-CN` | +| `settings` | `enabled` plus `public_fields` | +| `signal` | Aborted on unmount or account change. Pass it to listeners and fetches | +| `callBackend(action, payload)` | POSTs to one of this plugin's declared actions and resolves to the JSON response. It rejects on non-2xx responses and on actions not declared by the plugin | +| `openConversation(threadId)` | Optional. Navigates to a conversation after resolving it through the authenticated API, and rejects without navigating if it is missing or inaccessible | + +`mount` must return an object with a synchronous `dispose()`. The host calls it on unmount after aborting `signal`, and fences late `callBackend` results. + +### Conversation actions + +`conversationActions(t, locale)` must be synchronous and return `{ label, icon, actions }`. Each action has `id`, `label`, `icon`, a synchronous `available(settings)` that returns a boolean, and `execute(context, services)`: + +- `context.thread` is the conversation, and `context.messages` holds its messages when the host already has them. +- `services.callBackend` works as above. `services.latestVisibleAnswer(context)` returns the last visible assistant message as `{ id, text }` or `null`. `services.conversationText(context)` returns the visible transcript. Both reuse the host's export sanitizer, so hidden messages, tool output, and reasoning are excluded. `services.showMessage(text)` shows a toast. + +Each plugin's actions are evaluated inside their own error boundary. A malformed or throwing group is omitted, logged to the browser console, and leaves other plugins' actions and the conversation page intact. + +### Icons + +The host maps icon names to a fixed set: `bell`, `bookmark`, `download`, `file-json`, and `file-text`. Any other name renders as a generic puzzle icon. + +## HTTP endpoints + +The host serves plugins through four authenticated Gateway routes, behind the normal session and CSRF policies. Each returns `401` without an authenticated principal: + +| Route | Purpose | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `GET /api/plugins` | Every loaded plugin: `namespace`, `title`, `description`, `viewer_id`, `module`, `entry`, `transport` (`inline-v1`, `assets-v1`, or `null`), public `settings`, and `backend_actions` names | +| `GET /api/plugins/modules/{module}/{sha256}.mjs` | An inline module, addressed by its content hash. `text/javascript`, `nosniff`, `private, no-store` | +| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | One manifest-listed file. Its declared MIME type, `nosniff`, `Content-Security-Policy: sandbox`, and the immutable private caching described in [Revisions and caching](#revisions-and-caching) | +| `POST /api/plugins/{namespace}/actions/{name}` | Invokes one backend action (see [Backend actions](#backend-actions)) | + +A stale inline hash or asset revision returns `404` and requires a page reload. Both download routes serve a plugin's code even while the plugin is disabled. + +The browser sends `X-Deerflow-Plugin-Viewer` with the `viewer_id` it discovered, so an action from a view opened under a different account is rejected with `409`. + +## Security model + + + Browser modules and Python handlers are trusted, operator-installed code. + Browser code runs **in the main page** with the signed-in user's same-origin + capabilities. Shadow DOM scopes CSS, not privileges. Python handlers run in the + Gateway process with Gateway privileges. + + +What the host does guarantee: + +- It never loads code from a browser-supplied path, import string, or remote URL. It serves only the installed inline module, verified by content hash, or files listed in the manifest, read from the startup snapshot. Directory listings and unlisted files are never served. +- Asset responses carry `Content-Security-Policy: sandbox`, so opening an asset directly, such as an SVG with a script inside, cannot run scripts with the Gateway's origin. This restricts assets opened as documents. It does not sandbox the plugin JavaScript the host loads into the page. +- Every endpoint requires an authenticated principal, and the principal comes from the session rather than the request body. +- `callBackend` is bound to the plugin's own namespace and its declared action names. +- Only `enabled` and explicitly public fields reach the browser. + +Any authenticated user can download every listed asset, including source maps, even while the plugin is disabled. Never list secrets or private sources in the manifest. + +What remains the plugin's job: authorizing access to its own data by `principal.user_id`, validating every payload, escaping user and model text when rendering it (`textContent`, never `innerHTML`), and treating tool results as untrusted data. + +## Walkthrough: the bookmarks example + +[`examples/deerflow-extension-bookmarks`](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks) (version 0.2.0, which requires `deerflow-extension-api>=0.2.3`) is a complete plugin: users save the last visible answer from a conversation menu, then search, rename, open, or delete their bookmarks on a **My bookmarks** page. The Agent can search them too. It exercises every piece of this chapter: + +1. **Config and state.** `install()` requires `config.enabled` (a boolean) and an absolute `config.storage_path`, and opens a SQLite store there. Mount a persistent directory for it in containers. +2. **The contribution.** Namespace `community.bookmarks`, `enabled=config["enabled"]`, `BrowserAssets("bookmarks.v1", Path(__file__).parent)`, five `BackendAction`s (`save`, `search`, `get`, `rename`, `delete`), and one read-only `ModelTool`, `search_bookmarks`, which the model sees as `ext_community_bookmarks_search_bookmarks_`. +3. **The return check.** It raises `RuntimeError` if `registry.plugin(...)` is not `True`. +4. **Ownership.** Every SQL statement carries `owner = context.principal.user_id`, and each action accepts an exact set of payload keys. The model tool never accepts a user ID, so a user can only ever search their own bookmarks. +5. **The resources.** `ui_manifest.json` lists four files under `static/dist/`: the entry `index.mjs`, a page module `chunks/bookmarks.mjs`, `styles.css`, and `bookmark.svg`. They ship inside the Python package and need no JavaScript build step. +6. **The entry module.** `index.mjs` imports `mountBookmarks` from `./chunks/bookmarks.mjs` and exports one `page` surface, `library`, with `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`, served at `/workspace/extensions/community.bookmarks/library`. It also exports a "Bookmarks → Save last answer" conversation action that calls `services.latestVisibleAnswer` and then `callBackend("save", ...)`. +7. **The page module.** `chunks/bookmarks.mjs` resolves `../styles.css` and `../bookmark.svg` against `import.meta.url` and loads them with `crossorigin="use-credentials"`, so they work on split-origin deployments. It observes `context.signal`, renders text with `textContent`, and clears its DOM in `dispose()`. + +The deployment record looks like this: + +```yaml +plugins: + - name: bookmarks + use: deerflow_extension_bookmarks:install + enabled: true + config: + enabled: true + storage_path: /var/lib/deerflow/bookmarks.sqlite +``` + +After a restart and a browser refresh, the card appears in the Extensions tab, the sidebar gains **My bookmarks**, and the conversation menus gain **Bookmarks**. The example uses single-host SQLite storage. It is not a multi-node storage pattern. + +## A minimal plugin + +The smallest useful shape is one page, two actions, and one read-only tool. This one uses the inline transport, so it is a single `client.mjs`. To split it into files, switch to [static assets](#static-assets), as `acme.notes` does in that section. Every part below has been exercised against the host's registry and routes: + +```python +"""A per-user scratchpad: a page, one backend action, one read-only model tool.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from deerflow_extension_api import ( + ActionContext, + BackendAction, + BrowserModule, + ModelTool, + PluginContribution, + SettingsField, + ToolContext, + extension, +) + +NOTES: dict[str, list[str]] = {} # demo only: in-memory, single process + + +async def add_note(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + text = payload.get("text") + if not isinstance(text, str) or not text.strip(): + raise ValueError("text is required") # becomes HTTP 422 + notes = NOTES.setdefault(context.principal.user_id, []) + if len(notes) >= context.settings["max_notes"]: + raise ValueError("note limit reached") + notes.append(text.strip()) + return {"count": len(notes)} + + +async def list_notes(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + return {"notes": NOTES.get(context.principal.user_id, [])} + + +async def count_notes(payload: Mapping[str, Any], context: ToolContext) -> dict[str, Any]: + return {"count": len(NOTES.get(context.principal.user_id, []))} + + +@extension(api="0.2.2", name="notes") +def install(registry, config: Mapping[str, Any]) -> None: + accepted = registry.plugin( + PluginContribution( + namespace="acme.notes", + title="Notes", + description="A private scratchpad for each user.", + enabled=config.get("enabled", False) is True, + fields=(SettingsField("max_notes", "Maximum notes", "integer", 50, minimum=1, maximum=500),), + frontend=BrowserModule( + "notes.v1", + Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), + public_fields=("max_notes",), + ), + backend=(BackendAction("add", add_note), BackendAction("list", list_notes)), + tools=( + ModelTool( + "count_notes", + "Count the current user's saved notes. Read-only.", + {"type": "object", "properties": {}, "additionalProperties": False}, + count_notes, + ), + ), + ) + ) + if accepted is not True: + raise RuntimeError("acme-notes requires a host with full-stack plugin support") +``` + +And its `client.mjs`, placed next to `__init__.py` and included in the wheel: + +```js +function mountNotes(root, context) { + const list = document.createElement("ul"); + const input = document.createElement("input"); + const add = document.createElement("button"); + add.textContent = context.locale.startsWith("zh") ? "添加" : "Add"; + root.append(input, add, list); + + const render = async () => { + const { notes } = await context.callBackend("list", {}); + list.replaceChildren( + ...notes.map((note) => { + const item = document.createElement("li"); + item.textContent = note; // textContent, never innerHTML + return item; + }), + ); + }; + add.addEventListener( + "click", + async () => { + await context.callBackend("add", { text: input.value }); + input.value = ""; + await render(); + }, + { signal: context.signal }, + ); + render().catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], + conversationActions(_t, locale = "en") { + const zh = locale.startsWith("zh"); + return { + label: zh ? "笔记" : "Notes", + icon: "file-text", + actions: [ + { + id: "save-answer", + label: zh ? "把最后一条回答存为笔记" : "Save last answer as a note", + icon: "file-text", + available: (settings) => settings.enabled === true, + async execute(context, services) { + const answer = await services.latestVisibleAnswer?.(context); + if (!answer) return services.showMessage(zh ? "没有可保存的回答" : "No answer to save"); + await services.callBackend("add", { text: answer.text.slice(0, 2000) }); + services.showMessage(zh ? "已保存" : "Saved"); + }, + }, + ], + }; + }, +}; +``` + +The in-memory dict is only for the example. Real plugins need storage that survives restarts and is shared across Gateway workers. + +## Lifecycle + +- Install, upgrade, enable, disable, and every `config` change need a Gateway restart, followed by a browser reload. There is no hot reload or hot unload. +- After a restart, an open page may point at a module hash, asset revision, or action that no longer exists. Those requests fail explicitly (`404`) until the user reloads. +- A disabled or removed plugin has no sidebar entry after a reload. Visiting its page URL directly shows "Extension page unavailable" and mounts nothing. + +Operator commands are covered in [Operations](/docs/harness/extensions/operations). 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 00000000000..f2c7c0bf80b --- /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 00000000000..a989120c3e0 --- /dev/null +++ b/frontend/src/content/en/harness/extensions/reference.mdx @@ -0,0 +1,511 @@ +--- +title: Reference +description: Every public name in deerflow-extension-api 0.2.3, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history. +--- + +import { Callout } from "nextra/components"; + +# Reference + +This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.3**. 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. `plugin()` is the one method that reports this, by returning `False`; the version marker and package metadata are how you prevent it for the others. + +| 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()` | +| `plugin(contribution: PluginContribution)` | `bool` | A full-stack plugin. `True` when accepted, `False` on a host without plugin support. Experimental | + +## 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`. + +### `resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None` + +Return a reader bound to the authenticated caller of a contributed route. Use it in user-facing routes instead of the global `ExtensionRuntimeDeps.run_evidence_reader`, which sees every user's runs. `request` is duck-typed. Returns `None` on a host without request-scoped evidence. The Gateway raises `PermissionError("run evidence requires an authenticated user with runs:read")` when the caller is unauthenticated or lacks the `runs:read` permission; it never widens an admin or internal caller to global visibility. Any other resolver error propagates. + +### `require_run_evidence_reader(request: object) -> RunEvidenceReader` + +Like `resolve_run_evidence_reader()`, but raises `NotImplementedError("request-scoped run evidence is unavailable")` instead of returning `None`. Map `NotImplementedError` to HTTP 503 and `PermissionError` to 403. It never falls back to the global reader. + +### `RUN_EVIDENCE_READER_RESOLVER_KEY` + +`"deerflow_extension_run_evidence_reader_resolver"`. The `app.state` attribute the host installs its request-scoped resolver under. Host-owned. + +### `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. + +## Full-stack plugins (experimental) + + + The plugin contract is experimental in 0.2.3. Always check the return value of + `registry.plugin(...)`. See [Plugins](/docs/harness/extensions/plugins). + + +### `PluginContribution` + +Frozen dataclass. + +| Field | Type | Default | +| ------------- | ------------------------------ | ------- | +| `namespace` | `str` | required | +| `title` | `str` | required | +| `description` | `str` | `""` | +| `enabled` | `bool` | `False` | +| `fields` | `tuple[SettingsField, ...]` | `()` | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | +| `backend` | `tuple[BackendAction, ...]` | `()` | +| `api_version` | `int` | `1` | +| `tools` | `tuple[ModelTool, ...]` | `()` | + +The host accepts only `api_version == 1` and requires at least one of `frontend`, `backend`, or `tools`. Namespaces must be unique. + +### `BrowserModule` + +Frozen dataclass: `module: str`, `code: str` (a self-contained ES module, non-empty and at most 512 KiB), `public_fields: tuple[str, ...] = ()`. Discovery labels this transport `inline-v1`. Use `BrowserAssets` when the module needs relative imports, stylesheets, or images. + +### `BrowserAssets` + +Frozen dataclass declaring a manifest and static files inside the installed package. Discovery labels this transport `assets-v1`. + +| Field | Type | Default | +| --------------- | ----------------- | -------------------- | +| `module` | `str` | required | +| `root` | `str \| Path` | required | +| `manifest` | `str` | `"ui_manifest.json"` | +| `public_fields` | `tuple[str, ...]` | `()` | + +`root` is usually `Path(__file__).parent`. The manifest, relative to `root`, must be a JSON object with exactly the keys `schema_version` (the integer `1`), `entry`, and `files`: + +```json +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": ["static/dist/index.mjs", "static/dist/styles.css"] +} +``` + +`files` lists 1 to 256 unique paths and `entry` must be one of them, ending in `.js` or `.mjs`. Paths use ASCII letters, digits, `_`, `-`, and `.`, separated by `/`, and each segment must start with a letter, digit, `_`, or `-`. The root must not be a symlink, and no listed path may pass through one. Limits: 64 KiB for the manifest, 4 MiB per file, 16 MiB in total. Accepted file types: `.js`, `.mjs`, `.css`, `.json`, `.map`, `.wasm`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.ico`, `.woff`, `.woff2`, `.ttf`, `.otf`. + +`registry.plugin()` validates the manifest and reads every listed file into an in-memory snapshot. A validation error raises from `registry.plugin()`, so `install()` fails. The snapshot's revision is a SHA-256 over the manifest, the paths, and the file contents, and the Gateway serves the files from that snapshot at `GET /api/plugins/{namespace}/assets/{revision}/{path}`. Any authenticated user can download listed files, including source maps, even while the plugin is disabled, so never list secrets. + +### `BackendAction` + +Frozen dataclass: `name: str`, `handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]`. Names must be unique and handlers async. + +### `ModelTool` + +Frozen dataclass: `name: str`, `description: str`, `input_schema: Mapping[str, Any]` (an inline object schema), `handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]`, `group: str = "extensions"`. + +### `ActionContext` / `ToolContext` + +Frozen dataclasses. `ActionContext`: `principal: ExtensionPrincipal`, `settings: Mapping[str, bool | int | str]`. `ToolContext` extends it with `thread_id: str | None`. + +### `SettingsField` + +Frozen dataclass describing one non-secret deployment setting. + +| Field | Type | Default | +| ------------- | -------------------------------------------- | ------- | +| `key` | `str` | required | +| `title` | `str` | required | +| `kind` | `Literal["boolean", "integer", "string"]` | required | +| `default` | `bool \| int \| str` | required | +| `description` | `str` | `""` | +| `minimum` | `int \| None` | `None` | +| `maximum` | `int \| None` | `None` | +| `max_length` | `int` | `256` | + +## Version constant + +### `API_VERSION` + +`"0.2.3"`. 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.3` host accepts `0.2.0` through `0.2.3`, and rejects `0.2.4`, `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` | +| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | Experimental `registry.plugin()` with `PluginContribution`, `BrowserModule`, `BackendAction`, `ModelTool`, `ActionContext`, `ToolContext`, `SettingsField` | +| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727), [#5685](https://github.com/bytedance/deer-flow/pull/5685) | Request-scoped run evidence: `resolve_run_evidence_reader`, `require_run_evidence_reader`, `RUN_EVIDENCE_READER_RESOLVER_KEY` (#5727). Packaged browser resources: `BrowserAssets`, and `PluginContribution.frontend` also accepts it (#5685) | + +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 00000000000..2cc67155704 --- /dev/null +++ b/frontend/src/content/en/harness/extensions/run-evidence.mdx @@ -0,0 +1,338 @@ +--- +title: Run Evidence +description: The read-only readers that let a service or a route find runs that changed and read their persisted events. Covers the global service reader and the caller-scoped request reader, the three reader methods and their return types, cursor semantics, deletions, redaction, backend differences, a polling service, and a per-user route. +--- + +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 a reader + +There are two readers, and they differ only in what they can see: + +| Reader | How you get it | Sees | Use it in | +| ---------------- | ------------------------------------------------------------------------- | ---------------------------------------------- | --------------------------- | +| Service reader | `ExtensionRuntimeDeps.run_evidence_reader`, passed to `start()` | Every user's runs and events | Services: exporters, indexes | +| Request reader | `resolve_run_evidence_reader(request)` or `require_run_evidence_reader(request)` | Only the authenticated caller's own runs and events | Contributed routes | + +Both implement the same `RunEvidenceReader` interface described below. + +### The service reader + +The Gateway hands one reader to every service: + +```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 service 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 a caller; route handlers use the request reader instead. + + +### The request reader + +A route handler asks the host for a reader bound to the current request: + +| Helper | Host supports it | Caller lacks access | Host does not support it | +| --------------------------------------- | ---------------- | --------------------- | ------------------------ | +| `resolve_run_evidence_reader(request)` | Returns a reader | Raises `PermissionError` | Returns `None` | +| `require_run_evidence_reader(request)` | Returns a reader | Raises `PermissionError` | Raises `NotImplementedError` | + +Map them at your HTTP boundary: `PermissionError` to `403`, and `None` or `NotImplementedError` to `503`. Any other exception from the host's resolver propagates unchanged. Neither helper ever falls back to the service reader. + +The Gateway binds the reader from the request's authentication, never from anything the caller sends: + +- The caller must be authenticated and hold the **`runs:read`** permission. By default every signed-in user has it. When `authorization.enabled` is set in `config.yaml`, the configured provider decides. A personal access token must also carry the `runs:read` scope. +- The reader is scoped to the caller's own user ID: it sees the runs recorded with that user as owner, and nothing else. +- Admins and internal callers are **not** widened: an admin's request reader sees the admin's own runs, not everyone's. + +A cursor from one reader is rejected by a reader with a different scope, so a cursor issued to one user cannot be replayed by another. See [Cursor rules](#cursor-rules). + +## 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. + +## Example: the caller's own runs + +This route returns the signed-in user's recently changed runs, one page at a time. It uses the request reader, so the host guarantees each caller sees only their own runs. It declares `api="0.2.3"` because the request-reader helpers first shipped in that contract version. + +```python filename="deerflow_extension_myruns/__init__.py" +"""List the caller's own recently changed runs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from deerflow_extension_api import ( + ExtensionRegistry, + InvalidRunEvidenceCursor, + RunEvidenceReader, + extension, + require_run_evidence_reader, +) +from fastapi import APIRouter, Depends, HTTPException, Request + + +def caller_reader(request: Request) -> RunEvidenceReader: + try: + return require_run_evidence_reader(request) + except NotImplementedError as exc: + raise HTTPException(status_code=503, detail="run evidence is not available on this host") from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + +def build_router() -> APIRouter: + router = APIRouter(prefix="/api/ext-myruns", tags=["ext-myruns"]) + + @router.get("/runs") + async def my_runs( + cursor: str | None = None, + limit: int = 50, + reader: RunEvidenceReader = Depends(caller_reader), + ) -> dict[str, Any]: + try: + page = await reader.list_changed_runs(cursor=cursor, limit=min(max(limit, 1), 200)) + except InvalidRunEvidenceCursor as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "runs": [{"thread_id": run.thread_id, "run_id": run.run_id, "status": run.status} for run in page.items], + "next_cursor": page.next_cursor, + "has_more": page.has_more, + } + + return router + + +@extension(api="0.2.3", name="myruns") +def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: + registry.routers((build_router(),)) +``` + +| Request | Response | +| ---------------------------------------------------------- | ------------------------------------------------------------------------ | +| No session | `401` from the host, before the handler runs | +| Signed in, without `runs:read` | `403 {"detail": "run evidence requires an authenticated user with runs:read"}` | +| Signed in | `200` with only the caller's runs, plus `next_cursor` and `has_more` | +| `?cursor=` not issued to this caller | `400 {"detail": "invalid run evidence cursor"}`, or `run evidence cursor scope does not match this reader` for another user's cursor | +| On a host without request-scoped evidence | `503 {"detail": "run evidence is not available on this host"}` | + +The client stores `next_cursor` and passes it back as `?cursor=` to continue. The cursor is bound to the caller, so leaking it to another user gains them nothing. + +## 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 the service reader to users.** It sees every user. Route handlers use `require_run_evidence_reader(request)`, which the host scopes to the caller. 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 00000000000..eaa42481b7a --- /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.3`: + +| `@extension(api=...)` | Result | +| --------------------- | ------------------------------------------------------------------------------- | +| not decorated | Loaded | +| `"0.2"`, `"0.2.0"`, `"0.2.3"` | Loaded | +| `"0.2.4"` | 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.4, host provides 0.2.3. Install a matching version: pip install 'deerflow-extension-api>=0.2.4,<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 00000000000..43a92a89402 --- /dev/null +++ b/frontend/src/content/en/harness/extensions/services-and-routes.mdx @@ -0,0 +1,257 @@ +--- +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. Route handlers use a caller-scoped reader instead. 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.** A request without a valid session or personal access token gets `401` 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. Requests authenticated with an `Authorization: Bearer` header skip this check. + +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. A request authenticated with a **personal access token** never carries admin rights: `is_admin` is `False` and `roles` is empty even when the token belongs to an admin. + +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`. + +### Reading run evidence in a route + +Do not serve data from the service's `run_evidence_reader` in a route: it sees every user. Call `require_run_evidence_reader(request)` instead. The host returns a reader scoped to the authenticated caller, provided they hold the `runs:read` permission. Map `PermissionError` to `403` and `NotImplementedError` to `503`. [Run Evidence](/docs/harness/extensions/run-evidence) covers the rules and a complete route. + +## 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 or personal access token | `403 {"detail": "this endpoint requires an administrator account"}` | +| `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 service-reader data from a route.** The service reader is global. Routes use the caller-scoped request reader. 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 00000000000..caebf3acc28 --- /dev/null +++ b/frontend/src/content/en/harness/extensions/troubleshooting.mdx @@ -0,0 +1,205 @@ +--- +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.3` 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.4` on a `0.2.3` host): upgrade DeerFlow, or install the extension release that declares `0.2.3` or lower. +- Declared version on an **older** minor (for example `0.1.0` on a `0.2.3` 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. + +## Plugins and browser assets + +A validation error inside `registry.plugin(...)` raises, so it surfaces as `install() failed: ` for that extension, which then loads without any of its contributions. + +| Message after `install() failed:` | Cause and fix | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `[Errno 2] No such file or directory: ''` | The manifest, or a file it lists, is not in the installed package. Check that the wheel includes it; for an index or Git install, look inside the built wheel, not your source tree | +| `Unsupported browser asset manifest; expected schema_version 1` | The manifest must be an object with exactly `schema_version: 1`, `entry`, and `files` | +| `Duplicate browser manifest key` | A key appears twice in `ui_manifest.json` | +| `Browser manifest must list unique asset paths` | `files` is empty, has more than 256 entries, repeats a path, or contains an invalid path | +| `Browser manifest entry must be a listed JavaScript module` | `entry` is missing from `files` or does not end in `.js` or `.mjs` | +| `Invalid browser asset path` | A path contains `..`, a dot segment or dotfile, an empty segment, or a character outside `A-Z a-z 0-9 _ - .` | +| `Unsupported browser asset file type` | A listed file's extension is not an accepted type. HTML is never served | +| `Browser assets must not contain symlinks` / `Browser asset root must not be a symlink` | Replace links with real files | +| `Browser asset size limit exceeded` | Over 64 KiB for the manifest, 4 MiB for one file, or 16 MiB in total | +| `Browser code must be nonempty and at most 512 KiB` | An inline `BrowserModule` is empty or too large; switch to `BrowserAssets` | +| `Unsupported browser transport` | `frontend` is neither a `BrowserModule` nor a `BrowserAssets` | + +The plugin's files are snapshotted when the Gateway starts. After changing them, upgrade the extension and restart the Gateway. Browsers may keep already-cached code from the old revision until the page is reloaded. + +### A user-facing route cannot read run evidence + +`resolve_run_evidence_reader(request)` raises `PermissionError("run evidence requires an authenticated user with runs:read")` when the caller is not authenticated or lacks `runs:read`; return 403. `require_run_evidence_reader(request)` raises `NotImplementedError("request-scoped run evidence is unavailable")` on a host without request-scoped evidence; return 503. See [Run Evidence](/docs/harness/extensions/run-evidence). + +## 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 e1934f5ad0b..4117826eb17 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 00000000000..d889e09121e --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/_meta.ts @@ -0,0 +1,36 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + "quick-start": { + title: "快速上手", + }, + runtime: { + title: "运行时模型", + }, + middleware: { + title: "中间件贡献", + }, + observers: { + title: "生命周期与观察者", + }, + "services-and-routes": { + title: "服务与路由", + }, + "run-evidence": { + title: "运行证据", + }, + plugins: { + 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 00000000000..a9cae59ac6c --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/index.mdx @@ -0,0 +1,124 @@ +--- +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) | +| `registry.plugin(contribution)` | 实验性。浏览器模块、带认证的后端动作和模型工具。宿主不支持插件时返回 `False`。见[全栈插件](/docs/harness/extensions/plugins) | + +一个扩展可以注册任意组合。[内置示例](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` **0.2.3**,可通过 `deerflow_extension_api.API_VERSION` 读取。 + +- 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 00000000000..2ccd5586cff --- /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 00000000000..5641dce4076 --- /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 00000000000..af69494637b --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/operations.mdx @@ -0,0 +1,260 @@ +--- +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。 + +### 带浏览器资源的插件 + +声明了 `BrowserAssets` 的插件,会在其 Python 包内附带一个 `ui_manifest.json` 以及它列出的静态文件。[书签示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks)把它们放在 `deerflow_extension_bookmarks/static/dist/` 下。对运维人员来说,这意味着: + +- **文件必须包含在安装内容里。** 本地目录按原样做快照,所以请在 `install` 或 `upgrade` 之前构建好 JavaScript 产物:管理器运行的是 Python 构建后端,不会执行 JavaScript 构建。对于从索引或 Git 安装的来源,这些文件必须在构建出的 wheel 里。 +- **快照规则同样适用。** 包含符号链接或疑似机密文件的本地目录会先被管理器拒绝,Gateway 根本读不到清单。 +- **Gateway 只读取一次这些文件**:在启动时 `install()` 注册插件的时候读取,之后从内存中以基于内容的 revision 提供。修改资源和修改代码一样,需要 `upgrade` 并重启。浏览器可能会保留旧 revision 中已缓存的代码,直到页面重新加载。 +- **列出的文件任何已登录用户都能读取**,包括 source map,即使插件处于禁用状态。不要在清单中发布私有源码或机密。 +- Gateway 和前端请从同一个版本部署。较旧的前端不认识 `assets-v1` 传输方式。 + +清单错误见[故障排查](/docs/harness/extensions/troubleshooting)。 + +## 升级 + +`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/plugins.mdx b/frontend/src/content/zh/harness/extensions/plugins.mdx new file mode 100644 index 00000000000..30cace308e7 --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/plugins.mdx @@ -0,0 +1,554 @@ +--- +title: 全栈插件 +description: 实验性。一个 PluginContribution 可以同时交付带工作区页面和会话动作的浏览器代码、带认证的后端动作和模型工具。本章涵盖每个字段和校验规则、两种浏览器传输方式(内联模块和清单列出的静态资源)、浏览器 API、HTTP 端点与缓存、安全模型,并逐步讲解书签示例。 +--- + +import { Callout } from "nextra/components"; + +# 全栈插件 + + + **实验性。** 插件契约为 `api_version` 1。内联的 `BrowserModule` 传输方式随 + `deerflow-extension-api` 0.2.2 引入,基于清单的 `BrowserAssets` 传输方式随 **0.2.3** + 引入。两者共享页面和动作接口,这些接口在 1.0 之前仍可能变化。 + + +其他贡献类型改变的是宿主在幕后做的事。**插件**则增加一个用户看得见、用得上的功能:工作区里的一个页面、会话菜单里的一个入口、页面可以调用的后端动作,以及模型可以调用的工具。这一切都来自一个通过常规扩展流程安装的 Python 包,不需要重新构建 DeerFlow 前端,宿主里也没有专门针对某个插件的代码。 + +## 用户看到什么 + +| 界面 | 出现位置 | +| ------------------ | --------------------------------------------------------------------------------------------------------- | +| **扩展**标签页 | 能力中心(`/workspace/capabilities?tab=extensions`)。每个已安装插件一张只读卡片:标题、描述,以及"已启用 · 由管理员管理"或"已停用 · 由管理员管理" | +| 工作区页面 | `/workspace/extensions/{namespace}/{surface id}`,可选侧边栏入口。URL 由宿主生成,插件无法占用任意路径 | +| 会话动作 | 聊天工具栏和侧边栏每个会话菜单里的一个菜单组 | +| 模型工具 | Agent 常规工具集的一部分,与其他工具一样受分组过滤和策略约束 | + +界面里没有任何地方能安装、启用或配置插件。目录对所有人都是只读的,包括管理员。浏览器会一直沿用它发现的插件集合,直到用户手动刷新页面。 + +## 贡献声明 + +`install()` 构建一个 `PluginContribution` 并传给 `registry.plugin()`: + +| 字段 | 类型 | 默认值 | 含义 | +| ------------- | ------------------------ | ------------ | ------------------------------------------------------------------------------------- | +| `namespace` | `str` | 必填 | 唯一标识,匹配 `[a-z][a-z0-9_.-]{0,95}`,例如 `community.bookmarks`。用于 URL 和工具名 | +| `title` | `str` | 必填 | 卡片和页面标题,不能为空 | +| `description` | `str` | `""` | 卡片文字 | +| `enabled` | `bool` | **`False`** | 插件开关。为 `False` 时动作和工具拒绝运行。见[启用插件](#启用插件) | +| `fields` | `tuple[SettingsField]` | `()` | 非机密的部署设置。见[设置](#设置) | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | 浏览器代码。见[浏览器代码](#浏览器代码) | +| `backend` | `tuple[BackendAction]` | `()` | 浏览器代码可以调用的动作 | +| `tools` | `tuple[ModelTool]` | `()` | 模型可以调用的工具 | +| `api_version` | `int` | `1` | 必须为 `1` | + +一个贡献至少要提供 `frontend`、`backend`、`tools` 之一。注册表会在存储任何内容之前校验整个贡献,遇到第一处违规即抛出 `ValueError`: + +| 规则 | 错误信息 | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `api_version` 为 `1` | `Unsupported plugin contract` | +| 至少有一个浏览器模块、后端动作或工具 | `A plugin must contribute a browser module, backend action or tool` | +| 工具名匹配 `[a-z][a-z0-9_]{0,63}`、互不重复,且有描述和 `async` 处理函数 | `Model tools require unique names, descriptions and async handlers` | +| 工具 schema 是合法、内联、object 类型的 JSON Schema(见[模型工具](#模型工具)) | `Invalid plugin object schema` | +| 动作名匹配 `[a-z][a-z0-9_-]{0,63}`、互不重复,且有 `async` 处理函数 | `Backend actions require unique names and async handlers` | +| `frontend` 是 `BrowserModule`、`BrowserAssets` 或 `None` | `Unsupported browser transport` | +| 内联代码非空,UTF-8 编码后不超过 512 KiB | `Browser code must be nonempty and at most 512 KiB` | +| 资源清单及其列出的每个文件都符合[资源规则](#清单规则) | `Unsupported browser asset manifest; ...`、`Browser assets must not contain symlinks` 等 | +| 命名空间、设置字段和模块标识合法(见[设置](#设置)) | `Invalid settings contribution or namespace`、`Invalid or duplicate settings field` 等 | +| 命名空间和浏览器模块标识在所有已加载插件中唯一 | `Duplicate plugin namespace`、`Duplicate browser module` | + +从 `install()` 抛出的任何异常,包括这些 `ValueError`,以及清单列出的文件不存在时抛出的 `FileNotFoundError`,都会让这个扩展整体加载失败:它注册的一切都会回滚,Gateway 日志会记录原因(见[运行时](/docs/harness/extensions/runtime))。 + +### 检查返回值 + +宿主接受贡献时,`registry.plugin()` 返回 `True`。契约的默认实现返回 `False`,不支持插件的旧宿主就是这样。一个离开界面就没有意义的插件应该明确报错,而不是只装上一半: + +```python +if registry.plugin(contribution) is not True: + raise RuntimeError("this extension requires a host with full-stack plugin support") +``` + +## 启用插件 + +插件有**两个**开关,都要打开: + +- `plugins:` 记录里的 `enabled` 决定 Gateway 是否导入这个包。扩展管理器的 `enable` 和 `disable` 命令切换的就是它。 +- `PluginContribution.enabled` 控制这个贡献本身。它默认为 `False`,而且没有运行时覆盖存储,所以从不设置它的插件会一直处于停用状态:`GET /api/plugins` 报告 `enabled: false`,浏览器不加载它的模块,动作返回 `403 Plugin disabled by administrator.`,它的工具也不会进入任何 Agent。 + +惯例是从包的私有配置里读取第二个开关,让运维人员在 `config.yaml` 里控制它: + +```python +enabled=config.get("enabled", False) is True, +``` + +```yaml +plugins: + - name: notes + use: acme_notes:install + enabled: true # 加载这个包 + config: + enabled: true # 打开这个贡献 +``` + +两者都只在启动时读取,改动任何一个都需要重启 Gateway。 + +## 设置 + +`SettingsField` 声明一个非机密的部署值,动作和工具会收到它,浏览器也可以选择性地看到: + +| 字段 | 默认值 | 含义 | +| --------------------- | -------- | ------------------------------------------------------ | +| `key` | 必填 | 匹配 `[a-z][a-z0-9_]{0,63}`。`enabled` 是保留键 | +| `title` | 必填 | 显示标签 | +| `kind` | 必填 | `"boolean"`、`"integer"` 或 `"string"` | +| `default` | 必填 | 取值。必须符合 `kind` 和下面的边界 | +| `description` | `""` | | +| `minimum` / `maximum` | `None` | 整数边界 | +| `max_length` | `256` | 字符串长度上限,1 到 4096 | + +宿主会自己加上布尔字段 `enabled`。算上它,一个插件最多 32 个字段,留给你的是 31 个。取值只来自包本身:`default` 就是每个动作、工具和浏览器收到的值。没有设置 API,也不能在线编辑,所以运维需要调整的值,请在 `install()` 里从 `config` 推导默认值。 + + + 设置不是机密存储。不要把凭据放进 `SettingsField`。在 `install()` 里从环境变量或私有 + `config` 读取机密,并保存在你自己的 Python 对象中。 + + +浏览器只能看到 `enabled` 和浏览器声明的 `public_fields` 中列出的键(`BrowserModule` 和 `BrowserAssets` 都有这个字段)。列出一个未声明的字段会导致校验失败。 + +## 后端动作 + +```python +BackendAction(name: str, handler: async (payload, context) -> Any) +``` + +浏览器经由宿主调用动作,处理函数收到: + +- `payload`:从 JSON 请求体解析出的只读映射。宿主只保证它是一个不超过 256 KiB 的 JSON **对象**,每个字段都要自己校验。 +- `context`:一个 `ActionContext`,包含 `principal`(已认证调用方的 `ExtensionPrincipal`:`user_id`、`is_admin`、`is_internal`、`roles`)和 `settings`(只读的设置映射)。 + +返回值必须可以 JSON 序列化。宿主把结果映射成 HTTP 状态码,不会暴露你的异常文本: + +| 结果 | 状态码 | 响应体 `detail` | +| ------------------------------------- | ------ | ---------------------------------------------- | +| 处理函数正常返回 | `200` | 你的返回值 | +| 处理函数抛出 `ValueError` | `422` | `Invalid plugin action input.` | +| 处理函数抛出其他异常 | `502` | `Plugin action failed.`(日志中记录异常类型) | +| 处理函数运行超过 30 秒 | `504` | `Plugin action timed out.` | +| 请求体不是 JSON 对象 | `422` | `Plugin action requires a JSON object.` | +| 请求体超过 256 KiB | `413` | `Plugin action input exceeds 256 KiB.` | +| 插件已停用 | `403` | `Plugin disabled by administrator.` | +| 未知的命名空间或动作 | `404` | `Plugin is not installed.` / `Plugin action is not installed.` | +| viewer 请求头与会话不一致 | `409` | `Account changed; reload this plugin view.` | +| 没有已认证的调用方 | `401` | `Authentication required.` | + +处理函数无法自己决定状态码,因为就连抛出 `HTTPException` 也会变成 `502`。输入错误时抛出 `ValueError`;浏览器需要错误详情时,把它放在返回体里。超时会取消处理函数,但不会回滚外部副作用,也不会中止已经在工作线程里运行的任务。 + +**授权由你负责。** 宿主负责认证调用方,但不知道哪些记录属于谁。每次读写都要按 `context.principal.user_id` 限定范围,就像书签示例在每条查询里都带上属主条件一样。 + +## 模型工具 + +```python +ModelTool(name, description, input_schema, handler, group="extensions") +``` + +- **名称。** 模型看到的是由命名空间派生的名字:`ext___`。其中命名空间被清洗为 `[a-z0-9_]` 并截断到 20 个字符,名称截断到 25 个字符,再加 12 位哈希。例如 `acme.notes` 里的 `count_notes` 会变成 `ext_acme_notes_count_notes_d8aad91f72da`。哈希覆盖完整的命名空间和名称,所以截断不会让两个工具合并;万一两个插件工具仍然同名,工具组装会直接报错而不是二选一。与普通工具重名时,保留普通工具,跳过插件工具并记录警告。 +- **Schema。** `input_schema` 必须是合法的 JSON Schema(draft 2020-12),`"type": "object"`,而且完全内联:`$ref`、`$dynamicRef`、`$recursiveRef` 都会被拒绝,因此校验永远不会解析引用或访问网络。参数名 `runtime` 和 `config` 是保留的。 +- **限制。** 宿主会按 schema 校验每次调用的输入,上限 256 KiB。处理函数必须在 30 秒内返回,JSON 编码后的结果不能超过 64 KiB。 +- **上下文。** 处理函数收到一个 `ToolContext`:`settings`、`thread_id`(不在会话中时为 `None`)和 `principal`。对工具来说,principal **只**带 `user_id`,`is_admin` 恒为 `False`,`roles` 为空。不要让工具的授权依赖它们。 +- **错误。** 任何失败都以通用工具错误的形式交给模型:`Invalid plugin tool input.`、`Plugin result exceeds 64 KiB.`、`Plugin disabled by administrator.` 或 `Plugin tool unavailable or input rejected.`。Gateway 日志会记录插件、工具和异常类型。 +- **组装与策略。** 插件工具加入常规的工具组装流程。通过 `tool_groups` 限制过的 Agent,只有在列表包含该工具的 `group`(默认为 `extensions`)时才会拿到它。宿主之后的授权和技能工具策略与其他工具一样适用。 + +工具描述就是提示词。写清楚工具返回什么、返回的是数据而不是指令,以及它是否只读。 + +## 浏览器代码 + +插件用两种传输方式之一交付浏览器代码。两者产出的东西相同:一个默认导出符合[浏览器 API](#浏览器-api) 的 ES 模块。`GET /api/plugins` 会标出每个插件的传输方式,浏览器据此决定如何加载。 + +| 传输方式 | 声明 | 引入版本 | 适用场景 | +| ----------- | ---------------- | -------- | ---------------------------------------------------------------------------- | +| `inline-v1` | `BrowserModule` | 0.2.2 | 单个不超过 512 KiB 的自包含文件:没有相对导入、CSS 文件、图片或 WASM | +| `assets-v1` | `BrowserAssets` | 0.2.3 | 清单中列出的一个目录的文件:拆分的模块、样式表、图片、字体、WASM、source map | + +两种方式中,`module` 都是匹配 `[a-z][a-z0-9.-]{0,95}` 的标识,例如 `bookmarks.v1`,绝不是 URL,并且必须与默认导出里的 `module` 相同。使用 `BrowserAssets` 的插件需要 `deerflow-extension-api>=0.2.3`,宿主的前端和后端都必须是对应版本。旧版前端无法加载 `assets-v1` 插件,但失败只影响这一个插件。 + +### 内联模块 + +```python +BrowserModule(module: str, code: str, public_fields: tuple[str, ...] = ()) +``` + +`code` 是一个自包含 ES 模块的完整文本。宿主带着会话获取它,再从临时 Blob URL 导入,所以不能有相对导入,也不能有相对于 `import.meta.url` 解析的资源。把它打进 wheel,在 `install()` 里读取: + +```python +BrowserModule("notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8")) +``` + +配置了内容安全策略(CSP)的部署必须在 `script-src` 中允许 `blob:`。 + +### 静态资源 + +```python +BrowserAssets(module: str, root: str | Path, manifest: str = "ui_manifest.json", public_fields: tuple[str, ...] = ()) +``` + +`root` 是已安装包内的一个目录,通常是 `Path(__file__).parent`。清单文件相对于 `root`,列出浏览器可以加载的每一个文件: + +```json filename="ui_manifest.json" +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": [ + "static/dist/index.mjs", + "static/dist/chunks/page.mjs", + "static/dist/notes.css" + ] +} +``` + +入口模块可以用相对路径导入同级模块,并用 `new URL(..., import.meta.url)` 定位资源: + +```js filename="static/dist/index.mjs" +import { mountNotes } from "./chunks/page.mjs"; + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], +}; +``` + +```js filename="static/dist/chunks/page.mjs" +export function mountNotes(root, context) { + const style = document.createElement("link"); + style.rel = "stylesheet"; + style.crossOrigin = "use-credentials"; + style.href = new URL("../notes.css", import.meta.url).href; + const list = document.createElement("ul"); + root.append(style, list); + context + .callBackend("list", {}) + .then(({ notes }) => { + for (const note of notes) { + const item = document.createElement("li"); + item.textContent = note; + list.append(item); + } + }) + .catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} +``` + +```python +frontend=BrowserAssets("notes.v1", Path(__file__).parent), +``` + +把清单和列出的每个文件都打进 wheel。用 hatchling 打包 Python 包目录时,不需要额外配置就会包含它们。第三方依赖要打包进来:宿主不解析裸 npm 导入,也不提供共享的 React 实例。让打包工具输出相对 URL,而不是 `/assets/...` 这样的绝对路径。 + +#### 清单规则 + +注册表在 `install()` 期间读取、校验每个列出的文件,并把它们快照到内存。任何违规都会让扩展加载失败: + +| 规则 | 错误信息 | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| 清单是只含 `schema_version`(整数 `1`)、`entry` 和 `files` 三个键的 JSON 对象 | `Unsupported browser asset manifest; expected schema_version 1` | +| 没有重复的键 | `Duplicate browser manifest key` | +| `files` 列出 1 到 256 个互不重复的合法路径 | `Browser manifest must list unique asset paths` | +| `entry` 是 `files` 之一,并以 `.js` 或 `.mjs` 结尾 | `Browser manifest entry must be a listed JavaScript module` | +| 每个文件的类型都受支持(见下) | `Unsupported browser asset file type` | +| `root` 不是符号链接,列出路径的任何一级都不是符号链接 | `Browser asset root must not be a symlink`、`Browser assets must not contain symlinks` | +| 每个列出的文件都是 `root` 内的普通文件 | `Browser asset must be a regular file inside its package`(文件不存在时抛出 `FileNotFoundError`) | +| 清单不超过 64 KiB,单个文件不超过 4 MiB,所有文件合计不超过 16 MiB | `Browser asset size limit exceeded` | + +合法路径最长 512 个字符,由 `/` 分隔的段组成;每段只能用 ASCII 字母、数字、`_`、`-` 和 `.`,并以字母、数字、`_` 或 `-` 开头。因此点文件、`.` 和 `..` 段、空段、反斜杠、百分号编码和查询字符串都不被允许。 + +支持的类型(括号内为返回的 MIME 类型):`.js` 和 `.mjs`(`text/javascript`)、`.css`、`.json` 和 `.map`(`application/json`)、`.wasm`、`.png`、`.jpg`/`.jpeg`、`.gif`、`.webp`、`.svg`、`.ico`、`.woff`、`.woff2`、`.ttf` 和 `.otf`。HTML 和服务端文件永远不会被提供。 + +#### 修订版本与缓存 + +宿主对清单和每个文件的内容哈希计算一个 SHA-256 **修订版本**,每个 URL 都包含它:`/api/plugins/{namespace}/assets/{revision}/{path}`。任何一个文件变了,所有文件的修订版本都会变。请求由启动时的快照响应,从不读取文件系统,所以修改已安装的文件在重启前不会生效。 + +资源响应带有 `Cache-Control: private, max-age=31536000, immutable` 和 `Vary: Cookie, Authorization`。未知的路径或修订版本返回 `404`,带 `private, no-store`。宿主不保留历史快照,也没有公开 CDN 的约定。升级之后,仍持有旧修订版本的页面对未缓存的内容会得到 `404`,必须刷新。反过来,插件被移除后,浏览器仍可以继续使用已缓存的代码,所以移除并不等于立即撤销缓存。 + +#### 浏览器如何加载资源 + +宿主插入一个指向入口 URL、带 `crossorigin="use-credentials"` 的原生模块脚本,再从文档的模块映射里读取导出。因此相对的静态和动态导入都会在同一个修订版本内解析,并带上会话 cookie。URL 遵循 `NEXT_PUBLIC_BACKEND_BASE_URL`,包括路径前缀。 + +- **超时。** 宿主等待 30 秒后停止等待,并把该插件标为不可用。这是等待的期限,不是取消:模块之后仍可能完成求值。顶层代码不要有副作用,UI 工作放到 `mount` 里开始。 +- **模块状态是共享的。** 一个模块实例由整个文档共享。按查看者区分的数据放在每次挂载的状态里,并在 `dispose()` 中清除。不要在模块级别跨账号切换缓存 principal 或私有结果。 +- **其他资源由你负责。** 你创建的样式表和图片要设置 `crossOrigin = "use-credentials"`,就像 `page.mjs` 那样;JSON、WASM 或二进制数据用 `fetch(url, { credentials: "include" })`。CSS 里的字体和背景 URL 并不总会带上跨域 cookie。对这些资源,优先使用同源部署,或者带凭据获取后构造 `FontFace` 或 Blob URL,并在 dispose 时释放。 +- **CSP 与 CORS。** 后端来源必须在相应的 `script-src`、`style-src`、`img-src`、`font-src` 和 `connect-src` 指令中被允许。前后端分离部署需要精确来源的带凭据 CORS,以及可用的会话 cookie。 + +### 浏览器 API + +模块的默认导出必须符合宿主的浏览器 API v1: + +```js +export default { + apiVersion: 1, // 必填 + module: "notes.v1", // 必填,与声明里的 module 相同 + icon: "file-text", // 可选 + surfaces: [/* 页面 surface,最多 16 个 */], + conversationActions(t, locale) {/* 返回一个动作组 */}, +}; +``` + +如果 `apiVersion` 或 `module` 不匹配,或者任何 surface、导航项格式不对,宿主会拒绝这个模块,并在它的卡片上显示"当前页面加载失败"。 + +### 页面 surface + +| 字段 | 规则 | +| ------------ | ------------------------------------------------------------------- | +| `id` | `[a-z][a-z0-9-]{0,63}`,模块内唯一 | +| `slot` | `"page"`(目前唯一的 slot) | +| `title` | 非空字符串 | +| `navigation` | 可选的 `{ label, labelZh?, icon? }`,添加侧边栏入口。标签最多 120 个字符 | +| `mount` | `(root, context) => { dispose }`,同步调用 | + +宿主把页面挂载到一个 Shadow DOM 根节点,并传入上下文: + +| 上下文成员 | 含义 | +| ------------------------------ | --------------------------------------------------------------------------- | +| `namespace`、`locale` | 插件命名空间和界面语言,例如 `en-US` 或 `zh-CN` | +| `settings` | `enabled` 加上 `public_fields` | +| `signal` | 卸载或切换账号时中止。传给事件监听和请求 | +| `callBackend(action, payload)` | 向本插件声明过的某个动作发 POST,解析为 JSON 响应。非 2xx 响应和未声明的动作会被拒绝 | +| `openConversation(threadId)` | 可选。先通过认证 API 解析会话再跳转;会话不存在或无权访问时拒绝,不会跳转 | + +`mount` 必须返回一个带同步 `dispose()` 的对象。卸载时宿主先中止 `signal` 再调用它,并拦截之后才到达的 `callBackend` 结果。 + +### 会话动作 + +`conversationActions(t, locale)` 必须是同步的,返回 `{ label, icon, actions }`。每个动作有 `id`、`label`、`icon`,一个返回布尔值的同步 `available(settings)`,以及 `execute(context, services)`: + +- `context.thread` 是当前会话;宿主手上已有消息时,`context.messages` 就是这些消息。 +- `services.callBackend` 与上面相同。`services.latestVisibleAnswer(context)` 返回最后一条可见的助手消息 `{ id, text }` 或 `null`。`services.conversationText(context)` 返回可见的对话文本。两者都复用宿主的导出清洗逻辑,会排除隐藏消息、工具输出和推理内容。`services.showMessage(text)` 弹出一条提示。 + +每个插件的动作都在各自的错误边界里求值。格式错误或抛出异常的动作组会被省略并记录到浏览器控制台,其他插件的动作和会话页面都不受影响。 + +### 图标 + +宿主把图标名映射到一个固定集合:`bell`、`bookmark`、`download`、`file-json` 和 `file-text`。其他名字都会显示为通用的拼图图标。 + +## HTTP 端点 + +宿主通过四个需要认证的 Gateway 路由提供插件,它们遵循常规的会话和 CSRF 策略;没有已认证的调用方时都返回 `401`: + +| 路由 | 用途 | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `GET /api/plugins` | 所有已加载插件:`namespace`、`title`、`description`、`viewer_id`、`module`、`entry`、`transport`(`inline-v1`、`assets-v1` 或 `null`)、公开的 `settings`,以及 `backend_actions` 名称 | +| `GET /api/plugins/modules/{module}/{sha256}.mjs` | 按内容哈希寻址的内联模块。`text/javascript`、`nosniff`、`private, no-store` | +| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | 清单列出的一个文件。声明的 MIME 类型、`nosniff`、`Content-Security-Policy: sandbox`,以及[修订版本与缓存](#修订版本与缓存)中描述的不可变私有缓存 | +| `POST /api/plugins/{namespace}/actions/{name}` | 调用一个后端动作(见[后端动作](#后端动作)) | + +内联哈希或资源修订版本过期时返回 `404`,需要刷新页面。两个下载路由在插件停用时仍然会提供它的代码。 + +浏览器会带上 `X-Deerflow-Plugin-Viewer` 请求头,值为它发现插件时得到的 `viewer_id`,所以在另一个账号下打开的视图发起的动作会以 `409` 被拒绝。 + +## 安全模型 + + + 浏览器模块和 Python 处理函数都是运维人员安装的受信任代码。浏览器代码运行在**主页面里**, + 拥有当前登录用户的同源能力;Shadow DOM 隔离的是 CSS,不是权限。Python 处理函数在 + Gateway 进程中以 Gateway 权限运行。 + + +宿主确实保证的: + +- 它从不从浏览器提供的路径、导入字符串或远程 URL 加载代码,只提供已安装、经内容哈希校验的内联模块,或清单列出、从启动快照读取的文件。它从不提供目录列表或未列出的文件。 +- 资源响应带有 `Content-Security-Policy: sandbox`,所以直接打开一个资源(比如内含脚本的 SVG)不能以 Gateway 的来源运行脚本。这限制的是被当作文档打开的资源,并不会把宿主加载进页面的插件 JavaScript 放进沙箱。 +- 每个端点都要求已认证的调用方,而调用方身份来自会话,不来自请求体。 +- `callBackend` 绑定在插件自己的命名空间和它声明过的动作名上。 +- 只有 `enabled` 和显式公开的字段会到达浏览器。 + +任何已认证用户都可以下载清单列出的全部资源,包括 source map,即使插件已停用。不要在清单里列出机密或私有源码。 + +仍然是插件自己的职责:按 `principal.user_id` 对自己的数据做授权,校验每个 payload,渲染用户和模型文本时做转义(用 `textContent`,绝不用 `innerHTML`),并把工具结果当作不可信数据。 + +## 逐步讲解:书签示例 + +[`examples/deerflow-extension-bookmarks`](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks)(0.2.0 版,需要 `deerflow-extension-api>=0.2.3`)是一个完整的插件:用户在会话菜单里收藏最后一条可见回答,然后在 **我的书签** 页面搜索、重命名、打开或删除书签,Agent 也可以搜索这些书签。它用到了本章的每一部分: + +1. **配置和状态。** `install()` 要求 `config.enabled`(布尔值)和一个绝对路径 `config.storage_path`,并在那里打开 SQLite 存储。容器部署时要为它挂载持久目录。 +2. **贡献声明。** 命名空间 `community.bookmarks`,`enabled=config["enabled"]`,`BrowserAssets("bookmarks.v1", Path(__file__).parent)`,五个 `BackendAction`(`save`、`search`、`get`、`rename`、`delete`),以及一个只读的 `ModelTool`:`search_bookmarks`,模型看到的名字是 `ext_community_bookmarks_search_bookmarks_`。 +3. **检查返回值。** 如果 `registry.plugin(...)` 不是 `True`,它会抛出 `RuntimeError`。 +4. **数据归属。** 每条 SQL 语句都带有 `owner = context.principal.user_id`,每个动作只接受一组确定的 payload 键。模型工具从不接受用户 ID,所以用户永远只能搜索自己的书签。 +5. **资源。** `ui_manifest.json` 列出 `static/dist/` 下的四个文件:入口 `index.mjs`、页面模块 `chunks/bookmarks.mjs`、`styles.css` 和 `bookmark.svg`。它们随 Python 包一起发布,不需要 JavaScript 构建步骤。 +6. **入口模块。** `index.mjs` 从 `./chunks/bookmarks.mjs` 导入 `mountBookmarks`,导出一个 `page` surface `library`,带 `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`,地址为 `/workspace/extensions/community.bookmarks/library`;还导出一个"书签 → 收藏最后一条回答"会话动作,先调用 `services.latestVisibleAnswer`,再调用 `callBackend("save", ...)`。 +7. **页面模块。** `chunks/bookmarks.mjs` 相对于 `import.meta.url` 解析 `../styles.css` 和 `../bookmark.svg`,并用 `crossorigin="use-credentials"` 加载它们,因此在前后端分离部署下也能工作。它监听 `context.signal`,用 `textContent` 渲染文本,并在 `dispose()` 中清空自己的 DOM。 + +部署记录如下: + +```yaml +plugins: + - name: bookmarks + use: deerflow_extension_bookmarks:install + enabled: true + config: + enabled: true + storage_path: /var/lib/deerflow/bookmarks.sqlite +``` + +重启并刷新浏览器后,扩展标签页里会出现它的卡片,侧边栏多出 **我的书签**,会话菜单多出 **书签**。这个示例使用单机 SQLite 存储,不是多节点存储的范式。 + +## 最小插件 + +最小的实用形态是一个页面、两个动作和一个只读工具。这个例子使用内联传输方式,所以只有一个 `client.mjs`;要拆成多个文件,请改用[静态资源](#静态资源),就像那一节里的 `acme.notes` 那样。下面每一部分都已经在宿主的注册表和路由上实际跑过: + +```python +"""A per-user scratchpad: a page, one backend action, one read-only model tool.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from deerflow_extension_api import ( + ActionContext, + BackendAction, + BrowserModule, + ModelTool, + PluginContribution, + SettingsField, + ToolContext, + extension, +) + +NOTES: dict[str, list[str]] = {} # demo only: in-memory, single process + + +async def add_note(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + text = payload.get("text") + if not isinstance(text, str) or not text.strip(): + raise ValueError("text is required") # becomes HTTP 422 + notes = NOTES.setdefault(context.principal.user_id, []) + if len(notes) >= context.settings["max_notes"]: + raise ValueError("note limit reached") + notes.append(text.strip()) + return {"count": len(notes)} + + +async def list_notes(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + return {"notes": NOTES.get(context.principal.user_id, [])} + + +async def count_notes(payload: Mapping[str, Any], context: ToolContext) -> dict[str, Any]: + return {"count": len(NOTES.get(context.principal.user_id, []))} + + +@extension(api="0.2.2", name="notes") +def install(registry, config: Mapping[str, Any]) -> None: + accepted = registry.plugin( + PluginContribution( + namespace="acme.notes", + title="Notes", + description="A private scratchpad for each user.", + enabled=config.get("enabled", False) is True, + fields=(SettingsField("max_notes", "Maximum notes", "integer", 50, minimum=1, maximum=500),), + frontend=BrowserModule( + "notes.v1", + Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), + public_fields=("max_notes",), + ), + backend=(BackendAction("add", add_note), BackendAction("list", list_notes)), + tools=( + ModelTool( + "count_notes", + "Count the current user's saved notes. Read-only.", + {"type": "object", "properties": {}, "additionalProperties": False}, + count_notes, + ), + ), + ) + ) + if accepted is not True: + raise RuntimeError("acme-notes requires a host with full-stack plugin support") +``` + +它的 `client.mjs` 放在 `__init__.py` 旁边,并打进 wheel: + +```js +function mountNotes(root, context) { + const list = document.createElement("ul"); + const input = document.createElement("input"); + const add = document.createElement("button"); + add.textContent = context.locale.startsWith("zh") ? "添加" : "Add"; + root.append(input, add, list); + + const render = async () => { + const { notes } = await context.callBackend("list", {}); + list.replaceChildren( + ...notes.map((note) => { + const item = document.createElement("li"); + item.textContent = note; // textContent, never innerHTML + return item; + }), + ); + }; + add.addEventListener( + "click", + async () => { + await context.callBackend("add", { text: input.value }); + input.value = ""; + await render(); + }, + { signal: context.signal }, + ); + render().catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], + conversationActions(_t, locale = "en") { + const zh = locale.startsWith("zh"); + return { + label: zh ? "笔记" : "Notes", + icon: "file-text", + actions: [ + { + id: "save-answer", + label: zh ? "把最后一条回答存为笔记" : "Save last answer as a note", + icon: "file-text", + available: (settings) => settings.enabled === true, + async execute(context, services) { + const answer = await services.latestVisibleAnswer?.(context); + if (!answer) return services.showMessage(zh ? "没有可保存的回答" : "No answer to save"); + await services.callBackend("add", { text: answer.text.slice(0, 2000) }); + services.showMessage(zh ? "已保存" : "Saved"); + }, + }, + ], + }; + }, +}; +``` + +内存字典只用于示例。真实插件需要能在重启后保留、并在多个 Gateway worker 之间共享的存储。 + +## 生命周期 + +- 安装、升级、启用、停用和任何 `config` 改动都需要重启 Gateway,然后刷新浏览器。没有热加载,也没有热卸载。 +- 重启之后,已打开的页面可能指向已不存在的模块哈希、资源修订版本或动作。这些请求会明确失败(`404`),直到用户刷新页面。 +- 已停用或已移除的插件在刷新后没有侧边栏入口。直接访问它的页面 URL 会显示"扩展页面不可用",不会挂载任何内容。 + +运维命令见[运维](/docs/harness/extensions/operations)。 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 00000000000..dc050e44bee --- /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 00000000000..f99a9179a7a --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/reference.mdx @@ -0,0 +1,510 @@ +--- +title: 参考 +description: deerflow-extension-api 0.2.3 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。 +--- + +import { Callout } from "nextra/components"; + +# 参考 + +本页列出契约版本 **0.2.3** 中 `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()` 的只写接口。每个方法都有一个什么都不注册的默认实现。如果宿主的注册表早于某个方法,就会继承这个默认实现,于是调用成功,但贡献并没有被注册。只有 `plugin()` 会通过返回 `False` 报告这一点;对其他方法,要靠版本标记和包元数据来防止这种情况。 + +| 方法 | 返回值 | 注册内容 | +| ---------------------------------------------------------- | ------- | ---------------------------------------------------------------------------- | +| `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 路由器 | +| `plugin(contribution: PluginContribution)` | `bool` | 全栈插件。被接受时返回 `True`,宿主不支持插件时返回 `False`。实验性 | + +## 中间件 + +### `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`。 + +### `resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None` + +返回绑定到贡献路由已认证调用者的读取器。面向用户的路由应使用它,而不是能看到所有用户运行的全局 `ExtensionRuntimeDeps.run_evidence_reader`。`request` 是鸭子类型。宿主不支持请求级运行证据时返回 `None`。调用者未认证或缺少 `runs:read` 权限时,Gateway 抛出 `PermissionError("run evidence requires an authenticated user with runs:read")`;它从不把管理员或内部调用者放宽到全局可见。其他解析器错误会直接传播。 + +### `require_run_evidence_reader(request: object) -> RunEvidenceReader` + +与 `resolve_run_evidence_reader()` 相同,但在不支持时抛出 `NotImplementedError("request-scoped run evidence is unavailable")`,而不是返回 `None`。把 `NotImplementedError` 映射为 HTTP 503,把 `PermissionError` 映射为 403。它从不回退到全局读取器。 + +### `RUN_EVIDENCE_READER_RESOLVER_KEY` + +`"deerflow_extension_run_evidence_reader_resolver"`。宿主安装请求级解析器所用的 `app.state` 属性名,由宿主拥有。 + +### `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 十六进制摘要。 + +## 全栈插件(实验性) + + + 插件契约在 0.2.3 中是实验性的。务必检查 `registry.plugin(...)` 的返回值。见[插件](/docs/harness/extensions/plugins)。 + + +### `PluginContribution` + +冻结的 dataclass。 + +| 字段 | 类型 | 默认值 | +| ------------- | ------------------------------ | ------- | +| `namespace` | `str` | 必填 | +| `title` | `str` | 必填 | +| `description` | `str` | `""` | +| `enabled` | `bool` | `False` | +| `fields` | `tuple[SettingsField, ...]` | `()` | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | +| `backend` | `tuple[BackendAction, ...]` | `()` | +| `api_version` | `int` | `1` | +| `tools` | `tuple[ModelTool, ...]` | `()` | + +宿主只接受 `api_version == 1`,并要求 `frontend`、`backend`、`tools` 至少有一项。命名空间必须唯一。 + +### `BrowserModule` + +冻结的 dataclass:`module: str`、`code: str`(一个自包含的 ES 模块,非空且不超过 512 KiB)、`public_fields: tuple[str, ...] = ()`。发现接口把这种传输方式标记为 `inline-v1`。模块需要相对导入、样式表或图片时,请使用 `BrowserAssets`。 + +### `BrowserAssets` + +冻结的 dataclass,声明已安装包内的清单和静态文件。发现接口把这种传输方式标记为 `assets-v1`。 + +| 字段 | 类型 | 默认值 | +| --------------- | ----------------- | -------------------- | +| `module` | `str` | 必填 | +| `root` | `str \| Path` | 必填 | +| `manifest` | `str` | `"ui_manifest.json"` | +| `public_fields` | `tuple[str, ...]` | `()` | + +`root` 通常是 `Path(__file__).parent`。清单相对于 `root`,必须是恰好包含 `schema_version`(整数 `1`)、`entry` 和 `files` 三个键的 JSON 对象: + +```json +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": ["static/dist/index.mjs", "static/dist/styles.css"] +} +``` + +`files` 列出 1 到 256 个不重复的路径,`entry` 必须是其中之一,并以 `.js` 或 `.mjs` 结尾。路径只能使用 ASCII 字母、数字、`_`、`-` 和 `.`,以 `/` 分隔,每一段必须以字母、数字、`_` 或 `-` 开头。`root` 本身不能是符号链接,列出的路径也不能经过符号链接。限制:清单 64 KiB,单个文件 4 MiB,总计 16 MiB。接受的文件类型:`.js`、`.mjs`、`.css`、`.json`、`.map`、`.wasm`、`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`、`.svg`、`.ico`、`.woff`、`.woff2`、`.ttf`、`.otf`。 + +`registry.plugin()` 会校验清单,并把列出的每个文件读入内存快照。校验错误会从 `registry.plugin()` 抛出,于是 `install()` 失败。快照的 revision 是对清单、路径和文件内容计算的 SHA-256,Gateway 从这个快照在 `GET /api/plugins/{namespace}/assets/{revision}/{path}` 提供文件。任何已认证用户都能下载列出的文件(包括 source map),即使插件处于禁用状态,所以绝不要列出机密。 + +### `BackendAction` + +冻结的 dataclass:`name: str`、`handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]`。名称必须唯一,handler 必须是异步的。 + +### `ModelTool` + +冻结的 dataclass:`name: str`、`description: str`、`input_schema: Mapping[str, Any]`(内联的对象 schema)、`handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]`、`group: str = "extensions"`。 + +### `ActionContext` / `ToolContext` + +冻结的 dataclass。`ActionContext`:`principal: ExtensionPrincipal`、`settings: Mapping[str, bool | int | str]`。`ToolContext` 在其基础上增加 `thread_id: str | None`。 + +### `SettingsField` + +描述一项非机密部署设置的冻结 dataclass。 + +| 字段 | 类型 | 默认值 | +| ------------- | -------------------------------------------- | ------- | +| `key` | `str` | 必填 | +| `title` | `str` | 必填 | +| `kind` | `Literal["boolean", "integer", "string"]` | 必填 | +| `default` | `bool \| int \| str` | 必填 | +| `description` | `str` | `""` | +| `minimum` | `int \| None` | `None` | +| `maximum` | `int \| None` | `None` | +| `max_length` | `int` | `256` | + +## 版本常量 + +### `API_VERSION` + +`"0.2.3"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。 + +## 兼容性规则 + +- **增量演进。** 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值。新增方法或字段的契约发布不会破坏基于更早版本构建的扩展。 +- **1.0 之前**,minor 版本可以有不兼容变更,patch 版本只做增量。**1.0 之后**,不兼容变更会升 major。 +- **`@extension(api=...)` 检查。** install 函数带有标记时,宿主只在以下情况接受它: + - 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.3` 宿主接受 `0.2.0` 到 `0.2.3`,拒绝 `0.2.4`、`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` | +| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | 实验性的 `registry.plugin()`,以及 `PluginContribution`、`BrowserModule`、`BackendAction`、`ModelTool`、`ActionContext`、`ToolContext`、`SettingsField` | +| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727)、[#5685](https://github.com/bytedance/deer-flow/pull/5685) | 请求级运行证据:`resolve_run_evidence_reader`、`require_run_evidence_reader`、`RUN_EVIDENCE_READER_RESOLVER_KEY`(#5727)。打包的浏览器资源:`BrowserAssets`,`PluginContribution.frontend` 也接受它(#5685) | + +至今没有任何版本移除过公开名称。 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 00000000000..738d25a4829 --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/run-evidence.mdx @@ -0,0 +1,335 @@ +--- +title: 运行证据 +description: 让服务或路由发现有变化的运行并读取其持久化事件的只读读取器。涵盖全局的服务读取器和限定调用方的请求读取器、读取器的三个方法及返回类型、游标语义、删除、脱敏、后端差异、一个轮询服务,以及一个按用户的路由。 +--- + +import { Callout } from "nextra/components"; + +# 运行证据 + +中间件和生命周期钩子在运行发生时看到它。**运行证据读取器**让扩展在事后查看运行,依据的是 Gateway 已经持久化的记录:有哪些运行、它们最终处于什么状态、每个运行写下了怎样的事件流。扩展可以借此构建导出、审计索引或看板,而不必挂钩每一次调用。 + +读取器是只读的,没有任何方法会写入宿主。 + +## 获取读取器 + +读取器有两种,区别只在于能看到什么: + +| 读取器 | 获取方式 | 能看到 | 用在 | +| ---------- | ------------------------------------------------------------------------- | -------------------------------- | -------------- | +| 服务读取器 | `ExtensionRuntimeDeps.run_evidence_reader`,传给 `start()` | 所有用户的运行和事件 | 服务:导出器、索引 | +| 请求读取器 | `resolve_run_evidence_reader(request)` 或 `require_run_evidence_reader(request)` | 只有已认证调用方自己的运行和事件 | 贡献的路由 | + +两者都实现下文介绍的同一个 `RunEvidenceReader` 接口。 + +### 服务读取器 + +Gateway 把一个读取器交给每个服务: + +```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 有意不把这个读取器绑定到任何用户。事件内容按存储原样返回。 + 永远不要把它的数据返回给调用方;路由处理函数请改用请求读取器。 + + +### 请求读取器 + +路由处理函数向宿主索取一个绑定到当前请求的读取器: + +| 辅助函数 | 宿主支持 | 调用方无权访问 | 宿主不支持 | +| --------------------------------------- | ------------ | ------------------------ | ---------------------------- | +| `resolve_run_evidence_reader(request)` | 返回读取器 | 抛出 `PermissionError` | 返回 `None` | +| `require_run_evidence_reader(request)` | 返回读取器 | 抛出 `PermissionError` | 抛出 `NotImplementedError` | + +在你的 HTTP 边界做映射:`PermissionError` 映射为 `403`,`None` 或 `NotImplementedError` 映射为 `503`。宿主解析器抛出的其他异常原样传播。两个辅助函数都不会回退到服务读取器。 + +Gateway 依据请求的认证信息绑定读取器,绝不依据调用方发送的任何内容: + +- 调用方必须已认证并持有 **`runs:read`** 权限。默认所有已登录用户都有。在 `config.yaml` 中设置了 `authorization.enabled` 时,由配置的提供方决定。个人访问令牌还必须带有 `runs:read` scope。 +- 读取器限定在调用方自己的用户 ID 上:只能看到以该用户为所有者记录的运行,别的都看不到。 +- 管理员和内部调用方**不会**被放宽:管理员的请求读取器只能看到管理员自己的运行,而不是所有人的。 + +一个读取器签发的游标会被作用域不同的读取器拒绝,所以签发给某个用户的游标无法被另一个用户重放。见[游标规则](#游标规则)。 + +## 读取器接口 + +`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`,而不是让服务停下。 +- 一次同步中的异常会被记录,下一次轮询从最后提交的游标继续。 + +## 示例:调用方自己的运行 + +这个路由分页返回已登录用户最近发生变化的运行。它使用请求读取器,所以由宿主保证每个调用方只能看到自己的运行。它声明 `api="0.2.3"`,因为请求读取器辅助函数最早随该契约版本发布。 + +```python filename="deerflow_extension_myruns/__init__.py" +"""List the caller's own recently changed runs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from deerflow_extension_api import ( + ExtensionRegistry, + InvalidRunEvidenceCursor, + RunEvidenceReader, + extension, + require_run_evidence_reader, +) +from fastapi import APIRouter, Depends, HTTPException, Request + + +def caller_reader(request: Request) -> RunEvidenceReader: + try: + return require_run_evidence_reader(request) + except NotImplementedError as exc: + raise HTTPException(status_code=503, detail="run evidence is not available on this host") from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + +def build_router() -> APIRouter: + router = APIRouter(prefix="/api/ext-myruns", tags=["ext-myruns"]) + + @router.get("/runs") + async def my_runs( + cursor: str | None = None, + limit: int = 50, + reader: RunEvidenceReader = Depends(caller_reader), + ) -> dict[str, Any]: + try: + page = await reader.list_changed_runs(cursor=cursor, limit=min(max(limit, 1), 200)) + except InvalidRunEvidenceCursor as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "runs": [{"thread_id": run.thread_id, "run_id": run.run_id, "status": run.status} for run in page.items], + "next_cursor": page.next_cursor, + "has_more": page.has_more, + } + + return router + + +@extension(api="0.2.3", name="myruns") +def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: + registry.routers((build_router(),)) +``` + +| 请求 | 响应 | +| -------------------------------------- | -------------------------------------------------------------------------- | +| 没有会话 | 宿主返回 `401`,处理函数不会运行 | +| 已登录,但没有 `runs:read` | `403 {"detail": "run evidence requires an authenticated user with runs:read"}` | +| 已登录 | `200`,只包含调用方的运行,以及 `next_cursor` 和 `has_more` | +| `?cursor=` 不是签发给这个调用方的 | `400 {"detail": "invalid run evidence cursor"}`;如果是其他用户的游标,则为 `run evidence cursor scope does not match this reader` | +| 宿主不支持请求作用域的证据 | `503 {"detail": "run evidence is not available on this host"}` | + +客户端保存 `next_cursor`,并以 `?cursor=` 传回以继续翻页。游标绑定到调用方,泄露给其他用户也没有用。 + +## 常见误区 + +- **把空页当作不支持。** 空页表示已追平。不支持是 `run_evidence_reader is None`。 +- **在工作保存之前推进游标。** 一旦崩溃,这一页会被永久跳过。请先保存再推进。 +- **以为每个运行只有一条记录。** 运行每次变化都会再次出现。请以 `run_id` 为键组织产出。 +- **等待删除事件。** 没有这种事件。请用 `get_run_status` 对账。 +- **把服务读取器的数据提供给用户。** 它能看到所有用户。路由处理函数请使用 `require_run_evidence_reader(request)`,由宿主把它限定到调用方。 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 00000000000..4bc296e3a06 --- /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.3` 为例: + +| `@extension(api=...)` | 结果 | +| ----------------------------- | -------------------------------------------------- | +| 未装饰 | 加载 | +| `"0.2"`、`"0.2.0"`、`"0.2.3"` | 加载 | +| `"0.2.4"` | 拒绝:扩展可能用到本宿主没有的新增内容 | +| `"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.4, host provides 0.2.3. Install a matching version: pip install 'deerflow-extension-api>=0.2.4,<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 00000000000..b09e13ab3a4 --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx @@ -0,0 +1,256 @@ +--- +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`。 +- **CSRF。** 来自浏览器会话的 `POST`、`PUT`、`PATCH` 或 `DELETE` 必须在 `X-CSRF-Token` 头里带上 `csrf_token` cookie 的值,DeerFlow 前端已经这样做了。缺少时请求在你的处理函数运行之前就会得到 `403`。用 `Authorization: Bearer` 头认证的请求跳过这项检查。 + +当 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`,不是管理员。用**个人访问令牌**认证的请求永远不带管理员权限:即使令牌属于管理员,`is_admin` 也是 `False`,`roles` 为空。 + +两个辅助函数都是同步的,在同步和异步处理函数里都能用。它们与框架无关,所以要自己把结果映射成 HTTP 状态码:`None` 映射为 `401`,`PermissionError` 映射为 `403`。 + +### 在路由中读取运行证据 + +不要在路由里返回服务的 `run_evidence_reader` 中的数据:它能看到所有用户。请改为调用 `require_run_evidence_reader(request)`。只要调用方持有 `runs:read` 权限,宿主就返回一个限定到该已认证调用方的读取器。把 `PermissionError` 映射为 `403`,`NotImplementedError` 映射为 `503`。规则和一个完整的路由见[运行证据](/docs/harness/extensions/run-evidence)。 + +## 示例:一个状态 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"}` | +| `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 00000000000..5d67d31e2f3 --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/troubleshooting.mdx @@ -0,0 +1,205 @@ +--- +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.3` 宿主上声明 `0.3.0`):升级 DeerFlow,或安装为这个宿主构建的扩展版本。 +- 声明的 patch 版本**高于**宿主(例如在 `0.2.3` 宿主上声明 `0.2.4`):升级 DeerFlow,或安装声明 `0.2.3` 及以下版本的扩展。 +- 声明的版本属于**更旧**的 minor(例如在 `0.2.3` 宿主上声明 `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 构建期间同步运行;保持轻量并且不要抛异常。 + +## 插件与浏览器资源 + +`registry.plugin(...)` 内部的校验错误会直接抛出,因此会以该扩展的 `install() failed: <消息>` 出现,而该扩展的所有贡献都不会被加载。 + +| `install() failed:` 之后的消息 | 原因与修复 | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `[Errno 2] No such file or directory: ''` | 清单或它列出的某个文件不在已安装的包里。检查 wheel 是否包含它;对于从索引或 Git 安装的包,要检查构建出的 wheel,而不是你的源码目录 | +| `Unsupported browser asset manifest; expected schema_version 1` | 清单必须是恰好包含 `schema_version: 1`、`entry` 和 `files` 的对象 | +| `Duplicate browser manifest key` | `ui_manifest.json` 中某个键出现了两次 | +| `Browser manifest must list unique asset paths` | `files` 为空、超过 256 项、有重复路径,或包含非法路径 | +| `Browser manifest entry must be a listed JavaScript module` | `entry` 不在 `files` 中,或不以 `.js` / `.mjs` 结尾 | +| `Invalid browser asset path` | 路径中含有 `..`、点号段或点文件、空段,或 `A-Z a-z 0-9 _ - .` 以外的字符 | +| `Unsupported browser asset file type` | 列出的某个文件扩展名不在接受的类型中。HTML 永远不会被提供 | +| `Browser assets must not contain symlinks` / `Browser asset root must not be a symlink` | 把链接替换成真实文件 | +| `Browser asset size limit exceeded` | 清单超过 64 KiB、单个文件超过 4 MiB,或总计超过 16 MiB | +| `Browser code must be nonempty and at most 512 KiB` | 内联的 `BrowserModule` 为空或过大;改用 `BrowserAssets` | +| `Unsupported browser transport` | `frontend` 既不是 `BrowserModule` 也不是 `BrowserAssets` | + +插件的文件在 Gateway 启动时做快照。修改这些文件后,要升级扩展并重启 Gateway。浏览器可能会保留旧 revision 中已缓存的代码,直到页面重新加载。 + +### 面向用户的路由无法读取运行证据 + +调用者未认证或缺少 `runs:read` 时,`resolve_run_evidence_reader(request)` 会抛出 `PermissionError("run evidence requires an authenticated user with runs:read")`,应返回 403。宿主不支持请求级运行证据时,`require_run_evidence_reader(request)` 会抛出 `NotImplementedError("request-scoped run evidence is unavailable")`,应返回 503。见[运行证据](/docs/harness/extensions/run-evidence)。 + +## 服务与路由 + +| `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 说明请求未认证,而不是路由不存在。 From 407dc7d052546a82cdeb12884f3d3d0f9a28d7f0 Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Wed, 23 Sep 2026 16:08:56 +0800 Subject: [PATCH 2/4] docs(agents): trim root AGENTS.md to keep inherited chains under the size limit --- AGENTS.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8239117150..b13cff909d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,8 +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 five extension contribution kinds -├── examples/deerflow-extension-bookmarks/ # Full-stack plugin example: browser page, conversation action, backend actions +├── examples/ # Extension examples: deerflow-extension-{example,bookmarks} ├── 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 @@ -76,18 +75,14 @@ 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, agent-assembly and context-compaction observers, -Gateway services, FastAPI HTTP routers, and experimental full-stack plugins. The -[reference extension](examples/deerflow-extension-example/) demonstrates middleware, task -lifecycle, a system-model observer, a service, and a router; the -[bookmarks extension](examples/deerflow-extension-bookmarks/) demonstrates a plugin. Manage -extensions with `deerflow extensions install/upgrade/list/enable/disable/remove` or the root +middleware, lifecycle observers, Gateway services, FastAPI HTTP routers, and experimental +full-stack plugins. 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 user-facing -extension manual lives at `frontend/src/content/{en,zh}/harness/extensions/`. +[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 From 2fac7c9dd1a299da8f48515e877df09a874b82d0 Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Wed, 23 Sep 2026 17:33:50 +0800 Subject: [PATCH 3/4] docs(extensions): scope the manual to the 0.2.1 contract Plugins and request-scoped run evidence are not in 2.1.x-dev yet; they move to a stacked follow-up so this change can be cherry-picked. --- AGENTS.md | 5 +- CHANGELOG.md | 11 +- CHANGELOG_zh.md | 9 +- .../harness/deerflow/extensions/AGENTS.md | 5 +- docs/full-stack-plugins.md | 2 +- .../content/en/harness/extensions/_meta.ts | 3 - .../content/en/harness/extensions/index.mdx | 5 +- .../en/harness/extensions/operations.mdx | 12 - .../content/en/harness/extensions/plugins.mdx | 557 ------------------ .../en/harness/extensions/reference.mdx | 108 +--- .../en/harness/extensions/run-evidence.mdx | 114 +--- .../content/en/harness/extensions/runtime.mdx | 8 +- .../extensions/services-and-routes.mdx | 8 +- .../en/harness/extensions/troubleshooting.mdx | 30 +- .../content/zh/harness/extensions/_meta.ts | 3 - .../content/zh/harness/extensions/index.mdx | 5 +- .../zh/harness/extensions/operations.mdx | 12 - .../content/zh/harness/extensions/plugins.mdx | 554 ----------------- .../zh/harness/extensions/reference.mdx | 107 +--- .../zh/harness/extensions/run-evidence.mdx | 108 +--- .../content/zh/harness/extensions/runtime.mdx | 8 +- .../extensions/services-and-routes.mdx | 8 +- .../zh/harness/extensions/troubleshooting.mdx | 30 +- 23 files changed, 59 insertions(+), 1653 deletions(-) delete mode 100644 frontend/src/content/en/harness/extensions/plugins.mdx delete mode 100644 frontend/src/content/zh/harness/extensions/plugins.mdx diff --git a/AGENTS.md b/AGENTS.md index b13cff909d2..047dd43c8f8 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/ # Extension examples: deerflow-extension-{example,bookmarks} +├── 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,8 +75,7 @@ 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, lifecycle observers, Gateway services, FastAPI HTTP routers, and experimental -full-stack plugins. 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 46de4f094c9..35e3936635c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2981,14 +2981,13 @@ This release closes that milestone with **765 merged pull requests**. 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.3 contract: when + 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, experimental full-stack plugins, 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 in `AGENTS.md` and of the plugin `mount` return - value in `docs/full-stack-plugins.md`. + 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 fa155953547..8df1f120da7 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2228,11 +2228,10 @@ 成为该章节的索引页,指向该页面的已有链接保持有效;指向旧页面小节锚点的深链接 会落到索引页。 - **文档:** 新增中英文扩展开发手册(`harness/extensions/`),覆盖 - `deerflow-extension-api` 0.2.3 契约:何时编写扩展、快速上手、运行时模型、中间件 - 放置位置、生命周期与观察者、服务与路由、运行证据读取器、实验性的全栈插件、扩展 - 运维、按错误信息排查,以及列出全部公开名称和契约版本历史的参考章节。同时修正 - `AGENTS.md` 中对贡献类型的过时描述,以及 `docs/full-stack-plugins.md` 中插件 - `mount` 返回值的描述。 + `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 5df2f83b04a..db3fd3d9cfe 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -141,10 +141,9 @@ entry, the manager owns the controlled locked sync. The public package is `packages/extension-api/` and must never import `deerflow` or carry framework dependencies. Extensions declare any FastAPI, LangChain, or LangGraph imports -themselves. Its registry contract exposes eight contribution kinds: middleware +themselves. Its registry contract exposes seven contribution kinds: middleware contributors, task-lifecycle contributors, system-model-call observers, agent-assembly -observers, context-compaction observers, Gateway-lifetime services, eager routers, and -experimental full-stack plugins (`registry.plugin()`, see `docs/full-stack-plugins.md`). Middleware contributions declare lead/subagent scope, stable +observers, context-compaction observers, Gateway-lifetime services, and eager routers. Middleware contributions declare lead/subagent scope, stable order, and a semantic placement (`MODEL_LOGICAL`, `MODEL_PHYSICAL`, `TOOL_VISIBLE`, `TOOL_RAW`, or `STANDARD`) rather than a fragile list index. `extensions/stack.py` is the single final composition point; do not inject inside diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md index cb60358f9c9..546ec5bdfa2 100644 --- a/docs/full-stack-plugins.md +++ b/docs/full-stack-plugins.md @@ -79,7 +79,7 @@ allow credentialed CORS from the frontend, as for other host API calls. A page surface declares `id`, `slot: "page"`, `title`, `mount(root, context)` and optional `navigation: { label, labelZh?, icon? }`. The host generates the URL `/workspace/extensions/{namespace}/{id}` and mounts only that registered page. -`mount` runs synchronously and returns an object `{ dispose }`, whose `dispose()` is called on unmount. The context includes locale, +`mount` returns a synchronous `dispose()` callback. The context includes locale, public settings, an abort signal and a namespace-bound `callBackend` helper. The optional `openConversation(threadId)` host helper reads current conversation metadata through the authenticated API and uses the host's normal/custom-agent diff --git a/frontend/src/content/en/harness/extensions/_meta.ts b/frontend/src/content/en/harness/extensions/_meta.ts index 3a2189030fc..312cede7139 100644 --- a/frontend/src/content/en/harness/extensions/_meta.ts +++ b/frontend/src/content/en/harness/extensions/_meta.ts @@ -19,9 +19,6 @@ const meta: MetaRecord = { "run-evidence": { title: "Run Evidence", }, - plugins: { - title: "Full-Stack Plugins", - }, operations: { title: "Operating Extensions", }, diff --git a/frontend/src/content/en/harness/extensions/index.mdx b/frontend/src/content/en/harness/extensions/index.mdx index 0d5c346ec3c..c57dd63e1d2 100644 --- a/frontend/src/content/en/harness/extensions/index.mdx +++ b/frontend/src/content/en/harness/extensions/index.mdx @@ -51,7 +51,6 @@ The two middleware paths are easy to confuse. `extensions.middlewares` inserts a | `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) | -| `registry.plugin(contribution)` | Experimental. Browser modules, authenticated backend actions, and model tools. Returns `False` on a host without plugin support. See [Full-Stack Plugins](/docs/harness/extensions/plugins) | 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. @@ -103,7 +102,7 @@ The extension manager asks for confirmation before installing, rejects source UR ## Versioning -The contract package is versioned separately from DeerFlow. The current version is `deerflow-extension-api` **0.2.3**, exposed as `deerflow_extension_api.API_VERSION`. +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. @@ -114,7 +113,7 @@ Declare the matching range in your package metadata as well, for example `deerfl ## Terminology - **Host**: the DeerFlow Gateway process that loads extensions. -- **Contribution**: one object registered through the registry: a contributor, observer, service, router, or plugin. +- **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. diff --git a/frontend/src/content/en/harness/extensions/operations.mdx b/frontend/src/content/en/harness/extensions/operations.mdx index 82f505f3536..609475846a3 100644 --- a/frontend/src/content/en/harness/extensions/operations.mdx +++ b/frontend/src/content/en/harness/extensions/operations.mdx @@ -156,18 +156,6 @@ 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. -### Plugins with browser assets - -A plugin that declares `BrowserAssets` ships a `ui_manifest.json` and the static files it lists inside its Python package. The [bookmarks example](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks) keeps them under `deerflow_extension_bookmarks/static/dist/`. For operators this means: - -- **The files must be in what gets installed.** A local directory is snapshotted as-is, so build any JavaScript bundle before `install` or `upgrade`: the manager runs the Python build backend, not a JavaScript build. For an index or Git source, the files must be inside the built wheel. -- **The snapshot rules still apply.** A local directory containing symlinks or secret-looking files is rejected by the manager before the Gateway ever reads the manifest. -- **The Gateway reads the files once**, when `install()` registers the plugin at startup, and serves them from memory under a content-derived revision. Changing an asset needs `upgrade` and a restart, like any code change. Browsers may keep cached code from the previous revision until the page is reloaded. -- **Listed files are readable by any signed-in user**, including source maps, even while the plugin is disabled. Do not ship private sources or secrets in the manifest. -- Deploy the Gateway and the frontend from the same release. An older frontend does not understand the `assets-v1` transport. - -Manifest errors are listed in [Troubleshooting](/docs/harness/extensions/troubleshooting). - ## Upgrading `upgrade` replaces the source of an extension that is already installed and keeps its record: `config`, `required`, and `enabled` stay as they are. diff --git a/frontend/src/content/en/harness/extensions/plugins.mdx b/frontend/src/content/en/harness/extensions/plugins.mdx deleted file mode 100644 index 0cb8f74bf9a..00000000000 --- a/frontend/src/content/en/harness/extensions/plugins.mdx +++ /dev/null @@ -1,557 +0,0 @@ ---- -title: Full-Stack Plugins -description: Experimental. One PluginContribution can ship browser code with workspace pages and conversation actions, authenticated backend actions, and model tools. Covers every field and validation rule, the two browser transports (inline module and manifest-listed static assets), the browser API, the HTTP endpoints and caching, the security model, and a walkthrough of the bookmarks example. ---- - -import { Callout } from "nextra/components"; - -# Full-Stack Plugins - - - **Experimental.** The plugin contract is `api_version` 1. The inline - `BrowserModule` transport arrived in `deerflow-extension-api` 0.2.2, and the - manifest-based `BrowserAssets` transport in **0.2.3**. The page and action - interfaces are shared by both and may still change before 1.0. - - -The other contribution kinds change what the host does behind the scenes. A **plugin** adds a feature users can see and use: a page in the workspace, an entry in the conversation menu, backend actions that page can call, and tools the model can call. All of it comes from one Python package installed through the ordinary extension workflow, so there is no DeerFlow frontend rebuild and no host code specific to the plugin. - -## What users see - -| Surface | Where it appears | -| -------------------------- | ---------------------------------------------------------------------------------------------------------- | -| **Extensions** tab | Capability Center (`/workspace/capabilities?tab=extensions`). A read-only card per installed plugin: title, description, and "Enabled · Managed by your administrator" or "Disabled · ..." | -| Workspace page | `/workspace/extensions/{namespace}/{surface id}`, with an optional sidebar entry. The host builds the URL; a plugin cannot claim arbitrary paths | -| Conversation action | A menu group in the chat toolbar and in each sidebar conversation's menu | -| Model tool | Part of the agent's ordinary tool set, subject to the same group filtering and policies as any other tool | - -Nothing in the UI installs, enables, or configures a plugin. The catalog is read-only for everyone, administrators included. Browsers keep the plugin set they discovered until the page is reloaded manually. - -## The contribution - -`install()` builds one `PluginContribution` and passes it to `registry.plugin()`: - -| Field | Type | Default | Meaning | -| ------------- | ------------------------ | -------------- | ------------------------------------------------------------------------------------------ | -| `namespace` | `str` | required | Unique identity, matching `[a-z][a-z0-9_.-]{0,95}`, such as `community.bookmarks`. Used in URLs and tool names | -| `title` | `str` | required | Card and page title. Must be non-empty | -| `description` | `str` | `""` | Card text | -| `enabled` | `bool` | **`False`** | The plugin's on switch. Actions and tools refuse to run while it is `False`. See [Enabling](#enabling-a-plugin) | -| `fields` | `tuple[SettingsField]` | `()` | Non-secret deployment settings. See [Settings](#settings) | -| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | The browser code. See [Browser code](#browser-code) | -| `backend` | `tuple[BackendAction]` | `()` | Actions the browser code can call | -| `tools` | `tuple[ModelTool]` | `()` | Tools the model can call | -| `api_version` | `int` | `1` | Must be `1` | - -A contribution must supply at least one of `frontend`, `backend`, or `tools`. The registry validates the whole contribution before storing any of it, and raises `ValueError` on the first violation: - -| Rule | Error message | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| `api_version` is `1` | `Unsupported plugin contract` | -| At least one browser module, backend action, or tool | `A plugin must contribute a browser module, backend action or tool` | -| Tool names match `[a-z][a-z0-9_]{0,63}`, are unique, have a description and an `async` handler | `Model tools require unique names, descriptions and async handlers` | -| Tool schemas are valid, inline, object-typed JSON Schema (see [Model tools](#model-tools)) | `Invalid plugin object schema` | -| Action names match `[a-z][a-z0-9_-]{0,63}`, are unique, and have an `async` handler | `Backend actions require unique names and async handlers` | -| `frontend` is a `BrowserModule`, a `BrowserAssets`, or `None` | `Unsupported browser transport` | -| Inline code is non-empty and at most 512 KiB of UTF-8 | `Browser code must be nonempty and at most 512 KiB` | -| An assets manifest and every listed file pass the [asset rules](#manifest-rules) | `Unsupported browser asset manifest; ...`, `Browser assets must not contain symlinks`, ... | -| Namespace, settings fields, and module identifier are valid (see [Settings](#settings)) | `Invalid settings contribution or namespace`, `Invalid or duplicate settings field`, ... | -| Namespace and browser module identifier are unique across all loaded plugins | `Duplicate plugin namespace`, `Duplicate browser module` | - -Any exception escaping `install()`, including these `ValueError`s and the `FileNotFoundError` raised for a manifest-listed file that does not exist, fails that extension's load as a whole: everything it registered is rolled back and the Gateway logs the reason (see [Runtime](/docs/harness/extensions/runtime)). - -### Check the return value - -`registry.plugin()` returns `True` when the host accepted the contribution. The contract's default implementation returns `False`, which is what an older host without plugin support does. A plugin that is meaningless without its UI should fail loudly instead of loading half-installed: - -```python -if registry.plugin(contribution) is not True: - raise RuntimeError("this extension requires a host with full-stack plugin support") -``` - -## Enabling a plugin - -A plugin has **two** switches, and both must be on: - -- The `plugins:` record's `enabled` controls whether the Gateway imports the package at all. The extension manager's `enable` and `disable` commands flip it. -- `PluginContribution.enabled` controls the contribution. It defaults to `False`, and there is no runtime override store, so a plugin that never sets it stays disabled: `GET /api/plugins` reports `enabled: false`, the browser does not load its module, actions return `403 Plugin disabled by administrator.`, and its tools are left out of every agent. - -The convention is to read the second switch from the package's private config, so operators control it in `config.yaml`: - -```python -enabled=config.get("enabled", False) is True, -``` - -```yaml -plugins: - - name: notes - use: acme_notes:install - enabled: true # load the package - config: - enabled: true # turn the contribution on -``` - -Both are read only at startup, so a change to either needs a Gateway restart. - -## Settings - -`SettingsField` declares a non-secret deployment value that actions and tools receive, and that the browser can optionally see: - -| Field | Default | Meaning | -| --------------------- | -------- | ---------------------------------------------------------- | -| `key` | required | Matches `[a-z][a-z0-9_]{0,63}`. `enabled` is reserved | -| `title` | required | Display label | -| `kind` | required | `"boolean"`, `"integer"`, or `"string"` | -| `default` | required | The value. Must match `kind` and the bounds below | -| `description` | `""` | | -| `minimum` / `maximum` | `None` | Integer bounds | -| `max_length` | `256` | String bound, 1 to 4096 | - -The host adds the boolean `enabled` field itself. With it, a plugin can have at most 32 fields, which leaves 31 of your own. Values come only from the package: `default` is the value every action, tool, and browser receives. There is no settings API and no online editing, so derive defaults from `config` in `install()` when operators need to change them. - - - Settings are not a secret store. Never put credentials in a `SettingsField`. - Read secrets in `install()` from the environment or the private `config`, and - keep them in your own Python objects. - - -The browser sees only `enabled` plus the keys listed in the browser declaration's `public_fields` (`BrowserModule` and `BrowserAssets` both have it). Listing a key that is not a declared field fails validation. - -## Backend actions - -```python -BackendAction(name: str, handler: async (payload, context) -> Any) -``` - -The browser calls an action through the host, and the handler receives: - -- `payload`: a read-only mapping parsed from the JSON request body. The host guarantees only that it is a JSON **object** of at most 256 KiB. Validate every field yourself. -- `context`: an `ActionContext` with `principal`, the authenticated caller as an `ExtensionPrincipal` (`user_id`, `is_admin`, `is_internal`, `roles`), and `settings`, the read-only settings mapping. - -The return value must be JSON-serializable. The host maps outcomes to HTTP statuses and does not expose your exception text: - -| Outcome | Status | Body `detail` | -| ----------------------------------------- | ------ | ---------------------------------------------- | -| Handler returns | `200` | Your return value | -| Handler raises `ValueError` | `422` | `Invalid plugin action input.` | -| Handler raises anything else | `502` | `Plugin action failed.` (logged with the exception type) | -| Handler runs longer than 30 seconds | `504` | `Plugin action timed out.` | -| Body is not a JSON object | `422` | `Plugin action requires a JSON object.` | -| Body exceeds 256 KiB | `413` | `Plugin action input exceeds 256 KiB.` | -| Plugin disabled | `403` | `Plugin disabled by administrator.` | -| Unknown namespace or action | `404` | `Plugin is not installed.` / `Plugin action is not installed.` | -| Viewer header does not match the session | `409` | `Account changed; reload this plugin view.` | -| No authenticated principal | `401` | `Authentication required.` | - -A handler cannot choose its own status code, because even a raised `HTTPException` becomes `502`. Raise `ValueError` for bad input and return error details in the body when the browser needs them. A timeout cancels the handler, but it does not roll back external effects or work already running in a worker thread. - -**Authorization is yours.** The host authenticates the caller. It does not know which records belong to whom. Scope every read and write by `context.principal.user_id`, as the bookmarks example does with an owner predicate on every query. - -## Model tools - -```python -ModelTool(name, description, input_schema, handler, group="extensions") -``` - -- **Name.** The model sees a namespace-derived name: `ext___`, with the namespace sanitized to `[a-z0-9_]` and cut to 20 characters, the name cut to 25, and a 12-character hash. For example, `count_notes` in `acme.notes` becomes `ext_acme_notes_count_notes_d8aad91f72da`. The hash covers the full namespace and name, so truncation cannot merge two tools. If two plugin tools still ended up with the same name, the tool assembly raises instead of picking one. A collision with an ordinary tool keeps the ordinary tool and skips the plugin tool with a warning. -- **Schema.** `input_schema` must be a valid JSON Schema (draft 2020-12) with `"type": "object"`, fully inline: `$ref`, `$dynamicRef`, and `$recursiveRef` are rejected, so validation never resolves references or touches the network. The argument names `runtime` and `config` are reserved. -- **Limits.** The host validates each call's input against the schema and caps it at 256 KiB. The handler must return within 30 seconds, and its JSON-encoded result must fit in 64 KiB. -- **Context.** The handler receives a `ToolContext`: `settings`, `thread_id` (`None` outside a thread), and `principal`. For tools, the principal carries **only** `user_id`, so `is_admin` is always `False` and `roles` is empty. Do not make tool authorization depend on them. -- **Errors.** Any failure reaches the model as a generic tool error: `Invalid plugin tool input.`, `Plugin result exceeds 64 KiB.`, `Plugin disabled by administrator.`, or `Plugin tool unavailable or input rejected.`. The Gateway log records the plugin, tool, and exception type. -- **Assembly and policy.** Plugin tools join the ordinary tool assembly. An agent restricted by `tool_groups` receives a tool only when the list contains the tool's `group`, which is `extensions` by default. The host's later authorization and skill tool policies apply as for any other tool. - -Tool descriptions are prompt text. State what the tool returns, that results are data rather than instructions, and whether it is read-only. - -## Browser code - -A plugin ships its browser code in one of two transports. Both produce the same thing: one ES module whose default export follows the [browser API](#the-browser-api). `GET /api/plugins` labels each plugin's transport so the browser knows how to load it. - -| Transport | Declaration | Since | Use it for | -| ----------- | ---------------- | ----- | ------------------------------------------------------------------------------------- | -| `inline-v1` | `BrowserModule` | 0.2.2 | One self-contained file of at most 512 KiB: no relative imports, CSS files, images, or WASM | -| `assets-v1` | `BrowserAssets` | 0.2.3 | A directory of files listed in a manifest: split modules, stylesheets, images, fonts, WASM, source maps | - -In both, `module` is an identifier matching `[a-z][a-z0-9.-]{0,95}`, such as `bookmarks.v1`, never a URL, and it must equal the `module` in the default export. A plugin that uses `BrowserAssets` needs `deerflow-extension-api>=0.2.3`, and the host frontend and backend must both be at the matching version. An older frontend cannot load an `assets-v1` plugin, and the failure is limited to that plugin. - -### Inline modules - -```python -BrowserModule(module: str, code: str, public_fields: tuple[str, ...] = ()) -``` - -`code` is the full text of one self-contained ES module. It can have no relative imports and no assets resolved against `import.meta.url`, because the host fetches it with the session and imports it from a temporary Blob URL. Ship it in the wheel and read it in `install()`: - -```python -BrowserModule("notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8")) -``` - -Deployments with a Content Security Policy must allow `blob:` in `script-src`. - -### Static assets - -```python -BrowserAssets(module: str, root: str | Path, manifest: str = "ui_manifest.json", public_fields: tuple[str, ...] = ()) -``` - -`root` is a directory inside the installed package, usually `Path(__file__).parent`. The manifest, relative to `root`, lists every file the browser may load: - -```json filename="ui_manifest.json" -{ - "schema_version": 1, - "entry": "static/dist/index.mjs", - "files": [ - "static/dist/index.mjs", - "static/dist/chunks/page.mjs", - "static/dist/notes.css" - ] -} -``` - -The entry module can import its siblings with relative paths and locate resources with `new URL(..., import.meta.url)`: - -```js filename="static/dist/index.mjs" -import { mountNotes } from "./chunks/page.mjs"; - -export default { - apiVersion: 1, - module: "notes.v1", - surfaces: [ - { - id: "notes", - slot: "page", - title: "Notes", - navigation: { label: "My notes", labelZh: "我的笔记" }, - mount: mountNotes, - }, - ], -}; -``` - -```js filename="static/dist/chunks/page.mjs" -export function mountNotes(root, context) { - const style = document.createElement("link"); - style.rel = "stylesheet"; - style.crossOrigin = "use-credentials"; - style.href = new URL("../notes.css", import.meta.url).href; - const list = document.createElement("ul"); - root.append(style, list); - context - .callBackend("list", {}) - .then(({ notes }) => { - for (const note of notes) { - const item = document.createElement("li"); - item.textContent = note; - list.append(item); - } - }) - .catch(() => {}); - return { dispose: () => root.replaceChildren() }; -} -``` - -```python -frontend=BrowserAssets("notes.v1", Path(__file__).parent), -``` - -Include the manifest and every listed file in the wheel. A hatchling wheel that packages the Python package directory picks them up without extra configuration. Bundle third-party dependencies: bare npm imports are not resolved and there is no shared host React instance. Configure your bundler to emit relative URLs rather than absolute `/assets/...` paths. - -#### Manifest rules - -The registry reads, validates, and snapshots every listed file into memory during `install()`. Any violation fails the extension's load: - -| Rule | Error message | -| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | -| The manifest is a JSON object with exactly `schema_version` (the integer `1`), `entry`, and `files` | `Unsupported browser asset manifest; expected schema_version 1` | -| No duplicate keys | `Duplicate browser manifest key` | -| `files` lists 1 to 256 unique, valid paths | `Browser manifest must list unique asset paths` | -| `entry` is one of `files` and ends in `.js` or `.mjs` | `Browser manifest entry must be a listed JavaScript module` | -| Every file has a supported type (below) | `Unsupported browser asset file type` | -| `root` is not a symlink, and no component of any listed path is a symlink | `Browser asset root must not be a symlink`, `Browser assets must not contain symlinks` | -| Every listed file is a regular file inside `root` | `Browser asset must be a regular file inside its package` (a missing file raises `FileNotFoundError`) | -| The manifest is at most 64 KiB, each file at most 4 MiB, and all files together at most 16 MiB | `Browser asset size limit exceeded` | - -A valid path is at most 512 characters of `/`-separated segments, where each segment uses ASCII letters, digits, `_`, `-`, and `.` and starts with a letter, digit, `_`, or `-`. This rules out dotfiles, `.` and `..` segments, empty segments, backslashes, percent encoding, and query strings. - -Supported types, served with the MIME type in parentheses: `.js` and `.mjs` (`text/javascript`), `.css`, `.json` and `.map` (`application/json`), `.wasm`, `.png`, `.jpg`/`.jpeg`, `.gif`, `.webp`, `.svg`, `.ico`, `.woff`, `.woff2`, `.ttf`, and `.otf`. HTML and server-side files are never served. - -#### Revisions and caching - -The host computes a SHA-256 **revision** over the manifest and the content hash of every file. Every URL contains it: `/api/plugins/{namespace}/assets/{revision}/{path}`. Changing any file changes the revision of all of them. Requests are answered from the startup snapshot, never from the filesystem, so editing the installed files has no effect until a restart. - -Asset responses carry `Cache-Control: private, max-age=31536000, immutable` and `Vary: Cookie, Authorization`. An unknown path or revision returns `404` with `private, no-store`. The host keeps no historical snapshots, and there is no public CDN contract. After an upgrade, a page still holding the old revision gets `404`s for anything it has not cached and must be reloaded. Conversely, a browser can keep using code it already cached after a plugin is removed, so removal is not instant cache revocation. - -#### How the browser loads assets - -The host inserts a native module script with `crossorigin="use-credentials"` pointing at the entry URL, then reads the exports from the document's module map. Relative static and dynamic imports therefore resolve inside the same revision and carry the session cookie. URLs honor `NEXT_PUBLIC_BACKEND_BASE_URL`, including path prefixes. - -- **Timeout.** The host stops waiting after 30 seconds and marks that plugin unavailable. This is a deadline for waiting, not cancellation: the module can still finish evaluating later. Keep top-level code free of side effects and start UI work in `mount`. -- **Module state is shared.** A module instance is shared by the whole document. Keep per-viewer data in per-mount state and clear it in `dispose()`. Never cache principals or private results at module level across account changes. -- **Other resources are your job.** Set `crossOrigin = "use-credentials"` on stylesheets and images you create, as `page.mjs` does, and use `fetch(url, { credentials: "include" })` for JSON, WASM, or binary data. Font and background URLs inside CSS do not reliably carry cross-origin cookies. For those, prefer a same-origin deployment, or fetch with credentials and build a `FontFace` or Blob URL that you release on dispose. -- **CSP and CORS.** The backend origin must be allowed in the relevant `script-src`, `style-src`, `img-src`, `font-src`, and `connect-src` directives. Split-origin deployments need exact-origin credentialed CORS and working session cookies. - -### The browser API - -The module's default export must match the host's browser API v1: - -```js -export default { - apiVersion: 1, // required - module: "notes.v1", // required, equal to the declaration's module - icon: "file-text", // optional - surfaces: [/* page surfaces, at most 16 */], - conversationActions(t, locale) {/* returns one action group */}, -}; -``` - -The host rejects the module, and shows "Page module unavailable" on its card, if `apiVersion` or `module` does not match or any surface or navigation entry is malformed. - -### Page surfaces - -| Field | Rule | -| ------------ | --------------------------------------------------------------------- | -| `id` | `[a-z][a-z0-9-]{0,63}`, unique within the module | -| `slot` | `"page"` (the only slot today) | -| `title` | Non-empty string | -| `navigation` | Optional `{ label, labelZh?, icon? }` adds a sidebar entry. Labels are at most 120 characters | -| `mount` | `(root, context) => { dispose }`, called synchronously | - -The host mounts the page into a Shadow DOM root and passes a context: - -| Context member | Meaning | -| ------------------------------ | ------------------------------------------------------------------------------- | -| `namespace`, `locale` | The plugin namespace and the UI locale, such as `en-US` or `zh-CN` | -| `settings` | `enabled` plus `public_fields` | -| `signal` | Aborted on unmount or account change. Pass it to listeners and fetches | -| `callBackend(action, payload)` | POSTs to one of this plugin's declared actions and resolves to the JSON response. It rejects on non-2xx responses and on actions not declared by the plugin | -| `openConversation(threadId)` | Optional. Navigates to a conversation after resolving it through the authenticated API, and rejects without navigating if it is missing or inaccessible | - -`mount` must return an object with a synchronous `dispose()`. The host calls it on unmount after aborting `signal`, and fences late `callBackend` results. - -### Conversation actions - -`conversationActions(t, locale)` must be synchronous and return `{ label, icon, actions }`. Each action has `id`, `label`, `icon`, a synchronous `available(settings)` that returns a boolean, and `execute(context, services)`: - -- `context.thread` is the conversation, and `context.messages` holds its messages when the host already has them. -- `services.callBackend` works as above. `services.latestVisibleAnswer(context)` returns the last visible assistant message as `{ id, text }` or `null`. `services.conversationText(context)` returns the visible transcript. Both reuse the host's export sanitizer, so hidden messages, tool output, and reasoning are excluded. `services.showMessage(text)` shows a toast. - -Each plugin's actions are evaluated inside their own error boundary. A malformed or throwing group is omitted, logged to the browser console, and leaves other plugins' actions and the conversation page intact. - -### Icons - -The host maps icon names to a fixed set: `bell`, `bookmark`, `download`, `file-json`, and `file-text`. Any other name renders as a generic puzzle icon. - -## HTTP endpoints - -The host serves plugins through four authenticated Gateway routes, behind the normal session and CSRF policies. Each returns `401` without an authenticated principal: - -| Route | Purpose | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `GET /api/plugins` | Every loaded plugin: `namespace`, `title`, `description`, `viewer_id`, `module`, `entry`, `transport` (`inline-v1`, `assets-v1`, or `null`), public `settings`, and `backend_actions` names | -| `GET /api/plugins/modules/{module}/{sha256}.mjs` | An inline module, addressed by its content hash. `text/javascript`, `nosniff`, `private, no-store` | -| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | One manifest-listed file. Its declared MIME type, `nosniff`, `Content-Security-Policy: sandbox`, and the immutable private caching described in [Revisions and caching](#revisions-and-caching) | -| `POST /api/plugins/{namespace}/actions/{name}` | Invokes one backend action (see [Backend actions](#backend-actions)) | - -A stale inline hash or asset revision returns `404` and requires a page reload. Both download routes serve a plugin's code even while the plugin is disabled. - -The browser sends `X-Deerflow-Plugin-Viewer` with the `viewer_id` it discovered, so an action from a view opened under a different account is rejected with `409`. - -## Security model - - - Browser modules and Python handlers are trusted, operator-installed code. - Browser code runs **in the main page** with the signed-in user's same-origin - capabilities. Shadow DOM scopes CSS, not privileges. Python handlers run in the - Gateway process with Gateway privileges. - - -What the host does guarantee: - -- It never loads code from a browser-supplied path, import string, or remote URL. It serves only the installed inline module, verified by content hash, or files listed in the manifest, read from the startup snapshot. Directory listings and unlisted files are never served. -- Asset responses carry `Content-Security-Policy: sandbox`, so opening an asset directly, such as an SVG with a script inside, cannot run scripts with the Gateway's origin. This restricts assets opened as documents. It does not sandbox the plugin JavaScript the host loads into the page. -- Every endpoint requires an authenticated principal, and the principal comes from the session rather than the request body. -- `callBackend` is bound to the plugin's own namespace and its declared action names. -- Only `enabled` and explicitly public fields reach the browser. - -Any authenticated user can download every listed asset, including source maps, even while the plugin is disabled. Never list secrets or private sources in the manifest. - -What remains the plugin's job: authorizing access to its own data by `principal.user_id`, validating every payload, escaping user and model text when rendering it (`textContent`, never `innerHTML`), and treating tool results as untrusted data. - -## Walkthrough: the bookmarks example - -[`examples/deerflow-extension-bookmarks`](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks) (version 0.2.0, which requires `deerflow-extension-api>=0.2.3`) is a complete plugin: users save the last visible answer from a conversation menu, then search, rename, open, or delete their bookmarks on a **My bookmarks** page. The Agent can search them too. It exercises every piece of this chapter: - -1. **Config and state.** `install()` requires `config.enabled` (a boolean) and an absolute `config.storage_path`, and opens a SQLite store there. Mount a persistent directory for it in containers. -2. **The contribution.** Namespace `community.bookmarks`, `enabled=config["enabled"]`, `BrowserAssets("bookmarks.v1", Path(__file__).parent)`, five `BackendAction`s (`save`, `search`, `get`, `rename`, `delete`), and one read-only `ModelTool`, `search_bookmarks`, which the model sees as `ext_community_bookmarks_search_bookmarks_`. -3. **The return check.** It raises `RuntimeError` if `registry.plugin(...)` is not `True`. -4. **Ownership.** Every SQL statement carries `owner = context.principal.user_id`, and each action accepts an exact set of payload keys. The model tool never accepts a user ID, so a user can only ever search their own bookmarks. -5. **The resources.** `ui_manifest.json` lists four files under `static/dist/`: the entry `index.mjs`, a page module `chunks/bookmarks.mjs`, `styles.css`, and `bookmark.svg`. They ship inside the Python package and need no JavaScript build step. -6. **The entry module.** `index.mjs` imports `mountBookmarks` from `./chunks/bookmarks.mjs` and exports one `page` surface, `library`, with `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`, served at `/workspace/extensions/community.bookmarks/library`. It also exports a "Bookmarks → Save last answer" conversation action that calls `services.latestVisibleAnswer` and then `callBackend("save", ...)`. -7. **The page module.** `chunks/bookmarks.mjs` resolves `../styles.css` and `../bookmark.svg` against `import.meta.url` and loads them with `crossorigin="use-credentials"`, so they work on split-origin deployments. It observes `context.signal`, renders text with `textContent`, and clears its DOM in `dispose()`. - -The deployment record looks like this: - -```yaml -plugins: - - name: bookmarks - use: deerflow_extension_bookmarks:install - enabled: true - config: - enabled: true - storage_path: /var/lib/deerflow/bookmarks.sqlite -``` - -After a restart and a browser refresh, the card appears in the Extensions tab, the sidebar gains **My bookmarks**, and the conversation menus gain **Bookmarks**. The example uses single-host SQLite storage. It is not a multi-node storage pattern. - -## A minimal plugin - -The smallest useful shape is one page, two actions, and one read-only tool. This one uses the inline transport, so it is a single `client.mjs`. To split it into files, switch to [static assets](#static-assets), as `acme.notes` does in that section. Every part below has been exercised against the host's registry and routes: - -```python -"""A per-user scratchpad: a page, one backend action, one read-only model tool.""" - -from __future__ import annotations - -from collections.abc import Mapping -from pathlib import Path -from typing import Any - -from deerflow_extension_api import ( - ActionContext, - BackendAction, - BrowserModule, - ModelTool, - PluginContribution, - SettingsField, - ToolContext, - extension, -) - -NOTES: dict[str, list[str]] = {} # demo only: in-memory, single process - - -async def add_note(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: - text = payload.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("text is required") # becomes HTTP 422 - notes = NOTES.setdefault(context.principal.user_id, []) - if len(notes) >= context.settings["max_notes"]: - raise ValueError("note limit reached") - notes.append(text.strip()) - return {"count": len(notes)} - - -async def list_notes(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: - return {"notes": NOTES.get(context.principal.user_id, [])} - - -async def count_notes(payload: Mapping[str, Any], context: ToolContext) -> dict[str, Any]: - return {"count": len(NOTES.get(context.principal.user_id, []))} - - -@extension(api="0.2.2", name="notes") -def install(registry, config: Mapping[str, Any]) -> None: - accepted = registry.plugin( - PluginContribution( - namespace="acme.notes", - title="Notes", - description="A private scratchpad for each user.", - enabled=config.get("enabled", False) is True, - fields=(SettingsField("max_notes", "Maximum notes", "integer", 50, minimum=1, maximum=500),), - frontend=BrowserModule( - "notes.v1", - Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), - public_fields=("max_notes",), - ), - backend=(BackendAction("add", add_note), BackendAction("list", list_notes)), - tools=( - ModelTool( - "count_notes", - "Count the current user's saved notes. Read-only.", - {"type": "object", "properties": {}, "additionalProperties": False}, - count_notes, - ), - ), - ) - ) - if accepted is not True: - raise RuntimeError("acme-notes requires a host with full-stack plugin support") -``` - -And its `client.mjs`, placed next to `__init__.py` and included in the wheel: - -```js -function mountNotes(root, context) { - const list = document.createElement("ul"); - const input = document.createElement("input"); - const add = document.createElement("button"); - add.textContent = context.locale.startsWith("zh") ? "添加" : "Add"; - root.append(input, add, list); - - const render = async () => { - const { notes } = await context.callBackend("list", {}); - list.replaceChildren( - ...notes.map((note) => { - const item = document.createElement("li"); - item.textContent = note; // textContent, never innerHTML - return item; - }), - ); - }; - add.addEventListener( - "click", - async () => { - await context.callBackend("add", { text: input.value }); - input.value = ""; - await render(); - }, - { signal: context.signal }, - ); - render().catch(() => {}); - return { dispose: () => root.replaceChildren() }; -} - -export default { - apiVersion: 1, - module: "notes.v1", - surfaces: [ - { - id: "notes", - slot: "page", - title: "Notes", - navigation: { label: "My notes", labelZh: "我的笔记" }, - mount: mountNotes, - }, - ], - conversationActions(_t, locale = "en") { - const zh = locale.startsWith("zh"); - return { - label: zh ? "笔记" : "Notes", - icon: "file-text", - actions: [ - { - id: "save-answer", - label: zh ? "把最后一条回答存为笔记" : "Save last answer as a note", - icon: "file-text", - available: (settings) => settings.enabled === true, - async execute(context, services) { - const answer = await services.latestVisibleAnswer?.(context); - if (!answer) return services.showMessage(zh ? "没有可保存的回答" : "No answer to save"); - await services.callBackend("add", { text: answer.text.slice(0, 2000) }); - services.showMessage(zh ? "已保存" : "Saved"); - }, - }, - ], - }; - }, -}; -``` - -The in-memory dict is only for the example. Real plugins need storage that survives restarts and is shared across Gateway workers. - -## Lifecycle - -- Install, upgrade, enable, disable, and every `config` change need a Gateway restart, followed by a browser reload. There is no hot reload or hot unload. -- After a restart, an open page may point at a module hash, asset revision, or action that no longer exists. Those requests fail explicitly (`404`) until the user reloads. -- A disabled or removed plugin has no sidebar entry after a reload. Visiting its page URL directly shows "Extension page unavailable" and mounts nothing. - -Operator commands are covered in [Operations](/docs/harness/extensions/operations). diff --git a/frontend/src/content/en/harness/extensions/reference.mdx b/frontend/src/content/en/harness/extensions/reference.mdx index a989120c3e0..0c113a45f4b 100644 --- a/frontend/src/content/en/harness/extensions/reference.mdx +++ b/frontend/src/content/en/harness/extensions/reference.mdx @@ -1,13 +1,11 @@ --- title: Reference -description: Every public name in deerflow-extension-api 0.2.3, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history. +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. --- -import { Callout } from "nextra/components"; - # Reference -This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.3**. Import them from the package root: +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 @@ -36,7 +34,7 @@ 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. `plugin()` is the one method that reports this, by returning `False`; the version marker and package metadata are how you prevent it for the others. +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 | | ---------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- | @@ -47,7 +45,6 @@ A `runtime_checkable` Protocol: the write-only surface passed to `install()`. Ev | `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()` | -| `plugin(contribution: PluginContribution)` | `bool` | A full-stack plugin. `True` when accepted, `False` on a host without plugin support. Experimental | ## Middleware @@ -312,18 +309,6 @@ Frozen dataclass: `thread_id: str = ""`, `run_id: str = ""`, `seq: int = 0` (mon 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`. -### `resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None` - -Return a reader bound to the authenticated caller of a contributed route. Use it in user-facing routes instead of the global `ExtensionRuntimeDeps.run_evidence_reader`, which sees every user's runs. `request` is duck-typed. Returns `None` on a host without request-scoped evidence. The Gateway raises `PermissionError("run evidence requires an authenticated user with runs:read")` when the caller is unauthenticated or lacks the `runs:read` permission; it never widens an admin or internal caller to global visibility. Any other resolver error propagates. - -### `require_run_evidence_reader(request: object) -> RunEvidenceReader` - -Like `resolve_run_evidence_reader()`, but raises `NotImplementedError("request-scoped run evidence is unavailable")` instead of returning `None`. Map `NotImplementedError` to HTTP 503 and `PermissionError` to 403. It never falls back to the global reader. - -### `RUN_EVIDENCE_READER_RESOLVER_KEY` - -`"deerflow_extension_run_evidence_reader_resolver"`. The `app.state` attribute the host installs its request-scoped resolver under. Host-owned. - ### `InvalidRunEvidenceCursor` Subclass of `ValueError`, raised for a malformed or unsupported cursor, or a cursor from another scope. @@ -397,99 +382,18 @@ Deterministic JSON: sorted keys, `(",", ":")` separators, `ensure_ascii=False`. SHA-256 hex digest of `canonical_json(value)` encoded as UTF-8. -## Full-stack plugins (experimental) - - - The plugin contract is experimental in 0.2.3. Always check the return value of - `registry.plugin(...)`. See [Plugins](/docs/harness/extensions/plugins). - - -### `PluginContribution` - -Frozen dataclass. - -| Field | Type | Default | -| ------------- | ------------------------------ | ------- | -| `namespace` | `str` | required | -| `title` | `str` | required | -| `description` | `str` | `""` | -| `enabled` | `bool` | `False` | -| `fields` | `tuple[SettingsField, ...]` | `()` | -| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | -| `backend` | `tuple[BackendAction, ...]` | `()` | -| `api_version` | `int` | `1` | -| `tools` | `tuple[ModelTool, ...]` | `()` | - -The host accepts only `api_version == 1` and requires at least one of `frontend`, `backend`, or `tools`. Namespaces must be unique. - -### `BrowserModule` - -Frozen dataclass: `module: str`, `code: str` (a self-contained ES module, non-empty and at most 512 KiB), `public_fields: tuple[str, ...] = ()`. Discovery labels this transport `inline-v1`. Use `BrowserAssets` when the module needs relative imports, stylesheets, or images. - -### `BrowserAssets` - -Frozen dataclass declaring a manifest and static files inside the installed package. Discovery labels this transport `assets-v1`. - -| Field | Type | Default | -| --------------- | ----------------- | -------------------- | -| `module` | `str` | required | -| `root` | `str \| Path` | required | -| `manifest` | `str` | `"ui_manifest.json"` | -| `public_fields` | `tuple[str, ...]` | `()` | - -`root` is usually `Path(__file__).parent`. The manifest, relative to `root`, must be a JSON object with exactly the keys `schema_version` (the integer `1`), `entry`, and `files`: - -```json -{ - "schema_version": 1, - "entry": "static/dist/index.mjs", - "files": ["static/dist/index.mjs", "static/dist/styles.css"] -} -``` - -`files` lists 1 to 256 unique paths and `entry` must be one of them, ending in `.js` or `.mjs`. Paths use ASCII letters, digits, `_`, `-`, and `.`, separated by `/`, and each segment must start with a letter, digit, `_`, or `-`. The root must not be a symlink, and no listed path may pass through one. Limits: 64 KiB for the manifest, 4 MiB per file, 16 MiB in total. Accepted file types: `.js`, `.mjs`, `.css`, `.json`, `.map`, `.wasm`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.ico`, `.woff`, `.woff2`, `.ttf`, `.otf`. - -`registry.plugin()` validates the manifest and reads every listed file into an in-memory snapshot. A validation error raises from `registry.plugin()`, so `install()` fails. The snapshot's revision is a SHA-256 over the manifest, the paths, and the file contents, and the Gateway serves the files from that snapshot at `GET /api/plugins/{namespace}/assets/{revision}/{path}`. Any authenticated user can download listed files, including source maps, even while the plugin is disabled, so never list secrets. - -### `BackendAction` - -Frozen dataclass: `name: str`, `handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]`. Names must be unique and handlers async. - -### `ModelTool` - -Frozen dataclass: `name: str`, `description: str`, `input_schema: Mapping[str, Any]` (an inline object schema), `handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]`, `group: str = "extensions"`. - -### `ActionContext` / `ToolContext` - -Frozen dataclasses. `ActionContext`: `principal: ExtensionPrincipal`, `settings: Mapping[str, bool | int | str]`. `ToolContext` extends it with `thread_id: str | None`. - -### `SettingsField` - -Frozen dataclass describing one non-secret deployment setting. - -| Field | Type | Default | -| ------------- | -------------------------------------------- | ------- | -| `key` | `str` | required | -| `title` | `str` | required | -| `kind` | `Literal["boolean", "integer", "string"]` | required | -| `default` | `bool \| int \| str` | required | -| `description` | `str` | `""` | -| `minimum` | `int \| None` | `None` | -| `maximum` | `int \| None` | `None` | -| `max_length` | `int` | `256` | - ## Version constant ### `API_VERSION` -`"0.2.3"`. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`. +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.3` host accepts `0.2.0` through `0.2.3`, and rejects `0.2.4`, `0.1.x`, and `0.3.x`); + - 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. @@ -505,7 +409,5 @@ Frozen dataclass describing one non-secret deployment setting. | 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` | -| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | Experimental `registry.plugin()` with `PluginContribution`, `BrowserModule`, `BackendAction`, `ModelTool`, `ActionContext`, `ToolContext`, `SettingsField` | -| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727), [#5685](https://github.com/bytedance/deer-flow/pull/5685) | Request-scoped run evidence: `resolve_run_evidence_reader`, `require_run_evidence_reader`, `RUN_EVIDENCE_READER_RESOLVER_KEY` (#5727). Packaged browser resources: `BrowserAssets`, and `PluginContribution.frontend` also accepts it (#5685) | 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 index 2cc67155704..4c2afa39f53 100644 --- a/frontend/src/content/en/harness/extensions/run-evidence.mdx +++ b/frontend/src/content/en/harness/extensions/run-evidence.mdx @@ -1,6 +1,6 @@ --- title: Run Evidence -description: The read-only readers that let a service or a route find runs that changed and read their persisted events. Covers the global service reader and the caller-scoped request reader, the three reader methods and their return types, cursor semantics, deletions, redaction, backend differences, a polling service, and a per-user route. +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"; @@ -11,20 +11,9 @@ Middleware and lifecycle hooks see a run while it happens. A **run evidence read The reader is read-only. No method writes to the host. -## Getting a reader +## Getting the reader -There are two readers, and they differ only in what they can see: - -| Reader | How you get it | Sees | Use it in | -| ---------------- | ------------------------------------------------------------------------- | ---------------------------------------------- | --------------------------- | -| Service reader | `ExtensionRuntimeDeps.run_evidence_reader`, passed to `start()` | Every user's runs and events | Services: exporters, indexes | -| Request reader | `resolve_run_evidence_reader(request)` or `require_run_evidence_reader(request)` | Only the authenticated caller's own runs and events | Contributed routes | - -Both implement the same `RunEvidenceReader` interface described below. - -### The service reader - -The Gateway hands one reader to every service: +The Gateway hands one reader to every service, as `ExtensionRuntimeDeps.run_evidence_reader`: ```python class MyService: @@ -37,31 +26,12 @@ class MyService: 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 service 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 a caller; route handlers use the request reader instead. + 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 request reader - -A route handler asks the host for a reader bound to the current request: - -| Helper | Host supports it | Caller lacks access | Host does not support it | -| --------------------------------------- | ---------------- | --------------------- | ------------------------ | -| `resolve_run_evidence_reader(request)` | Returns a reader | Raises `PermissionError` | Returns `None` | -| `require_run_evidence_reader(request)` | Returns a reader | Raises `PermissionError` | Raises `NotImplementedError` | - -Map them at your HTTP boundary: `PermissionError` to `403`, and `None` or `NotImplementedError` to `503`. Any other exception from the host's resolver propagates unchanged. Neither helper ever falls back to the service reader. - -The Gateway binds the reader from the request's authentication, never from anything the caller sends: - -- The caller must be authenticated and hold the **`runs:read`** permission. By default every signed-in user has it. When `authorization.enabled` is set in `config.yaml`, the configured provider decides. A personal access token must also carry the `runs:read` scope. -- The reader is scoped to the caller's own user ID: it sees the runs recorded with that user as owner, and nothing else. -- Admins and internal callers are **not** widened: an admin's request reader sees the admin's own runs, not everyone's. - -A cursor from one reader is rejected by a reader with a different scope, so a cursor issued to one user cannot be replayed by another. See [Cursor rules](#cursor-rules). - ## The reader interface `RunEvidenceReader` has three async methods, all keyword-only: @@ -261,78 +231,10 @@ Points worth copying: - `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. -## Example: the caller's own runs - -This route returns the signed-in user's recently changed runs, one page at a time. It uses the request reader, so the host guarantees each caller sees only their own runs. It declares `api="0.2.3"` because the request-reader helpers first shipped in that contract version. - -```python filename="deerflow_extension_myruns/__init__.py" -"""List the caller's own recently changed runs.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from deerflow_extension_api import ( - ExtensionRegistry, - InvalidRunEvidenceCursor, - RunEvidenceReader, - extension, - require_run_evidence_reader, -) -from fastapi import APIRouter, Depends, HTTPException, Request - - -def caller_reader(request: Request) -> RunEvidenceReader: - try: - return require_run_evidence_reader(request) - except NotImplementedError as exc: - raise HTTPException(status_code=503, detail="run evidence is not available on this host") from exc - except PermissionError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - - -def build_router() -> APIRouter: - router = APIRouter(prefix="/api/ext-myruns", tags=["ext-myruns"]) - - @router.get("/runs") - async def my_runs( - cursor: str | None = None, - limit: int = 50, - reader: RunEvidenceReader = Depends(caller_reader), - ) -> dict[str, Any]: - try: - page = await reader.list_changed_runs(cursor=cursor, limit=min(max(limit, 1), 200)) - except InvalidRunEvidenceCursor as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return { - "runs": [{"thread_id": run.thread_id, "run_id": run.run_id, "status": run.status} for run in page.items], - "next_cursor": page.next_cursor, - "has_more": page.has_more, - } - - return router - - -@extension(api="0.2.3", name="myruns") -def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: - registry.routers((build_router(),)) -``` - -| Request | Response | -| ---------------------------------------------------------- | ------------------------------------------------------------------------ | -| No session | `401` from the host, before the handler runs | -| Signed in, without `runs:read` | `403 {"detail": "run evidence requires an authenticated user with runs:read"}` | -| Signed in | `200` with only the caller's runs, plus `next_cursor` and `has_more` | -| `?cursor=` not issued to this caller | `400 {"detail": "invalid run evidence cursor"}`, or `run evidence cursor scope does not match this reader` for another user's cursor | -| On a host without request-scoped evidence | `503 {"detail": "run evidence is not available on this host"}` | - -The client stores `next_cursor` and passes it back as `?cursor=` to continue. The cursor is bound to the caller, so leaking it to another user gains them nothing. - ## 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 the service reader to users.** It sees every user. Route handlers use `require_run_evidence_reader(request)`, which the host scopes to the caller. +- **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 index eaa42481b7a..ca8f7fe34ec 100644 --- a/frontend/src/content/en/harness/extensions/runtime.mdx +++ b/frontend/src/content/en/harness/extensions/runtime.mdx @@ -49,20 +49,20 @@ Reserve `required: true` for extensions whose absence makes the deployment wrong ### 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.3`: +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.3"` | Loaded | -| `"0.2.4"` | Refused: the extension may use additions this host lacks | +| `"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.4, host provides 0.2.3. Install a matching version: pip install 'deerflow-extension-api>=0.2.4,<0.3' +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. diff --git a/frontend/src/content/en/harness/extensions/services-and-routes.mdx b/frontend/src/content/en/harness/extensions/services-and-routes.mdx index 43a92a89402..34a2d0aa33e 100644 --- a/frontend/src/content/en/harness/extensions/services-and-routes.mdx +++ b/frontend/src/content/en/harness/extensions/services-and-routes.mdx @@ -40,7 +40,7 @@ Every service receives the same `ExtensionRuntimeDeps` snapshot: | `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. Route handlers use a caller-scoped reader instead. See [Run Evidence](/docs/harness/extensions/run-evidence) | +| `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. @@ -144,10 +144,6 @@ class ExtensionPrincipal: 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`. -### Reading run evidence in a route - -Do not serve data from the service's `run_evidence_reader` in a route: it sees every user. Call `require_run_evidence_reader(request)` instead. The host returns a reader scoped to the authenticated caller, provided they hold the `runs:read` permission. Map `PermissionError` to `403` and `NotImplementedError` to `503`. [Run Evidence](/docs/harness/extensions/run-evidence) covers the rules and a complete route. - ## 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. @@ -254,4 +250,4 @@ Against a Gateway with the memory database backend, the routes answer: - **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 service-reader data from a route.** The service reader is global. Routes use the caller-scoped request reader. +- **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 index caebf3acc28..b862439db01 100644 --- a/frontend/src/content/en/harness/extensions/troubleshooting.mdx +++ b/frontend/src/content/en/harness/extensions/troubleshooting.mdx @@ -45,9 +45,9 @@ The module imports, but the function after the colon does not exist. Correct `us 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.3` 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.4` on a `0.2.3` host): upgrade DeerFlow, or install the extension release that declares `0.2.3` or lower. -- Declared version on an **older** minor (for example `0.1.0` on a `0.2.3` host): upgrade the extension to a release written against the host's minor. +- 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. @@ -163,30 +163,6 @@ A system-model or compaction observation arrived while no Gateway notification l Your assembly observer raised. It runs synchronously during agent construction; keep it cheap and non-raising. -## Plugins and browser assets - -A validation error inside `registry.plugin(...)` raises, so it surfaces as `install() failed: ` for that extension, which then loads without any of its contributions. - -| Message after `install() failed:` | Cause and fix | -| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `[Errno 2] No such file or directory: ''` | The manifest, or a file it lists, is not in the installed package. Check that the wheel includes it; for an index or Git install, look inside the built wheel, not your source tree | -| `Unsupported browser asset manifest; expected schema_version 1` | The manifest must be an object with exactly `schema_version: 1`, `entry`, and `files` | -| `Duplicate browser manifest key` | A key appears twice in `ui_manifest.json` | -| `Browser manifest must list unique asset paths` | `files` is empty, has more than 256 entries, repeats a path, or contains an invalid path | -| `Browser manifest entry must be a listed JavaScript module` | `entry` is missing from `files` or does not end in `.js` or `.mjs` | -| `Invalid browser asset path` | A path contains `..`, a dot segment or dotfile, an empty segment, or a character outside `A-Z a-z 0-9 _ - .` | -| `Unsupported browser asset file type` | A listed file's extension is not an accepted type. HTML is never served | -| `Browser assets must not contain symlinks` / `Browser asset root must not be a symlink` | Replace links with real files | -| `Browser asset size limit exceeded` | Over 64 KiB for the manifest, 4 MiB for one file, or 16 MiB in total | -| `Browser code must be nonempty and at most 512 KiB` | An inline `BrowserModule` is empty or too large; switch to `BrowserAssets` | -| `Unsupported browser transport` | `frontend` is neither a `BrowserModule` nor a `BrowserAssets` | - -The plugin's files are snapshotted when the Gateway starts. After changing them, upgrade the extension and restart the Gateway. Browsers may keep already-cached code from the old revision until the page is reloaded. - -### A user-facing route cannot read run evidence - -`resolve_run_evidence_reader(request)` raises `PermissionError("run evidence requires an authenticated user with runs:read")` when the caller is not authenticated or lacks `runs:read`; return 403. `require_run_evidence_reader(request)` raises `NotImplementedError("request-scoped run evidence is unavailable")` on a host without request-scoped evidence; return 503. See [Run Evidence](/docs/harness/extensions/run-evidence). - ## Services and routes | Log line after `Extension :` | Cause and fix | diff --git a/frontend/src/content/zh/harness/extensions/_meta.ts b/frontend/src/content/zh/harness/extensions/_meta.ts index d889e09121e..b9f2ba678cb 100644 --- a/frontend/src/content/zh/harness/extensions/_meta.ts +++ b/frontend/src/content/zh/harness/extensions/_meta.ts @@ -19,9 +19,6 @@ const meta: MetaRecord = { "run-evidence": { title: "运行证据", }, - plugins: { - title: "全栈插件", - }, operations: { title: "运维扩展", }, diff --git a/frontend/src/content/zh/harness/extensions/index.mdx b/frontend/src/content/zh/harness/extensions/index.mdx index a9cae59ac6c..ca81057a0ec 100644 --- a/frontend/src/content/zh/harness/extensions/index.mdx +++ b/frontend/src/content/zh/harness/extensions/index.mdx @@ -50,7 +50,6 @@ import { Callout } from "nextra/components"; | `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) | -| `registry.plugin(contribution)` | 实验性。浏览器模块、带认证的后端动作和模型工具。宿主不支持插件时返回 `False`。见[全栈插件](/docs/harness/extensions/plugins) | 一个扩展可以注册任意组合。[内置示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example)注册了一个中间件、一个任务生命周期贡献者、一个系统模型观察者、一个服务和一个路由。 @@ -100,7 +99,7 @@ plugins: ## 版本 -契约包独立于 DeerFlow 进行版本管理。当前版本是 `deerflow-extension-api` **0.2.3**,可通过 `deerflow_extension_api.API_VERSION` 读取。 +契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.1**。 - 1.0 之前,minor 版本可以有不兼容变更,patch 版本只做增量。1.0 之后,不兼容变更会升 major。 - 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值,所以增量发布不会破坏已经发布的扩展。 @@ -111,7 +110,7 @@ plugins: ## 术语 - **宿主**:加载扩展的 DeerFlow Gateway 进程。 -- **贡献**:通过注册表注册的一个对象:贡献者、观察者、服务、路由或插件。 +- **贡献**:通过注册表注册的一个对象:贡献者、观察者、服务或路由。 - **贡献者**:宿主回调以获取贡献的对象,例如 `MiddlewareContributor`,每构建一个 Agent 就返回一次中间件。 - **作用域**:一份状态所属的生命周期。**应用作用域**与 Gateway 同寿;**任务作用域**只存活一次 Lead 运行或一次子 Agent 执行。 - **`ExtensionData`**:挂在某个作用域上的类型化存储,以 Python 类型为键,两个扩展不会冲突。 diff --git a/frontend/src/content/zh/harness/extensions/operations.mdx b/frontend/src/content/zh/harness/extensions/operations.mdx index af69494637b..8fbcad5b9ac 100644 --- a/frontend/src/content/zh/harness/extensions/operations.mdx +++ b/frontend/src/content/zh/harness/extensions/operations.mdx @@ -156,18 +156,6 @@ extension table_prefix 'runs' would hide host-owned table(s) ['runs'] from alemb 因为快照是一份拷贝,对工作目录的修改只能通过 `make extension-upgrade SOURCE=<同一路径>` 再重启才能到达 DeerFlow。 -### 带浏览器资源的插件 - -声明了 `BrowserAssets` 的插件,会在其 Python 包内附带一个 `ui_manifest.json` 以及它列出的静态文件。[书签示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks)把它们放在 `deerflow_extension_bookmarks/static/dist/` 下。对运维人员来说,这意味着: - -- **文件必须包含在安装内容里。** 本地目录按原样做快照,所以请在 `install` 或 `upgrade` 之前构建好 JavaScript 产物:管理器运行的是 Python 构建后端,不会执行 JavaScript 构建。对于从索引或 Git 安装的来源,这些文件必须在构建出的 wheel 里。 -- **快照规则同样适用。** 包含符号链接或疑似机密文件的本地目录会先被管理器拒绝,Gateway 根本读不到清单。 -- **Gateway 只读取一次这些文件**:在启动时 `install()` 注册插件的时候读取,之后从内存中以基于内容的 revision 提供。修改资源和修改代码一样,需要 `upgrade` 并重启。浏览器可能会保留旧 revision 中已缓存的代码,直到页面重新加载。 -- **列出的文件任何已登录用户都能读取**,包括 source map,即使插件处于禁用状态。不要在清单中发布私有源码或机密。 -- Gateway 和前端请从同一个版本部署。较旧的前端不认识 `assets-v1` 传输方式。 - -清单错误见[故障排查](/docs/harness/extensions/troubleshooting)。 - ## 升级 `upgrade` 替换一个已安装扩展的来源,并保留它的记录:`config`、`required`、`enabled` 都保持不变。 diff --git a/frontend/src/content/zh/harness/extensions/plugins.mdx b/frontend/src/content/zh/harness/extensions/plugins.mdx deleted file mode 100644 index 30cace308e7..00000000000 --- a/frontend/src/content/zh/harness/extensions/plugins.mdx +++ /dev/null @@ -1,554 +0,0 @@ ---- -title: 全栈插件 -description: 实验性。一个 PluginContribution 可以同时交付带工作区页面和会话动作的浏览器代码、带认证的后端动作和模型工具。本章涵盖每个字段和校验规则、两种浏览器传输方式(内联模块和清单列出的静态资源)、浏览器 API、HTTP 端点与缓存、安全模型,并逐步讲解书签示例。 ---- - -import { Callout } from "nextra/components"; - -# 全栈插件 - - - **实验性。** 插件契约为 `api_version` 1。内联的 `BrowserModule` 传输方式随 - `deerflow-extension-api` 0.2.2 引入,基于清单的 `BrowserAssets` 传输方式随 **0.2.3** - 引入。两者共享页面和动作接口,这些接口在 1.0 之前仍可能变化。 - - -其他贡献类型改变的是宿主在幕后做的事。**插件**则增加一个用户看得见、用得上的功能:工作区里的一个页面、会话菜单里的一个入口、页面可以调用的后端动作,以及模型可以调用的工具。这一切都来自一个通过常规扩展流程安装的 Python 包,不需要重新构建 DeerFlow 前端,宿主里也没有专门针对某个插件的代码。 - -## 用户看到什么 - -| 界面 | 出现位置 | -| ------------------ | --------------------------------------------------------------------------------------------------------- | -| **扩展**标签页 | 能力中心(`/workspace/capabilities?tab=extensions`)。每个已安装插件一张只读卡片:标题、描述,以及"已启用 · 由管理员管理"或"已停用 · 由管理员管理" | -| 工作区页面 | `/workspace/extensions/{namespace}/{surface id}`,可选侧边栏入口。URL 由宿主生成,插件无法占用任意路径 | -| 会话动作 | 聊天工具栏和侧边栏每个会话菜单里的一个菜单组 | -| 模型工具 | Agent 常规工具集的一部分,与其他工具一样受分组过滤和策略约束 | - -界面里没有任何地方能安装、启用或配置插件。目录对所有人都是只读的,包括管理员。浏览器会一直沿用它发现的插件集合,直到用户手动刷新页面。 - -## 贡献声明 - -`install()` 构建一个 `PluginContribution` 并传给 `registry.plugin()`: - -| 字段 | 类型 | 默认值 | 含义 | -| ------------- | ------------------------ | ------------ | ------------------------------------------------------------------------------------- | -| `namespace` | `str` | 必填 | 唯一标识,匹配 `[a-z][a-z0-9_.-]{0,95}`,例如 `community.bookmarks`。用于 URL 和工具名 | -| `title` | `str` | 必填 | 卡片和页面标题,不能为空 | -| `description` | `str` | `""` | 卡片文字 | -| `enabled` | `bool` | **`False`** | 插件开关。为 `False` 时动作和工具拒绝运行。见[启用插件](#启用插件) | -| `fields` | `tuple[SettingsField]` | `()` | 非机密的部署设置。见[设置](#设置) | -| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | 浏览器代码。见[浏览器代码](#浏览器代码) | -| `backend` | `tuple[BackendAction]` | `()` | 浏览器代码可以调用的动作 | -| `tools` | `tuple[ModelTool]` | `()` | 模型可以调用的工具 | -| `api_version` | `int` | `1` | 必须为 `1` | - -一个贡献至少要提供 `frontend`、`backend`、`tools` 之一。注册表会在存储任何内容之前校验整个贡献,遇到第一处违规即抛出 `ValueError`: - -| 规则 | 错误信息 | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------- | -| `api_version` 为 `1` | `Unsupported plugin contract` | -| 至少有一个浏览器模块、后端动作或工具 | `A plugin must contribute a browser module, backend action or tool` | -| 工具名匹配 `[a-z][a-z0-9_]{0,63}`、互不重复,且有描述和 `async` 处理函数 | `Model tools require unique names, descriptions and async handlers` | -| 工具 schema 是合法、内联、object 类型的 JSON Schema(见[模型工具](#模型工具)) | `Invalid plugin object schema` | -| 动作名匹配 `[a-z][a-z0-9_-]{0,63}`、互不重复,且有 `async` 处理函数 | `Backend actions require unique names and async handlers` | -| `frontend` 是 `BrowserModule`、`BrowserAssets` 或 `None` | `Unsupported browser transport` | -| 内联代码非空,UTF-8 编码后不超过 512 KiB | `Browser code must be nonempty and at most 512 KiB` | -| 资源清单及其列出的每个文件都符合[资源规则](#清单规则) | `Unsupported browser asset manifest; ...`、`Browser assets must not contain symlinks` 等 | -| 命名空间、设置字段和模块标识合法(见[设置](#设置)) | `Invalid settings contribution or namespace`、`Invalid or duplicate settings field` 等 | -| 命名空间和浏览器模块标识在所有已加载插件中唯一 | `Duplicate plugin namespace`、`Duplicate browser module` | - -从 `install()` 抛出的任何异常,包括这些 `ValueError`,以及清单列出的文件不存在时抛出的 `FileNotFoundError`,都会让这个扩展整体加载失败:它注册的一切都会回滚,Gateway 日志会记录原因(见[运行时](/docs/harness/extensions/runtime))。 - -### 检查返回值 - -宿主接受贡献时,`registry.plugin()` 返回 `True`。契约的默认实现返回 `False`,不支持插件的旧宿主就是这样。一个离开界面就没有意义的插件应该明确报错,而不是只装上一半: - -```python -if registry.plugin(contribution) is not True: - raise RuntimeError("this extension requires a host with full-stack plugin support") -``` - -## 启用插件 - -插件有**两个**开关,都要打开: - -- `plugins:` 记录里的 `enabled` 决定 Gateway 是否导入这个包。扩展管理器的 `enable` 和 `disable` 命令切换的就是它。 -- `PluginContribution.enabled` 控制这个贡献本身。它默认为 `False`,而且没有运行时覆盖存储,所以从不设置它的插件会一直处于停用状态:`GET /api/plugins` 报告 `enabled: false`,浏览器不加载它的模块,动作返回 `403 Plugin disabled by administrator.`,它的工具也不会进入任何 Agent。 - -惯例是从包的私有配置里读取第二个开关,让运维人员在 `config.yaml` 里控制它: - -```python -enabled=config.get("enabled", False) is True, -``` - -```yaml -plugins: - - name: notes - use: acme_notes:install - enabled: true # 加载这个包 - config: - enabled: true # 打开这个贡献 -``` - -两者都只在启动时读取,改动任何一个都需要重启 Gateway。 - -## 设置 - -`SettingsField` 声明一个非机密的部署值,动作和工具会收到它,浏览器也可以选择性地看到: - -| 字段 | 默认值 | 含义 | -| --------------------- | -------- | ------------------------------------------------------ | -| `key` | 必填 | 匹配 `[a-z][a-z0-9_]{0,63}`。`enabled` 是保留键 | -| `title` | 必填 | 显示标签 | -| `kind` | 必填 | `"boolean"`、`"integer"` 或 `"string"` | -| `default` | 必填 | 取值。必须符合 `kind` 和下面的边界 | -| `description` | `""` | | -| `minimum` / `maximum` | `None` | 整数边界 | -| `max_length` | `256` | 字符串长度上限,1 到 4096 | - -宿主会自己加上布尔字段 `enabled`。算上它,一个插件最多 32 个字段,留给你的是 31 个。取值只来自包本身:`default` 就是每个动作、工具和浏览器收到的值。没有设置 API,也不能在线编辑,所以运维需要调整的值,请在 `install()` 里从 `config` 推导默认值。 - - - 设置不是机密存储。不要把凭据放进 `SettingsField`。在 `install()` 里从环境变量或私有 - `config` 读取机密,并保存在你自己的 Python 对象中。 - - -浏览器只能看到 `enabled` 和浏览器声明的 `public_fields` 中列出的键(`BrowserModule` 和 `BrowserAssets` 都有这个字段)。列出一个未声明的字段会导致校验失败。 - -## 后端动作 - -```python -BackendAction(name: str, handler: async (payload, context) -> Any) -``` - -浏览器经由宿主调用动作,处理函数收到: - -- `payload`:从 JSON 请求体解析出的只读映射。宿主只保证它是一个不超过 256 KiB 的 JSON **对象**,每个字段都要自己校验。 -- `context`:一个 `ActionContext`,包含 `principal`(已认证调用方的 `ExtensionPrincipal`:`user_id`、`is_admin`、`is_internal`、`roles`)和 `settings`(只读的设置映射)。 - -返回值必须可以 JSON 序列化。宿主把结果映射成 HTTP 状态码,不会暴露你的异常文本: - -| 结果 | 状态码 | 响应体 `detail` | -| ------------------------------------- | ------ | ---------------------------------------------- | -| 处理函数正常返回 | `200` | 你的返回值 | -| 处理函数抛出 `ValueError` | `422` | `Invalid plugin action input.` | -| 处理函数抛出其他异常 | `502` | `Plugin action failed.`(日志中记录异常类型) | -| 处理函数运行超过 30 秒 | `504` | `Plugin action timed out.` | -| 请求体不是 JSON 对象 | `422` | `Plugin action requires a JSON object.` | -| 请求体超过 256 KiB | `413` | `Plugin action input exceeds 256 KiB.` | -| 插件已停用 | `403` | `Plugin disabled by administrator.` | -| 未知的命名空间或动作 | `404` | `Plugin is not installed.` / `Plugin action is not installed.` | -| viewer 请求头与会话不一致 | `409` | `Account changed; reload this plugin view.` | -| 没有已认证的调用方 | `401` | `Authentication required.` | - -处理函数无法自己决定状态码,因为就连抛出 `HTTPException` 也会变成 `502`。输入错误时抛出 `ValueError`;浏览器需要错误详情时,把它放在返回体里。超时会取消处理函数,但不会回滚外部副作用,也不会中止已经在工作线程里运行的任务。 - -**授权由你负责。** 宿主负责认证调用方,但不知道哪些记录属于谁。每次读写都要按 `context.principal.user_id` 限定范围,就像书签示例在每条查询里都带上属主条件一样。 - -## 模型工具 - -```python -ModelTool(name, description, input_schema, handler, group="extensions") -``` - -- **名称。** 模型看到的是由命名空间派生的名字:`ext___`。其中命名空间被清洗为 `[a-z0-9_]` 并截断到 20 个字符,名称截断到 25 个字符,再加 12 位哈希。例如 `acme.notes` 里的 `count_notes` 会变成 `ext_acme_notes_count_notes_d8aad91f72da`。哈希覆盖完整的命名空间和名称,所以截断不会让两个工具合并;万一两个插件工具仍然同名,工具组装会直接报错而不是二选一。与普通工具重名时,保留普通工具,跳过插件工具并记录警告。 -- **Schema。** `input_schema` 必须是合法的 JSON Schema(draft 2020-12),`"type": "object"`,而且完全内联:`$ref`、`$dynamicRef`、`$recursiveRef` 都会被拒绝,因此校验永远不会解析引用或访问网络。参数名 `runtime` 和 `config` 是保留的。 -- **限制。** 宿主会按 schema 校验每次调用的输入,上限 256 KiB。处理函数必须在 30 秒内返回,JSON 编码后的结果不能超过 64 KiB。 -- **上下文。** 处理函数收到一个 `ToolContext`:`settings`、`thread_id`(不在会话中时为 `None`)和 `principal`。对工具来说,principal **只**带 `user_id`,`is_admin` 恒为 `False`,`roles` 为空。不要让工具的授权依赖它们。 -- **错误。** 任何失败都以通用工具错误的形式交给模型:`Invalid plugin tool input.`、`Plugin result exceeds 64 KiB.`、`Plugin disabled by administrator.` 或 `Plugin tool unavailable or input rejected.`。Gateway 日志会记录插件、工具和异常类型。 -- **组装与策略。** 插件工具加入常规的工具组装流程。通过 `tool_groups` 限制过的 Agent,只有在列表包含该工具的 `group`(默认为 `extensions`)时才会拿到它。宿主之后的授权和技能工具策略与其他工具一样适用。 - -工具描述就是提示词。写清楚工具返回什么、返回的是数据而不是指令,以及它是否只读。 - -## 浏览器代码 - -插件用两种传输方式之一交付浏览器代码。两者产出的东西相同:一个默认导出符合[浏览器 API](#浏览器-api) 的 ES 模块。`GET /api/plugins` 会标出每个插件的传输方式,浏览器据此决定如何加载。 - -| 传输方式 | 声明 | 引入版本 | 适用场景 | -| ----------- | ---------------- | -------- | ---------------------------------------------------------------------------- | -| `inline-v1` | `BrowserModule` | 0.2.2 | 单个不超过 512 KiB 的自包含文件:没有相对导入、CSS 文件、图片或 WASM | -| `assets-v1` | `BrowserAssets` | 0.2.3 | 清单中列出的一个目录的文件:拆分的模块、样式表、图片、字体、WASM、source map | - -两种方式中,`module` 都是匹配 `[a-z][a-z0-9.-]{0,95}` 的标识,例如 `bookmarks.v1`,绝不是 URL,并且必须与默认导出里的 `module` 相同。使用 `BrowserAssets` 的插件需要 `deerflow-extension-api>=0.2.3`,宿主的前端和后端都必须是对应版本。旧版前端无法加载 `assets-v1` 插件,但失败只影响这一个插件。 - -### 内联模块 - -```python -BrowserModule(module: str, code: str, public_fields: tuple[str, ...] = ()) -``` - -`code` 是一个自包含 ES 模块的完整文本。宿主带着会话获取它,再从临时 Blob URL 导入,所以不能有相对导入,也不能有相对于 `import.meta.url` 解析的资源。把它打进 wheel,在 `install()` 里读取: - -```python -BrowserModule("notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8")) -``` - -配置了内容安全策略(CSP)的部署必须在 `script-src` 中允许 `blob:`。 - -### 静态资源 - -```python -BrowserAssets(module: str, root: str | Path, manifest: str = "ui_manifest.json", public_fields: tuple[str, ...] = ()) -``` - -`root` 是已安装包内的一个目录,通常是 `Path(__file__).parent`。清单文件相对于 `root`,列出浏览器可以加载的每一个文件: - -```json filename="ui_manifest.json" -{ - "schema_version": 1, - "entry": "static/dist/index.mjs", - "files": [ - "static/dist/index.mjs", - "static/dist/chunks/page.mjs", - "static/dist/notes.css" - ] -} -``` - -入口模块可以用相对路径导入同级模块,并用 `new URL(..., import.meta.url)` 定位资源: - -```js filename="static/dist/index.mjs" -import { mountNotes } from "./chunks/page.mjs"; - -export default { - apiVersion: 1, - module: "notes.v1", - surfaces: [ - { - id: "notes", - slot: "page", - title: "Notes", - navigation: { label: "My notes", labelZh: "我的笔记" }, - mount: mountNotes, - }, - ], -}; -``` - -```js filename="static/dist/chunks/page.mjs" -export function mountNotes(root, context) { - const style = document.createElement("link"); - style.rel = "stylesheet"; - style.crossOrigin = "use-credentials"; - style.href = new URL("../notes.css", import.meta.url).href; - const list = document.createElement("ul"); - root.append(style, list); - context - .callBackend("list", {}) - .then(({ notes }) => { - for (const note of notes) { - const item = document.createElement("li"); - item.textContent = note; - list.append(item); - } - }) - .catch(() => {}); - return { dispose: () => root.replaceChildren() }; -} -``` - -```python -frontend=BrowserAssets("notes.v1", Path(__file__).parent), -``` - -把清单和列出的每个文件都打进 wheel。用 hatchling 打包 Python 包目录时,不需要额外配置就会包含它们。第三方依赖要打包进来:宿主不解析裸 npm 导入,也不提供共享的 React 实例。让打包工具输出相对 URL,而不是 `/assets/...` 这样的绝对路径。 - -#### 清单规则 - -注册表在 `install()` 期间读取、校验每个列出的文件,并把它们快照到内存。任何违规都会让扩展加载失败: - -| 规则 | 错误信息 | -| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| 清单是只含 `schema_version`(整数 `1`)、`entry` 和 `files` 三个键的 JSON 对象 | `Unsupported browser asset manifest; expected schema_version 1` | -| 没有重复的键 | `Duplicate browser manifest key` | -| `files` 列出 1 到 256 个互不重复的合法路径 | `Browser manifest must list unique asset paths` | -| `entry` 是 `files` 之一,并以 `.js` 或 `.mjs` 结尾 | `Browser manifest entry must be a listed JavaScript module` | -| 每个文件的类型都受支持(见下) | `Unsupported browser asset file type` | -| `root` 不是符号链接,列出路径的任何一级都不是符号链接 | `Browser asset root must not be a symlink`、`Browser assets must not contain symlinks` | -| 每个列出的文件都是 `root` 内的普通文件 | `Browser asset must be a regular file inside its package`(文件不存在时抛出 `FileNotFoundError`) | -| 清单不超过 64 KiB,单个文件不超过 4 MiB,所有文件合计不超过 16 MiB | `Browser asset size limit exceeded` | - -合法路径最长 512 个字符,由 `/` 分隔的段组成;每段只能用 ASCII 字母、数字、`_`、`-` 和 `.`,并以字母、数字、`_` 或 `-` 开头。因此点文件、`.` 和 `..` 段、空段、反斜杠、百分号编码和查询字符串都不被允许。 - -支持的类型(括号内为返回的 MIME 类型):`.js` 和 `.mjs`(`text/javascript`)、`.css`、`.json` 和 `.map`(`application/json`)、`.wasm`、`.png`、`.jpg`/`.jpeg`、`.gif`、`.webp`、`.svg`、`.ico`、`.woff`、`.woff2`、`.ttf` 和 `.otf`。HTML 和服务端文件永远不会被提供。 - -#### 修订版本与缓存 - -宿主对清单和每个文件的内容哈希计算一个 SHA-256 **修订版本**,每个 URL 都包含它:`/api/plugins/{namespace}/assets/{revision}/{path}`。任何一个文件变了,所有文件的修订版本都会变。请求由启动时的快照响应,从不读取文件系统,所以修改已安装的文件在重启前不会生效。 - -资源响应带有 `Cache-Control: private, max-age=31536000, immutable` 和 `Vary: Cookie, Authorization`。未知的路径或修订版本返回 `404`,带 `private, no-store`。宿主不保留历史快照,也没有公开 CDN 的约定。升级之后,仍持有旧修订版本的页面对未缓存的内容会得到 `404`,必须刷新。反过来,插件被移除后,浏览器仍可以继续使用已缓存的代码,所以移除并不等于立即撤销缓存。 - -#### 浏览器如何加载资源 - -宿主插入一个指向入口 URL、带 `crossorigin="use-credentials"` 的原生模块脚本,再从文档的模块映射里读取导出。因此相对的静态和动态导入都会在同一个修订版本内解析,并带上会话 cookie。URL 遵循 `NEXT_PUBLIC_BACKEND_BASE_URL`,包括路径前缀。 - -- **超时。** 宿主等待 30 秒后停止等待,并把该插件标为不可用。这是等待的期限,不是取消:模块之后仍可能完成求值。顶层代码不要有副作用,UI 工作放到 `mount` 里开始。 -- **模块状态是共享的。** 一个模块实例由整个文档共享。按查看者区分的数据放在每次挂载的状态里,并在 `dispose()` 中清除。不要在模块级别跨账号切换缓存 principal 或私有结果。 -- **其他资源由你负责。** 你创建的样式表和图片要设置 `crossOrigin = "use-credentials"`,就像 `page.mjs` 那样;JSON、WASM 或二进制数据用 `fetch(url, { credentials: "include" })`。CSS 里的字体和背景 URL 并不总会带上跨域 cookie。对这些资源,优先使用同源部署,或者带凭据获取后构造 `FontFace` 或 Blob URL,并在 dispose 时释放。 -- **CSP 与 CORS。** 后端来源必须在相应的 `script-src`、`style-src`、`img-src`、`font-src` 和 `connect-src` 指令中被允许。前后端分离部署需要精确来源的带凭据 CORS,以及可用的会话 cookie。 - -### 浏览器 API - -模块的默认导出必须符合宿主的浏览器 API v1: - -```js -export default { - apiVersion: 1, // 必填 - module: "notes.v1", // 必填,与声明里的 module 相同 - icon: "file-text", // 可选 - surfaces: [/* 页面 surface,最多 16 个 */], - conversationActions(t, locale) {/* 返回一个动作组 */}, -}; -``` - -如果 `apiVersion` 或 `module` 不匹配,或者任何 surface、导航项格式不对,宿主会拒绝这个模块,并在它的卡片上显示"当前页面加载失败"。 - -### 页面 surface - -| 字段 | 规则 | -| ------------ | ------------------------------------------------------------------- | -| `id` | `[a-z][a-z0-9-]{0,63}`,模块内唯一 | -| `slot` | `"page"`(目前唯一的 slot) | -| `title` | 非空字符串 | -| `navigation` | 可选的 `{ label, labelZh?, icon? }`,添加侧边栏入口。标签最多 120 个字符 | -| `mount` | `(root, context) => { dispose }`,同步调用 | - -宿主把页面挂载到一个 Shadow DOM 根节点,并传入上下文: - -| 上下文成员 | 含义 | -| ------------------------------ | --------------------------------------------------------------------------- | -| `namespace`、`locale` | 插件命名空间和界面语言,例如 `en-US` 或 `zh-CN` | -| `settings` | `enabled` 加上 `public_fields` | -| `signal` | 卸载或切换账号时中止。传给事件监听和请求 | -| `callBackend(action, payload)` | 向本插件声明过的某个动作发 POST,解析为 JSON 响应。非 2xx 响应和未声明的动作会被拒绝 | -| `openConversation(threadId)` | 可选。先通过认证 API 解析会话再跳转;会话不存在或无权访问时拒绝,不会跳转 | - -`mount` 必须返回一个带同步 `dispose()` 的对象。卸载时宿主先中止 `signal` 再调用它,并拦截之后才到达的 `callBackend` 结果。 - -### 会话动作 - -`conversationActions(t, locale)` 必须是同步的,返回 `{ label, icon, actions }`。每个动作有 `id`、`label`、`icon`,一个返回布尔值的同步 `available(settings)`,以及 `execute(context, services)`: - -- `context.thread` 是当前会话;宿主手上已有消息时,`context.messages` 就是这些消息。 -- `services.callBackend` 与上面相同。`services.latestVisibleAnswer(context)` 返回最后一条可见的助手消息 `{ id, text }` 或 `null`。`services.conversationText(context)` 返回可见的对话文本。两者都复用宿主的导出清洗逻辑,会排除隐藏消息、工具输出和推理内容。`services.showMessage(text)` 弹出一条提示。 - -每个插件的动作都在各自的错误边界里求值。格式错误或抛出异常的动作组会被省略并记录到浏览器控制台,其他插件的动作和会话页面都不受影响。 - -### 图标 - -宿主把图标名映射到一个固定集合:`bell`、`bookmark`、`download`、`file-json` 和 `file-text`。其他名字都会显示为通用的拼图图标。 - -## HTTP 端点 - -宿主通过四个需要认证的 Gateway 路由提供插件,它们遵循常规的会话和 CSRF 策略;没有已认证的调用方时都返回 `401`: - -| 路由 | 用途 | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `GET /api/plugins` | 所有已加载插件:`namespace`、`title`、`description`、`viewer_id`、`module`、`entry`、`transport`(`inline-v1`、`assets-v1` 或 `null`)、公开的 `settings`,以及 `backend_actions` 名称 | -| `GET /api/plugins/modules/{module}/{sha256}.mjs` | 按内容哈希寻址的内联模块。`text/javascript`、`nosniff`、`private, no-store` | -| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | 清单列出的一个文件。声明的 MIME 类型、`nosniff`、`Content-Security-Policy: sandbox`,以及[修订版本与缓存](#修订版本与缓存)中描述的不可变私有缓存 | -| `POST /api/plugins/{namespace}/actions/{name}` | 调用一个后端动作(见[后端动作](#后端动作)) | - -内联哈希或资源修订版本过期时返回 `404`,需要刷新页面。两个下载路由在插件停用时仍然会提供它的代码。 - -浏览器会带上 `X-Deerflow-Plugin-Viewer` 请求头,值为它发现插件时得到的 `viewer_id`,所以在另一个账号下打开的视图发起的动作会以 `409` 被拒绝。 - -## 安全模型 - - - 浏览器模块和 Python 处理函数都是运维人员安装的受信任代码。浏览器代码运行在**主页面里**, - 拥有当前登录用户的同源能力;Shadow DOM 隔离的是 CSS,不是权限。Python 处理函数在 - Gateway 进程中以 Gateway 权限运行。 - - -宿主确实保证的: - -- 它从不从浏览器提供的路径、导入字符串或远程 URL 加载代码,只提供已安装、经内容哈希校验的内联模块,或清单列出、从启动快照读取的文件。它从不提供目录列表或未列出的文件。 -- 资源响应带有 `Content-Security-Policy: sandbox`,所以直接打开一个资源(比如内含脚本的 SVG)不能以 Gateway 的来源运行脚本。这限制的是被当作文档打开的资源,并不会把宿主加载进页面的插件 JavaScript 放进沙箱。 -- 每个端点都要求已认证的调用方,而调用方身份来自会话,不来自请求体。 -- `callBackend` 绑定在插件自己的命名空间和它声明过的动作名上。 -- 只有 `enabled` 和显式公开的字段会到达浏览器。 - -任何已认证用户都可以下载清单列出的全部资源,包括 source map,即使插件已停用。不要在清单里列出机密或私有源码。 - -仍然是插件自己的职责:按 `principal.user_id` 对自己的数据做授权,校验每个 payload,渲染用户和模型文本时做转义(用 `textContent`,绝不用 `innerHTML`),并把工具结果当作不可信数据。 - -## 逐步讲解:书签示例 - -[`examples/deerflow-extension-bookmarks`](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks)(0.2.0 版,需要 `deerflow-extension-api>=0.2.3`)是一个完整的插件:用户在会话菜单里收藏最后一条可见回答,然后在 **我的书签** 页面搜索、重命名、打开或删除书签,Agent 也可以搜索这些书签。它用到了本章的每一部分: - -1. **配置和状态。** `install()` 要求 `config.enabled`(布尔值)和一个绝对路径 `config.storage_path`,并在那里打开 SQLite 存储。容器部署时要为它挂载持久目录。 -2. **贡献声明。** 命名空间 `community.bookmarks`,`enabled=config["enabled"]`,`BrowserAssets("bookmarks.v1", Path(__file__).parent)`,五个 `BackendAction`(`save`、`search`、`get`、`rename`、`delete`),以及一个只读的 `ModelTool`:`search_bookmarks`,模型看到的名字是 `ext_community_bookmarks_search_bookmarks_`。 -3. **检查返回值。** 如果 `registry.plugin(...)` 不是 `True`,它会抛出 `RuntimeError`。 -4. **数据归属。** 每条 SQL 语句都带有 `owner = context.principal.user_id`,每个动作只接受一组确定的 payload 键。模型工具从不接受用户 ID,所以用户永远只能搜索自己的书签。 -5. **资源。** `ui_manifest.json` 列出 `static/dist/` 下的四个文件:入口 `index.mjs`、页面模块 `chunks/bookmarks.mjs`、`styles.css` 和 `bookmark.svg`。它们随 Python 包一起发布,不需要 JavaScript 构建步骤。 -6. **入口模块。** `index.mjs` 从 `./chunks/bookmarks.mjs` 导入 `mountBookmarks`,导出一个 `page` surface `library`,带 `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`,地址为 `/workspace/extensions/community.bookmarks/library`;还导出一个"书签 → 收藏最后一条回答"会话动作,先调用 `services.latestVisibleAnswer`,再调用 `callBackend("save", ...)`。 -7. **页面模块。** `chunks/bookmarks.mjs` 相对于 `import.meta.url` 解析 `../styles.css` 和 `../bookmark.svg`,并用 `crossorigin="use-credentials"` 加载它们,因此在前后端分离部署下也能工作。它监听 `context.signal`,用 `textContent` 渲染文本,并在 `dispose()` 中清空自己的 DOM。 - -部署记录如下: - -```yaml -plugins: - - name: bookmarks - use: deerflow_extension_bookmarks:install - enabled: true - config: - enabled: true - storage_path: /var/lib/deerflow/bookmarks.sqlite -``` - -重启并刷新浏览器后,扩展标签页里会出现它的卡片,侧边栏多出 **我的书签**,会话菜单多出 **书签**。这个示例使用单机 SQLite 存储,不是多节点存储的范式。 - -## 最小插件 - -最小的实用形态是一个页面、两个动作和一个只读工具。这个例子使用内联传输方式,所以只有一个 `client.mjs`;要拆成多个文件,请改用[静态资源](#静态资源),就像那一节里的 `acme.notes` 那样。下面每一部分都已经在宿主的注册表和路由上实际跑过: - -```python -"""A per-user scratchpad: a page, one backend action, one read-only model tool.""" - -from __future__ import annotations - -from collections.abc import Mapping -from pathlib import Path -from typing import Any - -from deerflow_extension_api import ( - ActionContext, - BackendAction, - BrowserModule, - ModelTool, - PluginContribution, - SettingsField, - ToolContext, - extension, -) - -NOTES: dict[str, list[str]] = {} # demo only: in-memory, single process - - -async def add_note(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: - text = payload.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("text is required") # becomes HTTP 422 - notes = NOTES.setdefault(context.principal.user_id, []) - if len(notes) >= context.settings["max_notes"]: - raise ValueError("note limit reached") - notes.append(text.strip()) - return {"count": len(notes)} - - -async def list_notes(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: - return {"notes": NOTES.get(context.principal.user_id, [])} - - -async def count_notes(payload: Mapping[str, Any], context: ToolContext) -> dict[str, Any]: - return {"count": len(NOTES.get(context.principal.user_id, []))} - - -@extension(api="0.2.2", name="notes") -def install(registry, config: Mapping[str, Any]) -> None: - accepted = registry.plugin( - PluginContribution( - namespace="acme.notes", - title="Notes", - description="A private scratchpad for each user.", - enabled=config.get("enabled", False) is True, - fields=(SettingsField("max_notes", "Maximum notes", "integer", 50, minimum=1, maximum=500),), - frontend=BrowserModule( - "notes.v1", - Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), - public_fields=("max_notes",), - ), - backend=(BackendAction("add", add_note), BackendAction("list", list_notes)), - tools=( - ModelTool( - "count_notes", - "Count the current user's saved notes. Read-only.", - {"type": "object", "properties": {}, "additionalProperties": False}, - count_notes, - ), - ), - ) - ) - if accepted is not True: - raise RuntimeError("acme-notes requires a host with full-stack plugin support") -``` - -它的 `client.mjs` 放在 `__init__.py` 旁边,并打进 wheel: - -```js -function mountNotes(root, context) { - const list = document.createElement("ul"); - const input = document.createElement("input"); - const add = document.createElement("button"); - add.textContent = context.locale.startsWith("zh") ? "添加" : "Add"; - root.append(input, add, list); - - const render = async () => { - const { notes } = await context.callBackend("list", {}); - list.replaceChildren( - ...notes.map((note) => { - const item = document.createElement("li"); - item.textContent = note; // textContent, never innerHTML - return item; - }), - ); - }; - add.addEventListener( - "click", - async () => { - await context.callBackend("add", { text: input.value }); - input.value = ""; - await render(); - }, - { signal: context.signal }, - ); - render().catch(() => {}); - return { dispose: () => root.replaceChildren() }; -} - -export default { - apiVersion: 1, - module: "notes.v1", - surfaces: [ - { - id: "notes", - slot: "page", - title: "Notes", - navigation: { label: "My notes", labelZh: "我的笔记" }, - mount: mountNotes, - }, - ], - conversationActions(_t, locale = "en") { - const zh = locale.startsWith("zh"); - return { - label: zh ? "笔记" : "Notes", - icon: "file-text", - actions: [ - { - id: "save-answer", - label: zh ? "把最后一条回答存为笔记" : "Save last answer as a note", - icon: "file-text", - available: (settings) => settings.enabled === true, - async execute(context, services) { - const answer = await services.latestVisibleAnswer?.(context); - if (!answer) return services.showMessage(zh ? "没有可保存的回答" : "No answer to save"); - await services.callBackend("add", { text: answer.text.slice(0, 2000) }); - services.showMessage(zh ? "已保存" : "Saved"); - }, - }, - ], - }; - }, -}; -``` - -内存字典只用于示例。真实插件需要能在重启后保留、并在多个 Gateway worker 之间共享的存储。 - -## 生命周期 - -- 安装、升级、启用、停用和任何 `config` 改动都需要重启 Gateway,然后刷新浏览器。没有热加载,也没有热卸载。 -- 重启之后,已打开的页面可能指向已不存在的模块哈希、资源修订版本或动作。这些请求会明确失败(`404`),直到用户刷新页面。 -- 已停用或已移除的插件在刷新后没有侧边栏入口。直接访问它的页面 URL 会显示"扩展页面不可用",不会挂载任何内容。 - -运维命令见[运维](/docs/harness/extensions/operations)。 diff --git a/frontend/src/content/zh/harness/extensions/reference.mdx b/frontend/src/content/zh/harness/extensions/reference.mdx index f99a9179a7a..c74c803bbe4 100644 --- a/frontend/src/content/zh/harness/extensions/reference.mdx +++ b/frontend/src/content/zh/harness/extensions/reference.mdx @@ -1,13 +1,11 @@ --- title: 参考 -description: deerflow-extension-api 0.2.3 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。 +description: deerflow-extension-api 0.2.1 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。 --- -import { Callout } from "nextra/components"; - # 参考 -本页列出契约版本 **0.2.3** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入: +本页列出契约版本 **0.2.1** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入: ```python from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement @@ -36,7 +34,7 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ... ### `ExtensionRegistry` -一个 `runtime_checkable` 的 Protocol:传给 `install()` 的只写接口。每个方法都有一个什么都不注册的默认实现。如果宿主的注册表早于某个方法,就会继承这个默认实现,于是调用成功,但贡献并没有被注册。只有 `plugin()` 会通过返回 `False` 报告这一点;对其他方法,要靠版本标记和包元数据来防止这种情况。 +一个 `runtime_checkable` 的 Protocol:传给 `install()` 的只写接口。每个方法都有一个什么都不注册的默认实现。如果宿主的注册表早于某个方法,就会继承这个默认实现,于是调用成功,但贡献并没有被注册。要靠版本标记和包元数据来防止这种情况。 | 方法 | 返回值 | 注册内容 | | ---------------------------------------------------------- | ------- | ---------------------------------------------------------------------------- | @@ -47,7 +45,6 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ... | `context_compaction_observer(observer: ContextCompactionObserver)` | `None` | 摘要压缩的观察者 | | `service(service: ExtensionService)` | `None` | 与 Gateway 同寿的服务 | | `routers(routers: Sequence[Any])` | `None` | 在 `install()` 期间构建的 FastAPI 路由器 | -| `plugin(contribution: PluginContribution)` | `bool` | 全栈插件。被接受时返回 `True`,宿主不支持插件时返回 `False`。实验性 | ## 中间件 @@ -312,18 +309,6 @@ Gateway 的实现接受 1 到 2000 的 `limit` 和非负的 `after_seq`,否则 冻结的 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`。 -### `resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None` - -返回绑定到贡献路由已认证调用者的读取器。面向用户的路由应使用它,而不是能看到所有用户运行的全局 `ExtensionRuntimeDeps.run_evidence_reader`。`request` 是鸭子类型。宿主不支持请求级运行证据时返回 `None`。调用者未认证或缺少 `runs:read` 权限时,Gateway 抛出 `PermissionError("run evidence requires an authenticated user with runs:read")`;它从不把管理员或内部调用者放宽到全局可见。其他解析器错误会直接传播。 - -### `require_run_evidence_reader(request: object) -> RunEvidenceReader` - -与 `resolve_run_evidence_reader()` 相同,但在不支持时抛出 `NotImplementedError("request-scoped run evidence is unavailable")`,而不是返回 `None`。把 `NotImplementedError` 映射为 HTTP 503,把 `PermissionError` 映射为 403。它从不回退到全局读取器。 - -### `RUN_EVIDENCE_READER_RESOLVER_KEY` - -`"deerflow_extension_run_evidence_reader_resolver"`。宿主安装请求级解析器所用的 `app.state` 属性名,由宿主拥有。 - ### `InvalidRunEvidenceCursor` `ValueError` 的子类,游标格式错误、不受支持或属于另一个作用域时抛出。 @@ -397,98 +382,18 @@ class ReleasePolicyProvider(Protocol): 对 `canonical_json(value)` 以 UTF-8 编码后计算的 SHA-256 十六进制摘要。 -## 全栈插件(实验性) - - - 插件契约在 0.2.3 中是实验性的。务必检查 `registry.plugin(...)` 的返回值。见[插件](/docs/harness/extensions/plugins)。 - - -### `PluginContribution` - -冻结的 dataclass。 - -| 字段 | 类型 | 默认值 | -| ------------- | ------------------------------ | ------- | -| `namespace` | `str` | 必填 | -| `title` | `str` | 必填 | -| `description` | `str` | `""` | -| `enabled` | `bool` | `False` | -| `fields` | `tuple[SettingsField, ...]` | `()` | -| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | -| `backend` | `tuple[BackendAction, ...]` | `()` | -| `api_version` | `int` | `1` | -| `tools` | `tuple[ModelTool, ...]` | `()` | - -宿主只接受 `api_version == 1`,并要求 `frontend`、`backend`、`tools` 至少有一项。命名空间必须唯一。 - -### `BrowserModule` - -冻结的 dataclass:`module: str`、`code: str`(一个自包含的 ES 模块,非空且不超过 512 KiB)、`public_fields: tuple[str, ...] = ()`。发现接口把这种传输方式标记为 `inline-v1`。模块需要相对导入、样式表或图片时,请使用 `BrowserAssets`。 - -### `BrowserAssets` - -冻结的 dataclass,声明已安装包内的清单和静态文件。发现接口把这种传输方式标记为 `assets-v1`。 - -| 字段 | 类型 | 默认值 | -| --------------- | ----------------- | -------------------- | -| `module` | `str` | 必填 | -| `root` | `str \| Path` | 必填 | -| `manifest` | `str` | `"ui_manifest.json"` | -| `public_fields` | `tuple[str, ...]` | `()` | - -`root` 通常是 `Path(__file__).parent`。清单相对于 `root`,必须是恰好包含 `schema_version`(整数 `1`)、`entry` 和 `files` 三个键的 JSON 对象: - -```json -{ - "schema_version": 1, - "entry": "static/dist/index.mjs", - "files": ["static/dist/index.mjs", "static/dist/styles.css"] -} -``` - -`files` 列出 1 到 256 个不重复的路径,`entry` 必须是其中之一,并以 `.js` 或 `.mjs` 结尾。路径只能使用 ASCII 字母、数字、`_`、`-` 和 `.`,以 `/` 分隔,每一段必须以字母、数字、`_` 或 `-` 开头。`root` 本身不能是符号链接,列出的路径也不能经过符号链接。限制:清单 64 KiB,单个文件 4 MiB,总计 16 MiB。接受的文件类型:`.js`、`.mjs`、`.css`、`.json`、`.map`、`.wasm`、`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`、`.svg`、`.ico`、`.woff`、`.woff2`、`.ttf`、`.otf`。 - -`registry.plugin()` 会校验清单,并把列出的每个文件读入内存快照。校验错误会从 `registry.plugin()` 抛出,于是 `install()` 失败。快照的 revision 是对清单、路径和文件内容计算的 SHA-256,Gateway 从这个快照在 `GET /api/plugins/{namespace}/assets/{revision}/{path}` 提供文件。任何已认证用户都能下载列出的文件(包括 source map),即使插件处于禁用状态,所以绝不要列出机密。 - -### `BackendAction` - -冻结的 dataclass:`name: str`、`handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]`。名称必须唯一,handler 必须是异步的。 - -### `ModelTool` - -冻结的 dataclass:`name: str`、`description: str`、`input_schema: Mapping[str, Any]`(内联的对象 schema)、`handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]`、`group: str = "extensions"`。 - -### `ActionContext` / `ToolContext` - -冻结的 dataclass。`ActionContext`:`principal: ExtensionPrincipal`、`settings: Mapping[str, bool | int | str]`。`ToolContext` 在其基础上增加 `thread_id: str | None`。 - -### `SettingsField` - -描述一项非机密部署设置的冻结 dataclass。 - -| 字段 | 类型 | 默认值 | -| ------------- | -------------------------------------------- | ------- | -| `key` | `str` | 必填 | -| `title` | `str` | 必填 | -| `kind` | `Literal["boolean", "integer", "string"]` | 必填 | -| `default` | `bool \| int \| str` | 必填 | -| `description` | `str` | `""` | -| `minimum` | `int \| None` | `None` | -| `maximum` | `int \| None` | `None` | -| `max_length` | `int` | `256` | - ## 版本常量 ### `API_VERSION` -`"0.2.3"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。 +宿主的契约版本,为点分字符串,本页描述的契约是 `"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.3` 宿主接受 `0.2.0` 到 `0.2.3`,拒绝 `0.2.4`、`0.1.x` 和 `0.3.x`); + - 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 函数不做检查。 @@ -504,7 +409,5 @@ class ReleasePolicyProvider(Protocol): | 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` | -| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | 实验性的 `registry.plugin()`,以及 `PluginContribution`、`BrowserModule`、`BackendAction`、`ModelTool`、`ActionContext`、`ToolContext`、`SettingsField` | -| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727)、[#5685](https://github.com/bytedance/deer-flow/pull/5685) | 请求级运行证据:`resolve_run_evidence_reader`、`require_run_evidence_reader`、`RUN_EVIDENCE_READER_RESOLVER_KEY`(#5727)。打包的浏览器资源:`BrowserAssets`,`PluginContribution.frontend` 也接受它(#5685) | 至今没有任何版本移除过公开名称。 diff --git a/frontend/src/content/zh/harness/extensions/run-evidence.mdx b/frontend/src/content/zh/harness/extensions/run-evidence.mdx index 738d25a4829..c03b4c3b3ba 100644 --- a/frontend/src/content/zh/harness/extensions/run-evidence.mdx +++ b/frontend/src/content/zh/harness/extensions/run-evidence.mdx @@ -1,6 +1,6 @@ --- title: 运行证据 -description: 让服务或路由发现有变化的运行并读取其持久化事件的只读读取器。涵盖全局的服务读取器和限定调用方的请求读取器、读取器的三个方法及返回类型、游标语义、删除、脱敏、后端差异、一个轮询服务,以及一个按用户的路由。 +description: 让服务发现有变化的运行并读取其持久化事件的只读读取器。涵盖如何获取读取器、它的三个方法及返回类型、游标语义、删除、脱敏、后端差异,以及一个轮询服务。 --- import { Callout } from "nextra/components"; @@ -13,18 +13,7 @@ import { Callout } from "nextra/components"; ## 获取读取器 -读取器有两种,区别只在于能看到什么: - -| 读取器 | 获取方式 | 能看到 | 用在 | -| ---------- | ------------------------------------------------------------------------- | -------------------------------- | -------------- | -| 服务读取器 | `ExtensionRuntimeDeps.run_evidence_reader`,传给 `start()` | 所有用户的运行和事件 | 服务:导出器、索引 | -| 请求读取器 | `resolve_run_evidence_reader(request)` 或 `require_run_evidence_reader(request)` | 只有已认证调用方自己的运行和事件 | 贡献的路由 | - -两者都实现下文介绍的同一个 `RunEvidenceReader` 接口。 - -### 服务读取器 - -Gateway 把一个读取器交给每个服务: +Gateway 通过 `ExtensionRuntimeDeps.run_evidence_reader` 把一个读取器交给每个服务: ```python class MyService: @@ -37,30 +26,11 @@ class MyService: Gateway 总会提供它。`None` 表示扩展运行在一个没有实现读取器的宿主上,应当视为"不支持",而不是"没有运行"。服务的生命周期和顺序见[服务与路由](/docs/harness/extensions/services-and-routes);从 `start()` 被调用到 `stop()` 返回,读取器都可以使用。 - 服务读取器具有**全局**可见性:它能看到所有用户的运行和事件。服务没有请求 + 读取器具有**全局**可见性:它能看到所有用户的运行和事件。服务没有请求 principal,所以 Gateway 有意不把这个读取器绑定到任何用户。事件内容按存储原样返回。 - 永远不要把它的数据返回给调用方;路由处理函数请改用请求读取器。 + 永远不要把它的数据返回给路由的调用方。 -### 请求读取器 - -路由处理函数向宿主索取一个绑定到当前请求的读取器: - -| 辅助函数 | 宿主支持 | 调用方无权访问 | 宿主不支持 | -| --------------------------------------- | ------------ | ------------------------ | ---------------------------- | -| `resolve_run_evidence_reader(request)` | 返回读取器 | 抛出 `PermissionError` | 返回 `None` | -| `require_run_evidence_reader(request)` | 返回读取器 | 抛出 `PermissionError` | 抛出 `NotImplementedError` | - -在你的 HTTP 边界做映射:`PermissionError` 映射为 `403`,`None` 或 `NotImplementedError` 映射为 `503`。宿主解析器抛出的其他异常原样传播。两个辅助函数都不会回退到服务读取器。 - -Gateway 依据请求的认证信息绑定读取器,绝不依据调用方发送的任何内容: - -- 调用方必须已认证并持有 **`runs:read`** 权限。默认所有已登录用户都有。在 `config.yaml` 中设置了 `authorization.enabled` 时,由配置的提供方决定。个人访问令牌还必须带有 `runs:read` scope。 -- 读取器限定在调用方自己的用户 ID 上:只能看到以该用户为所有者记录的运行,别的都看不到。 -- 管理员和内部调用方**不会**被放宽:管理员的请求读取器只能看到管理员自己的运行,而不是所有人的。 - -一个读取器签发的游标会被作用域不同的读取器拒绝,所以签发给某个用户的游标无法被另一个用户重放。见[游标规则](#游标规则)。 - ## 读取器接口 `RunEvidenceReader` 有三个异步方法,全部只接受关键字参数: @@ -258,78 +228,10 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: - 遇到 `InvalidRunEvidenceCursor` 时把游标重置为 `None`,而不是让服务停下。 - 一次同步中的异常会被记录,下一次轮询从最后提交的游标继续。 -## 示例:调用方自己的运行 - -这个路由分页返回已登录用户最近发生变化的运行。它使用请求读取器,所以由宿主保证每个调用方只能看到自己的运行。它声明 `api="0.2.3"`,因为请求读取器辅助函数最早随该契约版本发布。 - -```python filename="deerflow_extension_myruns/__init__.py" -"""List the caller's own recently changed runs.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from deerflow_extension_api import ( - ExtensionRegistry, - InvalidRunEvidenceCursor, - RunEvidenceReader, - extension, - require_run_evidence_reader, -) -from fastapi import APIRouter, Depends, HTTPException, Request - - -def caller_reader(request: Request) -> RunEvidenceReader: - try: - return require_run_evidence_reader(request) - except NotImplementedError as exc: - raise HTTPException(status_code=503, detail="run evidence is not available on this host") from exc - except PermissionError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - - -def build_router() -> APIRouter: - router = APIRouter(prefix="/api/ext-myruns", tags=["ext-myruns"]) - - @router.get("/runs") - async def my_runs( - cursor: str | None = None, - limit: int = 50, - reader: RunEvidenceReader = Depends(caller_reader), - ) -> dict[str, Any]: - try: - page = await reader.list_changed_runs(cursor=cursor, limit=min(max(limit, 1), 200)) - except InvalidRunEvidenceCursor as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return { - "runs": [{"thread_id": run.thread_id, "run_id": run.run_id, "status": run.status} for run in page.items], - "next_cursor": page.next_cursor, - "has_more": page.has_more, - } - - return router - - -@extension(api="0.2.3", name="myruns") -def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: - registry.routers((build_router(),)) -``` - -| 请求 | 响应 | -| -------------------------------------- | -------------------------------------------------------------------------- | -| 没有会话 | 宿主返回 `401`,处理函数不会运行 | -| 已登录,但没有 `runs:read` | `403 {"detail": "run evidence requires an authenticated user with runs:read"}` | -| 已登录 | `200`,只包含调用方的运行,以及 `next_cursor` 和 `has_more` | -| `?cursor=` 不是签发给这个调用方的 | `400 {"detail": "invalid run evidence cursor"}`;如果是其他用户的游标,则为 `run evidence cursor scope does not match this reader` | -| 宿主不支持请求作用域的证据 | `503 {"detail": "run evidence is not available on this host"}` | - -客户端保存 `next_cursor`,并以 `?cursor=` 传回以继续翻页。游标绑定到调用方,泄露给其他用户也没有用。 - ## 常见误区 - **把空页当作不支持。** 空页表示已追平。不支持是 `run_evidence_reader is None`。 - **在工作保存之前推进游标。** 一旦崩溃,这一页会被永久跳过。请先保存再推进。 - **以为每个运行只有一条记录。** 运行每次变化都会再次出现。请以 `run_id` 为键组织产出。 - **等待删除事件。** 没有这种事件。请用 `get_run_status` 对账。 -- **把服务读取器的数据提供给用户。** 它能看到所有用户。路由处理函数请使用 `require_run_evidence_reader(request)`,由宿主把它限定到调用方。 +- **把读取器的数据提供给用户。** 读取器能看到所有用户。永远不要在路由中返回它的数据。 diff --git a/frontend/src/content/zh/harness/extensions/runtime.mdx b/frontend/src/content/zh/harness/extensions/runtime.mdx index 4bc296e3a06..b0b19d363d6 100644 --- a/frontend/src/content/zh/harness/extensions/runtime.mdx +++ b/frontend/src/content/zh/harness/extensions/runtime.mdx @@ -49,20 +49,20 @@ ExtensionLoadError: required extension acme_audit:install failed to install ### 版本检查 -没有标记时跳过检查。否则,1.0 之前的规则是:major 和 minor 与宿主相同,且 patch 不高于宿主;1.0 之后将是:major 相同,且 minor 和 patch 不高于宿主。以宿主 `0.2.3` 为例: +没有标记时跳过检查。否则,1.0 之前的规则是:major 和 minor 与宿主相同,且 patch 不高于宿主;1.0 之后将是:major 相同,且 minor 和 patch 不高于宿主。以宿主 `0.2.1` 为例: | `@extension(api=...)` | 结果 | | ----------------------------- | -------------------------------------------------- | | 未装饰 | 加载 | -| `"0.2"`、`"0.2.0"`、`"0.2.3"` | 加载 | -| `"0.2.4"` | 拒绝:扩展可能用到本宿主没有的新增内容 | +| `"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.4, host provides 0.2.3. Install a matching version: pip install 'deerflow-extension-api>=0.2.4,<0.3' +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` 声明的依赖范围才是主要的兼容性机制。 diff --git a/frontend/src/content/zh/harness/extensions/services-and-routes.mdx b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx index b09e13ab3a4..db1544f794e 100644 --- a/frontend/src/content/zh/harness/extensions/services-and-routes.mdx +++ b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx @@ -40,7 +40,7 @@ def install(registry, config): | `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) | +| `run_evidence_reader` | `RunEvidenceReader` 或 `None` | 对所有用户的运行及其持久化事件的只读视图。永远不要在路由中返回它的数据。见[运行证据](/docs/harness/extensions/run-evidence) | `session_factory` 就是宿主自己的数据库连接,没有任何沙箱。用它的服务可以读写宿主的每一张表。如果扩展有自己的表,请在它的 `plugins:` 记录里声明 `table_prefix`,这样 `alembic revision --autogenerate` 就不会去动它们。 @@ -143,10 +143,6 @@ class ExtensionPrincipal: 两个辅助函数都是同步的,在同步和异步处理函数里都能用。它们与框架无关,所以要自己把结果映射成 HTTP 状态码:`None` 映射为 `401`,`PermissionError` 映射为 `403`。 -### 在路由中读取运行证据 - -不要在路由里返回服务的 `run_evidence_reader` 中的数据:它能看到所有用户。请改为调用 `require_run_evidence_reader(request)`。只要调用方持有 `runs:read` 权限,宿主就返回一个限定到该已认证调用方的读取器。把 `PermissionError` 映射为 `403`,`NotImplementedError` 映射为 `503`。规则和一个完整的路由见[运行证据](/docs/harness/extensions/run-evidence)。 - ## 示例:一个状态 API 这个扩展提供两条路由。`GET /api/ext-status/me` 对所有已登录用户开放,`POST /api/ext-status/reset` 需要管理员。服务启动之前两者都返回 `503`。 @@ -253,4 +249,4 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: - **通用路径。** 宿主命名空间下的路径会被拒绝,别的扩展先注册的路径会胜出。请使用你自己拥有的前缀。 - **把 `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 index 5d67d31e2f3..431038e7de3 100644 --- a/frontend/src/content/zh/harness/extensions/troubleshooting.mdx +++ b/frontend/src/content/zh/harness/extensions/troubleshooting.mdx @@ -45,9 +45,9 @@ Gateway 的环境里没有安装这个模块。请通过管理器安装,而不 `@extension(api=...)` 标记超出了这个宿主接受的范围。1.0 之前,宿主接受相同的 `0.minor`,且 patch 不高于宿主自身。 -- 声明的版本**高于**宿主(例如在 `0.2.3` 宿主上声明 `0.3.0`):升级 DeerFlow,或安装为这个宿主构建的扩展版本。 -- 声明的 patch 版本**高于**宿主(例如在 `0.2.3` 宿主上声明 `0.2.4`):升级 DeerFlow,或安装声明 `0.2.3` 及以下版本的扩展。 -- 声明的版本属于**更旧**的 minor(例如在 `0.2.3` 宿主上声明 `0.1.0`):把扩展升级到针对宿主 minor 编写的版本。 +- 声明的版本**高于**宿主(例如在 `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` 固定,所以对于旧扩展这个建议并不适用:要修的是扩展,不是宿主。 @@ -163,30 +163,6 @@ Gateway 的环境里没有安装这个模块。请通过管理器安装,而不 你的组装观察者抛出了异常。它在 Agent 构建期间同步运行;保持轻量并且不要抛异常。 -## 插件与浏览器资源 - -`registry.plugin(...)` 内部的校验错误会直接抛出,因此会以该扩展的 `install() failed: <消息>` 出现,而该扩展的所有贡献都不会被加载。 - -| `install() failed:` 之后的消息 | 原因与修复 | -| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `[Errno 2] No such file or directory: ''` | 清单或它列出的某个文件不在已安装的包里。检查 wheel 是否包含它;对于从索引或 Git 安装的包,要检查构建出的 wheel,而不是你的源码目录 | -| `Unsupported browser asset manifest; expected schema_version 1` | 清单必须是恰好包含 `schema_version: 1`、`entry` 和 `files` 的对象 | -| `Duplicate browser manifest key` | `ui_manifest.json` 中某个键出现了两次 | -| `Browser manifest must list unique asset paths` | `files` 为空、超过 256 项、有重复路径,或包含非法路径 | -| `Browser manifest entry must be a listed JavaScript module` | `entry` 不在 `files` 中,或不以 `.js` / `.mjs` 结尾 | -| `Invalid browser asset path` | 路径中含有 `..`、点号段或点文件、空段,或 `A-Z a-z 0-9 _ - .` 以外的字符 | -| `Unsupported browser asset file type` | 列出的某个文件扩展名不在接受的类型中。HTML 永远不会被提供 | -| `Browser assets must not contain symlinks` / `Browser asset root must not be a symlink` | 把链接替换成真实文件 | -| `Browser asset size limit exceeded` | 清单超过 64 KiB、单个文件超过 4 MiB,或总计超过 16 MiB | -| `Browser code must be nonempty and at most 512 KiB` | 内联的 `BrowserModule` 为空或过大;改用 `BrowserAssets` | -| `Unsupported browser transport` | `frontend` 既不是 `BrowserModule` 也不是 `BrowserAssets` | - -插件的文件在 Gateway 启动时做快照。修改这些文件后,要升级扩展并重启 Gateway。浏览器可能会保留旧 revision 中已缓存的代码,直到页面重新加载。 - -### 面向用户的路由无法读取运行证据 - -调用者未认证或缺少 `runs:read` 时,`resolve_run_evidence_reader(request)` 会抛出 `PermissionError("run evidence requires an authenticated user with runs:read")`,应返回 403。宿主不支持请求级运行证据时,`require_run_evidence_reader(request)` 会抛出 `NotImplementedError("request-scoped run evidence is unavailable")`,应返回 503。见[运行证据](/docs/harness/extensions/run-evidence)。 - ## 服务与路由 | `Extension :` 之后的日志行 | 原因与修复 | From dfb1fdfe080a96dc726bf5539b377542111af53b Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Wed, 23 Sep 2026 18:27:10 +0800 Subject: [PATCH 4/4] docs(extensions): clarify PAT rejection on contributed routes --- .../en/harness/extensions/services-and-routes.mdx | 9 +++++---- .../zh/harness/extensions/services-and-routes.mdx | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/content/en/harness/extensions/services-and-routes.mdx b/frontend/src/content/en/harness/extensions/services-and-routes.mdx index 34a2d0aa33e..40106d7f22d 100644 --- a/frontend/src/content/en/harness/extensions/services-and-routes.mdx +++ b/frontend/src/content/en/harness/extensions/services-and-routes.mdx @@ -117,8 +117,8 @@ The shadow check is conservative. It rejects a route only when an earlier route Contributed routes sit behind the same middleware as the host API, and they cannot opt out: -- **Authentication.** A request without a valid session or personal access token gets `401` 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. Requests authenticated with an `Authorization: Bearer` header skip this check. +- **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. @@ -140,7 +140,7 @@ class ExtensionPrincipal: | `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. A request authenticated with a **personal access token** never carries admin rights: `is_admin` is `False` and `roles` is empty even when the token belongs to an admin. +`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`. @@ -240,7 +240,8 @@ Against a Gateway with the memory database backend, the routes answer: | `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 or personal access token | `403 {"detail": "this endpoint requires an administrator account"}` | +| `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 diff --git a/frontend/src/content/zh/harness/extensions/services-and-routes.mdx b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx index db1544f794e..0be05dd0812 100644 --- a/frontend/src/content/zh/harness/extensions/services-and-routes.mdx +++ b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx @@ -116,8 +116,8 @@ Gateway 在**所有宿主路由之后**挂载贡献的路由,所以匹配时 贡献的路由与宿主 API 共用同一套中间件,而且无法退出: -- **认证。** 没有有效会话或个人访问令牌的请求,在你的处理函数运行之前就会得到 `401`。 -- **CSRF。** 来自浏览器会话的 `POST`、`PUT`、`PATCH` 或 `DELETE` 必须在 `X-CSRF-Token` 头里带上 `csrf_token` cookie 的值,DeerFlow 前端已经这样做了。缺少时请求在你的处理函数运行之前就会得到 `403`。用 `Authorization: Bearer` 头认证的请求跳过这项检查。 +- **认证。** 未认证的请求在你的处理函数运行之前就会得到 `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` 时无效),每个请求都以一个合成的管理员用户运行,两项检查都不生效。 @@ -139,7 +139,7 @@ class ExtensionPrincipal: | `resolve_principal(request)` | 调用方的 `ExtensionPrincipal`;宿主无法确定时返回 `None` | | `require_admin(request)` | 调用方是管理员时返回其 principal,否则抛出 `PermissionError`,身份未知时也是如此 | -`roles` 保存调用方唯一的系统角色,例如 `("admin",)` 或 `("user",)`。对于 Gateway 自身组件用内部服务令牌发出的请求(例如 IM 渠道桥接),`is_internal` 为 `true`;这类调用方的角色是 `internal`,不是管理员。用**个人访问令牌**认证的请求永远不带管理员权限:即使令牌属于管理员,`is_admin` 也是 `False`,`roles` 为空。 +`roles` 保存调用方唯一的系统角色,例如 `("admin",)` 或 `("user",)`。对于 Gateway 自身组件用内部服务令牌发出的请求(例如 IM 渠道桥接),`is_internal` 为 `true`;这类调用方的角色是 `internal`,不是管理员。PAT 请求在到达扩展贡献路由的处理函数之前就会被拒绝,因此这里的辅助函数不会收到 PAT principal。 两个辅助函数都是同步的,在同步和异步处理函数里都能用。它们与框架无关,所以要自己把结果映射成 HTTP 状态码:`None` 映射为 `401`,`PermissionError` 映射为 `403`。 @@ -239,7 +239,8 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: | `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"}` | +| `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}` | ## 常见误区