mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-16 09:49:10 +00:00
* feat(skills): per-user skill isolation (#2905) Implement user-scoped skill storage that isolates custom skills between users while sharing public skills globally. Key changes: - Add UserScopedSkillStorage class for per-user custom skill directories - Introduce get_or_new_user_skill_storage() factory with user_id context - Auth middleware sets effective_user_id for request-scoped storage - Agent/prompt/middleware now use user-scoped storage and prompt cache - Sandbox mounts user-scoped skill directories for search/read tools - Add validate_skill_file_path() to SkillStorage for path security - Migration script supports --all-users bulk migration - Frontend: add editable field to Skill type, error check in enableSkill - All skill categories can be toggled (custom skills default to enabled) - Update skill-creator SKILL.md with isolation-aware instructions Tests: - Add test_user_scoped_skill_storage.py (new) - Update all existing skill tests for user-scoped storage - Update sandbox, client, and router tests * fix(skills): address second-round PR review feedback (#3889) - P1-1: restrict legacy skill mount to users without custom skills - P1-2: fail-closed for _is_disabled_skill_path (OSError → return True) - P2-1: AND-merge global extensions_config skill disabled state - P2-2: atomic write for _skill_states.json (mkstemp + replace) - P2-3: normalize X-DeerFlow-Owner-User-Id in trusted boundary - P2-4: LRU-bounded _enabled_skills_by_config_cache (OrderedDict, maxsize=256) - P2-5: clear global prompt cache on PUBLIC skill toggle - P2-6: invalidate skill caches on client.update_skill * fix(tests): correct tool policy test after merge * fix(skills): use DEFAULT_SKILLS_CONTAINER_PATH in UserScopedSkillStorage The "/mnt/skills" literal in UserScopedSkillStorage.__init__ triggers test_skill_container_path_defaults::test_mnt_skills_literal_is_owned_by_skill_constants_module on CI. Migrate the default to the existing deerflow.constants constant, matching the pattern already used by LocalSkillStorage, SkillStorage, and the durable/tool_error middlewares. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
332 lines
17 KiB
Python
332 lines
17 KiB
Python
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from deerflow.agents.lead_agent.prompt import get_skills_prompt_section
|
|
from deerflow.config.agents_config import AgentConfig
|
|
from deerflow.skills.types import Skill
|
|
|
|
|
|
class NamedTool:
|
|
def __init__(self, name: str):
|
|
self.name = name
|
|
|
|
|
|
def _make_skill(name: str, allowed_tools: list[str] | None = None) -> Skill:
|
|
return Skill(
|
|
name=name,
|
|
description=f"Description for {name}",
|
|
license="MIT",
|
|
skill_dir=Path(f"/tmp/{name}"),
|
|
skill_file=Path(f"/tmp/{name}/SKILL.md"),
|
|
relative_path=Path(name),
|
|
category="public",
|
|
allowed_tools=allowed_tools,
|
|
enabled=True,
|
|
)
|
|
|
|
|
|
def _mock_skill_storages(monkeypatch, skills):
|
|
"""Patch storage factories and config so get_skills_prompt_section works without config.yaml."""
|
|
from types import SimpleNamespace
|
|
|
|
mock_storage = SimpleNamespace(load_skills=lambda *, enabled_only: skills)
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kwargs: mock_storage)
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda user_id, **kwargs: mock_storage)
|
|
monkeypatch.setattr(
|
|
"deerflow.config.get_app_config",
|
|
lambda: SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
),
|
|
)
|
|
|
|
|
|
def test_get_skills_prompt_section_returns_empty_when_no_skills_match(monkeypatch):
|
|
skills = [_make_skill("skill1"), _make_skill("skill2")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
_mock_skill_storages(monkeypatch, skills)
|
|
|
|
result = get_skills_prompt_section(available_skills={"non_existent_skill"})
|
|
assert result == ""
|
|
|
|
|
|
def test_get_skills_prompt_section_returns_empty_when_available_skills_empty(monkeypatch):
|
|
skills = [_make_skill("skill1"), _make_skill("skill2")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
_mock_skill_storages(monkeypatch, skills)
|
|
|
|
result = get_skills_prompt_section(available_skills=set())
|
|
assert result == ""
|
|
|
|
|
|
def test_get_skills_prompt_section_returns_skills(monkeypatch):
|
|
skills = [_make_skill("skill1"), _make_skill("skill2")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
_mock_skill_storages(monkeypatch, skills)
|
|
|
|
result = get_skills_prompt_section(available_skills={"skill1"})
|
|
assert "skill1" in result
|
|
assert "skill2" not in result
|
|
assert "[built-in]" in result
|
|
|
|
|
|
def test_get_skills_prompt_section_returns_all_when_available_skills_is_none(monkeypatch):
|
|
skills = [_make_skill("skill1"), _make_skill("skill2")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
_mock_skill_storages(monkeypatch, skills)
|
|
|
|
result = get_skills_prompt_section(available_skills=None)
|
|
assert "skill1" in result
|
|
assert "skill2" in result
|
|
|
|
|
|
def test_get_skills_prompt_section_includes_slash_activation_guidance(monkeypatch):
|
|
skills = [_make_skill("data-analysis")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
_mock_skill_storages(monkeypatch, skills)
|
|
|
|
result = get_skills_prompt_section(available_skills={"data-analysis"})
|
|
|
|
assert "Explicit Slash Skill Activation" in result
|
|
assert "The runtime injects the activated skill content" in result
|
|
assert "do not call `read_file` for that SKILL.md again" in result
|
|
|
|
|
|
def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch):
|
|
skills = [_make_skill("skill1")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: skills))
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda user_id, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: skills))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.get_app_config",
|
|
lambda: SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=True),
|
|
),
|
|
)
|
|
|
|
result = get_skills_prompt_section(available_skills=None)
|
|
assert "Skill Self-Evolution" in result
|
|
|
|
|
|
def test_get_skills_prompt_section_includes_self_evolution_rules_without_skills(monkeypatch):
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: [])
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: []))
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda user_id, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: []))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.get_app_config",
|
|
lambda: SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=True),
|
|
),
|
|
)
|
|
|
|
result = get_skills_prompt_section(available_skills=None)
|
|
assert "Skill Self-Evolution" in result
|
|
|
|
|
|
def test_get_skills_prompt_section_cache_respects_skill_evolution_toggle(monkeypatch):
|
|
skills = [_make_skill("skill1")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: skills))
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda user_id, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: skills))
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=True),
|
|
)
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
|
|
enabled_result = get_skills_prompt_section(available_skills=None)
|
|
assert "Skill Self-Evolution" in enabled_result
|
|
|
|
config.skill_evolution.enabled = False
|
|
disabled_result = get_skills_prompt_section(available_skills=None)
|
|
assert "Skill Self-Evolution" not in disabled_result
|
|
|
|
|
|
def test_get_skills_prompt_section_uses_explicit_config_for_enabled_skills(monkeypatch):
|
|
explicit_config = SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/alt-skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/alt-skills")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
)
|
|
|
|
def fail_get_app_config():
|
|
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
|
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: [_make_skill("global-skill")])
|
|
monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config)
|
|
monkeypatch.setattr(
|
|
"deerflow.agents.lead_agent.prompt.get_or_new_skill_storage",
|
|
lambda app_config=None, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: [_make_skill("explicit-skill")] if app_config is explicit_config else []),
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage",
|
|
lambda user_id, app_config=None, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: [_make_skill("explicit-skill")] if app_config is explicit_config else []),
|
|
)
|
|
|
|
result = get_skills_prompt_section(app_config=explicit_config)
|
|
|
|
assert "explicit-skill" in result
|
|
assert "global-skill" not in result
|
|
|
|
|
|
def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
|
|
from deerflow.agents.lead_agent import agent as lead_agent_module
|
|
|
|
# Mock dependencies
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: MagicMock())
|
|
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
|
|
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "_load_enabled_skills_for_tool_policy", lambda available_skills, *, app_config, user_id=None: [])
|
|
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
|
|
class MockModelConfig:
|
|
supports_thinking = False
|
|
|
|
mock_app_config = MagicMock()
|
|
mock_app_config.get_model_config.return_value = MockModelConfig()
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
|
|
|
|
captured_skills = []
|
|
|
|
def mock_apply_prompt_template(**kwargs):
|
|
captured_skills.append(kwargs.get("available_skills"))
|
|
return "mock_prompt"
|
|
|
|
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", mock_apply_prompt_template)
|
|
|
|
# Case 1: Empty skills list
|
|
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=[]))
|
|
lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
assert captured_skills[-1] == set()
|
|
|
|
# Case 2: None skills list
|
|
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=None))
|
|
lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
assert captured_skills[-1] is None
|
|
|
|
# Case 3: Some skills list
|
|
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=["skill1"]))
|
|
lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
assert captured_skills[-1] == {"skill1"}
|
|
|
|
|
|
def test_make_lead_agent_filters_tools_from_available_skills(monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
|
|
from deerflow.agents.lead_agent import agent as lead_agent_module
|
|
|
|
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
|
|
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "mock_prompt")
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=["restricted", "legacy"]))
|
|
monkeypatch.setattr(lead_agent_module, "_load_enabled_skills_for_tool_policy", lambda available_skills, *, app_config, user_id=None: [_make_skill("restricted", ["read_file", "web_search"]), _make_skill("legacy", None)])
|
|
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [NamedTool("bash"), NamedTool("read_file"), NamedTool("web_search")])
|
|
|
|
mock_app_config = MagicMock()
|
|
mock_app_config.get_model_config.return_value = SimpleNamespace(supports_thinking=False, supports_vision=False)
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
|
|
|
|
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
|
|
assert [tool.name for tool in agent_kwargs["tools"]] == ["read_file", "web_search"]
|
|
|
|
|
|
def test_skill_allowed_tools_default_does_not_preserve_read_file_for_subagents():
|
|
from deerflow.skills.tool_policy import filter_tools_by_skill_allowed_tools
|
|
|
|
tools = [NamedTool("read_file"), NamedTool("dataagent_query"), NamedTool("bash")]
|
|
skills = [_make_skill("data-query", ["dataagent_query"])]
|
|
|
|
filtered = filter_tools_by_skill_allowed_tools(tools, skills)
|
|
|
|
assert [tool.name for tool in filtered] == ["dataagent_query"]
|
|
|
|
|
|
def test_make_lead_agent_all_legacy_skills_preserve_all_tools(monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
|
|
from deerflow.agents.lead_agent import agent as lead_agent_module
|
|
|
|
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
|
|
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "mock_prompt")
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=None))
|
|
monkeypatch.setattr(lead_agent_module, "_load_enabled_skills_for_tool_policy", lambda available_skills, *, app_config, user_id=None: [_make_skill("legacy", None)])
|
|
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [NamedTool("bash"), NamedTool("read_file")])
|
|
|
|
mock_app_config = MagicMock()
|
|
mock_app_config.get_model_config.return_value = SimpleNamespace(supports_thinking=False, supports_vision=False)
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
|
|
|
|
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
|
|
assert [tool.name for tool in agent_kwargs["tools"]] == ["bash", "read_file", "update_agent"]
|
|
|
|
|
|
def test_make_lead_agent_enforces_allowed_tools_when_skill_cache_is_cold(monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
|
|
from deerflow.agents.lead_agent import agent as lead_agent_module
|
|
from deerflow.agents.lead_agent import prompt as prompt_module
|
|
|
|
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
|
|
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "mock_prompt")
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=["restricted"]))
|
|
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [NamedTool("bash"), NamedTool("read_file"), NamedTool("web_search")])
|
|
|
|
mock_app_config = MagicMock()
|
|
mock_app_config.get_model_config.return_value = SimpleNamespace(supports_thinking=False, supports_vision=False)
|
|
mock_storage = SimpleNamespace(load_skills=lambda *, enabled_only: [_make_skill("restricted", ["read_file"])])
|
|
|
|
with prompt_module._enabled_skills_lock:
|
|
prompt_module._enabled_skills_cache = None
|
|
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None, **kwargs: mock_storage)
|
|
monkeypatch.setattr(prompt_module, "get_or_new_user_skill_storage", lambda user_id, app_config=None, **kwargs: mock_storage)
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
|
|
|
|
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
|
|
assert [tool.name for tool in agent_kwargs["tools"]] == ["read_file"]
|
|
|
|
|
|
def test_make_lead_agent_fails_closed_when_skill_policy_load_fails(monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from deerflow.agents.lead_agent import agent as lead_agent_module
|
|
from deerflow.agents.lead_agent import prompt as prompt_module
|
|
|
|
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
|
|
create_agent_mock = MagicMock()
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", create_agent_mock)
|
|
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=["restricted"]))
|
|
|
|
mock_app_config = MagicMock()
|
|
mock_app_config.get_model_config.return_value = SimpleNamespace(supports_thinking=False, supports_vision=False)
|
|
|
|
def fail_storage(*args, **kwargs):
|
|
raise RuntimeError("skill storage unavailable")
|
|
|
|
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", fail_storage)
|
|
monkeypatch.setattr(prompt_module, "get_or_new_user_skill_storage", fail_storage)
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
|
|
|
|
with pytest.raises(RuntimeError, match="skill storage unavailable"):
|
|
lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
|
|
create_agent_mock.assert_not_called()
|