Skip to content

Commit fbf3c53

Browse files
committed
docs: add ARCHITECTURE.md field guide
A map of how the binary is put together — the Elm-style Model/Msg/Cmd loop, the maya rendering seam, and where the major pieces live. Code remains the single source of truth; the doc defers to it on any disagreement.
1 parent 9fbf92a commit fbf3c53

1 file changed

Lines changed: 319 additions & 0 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
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

Comments
 (0)