mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 02:46:02 +00:00
Replace the full-metadata <available_skills> system-prompt block with a compact <skill_index> (names only) and an on-demand describe_skill tool when skills.deferred_discovery: true (default: false / backward compat). New modules: - skills/catalog.py — SkillCatalog (immutable, searchable; select: has no cap, keyword/prefix search caps at MAX_RESULTS=5) - skills/describe.py — build_describe_skill_tool(catalog) closure; build_skill_search_setup() wires SkillSearchSetup into both the LangGraph agent factory (agent.py) and DeerFlowClient (client.py) Changes: - Skill @dataclass(frozen=True); allowed_tools/required_secrets list→tuple - Skill First prompt line gated on skill_names (deferred vs legacy wording) - get_skills_prompt_section: short-circuit storage on deferred path; merge user_id (upstream) + skill_names (this PR) params - describe_skill tool parameter named "name" (matches prompt wording) - select: branch removes [:MAX_RESULTS] cap (exact request, not ranking) - AGENTS.md: document deferred_discovery config field + new modules Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
475 lines
25 KiB
Python
475 lines
25 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=tuple(allowed_tools) if allowed_tools is not None else None,
|
|
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_get_skills_prompt_section_deferred_path_uses_skill_index(monkeypatch):
|
|
"""When skill_names is provided, renders <skill_index> instead of <available_skills>."""
|
|
skills = [_make_skill("data-analysis"), _make_skill("deep-research")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
monkeypatch.setattr(
|
|
"deerflow.config.get_app_config",
|
|
lambda: SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/skills"),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
),
|
|
)
|
|
# Deferred path never touches storage, but patch defensively in case of fallback.
|
|
_null_storage = SimpleNamespace(load_skills=lambda *, enabled_only: [])
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kw: _null_storage)
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda *a, **kw: _null_storage)
|
|
|
|
# Deferred path: skill_names provided
|
|
result = get_skills_prompt_section(
|
|
available_skills=None,
|
|
skill_names=frozenset({"data-analysis", "deep-research"}),
|
|
)
|
|
assert "<skill_index>" in result
|
|
assert "data-analysis" in result
|
|
assert "deep-research" in result
|
|
assert "describe_skill" in result
|
|
# Must NOT contain legacy full-metadata format
|
|
assert "<available_skills>" not in result
|
|
assert "Description for data-analysis" not in result # descriptions excluded from index
|
|
|
|
|
|
def test_get_skills_prompt_section_legacy_path_when_skill_names_none(monkeypatch):
|
|
"""When skill_names is None, falls back to legacy <available_skills> rendering."""
|
|
skills = [_make_skill("data-analysis")]
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
|
monkeypatch.setattr(
|
|
"deerflow.config.get_app_config",
|
|
lambda: SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/skills"),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
),
|
|
)
|
|
# Legacy path loads ALL skills (enabled + disabled) from storage for the disabled-skills section.
|
|
_storage = SimpleNamespace(load_skills=lambda *, enabled_only: skills)
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kw: _storage)
|
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda *a, **kw: _storage)
|
|
|
|
# Legacy path: skill_names not provided
|
|
result = get_skills_prompt_section(available_skills=None)
|
|
assert "<available_skills>" in result
|
|
assert "data-analysis" in result
|
|
assert "Description for data-analysis" in result
|
|
assert "<skill_index>" not in result
|
|
assert "describe_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)
|
|
mock_app_config.tool_search.enabled = True
|
|
mock_app_config.skills.container_path = "/mnt/skills"
|
|
mock_app_config.skills.deferred_discovery = True # describe_skill will be added
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
|
|
|
|
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
|
|
# With skills.deferred_discovery=True, describe_skill is added to tools
|
|
tool_names = [tool.name for tool in agent_kwargs["tools"]]
|
|
assert "read_file" in tool_names
|
|
assert "describe_skill" in tool_names
|
|
|
|
|
|
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"}})
|
|
|
|
# describe_skill is appended after skill-allowed-tools filtering (it bypasses policy).
|
|
tool_names = [tool.name for tool in agent_kwargs["tools"]]
|
|
assert tool_names == ["bash", "read_file", "update_agent", "describe_skill"]
|
|
|
|
|
|
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"}})
|
|
|
|
# describe_skill is appended after skill-allowed-tools filtering (it bypasses policy).
|
|
tool_names = [tool.name for tool in agent_kwargs["tools"]]
|
|
assert tool_names == ["read_file", "describe_skill"]
|
|
|
|
|
|
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()
|
|
|
|
|
|
def test_make_lead_agent_drops_update_agent_on_github_channel(monkeypatch):
|
|
"""Webhook-channel runs MUST NOT see ``update_agent``.
|
|
|
|
The lead-agent prompt actively encourages the model to call
|
|
``update_agent`` when the user asks it to change its own skills /
|
|
tool_groups / SOUL.md. On the GitHub channel, the "user" is whichever
|
|
external commenter posted the triggering ``@<bot>`` mention — anyone
|
|
with comment access on the configured repo. Exposing the tool there
|
|
would let that commenter durably mutate the agent's tool whitelist
|
|
or persona for every subsequent run. The factory therefore omits the
|
|
tool from the toolset whenever the run's channel is webhook-shaped.
|
|
|
|
This test guards against a future contributor reintroducing the tool
|
|
unconditionally — that regression would silently re-open the
|
|
privilege-escalation path.
|
|
"""
|
|
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)
|
|
|
|
# ``channel_name`` is plumbed onto run_context by ChannelManager and
|
|
# surfaced via _get_runtime_config alongside the other configurable keys.
|
|
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}, "context": {"channel_name": "github"}})
|
|
tool_names = [tool.name for tool in agent_kwargs["tools"]]
|
|
assert "update_agent" not in tool_names
|
|
# Sanity: regular tools still flow through.
|
|
assert "bash" in tool_names
|
|
assert "read_file" in tool_names
|
|
|
|
|
|
def test_make_lead_agent_keeps_update_agent_on_non_webhook_channels(monkeypatch):
|
|
"""Direct invocation and non-webhook channels still get ``update_agent``.
|
|
|
|
Sanity check for the inverse of
|
|
``test_make_lead_agent_drops_update_agent_on_github_channel``: a chat-UI
|
|
or default-channel run (or any run with no channel context at all)
|
|
must keep the tool, otherwise the operator-trusted "change your own
|
|
skills" workflow would break.
|
|
"""
|
|
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")])
|
|
|
|
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)
|
|
|
|
# No channel set — equivalent to a chat-UI or direct invocation.
|
|
kwargs_default = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}})
|
|
assert "update_agent" in [t.name for t in kwargs_default["tools"]]
|
|
|
|
# Explicit non-webhook channel — telegram is interactive/trusted-by-operator.
|
|
kwargs_tg = lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}, "context": {"channel_name": "telegram"}})
|
|
assert "update_agent" in [t.name for t in kwargs_tg["tools"]]
|