mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
dede3faed4
commit
d649cc73d2
19
CHANGELOG.md
19
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user