Files
deer-flow/docs/plans/2026-04-12-config-refactor-plan.md
T
greatmengqi edf345cd72 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
2026-04-14 01:18:19 +08:00

1106 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Config Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate global mutable state in the configuration system — frozen AppConfig, pure `from_file()`, single ContextVar, `Runtime[DeerFlowContext]` propagation.
**Architecture:** All config models become `frozen=True`. `from_file()` becomes a pure function (no side effects). Sub-config module globals are deleted; consumers migrate to `get_app_config().xxx`. Agent execution path uses LangGraph `Runtime[DeerFlowContext]` for typed, per-invocation config access. Gateway path uses a single ContextVar.
**Tech Stack:** Pydantic v2 (`frozen=True`, `model_copy`), Python `contextvars.ContextVar`, LangGraph `Runtime`/`ToolRuntime` (>= 1.1.5)
**Design Spec:** `docs/plans/2026-04-12-config-refactor-design.md`
**Issues:** #2151 (implementation), #1811 (RFC)
---
## File Structure
### New files
| File | Responsibility |
|------|---------------|
| `deerflow/config/context.py` | `DeerFlowContext` frozen dataclass + `init_app_config()` / `get_app_config()` backed by single ContextVar |
### Modified files (config layer)
| File | Change |
|------|--------|
| `deerflow/config/app_config.py` | `frozen=True`, purify `from_file()`, delete mtime/reload/reset/push/pop machinery |
| `deerflow/config/memory_config.py` | `frozen=True`, delete globals (`_memory_config`, `get_memory_config`, `set_memory_config`, `load_memory_config_from_dict`) |
| `deerflow/config/title_config.py` | Same pattern |
| `deerflow/config/summarization_config.py` | Same pattern |
| `deerflow/config/subagents_config.py` | Same pattern |
| `deerflow/config/guardrails_config.py` | Same pattern (also delete `reset_guardrails_config`) |
| `deerflow/config/tool_search_config.py` | Same pattern |
| `deerflow/config/checkpointer_config.py` | Same pattern |
| `deerflow/config/stream_bridge_config.py` | Same pattern |
| `deerflow/config/acp_config.py` | Same pattern |
| `deerflow/config/extensions_config.py` | `frozen=True`, delete globals (`_extensions_config`, `reload_extensions_config`, `reset_extensions_config`, `set_extensions_config`) |
| `deerflow/config/__init__.py` | Update exports — remove deleted getters, add `init_app_config`, `DeerFlowContext` |
### Modified files (consumers — production code)
| File | Change |
|------|--------|
| `deerflow/agents/lead_agent/agent.py` | `get_app_config()` calls stay; `get_summarization_config()``get_app_config().summarization` |
| `deerflow/agents/lead_agent/prompt.py` | `get_memory_config()``get_app_config().memory`; `get_acp_agents()``get_app_config()` based |
| `deerflow/agents/middlewares/memory_middleware.py` | `get_memory_config()` → read from `Runtime` or `get_app_config().memory` |
| `deerflow/agents/middlewares/title_middleware.py` | `get_title_config()` → read from `Runtime` or `get_app_config().title` |
| `deerflow/agents/middlewares/tool_error_handling_middleware.py` | `get_guardrails_config()``get_app_config().guardrails` |
| `deerflow/agents/memory/updater.py` | `get_memory_config()``get_app_config().memory` |
| `deerflow/agents/memory/queue.py` | `get_memory_config()``get_app_config().memory` |
| `deerflow/agents/memory/storage.py` | `get_memory_config()``get_app_config().memory` |
| `deerflow/agents/checkpointer/provider.py` | `get_checkpointer_config()``get_app_config().checkpointer` |
| `deerflow/runtime/store/provider.py` | `get_checkpointer_config()``get_app_config().checkpointer` |
| `deerflow/runtime/stream_bridge/async_provider.py` | `get_stream_bridge_config()``get_app_config().stream_bridge` |
| `deerflow/subagents/registry.py` | `get_subagents_app_config()``get_app_config().subagents` |
| `deerflow/tools/tools.py` | `get_acp_agents()``get_app_config()` based |
| `deerflow/client.py` | Remove `reload_app_config`/`reload_extensions_config` imports and calls; use `init_app_config()` |
| `app/gateway/routers/mcp.py` | `reload_extensions_config()` → construct new config + `init_app_config()` |
| `app/gateway/routers/skills.py` | Same |
| `app/gateway/routers/memory.py` | `get_memory_config()``get_app_config().memory` |
| `app/gateway/app.py` | Call `init_app_config()` at startup |
### Modified files (tests)
~100 test locations need updating. Pattern: replace `patch("...get_memory_config", ...)` with `patch("...get_app_config", ...)` returning a frozen AppConfig with the desired sub-config.
---
## Task 1: Freeze all sub-config models
**Files:**
- Modify: `deerflow/config/memory_config.py`
- Modify: `deerflow/config/title_config.py`
- Modify: `deerflow/config/summarization_config.py`
- Modify: `deerflow/config/subagents_config.py`
- Modify: `deerflow/config/guardrails_config.py`
- Modify: `deerflow/config/tool_search_config.py`
- Modify: `deerflow/config/checkpointer_config.py`
- Modify: `deerflow/config/stream_bridge_config.py`
- Modify: `deerflow/config/token_usage_config.py`
- Modify: `deerflow/config/skills_config.py`
- Modify: `deerflow/config/skill_evolution_config.py`
- Modify: `deerflow/config/sandbox_config.py`
- Modify: `deerflow/config/model_config.py`
- Modify: `deerflow/config/tool_config.py`
- Modify: `deerflow/config/agents_config.py`
- Modify: `deerflow/config/extensions_config.py` (McpServerConfig, McpOAuthConfig, SkillStateConfig, ExtensionsConfig)
- Test: `tests/test_config_frozen.py`
- [ ] **Step 1: Write test that all config models are frozen**
```python
# tests/test_config_frozen.py
import pytest
from pydantic import ValidationError
from deerflow.config.memory_config import MemoryConfig
from deerflow.config.title_config import TitleConfig
from deerflow.config.summarization_config import SummarizationConfig
from deerflow.config.subagents_config import SubagentsAppConfig
from deerflow.config.guardrails_config import GuardrailsConfig
from deerflow.config.tool_search_config import ToolSearchConfig
from deerflow.config.checkpointer_config import CheckpointerConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig
from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.skills_config import SkillsConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
@pytest.mark.parametrize("cls,kwargs", [
(MemoryConfig, {}),
(TitleConfig, {}),
(SummarizationConfig, {}),
(SubagentsAppConfig, {}),
(GuardrailsConfig, {}),
(ToolSearchConfig, {}),
(TokenUsageConfig, {}),
(SkillsConfig, {}),
(SkillEvolutionConfig, {}),
(McpServerConfig, {}),
(ExtensionsConfig, {}),
])
def test_config_model_is_frozen(cls, kwargs):
"""All config models must be frozen — mutation raises ValidationError."""
instance = cls(**kwargs)
first_field = next(iter(cls.model_fields))
with pytest.raises(ValidationError):
setattr(instance, first_field, getattr(instance, first_field))
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_config_frozen.py -v`
Expected: FAIL — models are not frozen yet
- [ ] **Step 3: Add `frozen=True` to every config model**
Add `model_config = ConfigDict(frozen=True)` (or update existing `ConfigDict`) in each file listed above. For models that already have `ConfigDict(extra="allow")`, change to `ConfigDict(extra="allow", frozen=True)`.
Example for `memory_config.py`:
```python
from pydantic import BaseModel, ConfigDict, Field
class MemoryConfig(BaseModel):
model_config = ConfigDict(frozen=True)
# ... fields unchanged
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_config_frozen.py -v`
Expected: PASS
- [ ] **Step 5: Run full test suite, fix any tests that mutate config objects**
Run: `cd backend && PYTHONPATH=. uv run pytest -x -v 2>&1 | head -100`
If tests fail because they mutate frozen config objects, fix them using `model_copy(update={...})` or by constructing fresh instances.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "refactor(config): make all config models frozen"
```
---
## Task 2: Freeze AppConfig
**Files:**
- Modify: `deerflow/config/app_config.py`
- Test: `tests/test_config_frozen.py` (extend)
- [ ] **Step 1: Add AppConfig frozen test**
```python
# Append to tests/test_config_frozen.py
from deerflow.config.app_config import AppConfig
def test_app_config_is_frozen():
config = AppConfig(sandbox={"use": "test"})
with pytest.raises(ValidationError):
config.log_level = "debug"
```
- [ ] **Step 2: Run test — should fail**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_config_frozen.py::test_app_config_is_frozen -v`
Expected: FAIL
- [ ] **Step 3: Set `frozen=True` on AppConfig**
In `app_config.py`, change:
```python
model_config = ConfigDict(extra="allow", frozen=False)
```
to:
```python
model_config = ConfigDict(extra="allow", frozen=True)
```
- [ ] **Step 4: Run test — should pass**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_config_frozen.py::test_app_config_is_frozen -v`
Expected: PASS
- [ ] **Step 5: Run full test suite, fix failures**
Run: `cd backend && PYTHONPATH=. uv run pytest -x -v 2>&1 | head -100`
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "refactor(config): make AppConfig frozen"
```
---
## Task 3: Purify `from_file()`
Remove the 8 `load_*_from_dict()` side-effect calls from `AppConfig.from_file()`. Sub-config data already flows through AppConfig fields — the globals are redundant.
**Files:**
- Modify: `deerflow/config/app_config.py`
- Test: `tests/test_from_file_pure.py`
- [ ] **Step 1: Write test that `from_file()` does not mutate sub-module globals**
```python
# tests/test_from_file_pure.py
from unittest.mock import patch
from deerflow.config.app_config import AppConfig
def test_from_file_does_not_call_load_functions(tmp_path):
"""from_file() must be pure — no side effects on sub-modules."""
config_file = tmp_path / "config.yaml"
config_file.write_text("""
config_version: 6
models: []
sandbox:
use: "deerflow.sandbox.local:LocalSandboxProvider"
memory:
enabled: false
title:
enabled: false
""")
load_fns = [
"deerflow.config.app_config.load_title_config_from_dict",
"deerflow.config.app_config.load_summarization_config_from_dict",
"deerflow.config.app_config.load_memory_config_from_dict",
"deerflow.config.app_config.load_subagents_config_from_dict",
"deerflow.config.app_config.load_tool_search_config_from_dict",
"deerflow.config.app_config.load_guardrails_config_from_dict",
"deerflow.config.app_config.load_checkpointer_config_from_dict",
"deerflow.config.app_config.load_stream_bridge_config_from_dict",
"deerflow.config.app_config.load_acp_config_from_dict",
]
patches = [patch(fn) for fn in load_fns]
mocks = [p.start() for p in patches]
result = AppConfig.from_file(str(config_file))
for mock, fn_name in zip(mocks, load_fns):
mock.assert_not_called(), f"{fn_name} should not be called by pure from_file()"
for p in patches:
p.stop()
assert result.memory.enabled is False
assert result.title.enabled is False
```
- [ ] **Step 2: Run test — should fail**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_from_file_pure.py -v`
Expected: FAIL — `from_file()` still calls `load_*_from_dict()`
- [ ] **Step 3: Remove all `load_*_from_dict()` calls from `from_file()`**
In `app_config.py`, delete these blocks from `from_file()`:
```python
# DELETE all of these:
if "title" in config_data:
load_title_config_from_dict(config_data["title"])
if "summarization" in config_data:
load_summarization_config_from_dict(config_data["summarization"])
if "memory" in config_data:
load_memory_config_from_dict(config_data["memory"])
if "subagents" in config_data:
load_subagents_config_from_dict(config_data["subagents"])
if "tool_search" in config_data:
load_tool_search_config_from_dict(config_data["tool_search"])
if "guardrails" in config_data:
load_guardrails_config_from_dict(config_data["guardrails"])
if "checkpointer" in config_data:
load_checkpointer_config_from_dict(config_data["checkpointer"])
if "stream_bridge" in config_data:
load_stream_bridge_config_from_dict(config_data["stream_bridge"])
load_acp_config_from_dict(config_data.get("acp_agents", {}))
```
Also remove the corresponding imports at the top of the file.
- [ ] **Step 4: Run test — should pass**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_from_file_pure.py -v`
Expected: PASS
- [ ] **Step 5: Run full test suite, fix failures**
Tests that relied on `from_file()` populating sub-module globals will now fail. Fix them by reading from AppConfig fields instead.
Run: `cd backend && PYTHONPATH=. uv run pytest -x -v 2>&1 | head -100`
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "refactor(config): purify from_file() — remove side-effect load calls"
```
---
## Task 4: Replace app_config.py lifecycle with single ContextVar
Replace the current mtime/reload/push/pop machinery with a simple ContextVar.
**Files:**
- Create: `deerflow/config/context.py`
- Modify: `deerflow/config/app_config.py`
- Modify: `deerflow/config/__init__.py`
- Test: `tests/test_config_context.py`
- [ ] **Step 1: Write tests for new ContextVar-based lifecycle**
```python
# tests/test_config_context.py
import pytest
from deerflow.config.context import init_app_config, get_app_config, ConfigNotInitializedError
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
def _make_config(**overrides) -> AppConfig:
defaults = {"sandbox": SandboxConfig(use="test")}
defaults.update(overrides)
return AppConfig(**defaults)
def test_get_before_init_raises():
"""get_app_config() must raise if init_app_config() was not called."""
# Note: this test must run in a fresh context — use contextvars.copy_context()
import contextvars
ctx = contextvars.copy_context()
with pytest.raises(ConfigNotInitializedError):
ctx.run(get_app_config)
def test_init_then_get():
import contextvars
config = _make_config()
ctx = contextvars.copy_context()
ctx.run(init_app_config, config)
result = ctx.run(get_app_config)
assert result is config
def test_init_replaces_previous():
import contextvars
config_a = _make_config(log_level="info")
config_b = _make_config(log_level="debug")
ctx = contextvars.copy_context()
ctx.run(init_app_config, config_a)
ctx.run(init_app_config, config_b)
result = ctx.run(get_app_config)
assert result.log_level == "debug"
```
- [ ] **Step 2: Run test — should fail**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_config_context.py -v`
Expected: FAIL — `context.py` does not exist yet
- [ ] **Step 3: Create `deerflow/config/context.py`**
```python
"""Single ContextVar for AppConfig lifecycle."""
from contextvars import ContextVar
from deerflow.config.app_config import AppConfig
class ConfigNotInitializedError(RuntimeError):
"""Raised when get_app_config() is called before init_app_config()."""
def __init__(self):
super().__init__(
"AppConfig not initialized. Call init_app_config() at process startup."
)
_app_config_var: ContextVar[AppConfig] = ContextVar("deerflow_app_config")
def init_app_config(config: AppConfig) -> None:
"""Set the AppConfig for the current context. Call once at process startup."""
_app_config_var.set(config)
def get_app_config() -> AppConfig:
"""Get the current AppConfig. Raises ConfigNotInitializedError if not initialized."""
try:
return _app_config_var.get()
except LookupError:
raise ConfigNotInitializedError()
```
- [ ] **Step 4: Run test — should pass**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_config_context.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor(config): add context.py with ContextVar-based lifecycle"
```
---
## Task 5: Migrate `get_app_config` imports to new context module
Replace the old `get_app_config` (from `app_config.py`) with the new one (from `context.py`) across all consumers. The old module's `get_app_config`, `reload_app_config`, `reset_app_config`, `set_app_config`, `push/pop_current_app_config` are deleted.
**Files:**
- Modify: `deerflow/config/__init__.py` — re-export `get_app_config` and `init_app_config` from `context.py`
- Modify: `deerflow/config/app_config.py` — delete `get_app_config`, `reload_app_config`, `reset_app_config`, `set_app_config`, `push/pop_current_app_config`, `_load_and_cache_app_config`, mtime globals
- Modify: `deerflow/client.py` — use `init_app_config` instead of `reload_app_config`
- Modify: `app/gateway/app.py` — call `init_app_config(AppConfig.from_file())` at startup
- Modify: All test files that import `get_app_config` from `deerflow.config.app_config` — point to new path
- [ ] **Step 1: Update `__init__.py` exports**
```python
# deerflow/config/__init__.py
from .context import get_app_config, init_app_config, ConfigNotInitializedError
from .app_config import AppConfig
from .extensions_config import ExtensionsConfig
from .memory_config import MemoryConfig
from .paths import Paths, get_paths
# ... keep type exports, remove getter function exports
```
- [ ] **Step 2: Delete lifecycle functions from `app_config.py`**
Delete everything after the `AppConfig` class definition: `_app_config`, `_app_config_path`, `_app_config_mtime`, `_app_config_is_custom`, `_current_app_config`, `_current_app_config_stack`, `_get_config_mtime`, `_load_and_cache_app_config`, `get_app_config`, `reload_app_config`, `reset_app_config`, `set_app_config`, `peek_current_app_config`, `push_current_app_config`, `pop_current_app_config`.
- [ ] **Step 3: Update `client.py`**
Replace:
```python
from deerflow.config.app_config import get_app_config, reload_app_config
```
With:
```python
from deerflow.config import get_app_config, init_app_config
from deerflow.config.app_config import AppConfig
```
In `__init__`, replace:
```python
if config_path is not None:
reload_app_config(config_path)
self._app_config = get_app_config()
```
With:
```python
config = AppConfig.from_file(config_path)
init_app_config(config)
self._app_config = config
```
- [ ] **Step 4: Update `app/gateway/app.py`**
Add at startup:
```python
from deerflow.config import init_app_config
from deerflow.config.app_config import AppConfig
init_app_config(AppConfig.from_file())
```
- [ ] **Step 5: Run full test suite, fix import paths**
Run: `cd backend && PYTHONPATH=. uv run pytest -x -v 2>&1 | head -100`
Every test that patches `deerflow.config.app_config.get_app_config` or `deerflow.client.reload_app_config` needs updating. The new patch target is `deerflow.config.context.get_app_config` (or via `deerflow.config.get_app_config` depending on import).
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "refactor(config): migrate to ContextVar-based get_app_config"
```
---
## Task 6: Delete sub-config module globals (memory, title, summarization)
Migrate the three most-used sub-config getters. Each follows the same pattern: delete the module-level global + getter/setter/loader, update consumers to use `get_app_config().xxx`.
**Files:**
- Modify: `deerflow/config/memory_config.py` — delete `_memory_config`, `get_memory_config`, `set_memory_config`, `load_memory_config_from_dict`
- Modify: `deerflow/config/title_config.py` — delete `_title_config`, `get_title_config`, `set_title_config`, `load_title_config_from_dict`
- Modify: `deerflow/config/summarization_config.py` — delete globals
- Modify: 6 production files that call `get_memory_config()`
- Modify: 1 production file that calls `get_title_config()`
- Modify: 1 production file that calls `get_summarization_config()`
- Modify: associated test files
- [ ] **Step 1: Delete globals from `memory_config.py`**
Delete lines 64-83 (everything after the class definition):
```python
# DELETE:
_memory_config: MemoryConfig = MemoryConfig()
def get_memory_config() -> MemoryConfig: ...
def set_memory_config(config: MemoryConfig) -> None: ...
def load_memory_config_from_dict(config_dict: dict) -> None: ...
```
- [ ] **Step 2: Migrate production consumers of `get_memory_config()`**
In each file, replace `get_memory_config()` with `get_app_config().memory`:
| File | Change |
|------|--------|
| `agents/middlewares/memory_middleware.py` | `from deerflow.config import get_app_config``get_app_config().memory` |
| `agents/memory/storage.py` | Same pattern |
| `agents/memory/updater.py` | Same pattern |
| `agents/memory/queue.py` | Same pattern |
| `agents/lead_agent/prompt.py` | Same pattern |
| `app/gateway/routers/memory.py` | Same pattern |
- [ ] **Step 3: Delete globals from `title_config.py`**
Delete lines 36-53.
- [ ] **Step 4: Migrate `get_title_config()` consumer**
`agents/middlewares/title_middleware.py``get_app_config().title`
- [ ] **Step 5: Delete globals from `summarization_config.py`**
- [ ] **Step 6: Migrate `get_summarization_config()` consumer**
`agents/lead_agent/agent.py``get_app_config().summarization`
- [ ] **Step 7: Fix tests**
Tests that patch `get_memory_config` / `get_title_config` / `get_summarization_config` must now patch `get_app_config` returning a config with the desired sub-config values.
Pattern:
```python
# Before
@patch("deerflow.agents.memory.updater.get_memory_config")
def test_something(mock_config):
mock_config.return_value = MemoryConfig(enabled=False)
# After
@patch("deerflow.config.context.get_app_config")
def test_something(mock_config):
mock_config.return_value = AppConfig(
sandbox=SandboxConfig(use="test"),
memory=MemoryConfig(enabled=False),
)
```
- [ ] **Step 8: Run full test suite**
Run: `cd backend && PYTHONPATH=. uv run pytest -x -v`
- [ ] **Step 9: Commit**
```bash
git add -A
git commit -m "refactor(config): delete memory/title/summarization module globals"
```
---
## Task 7: Delete remaining sub-config module globals
Same pattern as Task 6 for the remaining 7 modules.
**Files:**
- Modify: `deerflow/config/subagents_config.py` — delete globals
- Modify: `deerflow/config/guardrails_config.py` — delete globals + `reset_guardrails_config`
- Modify: `deerflow/config/tool_search_config.py` — delete globals
- Modify: `deerflow/config/checkpointer_config.py` — delete globals
- Modify: `deerflow/config/stream_bridge_config.py` — delete globals
- Modify: `deerflow/config/acp_config.py` — delete globals
- Modify: `deerflow/config/extensions_config.py` — delete globals + `reload_extensions_config` + `reset_extensions_config` + `set_extensions_config`
- Modify: All consumers of these getters (see consumer map in exploration)
- [ ] **Step 1: Delete globals from `subagents_config.py`, migrate `subagents/registry.py`**
`get_subagents_app_config()``get_app_config().subagents`
- [ ] **Step 2: Delete globals from `guardrails_config.py`, migrate `tool_error_handling_middleware.py`**
`get_guardrails_config()``get_app_config().guardrails`
- [ ] **Step 3: Delete globals from `tool_search_config.py`**
No production consumers outside config system.
- [ ] **Step 4: Delete globals from `checkpointer_config.py`, migrate 2 consumers**
`get_checkpointer_config()``get_app_config().checkpointer`
- [ ] **Step 5: Delete globals from `stream_bridge_config.py`, migrate 1 consumer**
`get_stream_bridge_config()``get_app_config().stream_bridge`
- [ ] **Step 6: Delete globals from `acp_config.py`, migrate 2 consumers**
`get_acp_agents()` → derive from `get_app_config()`
- [ ] **Step 7: Delete globals from `extensions_config.py`, migrate 4 production consumers**
`get_extensions_config()``get_app_config().extensions`
`reload_extensions_config()``init_app_config(AppConfig.from_file())`
Consumers:
- `deerflow/sandbox/tools.py`
- `deerflow/client.py`
- `app/gateway/routers/mcp.py`
- `app/gateway/routers/skills.py`
- [ ] **Step 8: Fix tests**
- [ ] **Step 9: Run full test suite**
Run: `cd backend && PYTHONPATH=. uv run pytest -x -v`
- [ ] **Step 10: Commit**
```bash
git add -A
git commit -m "refactor(config): delete all remaining sub-config module globals"
```
---
## Task 8: Update `__init__.py` exports — final cleanup
**Files:**
- Modify: `deerflow/config/__init__.py`
- [ ] **Step 1: Update exports to final state**
```python
# deerflow/config/__init__.py
from .app_config import AppConfig
from .context import ConfigNotInitializedError, get_app_config, init_app_config
from .extensions_config import ExtensionsConfig
from .memory_config import MemoryConfig
from .paths import Paths, get_paths
from .skill_evolution_config import SkillEvolutionConfig
from .skills_config import SkillsConfig
from .tracing_config import (
get_enabled_tracing_providers,
get_explicitly_enabled_tracing_providers,
get_tracing_config,
is_tracing_enabled,
validate_enabled_tracing_providers,
)
__all__ = [
"AppConfig",
"ConfigNotInitializedError",
"ExtensionsConfig",
"MemoryConfig",
"Paths",
"SkillEvolutionConfig",
"SkillsConfig",
"get_app_config",
"get_enabled_tracing_providers",
"get_explicitly_enabled_tracing_providers",
"get_paths",
"get_tracing_config",
"init_app_config",
"is_tracing_enabled",
"validate_enabled_tracing_providers",
]
```
- [ ] **Step 2: Run full test suite**
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "refactor(config): clean up __init__.py exports"
```
---
## Task 9: Update Gateway config update flow
Gateway API currently writes config files then calls `reload_*`. Change to: write file → construct new AppConfig → `init_app_config()` → rebuild agent.
**Files:**
- Modify: `app/gateway/routers/mcp.py`
- Modify: `app/gateway/routers/skills.py`
- Modify: `deerflow/client.py` (update_mcp_config, update_skill methods)
- [ ] **Step 1: Update `mcp.py` router**
Replace `reload_extensions_config()` call with:
```python
from deerflow.config import init_app_config
from deerflow.config.app_config import AppConfig
init_app_config(AppConfig.from_file())
```
- [ ] **Step 2: Update `skills.py` router**
Same pattern.
- [ ] **Step 3: Update `client.py` methods**
In `update_mcp_config()` and `update_skill()`, replace `reload_extensions_config()` with `init_app_config(AppConfig.from_file())`.
- [ ] **Step 4: Run full test suite**
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor(config): Gateway updates construct new config instead of reload"
```
---
## Task 10: Create `DeerFlowContext` and wire into agent creation ✅
Completed. `DeerFlowContext` with `app_config` field created, wired into `create_agent(context_schema=DeerFlowContext)` and `DeerFlowClient.stream(context=...)`.
---
## Task 11: Expand DeerFlowContext with `thread_id` and `agent_name`, add `resolve_context()`
Expand `DeerFlowContext` from config-only to full per-invocation context. Add `resolve_context()` helper for unified access across all entry points.
**Files:**
- Modify: `deerflow/config/deer_flow_context.py`
- Test: `tests/test_deer_flow_context.py` (extend)
- [ ] **Step 1: Write tests for expanded DeerFlowContext**
```python
# Extend tests/test_deer_flow_context.py
from unittest.mock import patch
from deerflow.config.deer_flow_context import DeerFlowContext, resolve_context
def test_deer_flow_context_fields():
config = AppConfig(sandbox=SandboxConfig(use="test"))
ctx = DeerFlowContext(app_config=config, thread_id="t1", agent_name="test-agent")
assert ctx.thread_id == "t1"
assert ctx.agent_name == "test-agent"
assert ctx.app_config is config
def test_deer_flow_context_agent_name_default():
config = AppConfig(sandbox=SandboxConfig(use="test"))
ctx = DeerFlowContext(app_config=config, thread_id="t1")
assert ctx.agent_name is None
def test_resolve_context_returns_typed_context():
"""When runtime.context is DeerFlowContext, return it directly."""
config = AppConfig(sandbox=SandboxConfig(use="test"))
ctx = DeerFlowContext(app_config=config, thread_id="t1")
runtime = MagicMock()
runtime.context = ctx
assert resolve_context(runtime) is ctx
def test_resolve_context_fallback_from_configurable():
"""When runtime.context is None (LangGraph Server), fallback to configurable."""
runtime = MagicMock()
runtime.context = None
config = AppConfig(sandbox=SandboxConfig(use="test"))
with patch("deerflow.config.deer_flow_context.get_app_config", return_value=config), \
patch("deerflow.config.deer_flow_context.get_config", return_value={"configurable": {"thread_id": "t2", "agent_name": "ag"}}):
ctx = resolve_context(runtime)
assert ctx.thread_id == "t2"
assert ctx.agent_name == "ag"
assert ctx.app_config is config
```
- [ ] **Step 2: Update `deer_flow_context.py`**
```python
"""Per-invocation context for DeerFlow agent execution."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from deerflow.config.app_config import AppConfig
@dataclass(frozen=True)
class DeerFlowContext:
"""Typed, immutable, per-invocation context injected via LangGraph Runtime."""
app_config: AppConfig
thread_id: str
agent_name: str | None = None
def resolve_context(runtime: Any) -> DeerFlowContext:
"""Extract or construct DeerFlowContext from runtime.
Gateway/Client paths: runtime.context is already DeerFlowContext → return directly.
LangGraph Server path: runtime.context is None → fallback to ContextVar + configurable.
"""
if isinstance(runtime.context, DeerFlowContext):
return runtime.context
from langgraph.config import get_config
from deerflow.config import get_app_config
cfg = get_config().get("configurable", {})
return DeerFlowContext(
app_config=get_app_config(),
thread_id=cfg.get("thread_id", ""),
agent_name=cfg.get("agent_name"),
)
```
- [ ] **Step 3: Run tests**
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "refactor(config): expand DeerFlowContext with thread_id, agent_name, resolve_context()"
```
---
## Task 12: Remove sandbox_id from runtime.context
Remove the mutable `sandbox_id` side channel from `runtime.context`. All sandbox_id access goes through `ThreadState.sandbox` (state channel).
**Files:**
- Modify: `deerflow/sandbox/tools.py` — delete 3× `runtime.context["sandbox_id"] = sandbox_id`
- Modify: `deerflow/sandbox/middleware.py` — delete context fallback in `after_agent`
- Test: `tests/test_sandbox_*.py` (verify existing tests still pass)
- [ ] **Step 1: Delete sandbox_id writes from `sandbox/tools.py`**
Remove lines:
- `tools.py:813`: `runtime.context["sandbox_id"] = sandbox_id`
- `tools.py:849`: `runtime.context["sandbox_id"] = sandbox_id`
- `tools.py:872`: `runtime.context["sandbox_id"] = sandbox_id`
- [ ] **Step 2: Delete context fallback from `sandbox/middleware.py:after_agent`**
Remove lines 76-80:
```python
# DELETE:
if (runtime.context or {}).get("sandbox_id") is not None:
sandbox_id = runtime.context.get("sandbox_id")
logger.info(f"Releasing sandbox {sandbox_id} from context")
get_sandbox_provider().release(sandbox_id)
return None
```
The state-based path (lines 69-74) already handles all cases.
- [ ] **Step 3: Run sandbox tests**
Run: `cd backend && PYTHONPATH=. uv run pytest tests/ -k sandbox -v`
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "refactor(sandbox): remove sandbox_id from runtime.context, use state channel only"
```
---
## Task 13: Wire DeerFlowContext into Gateway runtime and DeerFlowClient
Update the two primary entry points to construct and pass full `DeerFlowContext`.
**Files:**
- Modify: `deerflow/runtime/runs/worker.py` — replace dict context with DeerFlowContext
- Modify: `deerflow/client.py` — add thread_id to DeerFlowContext construction
- Test: existing client/runtime tests
- [ ] **Step 1: Update `worker.py`**
Replace:
```python
runtime = Runtime(context={"thread_id": thread_id}, store=store)
```
With:
```python
from deerflow.config.deer_flow_context import DeerFlowContext
from deerflow.config import get_app_config
context = DeerFlowContext(app_config=get_app_config(), thread_id=thread_id)
```
And pass `context=context` to the `agent.astream()` call instead of injecting `__pregel_runtime` manually.
Also remove the dict-style `config["context"].setdefault("thread_id", ...)` line.
- [ ] **Step 2: Update `client.py`**
Replace:
```python
context = DeerFlowContext(app_config=self._app_config)
```
With:
```python
context = DeerFlowContext(app_config=self._app_config, thread_id=thread_id)
```
Where `thread_id` comes from the `kwargs` or config.
- [ ] **Step 3: Run full test suite**
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "refactor(config): wire DeerFlowContext into Gateway runtime and DeerFlowClient"
```
---
## Task 14: Migrate middleware/tools from dict access to `resolve_context()`
Replace all `runtime.context.get("thread_id")` / `(runtime.context or {}).get(...)` patterns with `resolve_context(runtime).thread_id`.
**Files (middleware):**
- `deerflow/agents/middlewares/thread_data_middleware.py`
- `deerflow/agents/middlewares/uploads_middleware.py`
- `deerflow/agents/middlewares/memory_middleware.py`
- `deerflow/agents/middlewares/loop_detection_middleware.py`
- `deerflow/sandbox/middleware.py`
**Files (tools):**
- `deerflow/tools/builtins/present_file_tool.py`
- `deerflow/tools/builtins/setup_agent_tool.py`
- `deerflow/tools/builtins/task_tool.py`
- `deerflow/tools/skill_manage_tool.py`
- `deerflow/sandbox/tools.py`
- [ ] **Step 1: Update all middleware**
Pattern:
```python
# Before
thread_id = (runtime.context or {}).get("thread_id")
if thread_id is None:
config = get_config()
thread_id = config.get("configurable", {}).get("thread_id")
# After
from deerflow.config.deer_flow_context import resolve_context
ctx = resolve_context(runtime)
thread_id = ctx.thread_id
```
- [ ] **Step 2: Update all tools**
Same pattern. For tools using `ToolRuntime`, `resolve_context()` works identically.
- [ ] **Step 3: Fix tests**
Tests that mock `runtime.context` as a dict need to either:
- Pass a `DeerFlowContext` instance
- Or mock `runtime.context = None` with configurable fallback (LangGraph Server path)
- [ ] **Step 4: Run full test suite**
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor(config): migrate middleware/tools to resolve_context() typed access"
```
---
## Task 15: Migrate middleware to read config from Runtime
Convert middleware from global getter to reading `app_config` from `resolve_context()` at execution time.
**Files:**
- Modify: `deerflow/agents/middlewares/memory_middleware.py``get_app_config().memory``resolve_context(runtime).app_config.memory`
- Modify: `deerflow/agents/middlewares/title_middleware.py` — same pattern for `.title`
- Modify: associated tests
- [ ] **Step 1: Update MemoryMiddleware**
```python
ctx = resolve_context(runtime)
memory_config = ctx.app_config.memory
if not memory_config.enabled:
return None
```
- [ ] **Step 2: Update TitleMiddleware**
```python
ctx = resolve_context(runtime)
title_config = ctx.app_config.title
```
- [ ] **Step 3: Fix tests**
- [ ] **Step 4: Run full test suite**
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor(config): middleware reads config from Runtime[DeerFlowContext]"
```
---
## Task 16: Final cleanup and verification
- [ ] **Step 1: Grep for remaining dict-style context access**
```bash
cd backend && grep -rn 'runtime\.context\.get\|runtime\.context\[' --include="*.py" packages/ | grep -v __pycache__
```
Expected: No matches in production code.
- [ ] **Step 2: Grep for remaining deleted function references**
```bash
cd backend && grep -rn "get_memory_config\|get_title_config\|get_summarization_config\|get_subagents_app_config\|get_guardrails_config\|get_tool_search_config\|get_checkpointer_config\|get_stream_bridge_config\|get_acp_agents\|reload_app_config\|reload_extensions_config\|reset_app_config\|reset_extensions_config\|reset_guardrails_config\|set_app_config\|set_extensions_config\|push_current_app_config\|pop_current_app_config\|load_memory_config_from_dict\|load_title_config_from_dict" --include="*.py" | grep -v __pycache__
```
Expected: No matches (or only in comments/docs).
- [ ] **Step 3: Run full test suite**
```bash
cd backend && PYTHONPATH=. uv run pytest -v
```
Expected: All tests pass.
- [ ] **Step 4: Run linter**
```bash
cd backend && make lint
```
- [ ] **Step 5: Commit any final fixes**
```bash
git add -A
git commit -m "refactor(config): final cleanup — remove dead references"
```
- [ ] **Step 6: Update CLAUDE.md**
Update the Configuration System section in `backend/CLAUDE.md` to reflect the new architecture:
- `get_app_config()` backed by ContextVar (no mtime/reload)
- `init_app_config()` called at process startup
- Sub-config accessed via `get_app_config().memory`, etc.
- `DeerFlowContext` with `thread_id`, `agent_name`, `app_config` for agent execution path
- `resolve_context()` for unified access across Gateway/Client/LangGraph Server paths
- `sandbox_id` flows through state channel, not context
- All config models frozen
- [ ] **Step 7: Commit docs update**
```bash
git add backend/CLAUDE.md
git commit -m "docs: update CLAUDE.md for new config architecture"
```