diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 7bfca7bac..c6d4ef6e2 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -130,7 +130,7 @@ from app.gateway.app import app from app.channels.service import start_channel_service # App → Harness (allowed) -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig # Harness → App (FORBIDDEN — enforced by test_harness_boundary.py) # from app.gateway.routers.uploads import ... # ← will fail CI @@ -179,9 +179,16 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc **Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`. -**Config Lifecycle**: All config models are `frozen=True` (immutable after construction). `AppConfig.from_file()` is a pure function — no side effects on sub-module globals. `get_app_config()` is backed by a single `ContextVar`, set once via `init_app_config()` at process startup. To update config at runtime (e.g., Gateway API updates MCP/Skills), construct a new `AppConfig.from_file()` and call `init_app_config()` again. No mtime detection, no auto-reload. +**Config Lifecycle**: All config models are `frozen=True` (immutable after construction). `AppConfig.from_file()` is a pure function — no side effects, no process-global state. The resolved `AppConfig` is passed as an explicit parameter down every consumer lane: -**DeerFlowContext**: Per-invocation typed context for the agent execution path, injected via LangGraph `Runtime[DeerFlowContext]`. Holds `app_config: AppConfig`, `thread_id: str`, `agent_name: str | None`. Gateway runtime and `DeerFlowClient` construct full `DeerFlowContext` at invoke time; LangGraph Server path uses a fallback via `resolve_context()`. Middleware and tools access context through `resolve_context(runtime)` which returns a typed `DeerFlowContext` regardless of entry point. Mutable runtime state (`sandbox_id`) flows through `ThreadState.sandbox`, not context. +- **Gateway**: `app.state.config` populated in lifespan; routers receive it via `Depends(get_config)` from `app/gateway/deps.py`. +- **Client**: `DeerFlowClient._app_config` captured in the constructor; every method reads `self._app_config`. +- **Agent run**: wrapped in `DeerFlowContext(app_config=…)` and injected via LangGraph `Runtime[DeerFlowContext].context`. Middleware and tools read `runtime.context.app_config` directly or via `resolve_context(runtime)`. +- **LangGraph Server bootstrap**: `make_lead_agent` (registered in `langgraph.json`) calls `AppConfig.from_file()` itself — the only place in production that loads from disk at agent-build time. + +To update config at runtime (Gateway API mutations for MCP/Skills), write the new file and call `AppConfig.from_file()` to build a fresh snapshot, then swap `app.state.config`. No mtime detection, no auto-reload, no ambient ContextVar lookup (`AppConfig.current()` has been removed). + +**DeerFlowContext**: Per-invocation typed context for the agent execution path, injected via LangGraph `Runtime[DeerFlowContext]`. Holds `app_config: AppConfig`, `thread_id: str`, `agent_name: str | None`. Gateway runtime and `DeerFlowClient` construct full `DeerFlowContext` at invoke time; the LangGraph Server boundary builds one inside `make_lead_agent`. Middleware and tools access context through `resolve_context(runtime)` which returns the typed `DeerFlowContext` — legacy dict/None shapes are rejected. Mutable runtime state (`sandbox_id`) flows through `ThreadState.sandbox`, not context. Configuration priority: 1. Explicit `config_path` argument diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 4007bc086..73b1ffe18 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -26,11 +26,9 @@ if TYPE_CHECKING: def get_config(request: Request) -> AppConfig: """FastAPI dependency returning the app-scoped ``AppConfig``. - Prefer this over ``AppConfig.current()`` in new code. Reads from - ``request.app.state.config`` which is set at startup (``app.py`` - lifespan) and swapped on config reload (``routers/mcp.py``, - ``routers/skills.py``). Phase 2 of the config refactor migrates all - router-level ``AppConfig.current()`` callers to this dependency. + Reads from ``request.app.state.config`` which is set at startup + (``app.py`` lifespan) and swapped on config reload (``routers/mcp.py``, + ``routers/skills.py``). """ cfg = getattr(request.app.state, "config", None) if cfg is None: @@ -53,8 +51,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: from deerflow.runtime.events.store import make_run_event_store async with AsyncExitStack() as stack: - # app.state.config is populated earlier in lifespan(); thread it into - # every provider that used to reach for AppConfig.current(). + # app.state.config is populated earlier in lifespan(); thread it + # explicitly into every provider below. config = app.state.config app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config)) diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 2ffd93e7e..3e5d2a4bf 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -222,20 +222,7 @@ class AppConfig(BaseModel): return next((group for group in self.tool_groups if group.name == name), None) # AppConfig is a pure value object: construct with ``from_file()``, pass around. - # Composition roots that hold the singleton: + # Composition roots that hold the resolved instance: # - Gateway: ``app.state.config`` via ``Depends(get_config)`` # - Client: ``DeerFlowClient._app_config`` # - Agent run: ``Runtime[DeerFlowContext].context.app_config`` - # - # ``current()`` is kept as a deprecated no-op slot purely so legacy tests - # that still run ``patch.object(AppConfig, "current", ...)`` can attach - # without an ``AttributeError`` at teardown. Production code never calls - # it — any in-process invocation raises so regressions are loud. - - @classmethod - def current(cls) -> AppConfig: - raise RuntimeError( - "AppConfig.current() is removed. Pass AppConfig explicitly: " - "`runtime.context.app_config` in agent paths, `Depends(get_config)` in Gateway, " - "`self._app_config` in DeerFlowClient." - ) diff --git a/backend/tests/test_checkpointer.py b/backend/tests/test_checkpointer.py index fc7a67b3f..617998678 100644 --- a/backend/tests/test_checkpointer.py +++ b/backend/tests/test_checkpointer.py @@ -63,77 +63,54 @@ class TestGetCheckpointer: """get_checkpointer should return InMemorySaver when not configured.""" from langgraph.checkpoint.memory import InMemorySaver - with patch.object(AppConfig, "current", return_value=_make_config()): - cp = get_checkpointer(AppConfig.current()) + cfg = _make_config() + cp = get_checkpointer(cfg) assert cp is not None assert isinstance(cp, InMemorySaver) - def test_raises_when_config_file_missing(self): - """A missing config.yaml is a deployment error, not a cue to degrade to InMemorySaver. - - Silent degradation would drop persistent-run state on process restart. - `get_checkpointer` only falls back to InMemorySaver for the explicit - `checkpointer: null` opt-in (test above), not for I/O failure. - """ - with patch.object(AppConfig, "current", side_effect=FileNotFoundError): - with pytest.raises(FileNotFoundError): - get_checkpointer(AppConfig.current()) - def test_memory_returns_in_memory_saver(self): from langgraph.checkpoint.memory import InMemorySaver cfg = _make_config(CheckpointerConfig(type="memory")) - with patch.object(AppConfig, "current", return_value=cfg): - cp = get_checkpointer(AppConfig.current()) + cp = get_checkpointer(cfg) assert isinstance(cp, InMemorySaver) def test_memory_singleton(self): cfg = _make_config(CheckpointerConfig(type="memory")) - with patch.object(AppConfig, "current", return_value=cfg): - cp1 = get_checkpointer(AppConfig.current()) - cp2 = get_checkpointer(AppConfig.current()) + cp1 = get_checkpointer(cfg) + cp2 = get_checkpointer(cfg) assert cp1 is cp2 def test_reset_clears_singleton(self): cfg = _make_config(CheckpointerConfig(type="memory")) - with patch.object(AppConfig, "current", return_value=cfg): - cp1 = get_checkpointer(AppConfig.current()) - reset_checkpointer() - cp2 = get_checkpointer(AppConfig.current()) + cp1 = get_checkpointer(cfg) + reset_checkpointer() + cp2 = get_checkpointer(cfg) assert cp1 is not cp2 def test_sqlite_raises_when_package_missing(self): cfg = _make_config(CheckpointerConfig(type="sqlite", connection_string="/tmp/test.db")) - with ( - patch.object(AppConfig, "current", return_value=cfg), - patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": None}), - ): + with patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": None}): reset_checkpointer() with pytest.raises(ImportError, match="langgraph-checkpoint-sqlite"): - get_checkpointer(AppConfig.current()) + get_checkpointer(cfg) def test_postgres_raises_when_package_missing(self): cfg = _make_config(CheckpointerConfig(type="postgres", connection_string="postgresql://localhost/db")) - with ( - patch.object(AppConfig, "current", return_value=cfg), - patch.dict(sys.modules, {"langgraph.checkpoint.postgres": None}), - ): + with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": None}): reset_checkpointer() with pytest.raises(ImportError, match="langgraph-checkpoint-postgres"): - get_checkpointer(AppConfig.current()) + get_checkpointer(cfg) def test_postgres_raises_when_connection_string_missing(self): cfg = _make_config(CheckpointerConfig(type="postgres")) mock_saver = MagicMock() mock_module = MagicMock() mock_module.PostgresSaver = mock_saver - with ( - patch.object(AppConfig, "current", return_value=cfg), - patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_module}), - ): + with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_module}): reset_checkpointer() with pytest.raises(ValueError, match="connection_string is required"): - get_checkpointer(AppConfig.current()) + get_checkpointer(cfg) def test_sqlite_creates_saver(self): """SQLite checkpointer is created when package is available.""" @@ -150,12 +127,9 @@ class TestGetCheckpointer: mock_module = MagicMock() mock_module.SqliteSaver = mock_saver_cls - with ( - patch.object(AppConfig, "current", return_value=cfg), - patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": mock_module}), - ): + with patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": mock_module}): reset_checkpointer() - cp = get_checkpointer(AppConfig.current()) + cp = get_checkpointer(cfg) assert cp is mock_saver_instance mock_saver_cls.from_conn_string.assert_called_once() @@ -176,12 +150,9 @@ class TestGetCheckpointer: mock_pg_module = MagicMock() mock_pg_module.PostgresSaver = mock_saver_cls - with ( - patch.object(AppConfig, "current", return_value=cfg), - patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_pg_module}), - ): + with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_pg_module}): reset_checkpointer() - cp = get_checkpointer(AppConfig.current()) + cp = get_checkpointer(cfg) assert cp is mock_saver_instance mock_saver_cls.from_conn_string.assert_called_once_with("postgresql://localhost/db") @@ -209,7 +180,6 @@ class TestAsyncCheckpointer: mock_module.AsyncSqliteSaver = mock_saver_cls with ( - patch.object(AppConfig, "current", return_value=mock_config), patch.dict(sys.modules, {"langgraph.checkpoint.sqlite.aio": mock_module}), patch("deerflow.runtime.checkpointer.async_provider.asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread, patch( @@ -217,7 +187,7 @@ class TestAsyncCheckpointer: return_value="/tmp/resolved/test.db", ), ): - async with make_checkpointer(AppConfig.current()) as saver: + async with make_checkpointer(mock_config) as saver: assert saver is mock_saver mock_to_thread.assert_awaited_once() @@ -248,7 +218,7 @@ class TestAppConfigLoadsCheckpointer: class TestClientCheckpointerFallback: def test_client_uses_config_checkpointer_when_none_provided(self): - """DeerFlowClient._ensure_agent falls back to get_checkpointer(AppConfig.current()) when checkpointer=None.""" + """DeerFlowClient._ensure_agent falls back to get_checkpointer(app_config) when checkpointer=None.""" # This is a structural test — verifying the fallback path exists. cfg = _make_config(CheckpointerConfig(type="memory")) assert cfg.checkpointer is not None diff --git a/backend/tests/test_checkpointer_none_fix.py b/backend/tests/test_checkpointer_none_fix.py index f4912a0eb..3b8c81b08 100644 --- a/backend/tests/test_checkpointer_none_fix.py +++ b/backend/tests/test_checkpointer_none_fix.py @@ -1,12 +1,10 @@ """Test for issue #1016: checkpointer should not return None.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from langgraph.checkpoint.memory import InMemorySaver -from deerflow.config.app_config import AppConfig - class TestCheckpointerNoneFix: """Tests that checkpointer context managers return InMemorySaver instead of None.""" @@ -16,42 +14,38 @@ class TestCheckpointerNoneFix: """make_checkpointer should return InMemorySaver when config.checkpointer is None.""" from deerflow.runtime.checkpointer.async_provider import make_checkpointer - # Mock AppConfig.current to return a config with checkpointer=None and database=None mock_config = MagicMock() mock_config.checkpointer = None mock_config.database = None - with patch.object(AppConfig, "current", return_value=mock_config): - async with make_checkpointer(AppConfig.current()) as checkpointer: - # Should return InMemorySaver, not None - assert checkpointer is not None - assert isinstance(checkpointer, InMemorySaver) + async with make_checkpointer(mock_config) as checkpointer: + # Should return InMemorySaver, not None + assert checkpointer is not None + assert isinstance(checkpointer, InMemorySaver) - # Should be able to call alist() without AttributeError - # This is what LangGraph does and what was failing in issue #1016 - result = [] - async for item in checkpointer.alist(config={"configurable": {"thread_id": "test"}}): - result.append(item) + # Should be able to call alist() without AttributeError + # This is what LangGraph does and what was failing in issue #1016 + result = [] + async for item in checkpointer.alist(config={"configurable": {"thread_id": "test"}}): + result.append(item) - # Empty list is expected for a fresh checkpointer - assert result == [] + # Empty list is expected for a fresh checkpointer + assert result == [] def test_sync_checkpointer_context_returns_in_memory_saver_when_not_configured(self): """checkpointer_context should return InMemorySaver when config.checkpointer is None.""" from deerflow.runtime.checkpointer.provider import checkpointer_context - # Mock AppConfig.get to return a config with checkpointer=None mock_config = MagicMock() mock_config.checkpointer = None - with patch.object(AppConfig, "current", return_value=mock_config): - with checkpointer_context(AppConfig.current()) as checkpointer: - # Should return InMemorySaver, not None - assert checkpointer is not None - assert isinstance(checkpointer, InMemorySaver) + with checkpointer_context(mock_config) as checkpointer: + # Should return InMemorySaver, not None + assert checkpointer is not None + assert isinstance(checkpointer, InMemorySaver) - # Should be able to call list() without AttributeError - result = list(checkpointer.list(config={"configurable": {"thread_id": "test"}})) + # Should be able to call list() without AttributeError + result = list(checkpointer.list(config={"configurable": {"thread_id": "test"}})) - # Empty list is expected for a fresh checkpointer - assert result == [] + # Empty list is expected for a fresh checkpointer + assert result == [] diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 0b2ccbe1c..9de097a91 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -70,8 +70,7 @@ class TestClientInit: def test_custom_params(self, mock_app_config): mock_middleware = MagicMock() - 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]) + 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 assert c._subagent_enabled is True @@ -81,11 +80,10 @@ class TestClientInit: assert c._middlewares == [mock_middleware] def test_invalid_agent_name(self, 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"): - DeerFlowClient(agent_name="../path/traversal") + with pytest.raises(ValueError, match="Invalid agent name"): + DeerFlowClient(agent_name="invalid name with spaces!") + with pytest.raises(ValueError, match="Invalid agent name"): + DeerFlowClient(agent_name="../path/traversal") def test_custom_config_path(self, mock_app_config): # rather than touching AppConfig.init() / process-global state. @@ -96,8 +94,7 @@ class TestClientInit: def test_checkpointer_stored(self, mock_app_config): cp = MagicMock() - with patch.object(AppConfig, "current", return_value=mock_app_config): - c = DeerFlowClient(checkpointer=cp) + c = DeerFlowClient(checkpointer=cp) assert c._checkpointer is cp @@ -1840,7 +1837,6 @@ 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.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) @@ -2093,7 +2089,6 @@ 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.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) @@ -2700,7 +2695,6 @@ class TestConfigUpdateErrors: with ( patch("deerflow.skills.loader.load_skills", side_effect=[[skill], []]), patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - 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"): @@ -3115,7 +3109,6 @@ 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.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) diff --git a/backend/tests/test_client_e2e.py b/backend/tests/test_client_e2e.py index 456a3079d..42144fb1e 100644 --- a/backend/tests/test_client_e2e.py +++ b/backend/tests/test_client_e2e.py @@ -102,12 +102,7 @@ def e2e_env(tmp_path, monkeypatch): monkeypatch.setattr("deerflow.config.paths._paths", None) monkeypatch.setattr("deerflow.sandbox.sandbox_provider._default_sandbox_provider", None) - # 2. Inject a clean AppConfig via the ContextVar-backed singleton. - # Title, memory, and summarization are disabled in _make_e2e_config(). - config = _make_e2e_config() - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) - - # 3. Exclude TitleMiddleware from the chain. + # 2. Exclude TitleMiddleware from the chain. # It triggers an extra LLM call to generate a thread title, which adds # non-determinism and cost to E2E tests (title generation is already # disabled via TitleConfig above, but the middleware still participates diff --git a/backend/tests/test_client_multi_isolation.py b/backend/tests/test_client_multi_isolation.py index 39ede0a78..0271483af 100644 --- a/backend/tests/test_client_multi_isolation.py +++ b/backend/tests/test_client_multi_isolation.py @@ -1,7 +1,7 @@ """Multi-client isolation regression test. Phase 2 Task P2-3: ``DeerFlowClient`` now captures its ``AppConfig`` in the -constructor instead of going through process-global ``AppConfig.current()``. +constructor instead of going through a process-global config. This test pins the resulting invariant: two clients with different configs can coexist without contending over shared state. diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index dbd6eb570..22ba0c1d0 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -3,13 +3,12 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest import yaml from fastapi.testclient import TestClient -from deerflow.config.app_config import AppConfig from deerflow.config.memory_config import MemoryConfig _TEST_MEMORY_CONFIG = MemoryConfig() @@ -332,12 +331,8 @@ 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 - from deerflow.config.memory_config import MemoryConfig - with ( - patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)), - patch.object(AppConfig, "current", return_value=MagicMock(memory=MemoryConfig(storage_path=""))), - ): + 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" @@ -345,24 +340,16 @@ class TestMemoryFilePath: def test_agent_memory_path(self, tmp_path): """Providing agent_name should return per-agent memory file.""" from deerflow.agents.memory.storage import FileMemoryStorage - from deerflow.config.memory_config import MemoryConfig - with ( - patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)), - patch.object(AppConfig, "current", return_value=MagicMock(memory=MemoryConfig(storage_path=""))), - ): + 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 - from deerflow.config.memory_config import MemoryConfig - with ( - patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)), - patch.object(AppConfig, "current", return_value=MagicMock(memory=MemoryConfig(storage_path=""))), - ): + 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") diff --git a/backend/tests/test_exa_tools.py b/backend/tests/test_exa_tools.py index b6352e068..3953e21fc 100644 --- a/backend/tests/test_exa_tools.py +++ b/backend/tests/test_exa_tools.py @@ -5,8 +5,6 @@ from unittest.mock import MagicMock, patch import pytest -from deerflow.config.app_config import AppConfig - # --- Phase 2 test helper: injected runtime for community tools --- from types import SimpleNamespace as _P2NS from deerflow.config.app_config import AppConfig as _P2AppConfig @@ -22,7 +20,7 @@ def _runtime_with_config(config): ``DeerFlowContext`` is a frozen dataclass typed as ``AppConfig`` but dataclasses don't enforce the type at runtime — handing a Mock through lets tests exercise the tool's ``get_tool_config`` lookup without going - via ``AppConfig.current``. + through a process-global config. """ ctx = _P2Ctx.__new__(_P2Ctx) object.__setattr__(ctx, "app_config", config) @@ -35,17 +33,8 @@ def _runtime_with_config(config): @pytest.fixture def mock_app_config(): - """Mock the app config to return tool configurations.""" - with patch.object(AppConfig, "current") as mock_config: - tool_config = MagicMock() - tool_config.model_extra = { - "max_results": 5, - "search_type": "auto", - "contents_max_characters": 1000, - "api_key": "test-api-key", - } - mock_config.return_value.get_tool_config.return_value = tool_config - yield mock_config + """Fixture retained as a pass-through: tests inject config via runtime directly.""" + yield @pytest.fixture diff --git a/backend/tests/test_gateway_deps_config.py b/backend/tests/test_gateway_deps_config.py index a3a1d1007..ad309ece9 100644 --- a/backend/tests/test_gateway_deps_config.py +++ b/backend/tests/test_gateway_deps_config.py @@ -1,9 +1,8 @@ """Tests for the FastAPI get_config dependency. Phase 2 step 1: introduces the new explicit-config primitive that -resolves ``AppConfig`` from ``request.app.state.config``. This coexists -with the existing ``AppConfig.current()`` process-global during the -migration; it becomes the sole mechanism after Phase 2 task P2-10. +resolves ``AppConfig`` from ``request.app.state.config``. After migration, +it is the sole mechanism. """ from __future__ import annotations diff --git a/backend/tests/test_guardrail_middleware.py b/backend/tests/test_guardrail_middleware.py index 75d50e902..640f32d2e 100644 --- a/backend/tests/test_guardrail_middleware.py +++ b/backend/tests/test_guardrail_middleware.py @@ -334,8 +334,6 @@ class TestGuardrailsConfig: assert config.provider.config == {"denied_tools": ["bash"]} def test_guardrails_config_via_app_config(self): - from unittest.mock import patch - from deerflow.config.app_config import AppConfig from deerflow.config.guardrails_config import GuardrailProviderConfig, GuardrailsConfig from deerflow.config.sandbox_config import SandboxConfig @@ -344,6 +342,5 @@ class TestGuardrailsConfig: sandbox=SandboxConfig(use="test"), guardrails=GuardrailsConfig(enabled=True, provider=GuardrailProviderConfig(use="test:Foo")), ) - with patch.object(AppConfig, "current", return_value=cfg): - config = AppConfig.current().guardrails - assert config.enabled is True + config = cfg.guardrails + assert config.enabled is True diff --git a/backend/tests/test_infoquest_client.py b/backend/tests/test_infoquest_client.py index 2f142d625..daf70742d 100644 --- a/backend/tests/test_infoquest_client.py +++ b/backend/tests/test_infoquest_client.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch from deerflow.community.infoquest import tools from deerflow.community.infoquest.infoquest_client import InfoQuestClient -from deerflow.config.app_config import AppConfig # --- Phase 2 test helper: injected runtime for community tools --- from types import SimpleNamespace as _P2NS @@ -160,8 +159,7 @@ class TestInfoQuestClient: mock_get_client.assert_called_once() mock_client.fetch.assert_called_once_with("https://example.com") - @patch.object(AppConfig, "current") - def test_get_infoquest_client(self, mock_get): + def test_get_infoquest_client(self): """Test _get_infoquest_client function with config.""" mock_config = MagicMock() # Add image_search config to the side_effect @@ -170,7 +168,6 @@ class TestInfoQuestClient: MagicMock(model_extra={"fetch_time": 10, "timeout": 30, "navigation_timeout": 60}), # web_fetch config MagicMock(model_extra={"image_search_time_range": 7, "image_size": "l"}), # image_search config ] - mock_get.return_value = mock_config client = tools._get_infoquest_client(mock_config) diff --git a/backend/tests/test_invoke_acp_agent_tool.py b/backend/tests/test_invoke_acp_agent_tool.py index 56e515822..352109963 100644 --- a/backend/tests/test_invoke_acp_agent_tool.py +++ b/backend/tests/test_invoke_acp_agent_tool.py @@ -6,7 +6,6 @@ from types import SimpleNamespace import pytest from deerflow.config.acp_config import ACPAgentConfig -from deerflow.config.app_config import AppConfig from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig from deerflow.tools.builtins.invoke_acp_agent_tool import ( _build_acp_mcp_servers, @@ -679,11 +678,10 @@ def test_get_available_tools_includes_invoke_acp_agent_when_agents_configured(mo }, get_model_config=lambda name: None, ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: fake_config)) monkeypatch.setattr( "deerflow.config.extensions_config.ExtensionsConfig.from_file", classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})), ) - tools = get_available_tools(include_mcp=True, subagent_enabled=False, app_config=AppConfig.current()) + tools = get_available_tools(include_mcp=True, subagent_enabled=False, app_config=fake_config) assert "invoke_acp_agent" in [tool.name for tool in tools] diff --git a/backend/tests/test_jina_client.py b/backend/tests/test_jina_client.py index b96596f61..099d48615 100644 --- a/backend/tests/test_jina_client.py +++ b/backend/tests/test_jina_client.py @@ -9,7 +9,6 @@ import pytest import deerflow.community.jina_ai.jina_client as jina_client_module from deerflow.community.jina_ai.jina_client import JinaClient from deerflow.community.jina_ai.tools import web_fetch_tool -from deerflow.config.app_config import AppConfig # --- Phase 2 test helper: injected runtime for community tools --- from types import SimpleNamespace as _P2NS @@ -165,7 +164,6 @@ async def test_web_fetch_tool_returns_error_on_crawl_failure(monkeypatch): mock_config = MagicMock() mock_config.get_tool_config.return_value = None - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: mock_config)) monkeypatch.setattr(JinaClient, "crawl", mock_crawl) result = await web_fetch_tool.coroutine(url="https://example.com", runtime=_P2_RUNTIME) assert result.startswith("Error:") @@ -181,7 +179,6 @@ async def test_web_fetch_tool_returns_markdown_on_success(monkeypatch): mock_config = MagicMock() mock_config.get_tool_config.return_value = None - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: mock_config)) monkeypatch.setattr(JinaClient, "crawl", mock_crawl) result = await web_fetch_tool.coroutine(url="https://example.com", runtime=_P2_RUNTIME) assert "Hello world" in result diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index b7cfcb543..833f16c4c 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -75,7 +75,6 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey import deerflow.tools as tools_module - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: app_config)) monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: []) monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda app_config, config, model_name, agent_name=None: []) @@ -123,7 +122,6 @@ def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch): ] ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: app_config)) monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda _ac: None) monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None) @@ -137,7 +135,6 @@ def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch): def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch): app_config = _make_app_config([_make_model("default", supports_thinking=False)]) patched = app_config.model_copy(update={"summarization": SummarizationConfig(enabled=True, model_name="model-masswork")}) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: patched)) from unittest.mock import MagicMock diff --git a/backend/tests/test_lead_agent_skills.py b/backend/tests/test_lead_agent_skills.py index fceb26967..2623b840f 100644 --- a/backend/tests/test_lead_agent_skills.py +++ b/backend/tests/test_lead_agent_skills.py @@ -3,7 +3,6 @@ from types import SimpleNamespace from deerflow.agents.lead_agent.prompt import get_skills_prompt_section from deerflow.config.agents_config import AgentConfig -from deerflow.config.app_config import AppConfig from deerflow.skills.types import Skill @@ -105,7 +104,6 @@ def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch): from deerflow.agents.lead_agent import agent as lead_agent_module # Mock dependencies - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: MagicMock())) monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda app_config=None, x=None: "default-model") monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model") monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: []) @@ -117,7 +115,6 @@ def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch): mock_app_config = MagicMock() mock_app_config.get_model_config.return_value = MockModelConfig() - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: mock_app_config)) captured_skills = [] diff --git a/backend/tests/test_local_bash_tool_loading.py b/backend/tests/test_local_bash_tool_loading.py index f12e105f3..1a58b4aac 100644 --- a/backend/tests/test_local_bash_tool_loading.py +++ b/backend/tests/test_local_bash_tool_loading.py @@ -1,6 +1,5 @@ from types import SimpleNamespace -from deerflow.config.app_config import AppConfig from deerflow.tools.tools import get_available_tools @@ -22,26 +21,26 @@ def _make_config(*, allow_host_bash: bool, sandbox_use: str = "deerflow.sandbox. def test_get_available_tools_hides_bash_for_default_local_sandbox(monkeypatch): - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: _make_config(allow_host_bash=False))) + app_config = _make_config(allow_host_bash=False) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=AppConfig.current())] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=app_config)] assert "bash" not in names assert "ls" in names def test_get_available_tools_keeps_bash_when_explicitly_enabled(monkeypatch): - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: _make_config(allow_host_bash=True))) + app_config = _make_config(allow_host_bash=True) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=AppConfig.current())] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=app_config)] assert "bash" in names assert "ls" in names @@ -52,13 +51,12 @@ def test_get_available_tools_hides_renamed_host_bash_alias(monkeypatch): allow_host_bash=False, extra_tools=[SimpleNamespace(name="shell", group="bash", use="deerflow.sandbox.tools:bash_tool")], ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash_tool" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=AppConfig.current())] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=config)] assert "bash" not in names assert "shell" not in names @@ -70,13 +68,12 @@ def test_get_available_tools_keeps_bash_for_aio_sandbox(monkeypatch): allow_host_bash=False, sandbox_use="deerflow.community.aio_sandbox:AioSandboxProvider", ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash_tool" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=AppConfig.current())] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=config)] assert "bash" in names assert "ls" in names diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py index 9a3bd1d7b..3fd38bdce 100644 --- a/backend/tests/test_local_sandbox_provider_mounts.py +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -1,10 +1,8 @@ import errno from types import SimpleNamespace -from unittest.mock import patch import pytest -from deerflow.config.app_config import AppConfig from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping from deerflow.sandbox.local.local_sandbox_provider import LocalSandboxProvider @@ -313,8 +311,7 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch.object(AppConfig, "current", return_value=config): - provider = LocalSandboxProvider(app_config=config) + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/custom-skills"] @@ -335,8 +332,7 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch.object(AppConfig, "current", return_value=config): - provider = LocalSandboxProvider(app_config=config) + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"] @@ -359,8 +355,7 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch.object(AppConfig, "current", return_value=config): - provider = LocalSandboxProvider(app_config=config) + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"] @@ -383,7 +378,6 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch.object(AppConfig, "current", return_value=config): - provider = LocalSandboxProvider(app_config=config) + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills", "/mnt/data"] diff --git a/backend/tests/test_memory_queue.py b/backend/tests/test_memory_queue.py index c972b4ca1..e9eb3accd 100644 --- a/backend/tests/test_memory_queue.py +++ b/backend/tests/test_memory_queue.py @@ -25,10 +25,7 @@ def _make_config(**memory_overrides) -> AppConfig: def test_queue_add_preserves_existing_correction_flag_for_same_thread() -> None: queue = MemoryUpdateQueue(_TEST_APP_CONFIG) - with ( - patch.object(AppConfig, "current", return_value=_make_config(enabled=True)), - patch.object(queue, "_reset_timer"), - ): + with patch.object(queue, "_reset_timer"): queue.add(thread_id="thread-1", messages=["first"], correction_detected=True) queue.add(thread_id="thread-1", messages=["second"], correction_detected=False) @@ -66,10 +63,7 @@ def test_process_queue_forwards_correction_flag_to_updater() -> None: def test_queue_add_preserves_existing_reinforcement_flag_for_same_thread() -> None: queue = MemoryUpdateQueue(_TEST_APP_CONFIG) - with ( - patch.object(AppConfig, "current", return_value=_make_config(enabled=True)), - patch.object(queue, "_reset_timer"), - ): + with patch.object(queue, "_reset_timer"): queue.add(thread_id="thread-1", messages=["first"], reinforcement_detected=True) queue.add(thread_id="thread-1", messages=["second"], reinforcement_detected=False) diff --git a/backend/tests/test_memory_queue_user_isolation.py b/backend/tests/test_memory_queue_user_isolation.py index 4572a8e7a..23fc948a0 100644 --- a/backend/tests/test_memory_queue_user_isolation.py +++ b/backend/tests/test_memory_queue_user_isolation.py @@ -25,7 +25,6 @@ def _enable_memory(monkeypatch): """Ensure MemoryUpdateQueue.add() doesn't early-return on disabled memory.""" config = MagicMock(spec=AppConfig) config.memory = MemoryConfig(enabled=True) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) def test_conversation_context_has_user_id(): diff --git a/backend/tests/test_memory_storage.py b/backend/tests/test_memory_storage.py index 24ad87ded..60957f562 100644 --- a/backend/tests/test_memory_storage.py +++ b/backend/tests/test_memory_storage.py @@ -71,10 +71,9 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch.object(AppConfig, "current", return_value=_app_config(storage_path="")): - storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) - path = storage._get_memory_file_path(None) - assert path == tmp_path / "memory.json" + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + path = storage._get_memory_file_path(None) + assert path == tmp_path / "memory.json" def test_get_memory_file_path_agent(self, tmp_path): """Should return per-agent memory file path when agent_name is provided.""" @@ -105,11 +104,10 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch.object(AppConfig, "current", return_value=_app_config(storage_path="")): - storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) - memory = storage.load() - assert isinstance(memory, dict) - assert memory["version"] == "1.0" + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + memory = storage.load() + assert isinstance(memory, dict) + assert memory["version"] == "1.0" def test_save_writes_to_file(self, tmp_path): """Should save memory data to file.""" @@ -121,12 +119,11 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch.object(AppConfig, "current", return_value=_app_config(storage_path="")): - storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) - test_memory = {"version": "1.0", "facts": [{"content": "test fact"}]} - result = storage.save(test_memory) - assert result is True - assert memory_file.exists() + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + test_memory = {"version": "1.0", "facts": [{"content": "test fact"}]} + result = storage.save(test_memory) + assert result is True + assert memory_file.exists() def test_reload_forces_cache_invalidation(self, tmp_path): """Should force reload from file and invalidate cache.""" @@ -140,18 +137,17 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch.object(AppConfig, "current", return_value=_app_config(storage_path="")): - storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) - # First load - memory1 = storage.load() - assert memory1["facts"][0]["content"] == "initial fact" + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + # First load + memory1 = storage.load() + assert memory1["facts"][0]["content"] == "initial fact" - # Update file directly - memory_file.write_text('{"version": "1.0", "facts": [{"content": "updated fact"}]}') + # Update file directly + memory_file.write_text('{"version": "1.0", "facts": [{"content": "updated fact"}]}') - # Reload should get updated data - memory2 = storage.reload() - assert memory2["facts"][0]["content"] == "updated fact" + # Reload should get updated data + memory2 = storage.reload() + assert memory2["facts"][0]["content"] == "updated fact" class TestGetMemoryStorage: @@ -168,22 +164,19 @@ class TestGetMemoryStorage: def test_returns_file_memory_storage_by_default(self): """Should return FileMemoryStorage by default.""" - with patch.object(AppConfig, "current", return_value=_app_config(storage_class="deerflow.agents.memory.storage.FileMemoryStorage")): - storage = get_memory_storage(_TEST_MEMORY_CONFIG) - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) def test_falls_back_to_file_memory_storage_on_error(self): """Should fall back to FileMemoryStorage if configured storage fails to load.""" - with patch.object(AppConfig, "current", return_value=_app_config(storage_class="non.existent.StorageClass")): - storage = get_memory_storage(_TEST_MEMORY_CONFIG) - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) def test_returns_singleton_instance(self): """Should return the same instance on subsequent calls.""" - with patch.object(AppConfig, "current", return_value=_app_config(storage_class="deerflow.agents.memory.storage.FileMemoryStorage")): - storage1 = get_memory_storage(_TEST_MEMORY_CONFIG) - storage2 = get_memory_storage(_TEST_MEMORY_CONFIG) - assert storage1 is storage2 + storage1 = get_memory_storage(_TEST_MEMORY_CONFIG) + storage2 = get_memory_storage(_TEST_MEMORY_CONFIG) + assert storage1 is storage2 def test_get_memory_storage_thread_safety(self): """Should safely initialize the singleton even with concurrent calls.""" @@ -195,12 +188,11 @@ class TestGetMemoryStorage: # that the singleton initialization remains thread-safe. results.append(get_memory_storage(_TEST_MEMORY_CONFIG)) - with patch.object(AppConfig, "current", return_value=_app_config(storage_class="deerflow.agents.memory.storage.FileMemoryStorage")): - threads = [threading.Thread(target=get_storage) for _ in range(10)] - for t in threads: - t.start() - for t in threads: - t.join() + threads = [threading.Thread(target=get_storage) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() # All results should be the exact same instance assert len(results) == 10 @@ -209,13 +201,11 @@ class TestGetMemoryStorage: def test_get_memory_storage_invalid_class_fallback(self): """Should fall back to FileMemoryStorage if the configured class is not actually a class.""" # Using a built-in function instead of a class - with patch.object(AppConfig, "current", return_value=_app_config(storage_class="os.path.join")): - storage = get_memory_storage(_TEST_MEMORY_CONFIG) - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) def test_get_memory_storage_non_subclass_fallback(self): """Should fall back to FileMemoryStorage if the configured class is not a subclass of MemoryStorage.""" # Using 'dict' as a class that is not a MemoryStorage subclass - with patch.object(AppConfig, "current", return_value=_app_config(storage_class="builtins.dict")): - storage = get_memory_storage(_TEST_MEMORY_CONFIG) - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) diff --git a/backend/tests/test_memory_storage_user_isolation.py b/backend/tests/test_memory_storage_user_isolation.py index 517845bb2..8e5438eff 100644 --- a/backend/tests/test_memory_storage_user_isolation.py +++ b/backend/tests/test_memory_storage_user_isolation.py @@ -36,12 +36,6 @@ def storage() -> FileMemoryStorage: return FileMemoryStorage(_TEST_MEMORY_CONFIG) -@pytest.fixture(autouse=True) -def _mock_current_config(): - """Ensure AppConfig.current() returns a minimal config for all tests.""" - cfg = _mock_app_config() - with patch.object(AppConfig, "current", return_value=cfg): - yield class TestUserIsolatedStorage: diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index 21e0a4331..3246ab44f 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -518,7 +518,6 @@ class TestUpdateMemoryStructuredResponse: with ( patch.object(updater, "_get_model", return_value=self._make_mock_model(valid_json)), - patch.object(AppConfig, "current", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -541,7 +540,6 @@ class TestUpdateMemoryStructuredResponse: with ( patch.object(updater, "_get_model", return_value=self._make_mock_model(list_content)), - patch.object(AppConfig, "current", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -563,7 +561,6 @@ class TestUpdateMemoryStructuredResponse: with ( patch.object(updater, "_get_model", return_value=model), - patch.object(AppConfig, "current", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -588,7 +585,6 @@ class TestUpdateMemoryStructuredResponse: with ( patch.object(updater, "_get_model", return_value=model), - patch.object(AppConfig, "current", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -680,7 +676,6 @@ class TestReinforcementHint: with ( patch.object(updater, "_get_model", return_value=model), - patch.object(AppConfig, "current", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -705,7 +700,6 @@ class TestReinforcementHint: with ( patch.object(updater, "_get_model", return_value=model), - patch.object(AppConfig, "current", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -730,7 +724,6 @@ class TestReinforcementHint: with ( patch.object(updater, "_get_model", return_value=model), - patch.object(AppConfig, "current", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index 09f19b5f0..6010aed80 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -72,8 +72,7 @@ class FakeChatModel(BaseChatModel): def _patch_factory(monkeypatch, app_config: AppConfig, model_class=FakeChatModel): - """Patch AppConfig.get, resolve_class, and tracing for isolated unit tests.""" - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: app_config)) + """Patch resolve_class and tracing for isolated unit tests.""" monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: model_class) monkeypatch.setattr(factory_module, "build_tracing_callbacks", lambda: []) @@ -88,7 +87,7 @@ def test_uses_first_model_when_name_is_none(monkeypatch): _patch_factory(monkeypatch, cfg) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name=None, app_config=AppConfig.current()) + factory_module.create_chat_model(name=None, app_config=cfg) # resolve_class is called — if we reach here without ValueError, the correct model was used assert FakeChatModel.captured_kwargs.get("model") == "alpha" @@ -96,11 +95,10 @@ def test_uses_first_model_when_name_is_none(monkeypatch): def test_raises_when_model_not_found(monkeypatch): cfg = _make_app_config([_make_model("only-model")]) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: cfg)) monkeypatch.setattr(factory_module, "build_tracing_callbacks", lambda: []) with pytest.raises(ValueError, match="ghost-model"): - factory_module.create_chat_model(name="ghost-model", app_config=AppConfig.current()) + factory_module.create_chat_model(name="ghost-model", app_config=cfg) def test_appends_all_tracing_callbacks(monkeypatch): @@ -109,7 +107,7 @@ def test_appends_all_tracing_callbacks(monkeypatch): monkeypatch.setattr(factory_module, "build_tracing_callbacks", lambda: ["smith-callback", "langfuse-callback"]) FakeChatModel.captured_kwargs = {} - model = factory_module.create_chat_model(name="alpha", app_config=AppConfig.current()) + model = factory_module.create_chat_model(name="alpha", app_config=cfg) assert model.callbacks == ["smith-callback", "langfuse-callback"] @@ -127,7 +125,7 @@ def test_thinking_enabled_raises_when_not_supported_but_when_thinking_enabled_is _patch_factory(monkeypatch, cfg) with pytest.raises(ValueError, match="does not support thinking"): - factory_module.create_chat_model(name="no-think", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="no-think", thinking_enabled=True, app_config=cfg) def test_thinking_enabled_raises_for_empty_when_thinking_enabled_explicitly_set(monkeypatch): @@ -138,7 +136,7 @@ def test_thinking_enabled_raises_for_empty_when_thinking_enabled_explicitly_set( _patch_factory(monkeypatch, cfg) with pytest.raises(ValueError, match="does not support thinking"): - factory_module.create_chat_model(name="no-think-empty", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="no-think-empty", thinking_enabled=True, app_config=cfg) def test_thinking_enabled_merges_when_thinking_enabled_settings(monkeypatch): @@ -147,7 +145,7 @@ def test_thinking_enabled_merges_when_thinking_enabled_settings(monkeypatch): _patch_factory(monkeypatch, cfg) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="thinker", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="thinker", thinking_enabled=True, app_config=cfg) assert FakeChatModel.captured_kwargs.get("temperature") == 1.0 assert FakeChatModel.captured_kwargs.get("max_tokens") == 16000 @@ -183,7 +181,7 @@ def test_thinking_disabled_openai_gateway_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="openai-gw", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="openai-gw", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == {"thinking": {"type": "disabled"}} assert captured.get("reasoning_effort") == "minimal" @@ -216,7 +214,7 @@ def test_thinking_disabled_langchain_anthropic_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="anthropic-native", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="anthropic-native", thinking_enabled=False, app_config=cfg) assert captured.get("thinking") == {"type": "disabled"} assert "extra_body" not in captured @@ -238,7 +236,7 @@ def test_thinking_disabled_no_when_thinking_enabled_does_nothing(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="plain", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="plain", thinking_enabled=False, app_config=cfg) assert "extra_body" not in captured assert "thinking" not in captured @@ -278,7 +276,7 @@ def test_when_thinking_disabled_takes_precedence_over_hardcoded_disable(monkeypa monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="custom-disable", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="custom-disable", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == {"thinking": {"type": "disabled"}} # User overrode the hardcoded "minimal" with "low" @@ -310,7 +308,7 @@ def test_when_thinking_disabled_not_used_when_thinking_enabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="wtd-ignored", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="wtd-ignored", thinking_enabled=True, app_config=cfg) # when_thinking_enabled should apply, NOT when_thinking_disabled assert captured.get("extra_body") == {"thinking": {"type": "enabled"}} @@ -339,7 +337,7 @@ def test_when_thinking_disabled_without_when_thinking_enabled_still_applies(monk monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="wtd-only", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="wtd-only", thinking_enabled=False, app_config=cfg) # when_thinking_disabled is now gated independently of has_thinking_settings assert captured.get("reasoning_effort") == "low" @@ -370,7 +368,7 @@ def test_when_thinking_disabled_excluded_from_model_dump(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="no-leak-wtd", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="no-leak-wtd", thinking_enabled=True, app_config=cfg) # when_thinking_disabled value must NOT appear as a raw key assert "when_thinking_disabled" not in captured @@ -394,7 +392,7 @@ def test_reasoning_effort_cleared_when_not_supported(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="no-effort", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="no-effort", thinking_enabled=False, app_config=cfg) assert captured.get("reasoning_effort") is None @@ -422,7 +420,7 @@ def test_reasoning_effort_preserved_when_supported(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="effort-model", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="effort-model", thinking_enabled=False, app_config=cfg) # When supports_reasoning_effort=True, it should NOT be cleared to None # The disable path sets it to "minimal"; supports_reasoning_effort=True keeps it @@ -458,7 +456,7 @@ def test_thinking_shortcut_enables_thinking_when_thinking_enabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="shortcut-model", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="shortcut-model", thinking_enabled=True, app_config=cfg) assert captured.get("thinking") == thinking_settings @@ -488,7 +486,7 @@ def test_thinking_shortcut_disables_thinking_when_thinking_disabled(monkeypatch) monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="shortcut-disable", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="shortcut-disable", thinking_enabled=False, app_config=cfg) assert captured.get("thinking") == {"type": "disabled"} assert "extra_body" not in captured @@ -520,7 +518,7 @@ def test_thinking_shortcut_merges_with_when_thinking_enabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="merge-model", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="merge-model", thinking_enabled=True, app_config=cfg) # Both the thinking shortcut and when_thinking_enabled settings should be applied assert captured.get("thinking") == thinking_settings @@ -552,7 +550,7 @@ def test_thinking_shortcut_not_leaked_into_model_when_disabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="no-leak", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="no-leak", thinking_enabled=False, app_config=cfg) # The disable path should have set thinking to disabled (not the raw enabled shortcut) assert captured.get("thinking") == {"type": "disabled"} @@ -590,7 +588,7 @@ def test_openai_compatible_provider_passes_base_url(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="minimax-m2.5", app_config=AppConfig.current()) + factory_module.create_chat_model(name="minimax-m2.5", app_config=cfg) assert captured.get("model") == "MiniMax-M2.5" assert captured.get("base_url") == "https://api.minimax.io/v1" @@ -638,11 +636,11 @@ def test_openai_compatible_provider_multiple_models(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) # Create first model - factory_module.create_chat_model(name="minimax-m2.5", app_config=AppConfig.current()) + factory_module.create_chat_model(name="minimax-m2.5", app_config=cfg) assert captured.get("model") == "MiniMax-M2.5" # Create second model - factory_module.create_chat_model(name="minimax-m2.5-highspeed", app_config=AppConfig.current()) + factory_module.create_chat_model(name="minimax-m2.5-highspeed", app_config=cfg) assert captured.get("model") == "MiniMax-M2.5-highspeed" @@ -670,7 +668,7 @@ def test_codex_provider_disables_reasoning_when_thinking_disabled(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="codex", thinking_enabled=False, app_config=cfg) assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "none" @@ -690,7 +688,7 @@ def test_codex_provider_preserves_explicit_reasoning_effort(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=True, reasoning_effort="high", app_config=AppConfig.current()) + factory_module.create_chat_model(name="codex", thinking_enabled=True, reasoning_effort="high", app_config=cfg) assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "high" @@ -710,7 +708,7 @@ def test_codex_provider_defaults_reasoning_effort_to_medium(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="codex", thinking_enabled=True, app_config=cfg) assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "medium" @@ -731,7 +729,7 @@ def test_codex_provider_strips_unsupported_max_tokens(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=True, app_config=AppConfig.current()) + factory_module.create_chat_model(name="codex", thinking_enabled=True, app_config=cfg) assert "max_tokens" not in FakeChatModel.captured_kwargs @@ -757,7 +755,7 @@ def test_thinking_disabled_vllm_chat_template_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="vllm-qwen", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="vllm-qwen", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == {"top_k": 20, "chat_template_kwargs": {"thinking": False}} assert captured.get("reasoning_effort") is None @@ -784,7 +782,7 @@ def test_thinking_disabled_vllm_enable_thinking_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="vllm-qwen-enable", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="vllm-qwen-enable", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == { "top_k": 20, @@ -818,7 +816,7 @@ def test_stream_usage_injected_for_openai_compatible_model(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="deepseek", app_config=AppConfig.current()) + factory_module.create_chat_model(name="deepseek", app_config=cfg) assert captured.get("stream_usage") is True @@ -837,7 +835,7 @@ def test_stream_usage_not_injected_for_non_openai_model(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="claude", app_config=AppConfig.current()) + factory_module.create_chat_model(name="claude", app_config=cfg) assert "stream_usage" not in captured @@ -867,7 +865,7 @@ def test_stream_usage_not_overridden_when_explicitly_set_in_config(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="deepseek", app_config=AppConfig.current()) + factory_module.create_chat_model(name="deepseek", app_config=cfg) assert captured.get("stream_usage") is False @@ -897,7 +895,7 @@ def test_openai_responses_api_settings_are_passed_to_chatopenai(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="gpt-5-responses", app_config=AppConfig.current()) + factory_module.create_chat_model(name="gpt-5-responses", app_config=cfg) assert captured.get("use_responses_api") is True assert captured.get("output_version") == "responses/v1" @@ -938,7 +936,7 @@ def test_no_duplicate_kwarg_when_reasoning_effort_in_config_and_thinking_disable _patch_factory(monkeypatch, cfg, model_class=CapturingModel) # Must not raise TypeError - factory_module.create_chat_model(name="doubao-model", thinking_enabled=False, app_config=AppConfig.current()) + factory_module.create_chat_model(name="doubao-model", thinking_enabled=False, app_config=cfg) # kwargs (runtime) takes precedence: thinking-disabled path sets reasoning_effort=minimal assert captured.get("reasoning_effort") == "minimal" diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py index 1ba372371..9345415bd 100644 --- a/backend/tests/test_sandbox_search_tools.py +++ b/backend/tests/test_sandbox_search_tools.py @@ -2,7 +2,6 @@ from types import SimpleNamespace from unittest.mock import patch from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox -from deerflow.config.app_config import AppConfig from deerflow.sandbox.local.local_sandbox import LocalSandbox from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches from deerflow.sandbox.tools import glob_tool, grep_tool @@ -111,8 +110,6 @@ def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None: (workspace / "main.py").write_text("TODO one\nTODO two\nTODO three\n", encoding="utf-8") monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) - # Prevent config.yaml tool config from overriding the caller-supplied max_results=2. - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: SimpleNamespace(get_tool_config=lambda name: None))) result = grep_tool.func( runtime=runtime, @@ -332,10 +329,6 @@ def test_glob_tool_honors_smaller_requested_max_results(tmp_path, monkeypatch) - (workspace / "c.py").write_text("print('c')\n", encoding="utf-8") monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) - monkeypatch.setattr( - AppConfig, "current", - staticmethod(lambda: SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra={"max_results": 50}))), - ) result = glob_tool.func( runtime=runtime, diff --git a/backend/tests/test_security_scanner.py b/backend/tests/test_security_scanner.py index d21e7d7ef..d96549bdf 100644 --- a/backend/tests/test_security_scanner.py +++ b/backend/tests/test_security_scanner.py @@ -2,14 +2,12 @@ from types import SimpleNamespace import pytest -from deerflow.config.app_config import AppConfig from deerflow.skills.security_scanner import scan_skill_content @pytest.mark.anyio async def test_scan_skill_content_blocks_when_model_unavailable(monkeypatch): config = SimpleNamespace(skill_evolution=SimpleNamespace(moderation_model_name=None)) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) monkeypatch.setattr("deerflow.skills.security_scanner.create_chat_model", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) result = await scan_skill_content(config, "---\nname: demo-skill\ndescription: demo\n---\n", executable=False) diff --git a/backend/tests/test_skill_manage_tool.py b/backend/tests/test_skill_manage_tool.py index 4ce00d6c0..81bf49766 100644 --- a/backend/tests/test_skill_manage_tool.py +++ b/backend/tests/test_skill_manage_tool.py @@ -34,7 +34,6 @@ def test_skill_manage_create_and_patch(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) refresh_calls = [] async def _refresh(*a, **k): @@ -76,7 +75,6 @@ def test_skill_manage_patch_replaces_single_occurrence_by_default(monkeypatch, t skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) async def _refresh(*a, **k): return None @@ -114,7 +112,6 @@ def test_skill_manage_rejects_public_skill_patch(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) runtime = SimpleNamespace(context=_make_context("", config), config={"configurable": {}}) @@ -137,7 +134,6 @@ def test_skill_manage_sync_wrapper_supported(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) refresh_calls = [] async def _refresh(*a, **k): @@ -164,7 +160,6 @@ def test_skill_manage_rejects_support_path_traversal(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) async def _refresh(*a, **k): return None diff --git a/backend/tests/test_skills_custom_router.py b/backend/tests/test_skills_custom_router.py index 54bd7bc77..c15d7ace3 100644 --- a/backend/tests/test_skills_custom_router.py +++ b/backend/tests/test_skills_custom_router.py @@ -46,7 +46,6 @@ def test_custom_skills_router_lifecycle(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok")) refresh_calls = [] @@ -96,7 +95,6 @@ def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) get_skill_history_file("demo-skill", config).write_text( '{"action":"human_edit","prev_content":' + json.dumps(original_content) + ',"new_content":' + json.dumps(edited_content) + "}\n", encoding="utf-8", @@ -138,7 +136,6 @@ def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, t skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config)) monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok")) refresh_calls = [] @@ -185,7 +182,6 @@ def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path _app_cfg = AppConfig(sandbox=SandboxConfig(use="test"), extensions=ExtensionsConfig(mcp_servers={}, skills={})) monkeypatch.setattr("app.gateway.routers.skills.load_skills", _load_skills) - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: _app_cfg)) monkeypatch.setattr(AppConfig, "from_file", staticmethod(lambda: _app_cfg)) monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path)) monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) diff --git a/backend/tests/test_subagent_timeout_config.py b/backend/tests/test_subagent_timeout_config.py index a18bc32f6..d3f695534 100644 --- a/backend/tests/test_subagent_timeout_config.py +++ b/backend/tests/test_subagent_timeout_config.py @@ -3,14 +3,12 @@ Covers: - SubagentsAppConfig / SubagentOverrideConfig model validation and defaults - get_timeout_for() / get_max_turns_for() resolution logic -- AppConfig.subagents field access via AppConfig.current() +- AppConfig.subagents field access - registry.get_subagent_config() applies config overrides - registry.list_subagents() applies overrides for all agents - Polling timeout calculation in task_tool is consistent with config """ -from unittest.mock import patch - import pytest from deerflow.config.app_config import AppConfig @@ -133,17 +131,16 @@ class TestMaxTurnsResolution: # --------------------------------------------------------------------------- -# AppConfig.subagents via AppConfig.current() +# AppConfig.subagents # --------------------------------------------------------------------------- class TestAppConfigSubagents: def test_load_global_timeout(self): cfg = _make_config(timeout_seconds=300, max_turns=120) - with patch.object(AppConfig, "current", return_value=cfg): - sub = AppConfig.current().subagents - assert sub.timeout_seconds == 300 - assert sub.max_turns == 120 + sub = cfg.subagents + assert sub.timeout_seconds == 300 + assert sub.max_turns == 120 def test_load_with_per_agent_overrides(self): cfg = _make_config( @@ -154,29 +151,26 @@ class TestAppConfigSubagents: "bash": {"timeout_seconds": 60, "max_turns": 80}, }, ) - with patch.object(AppConfig, "current", return_value=cfg): - sub = AppConfig.current().subagents - assert sub.get_timeout_for("general-purpose") == 1800 - assert sub.get_timeout_for("bash") == 60 - assert sub.get_max_turns_for("general-purpose", 100) == 200 - assert sub.get_max_turns_for("bash", 60) == 80 + sub = cfg.subagents + assert sub.get_timeout_for("general-purpose") == 1800 + assert sub.get_timeout_for("bash") == 60 + assert sub.get_max_turns_for("general-purpose", 100) == 200 + assert sub.get_max_turns_for("bash", 60) == 80 def test_load_partial_override(self): cfg = _make_config( timeout_seconds=600, agents={"bash": {"timeout_seconds": 120, "max_turns": 70}}, ) - with patch.object(AppConfig, "current", return_value=cfg): - sub = AppConfig.current().subagents - assert sub.get_timeout_for("general-purpose") == 600 - assert sub.get_timeout_for("bash") == 120 - assert sub.get_max_turns_for("general-purpose", 100) == 100 - assert sub.get_max_turns_for("bash", 60) == 70 + sub = cfg.subagents + assert sub.get_timeout_for("general-purpose") == 600 + assert sub.get_timeout_for("bash") == 120 + assert sub.get_max_turns_for("general-purpose", 100) == 100 + assert sub.get_max_turns_for("bash", 60) == 70 def test_load_empty_uses_defaults(self): cfg = _make_config() - with patch.object(AppConfig, "current", return_value=cfg): - sub = AppConfig.current().subagents - assert sub.timeout_seconds == 900 - assert sub.max_turns is None - assert sub.agents == {} + sub = cfg.subagents + assert sub.timeout_seconds == 900 + assert sub.max_turns is None + assert sub.agents == {} diff --git a/backend/tests/test_token_usage.py b/backend/tests/test_token_usage.py index 8aa54fe9e..977756157 100644 --- a/backend/tests/test_token_usage.py +++ b/backend/tests/test_token_usage.py @@ -7,7 +7,6 @@ from unittest.mock import MagicMock, patch from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from deerflow.client import DeerFlowClient -from deerflow.config.app_config import AppConfig # --------------------------------------------------------------------------- # _serialize_message — usage_metadata passthrough @@ -155,8 +154,7 @@ class TestStreamUsageIntegration: """Test that stream() emits usage_metadata in messages-tuple and end events.""" def _make_client(self): - with patch.object(AppConfig, "current", return_value=_mock_app_config()): - return DeerFlowClient() + return DeerFlowClient() def test_stream_emits_usage_in_messages_tuple(self): """messages-tuple AI event should include usage_metadata when present.""" diff --git a/backend/tests/test_tool_search.py b/backend/tests/test_tool_search.py index 6580e4570..f51b43409 100644 --- a/backend/tests/test_tool_search.py +++ b/backend/tests/test_tool_search.py @@ -6,7 +6,6 @@ import sys import pytest from langchain_core.tools import tool as langchain_tool -from deerflow.config.app_config import AppConfig from deerflow.config.tool_search_config import ToolSearchConfig from deerflow.tools.builtins.tool_search import ( DeferredToolRegistry, @@ -255,42 +254,42 @@ class TestToolSearchTool: class TestDeferredToolsPromptSection: - @pytest.fixture(autouse=True) - def _mock_app_config(self, monkeypatch): + @pytest.fixture + def mock_config(self): """Provide a minimal AppConfig mock so tests don't need config.yaml.""" from unittest.mock import MagicMock from deerflow.config.tool_search_config import ToolSearchConfig - mock_config = MagicMock() - mock_config.tool_search = ToolSearchConfig() # disabled by default - monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: mock_config)) + config = MagicMock() + config.tool_search = ToolSearchConfig() # disabled by default + return config - def test_empty_when_disabled(self): + def test_empty_when_disabled(self, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section # tool_search.enabled defaults to False - section = get_deferred_tools_prompt_section(AppConfig.current()) + section = get_deferred_tools_prompt_section(mock_config) assert section == "" - def test_empty_when_enabled_but_no_registry(self, monkeypatch): + def test_empty_when_enabled_but_no_registry(self, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section - AppConfig.current().tool_search = ToolSearchConfig(enabled=True) - section = get_deferred_tools_prompt_section(AppConfig.current()) + mock_config.tool_search = ToolSearchConfig(enabled=True) + section = get_deferred_tools_prompt_section(mock_config) assert section == "" - def test_empty_when_enabled_but_empty_registry(self, monkeypatch): + def test_empty_when_enabled_but_empty_registry(self, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section - AppConfig.current().tool_search = ToolSearchConfig(enabled=True) + mock_config.tool_search = ToolSearchConfig(enabled=True) set_deferred_registry(DeferredToolRegistry()) - section = get_deferred_tools_prompt_section(AppConfig.current()) + section = get_deferred_tools_prompt_section(mock_config) assert section == "" - def test_lists_tool_names(self, registry, monkeypatch): + def test_lists_tool_names(self, registry, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section - AppConfig.current().tool_search = ToolSearchConfig(enabled=True) + mock_config.tool_search = ToolSearchConfig(enabled=True) set_deferred_registry(registry) - section = get_deferred_tools_prompt_section(AppConfig.current()) + section = get_deferred_tools_prompt_section(mock_config) assert "" in section assert "" in section assert "github_create_issue" in section diff --git a/docs/plans/2026-04-12-config-refactor-plan.md b/docs/plans/2026-04-12-config-refactor-plan.md index 82210bdeb..18a275b36 100644 --- a/docs/plans/2026-04-12-config-refactor-plan.md +++ b/docs/plans/2026-04-12-config-refactor-plan.md @@ -239,11 +239,9 @@ Commit: `a934a822`. # Phase 2: Pure explicit parameter passing -> **Status:** Partially shipped. Tasks P2-1 through P2-5 merged (explicit-config primitives added alongside `AppConfig.current()` fallbacks). P2-6 through P2-10 remain open — they remove the fallbacks and finish the migration. +> **Status:** Shipped. P2-1..P2-5 landed first with `AppConfig.current()` kept as a transition fallback; P2-6..P2-10 landed together in commit `84dccef2` to eliminate the fallback and delete the ambient-lookup surface entirely. `AppConfig` is now a pure Pydantic value object with no process-global state and no classmethod accessors. > > **Design:** [§8 of the design doc](./2026-04-12-config-refactor-design.md#8-phase-2-pure-explicit-parameter-passing) -> -> **Goal:** Delete `AppConfig._global`, `_override`, `init`, `current`, `set_override`, `reset_override`. `AppConfig` becomes a pure Pydantic value object. Every consumer receives config as an explicit parameter. ## Shipped commits @@ -254,39 +252,41 @@ Commit: `a934a822`. | `f8738d1e` | P2-3 | H (Client) | `DeerFlowClient.__init__(config=...)` captures config locally; multi-client isolation test pins invariant | | `23b424e7` | P2-4 | B (Agent construction) | `make_lead_agent`, `_build_middlewares`, `_resolve_model_name`, `build_lead_runtime_middlewares` accept optional `app_config` | | `74b7a7ef` | P2-5 (partial) | D (Runtime) | `RunContext` gains `app_config` field; Worker builds `DeerFlowContext` from it; Gateway `deps.get_run_context` populates it. Standalone providers (checkpointer/store/stream_bridge) already accept optional config from Phase 1 | +| `84dccef2` | P2-6..P2-10 | C+E+F+I + deletion | Memory closure-captures `MemoryConfig`; sandbox/skills/community/factories/tools thread `app_config` end-to-end; `resolve_context()` rejects non-typed runtime.context; `AppConfig.current()` removed; `get_sandbox_provider(app_config)` required; `make_lead_agent` LangGraph-Server bootstrap path loads via `AppConfig.from_file()`. All 2337 non-e2e tests pass. | -All five commits preserve backward compatibility by keeping `AppConfig.current()` as a fallback. No caller is broken. +## Completed tasks (P2-6 through P2-10) -## Deferred tasks (P2-6 through P2-10) +All landed in `84dccef2`. -Each remaining task deletes a `AppConfig.current()` call path after migrating the consumers that still use it. They can land incrementally. +### P2-6: Memory subsystem closure-captured config (Category C) — shipped +- [x] `MemoryConfig` captured at enqueue time so the Timer thread survives the ContextVar boundary. +- [x] `deerflow/agents/memory/{queue,updater,storage}.py` no longer read any process-global. -### P2-6: Memory subsystem closure-captured config (Category C) -- Files: `deerflow/agents/memory/{queue,updater,storage}.py` (8 calls) -- Pattern: `MemoryQueue.__init__(config: MemoryConfig)`, captured in Timer closure so the callback thread has config without consulting any global. -- Risk: medium — Timer thread runs outside ContextVar scope; closure capture at enqueue time is the only safe path. +### P2-7: Sandbox / skills / factories / tools / community (Categories E+F) — shipped +- [x] `sandbox/tools.py` helpers take `app_config` explicitly; the `_cached` attribute trick is gone. +- [x] `sandbox/security.py`, `sandbox/sandbox_provider.py`, `sandbox/local/local_sandbox_provider.py`, `community/aio_sandbox/aio_sandbox_provider.py` all require `app_config`. +- [x] `skills/manager.py` + `skills/loader.py` + `agents/lead_agent/prompt.py` cache refresh thread `app_config` through the worker thread via closure. +- [x] Community tools (tavily, jina, firecrawl, exa, ddg, image_search, infoquest, aio_sandbox) read `resolve_context(runtime).app_config`. +- [x] `subagents/registry.py` (`get_subagent_config`, `list_subagents`, `get_available_subagent_names`) take `app_config`. +- [x] `models/factory.py::create_chat_model` and `tools/tools.py::get_available_tools` require `app_config`. -### P2-7: Sandbox / skills / factories / tools / community (Categories E+F) -- Files: ~20 modules, ~35 call sites -- Pattern: function signature gains `config: AppConfig`; caller threads it down. -- Risk: low — mechanical. Each file is self-contained. +### P2-8: Test fixtures (Category I) — shipped +- [x] `conftest.py` autouse fixture no longer monkey-patches `AppConfig.current`; it only stubs `from_file()` so tests don't need a real `config.yaml`. +- [x] ~90 call sites migrated: `patch.object(AppConfig, "current", ...)` removed where production no longer calls it (≈56 sites), and for the remaining ~10 files whose tests called `AppConfig.current()` themselves, the tests now hold the config in a local variable and pass it explicitly. +- [x] `test_deer_flow_context.py` updated to assert that `resolve_context()` raises on dict/None contexts. +- [x] `grep -rn 'AppConfig\.current' backend/tests` is clean. -### P2-8: Test fixtures (Category I) -- Files: ~18 test files, ~91 `patch.object(AppConfig, "current")` or `_global` mutations -- Pattern: replace mock with `test_config` fixture returning an `AppConfig`; pass explicitly to function under test. -- Risk: medium — largest diff. Can land file-by-file. +### P2-9: Simplify `resolve_context()` — shipped +- [x] `resolve_context(runtime)` returns `runtime.context` when it is a `DeerFlowContext`; any other shape raises `RuntimeError` pointing at the composition root that should have attached the typed context. +- [x] The dict-context and `get_config().configurable` fallbacks are deleted. -### P2-9: Simplify `resolve_context()` -- File: `deerflow/config/deer_flow_context.py` -- Pattern: remove `AppConfig.current()` fallback; raise on non-`DeerFlowContext` runtime.context. -- Risk: low — one function. Depends on P2-6/7/8 to not leave dict-context callers. - -### P2-10: Delete `AppConfig` lifecycle -- Files: `config/app_config.py`, `tests/conftest.py`, `tests/test_app_config_reload.py`, `backend/CLAUDE.md` -- Pattern: grep confirms zero callers of `current()`, `init()`, `set_override()`, `reset_override()`; delete them plus `_global` and `_override`. -- Risk: high if P2-6/7/8 incomplete — grep gate must be clean. - -The detailed task-level step descriptions below were written before Phase 2 was split into shippable chunks. They remain accurate for the work that lies ahead. +### P2-10: Delete `AppConfig` lifecycle — shipped +- [x] `AppConfig.current()` classmethod removed. +- [x] `_global` / `_override` / `init` / `set_override` / `reset_override` already gone as of Phase 1; nothing left to delete on the ambient side. +- [x] LangGraph Server bootstrap uses `AppConfig.from_file()` inside `make_lead_agent` — a pure load, not an ambient lookup. +- [x] `backend/CLAUDE.md` Config Lifecycle section rewritten to describe the explicit-parameter design. +- [x] `app/gateway/deps.py` docstrings no longer mention `AppConfig.current()`. +- [x] Production grep confirms zero `AppConfig.current()` call sites in `backend/packages` or `backend/app`. ## Rationale