Skip to content

openhands: a new backend, driven through its maintained agent-server (1.49.2) - #215

Open
ZixiaoL wants to merge 9 commits into
HarnessRouter:mainfrom
ZixiaoL:feat/openhands-agent-server
Open

ZixiaoL wants to merge 9 commits into
HarnessRouter:mainfrom
ZixiaoL:feat/openhands-agent-server

Conversation

@ZixiaoL

@ZixiaoL ZixiaoL commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

OpenHands V1 as a backend, driven through openhands-agent-server 1.49.2 (MIT) — the REST/WebSocket
interface its vendor maintains, not the deprecated CLI. PyPI openhands is OpenHands/openhands-cli,
whose README opens with "This project is no longer actively maintained" and whose last release is
1.16.0 of 2026-05-08; the agent server released 1.49.2 on 2026-09-17.

The shape

One server process per turn, so the one-process-per-turn contract every other backend keeps is kept
here: the runner reads NDJSON off a driver's stdout and cancel is a process-group kill. Cold start
to a serving /alive is 3.3–4.1 s, measured, and a conversation created by one process is read back
intact by a different process over the same on-disk store — which is what makes the per-turn shape
work at all, and what this product needs, since sandboxes are recycled between turns.
runner/openhands_driver.py follows dsh_driver.py: same header, same {"m", "p"} line protocol.

Measured properties the design rests on, each pinned by a test:

  • the agent is frozen at the conversation's first creation, so the conversation id is a uuid5
    over the tool policy and the declared MCP servers — a second create with a different tool list
    leaves the persisted agent as it was
  • the credential is never persisted: base_state.json holds the LLM spec with api_key: None,
    so the key rides the environment
  • the answer can arrive as a FinishAction rather than a trailing assistant message
  • disabling a tool is enforced by omission from the spec, not by a refusal

Columns

column result
vercel, 49 of the 50 catalog ids 239 of 245 scenarios
google, the eight gemini ids 40 of 40
custom-harness passes: own skill, own script, own tool policy, and a declared MCP server

Vercel's six: qwen3.8-27b missed a recall after a switch and after a recycle (the model). The other
four were mistral-medium-3.5, which Vercel refuses with Assistant message must have either content or tool_calls about a message whose content is right there — isolated against the live endpoint,
the identical request answers 200 with content: "M1" and 400 with content: [{"type":"text",…}].
The relay stringifies assistant content for that route once the provider names it, the tool-role
twin one role over, and the re-run passed five of five.

The defect this PR spent the most on

The google column was 29 of 40 until the last commit. Every gemini follow-up failed after
145–212 s with no reason of any kind — eleven scenarios across five models, never a first turn:

AttributeError: 'PromptTokensDetailsWrapper' object has no attribute 'cache_creation_tokens'
  openhands/sdk/llm/utils/telemetry.py:259, _cache_buckets

Neither the provider nor the request shape. From litellm 1.95.0 the wrapper's __setattr__ mirrors
an assignment between cache_write_tokens and cache_creation_tokens, putting both names into
model_fields_set, and litellm then drops the unset attribute from __dict__ as a
construction-cost optimisation. The SDK's telemetry uses "cache_creation_tokens" in details.model_fields_set as its existence test. Each side is self-consistent; together they are
not, and three lines reproduce it with no agent, no provider and no network:

>>> PromptTokensDetailsWrapper(cached_tokens=123).model_fields_set
{'cache_creation_tokens', 'cached_tokens', 'cache_write_tokens'}
>>> hasattr(_, 'cache_creation_tokens')
False

1.49.2 is the newest SDK, so there is nothing to upgrade to; it asks only for litellm>=1.93.0, so
litellm joins the same single pip invocation pinned at 1.94.3, and the install asserts the
defect is absent rather than asserting a version
— a future bump fails the image instead of
failing every follow-up on a provider that reports prompt caching. The guard compares both pins,
because a volume built before this holds the right agent-server and the wrong litellm.

Why Vercel never saw it: it reports cache_creation_input_tokens on every response, so litellm
sets cache_creation_tokens rather than leaving it None and the attribute genuinely exists.
Measured through litellm 1.101.0 on the real streaming path, vercel/mistral-medium-3.5 and
vercel/gpt-5.4 both answer hasattr=True. That also rules the defect out as a second cause of the
mistral failures above.

