Repository navigation
feat(agents): add the OpenHands agent - #281
VascoSch92 wants to merge 8 commits into
Conversation
Ship OpenHands in the agents bundle as @local/agentenv-framework/agents/openhands. The agent drives the OpenHands SDK in process rather than a CLI: each task is one run of the context's persisted SDK conversation, with the task's MCP servers, its skills and the SDK's default tools, and its SDK events are the live trajectory. It implements agent-config, mcp-config, skill-config, trajectory, snapshot, peer-agents, install and triggers; install/v1 shares install.sh with the image build, as pi's does.
…ore empty workspaces The SDK does blocking work between its awaits, so a conversation run on the server's event loop stalled every other task and the agent's own endpoints, the peer-agents MCP server among them. Each run now has a loop of its own in a worker thread, closed with what the run left on it. An empty workspace's snapshot archive has no members, so restoring it created no staging directory and failed.
Drop the postponed-annotations import, which neither module needs, and annotate their module-level constants with typing.Final. Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Juan Michelini <juan@juan.com.uy>
The fake runtime now resumes a conversation only under the id and directory its state records, as the SDK does, instead of rewriting them on each run, which hid a snapshot load that kept the old id. New tests cover a snapshot load refused while a task runs, a snapshot save waiting for its context's running task, bundle skill paths that would leave the skill directory, an oversized attachment and a non-HTTP file URI; a hung run is bounded, so an agent that applies no timeout fails the test instead of hanging it. The bundle skill handler drops its own path check, which the protocol's RelativePath already makes unreachable.
The runtime needs the OpenHands SDK, which only the agent's image installs, so its behavior is now tested on the built image, as Claude Code's is: a fake Chat Completions endpoint answers its model calls, so no key is needed. The tests cover a reply through the configured endpoint with its key and headers, a shell command through the terminal tool, the deployed env's MCP tools, a refused key, and a timeout that interrupts the run, which then calls the model no more, while the context's next task resumes the conversation. They found that a failed run always reported openhands.error: the SDK reports why a run failed only as error events, which ConversationRunError does not carry, and a run the iteration cap ends does not raise at all. The runtime now takes the run's last error event, so a refused key fails as openhands.LLMAuthenticationError and the iteration cap as openhands.MaxIterationsReached.
…its conversation locks a2a_server.py now reads the LITELLM_* variables and the default model once, at startup, into an Endpoint the agent is given, so run() reads no environment and a LITELLM_EXTRA_HEADERS that is not a JSON object stops the agent with a clear error instead of failing each task. The framework already runs a context's tasks one at a time, so the agent's own lock only lets a snapshot save wait for the task running in its context; an entry now lives only while it is held, instead of one per context ever seen. The runtime types its conversation as the SDK's BaseConversation.
…wer errors as JSON
Following the agents guide (src/agent_env/agents/AGENTS.md): the agent's lifespan interrupts turns still running as
the server shuts down, and an interrupted run also closes its tools, killing the shell commands it started; errors
are answered as {"detail": ...}; the session ref is bound before the run starts; and events an interrupt emits after
its task ended are dropped instead of raising. New tests cover a cancel, the shutdown, the error bodies, and a
timeout mid-command leaving no process behind. Dataclasses take slots and module constants typed Finals.
| await asyncio.to_thread(_pack, directory, conversation_archive) | ||
| if request.objects.workspace is not None: | ||
| self.workspace.mkdir(parents=True, exist_ok=True) | ||
| await asyncio.to_thread(_pack, self.workspace, workspace_archive) |
There was a problem hiding this comment.
Restored attachments are missing
Attachments are saved under self.inputs_dir, outside both snapshot archives. After a snapshot is loaded on a fresh agent, the conversation still points to those files, but they are gone. A later request to reread an attached PDF or other file therefore fails. Include the context's attachments in the snapshot and restore them at paths the conversation can use.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/agents/agents/openhands/openhands_agent.py
Line: 439
Comment:
**Restored attachments are missing**
Attachments are saved under `self.inputs_dir`, outside both snapshot archives. After a snapshot is loaded on a fresh agent, the conversation still points to those files, but they are gone. A later request to reread an attached PDF or other file therefore fails. Include the context's attachments in the snapshot and restore them at paths the conversation can use.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| await asyncio.to_thread(_unpack, workspace_archive, self.workspace) | ||
| self.conversations_dir.mkdir(parents=True, exist_ok=True) | ||
| await asyncio.to_thread( | ||
| _install_conversation, conversation_archive, self.conversations_dir, conversation_id(context_id) |
There was a problem hiding this comment.
Failed loads erase current files
load_snapshot replaces the workspace before checking that the conversation archive can be loaded. A readable tar with a missing or invalid base_state.json passes the download step, deletes the current workspace contents, and then fails in _install_conversation. Extract and check both archives before replacing either destination.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/agents/agents/openhands/openhands_agent.py
Line: 458-461
Comment:
**Failed loads erase current files**
`load_snapshot` replaces the workspace before checking that the conversation archive can be loaded. A readable tar with a missing or invalid `base_state.json` passes the download step, deletes the current workspace contents, and then fails in `_install_conversation`. Extract and check both archives before replacing either destination.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| try: | ||
| text, images = await self._prompt(request) |
There was a problem hiding this comment.
Downloads bypass the task deadline
timeout_seconds starts after _prompt finishes downloading attachments. A task with a five-second limit can spend up to the separate HTTP timeout waiting for an attachment before its turn timer even starts. A server that keeps sending small chunks can extend that wait further. Put attachment downloads inside the task's deadline too.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/agents/agents/openhands/openhands_agent.py
Line: 342-343
Comment:
**Downloads bypass the task deadline**
`timeout_seconds` starts after `_prompt` finishes downloading attachments. A task with a five-second limit can spend up to the separate HTTP timeout waiting for an attachment before its turn timer even starts. A server that keeps sending small chunks can extend that wait further. Put attachment downloads inside the task's deadline too.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| async with self._holding(conversation), self._workspace_lock: | ||
| if not (directory / BASE_STATE).is_file(): | ||
| raise HTTPException(status_code=404, detail=f"No conversation for context '{request.context_id}'") | ||
| await asyncio.to_thread(_pack, directory, conversation_archive) | ||
| if request.objects.workspace is not None: | ||
| self.workspace.mkdir(parents=True, exist_ok=True) | ||
| await asyncio.to_thread(_pack, self.workspace, workspace_archive) |
There was a problem hiding this comment.
Snapshots race with other tasks
The workspace lock does not stop tasks already running in other contexts. Those tasks release it immediately after incrementing _running, so they can change or delete files while save_snapshot packs the shared workspace. This can fail the save or produce an archive with files from different moments. Before packing the workspace, wait for active writers to finish or reject the save while another context is running.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/agents/agents/openhands/openhands_agent.py
Line: 433-439
Comment:
**Snapshots race with other tasks**
The workspace lock does not stop tasks already running in other contexts. Those tasks release it immediately after incrementing `_running`, so they can change or delete files while `save_snapshot` packs the shared workspace. This can fail the save or produce an archive with files from different moments. Before packing the workspace, wait for active writers to finish or reject the save while another context is running.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
What and why
Adds OpenHands to the
agentsbundle as@local/agentenv-framework/agents/openhands, as agreed with the AgentEnv team (the scope was settled with them directly rather than in an issue). It follows the agents guide (src/agent_env/agents/AGENTS.md) and the shape ofclaude-codeandpi: anAgentEnvAgent, the LiteLLM[model]endpoint, oneinstall.shfor the image andinstall/v1, a smoke task. The difference is that it drives the OpenHands SDK in process instead of wrapping a CLI.openhands_agent.pyis the A2A side and imports no SDK, so it is unit-tested in agent-env's environment with a fake runtime;openhands_runtime.pyruns the SDK conversation;a2a_server.pyreads the environment once and wires the two.arun()on an event loop of its own in a worker thread, so the SDK's blocking work never stalls the server, its other tasks or its peer-agents MCP endpoint.agent-config(model,effort,system_prompt,append_system_prompt,max_iterations,timeout_seconds),mcp-config(with theAgentEnv-Roleheader, as Claude Code sends it),skill-config(inline and bundle), livetrajectory(the SDK's events,openhands-events/v1),snapshotsave/load (conversation and workspace; load rebinds the conversation to the target context),peer-agentsandtriggers(SDK defaults),install/v1(container mode, Debian/Ubuntu targets).timeout_seconds; a timeout, atasks/canceland the server's shutdown (its lifespan) interrupt the run and close its tools, killing the shell commands it started. Errors are answered as{"detail": ...}, and a failed run reports the SDK's own error code (openhands.LLMAuthenticationError,openhands.MaxIterationsReached, ...), typedagent_errororinfra_errorby the SDK's classification.OPENHANDS_A2A_MODELis the model when agent-config sets none, asPI_A2A_MODELis for pi: an agent only ever messaged by peers gets no agent-config, so registrations should set it (--env-var OPENHANDS_A2A_MODEL=...).requirements.txt(openhands-sdk/openhands-tools==1.57.0) andOPENHANDS_VERSION, which a test keeps equal. Like pi's, the image is built from this folder; it isn't inAGENT_IMAGES.Known limitation, documented in the folder's
README.md: in SDK 1.57.0 the terminal tool keeps waiting on a command that was running after its terminal is closed, until the command's own timeout. So a timeout or shutdown that lands while a shell command runs waits for it (the command itself is killed, and no state is lost). The fix is OpenHands/software-agent-sdk#6140; once it is released, a follow-up bumps the pin here.Not implemented: snapshot changelog, and host-mode
install/v1. Audio and video parts are saved to disk and their paths given to the agent, best effort.Docs to cover on the site: the agent, its config fields, and
OPENHANDS_A2A_MODEL/OPENHANDS_A2A_WORKSPACE.How it was tested
pytest tst/unit/agents/openhands: 29 tests with a fake runtime (card, turns, failures, timeout, cancel, shutdown, snapshots, skills, MCP and role headers, attachments, error bodies), also 10 times in parallel. 23 deliberate bugs planted inopenhands_agent.pyone at a time are each caught.pytest tst/integration/cli/run_bundle_openhands_test.py: 6int_test_slowtests on the built image against a fake OpenAI endpoint (tst/util/fake_openai.py), so no key is needed: a reply with the configured key and headers, a shell command through the terminal tool, the echo env's MCP tool, a refused key failing asopenhands.LLMAuthenticationError(infra_error), a timeout that interrupts the run (no further model calls, and the context's next task resumes the conversation), and a timeout mid-command leaving no process behind.tst/unitandpackages/agentenv-protocol/testspass, except 2test_mcp_cli_buildertests that fail the same way onmainon macOS;ruff checkis clean.linux/arm64andlinux/amd64.agent-env run agents --task openhands-smokepasses against a LiteLLM gateway (claude-haiku-4-5).agent-env a2a-agent put --id openhands ... --env-var OPENHANDS_A2A_MODEL=...with the full validator, locally (local sandboxes;defaultgateway anddefault-dbregistered; a stand-inmulti-caf64dbuilt fromecho-mcp, since the real validation env is not in a fresh store):mcp-config, because the validator asks for the real env'ssearch_emailstool, which the stand-in lacks; install/v1's rubric, because the install test's trajectory fetch could not reach the local grant server from the VM-host sandbox on macOS; audio and video, which do not finish within the probe's 120 s; changelog, not implemented.It would help to run the validator against the real
multi-caf64don your side.Fix the attachment deadline and snapshot restore problems before merging.
Fix with agent prompt
Summary
Adds OpenHands to the agents bundle, with persisted conversations, MCP tools, skills, snapshots, installation commands, and offline tests.
VascoSch92 explicitly documented the terminal shutdown wait in SDK 1.57.0 and deferred snapshot changelog and host-mode installation.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[A2A task] --> B[Download attachments] B --> C[Start task deadline] C --> D[Run persisted OpenHands conversation] D --> E[Reply and trajectory] D --> F[Shared workspace] G[Save snapshot] --> H[Archive conversation and workspace] I[Load snapshot] --> J[Replace workspace] J --> K[Read and install conversation state]Reviews (1) · Last reviewed commit: "Merge remote-tracking branch 'upstream/m..." · Reviewed by Greptile