-
Notifications
You must be signed in to change notification settings - Fork 0
systems droid
Foundry maintainers
The droid harness is Foundry's adapter over Factory's coding-agent CLI (droid). It turns a long-lived droid exec process into a stable contract for the engine:
send(prompt) → { text, usage, reason }
The executor cannot tell whether the turn ran over stream-JSON-RPC or the one-shot fallback. Sessions are scoped to one agent for a whole run, start lazily on that agent's first phase, and stay alive so corrections cost one message instead of a cold restart.
This layer owns wire framing, permission policy, event folding into the trace, model and tool catalog discovery, and graceful degradation when the RPC surface flaps. It does not own sequencing, envelope parsing, gates, or write-boundary enforcement. Those live in the engine.
All paths under apps/desktop/src/main/droid/:
| File | Role |
|---|---|
protocol.ts |
Wire types, version constants, request/response builders, notification payloads |
client.ts |
Long-lived droid exec child; JSON-RPC over stdio; turn collector |
agent.ts |
Per-agent session lifecycle; RPC vs oneshot mode; policy → human interrupt |
events.ts |
Folds notification stream into trace rows (one span per toolUseId) |
oneshot.ts |
Fallback: one droid exec -o json process per turn |
catalog.ts |
Model and tool discovery for the UI and roster editors |
permissions.ts |
Policy for droid.request_permission and droid.ask_user
|
Tests that encode the real CLI's quirks:
| File | Role |
|---|---|
tests/fake-droid.ts |
Scripted stdio peer that rejects plain JSON-RPC, numeric ids, etc. |
tests/droid-client.test.ts |
Session lifecycle, turns, labelling, permission policy (24 tests) |
Every frame on the RPC stream is JSON with a type discriminator:
-
RpcRequest: Foundry → droid. Carriesjsonrpc: '2.0',type: 'request', Factory version fields, stringid,method, optionalparams. -
RpcResponse: droid → Foundry for a pending call, or Foundry → droid answering a server request (droid.request_permission/droid.ask_user). -
RpcNotification: droid → Foundry. Shape isparams: { sessionId?, notification: DroidNotification }. Turn progress and completion are notifications, not responses.
Version constants currently pinned in protocol:
FACTORY_API_VERSION = '1.0.0'FACTORY_PROTOCOL_VERSION = '1.151.0'
Important payload types:
| Type | Meaning |
|---|---|
TokenUsage |
Input/output/cache/thinking tokens and factory credits |
ToolUse |
Streaming tool invocation (id, name, partial input) |
SessionSettings |
Flat settings: modelId, reasoningEffort, autonomyLevel, tool id lists |
AvailableModel |
Catalog entry returned on session init |
InitializeSessionResult |
sessionId + settings + available models |
ContextStatsResult |
Used / remaining / limit for the context lane bar |
DroidNotification covers the live surface: text deltas, create_message, tool_call / tool_result, token usage, working state, and the terminal agent_turn_completed.
These are observed against the real CLI and deliberately reproduced by fake-droid. A naive client gets all three wrong:
-
typediscriminator plus Factory versions. Every message needstype(request/response/notification) alongsidejsonrpcand the Factory API/protocol version fields. A plain JSON-RPC frame is rejected with parse error-32700. -
Request
idmust be a string. Numeric ids are rejected the same way (id: nullerror response). Foundry generates ids as`f${n}`. -
Session settings are flat params on
droid.update_session_settings. Nested under asettingsobject they are silently ignored. CLI flags like-mand--autodo not configure an already-open RPC session, so model substitution travels through this method.
Related turn semantics (also load-bearing):
-
droid.add_user_messagetakesparams.text(notmessage) and returns immediately. The turn ends when droid emitsagent_turn_completed. -
tool_callis re-emitted for the sametoolUseIdas arguments stream in. The client must fold them into one spanning event.
One long-lived child per agent per run:
droid exec --input-format stream-jsonrpc --output-format stream-jsonrpc --cwd <worktree> --auto <autonomy>
Responsibilities:
- Spawn, line-buffer stdout, dispatch frames.
- Correlate request ids via a
pendingmap with per-call timeouts. -
start():droid.initialize_sessionordroid.load_session, thenapplySettings(). -
send(text, timeoutMs):add_user_message+ wait forTurnCollectorto settle onagent_turn_completed. - Answer server requests for permissions without blocking the whole process tree.
- Degrade on policy-blocked models: retry
update_session_settingswithout the model override and emit amodel-warningevent rather than killing the session. - Prefer committed assistant text from
create_messageover the delta stream when finishing a turn.
INHERIT_MODEL = 'inherit' means "do not send a model override; use droid's default."
Adapter the engine holds in a Map<agentName, AgentSession>:
| Concern | Behavior |
|---|---|
| Laziness | No child until the agent's first send()
|
| Mode | Starts as rpc; may fall to oneshot
|
| Failure budget |
PROTOCOL_FAILURE_LIMIT = 2. After two protocol failures in a run, switch permanently to oneshot for that agent |
| Permissions |
evaluate() first; if policy returns null, askHuman sheet |
| Tracing | Upserts agent_sessions, records the droid process row, logs stderr and fallbacks, refreshes context stats after turns |
| Interrupt / kill | Soft interrupt via RPC; hard kill of the child |
Public turn result for the engine:
interface TurnOutcome {
text: string;
usage: UsageBreakdown;
reason: string;
}Folds the notification stream into trace rows while a turn is open:
-
tool_call: first frame for atoolUseIdopens a spanningtool_callevent with a human-readable label (bash: bun test,read: …/path.ts). Later frames for the same id rename the row as arguments fill in. -
tool_result: ends the open span with a truncated result andisError. -
assistant_text_delta: live tail only (ring buffer viaonText; not stored). -
Usage notifications: held for the turn outcome; unreported usage becomes
reported: false, not zeros that look like free work. -
closeDangling: if the transport dies mid-tool, open spans end with an error note so the UI never shows a row that never closes.
Fallback when RPC dies twice (or fails to start):
droid exec --output-format json --cwd <worktree> --auto <autonomy> [-m model] [-r effort] [--session-id id] <prompt>
Each turn is its own process. Session continuity uses --session-id when known. Mid-turn tool visibility is gone; AgentSession records one spanning tool_call event with a note that streaming is unavailable. Envelopes, gates, boundaries, and cost still work under the same send() contract. Policy-blocked models retry without -m.
Advisory discovery; droid remains authoritative at turn time.
| Source | Use |
|---|---|
droid exec --help |
Model table without a session or network |
Session availableModels
|
Merge into the help list after RPC init |
~/.factory/settings.json customModels
|
BYOK badges in the picker |
droid exec --list-tools -o json |
Tool list for the roster editor |
loadCatalog caches for 60 seconds. invalidateCatalog runs when the user changes droidPath in settings. IPC exposes catalogModels and catalogTools.
Why the raw JSON-RPC permission surface exists: too much auto-approve makes autonomy theater; too little stalls unattended runs. Every decision (auto or human) is traced as an interrupt event.
| Situation | Decision |
|---|---|
Read-only tools (Read, Grep, Glob, LS, …) |
Auto-allow |
| Write tools inside worktree and write boundary, autonomy medium+ | Auto-allow |
Write tools at autonomy low
|
Escalate to human |
| Write outside write boundary | Auto-deny (no sheet) |
| Write outside the worktree entirely | Escalate to human |
| Command on project allowlist (prefix match) | Auto-allow |
Command not allowlisted, autonomy high
|
Auto-allow |
| Command not allowlisted, autonomy medium/low | Escalate (sheet can "remember") |
droid.ask_user |
Always escalate; only a human answers questions |
| Unknown tool shape | Escalate with params as body |
Server method responses differ:
-
droid.request_permission→{ outcome: { outcome: 'allow' \| 'deny', reason? } } -
droid.ask_user→{ answer: 'yes' \| 'no' }
sequenceDiagram
participant Ex as Executor
participant AS as AgentSession
participant DC as DroidClient
participant CLI as droid exec
participant EF as EventFolder
participant Tr as Tracer
Ex->>AS: send(prompt, phaseId)
AS->>AS: ensureStarted()
AS->>DC: start() if no live child
DC->>CLI: spawn stream-jsonrpc
DC->>CLI: request initialize_session (string id)
CLI-->>DC: response sessionId + models
DC->>CLI: request update_session_settings (flat params)
CLI-->>DC: response + settings_updated notification
AS->>EF: new EventFolder for phase
AS->>DC: send(prompt)
DC->>CLI: request add_user_message (params.text)
CLI-->>DC: response (immediate, empty)
loop streaming turn
CLI-->>DC: notification tool_call / text_delta / usage
DC->>EF: onNotification → absorb
EF->>Tr: open/rename/end tool_call spans
end
CLI-->>DC: notification agent_turn_completed
DC-->>AS: TurnResult text + usage
AS->>Tr: context stats, agent_sessions upsert
AS-->>Ex: TurnOutcome
sequenceDiagram
participant CLI as droid exec
participant DC as DroidClient
participant AS as AgentSession
participant Pol as permissions.evaluate
participant UI as Human sheet
participant Tr as Tracer
CLI->>DC: request droid.request_permission
DC->>AS: onPermission(ask)
AS->>Pol: evaluate(ask, policy)
alt decision non-null
Pol-->>AS: allow or deny
AS->>Tr: interrupt (auto: true)
else decision null
AS->>Tr: interrupt (auto: false)
AS->>UI: askHuman(title, body, command?)
UI-->>AS: approve / reject (+ remember?)
AS->>Tr: endEvent(answered)
end
AS-->>DC: PermissionDecision
DC->>CLI: response with outcome or answer
stateDiagram-v2
[*] --> rpc: AgentSession created
rpc --> rpc: protocolFailures < 2, restart child and retry
rpc --> oneshot: protocolFailures >= 2 or start failed
oneshot --> oneshot: rest of run for this agent
After a transport failure under the limit, AgentSession drops the dead child, restarts RPC (reusing droidSessionId via load_session when possible), and retries the turn once. A second failure, or a hard start failure, switches to oneshot for the remainder of the run and notifies the executor via onModeChange so the run row's mode updates.
TurnCollector (inside client.ts) accumulates deltas for the live UI but prefers the committed assistant message from create_message when finishing. That is the text the engine parses as an envelope.
executor.ts owns a Map of AgentSession instances, one per agent name for the run. On each agent phase it:
- Resolves the roster agent and reuses or creates a session (
sessionFor). - Renders the prompt, writes it under the run files tree, and calls
session.send. - On correction (envelope or gate), re-prompts the same session so context is retained.
- On mode change, writes
runs.modethrough the tracer. - Wires
askHumanfor both engineer phases and permission interrupts. - Closes or kills sessions when the run finishes or is cancelled.
The harness never decides phase success. It only returns text and usage.
The harness is a heavy writer into the trace (never a reader of its own history for control flow):
| Write | When |
|---|---|
agent_sessions upsert |
Start, mode switch, oneshot session id adoption |
processes row (kind droid) |
RPC child pid known |
tool_call / end |
EventFolder folding |
interrupt |
Auto policy or human permission sheet |
log / error
|
stderr, protocol errors, fallback notes |
| Context tokens / window | After successful RPC turns via get_context_stats
|
runs.mode |
Through executor when mode changes |
Live assistant text is not stored; it is a ring buffer for the phase panel via onLiveText.
- IPC
catalogModels/catalogToolscall intocatalog.tswith the configured droid path. - Doctor checks use
droidVersionfor presence and version reporting.
Write boundaries after a phase still belong to the engine (git status diff). Permission policy only gates what the agent may attempt mid-turn. See security when that page covers autonomy and allowlists, and the write-boundary discussion in engine.
The renderer never speaks droid. It sees folded events, agent session rows, context occupancy, and interrupt sheets over the named IPC surface only.
| Caller | Entry | Effect |
|---|---|---|
| Executor agent phase |
new AgentSession / session.send
|
Lazy spawn, turn, fold events |
| Executor cancel / finish |
session.interrupt / close / kill
|
Soft stop or teardown |
| Executor mode callback | onModeChange |
Persist run mode rpc | oneshot
|
| IPC settings | invalidateCatalog |
Drop model cache when droid path changes |
| IPC catalog |
loadCatalog / loadTools
|
Roster and picker data |
| Doctor | droidVersion |
Binary health |
| Tests |
writeFakeDroid() + DroidClient
|
Protocol and policy without a real model |
There is no renderer entry into this package.
| Path | Notes |
|---|---|
apps/desktop/src/main/droid/protocol.ts |
Quirks documented at file top; frame builders; notification union |
apps/desktop/src/main/droid/client.ts |
Child process, pending map, TurnCollector, server-request answers |
apps/desktop/src/main/droid/agent.ts |
Session lifecycle, failure budget, oneshot switch, policy bridge |
apps/desktop/src/main/droid/events.ts |
toolUseId folding, labels, usage honesty |
apps/desktop/src/main/droid/oneshot.ts |
Per-turn exec -o json fallback |
apps/desktop/src/main/droid/catalog.ts |
Help-parse models, tools list, custom model badges |
apps/desktop/src/main/droid/permissions.ts |
Auto vs escalate matrix |
apps/desktop/src/main/engine/executor.ts |
Owns sessions; corrections re-prompt live sessions |
apps/desktop/src/main/trace/tracer.ts |
upsertAgentSession, process rows, event spans |
apps/desktop/tests/fake-droid.ts |
Quirk-faithful stdio peer; scenarios for happy path, tools, die mid-turn, model reject, permission ask |
apps/desktop/tests/droid-client.test.ts |
Contract tests for the harness |
- Architecture — process topology and where droid sits
-
Engine — run loop that owns
AgentSession -
Trace — event rows and
agent_sessions - Runs and traces — operator-facing live view
- Security — autonomy, allowlists, and human interrupts
- Glossary — droid, oneshot, correction
- Patterns and conventions — protocol quirks in contributor form
Overview
Snapshot and history
How to contribute
Apps
Systems
Features
Primitives
Background and security
Reference