From f8fb8d6fb129a38049e0e86dd099093a45498a37 Mon Sep 17 00:00:00 2001 From: knukn Date: Thu, 2 Apr 2026 15:02:09 +0800 Subject: [PATCH] feat/per agent skill filter (#1650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): 为AgentConfig添加skills字段并更新lead_agent系统提示 在AgentConfig中添加skills字段以支持配置agent可用技能 更新lead_agent的系统提示模板以包含可用技能信息 * fix: resolve agent skill configuration edge cases and add tests * Update backend/packages/harness/deerflow/agents/lead_agent/prompt.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor(agent): address PR review comments for skills configuration - Add detailed docstring to `skills` field in `AgentConfig` to clarify the semantics of `None` vs `[]`. - Add unit tests in `test_custom_agent.py` to verify `load_agent_config()` correctly parses omitted skills and explicit empty lists. - Fix `test_make_lead_agent_empty_skills_passed_correctly` to include `agent_name` in the runtime config, ensuring it exercises the real code path. * docs: 添加关于按代理过滤技能的配置说明 在配置示例文件和文档中添加说明,解释如何通过代理的config.yaml文件限制加载的技能 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/docs/CONFIGURATION.md | 6 ++ .../deerflow/agents/lead_agent/agent.py | 4 +- .../deerflow/agents/lead_agent/prompt.py | 4 + .../harness/deerflow/config/agents_config.py | 5 + backend/tests/test_custom_agent.py | 22 +++++ backend/tests/test_lead_agent_skills.py | 96 +++++++++++++++++++ config.example.yaml | 6 ++ 7 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_lead_agent_skills.py 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 # ============================================================================