- 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
6.3 KiB
Design: Eliminate Global Mutable State in Configuration System
Problem
deerflow/config/ has three structural issues:
- Dual source of truth — each sub-config exists both as an
AppConfigfield and a module-level global (e.g._memory_config). Consumers don't know which to trust. - Side-effect coupling —
AppConfig.from_file()silently mutates 8 sub-module globals viaload_*_from_dict()calls. - Incomplete isolation —
ContextVaronly scopesAppConfig, 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:
Runtimeis LangGraph's official DI, not a private dict hack- Generic type parameter (
Runtime[DeerFlowContext]) gives type safety RunnableConfigis 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.yamlschemaextensions_config.jsonloading- 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
Runtimesupport