ADR 0023 — Decompose server.py: an AppState container + a composition root
- Status: Done (2026-06-05) — fully shipped across all three phases, each a live-smoked PR. Phase 1 #547; phase 2 #549–#552; phase 3 #554–#558.
server.py's 3,353 lines are now a ~700-lineserver/package composition root. - Date: 2026-06-05
- Deciders: Josh Mabry; protoAgent maintainers
- Tags: architecture, refactor, server, maintainability
- Supersedes / Superseded by: —
server.pyis 3,353 lines — 3.2× the next-largest file — and it isn't one concern: it's the CLI entry, agent init/reload, the store/scheduler/skills/ workflow builders, the chat backend, the A2A wiring, and ~26 HTTP route handlers, with_main()alone at 1,084 lines. The root cause is 26 ambient module-global singletons: every helper that touches runtime state reaches for a global, so it must live in (or close over)server.py. This ADR replaces the ambient globals with an AppState container and splits the file into a thin composition root + focused modules — zero functional change.
1. Context & Problem statement
server.py works (the suite is green), so this is a maintainability cost, not a bug — but a real one for a template others fork and edit: it's where merge conflicts cluster (it changed in roughly half the PRs of the session that prompted this ADR), where new contributors get lost, and where the unit-test-vs-wire-test gap lives.
Two structural causes:
- 26 ambient module-global singletons —
_graph,_graph_config,_knowledge_store,_skills_index,_workflow_registry,_telemetry_store,_inbox_store,_activity_log,_storm_guard,_checkpointer, the MCP / plugin lists, … Functions read them directly and write them viaglobal. So nothing that touches state can move out ofserver.py. This is the disease; the 1,084-line_main()is the symptom. - The
operator_api/extraction is half-done.register_operator_routesalready exists and takes ~22 handler callbacks — but the callback bodies are still defined inline in_main()as_operator_*closures over the globals. Only the URL-wiring moved out; the logic stayed.
2. Decision
Introduce an AppState container, then extract by concern; server.py becomes a composition root (~300–400 lines).
2.1 runtime/state.py — AppState
A single dataclass holds the 26 fields (graph, config, the stores, registries, MCP/plugin state, scheduler, background-task handles). A module-level singleton STATE replaces the ambient globals; get_state() returns it (usable as a FastAPI dependency). Functions read state.knowledge_store instead of the global _knowledge_store; init/reload mutate state.* instead of global _x. The migration is mechanical and behavior-preserving — same objects, same lifecycle, named field access instead of ambient names.
2.2 Extract by concern
With state no longer ambient, the big regions move to their own modules, each importing STATE:
server/agent_init.py—_build_*(knowledge/scheduler/skills/workflow),_init_langgraph_agent,_reload_langgraph_agent, settings callbacks.server/chat.py—_chat_langgraph_stream,_run_turn_stream, the slash- command parsing (workflow + subagent).server/a2a.py— the a2a-sdk wiring,ProtoAgentExecutorhookup, the terminal hook,_record_a2a_telemetry.operator_api/route modules — finish the extraction: move the inline_operator_*/_api_*route bodies intoknowledge.py,activity.py,telemetry.py,config_routes.py,chat_routes.py.
server.py keeps only: arg parsing, the FastAPI app, and the wiring that composes the modules — the composition root.
2.3 Safety protocol (load-bearing)
This is a refactor of the app core with zero intended functional change, so the risk is regression, not design. Therefore:
- One phase per PR, in dependency order: AppState → backends (chat/a2a/init) → route groups. Each is independently reviewable and revertable.
- Every PR is green on the full suite AND live-smoked against a throwaway instance (boot, a chat turn, the A2A round-trip, the knowledge store, an inbox fire) before merge — per the smoke-test lesson that caught three wire-level bugs this same session.
- No behavior changes ride along — pure moves + the state rename.
3. Consequences
server.pybecomes navigable; route/backend logic lives with its concern.- State is explicit and injectable — testable without monkeypatching module globals, and a step toward catching the wire-level gaps in CI.
- Finishes the
operator_api/extraction the codebase already started. - Short-term churn + regression risk, mitigated by the phasing + live smoke.
4. Implementation (phased)
- AppState ✅ shipped (#547) —
runtime/state.py+ replace the 26 globals. Biggest, most mechanical; the foundation everything else stands on. - Backends ✅ shipped (#549–#552) — extracted as a
server/package (server.pycould not coexist with aserver/dir, so it was promoted toserver/__init__.py+server/__main__.py; launch is nowpython -m server). Then one PR each:server/a2a.py(#550),server/chat.py(#551),server/agent_init.py(#552). The composition root dropped 3,353 → ~1,354 lines; each PR was live-smoked (boot, chat turn, A2A round-trip, hot reload). The cross-module shared surface stayed tiny —STATEplusagent_name/AGENT_NAME_ENV/_event_bus/_bundle_root, re-exported from__init__soserver.<symbol>is unchanged. - Route groups ✅ shipped (#554–#558) — moved the inline
_main()route bodies intooperator_api/*registrars:telemetry_routes.py,knowledge_routes.py,config_routes.py,chat_routes.py(each aregister_*_routes(app)), plus the 21 React-console handler closures intoconsole_handlers.py(finishing the half-doneoperator_api/extraction)._main()is now ~430 lines of pure app assembly; the wholeserver/__init__.pyis ~700. Each route group ships its own unit tests (testable on a bareFastAPI()app, no boot) and was live-smoked end-to-end.
Outcome: the ~3.35k-line monolith is a ~700-line composition-root package + focused modules (server/{a2a,chat,agent_init}.py, operator_api/*_routes.py). The fork-and-edit core is navigable; state is explicit (STATE) and injectable.
5. Alternatives considered
- Finish route extraction only (leave the global model). Rejected as the end state: the extracted routes would still import the ambient globals, so the coupling just spreads across files. (It's a valid first increment, but AppState is the actual fix.)
- Split by concern without AppState. Same problem — every moved function drags the globals with it.
- Leave it. Rejected: it's the fork-and-edit core; navigability compounds.