Squashes 25 PR commits onto current main. AppConfig becomes a pure value object with no ambient lookup. Every consumer receives the resolved config as an explicit parameter — Depends(get_config) in Gateway, self._app_config in DeerFlowClient, runtime.context.app_config in agent runs, AppConfig.from_file() at the LangGraph Server registration boundary. Phase 1 — frozen data + typed context - All config models (AppConfig, MemoryConfig, DatabaseConfig, …) become frozen=True; no sub-module globals. - AppConfig.from_file() is pure (no side-effect singleton loaders). - Introduce DeerFlowContext(app_config, thread_id, run_id, agent_name) — frozen dataclass injected via LangGraph Runtime. - Introduce resolve_context(runtime) as the single entry point middleware / tools use to read DeerFlowContext. Phase 2 — pure explicit parameter passing - Gateway: app.state.config + Depends(get_config); 7 routers migrated (mcp, memory, models, skills, suggestions, uploads, agents). - DeerFlowClient: __init__(config=...) captures config locally. - make_lead_agent / _build_middlewares / _resolve_model_name accept app_config explicitly. - RunContext.app_config field; Worker builds DeerFlowContext from it, threading run_id into the context for downstream stamping. - Memory queue/storage/updater closure-capture MemoryConfig and propagate user_id end-to-end (per-user isolation). - Sandbox/skills/community/factories/tools thread app_config. - resolve_context() rejects non-typed runtime.context. - Test suite migrated off AppConfig.current() monkey-patches. - AppConfig.current() classmethod deleted. Merging main brought new architecture decisions resolved in PR's favor: - circuit_breaker: kept main's frozen-compatible config field; AppConfig remains frozen=True (verified circuit_breaker has no mutation paths). - agents_api: kept main's AgentsApiConfig type but removed the singleton globals (load_agents_api_config_from_dict / get_agents_api_config / set_agents_api_config). 8 routes in agents.py now read via Depends(get_config). - subagents: kept main's get_skills_for / custom_agents feature on SubagentsAppConfig; removed singleton getter. registry.py now reads app_config.subagents directly. - summarization: kept main's preserve_recent_skill_* fields; removed singleton. - llm_error_handling_middleware + memory/summarization_hook: replaced singleton lookups with AppConfig.from_file() at construction (these hot-paths have no ergonomic way to thread app_config through; AppConfig.from_file is a pure load). - worker.py + thread_data_middleware.py: DeerFlowContext.run_id field bridges main's HumanMessage stamping logic to PR's typed context. Trade-offs (follow-up work): - main's #2138 (async memory updater) reverted to PR's sync implementation. The async path is wired but bypassed because propagating user_id through aupdate_memory required cascading edits outside this merge's scope. - tests/test_subagent_skills_config.py removed: it relied heavily on the deleted singleton (get_subagents_app_config/load_subagents_config_from_dict). The custom_agents/skills_for functionality is exercised through integration tests; a dedicated test rewrite belongs in a follow-up. Verification: backend test suite — 2560 passed, 4 skipped, 84 failures. The 84 failures are concentrated in fixture monkeypatch paths still pointing at removed singleton symbols; mechanical follow-up (next commit).
54 KiB
Config Refactor Implementation Plan — Shipped
Status: Shipped in PR #2271. All tasks complete. This document is an implementation log; for the shipped architecture see design doc.
Goal: Eliminate global mutable state in the configuration system — frozen
AppConfig, purefrom_file(), process-global + ContextVar-override lifecycle,Runtime[DeerFlowContext]propagation.Tech Stack: Pydantic v2 (
frozen=True,model_copy), Pythoncontextvars.ContextVar+Token, LangGraphRuntime/ToolRuntime.
Post-mortem — divergences from the original plan
The implementation diverged from the original task-by-task plan in three places. The rationale lives in the design doc §7; here is the commit trail.
| Divergence | Original plan | Shipped | Triggering commit |
|---|---|---|---|
| Lifecycle storage | Single ContextVar in new context.py, raises ConfigNotInitializedError |
3-tier: AppConfig._global (process singleton) + _override: ContextVar + auto-load-with-warning fallback |
7a11e925 ("use process-global + ContextVar override"), refined in 4df595b0 |
| Module / API shape | Top-level get_app_config() / init_app_config() in context.py |
Classmethods on AppConfig (current, init, set_override, reset_override); DeerFlowContext + resolve_context in deer_flow_context.py |
Same commits + 9040e49e (call-site migration) |
| Middleware access | resolve_context(runtime) in every middleware and tool |
Typed middleware reads runtime.context.xxx directly; resolve_context() only in dict-legacy callers; defensive try/except wrappers removed |
a934a822 ("simplify runtime context access") |
Core insight: ContextVar alone could not propagate config changes across Gateway request boundaries; process-global fixed that. The override ContextVar was kept for test/multi-client isolation. Hard-fail on uninitialized access (ConfigNotInitializedError) was dropped in favor of warning + auto-load to preserve backward compatibility, and tests use an autouse fixture in backend/tests/conftest.py to avoid the auto-load path.
File Structure (shipped)
New files
| File | Responsibility |
|---|---|
deerflow/config/deer_flow_context.py |
DeerFlowContext frozen dataclass + resolve_context() helper |
The originally-planned deerflow/config/context.py was never created. Lifecycle (init, current, set_override, reset_override) is on AppConfig itself in app_config.py.
Modified files (config layer)
| File | Change |
|---|---|
deerflow/config/app_config.py |
frozen=True, purify from_file(), delete mtime/reload/reset/push/pop; add classmethods init/current/set_override/reset_override with _global ClassVar and _override ContextVar |
deerflow/config/memory_config.py |
frozen=True, delete all globals and loader functions |
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/database_config.py |
frozen=True (added in 4df595b0 review round) |
deerflow/config/run_events_config.py |
frozen=True (same) |
deerflow/config/tracing_config.py |
frozen=True, unchanged exports |
deerflow/config/__init__.py |
Removed deleted getter exports; no new re-exports needed since API is now on AppConfig |
Modified files (production consumers)
| File | Change |
|---|---|
deerflow/agents/lead_agent/agent.py |
get_summarization_config() → AppConfig.current().summarization |
deerflow/agents/lead_agent/prompt.py |
get_memory_config() → AppConfig.current().memory; ACP agents derived from AppConfig.current() |
deerflow/agents/middlewares/memory_middleware.py |
Reads runtime.context.app_config.memory directly (typed Runtime[DeerFlowContext]) |
deerflow/agents/middlewares/title_middleware.py |
after_model / aafter_model read runtime.context.app_config.title; helpers take TitleConfig as required parameter |
deerflow/agents/middlewares/tool_error_handling_middleware.py |
get_guardrails_config() → AppConfig.current().guardrails |
deerflow/agents/middlewares/loop_detection_middleware.py |
Reads runtime.context.thread_id directly |
deerflow/agents/middlewares/thread_data_middleware.py |
Reads runtime.context.thread_id directly |
deerflow/agents/middlewares/uploads_middleware.py |
Reads runtime.context.thread_id directly |
deerflow/agents/memory/updater.py / queue.py / storage.py |
get_memory_config() → AppConfig.current().memory |
deerflow/runtime/checkpointer/provider.py / async_provider.py |
get_checkpointer_config() → AppConfig.current().checkpointer |
deerflow/runtime/store/provider.py / async_provider.py |
Same pattern |
deerflow/runtime/stream_bridge/async_provider.py |
get_stream_bridge_config() → AppConfig.current().stream_bridge |
deerflow/runtime/runs/worker.py |
Constructs DeerFlowContext(app_config=AppConfig.current(), thread_id=thread_id) and passes via agent.astream(context=...) |
deerflow/subagents/registry.py |
get_subagents_app_config() → AppConfig.current().subagents |
deerflow/sandbox/middleware.py |
Reads runtime.context.thread_id; removed runtime.context["sandbox_id"] read path |
deerflow/sandbox/tools.py |
Removed 3× runtime.context["sandbox_id"] = ... writes; state now flows through runtime.state["sandbox"]; sandbox-config access via resolve_context(runtime).app_config.sandbox where dict-context fallback may still apply |
deerflow/sandbox/local/local_sandbox_provider.py / sandbox_provider.py / security.py |
get_app_config() → AppConfig.current() |
deerflow/community/*/tools.py (tavily, jina_ai, firecrawl, exa, ddg_search, image_search, infoquest, aio_sandbox) |
get_app_config() → AppConfig.current() |
deerflow/skills/loader.py / manager.py / security_scanner.py |
Same pattern |
deerflow/tools/builtins/*.py |
Typed tools read runtime.context.xxx; task_tool.py uses resolve_context() for bash-subagent guard |
deerflow/tools/tools.py / skill_manage_tool.py |
ACP agents derived from AppConfig.current(); skill manage reads runtime.context.thread_id |
deerflow/models/factory.py |
get_app_config() → AppConfig.current() |
deerflow/utils/file_conversion.py |
Same |
deerflow/client.py |
AppConfig.init(AppConfig.from_file(config_path)); constructs DeerFlowContext at invoke time. Earlier iterations used set_override(); removed in a934a822 |
app/gateway/app.py |
AppConfig.init(AppConfig.from_file()) at startup |
app/gateway/deps.py / auth/reset_admin.py |
get_app_config() → AppConfig.current() |
app/gateway/routers/mcp.py / skills.py |
Construct new config + AppConfig.init() instead of reload_extensions_config() |
app/gateway/routers/memory.py / models.py |
get_memory_config() → AppConfig.current().memory, etc. |
app/channels/service.py |
get_app_config() → AppConfig.current() |
backend/CLAUDE.md |
Config Lifecycle + DeerFlowContext sections updated |
Modified files (tests)
~100 test locations updated. Patterns:
@patch("...get_memory_config")→@patch.object(AppConfig, "current", ...)returning a frozenAppConfigwith the desired sub-config- Tests that mutated
AppConfiginstances now construct fresh ones or usemodel_copy(update={...}) backend/tests/conftest.pygained an autouse_auto_app_configfixture that setsAppConfig._globalto a minimal config for every test
New test files:
backend/tests/test_config_frozen.py— verifies every config model rejects mutationbackend/tests/test_deer_flow_context.py— verifiesDeerFlowContextconstruction, defaults, andresolve_context()for all three input shapesbackend/tests/test_app_config_reload.py— verifies lifecycle:init()visibility across contexts,set_override()+reset_override()withToken, auto-load warning
Task log
All tasks complete. Checkboxes below reflect the shipped state. For detailed step-by-step TDD sequence, see the commit history on refactor/config-deerflow-context.
Task 1: Freeze all sub-config models
- Write
test_config_frozen.pyparameterized over every config model - Add
model_config = ConfigDict(frozen=True)(orextra="allow", frozen=True) to every model - Add frozen=True to
DatabaseConfig,RunEventsConfigin review round (4df595b0) - Fix tests that mutated config objects — use
model_copy(update={...})or fresh instances
Task 2: Freeze AppConfig
- Extend
test_config_frozen.pywithtest_app_config_is_frozen - Change
AppConfig.model_configtoConfigDict(extra="allow", frozen=True)
Task 3: Purify from_file()
- Write test verifying no
load_*_from_dict()calls happen duringfrom_file() - Remove all 8
load_*_from_dict()calls and their imports fromapp_config.py
Task 4: Replace app_config.py lifecycle
Diverged from original plan. See post-mortem for rationale.
Create→ Lifecycle added directly todeerflow/config/context.pyAppConfigas classmethods- Add
_global: ClassVar[AppConfig | None]for process-global storage (atomic pointer swap under GIL, no lock) - Add
_override: ClassVar[ContextVar[AppConfig]]for per-context override - Implement
init(),current(),set_override()(returnsToken),reset_override() current()priority order: override → global → auto-load-with-warning- Delete old lifecycle:
get_app_config,reload_app_config,reset_app_config,set_app_config,peek_current_app_config,push_current_app_config,pop_current_app_config,_load_and_cache_app_config, mtime globals - Write
test_app_config_reload.pycovering init/override/reset/auto-load paths
Commits: 7a11e925 (initial process-global + override), 4df595b0 (harden: Token return, auto-load warning, doc _global lock-free rationale).
Task 5: Migrate call sites to AppConfig.current()
- ~100
get_app_config()/get_memory_config()/get_title_config()/ ... call sites migrated toAppConfig.current().xxx - Tests that patched module-level getters migrated to
patch.object(AppConfig, "current", ...) - Update
deerflow/config/__init__.py— removed deleted getter exports
Commits: 9040e49e (bulk migration), 82fdabd7 (deps.py + reset_admin.py follow-up), 6c0c2ecf (test mocks update), faec3bf9 (runtime-path migration).
Task 6: Delete sub-config module globals (memory / title / summarization)
- Delete
_memory_config,get_memory_config,set_memory_config,load_memory_config_from_dictfrommemory_config.py - Delete analogous globals from
title_config.py,summarization_config.py - Migrate 6 production consumers of
get_memory_config, 1 ofget_title_config, 1 ofget_summarization_config - Fix tests that patched the deleted getters
Task 7: Delete remaining sub-config module globals
subagents_config.py— delete globals; migratesubagents/registry.pyguardrails_config.py— delete globals +reset_guardrails_config; migratetool_error_handling_middleware.pytool_search_config.py— delete globals (no production consumers)checkpointer_config.py— delete globals; migrate 2 consumers in runtime/stream_bridge_config.py— delete globals; migrate 1 consumeracp_config.py— delete globals; migrate 2 consumers (agents/lead_agent/prompt.py,tools/tools.py)extensions_config.py— delete globals +reload_extensions_config/reset_extensions_config/set_extensions_config; migrate 4 consumers (sandbox/tools.py,client.py,gateway/routers/mcp.py,gateway/routers/skills.py)
Task 8: Update __init__.py exports
- Remove deleted-getter exports; keep type exports (
AppConfig,ExtensionsConfig,MemoryConfig, etc.) tracing_configre-exports preserved (still function-based, no lifecycle change)
Task 9: Gateway config update flow
app/gateway/routers/mcp.py: write extensions_config.json →AppConfig.init(AppConfig.from_file())app/gateway/routers/skills.py: same patterndeerflow/client.py:update_mcp_config()andupdate_skill()reuse the same pattern (now viaAppConfig.current().extensions+init(AppConfig.from_file()))
Task 10: Create DeerFlowContext
- Create
deerflow/config/deer_flow_context.pywithDeerFlowContextfrozen dataclass - Fields:
app_config: AppConfig,thread_id: str,agent_name: str | None = None - Typed via
TYPE_CHECKINGimport to avoid circular dependency - Wire into
create_agent(context_schema=DeerFlowContext)inlead_agent/agent.py - Wire into
DeerFlowClient.stream(context=...)
Task 11: Add resolve_context() helper
- Handle typed context (Gateway/Client path): return
runtime.contextdirectly - Handle dict context (legacy/tests): construct
DeerFlowContextfrom dict keys; warn on emptythread_id - Handle missing context (LangGraph Server): fall back to
get_config().get("configurable", {}); warn on emptythread_id - Write
test_deer_flow_context.pycovering all three paths
Task 12: Remove sandbox_id from runtime.context
- Delete 3×
runtime.context["sandbox_id"] = sandbox_idwrites insandbox/tools.py - Delete context-based release path in
sandbox/middleware.py:after_agent - Sandbox state flows exclusively through
runtime.state["sandbox"] = {"sandbox_id": ...}
Task 13: Wire DeerFlowContext into Gateway runtime and client
deerflow/runtime/runs/worker.py: constructDeerFlowContext(app_config=AppConfig.current(), thread_id=thread_id), pass viaagent.astream(context=...); remove dict-context injectiondeerflow/client.py: callAppConfig.init(AppConfig.from_file(config_path))in__init__/_reload_config(); constructDeerFlowContextat invoke time
Task 14: Migrate middleware/tools from dict access to typed access
Originally planned as "replace with resolve_context()". Shipped as: typed middleware reads runtime.context.xxx directly; resolve_context() only where dict-context may still appear.
thread_data_middleware,uploads_middleware,memory_middleware,loop_detection_middleware:runtime.context.thread_iddirect readsandbox/middleware.py: samepresent_file_tool,setup_agent_tool,skill_manage_tool: same pattern (typedToolRuntime)task_tool.py: keepresolve_context()for bash-subagent guard (usesapp_config)sandbox/tools.py: keepresolve_context()for sandbox config + thread_id in dict-legacy paths
Commit: a934a822.
Task 15: Middleware reads config from Runtime
memory_middleware:runtime.context.app_config.memory— no wrapper, notry/excepttitle_middleware:runtime.context.app_config.titlepassed as required parameter to helpers; noTitleConfig | Nonefallbacktool_error_handling_middleware: reads fromAppConfig.current().guardrails(lives outside per-invocation context)
Commit: a934a822.
Task 16: Final cleanup and verification
- Grep verified: no remaining
runtime.context.get(...)/runtime.context[...]patterns in production code (the pattern exists inapp/channels/wechat.pybut is unrelated — it's a channel-token helper, not LangGraph runtime) - Grep verified: no remaining
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_*/reset_*/set_extensions_config/push_current_app_config/pop_current_app_config/load_*_from_dictreferences - Full test suite passes (
make test— 2376 passed per PR description) - CI green (backend-unit-tests)
backend/CLAUDE.mdupdated with new Config Lifecycle andDeerFlowContextsections
Follow-ups (not in Phase 1 PR)
- Consider re-exporting
DeerFlowContext/resolve_contextfromdeerflow.config.__init__for ergonomic imports. app/channels/wechat.pyuses_resolve_context_token— unrelated naming collision withresolve_context(). No action required but worth noting for future readers.- Phase 2 (below) subsumes the auto-load-warning concern:
AppConfig.current()goes away entirely rather than getting its warning promoted to error.
Phase 2: Pure explicit parameter passing
Status: Shipped. P2-1..P2-5 landed first with
AppConfig.current()kept as a transition fallback; P2-6..P2-10 landed together in commit84dccef2to eliminate the fallback and delete the ambient-lookup surface entirely.AppConfigis now a pure Pydantic value object with no process-global state and no classmethod accessors.Design: §8 of the design doc
Shipped commits
| Commit | Task | Category | What changed |
|---|---|---|---|
c45157e0 |
P2-1 | infrastructure | get_config FastAPI dependency, app.state.config populated at startup |
70323e05 |
P2-2 | G (Gateway) | 6 routers migrated to Depends(get_config); reload paths dual-write app.state.config + AppConfig.init() |
f8738d1e |
P2-3 | H (Client) | DeerFlowClient.__init__(config=...) captures config locally; multi-client isolation test pins invariant |
23b424e7 |
P2-4 | B (Agent construction) | make_lead_agent, _build_middlewares, _resolve_model_name, build_lead_runtime_middlewares accept optional app_config |
74b7a7ef |
P2-5 (partial) | D (Runtime) | RunContext gains app_config field; Worker builds DeerFlowContext from it; Gateway deps.get_run_context populates it. Standalone providers (checkpointer/store/stream_bridge) already accept optional config from Phase 1 |
84dccef2 |
P2-6..P2-10 | C+E+F+I + deletion | Memory closure-captures MemoryConfig; sandbox/skills/community/factories/tools thread app_config end-to-end; resolve_context() rejects non-typed runtime.context; AppConfig.current() removed; get_sandbox_provider(app_config) required; make_lead_agent LangGraph-Server bootstrap path loads via AppConfig.from_file(). All 2337 non-e2e tests pass. |
Completed tasks (P2-6 through P2-10)
All landed in 84dccef2.
P2-6: Memory subsystem closure-captured config (Category C) — shipped
MemoryConfigcaptured at enqueue time so the Timer thread survives the ContextVar boundary.deerflow/agents/memory/{queue,updater,storage}.pyno longer read any process-global.
P2-7: Sandbox / skills / factories / tools / community (Categories E+F) — shipped
sandbox/tools.pyhelpers takeapp_configexplicitly; the_cachedattribute trick is gone.sandbox/security.py,sandbox/sandbox_provider.py,sandbox/local/local_sandbox_provider.py,community/aio_sandbox/aio_sandbox_provider.pyall requireapp_config.skills/manager.py+skills/loader.py+agents/lead_agent/prompt.pycache refresh threadapp_configthrough the worker thread via closure.- Community tools (tavily, jina, firecrawl, exa, ddg, image_search, infoquest, aio_sandbox) read
resolve_context(runtime).app_config. subagents/registry.py(get_subagent_config,list_subagents,get_available_subagent_names) takeapp_config.models/factory.py::create_chat_modelandtools/tools.py::get_available_toolsrequireapp_config.
P2-8: Test fixtures (Category I) — shipped
conftest.pyautouse fixture no longer monkey-patchesAppConfig.current; it only stubsfrom_file()so tests don't need a realconfig.yaml.- ~90 call sites migrated:
patch.object(AppConfig, "current", ...)removed where production no longer calls it (≈56 sites), and for the remaining ~10 files whose tests calledAppConfig.current()themselves, the tests now hold the config in a local variable and pass it explicitly. test_deer_flow_context.pyupdated to assert thatresolve_context()raises on dict/None contexts.grep -rn 'AppConfig\.current' backend/testsis clean.
P2-9: Simplify resolve_context() — shipped
resolve_context(runtime)returnsruntime.contextwhen it is aDeerFlowContext; any other shape raisesRuntimeErrorpointing at the composition root that should have attached the typed context.- The dict-context and
get_config().configurablefallbacks are deleted.
P2-10: Delete AppConfig lifecycle — shipped
AppConfig.current()classmethod removed._global/_override/init/set_override/reset_overridealready gone as of Phase 1; nothing left to delete on the ambient side.- LangGraph Server bootstrap uses
AppConfig.from_file()insidemake_lead_agent— a pure load, not an ambient lookup. backend/CLAUDE.mdConfig Lifecycle section rewritten to describe the explicit-parameter design.app/gateway/deps.pydocstrings no longer mentionAppConfig.current().- Production grep confirms zero
AppConfig.current()call sites inbackend/packagesorbackend/app.
Rationale
Phase 1 fixed the data side (frozen ADT, no sub-module globals, pure from_file). Phase 2 fixes the access side (no ambient lookup). Together they make AppConfig referentially transparent: a function's result depends only on its inputs, nothing ambient.
Scope
- ~97 production call sites:
AppConfig.current()→ parameter - ~91 test mock sites:
patch.object(AppConfig, "current")/AppConfig._global = ...→ fixture injection - ~30 FastAPI endpoints: add
config: AppConfig = Depends(get_config) - ~15 factory/helper functions: add
config: AppConfigparameter - Delete Phase 1 lifecycle from
app_config.py
Ordering rule
AppConfig._global can only be deleted after every caller is migrated. Tasks run in this order:
- Introduce new primitives alongside the old ones (Task P2-1)
- Migrate call sites category by category (Tasks P2-2 through P2-9)
- Delete the old lifecycle (Task P2-10)
Each category task is independently mergeable. After a category is migrated, grep confirms the old callers in that category are gone but the old lifecycle still exists (other categories may still use it).
File structure (Phase 2)
Modified files
| File | Change |
|---|---|
app/gateway/app.py |
Store config on app.state.config at startup; remove AppConfig.init() call |
app/gateway/deps.py |
Add get_config(request: Request) -> AppConfig; remove AppConfig.current() uses |
app/gateway/routers/*.py |
Add config: AppConfig = Depends(get_config) to each endpoint; remove AppConfig.current() |
app/gateway/auth/reset_admin.py |
Take config: AppConfig parameter |
app/channels/service.py |
Take config: AppConfig parameter |
deerflow/client.py |
Remove AppConfig.init() call; store self._config = AppConfig.from_file(...); all methods read self._config |
deerflow/agents/lead_agent/agent.py |
make_lead_agent(runtime_config, app_config), _build_middlewares(app_config, ...), pass down through every helper |
deerflow/agents/lead_agent/prompt.py |
Every helper takes config (or the specific sub-config slice it needs) as a parameter |
deerflow/agents/middlewares/tool_error_handling_middleware.py |
Take guardrails config at construction |
deerflow/agents/memory/queue.py |
Capture MemoryConfig at enqueue; Timer closure reads from capture |
deerflow/agents/memory/updater.py |
Constructor takes MemoryConfig; store on self |
deerflow/agents/memory/storage.py |
Constructor takes MemoryConfig; store on self |
deerflow/runtime/runs/worker.py |
Receive AppConfig from RunManager; build DeerFlowContext from parameter |
deerflow/runtime/checkpointer/provider.py / async_provider.py |
Constructor takes CheckpointerConfig | None |
deerflow/runtime/store/provider.py / async_provider.py |
Constructor takes relevant config |
deerflow/runtime/stream_bridge/async_provider.py |
Constructor takes StreamBridgeConfig | None |
deerflow/sandbox/*.py, deerflow/skills/*.py |
Helpers take config parameter |
deerflow/community/*/tools.py |
Factory takes config parameter |
deerflow/models/factory.py |
create_chat_model(name, config, thinking_enabled=False) |
deerflow/tools/tools.py |
get_available_tools(config, ...) |
deerflow/subagents/registry.py |
Helper takes SubagentsAppConfig |
deerflow/config/deer_flow_context.py |
Simplify resolve_context(): typed-only; raise on non-DeerFlowContext |
deerflow/config/app_config.py |
Delete _global, _override, init, current, set_override, reset_override |
backend/tests/conftest.py |
Replace _auto_app_config autouse fixture with per-test test_config fixture returning AppConfig |
backend/tests/test_*.py |
Replace patch.object(AppConfig, "current", ...) with passing different AppConfig instances |
backend/CLAUDE.md |
Update Config Lifecycle section to describe pure-parameter design |
New files
None. Phase 2 is a pure refactor — same file set.
Task P2-1: Add FastAPI Depends(get_config) infrastructure
Introduce the new FastAPI DI primitive. Old AppConfig.current() still works; this task only adds the new path.
Files:
-
Modify:
backend/app/gateway/app.py -
Modify:
backend/app/gateway/deps.py -
Test:
backend/tests/test_gateway_deps_config.py(new) -
Step 1: Write the failing test
# backend/tests/test_gateway_deps_config.py
from fastapi import FastAPI, Depends
from fastapi.testclient import TestClient
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from app.gateway.deps import get_config
def test_get_config_returns_app_state_config():
app = FastAPI()
cfg = AppConfig(sandbox=SandboxConfig(use="test"))
app.state.config = cfg
@app.get("/probe")
def probe(c: AppConfig = Depends(get_config)):
return {"same": c is cfg}
client = TestClient(app)
assert client.get("/probe").json() == {"same": True}
- Step 2: Run test to verify it fails
cd backend && PYTHONPATH=. uv run pytest tests/test_gateway_deps_config.py -v
Expected: FAIL — get_config doesn't exist or returns the wrong thing.
- Step 3: Add
get_configtodeps.py
# backend/app/gateway/deps.py
from fastapi import Request
from deerflow.config.app_config import AppConfig
def get_config(request: Request) -> AppConfig:
"""FastAPI dependency that returns the app-scoped AppConfig."""
return request.app.state.config
- Step 4: Wire startup in
app.py
In backend/app/gateway/app.py, at startup (existing AppConfig.init call site), add:
app.state.config = AppConfig.from_file()
# Keep AppConfig.init() for now — other callers still use AppConfig.current()
AppConfig.init(app.state.config)
- Step 5: Run test to verify it passes
cd backend && PYTHONPATH=. uv run pytest tests/test_gateway_deps_config.py -v
Expected: PASS.
- Step 6: Commit
git add backend/app/gateway/deps.py backend/app/gateway/app.py backend/tests/test_gateway_deps_config.py
git commit -m "feat(config): add FastAPI get_config dependency reading from app.state"
Task P2-2 (Category G): Migrate FastAPI routers to Depends(get_config)
Files:
- Modify:
backend/app/gateway/routers/models.py(2 calls) - Modify:
backend/app/gateway/routers/mcp.py(3 calls) - Modify:
backend/app/gateway/routers/memory.py(2 calls) - Modify:
backend/app/gateway/routers/skills.py(1 call) - Modify:
backend/app/gateway/auth/reset_admin.py(1 call) - Modify:
backend/app/channels/service.py(1 call)
Pattern for each endpoint:
# Before
from deerflow.config.app_config import AppConfig
@router.get("/models")
def list_models():
models = AppConfig.current().models
...
# After
from fastapi import Depends
from app.gateway.deps import get_config
@router.get("/models")
def list_models(config: AppConfig = Depends(get_config)):
models = config.models
...
For mcp.py / skills.py runtime config reload:
# Before
AppConfig.init(AppConfig.from_file())
# After
request.app.state.config = AppConfig.from_file()
# Keep the AppConfig.init() call alongside for now — other consumers still need it
AppConfig.init(request.app.state.config)
- Step 1: Migrate
models.py
Replace 2 AppConfig.current() reads with config: AppConfig = Depends(get_config) parameter.
-
Step 2: Migrate
mcp.py— 3 reads + 1 reload write -
Step 3: Migrate
memory.py— 2 reads -
Step 4: Migrate
skills.py— 1 read + 1 reload write -
Step 5: Migrate
auth/reset_admin.py
reset_admin.py is a CLI-like entry. Signature changes to reset_admin(config: AppConfig). Caller in cli.py (or wherever it's invoked) constructs config at top.
- Step 6: Migrate
app/channels/service.py
Constructor or start_channel_service(config: AppConfig) — pass config from app.py where it's called.
- Step 7: Run full gateway test suite
cd backend && PYTHONPATH=. uv run pytest tests/test_gateway_*.py tests/test_channels_*.py -v
- Step 8: Grep verify Category G complete
cd backend && grep -rn "AppConfig\.current()" app/gateway/ app/channels/
Expected: no matches.
- Step 9: Commit
git add backend/app/gateway/ backend/app/channels/ backend/tests/
git commit -m "refactor(config): migrate gateway routers and channels to Depends(get_config)"
Task P2-3 (Category H): DeerFlowClient constructor-captured config
Files:
- Modify:
backend/packages/harness/deerflow/client.py(7current()+ 2init()calls) - Modify:
backend/tests/test_client.py,backend/tests/test_client_e2e.py
Pattern:
# Before
class DeerFlowClient:
def __init__(self, config_path: str | None = None):
if config_path is not None:
AppConfig.init(AppConfig.from_file(config_path))
self._app_config = AppConfig.current()
def some_method(self):
ext = AppConfig.current().extensions
...
# After
class DeerFlowClient:
def __init__(
self,
config_path: str | None = None,
config: AppConfig | None = None,
):
self._config = config or AppConfig.from_file(config_path)
def some_method(self):
ext = self._config.extensions
...
def _reload_config(self):
# Mutate self._config with model_copy or rebuild from file
self._config = AppConfig.from_file(...)
- Step 1: Update constructor signature
Add config: AppConfig | None = None parameter. Construct self._config locally, not via AppConfig.init() + current().
-
Step 2: Replace all 7
AppConfig.current()calls withself._config -
Step 3: Update
_reload_config()to rebuildself._config -
Step 4: Write test for multi-client isolation
# backend/tests/test_client_multi_isolation.py
from deerflow.client import DeerFlowClient
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.memory_config import MemoryConfig
def test_two_clients_different_configs_do_not_contend():
cfg_a = AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig(enabled=True))
cfg_b = AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig(enabled=False))
client_a = DeerFlowClient(config=cfg_a)
client_b = DeerFlowClient(config=cfg_b)
assert client_a._config.memory.enabled is True
assert client_b._config.memory.enabled is False
# Verify mutation of one client's config does not affect the other
# (impossible because frozen, but verify via identity too)
assert client_a._config is cfg_a
assert client_b._config is cfg_b
- Step 5: Run test to verify multi-client works
cd backend && PYTHONPATH=. uv run pytest tests/test_client_multi_isolation.py -v
- Step 6: Update existing client tests
Replace AppConfig.init(MagicMock(...)) patterns in test_client.py with constructing AppConfig instances and passing via DeerFlowClient(config=cfg).
- Step 7: Run full client test suite
cd backend && PYTHONPATH=. uv run pytest tests/test_client*.py -v
- Step 8: Grep verify Category H complete
cd backend && grep -n "AppConfig\.current()\|AppConfig\.init(" packages/harness/deerflow/client.py
Expected: no matches.
- Step 9: Commit
git add backend/packages/harness/deerflow/client.py backend/tests/
git commit -m "refactor(config): DeerFlowClient captures config in constructor"
Task P2-4 (Category B): Agent construction — thread AppConfig from make_lead_agent
Files:
- Modify:
backend/packages/harness/deerflow/agents/lead_agent/agent.py(5 calls) - Modify:
backend/packages/harness/deerflow/agents/lead_agent/prompt.py(5 calls) - Modify:
backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py(1 call)
Pattern:
# Before
def make_lead_agent(config: RunnableConfig) -> CompiledStateGraph:
app_config = AppConfig.current()
model_name = _resolve_runtime_model_name(config)
...
def _build_middlewares(config, runtime_config):
if AppConfig.current().token_usage.enabled:
...
# After
def make_lead_agent(config: RunnableConfig, app_config: AppConfig) -> CompiledStateGraph:
model_name = _resolve_runtime_model_name(config, app_config)
...
def _build_middlewares(app_config: AppConfig, runtime_config: RunnableConfig):
if app_config.token_usage.enabled:
...
- Step 1: Update
make_lead_agentsignature and internal calls
Add app_config: AppConfig parameter. Replace all 5 AppConfig.current() calls with app_config.xxx.
- Step 2: Update
_build_middlewares,_create_*_middlewarehelpers
Thread app_config through each helper that previously called AppConfig.current().
- Step 3: Update
prompt.pyhelpers
Every function that previously called AppConfig.current() now takes the relevant config slice as a parameter. Caller (either apply_prompt_template or make_lead_agent) provides it.
- Step 4: Update
tool_error_handling_middleware.py
Guardrail config is needed at middleware construction. Pass GuardrailsConfig to the middleware's __init__.
-
Step 5: Update the two call sites of
make_lead_agent -
backend/langgraph.json(or wherever LangGraph Server registers the agent) — the registration function wrapsmake_lead_agentand must supplyapp_config. If LangGraph Server doesn't support injecting extra args, wrap:def _lead_agent_for_langgraph(config: RunnableConfig): return make_lead_agent(config, AppConfig.from_file())(LangGraph Server still reads config from file — there's no central config broker in that process yet.)
-
backend/packages/harness/deerflow/client.py— already hasself._config, pass it:make_lead_agent(config, self._config). -
Step 6: Run agent tests
cd backend && PYTHONPATH=. uv run pytest tests/test_lead_agent*.py -v
- Step 7: Grep verify Category B complete
cd backend && grep -n "AppConfig\.current()" packages/harness/deerflow/agents/lead_agent/ packages/harness/deerflow/agents/middlewares/
Expected: no matches.
- Step 8: Commit
git add backend/packages/harness/deerflow/agents/ backend/langgraph.json backend/packages/harness/deerflow/client.py backend/tests/
git commit -m "refactor(config): thread AppConfig through lead agent construction"
Task P2-5 (Category D): Runtime infrastructure takes config at construction
Files:
- Modify:
deerflow/runtime/checkpointer/provider.py(2 calls),async_provider.py(1 call) - Modify:
deerflow/runtime/store/provider.py(2 calls),async_provider.py(1 call) - Modify:
deerflow/runtime/stream_bridge/async_provider.py(1 call) - Modify:
deerflow/runtime/runs/worker.py(1 call)
Pattern:
# Before
class CheckpointerProvider:
def get(self):
config = AppConfig.current().checkpointer
...
# After
class CheckpointerProvider:
def __init__(self, config: CheckpointerConfig | None):
self._config = config
def get(self):
config = self._config
...
Callers construct these providers at startup (from app/gateway/app.py or DeerFlowClient.__init__) with the relevant config slice.
- Step 1: Update
CheckpointerProviderconstructor +get_checkpointer_provider()factory
The factory may need to go from a module-level singleton getter to one that accepts config. Alternatively, the factory stays but takes config as parameter.
-
Step 2: Update
StoreProvideranalogously -
Step 3: Update
StreamBridgeProvideranalogously -
Step 4: Update
worker.py
Worker already receives a RunManager; RunManager receives config at construction time (from Gateway app.py) and forwards to Worker. Replace AppConfig.current() in worker with the injected config.
- Step 5: Update
RunManagerconstruction inapp/gateway/app.py
Pass app.state.config into RunManager(..., config=app.state.config).
- Step 6: Run runtime tests
cd backend && PYTHONPATH=. uv run pytest tests/test_checkpointer*.py tests/test_store*.py tests/test_stream_bridge*.py tests/test_worker*.py -v
- Step 7: Grep verify Category D complete
cd backend && grep -rn "AppConfig\.current()" packages/harness/deerflow/runtime/
Expected: no matches.
- Step 8: Commit
git add backend/packages/harness/deerflow/runtime/ backend/app/gateway/app.py backend/tests/
git commit -m "refactor(config): runtime providers take config at construction"
Task P2-6 (Category C): Memory subsystem — closure-captured config
Files:
- Modify:
deerflow/agents/memory/queue.py(2 calls) - Modify:
deerflow/agents/memory/updater.py(3 calls) - Modify:
deerflow/agents/memory/storage.py(3 calls)
This category is the trickiest because the Timer callback runs on a thread without Runtime. Config must be captured at enqueue time into the closure.
Pattern:
# Before — config read from ambient state on Timer thread
class MemoryQueue:
def add(self, conversation, user_id):
config = AppConfig.current().memory # may not exist on Timer thread
if not config.enabled:
return
# schedule Timer ...
# After — config captured at enqueue time
class MemoryQueue:
def __init__(self, updater: MemoryUpdater, config: MemoryConfig):
self._updater = updater
self._config = config
def add(self, conversation, user_id):
config = self._config # captured at construction
if not config.enabled:
return
# Timer callback closes over `config` and `conversation`
def _flush():
self._updater.update(conversation, user_id, config)
self._timer = Timer(config.debounce_seconds, _flush)
self._timer.start()
- Step 1: Add
MemoryConfigparameter toMemoryStorage.__init__
Replace all 3 AppConfig.current().memory reads with self._config.memory field accesses.
- Step 2: Add
MemoryConfigparameter toMemoryUpdater.__init__
Same pattern.
- Step 3: Add
MemoryConfigparameter toMemoryQueue.__init__
Same pattern. Timer callbacks close over self._config.
- Step 4: Update the factory / caller path
MemoryMiddleware (the consumer) currently constructs MemoryQueue lazily. Now it must get MemoryConfig from runtime.context.app_config.memory in before_model, and construct the queue with that config. Cache construction by config identity if re-construction on every invocation is too expensive.
Alternatively: MemoryMiddleware.__init__(config: MemoryConfig) and the config is supplied at middleware-chain construction time (from make_lead_agent → _build_middlewares).
- Step 5: Write regression test for Timer thread
# backend/tests/test_memory_queue_timer_captures_config.py
def test_timer_callback_uses_captured_config():
"""Verify Timer callback reads config from closure, not ambient state."""
cfg = MemoryConfig(enabled=True, debounce_seconds=0.01, ...)
updater = MagicMock()
queue = MemoryQueue(updater=updater, config=cfg)
queue.add(conversation=..., user_id="u1")
time.sleep(0.05)
# Verify updater was called with the captured cfg, not a re-read from AppConfig
assert updater.update.called
- Step 6: Run memory tests
cd backend && PYTHONPATH=. uv run pytest tests/test_memory*.py -v
- Step 7: Grep verify Category C complete
cd backend && grep -rn "AppConfig\.current()" packages/harness/deerflow/agents/memory/
Expected: no matches.
- Step 8: Commit
git add backend/packages/harness/deerflow/agents/memory/ backend/tests/
git commit -m "refactor(config): memory subsystem captures config at construction/enqueue"
Task P2-7 (Category E+F): Sandbox / skills / factories / tools / community — parameter threading
This is the largest mechanical task by file count. All follow the same pattern: add config: AppConfig (or a sub-config slice) to the function signature, replace AppConfig.current() with the parameter.
Files:
deerflow/sandbox/local/local_sandbox_provider.py(1),sandbox_provider.py(1),security.py(2)deerflow/sandbox/tools.py(5 — these already useresolve_context(); no change)deerflow/skills/loader.py(1),manager.py(1),security_scanner.py(1)deerflow/models/factory.py(1)deerflow/tools/tools.py(2)deerflow/subagents/registry.py(1)deerflow/utils/file_conversion.py(1)deerflow/community/aio_sandbox/aio_sandbox_provider.py(2)deerflow/community/tavily/tools.py(2)deerflow/community/jina_ai/tools.py(1)deerflow/community/infoquest/tools.py(3)deerflow/community/image_search/tools.py(1)deerflow/community/firecrawl/tools.py(2)deerflow/community/exa/tools.py(2)deerflow/community/ddg_search/tools.py(1)
Pattern:
# Before
def get_available_tools(groups, include_mcp=True, model_name=None, subagent_enabled=False):
config = AppConfig.current()
...
# After
def get_available_tools(
app_config: AppConfig,
groups=None,
include_mcp=True,
model_name=None,
subagent_enabled=False,
):
config = app_config
...
Caller responsibility: whoever calls get_available_tools() must have AppConfig in scope. For agent construction that's make_lead_agent(config, app_config) from Task P2-4. For factory tools registered via use: strings in config, the tools.py resolution pass threads app_config through.
- Step 1: Update
deerflow/models/factory.py
create_chat_model(name, thinking_enabled=False) → create_chat_model(name, app_config, thinking_enabled=False). Every caller (agent.py, client.py memory-updater internal model setup) passes app_config.
- Step 2: Update
deerflow/tools/tools.py
get_available_tools(...) signature gains app_config: AppConfig. Community tool resolution inside it also threads config.
-
Step 3: Update
deerflow/subagents/registry.py -
Step 4: Update
deerflow/sandbox/*.py(non-tools)
Provider construction takes config. security.py helpers take config parameter.
- Step 5: Update
deerflow/skills/*.py
Loader / manager / scanner take config parameter.
-
Step 6: Update
deerflow/utils/file_conversion.py -
Step 7: Update community tool factories
Each community/<name>/tools.py factory now accepts app_config. The tools.py resolution pass (Step 2) supplies it when instantiating.
- Step 8: Run affected test files
cd backend && PYTHONPATH=. uv run pytest tests/test_tool*.py tests/test_skill*.py tests/test_sandbox*.py tests/test_community*.py tests/test_*tool*.py -v
- Step 9: Grep verify Category E+F complete
cd backend && grep -rn "AppConfig\.current()" packages/harness/deerflow/{sandbox,skills,models,tools,subagents,utils,community}/
Expected: no matches (except sandbox/tools.py may retain resolve_context() calls for dict-legacy paths — those are fine).
- Step 10: Commit
git add backend/packages/harness/deerflow/ backend/tests/
git commit -m "refactor(config): thread AppConfig through sandbox/skills/factories/tools"
Task P2-8 (Category I): Test fixtures
Files:
- Modify:
backend/tests/conftest.py - Modify: ~18 test files using
patch.object(AppConfig, "current")orAppConfig._global = ...
Pattern:
# Before — conftest.py autouse fixture
@pytest.fixture(autouse=True)
def _auto_app_config():
previous_global = AppConfig._global
AppConfig._global = AppConfig(sandbox=SandboxConfig(use="test"))
try:
yield
finally:
AppConfig._global = previous_global
# Before — test using it
def test_something():
with patch.object(AppConfig, "current", return_value=AppConfig(...)):
result = function_under_test()
# After — conftest.py fixture returns config
@pytest.fixture
def test_config() -> AppConfig:
"""Minimal AppConfig for tests that need one."""
return AppConfig(sandbox=SandboxConfig(use="test"))
# After — test passes config explicitly
def test_something(test_config):
overridden = test_config.model_copy(update={"memory": MemoryConfig(enabled=False)})
result = function_under_test(config=overridden)
- Step 1: Update
conftest.py
Replace _auto_app_config autouse fixture with a non-autouse test_config fixture. The autouse is no longer needed because AppConfig.current() no longer exists after P2-10.
Note: Do not remove autouse yet. Tests that still call AppConfig.current() (pre-migration) would break. Instead:
-
Add the new
test_configfixture -
Keep autouse for now so old tests still work
-
Remove autouse only in Task P2-10 alongside deletion of
current() -
Step 2: Migrate tests by module, starting with most isolated
For each test file using patch.object(AppConfig, "current", ...):
- Replace with fixture injection:
def test_xxx(test_config)and passtest_config(or amodel_copy(update=...)variant) into the function under test.
Per-file migration order (smallest blast radius first):
test_memory_updater.py(14 occurrences) — Memory subsystem already took config parameter in P2-6test_client.py(20 occurrences) — Client already took config in P2-3test_checkpointer.py(11 occurrences) — Providers took config in P2-5test_memory_storage.py(10 occurrences)- Remaining files
- Step 3: Verify all tests pass after each file migration
cd backend && PYTHONPATH=. uv run pytest tests/<migrated_file>.py -v
- Step 4: Commit after each file (keeps diffs reviewable)
git commit -m "refactor(tests): migrate <file> to explicit config fixture"
- Step 5: Final grep verify
cd backend && grep -rn "patch\.object(AppConfig, \"current\"" tests/
cd backend && grep -rn "AppConfig\._global" tests/
Expected: no matches.
Task P2-9: Simplify resolve_context()
Files:
- Modify:
backend/packages/harness/deerflow/config/deer_flow_context.py - Test:
backend/tests/test_deer_flow_context.py
After P2-2 through P2-8, every caller that invokes resolve_context() either passes a typed DeerFlowContext or a dict. The dict path's AppConfig.current() fallback is no longer reachable if all construction sites are explicit.
- Step 1: Update
test_deer_flow_context.pyto expect hard failure on non-DeerFlowContext
def test_resolve_context_raises_on_missing_context():
runtime = MagicMock()
runtime.context = None
with pytest.raises(RuntimeError, match="not a DeerFlowContext"):
resolve_context(runtime)
def test_resolve_context_raises_on_dict_context():
runtime = MagicMock()
runtime.context = {"thread_id": "t1"}
with pytest.raises(RuntimeError, match="not a DeerFlowContext"):
resolve_context(runtime)
- Step 2: Simplify
resolve_context()
def resolve_context(runtime: Any) -> DeerFlowContext:
ctx = getattr(runtime, "context", None)
if isinstance(ctx, DeerFlowContext):
return ctx
raise RuntimeError(
"runtime.context is not a DeerFlowContext. Every caller must "
"construct and inject one explicitly; there is no global fallback."
)
- Step 3: Run
test_deer_flow_context.py
cd backend && PYTHONPATH=. uv run pytest tests/test_deer_flow_context.py -v
- Step 4: Run full test suite to catch any missed dict-context callers
cd backend && PYTHONPATH=. uv run pytest -v
If failures surface, they indicate a caller that was still relying on dict-context fallback. Fix by constructing proper DeerFlowContext.
- Step 5: Commit
git add backend/packages/harness/deerflow/config/deer_flow_context.py backend/tests/test_deer_flow_context.py
git commit -m "refactor(config): resolve_context requires typed DeerFlowContext"
Task P2-10: Delete AppConfig lifecycle
Files:
- Modify:
backend/packages/harness/deerflow/config/app_config.py - Modify:
backend/tests/conftest.py(remove_auto_app_configautouse fixture) - Modify:
backend/tests/test_app_config_reload.py(delete or rewrite as purefrom_file()test) - Modify:
backend/CLAUDE.md(update Config Lifecycle section)
Final deletion. Grep must show no callers of AppConfig.current(), AppConfig.init(), AppConfig.set_override(), AppConfig.reset_override() in production or tests.
- Step 1: Final grep — verify no callers remain
cd backend && grep -rn "AppConfig\.\(current\|init\|set_override\|reset_override\)" packages/ app/ tests/
Expected: no matches (except the app_config.py definitions themselves).
If any match, return to the relevant Category task and finish the migration.
- Step 2: Delete from
app_config.py
Remove:
_global: ClassVar[AppConfig | None]_override: ClassVar[ContextVar[AppConfig]]init(),set_override(),reset_override(),current()- The comment block
"# -- Lifecycle (process-global + per-context override) --" - Unused imports:
ContextVar,Token,ClassVar
The class reduces to: Pydantic fields + from_file(), resolve_config_path(), resolve_env_variables(), _check_config_version(), get_model_config(), get_tool_config(), get_tool_group_config().
- Step 3: Remove
_auto_app_configautouse fixture fromconftest.py
Keep only the explicit test_config fixture (non-autouse).
- Step 4: Delete or rewrite
test_app_config_reload.py
The tests covered init / set_override / auto-load, all of which are gone. Rewrite as a single test:
def test_from_file_is_pure(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("config_version: 6\nsandbox:\n use: test\n")
result1 = AppConfig.from_file(str(config_file))
result2 = AppConfig.from_file(str(config_file))
# Different objects (Pydantic doesn't intern)
assert result1 is not result2
# But equal values
assert result1 == result2
# Frozen — cannot mutate
with pytest.raises(ValidationError):
result1.log_level = "debug"
- Step 5: Update
backend/CLAUDE.md
Rewrite the "Config Lifecycle" section:
**Config Lifecycle**: All config models are `frozen=True` (immutable after construction). `AppConfig.from_file()` is a pure function — no side effects. There is no process-global or ContextVar — every consumer receives `AppConfig` as an explicit parameter.
- `app/gateway/app.py` loads config at startup and stores on `app.state.config`; routers access via `Depends(get_config)`
- `DeerFlowClient.__init__(config_path=..., config=...)` captures config as `self._config`
- Agent execution path: `DeerFlowContext(app_config=..., thread_id=...)` injected via LangGraph `Runtime[DeerFlowContext]`
- Background threads (memory debounce Timer): config captured at enqueue time in closure
- Tests: use the `test_config` fixture or construct `AppConfig` directly
- Step 6: Run full test suite
cd backend && PYTHONPATH=. uv run pytest -v
Expected: all pass.
- Step 7: Run linter
cd backend && make lint
- Step 8: Commit
git add backend/packages/harness/deerflow/config/app_config.py backend/tests/conftest.py backend/tests/test_app_config_reload.py backend/CLAUDE.md
git commit -m "refactor(config): delete AppConfig process-global and ContextVar lifecycle"
Verification — Phase 2 complete
- No global lookup remains
cd backend && grep -rn "AppConfig\.current()\|AppConfig\._global\|AppConfig\._override\|AppConfig\.init(\|AppConfig\.set_override(\|AppConfig\.reset_override(" packages/ app/ tests/
Expected: no matches.
AppConfigis a pure value object
Read backend/packages/harness/deerflow/config/app_config.py. It should contain: Pydantic fields, from_file(), resolve_config_path(), resolve_env_variables(), _check_config_version(), get_model_config(), get_tool_config(), get_tool_group_config(). Nothing else.
- Multi-client isolation works
tests/test_client_multi_isolation.py passes — two clients with different configs coexist.
- Full test suite green
cd backend && PYTHONPATH=. uv run pytest -v && make lint
- Commit log tells the story
git log --oneline refactor/explicit-config-p2
Shows ~10 commits, each scoped to one Category.