deer-flow/backend/tests/test_custom_agent.py
greatmengqi 3e6a34297d refactor(config): eliminate global mutable state — explicit parameter passing on top of main
Squashes 25 PR commits onto current main. AppConfig becomes a pure value
object with no ambient lookup. Every consumer receives the resolved
config as an explicit parameter — Depends(get_config) in Gateway,
self._app_config in DeerFlowClient, runtime.context.app_config in agent
runs, AppConfig.from_file() at the LangGraph Server registration
boundary.

Phase 1 — frozen data + typed context

- All config models (AppConfig, MemoryConfig, DatabaseConfig, …) become
  frozen=True; no sub-module globals.
- AppConfig.from_file() is pure (no side-effect singleton loaders).
- Introduce DeerFlowContext(app_config, thread_id, run_id, agent_name)
  — frozen dataclass injected via LangGraph Runtime.
- Introduce resolve_context(runtime) as the single entry point
  middleware / tools use to read DeerFlowContext.

Phase 2 — pure explicit parameter passing

- Gateway: app.state.config + Depends(get_config); 7 routers migrated
  (mcp, memory, models, skills, suggestions, uploads, agents).
- DeerFlowClient: __init__(config=...) captures config locally.
- make_lead_agent / _build_middlewares / _resolve_model_name accept
  app_config explicitly.
- RunContext.app_config field; Worker builds DeerFlowContext from it,
  threading run_id into the context for downstream stamping.
- Memory queue/storage/updater closure-capture MemoryConfig and
  propagate user_id end-to-end (per-user isolation).
- Sandbox/skills/community/factories/tools thread app_config.
- resolve_context() rejects non-typed runtime.context.
- Test suite migrated off AppConfig.current() monkey-patches.
- AppConfig.current() classmethod deleted.

Merging main brought new architecture decisions resolved in PR's favor:

- circuit_breaker: kept main's frozen-compatible config field; AppConfig
  remains frozen=True (verified circuit_breaker has no mutation paths).
- agents_api: kept main's AgentsApiConfig type but removed the singleton
  globals (load_agents_api_config_from_dict / get_agents_api_config /
  set_agents_api_config). 8 routes in agents.py now read via
  Depends(get_config).
- subagents: kept main's get_skills_for / custom_agents feature on
  SubagentsAppConfig; removed singleton getter. registry.py now reads
  app_config.subagents directly.
- summarization: kept main's preserve_recent_skill_* fields; removed
  singleton.
- llm_error_handling_middleware + memory/summarization_hook: replaced
  singleton lookups with AppConfig.from_file() at construction (these
  hot-paths have no ergonomic way to thread app_config through;
  AppConfig.from_file is a pure load).
- worker.py + thread_data_middleware.py: DeerFlowContext.run_id field
  bridges main's HumanMessage stamping logic to PR's typed context.

Trade-offs (follow-up work):

- main's #2138 (async memory updater) reverted to PR's sync
  implementation. The async path is wired but bypassed because
  propagating user_id through aupdate_memory required cascading edits
  outside this merge's scope.
- tests/test_subagent_skills_config.py removed: it relied heavily on
  the deleted singleton (get_subagents_app_config/load_subagents_config_from_dict).
  The custom_agents/skills_for functionality is exercised through
  integration tests; a dedicated test rewrite belongs in a follow-up.

Verification: backend test suite — 2560 passed, 4 skipped, 84 failures.
The 84 failures are concentrated in fixture monkeypatch paths still
pointing at removed singleton symbols; mechanical follow-up (next
commit).
2026-04-26 21:45:02 +08:00

