Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/agent_env/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,12 @@ the `[model]` endpoint as an OpenAI-compatible one and takes its model from the
`[model] default` (or the `agent` role) set. It declares `install/v1` too, for Debian or Ubuntu targets.
Its folder's `README.md` covers the extensions it implements.

`agents/openhands/` runs the [OpenHands](https://github.com/OpenHands/software-agent-sdk) agent behind the
protocol's A2A agent framework, driving the OpenHands SDK in process rather than a CLI: each turn is one run of a
persisted SDK conversation, the task env's MCP servers go to its `mcp_config`, and its SDK events are the turn's
trajectory. Name it as `@local/agentenv-framework/agents/openhands`. It calls the `[model]` endpoint through LiteLLM
and takes its model from the prompt, so its smoke task needs `[model] default` (or the `agent` role) set. It declares
`install/v1` too, for Debian or Ubuntu targets. Its folder's `README.md` covers the extensions it implements.

`agent-env run agents` runs every agent's smoke task, building each image; `--task claude-code-smoke` runs Claude
Code's alone, `--task pi-smoke` pi's.
Code's alone, `--task pi-smoke` pi's, `--task openhands-smoke` OpenHands'.
13 changes: 13 additions & 0 deletions src/agent_env/agents/agents/openhands/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# docker build -t openhands-a2a src/agent_env/agents/agents/openhands
FROM mirror.gcr.io/library/ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55
WORKDIR /app
# Named sources: agent-env packs the build context install/v1 copies from the COPY sources.
COPY a2a_server.py conversation_run.py install.sh openhands_agent.py openhands_runtime.py requirements.txt start.sh ./
RUN sh install.sh \
&& useradd --create-home agent \
&& mkdir /workspace \
&& chown agent /app /workspace
ENV PATH="/opt/openhands-a2a-venv/bin:${PATH}" OPENHANDS_SUPPRESS_BANNER=1
USER agent
EXPOSE 8000
CMD ["python", "a2a_server.py"]
67 changes: 67 additions & 0 deletions src/agent_env/agents/agents/openhands/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# OpenHands A2A agent

The [OpenHands](https://github.com/OpenHands/software-agent-sdk) agent wrapped with the
`agentenv_protocol.a2a_agent` framework, shipped in the `agents` bundle as
`@local/agentenv-framework/agents/openhands`. This directory is the whole build context: the
protocol package and the OpenHands SDK come from PyPI (`requirements.txt`), and `install.sh` installs
them in a Python of uv's own, with tmux and git for the agent's terminal, both for the image and for `install/v1`.

Instead of wrapping a CLI, the agent drives the OpenHands SDK in process. `openhands_agent.py` is the
A2A side and imports no SDK; `openhands_runtime.py` runs the conversation. Each task is one `arun()`
of an SDK conversation in `/workspace`, with the SDK's default tools (terminal, file editor, task
tracker; no browser) and its default condenser, which summarizes a conversation that outgrows the model's window.
The model is called through the `[model]` LiteLLM endpoint as `litellm_proxy/<model>`, with `LITELLM_EXTRA_HEADERS`
sent on every call but kept out of the persisted conversation, which a snapshot uploads. Each turn runs on a thread
and event loop of its own.

A context maps to one conversation, whose id is derived from the context id, persisted in
`~/.openhands-a2a/conversations/<id>`, so follow-up tasks in the context resume it, across restarts too.
Images are sent to the model as images; every file part is also saved in the conversation's own directory under
`~/.openhands-a2a/inputs` and its path added to the prompt, for the agent's tools, and the conversation's snapshots
carry it.

| Extension | Implementation |
|---|---|
| `agent-config/v1` | `OpenHandsConfig`: `model`, `effort` (the LLM's reasoning effort), `system_prompt` (replaces OpenHands' own), `append_system_prompt`, `max_iterations`, `timeout_seconds` |
| `mcp-config/v1` | SDK default; each server becomes an HTTP `MCPServer` in the agent's `mcp_config`, with its headers, sent as written: a `${VAR}` in a URL or header is not filled from the agent's environment |
| `skill-config/v1` | inline and bundle skills installed as `SKILL.md` directories under `~/.openhands-a2a/skills`, loaded into the agent's context |
| `trajectory/v1` | live: the task's OpenHands SDK events, appended as the conversation emits them (format `openhands-events/v1`) |
| `snapshot/v1` | `save` uploads the context's persisted conversation, with the files attached in the context, as a gzipped tar and, when asked, the workspace as another; `load` extracts both aside, checks the conversation and points it at the target context's paths before it replaces anything, then replaces the workspace's contents, puts the attached files, as they were saved, in the target's own directory and installs the conversation under the target context's id, so an archive that fails to extract, or holds no conversation, changes nothing |
| `peer-agents/v1` | SDK default: the `peer_agents` MCP server reaches the agent like any other |
| `install/v1` | copies this directory into the task container as `/opt/openhands-a2a`, runs `install.sh` (Debian or Ubuntu, as root, with network access), writes the LiteLLM credentials to a 0600 `agent.env` through a heredoc and starts `start.sh`, which sources it |
| `triggers/v1` | SDK default |

`OPENHANDS_A2A_MODEL` sets the model when agent-config sets none, as for a peer that other agents
message directly, and `OPENHANDS_A2A_WORKSPACE` the workspace (default `/workspace`). `a2a_server.py` reads
these and the `LITELLM_*` variables once, at startup: a `LITELLM_EXTRA_HEADERS` that is not a JSON object stops
the agent there, while a missing `LITELLM_BASE_URL` fails each task as `openhands.no_endpoint`.

Snapshot `load` answers 409 while a task is running, and 400 for an archive that holds no conversation. A
snapshot `save` waits for its context's running task to end, and a conversation runs one task at a time; one that
saves the workspace also waits for the tasks running in other contexts, which work in the same workspace.

Usage is the conversation's spend over the task: tokens, cost as LiteLLM prices it, and tool calls. A
failed run's error code is `openhands.<code>` of the SDK's last error event (`openhands.LLMAuthenticationError`,
`openhands.MaxIterationsReached`, ...). It is typed by the SDK's classification when the event carries one;
otherwise a run that raised is an `infra_error`, and one a run limit ended, or that ends stuck or paused, an
`agent_error`. A turn the SDK can't start, such as one whose persisted conversation it can't resume, fails as
`openhands.start` and one the agent's shutdown stops as `openhands.shutdown`, both `infra_error`s. A timeout
(`openhands.timeout`) counts reading the task's file parts too, reports what the run had spent, and interrupts the
conversation, which the context's next task resumes.

A timeout, a `tasks/cancel` and the server shutting down each interrupt the run and close its tools, which kills
the shell commands it started. The SDK's terminal tool does not notice its closed session, though: it keeps waiting
on a command that was running until the command's own timeout, or its 30 s no-change timeout when the command has
none, and holds the run, and the agent's exit, until then.
Errors are answered as `{"detail": ...}`.

`tst/integration/cli/run_bundle_openhands_test.py` runs the built image against a fake OpenAI endpoint.

```bash
docker build -t openhands-a2a src/agent_env/agents/agents/openhands
docker run -p 8000:8000 -e LITELLM_BASE_URL=... -e LITELLM_API_KEY=... openhands-a2a
pytest tst/unit/agents/openhands
```

The OpenHands version is pinned in `requirements.txt` and `OPENHANDS_VERSION`; a new release is a pull
request that bumps both.
24 changes: 24 additions & 0 deletions src/agent_env/agents/agents/openhands/a2a_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Serves the OpenHands agent, configured from its environment. ``AGENT_CARD`` is what agent-env's install/v1 reads
from ``/app/a2a_server.py``."""

import os
from pathlib import Path
from typing import Final

from a2a.types import AgentCard
from starlette.applications import Starlette

from openhands_agent import DEFAULT_MODEL_ENV, Endpoint, OpenHandsAgent
from openhands_runtime import SdkRuntime

agent: Final[OpenHandsAgent] = OpenHandsAgent(
SdkRuntime(),
endpoint=Endpoint.from_environ(os.environ),
default_model=os.environ.get(DEFAULT_MODEL_ENV),
workspace=Path(os.environ.get("OPENHANDS_A2A_WORKSPACE", "/workspace")),
)
app: Final[Starlette] = agent.create_app()
AGENT_CARD: Final[AgentCard] = app.state.agentenv_a2a.card

if __name__ == "__main__":
agent.serve()
87 changes: 87 additions & 0 deletions src/agent_env/agents/agents/openhands/conversation_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""One ``arun()`` of a conversation, on a thread and an event loop of its own. The SDK does blocking work between its
awaits (tools, persistence), which on the server's loop would stall every other task and the agent's own HTTP
endpoints, the peer-agents MCP server among them; and a run on the loop's default executor would hold one of the few
threads the server's other blocking calls share for as long as it runs."""

import asyncio
import threading
from contextlib import suppress
from typing import Protocol


class Conversation(Protocol):
async def arun(self) -> None: ...

def interrupt(self) -> None: ...

def close(self) -> None: ...


class ConversationRun:
def __init__(self, conversation: Conversation) -> None:
self.conversation = conversation
self.stopped = threading.Event()
self._loop: asyncio.AbstractEventLoop | None = None
self._lock = threading.Lock()

def stop(self) -> None:
"""Interrupt the run, or keep it from starting, and close its tool executors: the terminal tool does not heed
the interrupt, and a command it is running would otherwise hold the run until the command ends."""
with self._lock:
self.stopped.set()
loop = self._loop
if loop is not None:
# A run that ends meanwhile closes its loop, and then has nothing left to interrupt.
with suppress(RuntimeError):
loop.call_soon_threadsafe(self.conversation.interrupt)
self.conversation.close()

async def to_its_end(self) -> None:
"""Run to the end; cancelled, stop the run, wait until it has stopped and re-raise."""
loop = asyncio.get_running_loop()
done: asyncio.Future[None] = loop.create_future()

def settle(error: BaseException | None) -> None:
if done.done():
return
if error is None:
done.set_result(None)
else:
done.set_exception(error)

def drive() -> None:
error: BaseException | None = None
try:
self._drive()
except BaseException as exc:
error = exc
# The server's loop is closed once it has stopped, and then nothing waits for the run.
with suppress(RuntimeError):
loop.call_soon_threadsafe(settle, error)

threading.Thread(target=drive, name="openhands-run").start()
try:
await asyncio.shield(done)
except asyncio.CancelledError:
if not done.done():
await asyncio.to_thread(self.stop)
await asyncio.wait([done])
if not done.cancelled():
done.exception() # the caller was cancelled, so how the run ended is no longer its to see
raise

def _drive(self) -> None:
# Made on this thread, so the server's thread keeps its own event loop. Closing the runner cancels what the
# run left on its loop, LiteLLM's logging worker among them.
with asyncio.Runner() as runner:
with self._lock:
if self.stopped.is_set():
return
self._loop = runner.get_loop()
runner.run(self._arun())

async def _arun(self) -> None:
# A stop sets the flag before it sends the interrupt here, so one that arrives before arun() has recorded the
# task it would cancel, and so only pauses, which arun() undoes, keeps the run from starting.
if not self.stopped.is_set():
await self.conversation.arun()
32 changes: 32 additions & 0 deletions src/agent_env/agents/agents/openhands/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/sh
# Installs tmux and git for the agent's terminal and this agent's Python environment, in uv's own Python so the
# target's is never used, on Debian or Ubuntu, as root. The image build and install/v1 both run it, so an install
# into a task container matches the image.
set -eu
UV_VERSION=0.13.1
UV_SHA256_X86_64=6c86040a8f045ab9adc9e3ec4cdafa03fd00e9a988ed2bf28971c7fc2d0debd0
UV_SHA256_AARCH64=345a8308e4d1835cdfaadf692836fd3137a6d45864773f05ad7bf59442b1ba16
PYTHON_VERSION=3.12
here=$(cd "$(dirname "$0")" && pwd)

export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl git tmux
rm -rf /var/lib/apt/lists/*

case "$(uname -m)" in
x86_64) arch=x86_64 sha256=$UV_SHA256_X86_64 ;;
aarch64 | arm64) arch=aarch64 sha256=$UV_SHA256_AARCH64 ;;
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
archive=uv-$arch-unknown-linux-musl.tar.gz
curl -fsSL --retry 3 "https://github.com/astral-sh/uv/releases/download/$UV_VERSION/$archive" -o "$stage/$archive"
echo "$sha256 $stage/$archive" | sha256sum -c -
tar -xzf "$stage/$archive" -C "$stage"
uv="$stage/uv-$arch-unknown-linux-musl/uv"

export UV_PYTHON_INSTALL_DIR=/opt/openhands-a2a-python UV_PYTHON_PREFERENCE=only-managed UV_CACHE_DIR="$stage/cache"
"$uv" venv --quiet --clear --python "$PYTHON_VERSION" /opt/openhands-a2a-venv
"$uv" pip install --quiet --python /opt/openhands-a2a-venv/bin/python -r "$here/requirements.txt"
Loading
Loading