|
| 1 | +# agentty — Architecture |
| 2 | + |
| 3 | +A field guide to how the binary is put together. This is a map for someone |
| 4 | +about to change the code, not marketing. The single source of truth is always |
| 5 | +the code itself; where this doc and the code disagree, the code wins. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 1. The shape of the program |
| 10 | + |
| 11 | +agentty is an Elm-style application. The entire runtime is one pure function |
| 12 | +applied in a loop: |
| 13 | + |
| 14 | +``` |
| 15 | +(Model, Msg) -> (Model, Cmd<Msg>) |
| 16 | +``` |
| 17 | + |
| 18 | +- **Model** is the whole application state — one aggregate struct. |
| 19 | +- **Msg** is a closed sum type of every event that can happen. |
| 20 | +- **Cmd** is a description of side effects to run (network, disk, timers); the |
| 21 | + runtime executes them and feeds their results back as new `Msg`s. |
| 22 | + |
| 23 | +Rendering is a second pure function, `view : Model -> Element`, delegated to |
| 24 | +**maya** — a sister TUI engine pulled in as a git submodule. The host never |
| 25 | +constructs chrome glyphs or makes layout decisions; it builds widget *Config* |
| 26 | +values from `Model` state and maya owns every pixel, border, and animation. |
| 27 | + |
| 28 | +The four maya `Program` hooks are bound in |
| 29 | +`include/agentty/runtime/app/program.hpp`: |
| 30 | + |
| 31 | +| Hook | Meaning | |
| 32 | +|----------------|-------------------------------------------------| |
| 33 | +| `init` | Load settings + recent threads via Store seam. | |
| 34 | +| `update` | The reducer — `src/runtime/app/update.cpp`. | |
| 35 | +| `view` | `Model -> Element`. | |
| 36 | +| `subscribe` | Timers and the live stream subscription. | |
| 37 | +| `visual_hash` | Render-skip gate; identical hash → skip frame. | |
| 38 | +| `needs_warmup` | One-shot fast scrollback rehydration on resume. | |
| 39 | + |
| 40 | +`main.cpp` is wiring only: parse argv, resolve credentials, construct the |
| 41 | +concrete `AnthropicProvider` + `FsStore`, install them behind the `Deps` seam, |
| 42 | +then hand `AgenttyApp` to `maya::run`. |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## 2. Directory layout |
| 47 | + |
| 48 | +`include/agentty/` and `src/` mirror each other by domain. Headers carry the |
| 49 | +types and inline logic; `src/` carries the heavier implementations. |
| 50 | + |
| 51 | +- **`domain/`** — pure data, no I/O. `session`, `conversation`, `catalog`, |
| 52 | + `todo`, `profile`, and the strong-id newtypes in `id.hpp` (`ToolCallId`, |
| 53 | + `ThreadId`, `OAuthCode`, `PkceVerifier`). Swapping two ids of different |
| 54 | + newtype is a compile error, not a debugging session. |
| 55 | +- **`runtime/`** — the application proper. |
| 56 | + - `model.hpp` — the composed `Model` plus UI-only sub-states (composer, |
| 57 | + pickers, palette, modals) that belong to no domain. |
| 58 | + - `msg.hpp` — the `Msg` sum, split into domain sub-variants (see §4). |
| 59 | + - `app/update/<domain>.cpp` — per-domain reducers. |
| 60 | + - `view/` — the `Model -> Element` pipeline, one file per widget family. |
| 61 | +- **`provider/`** — the `Provider` concept and its only implementation, |
| 62 | + `anthropic/transport.cpp` (HTTP/2 + SSE). |
| 63 | +- **`tool/`** — the `Tool` concept, the registry, the permission policy, and |
| 64 | + one file per tool under `tool/tools/`. `memory_store.cpp` backs |
| 65 | + `remember`/`forget`. |
| 66 | +- **`io/`** — `http`, `tls` (certificate pinning), `auth` (OAuth + PKCE), |
| 67 | + `persistence` (atomic writes), `clipboard`. |
| 68 | +- **`airgap/`** — SOCKS5-over-SSH so the agent can run on a host with no direct |
| 69 | + internet while the laptop relays the bytes. |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +## 3. Seams: how concrete types stay hidden |
| 74 | + |
| 75 | +`AgenttyApp` must not be templated on the Provider and Store types — that would |
| 76 | +force every translation unit to know the concrete types and rebuild when they |
| 77 | +change. Instead, `include/agentty/runtime/app/deps.hpp` defines a small |
| 78 | +`Deps` struct of `std::function`s: |
| 79 | + |
| 80 | +- **Provider seam** — `stream(Request, EventSink)`. |
| 81 | +- **Store seam** — `save_thread`, `load_threads`, `load_thread`, |
| 82 | + `load_settings`, `save_settings`, `new_thread_id`, `title_from`. |
| 83 | +- **Auth context** — the typed `AuthHeader` for the session. |
| 84 | + |
| 85 | +`main.cpp` calls `app::install(provider, store, auth_header)` once at startup; |
| 86 | +the reducer reaches the seams through `app::deps()`. `update_auth(...)` |
| 87 | +live-swaps credentials after an in-app login without restarting the process — |
| 88 | +in-flight streams cached the header at request-build time, so they are |
| 89 | +unaffected. |
| 90 | + |
| 91 | +The `Provider` concept is deliberately tiny: |
| 92 | + |
| 93 | +```cpp |
| 94 | +template <class P> |
| 95 | +concept Provider = requires(P& p, Request req, EventSink sink) { |
| 96 | + { p.stream(std::move(req), std::move(sink)) } -> std::same_as<void>; |
| 97 | +}; |
| 98 | +``` |
| 99 | + |
| 100 | +Anything that streams a chat completion satisfies it — the real Anthropic |
| 101 | +transport in production, a deterministic in-memory script in tests. |
| 102 | + |
| 103 | +--- |
| 104 | + |
| 105 | +## 4. Msg: a closed sum, split for compile speed |
| 106 | + |
| 107 | +A naive design inlines every leaf event in one giant variant. That pins |
| 108 | +`sizeof(Msg)` to the heaviest leaf, instantiates an N-wide `std::visit` |
| 109 | +dispatch table, and forces the whole reducer TU to rebuild on any leaf change. |
| 110 | + |
| 111 | +agentty instead groups leaves into ~12 **domain sub-variants** in `msg.hpp` |
| 112 | +(`ComposerMsg`, `StreamMsg`, `ToolMsg`, `ModelPickerMsg`, `ThreadListMsg`, |
| 113 | +`CommandPaletteMsg`, `MentionPaletteMsg`, `SymbolPaletteMsg`, `TodoMsg`, |
| 114 | +`LoginMsg`, `DiffReviewMsg`, `MetaMsg`). The top-level reducer in |
| 115 | +`update.cpp` is then a small `std::visit` that forwards each domain to its own |
| 116 | +TU: |
| 117 | + |
| 118 | +```cpp |
| 119 | +auto step = std::visit(overload{ |
| 120 | + [&](msg::ComposerMsg cm) { return detail::composer_update(std::move(m), std::move(cm)); }, |
| 121 | + [&](msg::StreamMsg sm) { return detail::stream_update (std::move(m), std::move(sm)); }, |
| 122 | + [&](msg::ToolMsg tm) { return detail::tool_update (std::move(m), std::move(tm)); }, |
| 123 | + // … nine more domain arms … |
| 124 | +}, msg); |
| 125 | +``` |
| 126 | + |
| 127 | +Each `update/<domain>.cpp` recompiles only when its own leaves change. |
| 128 | +Call sites still build a `Msg` directly via `std::variant`'s converting |
| 129 | +constructor — only the owning domain accepts a given leaf, so the wrap is |
| 130 | +unambiguous. |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## 5. Tools: typed bundles behind a JSON edge |
| 135 | + |
| 136 | +The `Tool` concept (`include/agentty/tool/tool.hpp`) requires a static bundle |
| 137 | +of identity + schema + effects + behavior: |
| 138 | + |
| 139 | +```cpp |
| 140 | +template <class T> |
| 141 | +concept Tool = requires { |
| 142 | + typename T::Args; |
| 143 | + typename T::Result; |
| 144 | + { T::name() } -> std::convertible_to<std::string_view>; |
| 145 | + { T::description() } -> std::convertible_to<std::string_view>; |
| 146 | + { T::input_schema() } -> std::convertible_to<nlohmann::json>; |
| 147 | + { T::effects() } -> std::convertible_to<EffectSet>; |
| 148 | +} && requires(const nlohmann::json& args) { |
| 149 | + { T::execute(args) } -> std::convertible_to<ExecResult>; |
| 150 | +}; |
| 151 | +``` |
| 152 | + |
| 153 | +Tools are fully typed internally; only the dispatcher boundary speaks JSON. |
| 154 | +`DynamicDispatch` looks a tool up in the registry, executes it inside a |
| 155 | +`try/catch` (a crashing tool becomes a typed `ToolError`, not a process abort), |
| 156 | +and applies a **per-tool output budget** so a runaway `read`/`bash`/`grep` |
| 157 | +can't blow the context window. Truncation is UTF-8-safe and comes in three |
| 158 | +strategies: |
| 159 | + |
| 160 | +- **Head** — keep the front; right for ordered chunks (read, edit, write). |
| 161 | +- **Tail** — keep the end; right for log streams (bash, diagnostics). |
| 162 | +- **HeadTail** — keep both ends with a middle elision marker; right for tools |
| 163 | + where both ends carry signal (grep, web_*, git diff/log/status). |
| 164 | + |
| 165 | +The shipped tools: `read`, `write`, `edit`, `bash`, `grep`, `glob`, |
| 166 | +`list_dir`, `find_definition`, `web_fetch`, `web_search`, `todo`, |
| 167 | +`diagnostics`, `git_status`, `git_diff`, `git_log`, `git_commit`, `remember`, |
| 168 | +`forget`, `wipe_memory`. |
| 169 | + |
| 170 | +--- |
| 171 | + |
| 172 | +## 6. Permission policy: a constexpr matrix |
| 173 | + |
| 174 | +Every tool declares an `EffectSet` over four bits: `ReadFs`, `WriteFs`, `Net`, |
| 175 | +`Exec`. The active **Profile** plus that effect set feed the pure `constexpr` |
| 176 | +function `policy::permission(effects, profile)` in `tool/policy.hpp`, which |
| 177 | +returns `Allow` or `Prompt`. The rule: |
| 178 | + |
| 179 | +| Profile | Pure | ReadFs | WriteFs | Net | Exec | |
| 180 | +|-------------|-------|--------|---------|--------|--------| |
| 181 | +| **Write** | Allow | Allow | Allow | Allow | Allow | |
| 182 | +| **Ask** | Allow | Allow | Prompt | Prompt | Prompt | |
| 183 | +| **Minimal** | Allow | Prompt | Prompt | Prompt | Prompt | |
| 184 | + |
| 185 | +`Write` is fully autonomous. `Ask` trusts read-only inspection so an agent |
| 186 | +loop's read/grep/glob doesn't prompt on every step but gates anything that |
| 187 | +mutates state, runs code, or hits the network. `Minimal` prompts for every tool |
| 188 | +that touches the outside world and auto-allows only pure ones. `Exec` is the |
| 189 | +maximal capability — a tool carrying it prompts regardless of what else it has, |
| 190 | +on the type-theoretic claim that `bash` lets the model *author* the side |
| 191 | +effect, so it dominates any individual filesystem mutation already gated. |
| 192 | + |
| 193 | +The whole table is proved at compile time. `EffectSet` is a 4-bit bitset (16 |
| 194 | +sets) × 3 profiles = exactly **48 cells**. A second function, |
| 195 | +`expected_decision`, re-states the policy independently, and an exhaustive |
| 196 | +`constexpr` loop `static_assert`s `permission(e, p) == expected_decision(e, p)` |
| 197 | +over every cell — so a one-handed change to either side breaks the *build*, not |
| 198 | +a test nobody runs. A further `static_assert` pins the bitset width, firing if |
| 199 | +a fifth `Effect` is added without extending both sides. |
| 200 | + |
| 201 | +`DynamicDispatch::needs_permission` is the single place the runtime asks "does |
| 202 | +this gate on the user?", and unknown tools fail closed (default to requiring |
| 203 | +permission). The companion `policy::reason` supplies the one-line explanation |
| 204 | +rendered in the permission card ("wants to run an arbitrary subprocess", "will |
| 205 | +modify files on disk", …). |
| 206 | + |
| 207 | +--- |
| 208 | + |
| 209 | +## 7. Tool scheduling: parallel-safety from the effect set |
| 210 | + |
| 211 | +The same `EffectSet` that drives permissions also decides whether two tools may |
| 212 | +run concurrently. `effects::is_parallel_safe(active, want)` answers "may a tool |
| 213 | +with `want` effects start while `active` effects are in flight?": |
| 214 | + |
| 215 | +- **`WriteFs` and `Exec` demand exclusive access.** A write can mutate state a |
| 216 | + sibling is reading, writing, or shelling against — two edits to "different" |
| 217 | + files look independent until the model picks overlapping paths. `Exec` is |
| 218 | + worse still because the model chose the command, so the runtime serialises. |
| 219 | +- **`Pure`, `ReadFs`, and `Net` compose freely.** Read-read never races, `Net` |
| 220 | + touches neither FS nor process state, and in-memory `Pure` tools (`todo`) |
| 221 | + operate on data the model can't observe concurrently. |
| 222 | + |
| 223 | +The rule is, again, proved at compile time — `effects.hpp` carries a block of |
| 224 | +`static_assert`s pinning the exclusive/compose decisions, and the tool spec |
| 225 | +carries `parallel_rule_is_well_founded`. Effects are chosen by *what the tool |
| 226 | +does to the world, not how it's implemented*: `git_status` is `ReadFs` even |
| 227 | +though it shells out to `git`, because the runtime knows what that subprocess |
| 228 | +does; `bash` is `Exec` because the model picks the command. |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +## 8. The streaming turn: a phase FSM with a retry watchdog |
| 233 | + |
| 234 | +A turn is not a single request — it cycles `Streaming → AwaitingPermission → |
| 235 | +ExecutingTool → Streaming → … → Idle`. `domain/session.hpp` models this as a |
| 236 | +phase variant where the per-turn `Active` context (cancel token, start stamp, |
| 237 | +retry counters) lives *inside* every non-`Idle` alternative — so reading those |
| 238 | +fields from `Idle` is a type error, not a logic bug masked by zero defaults. |
| 239 | +Legal transitions take the source by `&&` and re-wrap its context in the |
| 240 | +destination, so the FSM itself carries the turn state across phases. |
| 241 | + |
| 242 | +Reliability rides on two independent pieces: |
| 243 | + |
| 244 | +- **A retry state machine** (`retry::Fresh / StallFired / Scheduled`) replaces |
| 245 | + what used to be two hand-synchronised bools. A 120-s stall watchdog trips the |
| 246 | + cancel token (`Fresh → StallFired`); the synthetic `StreamError` schedules a |
| 247 | + retry via `Cmd::after` (`→ Scheduled`); a second error during the wait can't |
| 248 | + schedule a duplicate; `RetryStream` firing returns to `Fresh`. |
| 249 | +- **Two independent retry budgets.** `truncation_retries` covers a stream that |
| 250 | + EOFs mid-tool-args; `transient_retries` covers 5xx / network / overloaded / |
| 251 | + 429. `transient_retries` is *not* monotonic per turn — it resets to 0 |
| 252 | + whenever the wire proves healthy (first content delta, or an SSE ping / |
| 253 | + thinking delta), so a connect-ping-stall sequence gets a fresh budget each |
| 254 | + attempt instead of latching the session terminal. |
| 255 | + |
| 256 | +--- |
| 257 | + |
| 258 | +## 9. Safety boundaries |
| 259 | + |
| 260 | +- **Workspace boundary.** Filesystem tools refuse any path outside the launch |
| 261 | + directory (or `--workspace DIR`). `--workspace /` opts out. |
| 262 | +- **Sandbox.** `bash` and `diagnostics` run inside `bwrap` (Linux) or |
| 263 | + `sandbox-exec` (macOS) by default. Workspace + system libs + network are |
| 264 | + reachable; `~/.ssh`, `/etc`, and other projects are read-only. An approved |
| 265 | + `bash` call still can't `cat ~/.ssh/id_rsa`. `--sandbox auto|on|off`. |
| 266 | +- **TLS pinning.** Certificates are pinned on the real upstreams, end-to-end, |
| 267 | + including through the airgap SOCKS tunnel. |
| 268 | +- **Atomic writes.** Every persisted file is `write` + `fsync` + `rename` (or |
| 269 | + the Windows `MoveFileExW` equivalent), so a crash mid-write never corrupts a |
| 270 | + thread or the credential store. |
| 271 | + |
| 272 | +--- |
| 273 | + |
| 274 | +## 8. Rendering performance |
| 275 | + |
| 276 | +Idle agentty costs zero CPU: `fps = 0` means maya only renders on a `Msg`, |
| 277 | +input, or timer tick. Two host-side optimizations keep it cheap under load: |
| 278 | + |
| 279 | +- **`visual_hash`** mixes only the axes that change what's on screen. When the |
| 280 | + hash matches the previous frame, `view` + render are skipped entirely. The |
| 281 | + hash hashes only the *live* message tail (the frozen scrollback prefix is |
| 282 | + immutable archaeology), samples long strings instead of hashing every byte, |
| 283 | + and buckets time-driven animations so each visible step — and only each |
| 284 | + visible step — advances the hash. The animation bucket is phase-locked to |
| 285 | + whatever is actually on screen so the render gate and the animation never |
| 286 | + beat against each other. |
| 287 | +- **`needs_warmup`** fires a one-shot off-wire render when a thread is resumed, |
| 288 | + converting the first visible frame of a tool-heavy thread from O(content) to |
| 289 | + O(blit). |
| 290 | + |
| 291 | +--- |
| 292 | + |
| 293 | +## 9. Build notes |
| 294 | + |
| 295 | +- Requires GCC 14+ / Clang 18+ / MSVC 14.40+ and CMake 3.28+ (C++26). |
| 296 | +- `-DAGENTTY_STANDALONE=ON` statically links OpenSSL + nghttp2 + libstdc++ + |
| 297 | + libgcc when their `.a` archives are present; libc stays dynamic. A musl |
| 298 | + toolchain with `-DAGENTTY_FULLY_STATIC=ON` yields a 100% static binary. |
| 299 | +- `-DAGENTTY_USE_MIMALLOC=ON` (default) routes global `new`/`delete` through |
| 300 | + mimalloc; the override lives in exactly one TU (`main.cpp`). |
| 301 | +- **Gotcha:** `AGENTTY_AUTO_PULL_MAYA=ON` is the default and runs |
| 302 | + `git reset --hard origin/master` on the `maya/` submodule during build. Its |
| 303 | + only guard checks for *uncommitted* changes, so committed local maya work |
| 304 | + still gets wiped. Build with `-DAGENTTY_AUTO_PULL_MAYA=OFF` when iterating on |
| 305 | + maya. |
| 306 | + |
| 307 | +--- |
| 308 | + |
| 309 | +## 10. One-paragraph mental model |
| 310 | + |
| 311 | +`main.cpp` resolves credentials and installs a Provider + Store behind the |
| 312 | +`Deps` seam, then hands control to maya. maya calls `view(model)` to paint and |
| 313 | +`update(model, msg)` for every event. User input and SSE chunks become `Msg`s; |
| 314 | +the reducer dispatches each to a per-domain handler that returns the next |
| 315 | +`Model` plus a `Cmd` describing any side effects. Tools run behind a JSON |
| 316 | +dispatch edge with a `constexpr` permission gate and OS-level sandboxing, and |
| 317 | +their results loop back in as more `Msg`s. Nothing in the loop mutates global |
| 318 | +state; the only escape hatches are the explicit `Cmd`s the runtime executes on |
| 319 | +your behalf. |
0 commit comments