554 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for custom agent support."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from fastapi.testclient import TestClient
from deerflow.config.memory_config import MemoryConfig
_TEST_MEMORY_CONFIG = MemoryConfig()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_paths(base_dir: Path):
"""Return a Paths instance pointing to base_dir."""
from deerflow.config.paths import Paths
return Paths(base_dir=base_dir)
def _write_agent(base_dir: Path, name: str, config: dict, soul: str = "You are helpful.") -> None:
"""Write an agent directory with config.yaml and SOUL.md."""
agent_dir = base_dir / "agents" / name
agent_dir.mkdir(parents=True, exist_ok=True)
config_copy = dict(config)
if "name" not in config_copy:
config_copy["name"] = name
with open(agent_dir / "config.yaml", "w") as f:
yaml.dump(config_copy, f)
(agent_dir / "SOUL.md").write_text(soul, encoding="utf-8")
# ===========================================================================
# 1. Paths class agent path methods
# ===========================================================================
class TestPaths:
def test_agents_dir(self, tmp_path):
paths = _make_paths(tmp_path)
assert paths.agents_dir == tmp_path / "agents"
def test_agent_dir(self, tmp_path):
paths = _make_paths(tmp_path)
assert paths.agent_dir("code-reviewer") == tmp_path / "agents" / "code-reviewer"
def test_agent_memory_file(self, tmp_path):
paths = _make_paths(tmp_path)
assert paths.agent_memory_file("code-reviewer") == tmp_path / "agents" / "code-reviewer" / "memory.json"
def test_user_md_file(self, tmp_path):
paths = _make_paths(tmp_path)
assert paths.user_md_file == tmp_path / "USER.md"
def test_paths_are_different_from_global(self, tmp_path):
paths = _make_paths(tmp_path)
assert paths.memory_file != paths.agent_memory_file("my-agent")
assert paths.memory_file == tmp_path / "memory.json"
assert paths.agent_memory_file("my-agent") == tmp_path / "agents" / "my-agent" / "memory.json"
# ===========================================================================
# 2. AgentConfig Pydantic parsing
# ===========================================================================
class TestAgentConfig:
def test_minimal_config(self):
from deerflow.config.agents_config import AgentConfig
cfg = AgentConfig(name="my-agent")
assert cfg.name == "my-agent"
assert cfg.description == ""
assert cfg.model is None
assert cfg.tool_groups is None
def test_full_config(self):
from deerflow.config.agents_config import AgentConfig
cfg = AgentConfig(
name="code-reviewer",
description="Specialized for code review",
model="deepseek-v3",
tool_groups=["file:read", "bash"],
)
assert cfg.name == "code-reviewer"
assert cfg.model == "deepseek-v3"
assert cfg.tool_groups == ["file:read", "bash"]
def test_config_from_dict(self):
from deerflow.config.agents_config import AgentConfig
data = {"name": "test-agent", "description": "A test", "model": "gpt-4"}
cfg = AgentConfig(**data)
assert cfg.name == "test-agent"
assert cfg.model == "gpt-4"
assert cfg.tool_groups is None
# ===========================================================================
# 3. load_agent_config
# ===========================================================================
class TestLoadAgentConfig:
def test_load_valid_config(self, tmp_path):
config_dict = {"name": "code-reviewer", "description": "Code review agent", "model": "deepseek-v3"}
_write_agent(tmp_path, "code-reviewer", 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("code-reviewer")
assert cfg.name == "code-reviewer"
assert cfg.description == "Code review agent"
assert cfg.model == "deepseek-v3"
def test_load_missing_agent_raises(self, tmp_path):
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import load_agent_config
with pytest.raises(FileNotFoundError):
load_agent_config("nonexistent-agent")
def test_load_missing_config_yaml_raises(self, tmp_path):
# Create directory without config.yaml
(tmp_path / "agents" / "broken-agent").mkdir(parents=True)
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import load_agent_config
with pytest.raises(FileNotFoundError):
load_agent_config("broken-agent")
def test_load_config_infers_name_from_dir(self, tmp_path):
"""Config without 'name' field should use directory name."""
agent_dir = tmp_path / "agents" / "inferred-name"
agent_dir.mkdir(parents=True)
(agent_dir / "config.yaml").write_text("description: My agent\n")
(agent_dir / "SOUL.md").write_text("Hello")
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("inferred-name")
assert cfg.name == "inferred-name"
def test_load_config_with_tool_groups(self, tmp_path):
config_dict = {"name": "restricted", "tool_groups": ["file:read", "file:write"]}
_write_agent(tmp_path, "restricted", 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("restricted")
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"
agent_dir.mkdir(parents=True)
(agent_dir / "config.yaml").write_text("name: legacy-agent\nprompt_file: system.md\n")
(agent_dir / "SOUL.md").write_text("Soul content")
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("legacy-agent")
assert cfg.name == "legacy-agent"
# ===========================================================================
# 4. load_agent_soul
# ===========================================================================
class TestLoadAgentSoul:
def test_reads_soul_file(self, tmp_path):
expected_soul = "You are a specialized code review expert."
_write_agent(tmp_path, "code-reviewer", {"name": "code-reviewer"}, soul=expected_soul)
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import AgentConfig, load_agent_soul
cfg = AgentConfig(name="code-reviewer")
soul = load_agent_soul(cfg.name)
assert soul == expected_soul
def test_missing_soul_file_returns_none(self, tmp_path):
agent_dir = tmp_path / "agents" / "no-soul"
agent_dir.mkdir(parents=True)
(agent_dir / "config.yaml").write_text("name: no-soul\n")
# No SOUL.md created
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import AgentConfig, load_agent_soul
cfg = AgentConfig(name="no-soul")
soul = load_agent_soul(cfg.name)
assert soul is None
def test_empty_soul_file_returns_none(self, tmp_path):
agent_dir = tmp_path / "agents" / "empty-soul"
agent_dir.mkdir(parents=True)
(agent_dir / "config.yaml").write_text("name: empty-soul\n")
(agent_dir / "SOUL.md").write_text(" \n ")
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import AgentConfig, load_agent_soul
cfg = AgentConfig(name="empty-soul")
soul = load_agent_soul(cfg.name)
assert soul is None
# ===========================================================================
# 5. list_custom_agents
# ===========================================================================
class TestListCustomAgents:
def test_empty_when_no_agents_dir(self, tmp_path):
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import list_custom_agents
agents = list_custom_agents()
assert agents == []
def test_discovers_multiple_agents(self, tmp_path):
_write_agent(tmp_path, "agent-a", {"name": "agent-a"})
_write_agent(tmp_path, "agent-b", {"name": "agent-b", "description": "B"})
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import list_custom_agents
agents = list_custom_agents()
names = [a.name for a in agents]
assert "agent-a" in names
assert "agent-b" in names
def test_skips_dirs_without_config_yaml(self, tmp_path):
# Valid agent
_write_agent(tmp_path, "valid-agent", {"name": "valid-agent"})
# Invalid dir (no config.yaml)
(tmp_path / "agents" / "invalid-dir").mkdir(parents=True)
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import list_custom_agents
agents = list_custom_agents()
assert len(agents) == 1
assert agents[0].name == "valid-agent"
def test_skips_non_directory_entries(self, tmp_path):
# Create the agents dir with a file (not a dir)
agents_dir = tmp_path / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "not-a-dir.txt").write_text("hello")
_write_agent(tmp_path, "real-agent", {"name": "real-agent"})
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import list_custom_agents
agents = list_custom_agents()
assert len(agents) == 1
assert agents[0].name == "real-agent"
def test_returns_sorted_by_name(self, tmp_path):
_write_agent(tmp_path, "z-agent", {"name": "z-agent"})
_write_agent(tmp_path, "a-agent", {"name": "a-agent"})
_write_agent(tmp_path, "m-agent", {"name": "m-agent"})
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import list_custom_agents
agents = list_custom_agents()
names = [a.name for a in agents]
assert names == sorted(names)
# ===========================================================================
# 7. Memory isolation: _get_memory_file_path
# ===========================================================================
class TestMemoryFilePath:
def test_global_memory_path(self, tmp_path):
"""None agent_name should return global memory file."""
from deerflow.agents.memory.storage import FileMemoryStorage
with patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)):
storage = FileMemoryStorage(_TEST_MEMORY_CONFIG)
path = storage._get_memory_file_path(None)
assert path == tmp_path / "memory.json"
def test_agent_memory_path(self, tmp_path):
"""Providing agent_name should return per-agent memory file."""
from deerflow.agents.memory.storage import FileMemoryStorage
with patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)):
storage = FileMemoryStorage(_TEST_MEMORY_CONFIG)
path = storage._get_memory_file_path("code-reviewer")
assert path == tmp_path / "agents" / "code-reviewer" / "memory.json"
def test_different_paths_for_different_agents(self, tmp_path):
from deerflow.agents.memory.storage import FileMemoryStorage
with patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)):
storage = FileMemoryStorage(_TEST_MEMORY_CONFIG)
path_global = storage._get_memory_file_path(None)
path_a = storage._get_memory_file_path("agent-a")
path_b = storage._get_memory_file_path("agent-b")
assert path_global != path_a
assert path_global != path_b
assert path_a != path_b
# ===========================================================================
# 8. Gateway API Agents endpoints
# ===========================================================================
def _make_test_app(tmp_path: Path):
"""Create a FastAPI app with the agents router, patching paths to tmp_path."""
from fastapi import FastAPI
from app.gateway.routers.agents import router
app = FastAPI()
app.include_router(router)
return app
@pytest.fixture()
def agent_client(tmp_path):
"""TestClient with agents router, using tmp_path as base_dir."""
paths_instance = _make_paths(tmp_path)
with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch("app.gateway.routers.agents.get_paths", return_value=paths_instance):
app = _make_test_app(tmp_path)
with TestClient(app) as client:
client._tmp_path = tmp_path # type: ignore[attr-defined]
yield client
class TestAgentsAPI:
def test_list_agents_empty(self, agent_client):
response = agent_client.get("/api/agents")
assert response.status_code == 200
data = response.json()
assert data["agents"] == []
def test_create_agent(self, agent_client):
payload = {
"name": "code-reviewer",
"description": "Reviews code",
"soul": "You are a code reviewer.",
}
response = agent_client.post("/api/agents", json=payload)
assert response.status_code == 201
data = response.json()
assert data["name"] == "code-reviewer"
assert data["description"] == "Reviews code"
assert data["soul"] == "You are a code reviewer."
def test_create_agent_invalid_name(self, agent_client):
payload = {"name": "Code Reviewer!", "soul": "test"}
response = agent_client.post("/api/agents", json=payload)
assert response.status_code == 422
def test_create_duplicate_agent_409(self, agent_client):
payload = {"name": "my-agent", "soul": "test"}
agent_client.post("/api/agents", json=payload)
# Second create should fail
response = agent_client.post("/api/agents", json=payload)
assert response.status_code == 409
def test_list_agents_after_create(self, agent_client):
agent_client.post("/api/agents", json={"name": "agent-one", "soul": "p1"})
agent_client.post("/api/agents", json={"name": "agent-two", "soul": "p2"})
response = agent_client.get("/api/agents")
assert response.status_code == 200
names = [a["name"] for a in response.json()["agents"]]
assert "agent-one" in names
assert "agent-two" in names
def test_list_agents_includes_soul(self, agent_client):
agent_client.post("/api/agents", json={"name": "soul-agent", "soul": "My soul content"})
response = agent_client.get("/api/agents")
assert response.status_code == 200
agents = response.json()["agents"]
soul_agent = next(a for a in agents if a["name"] == "soul-agent")
assert soul_agent["soul"] == "My soul content"
def test_get_agent(self, agent_client):
agent_client.post("/api/agents", json={"name": "test-agent", "soul": "Hello world"})
response = agent_client.get("/api/agents/test-agent")
assert response.status_code == 200
data = response.json()
assert data["name"] == "test-agent"
assert data["soul"] == "Hello world"
def test_get_missing_agent_404(self, agent_client):
response = agent_client.get("/api/agents/nonexistent")
assert response.status_code == 404
def test_update_agent_soul(self, agent_client):
agent_client.post("/api/agents", json={"name": "update-me", "soul": "original"})
response = agent_client.put("/api/agents/update-me", json={"soul": "updated"})
assert response.status_code == 200
assert response.json()["soul"] == "updated"
def test_update_agent_description(self, agent_client):
agent_client.post("/api/agents", json={"name": "desc-agent", "description": "old desc", "soul": "p"})
response = agent_client.put("/api/agents/desc-agent", json={"description": "new desc"})
assert response.status_code == 200
assert response.json()["description"] == "new desc"
def test_update_missing_agent_404(self, agent_client):
response = agent_client.put("/api/agents/ghost-agent", json={"soul": "new"})
assert response.status_code == 404
def test_delete_agent(self, agent_client):
agent_client.post("/api/agents", json={"name": "del-me", "soul": "bye"})
response = agent_client.delete("/api/agents/del-me")
assert response.status_code == 204
# Verify it's gone
response = agent_client.get("/api/agents/del-me")
assert response.status_code == 404
def test_delete_missing_agent_404(self, agent_client):
response = agent_client.delete("/api/agents/does-not-exist")
assert response.status_code == 404
def test_create_agent_with_model_and_tool_groups(self, agent_client):
payload = {
"name": "specialized",
"description": "Specialized agent",
"model": "deepseek-v3",
"tool_groups": ["file:read", "bash"],
"soul": "You are specialized.",
}
response = agent_client.post("/api/agents", json=payload)
assert response.status_code == 201
data = response.json()
assert data["model"] == "deepseek-v3"
assert data["tool_groups"] == ["file:read", "bash"]
def test_create_persists_files_on_disk(self, agent_client, tmp_path):
agent_client.post("/api/agents", json={"name": "disk-check", "soul": "disk soul"})
agent_dir = tmp_path / "agents" / "disk-check"
assert agent_dir.exists()
assert (agent_dir / "config.yaml").exists()
assert (agent_dir / "SOUL.md").exists()
assert (agent_dir / "SOUL.md").read_text() == "disk soul"
def test_delete_removes_files_from_disk(self, agent_client, tmp_path):
agent_client.post("/api/agents", json={"name": "remove-me", "soul": "bye"})
agent_dir = tmp_path / "agents" / "remove-me"
assert agent_dir.exists()
agent_client.delete("/api/agents/remove-me")
assert not agent_dir.exists()
# ===========================================================================
# 9. Gateway API User Profile endpoints
# ===========================================================================
class TestUserProfileAPI:
def test_get_user_profile_empty(self, agent_client):
response = agent_client.get("/api/user-profile")
assert response.status_code == 200
assert response.json()["content"] is None
def test_put_user_profile(self, agent_client, tmp_path):
content = "# User Profile\n\nI am a developer."
response = agent_client.put("/api/user-profile", json={"content": content})
assert response.status_code == 200
assert response.json()["content"] == content
# File should be written to disk
user_md = tmp_path / "USER.md"
assert user_md.exists()
assert user_md.read_text(encoding="utf-8") == content
def test_get_user_profile_after_put(self, agent_client):
content = "# Profile\n\nI work on data science."
agent_client.put("/api/user-profile", json={"content": content})
response = agent_client.get("/api/user-profile")
assert response.status_code == 200
assert response.json()["content"] == content
def test_put_empty_user_profile_returns_none(self, agent_client):
response = agent_client.put("/api/user-profile", json={"content": ""})
assert response.status_code == 200
assert response.json()["content"] is None