The defect is intermittent — Google populates prompt_tokens_details only sometimes, and a turn
without it passes on the broken pin too (captured from inside _cache_buckets:
Usage(prompt_tokens=7410, …, prompt_tokens_details=None), on a follow-up that passed). So the
single-model A/B is supporting evidence and the column is what carries the claim: 29 of 40 before,
40 of 40 after, same ids, same image otherwise.

Why the record said nothing, and what now happens instead

event_service._run_and_publish publishes an error event only for an exception that is not a
ConversationRunError, assuming the run already emitted its own — and an exception raised out of
the run's error handling is exactly the case where nobody did. The status flips to error and the
WebSocket carries no reason at all; the sentence existed only in the server's log, and the record
said the turn ended error eleven times.

The driver now falls back to that log, taking the exception lines rather than the file's last
lines: a turn that dies in seconds and is retried to exhaustion ends with SIGTERM … Shutting down,
and the first version of this fallback put exactly that into the record, which reads like a reason
and is worse than silence. It reports both ends of the chain, since the root cause names the defect
and the wrapper names the operation. COLUMNS is set so the sentence survives rich's wrapping in
one piece. Verified end to end against the broken pin: the record now reads
agent-server said: AttributeError: 'PromptTokensDetailsWrapper' object has no attribute 'cache_creation_tokens' … ConversationRunError: …. Five tests pin it, three of them for defects
this fallback had.

docs/harness-verification.md point 6 gains the case it did not cover: a harness that reports a
failure nowhere.

Also here

  • tmux in the image: openhands-tools declares libtmux, which drives the real binary; upstream's
    own image installs it, and without it the server dies at start.
  • TMUX_TMPDIR is set short. The server defaults it inside the working directory, and a workspace
    here is /data/workspaces/hsess<32 hex>, so the socket landed at 106 characters against the
    108-byte sun_path limit; tmux answered File name too long, the agent retried the tool it could
    not start, and a 10 s turn became 235 s and then 4,000 s. Four wrong guesses preceded it; what
    ended the investigation was sending the server's stdout to a file instead of DEVNULL.
  • The relay route is guarded on the key like every other builder's.
  • openhands is opt-in, not in the default HR_BACKENDS: its environment is ~666 MB, the aider
    precedent and well above the 300 MB line.

Verification

All 17 registration points. tsc clean. Python suite 976 passed against origin/main's 944,
with the same 23 environment failures on both, so no regression. docs/support-matrix.md carries no
openhands rows, matching kimi, the most recent merged harness; the record is in
docs/support-matrix-notes.md.

🤖 Generated with Claude Code

ZixiaoL and others added 9 commits September 18, 2026 02:41
Not the CLI. PyPI `openhands` is OpenHands/openhands-cli, whose README opens with "This project is
no longer actively maintained" and whose last release is 1.16.0 of 2026-05-08; this drives
openhands-agent-server 1.49.2 (OpenHands/agent-sdk, released 2026-09-17) over its HTTP + WebSocket
API, one server process per turn.

Measured on the pinned version, and each decided a design choice:
- cold start to a serving /alive is 3.3-4.1 s, and a conversation created by one server process is
  read back intact by a different process over the same on-disk store — which is what lets the
  one-process-per-turn contract hold.
- the credential is never persisted (base_state.json carries the LLM spec with api_key: None), so
  it rides the driver's environment and the id is sent `openai/<id>` to stop litellm inferring a
  provider from the relay url.
- the agent is frozen at the conversation's first creation, so the tool policy is part of the
  conversation id: a changed policy is a new conversation created with the tools it asks for.
- tool_enforcement is hard, measured against a control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The venv lives on the data volume and outlives the image, so an existence check cannot tell a
current install from an old one. A volume first started by an earlier build of this branch held an
openhands-venv whose python ran perfectly and whose contents were the deprecated CLI's stack
(openhands 1.16.0 pinning openhands-sdk 1.21.0); the install was skipped and every turn died on
`No module named 'openhands.sdk.marketplace.registration'`. Found by the first column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The version guard ran `$(backend_bin openhands) -c …` before anything checked that the path
exists. Under `set -euo pipefail` a missing interpreter is a 127 that kills the whole entrypoint:
the container came up unhealthy and exited with no backends at all. Probed only when there is
something to probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four calls in the driver can raise `TimeoutError: timed out` with that exact message — three
urlopen timeouts, the socket open — so the reason a turn failed did not say which one. Measured on
vercel|openhands|claude-sonnet-4.6, whose recycle failed in 250 s saying only that. Each phase now
names itself, and the reason reads `… (while sending the message)`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The terminal tool runs commands in tmux, and the server defaults TMUX_TMPDIR to a directory inside
the working directory. A workspace here is /data/workspaces/hsess<32 hex>, so tmux's socket landed
at 106 characters against the 108-byte sun_path limit and answered `LibTmuxException: new-session:
error connecting to … (File name too long)`. The agent then retried the tool it could not start:
the same three ids that failed at 235 s, and later at 2,377 s and 4,000 s, now run five scenarios
each in 10-17 s.

