`ConfigResolver` (with its cached per-process snapshot) shipped in #5736, but every production reader still went through the legacy `resolve_scalar` / `resolve_ranked_scalar` wrappers, which re-parse `config.toml` on every call. This PR retires those wrappers and settles the question the wrappers had been hiding: **when is configuration read?** ## Behavior change Configuration files are now read once into a single process-wide generation. Every reader resolves against that generation, so no two parts of the process can disagree about a setting. **Editing `config.toml` while the app is running therefore has no effect until the generation advances**, which happens in exactly two places: an in-app write (toggling a preference refreshes the generation itself) and `/reload`. A file that fails to parse leaves the previous generation in force rather than half-applying the new one. This is the convention every long-running Unix service uses — read at start, change on an explicit signal. Watching files for edits is deliberately not done: a partly applied configuration is a worse failure than a stale one, and per-option exceptions (some live, some cached) would make the effective configuration unpredictable per setting. The policy is now written down in `ARCHITECTURE.md` and pinned by tests, so it stops being an accident of which call path a reader happens to take. Users who edit the file by hand and expect immediate effect will need `/reload`. Worth a release note. ## Retiring the wrappers 1. **Plain single-key readers move to the shared resolver.** `app.py`, `config.py`, `cold_cache.py`, `cost_tracking.py`, `main.py`, `plugins/discovery.py`, and the two TUI widgets now call `get_config_resolver().get(option)`, replacing per-call file parsing with the resolver's cached snapshot. Diagnostics behavior is preserved by calling `_emit_ranked_diagnostics(option, resolved)` explicitly at each site. `Settings._reload_values` resolves through `get_config_resolver(refresh_managed=refresh_managed)`, so `/reload` and later readers observe the same generation instead of the cache going stale; the env tier still comes from the method's `env` argument, and the "a failed or blocked reload never drops policy in force" invariant is unchanged. 2. **Explicit-snapshot callers build ad-hoc resolvers.** Callers that pass `toml_data=`/`managed_toml_data=` deliberately inspect a specific file generation rather than process state, so pointing them at the shared cache would be wrong. The decision rule: if the caller snapshots one generation itself — the `config` CLI (one read per invocation), `update_check` (health reported next to the value), the sandbox/theme loaders (a non-default `config_path` excludes managed policy), and the managed-policy validators (a candidate generation not yet in force) — it builds `resolver_from_snapshots(TomlSnapshot(...), TomlSnapshot(...))` and calls `.get(option)`. 3. **The legacy path is deleted.** `resolve_scalar`, `resolve_ranked_scalar`, and the now-dead `_coerce_env` helper are gone from `config_manifest`. The manifest's remaining bespoke readers share a private `_resolve_option`, which resolves through the shared generation when the caller supplies no tables. The migration-parity equivalence test is deleted with them — its job (proving the two paths agree) is done. Tests that drove the wrappers directly now exercise the same coercion, precedence, and diagnostics assertions through `ConfigResolver`/`resolver_from_snapshots` or real TOML files under the test-redirected config path. 4. **The remaining fresh-parse readers, and why.** Two keep a concrete parse, and neither is on a live path: - `resolve_read_project_dotenv` runs during dotenv bootstrap, before the project `.env` is layered into `os.environ`; seeding the shared generation there would capture an env tier later readers do not see. - `resolve_startup_mode_with_source` inspects the raw user table on its fall-through path, which the resolver does not expose. Its only production caller (`dcode config`) passes an explicit generation. ## Fixes found while reviewing this branch - **`/reload` previews read a stale user tier.** The preview path shares `_reload_values` with `refresh_managed=False`, so its cached user snapshot could predate the `[shell].allow_list` edit being previewed. The preview then reported no change while the accepted reload applied it — diverging on a security-sensitive auto-approval setting, and reaching the cwd-switch consent prompt as `project_settings_change_detected=False`. The preview now reads the user file fresh while keeping the managed snapshot the process is enforcing (a preview must not refresh policy in force). This is the atomic-swap half of the policy, not a liveness exception: preview and apply must agree on one generation. - **A lost diagnostic on UI preference writes.** `_save_ui_bool_result` was the one migrated reader that did not pick up the wrapper's implicit diagnostics call, so a malformed managed `[ui]` entry was reported nowhere — removing the one signal an administrator has that their policy is inert. - **`/reload` reloaded every provider twice.** `get_config_resolver(refresh_managed=True)` already reloads on a cache hit, so the explicit `.reload()` was redundant: one `/reload` read the managed file four times and re-ran `managed_policy_violations` with each. - **Merge strategies for real manifest options were unasserted.** The deleted `test_populated_tiers_actually_reach_the_resolver` did not depend on the `resolve_scalar` oracle, and `merge_strategy` appeared nowhere else in the suite — so flipping `threads.columns` from `DEEP_MERGE` to `REPLACE`, or `mcp.disabled_servers` from `UNION` to `REPLACE`, passed the full suite. Restored and mutation-checked in both directions. - **`refresh_managed` was untested.** Removing the refresh from `/reload` kept the suite green, because the existing reload tests drive env rather than a file edit and monkeypatch `DEFAULT_CONFIG_PATH` (which changes the resolver cache key and rebuilds a fresh resolver, hiding staleness). Now pinned in both directions. - **Comment rot.** Roughly ten comments still described per-call file parsing, including one that promised live edits took effect without a restart — the exact behavior the migration removed. `get_option`, `get_config_options`, `_emit_ranked_diagnostics`, and the manifest types stay public and unchanged; managed config remains read-only; no new dependencies. ## Follow-ups - `_emit_ranked_diagnostics` and `_ranked_source` now have nine external importers, and the snapshot-construction block appears at six production sites. A small `resolve_for_display(option)` seam (get + emit + label) would restore the encapsulation the wrapper provided and make the missing-emit bug above structurally impossible. - A file corrupted *after* its generation is taken is not reported. Corruption present when the file is read still logs with exact line and column; only mid-session corruption is silent. A cheap mtime/size check on `/reload` would close it. - Five `cast(...)` calls exist only because `ConfigResolver.get` returns `ResolvedValue[object]`; three sit behind validating predicates that could be `TypeGuard`s. `AGENTS.md` treats `cast` as a last resort.
5.7 KiB
Deep Agents Code Architecture
What this package is
deepagents-code is a prebuilt terminal coding agent built on top of the deepagents SDK. It is a reference implementation: one design for packaging the SDK into a useful coding-agent product, based on patterns that have worked well in our experience.
The SDK provides the agent harness. This package shows how to combine that harness with a terminal experience, persistence, tools, skills, and optional sandboxed execution.
The big picture
Deep Agents Code has two runtime halves:
┌──────────────────── Terminal client ─────────────────────┐
│ Presents interactive or headless output │
│ Collects user input and approvals │
└──────────────────────────┬───────────────────────────────┘
│ streaming protocol
▼
┌──────────────────── Agent server ────────────────────────┐
│ Runs the coding agent graph │
│ Connects the model, tools, memory, skills, and backend │
└──────────────────────────────────────────────────────────┘
The client and server run in separate processes. The client owns presentation and input. The server owns the agent runtime. Keeping that boundary narrow makes the UI responsive while letting the agent use LangGraph's streaming, checkpointing, and resume behavior.
Request flow
A request follows the same shape in interactive and headless mode:
- The client receives user input.
- The client sends that input to the agent server.
- The server runs the agent and streams events back.
- The client renders those events and collects any needed human response.
- Session state is preserved so the conversation can continue later.
Headless mode uses the same agent runtime as the interactive UI, but swaps the terminal interface for machine-friendly input and output.
Configuration and extension
Configuration is layered across user, project, session, and runtime scopes. That lets teams share project defaults while individual users keep their own credentials, preferences, skills, and local settings.
Configuration files are read into a single process-wide generation, built on the first read and reused after that. Readers that resolve through the shared resolver all observe that one generation. They cannot disagree about a setting.
An edit to config.toml while the app runs has no effect on those readers until the generation advances. This happens in two places: an in-app write to the default config path, which refreshes the generation itself, and /reload. Each source keeps its last usable snapshot, so a file that fails to parse leaves that tier unchanged instead of erasing it.
The app does not watch files for edits. A partly applied configuration is a worse failure than a stale one.
Some readers sit outside the shared generation. Callers that take their own snapshot inspect one file generation instead of process state, and report it next to its health: get_config_sources, the dcode config command, and the dcode doctor command, which reads the managed file against an empty user tier. A few readers parse a file on each call, because the shared generation cannot serve them: resolve_read_project_dotenv runs before the project .env is layered into the environment, resolve_startup_mode_with_source needs the raw user table, and update_check reports the value next to the file health it just read. The reload preview also reads the user file fresh, because a dry run must show the edit under review.
The environment tier is always live. EnvProvider reads os.environ at resolution time, because the process changes it during dotenv bootstrap and on each cwd switch.
These exceptions are per caller, not per setting. A caller decides to snapshot a file itself. No option is intended to be live for one reader and cached for another, which would make the effective configuration unpredictable per option.
The main extension points are:
- Skills and subagents for reusable agent workflows
- Tools and MCP servers for external capabilities
- Sandboxes for changing where tool execution happens
- Hooks and commands for integrating with local workflows
These pieces are designed to compose. A project can provide shared defaults and integrations, while each user can layer personal configuration on top.
Design tradeoffs
This architecture optimizes for:
- A responsive local terminal experience
- A reusable agent core that can be tested apart from the UI
- Durable sessions that can be resumed
- Controlled tool execution, locally or in a sandbox
- Practical extension points without rewriting the core app
The main cost is the client/server boundary. When debugging, first decide which side owns the failure: presentation and input usually belong to the client; model execution, tools, memory, and graph startup usually belong to the server.
Where to go next
- For local setup and debugging, see
DEVELOPMENT.md. - For command behavior, see
COMMANDS.md. - For lifecycle hooks (
hooks.json), seeHOOKS.md. - For cost estimates and local pricing overrides (
prices.json), seePRICING.md. - For security boundaries, see
THREAT_MODEL.md. - For package-specific coding conventions, see
AGENTS.md.