Repository navigation
feat(plugins): full-stack plugin APIs and bookmarks - #5647
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Five unresolved moderate findings affect plugin activation, module loading and validation, route preservation, and action isolation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Adds full-stack plugin APIs, runtime frontend integration, and a standalone SQLite bookmarks extension.
Changes:
- Adds plugin registration, authenticated assets/actions, and model-tool assembly.
- Adds frontend discovery, navigation, pages, and conversation actions.
- Adds the bookmarks example, documentation, fixtures, and tests.
| File | Reviewed change |
|---|---|
README.md |
Documents full-stack plugins. |
frontend/tests/unit/core/extensions/surfaces.dom.test.ts |
Tests surface lifecycle. |
frontend/tests/unit/core/extensions/services.test.ts |
Tests plugin services. |
frontend/tests/unit/core/extensions/pages.test.ts |
Tests plugin page routing. |
frontend/tests/e2e/utils/mock-api.ts |
Mocks plugin discovery. |
frontend/tests/e2e/capability-center.spec.ts |
Tests capability-center behavior. |
frontend/tests/e2e/bookmark-plugin.spec.ts |
Covers the bookmark workflow. |
frontend/src/core/extensions/surfaces.ts |
Mounts and cleans up surfaces. |
frontend/src/core/extensions/services.ts |
Provides plugin services. |
frontend/src/core/extensions/registry.ts |
Loads and validates browser modules; moderate finding (3 votes) remains for configured backend URL and authenticated loading. |
frontend/src/core/extensions/pages.ts |
Builds plugin page routes. |
frontend/src/core/extensions/hooks.ts |
Provides extension queries. |
frontend/src/core/extensions/contracts.ts |
Defines frontend contracts. |
frontend/src/core/extensions/api.ts |
Fetches plugin descriptors. |
frontend/src/components/workspace/workspace-sidebar.tsx |
Adds plugin navigation. |
frontend/src/components/workspace/thread-extension-actions.tsx |
Adds thread actions. |
frontend/src/components/workspace/recent-chat-list.tsx |
Adds sidebar actions. |
frontend/src/components/workspace/plugin-surfaces.tsx |
Renders plugin surfaces. |
frontend/src/components/workspace/plugin-page.tsx |
Hosts plugin pages. |
frontend/src/components/workspace/plugin-navigation.tsx |
Renders plugin links. |
frontend/src/components/workspace/conversation-extension-actions.tsx |
Renders conversation actions; moderate finding (1 vote) remains for plugin error isolation. |
frontend/src/components/workspace/chats/chat-page.tsx |
Adds chat toolbar actions. |
frontend/src/components/workspace/capabilities/extension-gallery.tsx |
Displays extension metadata. |
frontend/src/components/workspace/capabilities/capability-center.tsx |
Adds the Extensions tab. |
frontend/src/app/workspace/workspace-content.tsx |
Bootstraps extensions. |
frontend/src/app/workspace/extensions/[namespace]/[surface_id]/page.tsx |
Adds extension page routing. |
frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx |
Adds actions to custom-agent chats. |
frontend/AGENTS.md |
Documents frontend plugin architecture. |
examples/deerflow-extension-bookmarks/THIRD_PARTY_NOTICES.md |
Provides attribution and licensing. |
examples/deerflow-extension-bookmarks/README.md |
Documents bookmark deployment and use. |
examples/deerflow-extension-bookmarks/pyproject.toml |
Defines example package metadata. |
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs |
Implements bookmark UI/actions; moderate finding (1 vote) remains for preserving custom-agent routes. |
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py |
Implements bookmark backend and tool. |
docs/full-stack-plugins.md |
Documents plugin contracts and lifecycle. |
backend/uv.lock |
Updates locked dependencies. |
backend/tests/test_task_tool_core_logic.py |
Tests delegated extension snapshots. |
backend/tests/test_plugin_tools.py |
Tests model-tool dispatch and policy. |
backend/tests/test_plugin_contributions.py |
Tests plugin registration and behavior. |
backend/tests/test_extension_api_surface.py |
Tests public API exports. |
backend/tests/test_extension_api_contracts.py |
Tests API version contracts. |
backend/tests/test_bookmark_plugin.py |
Tests bookmark persistence and isolation. |
backend/packages/harness/pyproject.toml |
Pins extension API and schema dependencies. |
backend/packages/harness/deerflow/tools/tools.py |
Adds plugin tools to assembly. |
backend/packages/harness/deerflow/tools/builtins/task_tool.py |
Propagates extension snapshots. |
backend/packages/harness/deerflow/extensions/registry.py |
Registers plugins; moderate finding (1 vote) remains for validating module identifiers. |
backend/packages/harness/deerflow/extensions/plugin_tools.py |
Builds guarded model tools. |
backend/packages/harness/deerflow/extensions/AGENTS.md |
Documents backend plugin behavior. |
backend/packages/harness/deerflow/config/plugin_settings.py |
Validates plugin settings. |
backend/packages/extension-api/pyproject.toml |
Bumps the extension API version. |
backend/packages/extension-api/deerflow_extension_api/settings.py |
Defines settings contracts. |
backend/packages/extension-api/deerflow_extension_api/plugins.py |
Defines plugin contribution types. |
backend/packages/extension-api/deerflow_extension_api/contracts.py |
Adds the registry plugin protocol. |
backend/packages/extension-api/deerflow_extension_api/__init__.py |
Exports new API symbols. |
backend/extension_test_fixtures/bookmark_plugin_gateway.py |
Provides the bookmark Gateway fixture. |
backend/app/gateway/routers/plugins.py |
Adds plugin endpoints; moderate finding (1 vote) remains for rechecking activation before serving assets. |
backend/app/gateway/app.py |
Mounts the plugin router. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Thanks @AnnaSuSu - I will take a look. |
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed the head (80702d5). The overall shape is solid: the registry contribution is validated before any bucket mutation with positional rollback, the browser entry path is pinned to the content-addressed same-origin asset route, backend actions/tools are auth-gated with size and timeout bounds, and the bookmark example keeps owner predicates on every SQLite statement (verified by executing the head's plugin suite in a clean worktree: 59/59 focused tests pass; the only 2 local failures are stale dist-metadata artifacts of my shared venv, not PR code). Three suggestions below: (1) a plugin tool name collision currently fails the entire agent tool assembly rather than degrading per-source like every other failure mode in get_available_tools; (2) conversationActions output is unvalidated and runs unguarded during render, so one malformed plugin can take down the whole workspace page even though surfaces are rigorously contained; (3) ~40 new user-facing strings are hardcoded bilingual ternaries instead of going through the i18n locale dictionaries. The known split-origin dynamic-import concern on registry.ts is already covered by an earlier comment, so I won't duplicate it.
| from deerflow.extensions.plugin_tools import build_plugin_tools | ||
|
|
||
| ordinary_tools = loaded_tools + builtin_tools + mcp_tools + acp_tools | ||
| plugin_tools = build_plugin_tools(extensions if extensions is not None else get_agent_build_extensions(), groups=groups, reserved_names={t.name for t in ordinary_tools}) |
There was a problem hiding this comment.
A name collision here raises ValueError out of get_available_tools, which fails the entire tool assembly — every lead and subagent build in the process, not just the offending plugin's tool. Every other failure source in this function degrades per-source (config tool resolution, MCP cache, and ACP setup are each wrapped in try/except that log and continue), and registration can't check host tool names, so this is only reachable when an ordinary/config/MCP tool happens to be named exactly like a generated ext_<ns>_<name>_<digest> — an external MCP server exposing a colliding name would break all chat builds until an operator intervenes. Consider catching the collision at this call site and degrading to log-and-drop of the plugin tool, keeping the strict raise inside build_plugin_tools for plugin-vs-plugin duplicates.
There was a problem hiding this comment.
Fixed in 413a68f. I reused the existing ordinary-first deduplication in get_available_tools: plugin tools are assembled with their internal duplicate checks, then the shared deduplication logs and drops only names already provided by ordinary tools. This preserves unrelated plugin tools, rather than dropping the whole plugin-tool batch on a caught exception. The strict build_plugin_tools duplicate/reserved-name checks remain available to direct callers.
The regression runs the real assembly against collisions from all four ordinary sources (config, built-in, MCP and ACP), checks that the original tool still executes, and verifies that a healthy plugin survives and plugin-vs-plugin duplicates still raise. All four cases failed before the fix and pass now. Full backend testing has no new failing node IDs; the 15 local DNS/SSRF failures also reproduce on clean main.
|
|
||
| return activeFrontendExtensions(entries).map( | ||
| ({ contribution, extension }) => { | ||
| const group = extension.conversationActions?.(t, locale); |
There was a problem hiding this comment.
loadFrontendExtensions deeply validates surfaces (ids, titles, navigation, mount) so a malformed module is contained to its own error state, but conversationActions is only checked to be a function — the returned group shape is unvalidated, and it is executed here during render of every chat header and every sidebar conversation row. A plugin whose conversationActions() throws, or returns { actions: undefined }, makes group.actions.filter(...) throw; the render error then hits the route-level error boundary and takes down the whole workspace page, including conversations unrelated to that plugin. Consider validating the group shape at load time in core/extensions/registry.ts (mirroring the surface checks) or wrapping this per-contribution block in try/catch and skipping the plugin.
There was a problem hiding this comment.
Fixed in 413a68f. Both toolbar and sidebar now use a shared per-contribution resolver that guards the locale-dependent factory and availability callbacks, validates group/action fields and unique IDs, and passes copied, validated values into JSX. Exceptions or malformed values omit only that plugin's group. Unknown icon names also use an own-property lookup so names such as __proto__ cannot supply an invalid React component. Execution failures keep the existing toast path.
The DOM regressions cover throwing factories, malformed groups/actions, unavailable or asynchronous availability, duplicate IDs, getter snapshots and icon fallback while retaining a healthy sibling. Ten cases failed before the fix. Two production-browser cases load a throwing/malformed module alongside a working module and successfully execute the healthy action without losing the conversation. Full frontend unit tests: 1,924 passed; targeted production browser tests: 18 passed.
| <BlocksIcon className="size-4" /> | ||
| {t.capabilities.plugins} | ||
| {locale.startsWith("zh") | ||
| ? "工具与集成" |
There was a problem hiding this comment.
This swaps t.capabilities.plugins for inline locale.startsWith("zh") ? ... : ... strings, and the new extension components (plugin-page, plugin-navigation, extension-gallery, conversation-extension-actions) add ~40 more user-facing strings the same way. The rest of the workspace routes copy through core/i18n/locales/en-US.ts/zh-CN.ts and the Translations type; hardcoding bypasses the dictionary and any future locale tooling. Also note the search box above still uses t.capabilities.searchPlugins for the extensions tab, so the placeholder reads "Search plugins" while browsing extensions. Suggest adding these keys to both locale files for consistency.
There was a problem hiding this comment.
Fixed in 413a68f. Host copy across all six extension UI components now comes from the typed en-US/zh-CN dictionaries, including the surface-error message. The Extensions tab has its own search placeholder and accessible label; Tools & integrations and Skills retain their respective labels. Plugin-authored content stays owned by the independent plugin package.
Production-browser regressions verify both English and Chinese extension labels, search, empty state and unavailable-page navigation. Format, lint, type checks, all 1,924 frontend unit tests and all 18 targeted browser tests pass.
|
Thanks for putting together this first implementation slice. The runtime discovery and end-to-end plugin path are valuable, but I want to call out one important difference from RFC #5510: the RFC proposed a manifest plus packaged static assets ( The inline-module approach is a reasonable way to validate the host contract, but it is too restrictive as the long-term asset model. Requiring every browser module to be self-contained means a non-trivial plugin cannot naturally use code splitting, lazy-loaded chunks, CSS files, images, fonts, WASM, source maps, or assets referenced through I suggest treating I do not think this needs to block proving the current page/action APIs, but the limitation and intended compatibility direction should be explicit before |
|
@zhfeng I agree that the single-file module should not define the long-term packaging model. In 413a68f, the README, public The follow-up is a distinct versioned manifest/asset-root declaration that can coexist with inline v1, while retaining namespaces, page/action interfaces and deployment-controlled installation. The guide calls out manifest allowlisting, traversal/symlink confinement, correct MIME types, revisioned immutable caching with private authentication preserved, and explicit transport negotiation. It also notes that relative chunks need an authenticated loading design for split-origin deployments; simply reusing the current Blob importer would not work, and a same-origin asset proxy is one option to evaluate. These are documented compatibility requirements, not a claim that packaged static assets are implemented by this PR. This keeps the current slice focused on validating page/action APIs without presenting the single-file restriction as the final stable contract. |
|
Thanks for the follow-up. I rechecked One non-blocking edge case remains in The callback contract requires a synchronous boolean, and the bundled bookmark plugin follows it, so I would not block this slice on that edge case. I found no additional blocking issue in this follow-up. |
| const actions = make("div", "", { class: "actions" }); | ||
| // This is navigation metadata, never an authorization grant to a thread. | ||
| const link = make("a", words.open, { | ||
| href: `/workspace/chats/${encodeURIComponent(item.thread_id)}`, |
There was a problem hiding this comment.
[P2] Preserve Custom Agent routing when reopening bookmarks
“Save last answer” is also available on Custom Agent chat pages, but this link always opens /workspace/chats/{id}. A bookmark created in /workspace/agents/{agent}/chats/{id} therefore reopens the default chat page instead of the originating agent-specific page; the default page uses useThreadStream with its default assistantId="lead_agent". Preserve or resolve the originating agent and generate the corresponding agent-specific conversation URL (following the existing pathOfThread routing rules). The toolbar context and stored bookmark currently retain only the thread ID for navigation, so those paths also need to carry or resolve the routing metadata. Please add a regression covering saving and reopening a Custom Agent conversation.
There was a problem hiding this comment.
Fixed in f78c206. The bookmark page now calls a host-provided openConversation(threadId) helper. It retrieves the thread through the authenticated API and uses the existing pathOfThread routing rules to preserve its originating Agent. This also fixes existing bookmarks without a data migration. Failed or aborted metadata lookups do not navigate, and surface cleanup cancels pending navigation.
The production Chromium regression now saves and reopens bookmarks from a default conversation, a custom-Agent toolbar and a custom-Agent sidebar, then sends another message and checks the actual run request uses the original Agent. All three pass. The full frontend unit suite passes (1,934 tests), as do lint, TypeScript, formatting, the production build and all 21 targeted E2E tests.
|
Following up on the rejected async Added unit regressions for rejecting factories, availability callbacks and thenables, plus a production browser regression checking that rejecting async availability produces no page error and leaves healthy actions usable. Validation: 1,934 frontend unit tests and 21 targeted production Chromium E2E tests passed; lint, TypeScript, formatting and production build also passed. |
KevinAndrewDong
left a comment
There was a problem hiding this comment.
task_tool now carries the parent run immutable extension snapshot into both tool assembly and SubagentExecutor, but the durable batch_task path does not preserve the same generation boundary.
SubagentBatchService._execute_item() calls get_available_tools() and constructs SubagentExecutor without extensions=. Outside the run-bound assembly context, tool assembly therefore falls through get_agent_build_extensions() to the process-global snapshot, while the executor later falls back directly to get_loaded_extensions(). An unrelated set_loaded_extensions() or second app instance in the same process can consequently make a Gateway run and its durable-batch delegation use different extension generations.
I do not think LoadedExtensions should be persisted in execution_spec: durable items survive restarts, so a Python snapshot cannot be part of the durable contract. Could the app-scoped SubagentBatchService instead capture app.state.extensions at startup, like its other runtime dependencies, and pass that snapshot to both get_available_tools(..., extensions=...) and SubagentExecutor(..., extensions=...)?
That would keep all items executed by one Gateway instance on its immutable extension generation, while naturally adopting the new generation after a restart.
|
@KevinAndrewDong addressed your review in fc20b5b. The generation mismatch is reproducible: replacing the process snapshot before durable tool assembly selects the replacement plugins; replacing it after assembly can split the tool and executor generations. The fix pins one The regression exercises production tool assembly and the real executor's snapshot selection with a scripted graph. It covers replacements before assembly and before execution, explicit and implicit snapshots, empty snapshots, and recovery under a new worker. A separate Gateway lifespan test checks app-scoped injection. Persistence and model responses in these focused tests are fixtures; no live provider verification is claimed. Validation: 140 focused backend tests and 149 blocking-I/O tests passed, along with format/lint and the Gateway import checks in the full suite. Full backend: 18,029 passed / 15 failed / 206 skipped; all 15 failures reproduce on clean baseline |

Related: #5539 and #5510. This is the first implementation slice, not a claim to complete either RFC.
Why
Python extensions can add backend behavior, but a full feature with its own UI still needs core frontend changes. This PR lets a trusted deployment-installed package provide its own browser page, conversation actions, backend operations and model tools. The standalone bookmark package exercises that path end to end.
What changed
registry.plugin(PluginContribution(...))to extension-api 0.2.2, with source attribution, validation and rollback. Browser modules, async actions and model tools share one plugin namespace./api/plugins. Deployment configuration/CLI owns installation and activation; no online configuration write or override store is added.examples/deerflow-extension-bookmarks: save a visible answer, manage it under My bookmarks, and retrieve personal bookmarks with a read-only model tool. The package owns its SQLite data and browser implementation; the host has no bookmark-specific page or business router.This slice supports pages and conversation actions. Composer selection, right-side panels, hot unloading, a marketplace and built-in runtime configuration editors remain out of scope. Adding this host API requires a DeerFlow update; subsequent compatible plugin UI packages do not require rebuilding the host frontend. Server package/config changes require restart and browsers update on manual refresh.
Experimental browser transport:
BrowserModule(code=...)is an MVP single-file transport, not the stable long-term packaging contract. The plugin guide describes an additive, versioned manifest/static-resource transport, safe asset serving, private immutable caching and authenticated relative-chunk loading. Those packaged-asset APIs are follow-up design, not implemented in this slice; existing inline packages should remain supported.Trust boundary: browser modules run as operator-trusted same-origin code. Shadow DOM scopes CSS, not privileges. Sandboxed iframe isolation and a broader compatibility contract remain design discussions in #5510/#5539. Bookmark persistence is single-host and stores a user-submitted answer copy; tests do not claim production identity or live LLM verification.
Surface area
jsonschemais made a direct harness dependency for tool input validation (already present in the backend dependency graph). Parallel PR feat(skills): add guarded evolution host APIs #5645 also extends extension-api 0.2.2; these features are independent, but exports/version metadata will need alignment when rebasingScreenshots / Recording
Screenshots use synthetic data.
Validation
make formatandmake lint: passed. Focused plugin tool contracts: 7 passed, including collisions with config, built-in, MCP and ACP tools. The four assembly cases failed before the fix; strict plugin-vs-plugin duplicate rejection remains covered. Blocking I/O suite: 149 passed.make test: 18,029 passed / 15 failed / 206 skipped. The failing node IDs exactly match the recorded clean-main full-suite baseline (29d28573, 18,008 passed / 15 failed / 205 skipped). All 15 failing nodes were also rerun on that clean main checkout and reproduced, with 0 new failures. Public test URLs hit the local DNS/SSRF address guard; these are not plugin failures.f78c2061(frontend unchanged in the batch follow-up):pnpm format,pnpm check(lint + TypeScript): passed. Full unit suite: 1,934 passed. The action component regressions cover callback exceptions, malformed groups/actions, availability failures, duplicate IDs, snapshot getters and icon fallback; ten cases failed before the isolation fix. Module-loader coverage includes authenticated requests and configured backend path prefixes.SKIP_ENV_VALIDATION=1 DEER_FLOW_AUTH_DISABLED=1 pnpm exec next build --webpackpassed.bookmark-plugin.spec.ts,capability-center.spec.ts,settings-notification.spec.ts: 21 passed. This covers the real Python bookmark router, SQLite persistence, scripted ToolNode lookup, owner isolation, navigation, reload and deletion; three deliberately broken plugin action groups coexist with a working conversation/action without browser page errors; bookmarks saved from the default conversation, custom-Agent toolbar and custom-Agent sidebar reopen the correct route and send the next request to the original Agent; extension host labels and search are checked in English and Chinese. Authentication and other backend APIs are fixture-controlled. The entire E2E suite was not run; no live model/provider verification is claimed.get_available_tools→ ToolNode owner/group checks passed. E2E workflow setup YAML and all 5 uv/action pin contract tests passed.AI assistance
Tool(s) used: Codex.
How you used it: Developed the host interfaces and bookmark example, refined the scope from local prototypes, wrote and ran tests, and reviewed the resulting diff with AI assistance.