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

37 KiB
Raw Blame History

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=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:

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=True on 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 from from_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-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

# 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_configget_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.pyget_app_config().title

  • Step 5: Delete globals from summarization_config.py

  • Step 6: Migrate get_summarization_config() consumer

agents/lead_agent/agent.pyget_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, 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
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.py router

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.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

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 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:

# 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.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:

# 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

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.pyget_app_config().memoryresolve_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.

  • 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

git add backend/CLAUDE.md
git commit -m "docs: update CLAUDE.md for new config architecture"