diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 63ccc8d28..63791b820 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -278,6 +278,12 @@ skills: - Skills are automatically discovered and loaded - Available in both local and Docker sandbox via path mapping +**Per-Agent Skill Filtering**: +Custom agents can restrict which skills they load by defining a `skills` field in their `config.yaml` (located at `workspace/agents//config.yaml`): +- **Omitted or `null`**: Loads all globally enabled skills (default fallback). +- **`[]` (empty list)**: Disables all skills for this specific agent. +- **`["skill-name"]`**: Loads only the explicitly specified skills. + ### Title Generation Automatic conversation title generation: diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index fe743e448..c7e9d77b1 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -343,6 +343,8 @@ def make_lead_agent(config: RunnableConfig): model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort), tools=get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled), middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name), - system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name), + system_prompt=apply_prompt_template( + subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name, available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None + ), state_schema=ThreadState, ) diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 6329fd193..0ce6ff899 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -402,6 +402,10 @@ def get_skills_prompt_section(available_skills: set[str] | None = None) -> str: if available_skills is not None: skills = [skill for skill in skills if skill.name in available_skills] + # Check again after filtering + if not skills: + return "" + skill_items = "\n".join( f" \n {skill.name}\n {skill.description}\n {skill.get_container_file_path(container_base_path)}\n " for skill in skills ) diff --git a/backend/packages/harness/deerflow/config/agents_config.py b/backend/packages/harness/deerflow/config/agents_config.py index c35308df8..baf47fc6b 100644 --- a/backend/packages/harness/deerflow/config/agents_config.py +++ b/backend/packages/harness/deerflow/config/agents_config.py @@ -22,6 +22,11 @@ class AgentConfig(BaseModel): description: str = "" model: str | None = None tool_groups: list[str] | None = None + # skills controls which skills are loaded into the agent's prompt: + # - None (or omitted): load all enabled skills (default fallback behavior) + # - [] (explicit empty list): disable all skills + # - ["skill1", "skill2"]: load only the specified skills + skills: list[str] | None = None def load_agent_config(name: str | None) -> AgentConfig | None: diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index e2b4b631e..c97cb4789 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -164,6 +164,28 @@ class TestLoadAgentConfig: assert cfg.tool_groups == ["file:read", "file:write"] + def test_load_config_with_skills_empty_list(self, tmp_path): + config_dict = {"name": "no-skills-agent", "skills": []} + _write_agent(tmp_path, "no-skills-agent", config_dict) + + with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from deerflow.config.agents_config import load_agent_config + + cfg = load_agent_config("no-skills-agent") + + assert cfg.skills == [] + + def test_load_config_with_skills_omitted(self, tmp_path): + config_dict = {"name": "default-skills-agent"} + _write_agent(tmp_path, "default-skills-agent", config_dict) + + with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from deerflow.config.agents_config import load_agent_config + + cfg = load_agent_config("default-skills-agent") + + assert cfg.skills is None + def test_legacy_prompt_file_field_ignored(self, tmp_path): """Unknown fields like the old prompt_file should be silently ignored.""" agent_dir = tmp_path / "agents" / "legacy-agent" diff --git a/backend/tests/test_lead_agent_skills.py b/backend/tests/test_lead_agent_skills.py new file mode 100644 index 000000000..37a6dbff8 --- /dev/null +++ b/backend/tests/test_lead_agent_skills.py @@ -0,0 +1,96 @@ +from pathlib import Path + +from deerflow.agents.lead_agent.prompt import get_skills_prompt_section +from deerflow.config.agents_config import AgentConfig +from deerflow.skills.types import Skill + + +def _make_skill(name: str) -> 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", + enabled=True, + ) + + +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.load_skills", lambda enabled_only: 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.load_skills", lambda enabled_only: 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.load_skills", lambda enabled_only: skills) + + result = get_skills_prompt_section(available_skills={"skill1"}) + assert "skill1" in result + assert "skill2" not 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.load_skills", lambda enabled_only: skills) + + result = get_skills_prompt_section(available_skills=None) + assert "skill1" in result + assert "skill2" 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: "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, "_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"} diff --git a/config.example.yaml b/config.example.yaml index 0bff5d6ad..813da9749 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -484,6 +484,12 @@ skills: # Default: /mnt/skills container_path: /mnt/skills +# Note: To restrict which skills are loaded for a specific custom agent, +# define a `skills` list in that agent's `config.yaml` (e.g. `agents/my-agent/config.yaml`): +# - Omitted or null: load all globally enabled skills (default) +# - []: disable all skills for this agent +# - ["skill-name"]: load only specific skills + # ============================================================================ # Title Generation Configuration # ============================================================================