OpenHands execution loop¶
OpenHands is a selectable fourth inner execution loop, alongside the
native react / plan_execute / hybrid loops behind the
ExecutionLoop protocol. The inner coding loop is commodity; SynthOrg's
product is the orchestration, routing and control plane around it. So
rather than treat the native loop as the only option, we bundle a
best-in-class open coder as a second, selectable inner loop and A/B them
end to end, promoting the winner. This page covers the adapter; its two
governance boundaries are the LLM gateway and the
credentialed-tool MCP server.
Phase 0: harness survey (July 2026)¶
The pick was re-validated against the current field before building.
OpenHands V1 openhands-sdk is the confirmed choice, with no shift
and no licence change (the whole field is MIT/Apache).
- OpenHands V1 software-agent-sdk (
openhands-sdk, MIT, Python=3.12):
LLM(model=, api_key=, base_url=)points straight at our gateway; the append-onlyEventLogwith anon_eventcallback is the exact seam to map toTurnRecords and consult budget/shutdown/ cancellation checkers; MCP tools are first-class (FastMCP) so it consumes our credentialed-MCP endpoint natively;SecurityAnalyzer+ConfirmationPolicygive per-action defence-in-depth; aLocalWorkspaceruns bash/edit/test natively inside our container;ConversationState+EventLog(keyed by a stable conversation id under a persistence dir) give task-level resume. - Rejected: Goose (now Apache-2.0, but Rust, a foreign-runtime embed
with no Python SDK); OpenCode (MIT, but a CLI/TUI with weaker
per-action governance and non-LiteLLM routing); mini-swe-agent (great
baseline, no governance hooks); Aider (pair-programmer, not
autonomous); vendor
CLItools (reintroduce provider lock-in). None clears Python-embeddable + LiteLLM + event-stream governance + MCP-first at once.
Adapter shape¶
The adapter is a thin, testable client (engine/openhands/,
# module-kind: adapter). The runtime boundary is inverted behind an
OpenHandsConversation protocol (conversation.py) so the loop logic is
unit-tested against a deterministic fake that emits scripted events; the
real runtime (container_runtime.py) drives a sandbox container and is
exercised end-to-end by the live smoke. The SDK never enters the main
venv: it runs only inside the container.
loop.py:OpenHandsLoopsatisfies the structuralExecutionLoopprotocol;get_loop_type()returns"openhands".tool_invoker,provider,completion_configandstreaming_enabledare unused by design (OpenHands runs its own tools, and reaches models and its own streaming only through the gateway).events.py: the transport-neutralOpenHandsEvent(message / action / observation / finished / error). A model validator enforces thattool_nameis set only on an action and token/cost figures only on a turn (message / action);finish_reasonis a computed field.conversation.py: theOpenHandsConversationprotocol, itsOpenHandsRunSpec(task prompt, model, gateway + MCP base URLs, gateway token, workspace path, conversation id, max turns, project id), anEventSink(returnsFalseto request an early stop) and theConversationFactory.OpenHandsOutcomemakes a natural finish and an error message mutually exclusive.config.py: frozenOpenHandsLoopConfig(max turns, token TTL, idle timeout) andOpenHandsLoopDeps(conversation factory, the shared gateway signer, gateway + MCP base URLs, clock; blank URLs are rejected at construction).container_runtime.py: the real runtime. It drives the injectedSandboxStreamer(structurally the egress-pinnedDockerSandbox), serialises the run spec to one JSON line on the container'sstdin, and parses the structured JSON event stream from itsstdout. Depends on a narrow protocol, not the concrete backend, so the engine stays off the sandbox internals.errors.py:OpenHandsLoopError/OpenHandsRuntimeError/OpenHandsUnavailableError.
In-sandbox execution model¶
The agent runs to completion inside the docker/openhands container,
never in the API process: the SDK and its native terminal / file_editor
tools live only in the image. The host drives one run over the container's
standard streams (no in-container HTTP server):
- The container entrypoint (
docker/openhands/run_task.py) reads one JSON run-spec line fromstdin, builds the SDKLLM(pointed at the gateway bearer + base URL),Agent(native tools + the credentialed-MCP server over streamable-http) and aLocalConversation(persisting state under the workspace, keyed by the conversation id), and streams each agent event as one structured JSON line onstdout. - The host-side
DockerSandbox.stream_container_taskspawns the container withstdin/stdoutattached, writes the spec, and yields eachstdoutline. Egress is pinned by the network sidecar to exactly the gateway + credentialed-MCP hosts (the backend'sallowed_hosts); the workspace is mounted read-write. The container and sidecar are torn down on every exit path (natural end, early stop, error, or cancellation of the awaiting coroutine).
execute() flow¶
- Mint the per-run bearer from the shared gateway signer, binding
(execution_id, agent_id, task_id, project_id, provider, model_id, cost_ceiling); Explicit Provider Binding is enforced at mint (an unbound model fails loud, never auto-picks). - Build the run spec with the gateway/MCP endpoints, the workspace mount path, and the stable per-task conversation id for resume.
- Build the conversation via the factory:
container_runtimebound to the egress-pinned sandbox. The container'sLLM(api_key=<bearer>, base_url=<gateway>)reaches models only through the gateway and its credentialed tools only through the MCP endpoint. - Stream, consuming the container's
stdoutevents. At every event boundary the sink consultsbudget_checker/shutdown_checker/task_cancellation_checkerand returnsFalseto stop, yielding the matchingTerminationReason(BUDGET_EXHAUSTED/SHUTDOWN/CANCELLED) and tearing the container down. The gateway additionally enforces the authoritative hard token kill server-side. - Map events to turns: an action becomes one
TurnRecord; a message advances conversation state. The container reports a running accumulated cost per event, so the host attributes the per-turn cost delta (the deltas sum to the run total; the gateway is the authoritative cost sink). - Completion: build
ExecutionResult(COMPLETED), then apply the exact native NO_OP predicate: a task withartifacts_expectedthat produced no tool calls and is not a resumed run terminatesNO_OP(routed toFAILEDdownstream), never a silent success. Every terminal transition logsEXECUTION_LOOP_TERMINATED.
Resume¶
Task-level. The container persists ConversationState + EventLog under
the (read-write) workspace, keyed by the stable per-task conversation id;
on a resumed run the same id re-attaches to the persisted conversation. No
per-tool-exec SynthOrg checkpoint callback is wired (that is
native-loop-specific), so make_loop_with_callback returns the
OpenHandsLoop unchanged rather than warning it is unsupported.
Dependency isolation¶
openhands-sdk + openhands-tools are bundled only in the container
image (docker/openhands/, a hash-pinned lockfile), never in the main
package venv. The host needs no SDK client at all: it drives the container
over stdin/stdout, so the SDK's litellm 1.93 closure never touches the
app's pinned dependency set. (openhands-agent-server is deliberately
excluded: the stream model does not use it.) This keeps
check_license_compat.py green (image-only MIT/Apache). The runtime lives
behind the SandboxStreamer protocol, so the main venv never imports the
SDK.
Selection¶
The loop is chosen per task-complexity through the existing loop-selection
path, with "openhands" registered in the loop registry and both
known/buildable frozensets. Selection is off by default: the boot wiring
builds an AutoLoopConfig only when engine.loop_auto_select_enabled is
set, from the default complexity rules merged with
engine.loop_complexity_overrides and the engine.default_loop_type
fallback, so an operator can route any complexity (or every unmatched
task) to OpenHands for an A/B. The registry factory requires
OpenHandsLoopDeps; without them it fails loud, so an unwired deployment
can never silently fall back to a different loop.