fix(skills): create a missing extensions_config.json in the project root (#7048)

* fix(skills): create a missing extensions_config.json in the project root

When no extensions config resolved, the skill toggle created one at
Path.cwd().parent while the MCP writers used project_root(). Unless the
Gateway runs from backend/ with the project root at its parent, the
toggle's file landed outside the resolver's search path: the toggle was
lost on reload and the next MCP edit created a second file.

Add ExtensionsConfig.default_config_path() (the project root, the first
location resolve_config_path searches) and use it from the skill toggle
and both MCP create paths, so a created file is always the one the next
resolution reads back.

* docs(changelog): reference #7048 for the extensions config create path

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-21 23:43:49 +09:00 committed by GitHub
parent dede3faed4
commit d649cc73d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 137 additions and 13 deletions

View File

@ -2086,6 +2086,7 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
`channel_conversations` / `channel_thread_bindings`: the instance that loses
adopts the winner's thread and deletes its own empty one, and `/new` still
rebinds. (issue #7103, [#7104])
- **skills:** A skill the agent creates, edits, patches or deletes with the
`skill_manage` tool now reaches every Gateway process sharing the home
volume, as a change made through the skills API already did. The tool
@ -2097,12 +2098,14 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
this or a peer process serving the stale list. When the marker cannot be
written, the tool reports that the change was applied but other processes
were not notified, instead of reading as a failed write. ([#7099])
- **provisioner:** When two provisioner replicas start together and
`K8S_NAMESPACE` does not exist yet, the one that loses the create race no
longer crashes at startup: its `409 AlreadyExists` now counts as the
namespace existing, as the Pod and Service creates already did. Helm
deployments, where the provisioner runs inside the namespace it manages,
never took this path. ([#7099])
- **projects:** A shelf upload or save-from-thread into a project the caller
cannot write no longer leaves a directory behind or answers `500`. Both
routes staged the body under `users/{user_id}/projects/{project_id}/` before
@ -2112,6 +2115,18 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
now check for an owned, active project first and answer the same `404`
without writing; the shelf insert still re-checks under the project row lock.
([#7040])
- **skills:** Toggling a skill when no `extensions_config.json` exists yet now
creates the file in the project root (`DEER_FLOW_PROJECT_ROOT`, else the
working directory), where the MCP editor already creates it and where config
resolution looks first. The toggle used the working directory's parent
instead: with the Gateway started from anywhere but `backend/`, the new file
landed outside the search path, so the toggle was silently lost after reload
and the next MCP edit created a second file. Both routers now share
`ExtensionsConfig.default_config_path()`. Docker and `make dev` were not
affected: they set the config path, or a project root equal to that parent.
([#7048])
- **workflows:** A script workflow waiting on a long child Agent run no longer
fails with `Worker turn limit` after about 209 seconds regardless of
`timeout_seconds`. The supervisor re-sent an empty frame to the QuickJS worker
@ -2120,7 +2135,8 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
and the supervisor then waits on the pending calls locally (still checking
cancellation, faults and worker exits every 20 ms) without exchanging frames,
so idle waits no longer consume turns and `timeout_seconds` is the only wall-clock bound;
scripts that spin the job queue still hit the turn cap. (issue #7022, [#7024])
scripts that spin the job queue still hit the turn cap. (issue [#7022], [#7024])
- **browser:** `browser_screenshot` no longer replaces a file that already
exists in the thread's outputs directory. A screenshot saved as
`report.png` used to overwrite the user's own `report.png`, an earlier
@ -12930,6 +12946,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7035]: https://github.com/bytedance/deer-flow/pull/7035
[#7039]: https://github.com/bytedance/deer-flow/pull/7039
[#7040]: https://github.com/bytedance/deer-flow/pull/7040
[#7048]: https://github.com/bytedance/deer-flow/pull/7048
[#7042]: https://github.com/bytedance/deer-flow/pull/7042
[#7051]: https://github.com/bytedance/deer-flow/pull/7051
[#7053]: https://github.com/bytedance/deer-flow/pull/7053

View File

@ -1631,29 +1631,42 @@
两个实例为同一会话的第一条消息各自创建线程时,绑定现在以“不存在才插入”的方式在
`channel_conversations` / `channel_thread_bindings` 中认领:落败的实例改用胜出者的线程
并删除自己创建的空线程,`/new` 仍会重新绑定到新线程。 (issue #7103, [#7104])
- **技能:** agent 通过 `skill_manage` 工具创建、编辑、修补或删除的技能,现在会像通过
技能 API 所做的修改一样,传达到共享同一 home 卷的每个 Gateway 进程。此前该工具只刷新
本进程的技能提示缓存,对端副本会继续为该用户渲染旧的技能列表;现在它在本地刷新之后、
于事件循环之外发布同一个按用户限定的共享缓存重置标记。写入、历史记录、刷新与标记作为
一个整体完成,因此写入途中被取消的运行不再让本进程或对端继续使用过期列表。标记无法写入
时,工具会报告修改已生效但未能通知其他进程,而不会让人误以为写入失败。 ([#7099])
- **provisioner:** 两个 provisioner 副本在 `K8S_NAMESPACE` 尚不存在时同时启动,输掉
创建竞争的一方不再在启动时崩溃:它收到的 `409 AlreadyExists` 现在视为命名空间已存在,
与 Pod 和 Service 的创建一致。Helm 部署中 provisioner 运行在其管理的命名空间内,从不
经过这条路径。 ([#7099])
- **项目:** 向调用者无权写入的项目上传书架文档或从对话保存文件时,不再遗留目录,
也不再返回 `500`。这两个路由此前先把请求体暂存到
`users/{user_id}/projects/{project_id}/` 下才检查项目,因此不存在、属于他人或已归档
的 id 会留下一个回收站对账永远不会清理的空 `{project_id}/documents/` 目录,而超过单个
路径分量长度的 id 会因 `ENAMETOOLONG` 失败。现在会先确认项目归调用者所有且处于活跃
状态,不写任何内容即返回同样的 `404`;书架插入仍会在项目行锁下再次检查。 ([#7040])
- **skills:** 在尚无 `extensions_config.json` 时切换技能开关,现在会在项目根目录
(`DEER_FLOW_PROJECT_ROOT`,未设置时为工作目录)创建该文件,这与 MCP 编辑器已有的
创建位置一致,也是配置解析最先查找的位置。此前技能开关使用工作目录的父目录:当
Gateway 不是从 `backend/` 启动时,新文件会落在查找路径之外,重新加载后开关被静默
丢弃,之后的 MCP 编辑还会再创建第二个文件。现在两个路由共用
`ExtensionsConfig.default_config_path()`。Docker 与 `make dev` 不受影响:它们设置了
配置路径,或者项目根目录恰好就是那个父目录。 ([#7048])
- **工作流:** 脚本工作流等待耗时较长的子 Agent 运行时,不再无视 `timeout_seconds`,
在约 209 秒后因 `Worker turn limit` 失败。此前只要步骤尚未完成,监督器就会在每次
20 ms 轮询时向 QuickJS worker 重发一个空帧,每次往返都会计入 10,000 次交互上限。
现在 worker 会报告其作业队列何时已排空,随后监督器在本地等待尚未完成的调用,不再
交换帧(仍每隔 20 ms 检查取消、故障和 worker 退出),因此空闲等待不再消耗交互次数,
`timeout_seconds` 成为唯一的实际耗时上限;持续驱动作业队列的脚本仍受交互次数上限
约束。(issue #7022, [#7024])
约束。(issue [#7022], [#7024])
- **浏览器:** `browser_screenshot` 不再替换线程 outputs 目录中已存在的文件。此前以
`report.png` 保存的截图会覆盖用户自己的 `report.png`、更早的截图,或同一秒内生成的
另一张默认命名截图;现在会像 `web_capture` 一样改用下一个空闲名称(`report-1.png`、
@ -10312,6 +10325,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7035]: https://github.com/bytedance/deer-flow/pull/7035
[#7039]: https://github.com/bytedance/deer-flow/pull/7039
[#7040]: https://github.com/bytedance/deer-flow/pull/7040
[#7048]: https://github.com/bytedance/deer-flow/pull/7048
[#7042]: https://github.com/bytedance/deer-flow/pull/7042
[#7051]: https://github.com/bytedance/deer-flow/pull/7051
[#7053]: https://github.com/bytedance/deer-flow/pull/7053

View File

@ -29,7 +29,6 @@ from deerflow.config.extensions_config import (
reload_extensions_config,
validate_raw_extensions_config,
)
from deerflow.config.runtime_paths import project_root
from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT
from deerflow.mcp.cache import (
McpReconciliationPending,
@ -1303,7 +1302,7 @@ def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
# same sidecar path for the complete read-modify-write cycle.
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
config_path = project_root() / "extensions_config.json"
config_path = ExtensionsConfig.default_config_path()
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
with extensions_config_write_lock, extensions_config_file_lock(config_path):
@ -1401,7 +1400,7 @@ def _mcp_config_path(*, create: bool) -> Path:
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
if create:
config_path = project_root() / "extensions_config.json"
config_path = ExtensionsConfig.default_config_path()
logger.info("No existing extensions config found. Creating new config at: %s", config_path)
else:
raise HTTPException(

View File

@ -1056,7 +1056,7 @@ def _write_extensions_skill_state(
projection_update = skill_projection_mutation(storage, "public", remove_names=removal_names) if rebuild_public_projection and isinstance(storage, LocalSkillStorage) else nullcontext()
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
config_path = Path.cwd().parent / "extensions_config.json"
config_path = ExtensionsConfig.default_config_path()
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
from deerflow.skills.mutations.guard import managed_global_state_write

View File

@ -112,8 +112,10 @@ can become stale or inaccessible when a host editor replaces a file on save.
Configuration priority:
1. Explicit `config_path` argument
2. `DEER_FLOW_EXTENSIONS_CONFIG_PATH` environment variable
3. `extensions_config.json` in current directory (backend/)
4. `extensions_config.json` in parent directory (project root - **recommended location**)
3. `extensions_config.json`, then legacy `mcp_config.json`, in the project root (`DEER_FLOW_PROJECT_ROOT`, else the current directory) - **recommended location**
4. Legacy `backend/` and repository-root copies, located from the harness source tree
When the search finds nothing, the Gateway skill toggle and MCP writers create the file at `ExtensionsConfig.default_config_path()` (the project root, step 3), so the next resolution reads back what they wrote; `DeerFlowClient` raises instead of creating one.
Extensions are optional only in the fallback *search* mode (priority 3-4 above): `ExtensionsConfig.resolve_config_path()` returns `None` when neither an explicit `config_path` nor `DEER_FLOW_EXTENSIONS_CONFIG_PATH` is given and the search locations find nothing. An explicit `config_path` argument or a set `DEER_FLOW_EXTENSIONS_CONFIG_PATH` (priority 1-2) is an operator assertion that one particular file must be used, so a missing file in either of those modes raises `FileNotFoundError` instead — including when the file existed earlier and has since been deleted. The MCP tools cache's staleness check (`deerflow.mcp.cache._resolve_config_path`) is a narrow, deliberate exception to that rule: it catches that `FileNotFoundError` locally and treats it as "unconfigured" so a previously-valid config disappearing mid-run degrades the cache to serving its last-known-good tools instead of raising out of a per-request hot path (see the MCP System section below).

View File

@ -18,7 +18,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validat
from deerflow.config._boolean_guards import reject_boolean
from deerflow.config.file_signature import ConfigSignature, get_config_signature, read_config_with_signature
from deerflow.config.runtime_paths import existing_project_file
from deerflow.config.runtime_paths import existing_project_file, project_root
from deerflow.constants import (
DEFAULT_MCP_SESSION_INIT_TIMEOUT,
MCP_TASK_NAME_MAX_LENGTH,
@ -515,6 +515,18 @@ class ExtensionsConfig(BaseModel):
# return None rather than raising.
return None
@classmethod
def default_config_path(cls) -> Path:
"""Where a writer creates ``extensions_config.json`` when none resolves.
The project root is the first location the fallback search of
:meth:`resolve_config_path` checks, so a file created here is the one
the next resolution — and every other writer — reads back. Only for
writers that create the file after ``resolve_config_path()`` returned
``None``; the explicit and env-var modes never reach it.
"""
return project_root() / "extensions_config.json"
@classmethod
def from_file(cls, config_path: str | None = None) -> "ExtensionsConfig":
"""Load extensions config from JSON file.

View File

@ -1845,7 +1845,7 @@ async def test_mcp_create_without_existing_config_uses_resolvable_project_root(o
return expected_path if expected_path.exists() else None
monkeypatch.chdir(working_dir)
monkeypatch.setattr(mcp_router, "project_root", lambda: project_dir, raising=False)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_dir))
monkeypatch.setattr(mcp_router.ExtensionsConfig, "resolve_config_path", resolve_created_config)
monkeypatch.setattr(mcp_router, "get_extensions_config", lambda: SimpleNamespace(skills={}))
monkeypatch.setattr(mcp_router, "reload_extensions_config", lambda: ExtensionsConfig.from_file(str(expected_path)))
@ -1863,6 +1863,58 @@ async def test_mcp_create_without_existing_config_uses_resolvable_project_root(o
assert not (working_dir.parent / "extensions_config.json").exists()
@pytest.mark.asyncio
@pytest.mark.parametrize("first_writer", ["skill", "mcp"])
async def test_skill_toggle_and_mcp_create_share_one_new_config(first_writer, monkeypatch, tmp_path):
"""With no config anywhere, the first skill toggle and the first MCP edit
must create and then reuse ONE file that resolution reads back."""
from app.gateway.routers import skills as skills_router
from deerflow.config import extensions_config as extensions_config_module
project_dir = tmp_path / "project"
working_dir = tmp_path / "runtime" / "nested"
project_dir.mkdir()
working_dir.mkdir(parents=True)
for name in ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_EXTENSIONS_CONFIG_PATH", "DEER_FLOW_HOME", "DEER_FLOW_SKILLS_PATH"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_dir))
monkeypatch.chdir(working_dir)
# Real resolver, but its legacy search anchored on an empty fake checkout
# so a developer's own extensions_config.json can never be found or written.
fake_module_file = tmp_path / "fake-backend" / "packages" / "harness" / "deerflow" / "config" / "extensions_config.py"
fake_module_file.parent.mkdir(parents=True)
fake_module_file.write_text("", encoding="utf-8")
monkeypatch.setattr(extensions_config_module, "__file__", str(fake_module_file))
assert ExtensionsConfig.resolve_config_path() is None
monkeypatch.setattr(skills_router, "get_extensions_config", lambda: ExtensionsConfig())
monkeypatch.setattr(skills_router, "reload_extensions_config", lambda: None)
monkeypatch.setattr(mcp_router, "get_extensions_config", lambda: SimpleNamespace(skills={}))
monkeypatch.setattr(mcp_router, "reload_extensions_config", lambda: ExtensionsConfig.from_file())
monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", lambda: None)
async def _create_mcp_server() -> None:
body = McpConfigUpdateRequest(mcp_servers={"added": McpServerConfigResponse(command="npx")})
await create_mcp_servers(_request_with_role("admin"), body)
def _toggle_skill() -> None:
skills_router._write_extensions_skill_state(None, "demo-skill", False, rebuild_public_projection=False)
if first_writer == "skill":
_toggle_skill()
await _create_mcp_server()
else:
await _create_mcp_server()
_toggle_skill()
created = project_dir / "extensions_config.json"
assert ExtensionsConfig.resolve_config_path() == created
persisted = json.loads(created.read_text(encoding="utf-8"))
assert persisted["skills"] == {"demo-skill": {"enabled": False}}
assert set(persisted["mcpServers"]) == {"added"}
assert sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("extensions_config.json")) == ["project/extensions_config.json"]
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["bulk", "disable", "delete"])
async def test_frozen_task_server_mutation_rejected_before_persistence(operation, monkeypatch, tmp_path):

View File

@ -181,6 +181,30 @@ def test_extensions_config_falls_back_to_legacy_when_project_root_lacks_file(tmp
assert ExtensionsConfig.resolve_config_path() == legacy_extensions
def test_extensions_config_default_path_is_the_first_search_location(tmp_path: Path, monkeypatch):
"""A writer that finds no extensions config must create it where the
search looks first, so the next resolution reads the file it wrote."""
_clear_path_env(monkeypatch)
project = tmp_path / "project"
project.mkdir()
cwd = tmp_path / "runtime" / "backend"
cwd.mkdir(parents=True)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project))
monkeypatch.chdir(cwd)
fake_paths_module_file = tmp_path / "fake-backend-empty" / "packages" / "harness" / "deerflow" / "config" / "extensions_config.py"
fake_paths_module_file.parent.mkdir(parents=True)
fake_paths_module_file.write_text("", encoding="utf-8")
monkeypatch.setattr(extensions_config_module, "__file__", str(fake_paths_module_file))
assert ExtensionsConfig.resolve_config_path() is None
created = ExtensionsConfig.default_config_path()
assert created == project / "extensions_config.json"
created.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
assert ExtensionsConfig.resolve_config_path() == created
def test_extensions_config_explicit_path_missing_file_raises(tmp_path: Path, monkeypatch):
"""An explicit ``config_path`` argument pointing at a file that does not
exist (e.g. it existed earlier and was deleted) must raise

View File

@ -1160,12 +1160,15 @@ def test_public_skill_toggle_clears_all_users_cache(monkeypatch, tmp_path):
assert persisted["skills"]["public-skill"]["enabled"] is False
def test_public_skill_toggle_creates_missing_extensions_config(monkeypatch, tmp_path):
def test_public_skill_toggle_creates_missing_extensions_config_at_project_root(monkeypatch, tmp_path):
from deerflow.config.extensions_config import ExtensionsConfig
backend_dir = tmp_path / "backend"
backend_dir.mkdir()
# The working directory's parent is not the project root: the new file
# must land where resolution looks first, not next to the cwd.
backend_dir = tmp_path / "runtime" / "backend"
backend_dir.mkdir(parents=True)
monkeypatch.chdir(backend_dir)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
config_path = tmp_path / "extensions_config.json"
def _load_skills(*, enabled_only: bool):
@ -1201,6 +1204,7 @@ def test_public_skill_toggle_creates_missing_extensions_config(monkeypatch, tmp_
assert json.loads(config_path.read_text(encoding="utf-8")) == {
"skills": {"public-skill": {"enabled": False}},
}
assert not (backend_dir.parent / "extensions_config.json").exists()
def test_public_skill_toggle_rebuilds_projection_before_response(monkeypatch, tmp_path):