Status: Accepted (2026-08-13) — amends/supersedes ADR 0084 D3 (agent loop on the Worker) and the loop-host role assigned to the Worker in D2. All other ADR 0084 decisions (D1, D2's port/adapter principle, D4, D5, D6) stand. Date: 2026-08-13 Deciders: founder + co-admin (per Rules.md governance)
Context
ADR 0084 D3 placed the agent loop on the Worker: the client would send
{ prompt, selectionContext, model } (no key), the Worker would run
model ⇄ tool-call ⇄ ToolPort ⇄ result and stream applied edits back over SSE. P1
shipped that shape with a read-only stub ToolPort.
P2 implements the real ToolPort → ponte adapter — the point at which Garzone
stops answering and starts editing. Wiring the tools to real ops forced the question
of where the loop executes, and three facts made the Worker the wrong host:
- The tree lives in the browser. The design tree the tools read and mutate is in
browser React state, reachable via the studio bus (
getActiveTree().getTree()), not on the Worker. A Worker-side loop would have to ship the whole tree to the edge, mutate it there, and ship a diff back — every turn. - Undo is a browser concern. StepBack (ADR 0074) records one history entry per
setTreein the client. Intake decision 3 — "oneCmd+Zreverts the entire assistant response" — requires buffering a per-response working tree in the browser and committing it once. That is impossible if tool execution happens on the Worker. - Selection and tokens are browser singletons.
getPickedBoxIds()(@arno/selection-frame) and the origine token registry are client state; the read tools need them in-process.
Keeping the loop on the Worker would mean a chatty tree round-trip per turn AND no way to honor single-response undo — a direct conflict with the module's safety spine.
Decision
1. The agent loop runs in the browser client
apps/web/src/features/garzone/runner.ts (runGarzoneResponse) owns the loop. Per
assistant response it:
- seeds a per-response working tree (a deep clone of the live studio tree);
- drives
runGarzonewith a proxy driver that turns the Worker's per-turn frames intoDriverSteps and feeds each locally-executed tool-result back across turns; - executes every tool locally through the real
ToolPortadapter against the pure@arno/ponte/tree-ops(never the bus-imperative,setTree-calling ops), folding each write into the working copy so mid-response reads see prior same-turn writes; - commits the whole response with a single
setTreeat thedoneboundary — one StepBack entry, oneCmd+Z, regardless of how many writes ran across how many model turns. A zero-write or errored response commits nothing.
2. The Worker narrows to a keyed per-model-turn streaming proxy
apps/api/src/garzone/route.ts (POST /api/v1/garzone/stream) no longer runs
runGarzone, no longer imports @arno/garzone at runtime, and no longer emits the
neutral GarzoneEvent union. It holds the BYOK key for the request, calls the provider
for one model turn, and streams the turn-proxy frame union
(text-delta / tool-call / turn-end / error). It declares the D6 tool catalog
(tool-schemas.ts, type-only pinned to ToolName) to the provider so the model can
emit tool-calls — but it never executes a tool. The request body changes from
{ prompt, selectionContext, model? } to { messages, model? } (the client composes
selection context into ordinary conversation content, since it has the tree).
3. The neutral union stays single-sourced in @arno/garzone
The neutral GarzoneEvent union (the contract P1 put on the wire) is assembled
client-side, but the DriverStep → GarzoneEvent mapping is not re-implemented in
apps/web: the client drives runGarzone with the proxy driver, and the engine feeds
each tool-result back to the driver via iterator.next(result)
(packages/garzone/src/engine.ts) so the driver can request the next turn. The union
therefore stays single-sourced in the engine (independence guard, C29), avoiding the
drift a client-side fork would invite. The change is backward-compatible: P0/P1
single-turn drivers ignore the fed-back value and behave identically.
4. What ADR 0084 keeps
- D1 — Vercel AI SDK for provider-agnostic streaming + tool-calling.
- D2's port/adapter principle — Garzone raises a
ToolPort; the host brings the hands. Only the loop-host role the D2 diagram assigned to the Worker moves to the client. - D4 — key-source enum + flag-gated metering (still BYOK-only, metering off).
- D5 — every tree mutation is undoable via StepBack (now enforced by the client's single-commit model).
- D6 — the tool catalog (3 reads + 9 writes) and the token-SSOT rule (
setPropnever writes token values; the adapter no-ops such a call).
Consequences
- Undo by construction. The working-tree-then-single-commit model yields exactly
one
Cmd+Zper response with zero Worker↔client tree round-trips. - The Worker is stateless and cheap. One provider turn per request, no tree, no loop state — a better fit for the 30K-MAU, no-dedicated-inference-infra ceiling (ADR 0021) than a stateful edge loop.
- BYOK posture unchanged. The key still lives only server-side for the request and is never logged, echoed, or persisted (C34).
- Loop-level errors move to the client. A turn-proxy
errorframe becomes a terminal neutralerrorevent in the browser; the Worker keeps only per-turn provider-throw mapping (oneprovider_errorframe). - Security preserved. Tool execution is still the fixed catalog, now enforced in the browser adapter; prompt-injected tree/token content remains untrusted data, not commands (ADR 0084 § Security).
- Cost. The browser accumulates the conversation (
messages) and re-POSTs it each turn; the Worker is a thin relay. Acceptable — the tree never leaves the browser.
Alternatives considered
- Keep the loop on the Worker (ADR 0084 D3 as-is) — rejected: requires streaming the full tree to the edge and a diff back every turn, and cannot honor one-undo-per-response since StepBack lives in the client.
- Hybrid: Worker runs the loop, calls back to the browser per tool — rejected: a chatty per-tool round-trip with worse latency and the same undo problem.
- Re-implement the neutral-event mapping in
apps/web(fork the union) — rejected: exactly the drift the independence guard (C29) exists to prevent; the engine feed-back seam keeps the union single-sourced.
References
- Amends ADR 0084 — D2 (loop-host role) + D3 (loop location); keeps D1/D4/D5/D6.
- ADR 0074 (StepBack undo/redo),
ADR 0077 (package-seam
CameraPortpattern), ADR 0021 (30K-MAU ceiling). requirements/garzone-client-loop.md,requirements/garzone-stream-endpoint.md.- Intake decisions: loop-location, decision 3 (one undo unit per response), decision 5 (key-gated live-provider integration).