Files
deepagents/libs/code/ARCHITECTURE.md
T
Mason Daugherty a564f8eead refactor(code): remove legacy resolve_scalar config path (#5755)
`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.
2026-08-24 12:24:34 -04:00

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:

  1. The client receives user input.
  2. The client sends that input to the agent server.
  3. The server runs the agent and streams events back.
  4. The client renders those events and collects any needed human response.
  5. 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