Files
deer-flow/docs/plans/2026-04-12-config-refactor-design.md
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

6.3 KiB

Design: Eliminate Global Mutable State in Configuration System

Implements #1811 · Tracked in #2151

Problem

deerflow/config/ has three structural issues:

  1. Dual source of truth — each sub-config exists both as an AppConfig field and a module-level global (e.g. _memory_config). Consumers don't know which to trust.
  2. Side-effect couplingAppConfig.from_file() silently mutates 8 sub-module globals via load_*_from_dict() calls.
  3. Incomplete isolationContextVar only scopes AppConfig, not the 8 sub-config globals.

Design Principle

Config is a value object, not live shared state. Constructed once, immutable, no reload. New config = new object + rebuild agent.

Solution

1. Frozen AppConfig (full tree)

All config models set frozen=True. No mutation after construction.

class MemoryConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

class AppConfig(BaseModel):
    model_config = ConfigDict(frozen=True)
    memory: MemoryConfig
    title: TitleConfig
    ...

Changes use copy-on-write: config.model_copy(update={...}).

2. Pure from_file()

AppConfig.from_file() becomes a pure function — returns a frozen object, no side effects. All load_*_from_dict() calls removed.

3. Delete sub-module globals

Every sub-config module's global state is deleted:

Delete Files
_memory_config, get_memory_config(), set_memory_config(), load_memory_config_from_dict() memory_config.py
_title_config, get_title_config(), set_title_config(), load_title_config_from_dict() title_config.py
Same pattern summarization_config.py, subagents_config.py, guardrails_config.py, tool_search_config.py, checkpointer_config.py, stream_bridge_config.py, acp_config.py
_extensions_config, reload_extensions_config(), reset_extensions_config(), set_extensions_config() extensions_config.py
reload_app_config(), reset_app_config(), set_app_config(), mtime detection, push/pop_current_app_config() app_config.py

Consumers migrate from get_memory_config()get_app_config().memory.

4. Propagation

Agent path: Runtime[DeerFlowContext]

LangGraph's official DI mechanism. Context is injected per-invocation, type-safe.

@dataclass(frozen=True)
class DeerFlowContext:
    app_config: AppConfig
    thread_id: str
    agent_name: str | None = None

Fields:

Field Type Source Mutability
app_config AppConfig ContextVar (get_app_config()) Immutable per-run
thread_id str Caller-provided Immutable per-run
agent_name str | None Caller-provided (bootstrap only) Immutable per-run

Not in context: sandbox_id is mutable runtime state (lazy-acquired mid-execution). It flows through ThreadState.sandbox (state channel), not context. The 3 existing runtime.context["sandbox_id"] = ... writes in sandbox/tools.py are removed; SandboxMiddleware.after_agent reads from state["sandbox"] only.

Construction per entry point (Gateway is primary):

# Gateway runtime (worker.py) — primary path
context = DeerFlowContext(app_config=get_app_config(), thread_id=thread_id)
agent.astream(input, config=config, context=context)

# DeerFlowClient (client.py)
context = DeerFlowContext(app_config=self._app_config, thread_id=thread_id)
agent.stream(input, config=config, context=context)

# LangGraph Server — legacy path, context=None, fallback via resolve_context()

Access in middleware/tools:

from deerflow.config.deer_flow_context import DeerFlowContext, resolve_context

# Middleware
def before_model(self, state, runtime: Runtime[DeerFlowContext]):
    ctx = resolve_context(runtime)
    ctx.app_config.title     # typed
    ctx.thread_id             # typed

# Tool
@tool
def my_tool(runtime: ToolRuntime[DeerFlowContext]) -> str:
    ctx = resolve_context(runtime)
    ctx.app_config.memory    # typed

resolve_context() returns runtime.context directly when it's already a DeerFlowContext (Gateway/Client paths). For legacy LangGraph Server path (context is None), it falls back to constructing from ContextVar + configurable.

Why Runtime over RunnableConfig.configurable:

  • Runtime is LangGraph's official DI, not a private dict hack
  • Generic type parameter (Runtime[DeerFlowContext]) gives type safety
  • RunnableConfig is for framework internals (tags, callbacks), not user dependencies

Non-agent path: ContextVar

Gateway API routers use get_app_config() backed by a single ContextVar. This is appropriate — Gateway doesn't run through the LangGraph execution graph.

5. No reload

Config lifecycle is simple:

Process start → from_file() → set ContextVar → run
                                                 ↓
                               Gateway API changed file?
                                                 ↓
                               from_file() → new frozen config
                               → set ContextVar → rebuild agent
  • Edit config.yaml → restart process
  • Gateway updates MCP/Skills → construct new config + rebuild agent
  • No mtime detection, no reload_*(), no auto-refresh

6. Structure vs runtime config

Type Example Reload behavior
Structural (agent composition) model, tools, middleware chain Requires agent rebuild
Runtime (execution behavior) memory.enabled, title.max_words Next invocation picks up new config automatically via Runtime

Middleware reads config from Runtime at execution time (not __init__ capture), so runtime config changes take effect without agent rebuild.

What doesn't change

  • config.yaml schema
  • extensions_config.json loading
  • External API behavior (Gateway, DeerFlowClient)

Migration scope

  • 50+ call sites: get_*_config()get_app_config().xxx
  • Middleware: __init__ capture → Runtime[DeerFlowContext] read
  • Tools: global getters → ToolRuntime[DeerFlowContext]
  • Tests: reset_*_config() → construct frozen config directly
  • Gateway update flow: reload → construct new config + rebuild agent
  • Dependency: upgrade langgraph >= 1.1.5 for Runtime support