Execution Protocols
RFC: 221 (LoopRunner), 222 revised (Autopilot dispatch)
Location: packages/soothe/src/soothe/protocols/runner.py
Status: Implemented
What the Execution Layer Is
Execution protocols define how agent runs are launched, orchestrated, and streamed back to consumers. The central abstraction is LoopRunnerProtocol — the interface that runs complete StrangeLoop (Plan → Execute) cycles and streams results in real time.
The execution layer sits between high-level orchestration (the daemon’s QueryEngine) and low-level agent execution (the StrangeLoop graph). Consumers depend only on LoopRunnerProtocol; the concrete runner — LocalLoopRunner (multiprocessing) or RayLoopRunner (Ray actor) — is selected by the daemon’s LoopRunnerFactory based on config.
LoopRunnerProtocol
Role
LoopRunnerProtocol is a two-method interface:
run(request)— execute the StrangeLoop, yieldingStreamChunktuples until completion. This is anAsyncIterator— results stream in real time, they don’t arrive all at once.cancel()— request cancellation of a running loop.
The deliberately tiny surface (two methods) is a design choice: all execution complexity is encapsulated in LoopRunRequest and the streaming protocol, keeping the interface stable as internals evolve.
The StreamChunk Contract
StreamChunk = tuple[tuple[str, ...], str, Any]
# (namespace, mode, data)
This is the deepagents-canonical streaming format:
- namespace — a path tuple (e.g.,
("agent", "loop")) identifying which subgraph produced the chunk - mode — the stream mode (
"messages","updates","custom") - data — the payload (message dict, event object, etc.)
Consumers parse chunks by mode and namespace to render TUI output, extract plan results, or detect completion. The runner streams multiple modes simultaneously (subgraphs=True), so a single run interleaves message tokens, state updates, and custom events.
LoopRunRequest: The Execution Envelope
LoopRunRequest is a dataclass consolidating everything needed to run one agent loop. It replaced ad-hoc parameters previously passed to SootheRunner.astream(), including thread/workspace binding that was once mutated on a shared singleton.
Key fields and their purposes:
loop_id/thread_id— bind the run to a durable thread (DurabilityProtocol) and a unique loop instance.user_input— the user’s query or instruction.client_workspace/user_id/client_workspace_id— workspace resolution. Ifclient_workspaceis set, it’s used directly; otherwise the runner computes$SOOTHE_HOME/workspaces/<normalized_user_id>/ws_<hash>.autonomous/max_iterations— control autonomous goal management and loop iteration limits.timeout_seconds— worker pool timeout for cancellation.intent_hint— daemon-only direct model turns (text_completion,image_to_text,ocr,embed).clarification_mode/clarification_answer/clarification_answers(RFC-622) — whenclarification_answer=True, the runner treatsuser_inputas the answer to a pending clarification interrupt and resumes the graph viaCommand(resume=...)rather than starting a new turn. The runner verifies against the loop’s persistedpending_clarificationstate.autopilot_job(RFC-222 revised) — when set, this request is dispatched by the daemon’sAutopilotService; the worker hydrates from a context bundle instead ofuser_input.
GoalDispatchEnvelope
When autopilot_job is set, it carries a GoalDispatchEnvelope — a transient wire message (not a persistent entity). Created by the daemon when dispatching a goal to a subprocess worker, consumed by the worker’s hydration path:
goal_id— daemon’s canonical goal IDgoal_description— frozen at dispatch timemerged_context— pre-projected hydration bundle from the daemon’sContextProjector; the worker treats it as opaquedeadline_seconds— wall-clock budget (None= no cap)attempt— 1 on first dispatch, N on retry/backoff
Terminology gotcha: “Job” in Desktop UX = user-facing term for a root Goal (persistent).
GoalDispatchEnvelope= transient dispatch message (not stored).AutopilotJobis a deprecated alias — useGoalDispatchEnvelopein new code.
Execution Flow
The runner orchestrates the full StrangeLoop cycle:
LoopRunner.run(request)
→ resolve workspace path
→ configure agent loop (model, concurrency, subagents)
→ initialize StrangeLoop
→ loop iteration:
a. LoopPlanner.plan() → PlanResult
b. execute decision via CoreAgent (tools/subagents)
c. collect StepResult, update LoopState
→ stream StreamChunks to consumer throughout
→ persist final state
The runner streams chunks during execution, not after. This is why run() is an AsyncIterator — consumers (TUI, API) render progress incrementally.
Backends
| Backend | Status | Use Case |
|---|---|---|
LocalLoopRunner |
Current | Multiprocessing-based subprocess execution |
RayLoopRunner |
Available | Ray actor-based distributed execution |
Both implement LoopRunnerProtocol. The daemon’s LoopRunnerFactory selects between them via SootheDaemonConfig. Consumers (QueryEngine) see only the protocol.
Integration Points
Runner ↔ LoopPlanner
Each StrangeLoop iteration calls LoopPlannerProtocol.plan() to get a PlanResult. If plan_action == "new", the runner executes the new decision; if status == "complete", the goal is achieved and the loop ends. See planner.md.
Runner ↔ Policy
Before executing any tool or subagent, the runtime consults PolicyProtocol for permission. Denied actions raise PermissionError; need_approval actions pause for user input. See policy.md.
Runner ↔ Durability
The run binds to a thread_id (DurabilityProtocol). Thread metadata — including policy_profile — flows from durability into the runner’s configuration. On completion, state persists to the thread.
Gotchas
autopilot_jobvsuser_inputare mutually exclusive paths — whenautopilot_jobis set, the worker ignoresuser_inputand hydrates frommerged_context. WhenNone, the worker runs solo-mode (today’s default path).- Clarification resume is state-verified — the runner checks persisted
pending_clarificationstate before treating input as an answer. If no clarification is pending, it falls back to a normal turn. - Streaming is multi-mode — the runner streams
messages,updates, andcustommodes simultaneously withsubgraphs=True. Consumers must handle interleaved chunk types. resolve_workspace_path()has fallback logic — whenclient_workspaceis absent, it derives a path fromuser_id(or"anonymous") hashed withclient_workspace_idorloop_id. Don’t assume a specific path without calling this method.
Related Documentation
- Planner Protocol —
LoopPlannerProtocoldrives each iteration - Loop Protocols — working memory and operation security within loops
- Policy Protocol — permission checks during execution
- StrangeLoop Architecture