- 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
37 KiB
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
# 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=Trueto 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:
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
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
# 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=Trueon AppConfig
In app_config.py, change:
model_config = ConfigDict(extra="allow", frozen=False)
to:
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
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
# 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 fromfrom_file()
In app_config.py, delete these blocks from from_file():
# 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
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
# 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
"""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
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-exportget_app_configandinit_app_configfromcontext.py -
Modify:
deerflow/config/app_config.py— deleteget_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— useinit_app_configinstead ofreload_app_config -
Modify:
app/gateway/app.py— callinit_app_config(AppConfig.from_file())at startup -
Modify: All test files that import
get_app_configfromdeerflow.config.app_config— point to new path -
Step 1: Update
__init__.pyexports
# 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:
from deerflow.config.app_config import get_app_config, reload_app_config
With:
from deerflow.config import get_app_config, init_app_config
from deerflow.config.app_config import AppConfig
In __init__, replace:
if config_path is not None:
reload_app_config(config_path)
self._app_config = get_app_config()
With:
config = AppConfig.from_file(config_path)
init_app_config(config)
self._app_config = config
- Step 4: Update
app/gateway/app.py
Add at startup:
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
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):
# 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:
# 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
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, migratesubagents/registry.py
get_subagents_app_config() → get_app_config().subagents
- Step 2: Delete globals from
guardrails_config.py, migratetool_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
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
# 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
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.pyrouter
Replace reload_extensions_config() call with:
from deerflow.config import init_app_config
from deerflow.config.app_config import AppConfig
init_app_config(AppConfig.from_file())
- Step 2: Update
skills.pyrouter
Same pattern.
- Step 3: Update
client.pymethods
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
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
# 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
"""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
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 inafter_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:
# 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
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:
runtime = Runtime(context={"thread_id": thread_id}, store=store)
With:
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:
context = DeerFlowContext(app_config=self._app_config)
With:
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
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.pydeerflow/agents/middlewares/uploads_middleware.pydeerflow/agents/middlewares/memory_middleware.pydeerflow/agents/middlewares/loop_detection_middleware.pydeerflow/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:
# 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
DeerFlowContextinstance -
Or mock
runtime.context = Nonewith configurable fallback (LangGraph Server path) -
Step 4: Run full test suite
-
Step 5: Commit
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
ctx = resolve_context(runtime)
memory_config = ctx.app_config.memory
if not memory_config.enabled:
return None
- Step 2: Update TitleMiddleware
ctx = resolve_context(runtime)
title_config = ctx.app_config.title
-
Step 3: Fix tests
-
Step 4: Run full test suite
-
Step 5: Commit
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
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
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
cd backend && PYTHONPATH=. uv run pytest -v
Expected: All tests pass.
- Step 4: Run linter
cd backend && make lint
- Step 5: Commit any final fixes
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. -
DeerFlowContextwiththread_id,agent_name,app_configfor agent execution path -
resolve_context()for unified access across Gateway/Client/LangGraph Server paths -
sandbox_idflows through state channel, not context -
All config models frozen
-
Step 7: Commit docs update
git add backend/CLAUDE.md
git commit -m "docs: update CLAUDE.md for new config architecture"