diff --git a/backend/packages/harness/deerflow/config/skills_config.py b/backend/packages/harness/deerflow/config/skills_config.py index 671b48fde..c306ce747 100644 --- a/backend/packages/harness/deerflow/config/skills_config.py +++ b/backend/packages/harness/deerflow/config/skills_config.py @@ -6,6 +6,13 @@ from pydantic import BaseModel, Field from deerflow.config.runtime_paths import project_root, resolve_path +def _legacy_skills_candidates() -> tuple[Path, ...]: + """Return source-tree skills locations for monorepo compatibility.""" + backend_dir = Path(__file__).resolve().parents[4] + repo_root = backend_dir.parent + return (repo_root / "skills",) + + class SkillsConfig(BaseModel): """Configuration for skills system""" @@ -15,7 +22,7 @@ class SkillsConfig(BaseModel): ) path: str | None = Field( default=None, - description="Path to skills directory. If not specified, defaults to skills under the caller project root.", + description=("Path to skills directory. If not specified, defaults to `skills` under the caller project root, falling back to the legacy repo-root location for monorepo compatibility."), ) container_path: str = Field( default="/mnt/skills", @@ -26,15 +33,30 @@ class SkillsConfig(BaseModel): """ Get the resolved skills directory path. - Returns: - Path to the skills directory + Resolution order: + 1. Explicit ``path`` field + 2. ``DEER_FLOW_SKILLS_PATH`` environment variable + 3. ``skills`` under the caller project root (``project_root()``) + 4. Legacy repo-root candidates for monorepo compatibility (``_legacy_skills_candidates``) + + When none of (3) or (4) exist on disk, the project-root default is returned so callers + can still surface a stable "no skills" location without raising. """ if self.path: # Use configured path (can be absolute or relative to project root) return resolve_path(self.path) if env_path := os.getenv("DEER_FLOW_SKILLS_PATH"): return resolve_path(env_path) - return project_root() / "skills" + + project_default = project_root() / "skills" + if project_default.is_dir(): + return project_default + + for candidate in _legacy_skills_candidates(): + if candidate.is_dir(): + return candidate + + return project_default def get_skill_container_path(self, skill_name: str, category: str = "public") -> str: """ diff --git a/backend/tests/test_runtime_paths.py b/backend/tests/test_runtime_paths.py index aa9e94641..c6a3d9429 100644 --- a/backend/tests/test_runtime_paths.py +++ b/backend/tests/test_runtime_paths.py @@ -7,6 +7,7 @@ import yaml from deerflow.config import app_config as app_config_module from deerflow.config import extensions_config as extensions_config_module +from deerflow.config import skills_config as skills_config_module from deerflow.config.app_config import AppConfig from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.paths import Paths @@ -35,6 +36,7 @@ def test_default_runtime_paths_resolve_from_current_project(tmp_path: Path, monk encoding="utf-8", ) (tmp_path / "extensions_config.json").write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8") + (tmp_path / "skills").mkdir() assert AppConfig.resolve_config_path() == tmp_path / "config.yaml" assert ExtensionsConfig.resolve_config_path() == tmp_path / "extensions_config.json" @@ -121,6 +123,40 @@ def test_app_config_falls_back_to_legacy_when_project_root_lacks_config(tmp_path assert AppConfig.resolve_config_path() == legacy_backend_config +def test_skills_config_falls_back_to_legacy_when_project_root_lacks_skills(tmp_path: Path, monkeypatch): + """When DEER_FLOW_PROJECT_ROOT is unset and cwd has no `skills/`, the legacy + repo-root candidate must be used so monorepo runs (cwd=backend/) keep finding + `/skills` instead of `/backend/skills` (regression test for #2694).""" + _clear_path_env(monkeypatch) + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + + legacy_skills = tmp_path / "legacy-repo" / "skills" + legacy_skills.mkdir(parents=True) + + monkeypatch.setattr( + skills_config_module, + "_legacy_skills_candidates", + lambda: (legacy_skills,), + ) + + assert SkillsConfig().get_skills_path() == legacy_skills + + +def test_skills_config_returns_project_default_when_neither_exists(tmp_path: Path, monkeypatch): + """When nothing exists, fall back to the project-root default path so callers + surface a stable empty location instead of silently picking a stale legacy dir.""" + _clear_path_env(monkeypatch) + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + + monkeypatch.setattr(skills_config_module, "_legacy_skills_candidates", lambda: ()) + + assert SkillsConfig().get_skills_path() == cwd / "skills" + + def test_extensions_config_falls_back_to_legacy_when_project_root_lacks_file(tmp_path: Path, monkeypatch): """ExtensionsConfig should hit the legacy backend/repo-root locations when the caller project root has no extensions_config.json/mcp_config.json.""" diff --git a/backend/tests/test_skills_loader.py b/backend/tests/test_skills_loader.py index 886090f71..55b23c68c 100644 --- a/backend/tests/test_skills_loader.py +++ b/backend/tests/test_skills_loader.py @@ -19,6 +19,7 @@ def test_get_skills_root_path_points_to_current_project_skills(tmp_path: Path, m monkeypatch.delenv("DEER_FLOW_SKILLS_PATH", raising=False) monkeypatch.delenv("DEER_FLOW_PROJECT_ROOT", raising=False) monkeypatch.chdir(tmp_path) + (tmp_path / "skills").mkdir() app_config = SimpleNamespace(skills=SkillsConfig()) path = get_or_new_skill_storage(app_config=app_config).get_skills_root_path()