refactor(config): eliminate global mutable state, wire DeerFlowContext into runtime

- Freeze all config models (AppConfig + 15 sub-configs) with frozen=True
- Purify from_file() — remove 9 load_*_from_dict() side-effect calls
- Replace mtime/reload/push/pop machinery with single ContextVar + init_app_config()
- Delete 10 sub-module globals and their getters/setters/loaders
- Migrate 50+ consumers from get_*_config() to get_app_config().xxx

- Expand DeerFlowContext: app_config + thread_id + agent_name (frozen dataclass)
- Wire into Gateway runtime (worker.py) and DeerFlowClient via context= parameter
- Remove sandbox_id from runtime.context — flows through ThreadState.sandbox only
- Middleware/tools access runtime.context directly via Runtime[DeerFlowContext] generic
- resolve_context() retained at server entry points for LangGraph Server fallback
This commit is contained in:
greatmengqi
2026-04-13 23:49:31 +08:00
parent c4d273a68a
commit edf345cd72
111 changed files with 4848 additions and 4079 deletions
+69 -52
View File
@@ -18,6 +18,7 @@ from app.gateway.routers.models import ModelResponse, ModelsListResponse
from app.gateway.routers.skills import SkillInstallResponse, SkillResponse, SkillsListResponse
from app.gateway.routers.uploads import UploadResponse
from deerflow.client import DeerFlowClient
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import Paths
from deerflow.uploads.manager import PathTraversalError
@@ -44,7 +45,7 @@ def mock_app_config():
@pytest.fixture
def client(mock_app_config):
"""Create a DeerFlowClient with mocked config loading."""
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
with patch.object(AppConfig, "current", return_value=mock_app_config):
return DeerFlowClient()
@@ -66,7 +67,7 @@ class TestClientInit:
def test_custom_params(self, mock_app_config):
mock_middleware = MagicMock()
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
with patch.object(AppConfig, "current", return_value=mock_app_config):
c = DeerFlowClient(model_name="gpt-4", thinking_enabled=False, subagent_enabled=True, plan_mode=True, agent_name="test-agent", available_skills={"skill1", "skill2"}, middlewares=[mock_middleware])
assert c._model_name == "gpt-4"
assert c._thinking_enabled is False
@@ -77,7 +78,7 @@ class TestClientInit:
assert c._middlewares == [mock_middleware]
def test_invalid_agent_name(self, mock_app_config):
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
with patch.object(AppConfig, "current", return_value=mock_app_config):
with pytest.raises(ValueError, match="Invalid agent name"):
DeerFlowClient(agent_name="invalid name with spaces!")
with pytest.raises(ValueError, match="Invalid agent name"):
@@ -85,15 +86,17 @@ class TestClientInit:
def test_custom_config_path(self, mock_app_config):
with (
patch("deerflow.client.reload_app_config") as mock_reload,
patch("deerflow.client.get_app_config", return_value=mock_app_config),
patch.object(AppConfig, "from_file", return_value=mock_app_config) as mock_from_file,
patch.object(AppConfig, "init") as mock_init,
patch.object(AppConfig, "current", return_value=mock_app_config),
):
DeerFlowClient(config_path="/tmp/custom.yaml")
mock_reload.assert_called_once_with("/tmp/custom.yaml")
mock_from_file.assert_called_once_with("/tmp/custom.yaml")
mock_init.assert_called_once_with(mock_app_config)
def test_checkpointer_stored(self, mock_app_config):
cp = MagicMock()
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
with patch.object(AppConfig, "current", return_value=mock_app_config):
c = DeerFlowClient(checkpointer=cp)
assert c._checkpointer is cp
@@ -249,8 +252,8 @@ class TestStream:
# Verify context passed to agent.stream
agent.stream.assert_called_once()
call_kwargs = agent.stream.call_args.kwargs
assert call_kwargs["context"]["thread_id"] == "t1"
assert call_kwargs["context"]["agent_name"] == "test-agent-1"
ctx = call_kwargs["context"]
assert ctx.app_config is client._app_config
def test_custom_mode_is_normalized_to_string(self, client):
"""stream() forwards custom events even when the mode is not a plain string."""
@@ -1089,7 +1092,7 @@ class TestMcpConfig:
ext_config = MagicMock()
ext_config.mcp_servers = {"github": server}
with patch("deerflow.client.get_extensions_config", return_value=ext_config):
with patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)):
result = client.get_mcp_config()
assert "mcp_servers" in result
@@ -1114,10 +1117,12 @@ class TestMcpConfig:
# Pre-set agent to verify it gets invalidated
client._agent = MagicMock()
# Set initial AppConfig with current extensions
AppConfig.init(MagicMock(extensions=current_config))
with (
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=tmp_path),
patch("deerflow.client.get_extensions_config", return_value=current_config),
patch("deerflow.client.reload_extensions_config", return_value=reloaded_config),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=reloaded_config)),
):
result = client.update_mcp_config({"new-server": {"enabled": True, "type": "sse"}})
@@ -1179,8 +1184,8 @@ class TestSkillsManagement:
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [updated_skill]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=tmp_path),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()),
):
result = client.update_skill("test-skill", enabled=False)
assert result["enabled"] is False
@@ -1311,35 +1316,40 @@ class TestMemoryManagement:
assert result == data
def test_get_memory_config(self, client):
config = MagicMock()
config.enabled = True
config.storage_path = ".deer-flow/memory.json"
config.debounce_seconds = 30
config.max_facts = 100
config.fact_confidence_threshold = 0.7
config.injection_enabled = True
config.max_injection_tokens = 2000
mem_config = MagicMock()
mem_config.enabled = True
mem_config.storage_path = ".deer-flow/memory.json"
mem_config.debounce_seconds = 30
mem_config.max_facts = 100
mem_config.fact_confidence_threshold = 0.7
mem_config.injection_enabled = True
mem_config.max_injection_tokens = 2000
with patch("deerflow.config.memory_config.get_memory_config", return_value=config):
app_cfg = MagicMock()
app_cfg.memory = mem_config
with patch.object(AppConfig, "current", return_value=app_cfg):
result = client.get_memory_config()
assert result["enabled"] is True
assert result["max_facts"] == 100
def test_get_memory_status(self, client):
config = MagicMock()
config.enabled = True
config.storage_path = ".deer-flow/memory.json"
config.debounce_seconds = 30
config.max_facts = 100
config.fact_confidence_threshold = 0.7
config.injection_enabled = True
config.max_injection_tokens = 2000
mem_config = MagicMock()
mem_config.enabled = True
mem_config.storage_path = ".deer-flow/memory.json"
mem_config.debounce_seconds = 30
mem_config.max_facts = 100
mem_config.fact_confidence_threshold = 0.7
mem_config.injection_enabled = True
mem_config.max_injection_tokens = 2000
app_cfg = MagicMock()
app_cfg.memory = mem_config
data = {"version": "1.0", "facts": []}
with (
patch("deerflow.config.memory_config.get_memory_config", return_value=config),
patch.object(AppConfig, "current", return_value=app_cfg),
patch("deerflow.agents.memory.updater.get_memory_data", return_value=data),
):
result = client.get_memory_status()
@@ -1783,10 +1793,10 @@ class TestScenarioConfigManagement:
reloaded_config.mcp_servers = {"my-mcp": reloaded_server}
client._agent = MagicMock() # Simulate existing agent
AppConfig.init(MagicMock(extensions=current_config))
with (
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=current_config),
patch("deerflow.client.reload_extensions_config", return_value=reloaded_config),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=reloaded_config)),
):
mcp_result = client.update_mcp_config({"my-mcp": {"enabled": True}})
assert "my-mcp" in mcp_result["mcp_servers"]
@@ -1815,8 +1825,8 @@ class TestScenarioConfigManagement:
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [toggled]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()),
):
skill_result = client.update_skill("code-gen", enabled=False)
assert skill_result["enabled"] is False
@@ -2001,8 +2011,10 @@ class TestScenarioMemoryWorkflow:
refreshed = client.reload_memory()
assert len(refreshed["facts"]) == 2
app_cfg = MagicMock()
app_cfg.memory = config
with (
patch("deerflow.config.memory_config.get_memory_config", return_value=config),
patch.object(AppConfig, "current", return_value=app_cfg),
patch("deerflow.agents.memory.updater.get_memory_data", return_value=updated_data),
):
status = client.get_memory_status()
@@ -2065,8 +2077,8 @@ class TestScenarioSkillInstallAndUse:
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[installed_skill], [disabled_skill]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()),
):
toggled = client.update_skill("my-analyzer", enabled=False)
assert toggled["enabled"] is False
@@ -2198,7 +2210,7 @@ class TestGatewayConformance:
model.supports_thinking = False
mock_app_config.models = [model]
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
with patch.object(AppConfig, "current", return_value=mock_app_config):
client = DeerFlowClient()
result = client.list_models()
@@ -2217,7 +2229,7 @@ class TestGatewayConformance:
mock_app_config.models = [model]
mock_app_config.get_model_config.return_value = model
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
with patch.object(AppConfig, "current", return_value=mock_app_config):
client = DeerFlowClient()
result = client.get_model("test-model")
@@ -2287,7 +2299,7 @@ class TestGatewayConformance:
ext_config = MagicMock()
ext_config.mcp_servers = {"test": server}
with patch("deerflow.client.get_extensions_config", return_value=ext_config):
with patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)):
result = client.get_mcp_config()
parsed = McpConfigResponse(**result)
@@ -2313,9 +2325,9 @@ class TestGatewayConformance:
config_file.write_text("{}")
with (
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.reload_extensions_config", return_value=ext_config),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=ext_config)),
):
result = client.update_mcp_config({"srv": server.model_dump.return_value})
@@ -2346,7 +2358,10 @@ class TestGatewayConformance:
mem_cfg.injection_enabled = True
mem_cfg.max_injection_tokens = 2000
with patch("deerflow.config.memory_config.get_memory_config", return_value=mem_cfg):
app_cfg = MagicMock()
app_cfg.memory = mem_cfg
with patch.object(AppConfig, "current", return_value=app_cfg):
result = client.get_memory_config()
parsed = MemoryConfigResponse(**result)
@@ -2363,6 +2378,8 @@ class TestGatewayConformance:
mem_cfg.injection_enabled = True
mem_cfg.max_injection_tokens = 2000
app_cfg = MagicMock()
app_cfg.memory = mem_cfg
memory_data = {
"version": "1.0",
"lastUpdated": "",
@@ -2380,7 +2397,7 @@ class TestGatewayConformance:
}
with (
patch("deerflow.config.memory_config.get_memory_config", return_value=mem_cfg),
patch.object(AppConfig, "current", return_value=app_cfg),
patch("deerflow.agents.memory.updater.get_memory_data", return_value=memory_data),
):
result = client.get_memory_status()
@@ -2671,8 +2688,8 @@ class TestConfigUpdateErrors:
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], []]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()),
):
with pytest.raises(RuntimeError, match="disappeared"):
client.update_skill("ghost-skill", enabled=False)
@@ -3042,10 +3059,10 @@ class TestBugAgentInvalidationInconsistency:
config_file = Path(tmp) / "ext.json"
config_file.write_text("{}")
AppConfig.init(MagicMock(extensions=current_config))
with (
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=current_config),
patch("deerflow.client.reload_extensions_config", return_value=reloaded),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=reloaded)),
):
client.update_mcp_config({})
@@ -3077,8 +3094,8 @@ class TestBugAgentInvalidationInconsistency:
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [updated]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
patch.object(AppConfig, "current", return_value=MagicMock(extensions=ext_config)),
patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()),
):
client.update_skill("s1", enabled=False)