It took four wrong guesses to find, because the driver threw away the only thing that could say so:
the server's output went to DEVNULL. It goes to a file under .harness/ now and its tail rides every
failure this driver reports. A pipe would not do — one nobody drains fills and blocks the server
mid-turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_build_openhands accepted mcp_servers and dropped it — the defect aider's bridge already taught
this repo, in a different place. This base needs no bridge: the SDK takes the servers on the agent
as `mcp_config` and dials them itself, and its MCPServer maps onto the harness's declaration one
field at a time. The declared transport travels, as HarnessRouter#191's review settled for kimi, and a transport
the SDK does not know is left out rather than guessed at: a config it rejects fails the whole turn
rather than the one server.

The servers are part of the conversation identity for the same reason the tool policy is — the
agent is frozen at creation, so a server declared later cannot reach an agent already made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every gemini follow-up on the google column failed after 145-212 s with no reason of any kind —
eleven scenarios across five models, never a first turn. The cause is neither the provider nor the
request shape. From litellm 1.95.0, PromptTokensDetailsWrapper mirrors an assignment between
cache_write_tokens and cache_creation_tokens, putting BOTH names into model_fields_set, and then
drops the unset attribute from __dict__; the SDK's telemetry uses `"cache_creation_tokens" in
details.model_fields_set` as its existence test. Each side is self-consistent, together they are
not, and 1.49.2 is the newest SDK.

The SDK asks only for litellm>=1.93.0, so litellm joins the same single pip invocation pinned at
1.94.3, and the install asserts THE DEFECT IS ABSENT rather than asserting a version: a future bump
fails the image instead of failing every follow-up on a provider that reports prompt caching. The
guard compares both pins, because a volume built before this holds the right agent-server and the
wrong litellm.

The google column is 40 of 40 with the pin and was 29 of 40 without it. The defect is intermittent
— Google populates prompt_tokens_details only sometimes, and a turn without it passes on the broken
pin too — so the column, not the single-model A/B, is what carries the claim.

Why the record said nothing: event_service publishes an error event only for an exception that is
NOT a ConversationRunError, assuming run()/arun() already emitted its own, and an exception raised
out of arun's error handling is exactly the case where nobody did. The driver now falls back to the
server log, taking the exception lines rather than the file's last lines — a turn that dies in
seconds and is retried to exhaustion ends with SIGTERM three minutes later, and a plain tail put
that shutdown noise into the record, which reads like a reason and is worse than silence. COLUMNS
is set so the sentence survives rich's wrapping in one piece. Five tests pin it.

Also: the relay route is guarded on the key like every other builder's, and the catalog comment
records that these ids are now measured rather than offered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… 6 gains the case with no reason at all

Three things the final pass against the other harnesses found.

ui/src/lib/harness.ts still carried "NOT ONE of these has been measured — no column has run",
written before any column existed. The gateway's catalog comment was corrected when the columns
finished and this copy was not; it now reads like kimi's, with the numbers and a pointer to where
the record lives.

docs/harness-verification.md point 6 tells the next backend to check how its CLI reports a provider
failure rather than assume an error event. openhands found the case the point does not cover: a
harness that reports a failure NOWHERE, and what to do about it (read the log, take the exception
lines and not the last lines, because a turn retried to exhaustion ends with SIGTERM).

Merged origin/main: b96d144, 0fcd90b and efd320b landed while this branch was out. Verified after
the merge rather than trusting a clean auto-merge — openhands is intact at every registration
point, the three commits' own additions are intact, tsc passes, and the python suite is 976 passed
against origin/main's 944 with the same 23 environment failures on both, so no regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 19, 2026

Copy link
Copy Markdown

@ZixiaoL is attempting to deploy a commit to the Future HR Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant