mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
* feat(channels): add GitHub event-driven agents (#3754) Add a webhook-driven GitHub channel with fail-closed webhook routing, deterministic per-agent PR/issue threads, mention-gated trigger fan-out, GitHub App token injection for sandboxed gh/git commands, and backend/AGENTS.md documentation. * fix(llm-middleware): classify bare IndexError as transient Upstream chat providers occasionally return 200 OK with an empty generations list (observed against Volces "coding" on ark.cn-beijing.volces.com). When that happens, langchain_core.language_models.chat_models.ainvoke raises ``IndexError: list index out of range`` at ``llm_result.generations[0][0].message`` and kills the run. Treat a bare IndexError reaching the middleware as a transient upstream-payload glitch and route it through the existing retry/backoff path instead of failing the whole agent run. The retry budget and backoff schedule are unchanged. Adds three regression tests covering the classifier and both the recover-on-retry and exhausted-retries paths. * fix(runtime): ignore stale LLM fallback markers from prior runs When a run on a thread ends with the LLM-error-handling middleware emitting a `deerflow_error_fallback`-marked AIMessage (e.g. after the IndexError empty-generations classification fix lands), that message is persisted to the thread's checkpoint as part of the messages channel. LangGraph replays the full message history in `stream_mode="values"` chunks, so every subsequent run on the same thread re-streams the stale fallback marker — and the worker's chunk scanner faithfully picks it up, flipping `RunStatus.success` to `RunStatus.error` for runs that themselves had no LLM failure at all. Snapshot the set of pre-existing message ids from the pre-run checkpoint and thread it through `_extract_llm_error_fallback_message` / `_try_extract_from_message` as a filter. Markers on history messages are ignored; markers on fresh messages produced during this run still trip the error path. Falls back to an empty set when the checkpointer is absent or the snapshot can't be captured, preserving the prior behavior on first-run / no-state paths. Adds unit tests for the new filter (helper-level and `_collect_pre_existing_message_ids`) plus an integration test exercising the full `run_agent` path with a stale history checkpointer. * fix(channels): make github channel fire-and-forget to avoid httpx.ReadTimeout on long runs GitHub agent runs (clone -> edit -> test -> push -> PR) routinely exceed the langgraph_sdk default 300s read deadline. The manager's runs.wait call kept an HTTP stream open for the entire run lifetime, so the long run blew up with httpx.ReadTimeout and the outer except branch then released the dedupe key and emitted a false 'internal error' outbound. The GitHub channel's outbound send is log-only by design: agents post to the issue/PR via the gh CLI in the sandbox when they choose to comment or create a PR. There is nothing for the manager to ferry back, so the long-poll was pure overhead. This change adds ChannelRunPolicy.fire_and_forget (default False) and sets it True for the github channel. When fire_and_forget is True, _handle_chat dispatches via client.runs.create (short POST, returns once the run is pending) instead of client.runs.wait, and skips the response-extraction + outbound-publish block. ConflictError on a busy thread still trips the standard THREAD_BUSY_MESSAGE path so behavior on the busy case is preserved for any future non-github fire-and-forget channel. Other (non-github) channels are unchanged: their policy defaults fire_and_forget=False and they continue to dispatch via runs.wait. Adds 6 regression tests in tests/test_channels.py::TestGithubFireAndForget: - Default ChannelRunPolicy.fire_and_forget is False. - The github policy registers fire_and_forget=True. - github inbound calls runs.create, not runs.wait, with the right kwargs. - github inbound publishes no outbound on success. - ConflictError from runs.create still emits THREAD_BUSY_MESSAGE. - Non-github channels (slack) still dispatch via runs.wait. * test(lead-agent): accept user_id kwarg in skill-policy test stubs The two GitHub-channel tests added in #3754 stubbed _load_enabled_skills_for_tool_policy with a lambda that only accepted `available_skills` and `app_config`, but the real function (and its call site in agent.py) also passes `user_id`. This raised TypeError on every run, failing backend-unit-tests. Add `user_id=None` to match the three sibling stubs in the same file. * refactor(gateway): disambiguate context-key set names The two frozensets _INTERNAL_ONLY_CONTEXT_KEYS and _CONTEXT_ONLY_KEYS shared a confusable "CONTEXT_ONLY" token in different orders, and the first broke the _CONTEXT_<X>_KEYS pattern of its sibling _CONTEXT_CONFIGURABLE_KEYS. Rename to make the distinct axes explicit: _CONTEXT_INTERNAL_CALLER_KEYS - WHO: internal callers (scheduler) only _CONTEXT_RUNTIME_ONLY_KEYS - WHERE: runtime context only, never configurable Pure rename, no behavior change.
410 lines
22 KiB
Python
410 lines
22 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()
|
|
|
|
|
|
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"]]
|