Agent Server bug tracker
Bugs found while building an executable feature map of the OpenHands Agent Server API (#5621). Each one is a known-bug bullet in the map, so the map reports it as an expected failure while it lasts and as an unexpected pass once it is fixed. The umbrella issue is #5653. Security findings are handled separately and are not listed here. The map records about 135 bugs in all (1 high, 37 medium, 89 low, 8 trivial); the low and trivial ones stay as known-bug bullets in the map and were not filed as issues. The story of the map, with a short film, is in A map that keeps itself honest.
-
After a cipher key change, settings/secrets/profile writes answer 200 and null stored keys, not 409
After the cipher key changes (new OH_SECRET_KEY, or a new session API key when it is unset), any write to settings, secrets or the active LLM profile answers 200 and rewrites values stored under the old key as null for good. The stores' "different key" guard never fires because undecryptable values load as None.
F18.secret-key-rotationF20.secret-key-rotation -
GET omits secrets added at create or via POST .../secrets until the next save; restart can drop them
Secrets from the create body (secrets) or POST /api/conversations/{id}/secrets are applied to the live ConversationState in place, which skips the autosave. GET, search and batch get read the base_state.json snapshot, so they omit the secret until another change (a tags PATCH, a message) saves the state.
F04.create-secrets-getF08.secrets-visibleF08.secrets-unsaved-restart -
Pause during a model call not saved: GET says running (expected paused); after SIGKILL it is error
POST /api/conversations/{id}/pause during an in-flight model call returns 200, appends a PauseEvent and sets the live status to paused, but base_state.json is not written until the model call returns or is interrupted. GET /api/conversations/{id} reads that file, so a client that pauses and polls GET keeps seeing running.
F05.pause-running-getF05.pause-running-crash -
Failed run blocks the event loop while logging a rich traceback: /alive takes ~5 s, not ~100 ms
When a conversation run fails, EventService._run_and_publish logs it with logger.exception(...) on the server's event loop. With default console logging (Rich, LOG_RICH_TRACEBACKS defaults to true), rendering the chained traceback of an ordinary LLM connection error (about 1,000 lines) takes about 5 s, and every request, socket and run on the server waits meanwhile.
F05.run-error-responsive -
events/search and /count with kind=MessageEvent return nothing; only the full class path matches
GET /api/conversations/{id}/events/search and GET /api/conversations/{id}/events/count only match the kind filter against the fully qualified class path (openhands.sdk.event.llm_convertible.message.MessageEvent). The query parameter's documentation (e.g., ActionEvent, MessageEvent), the kind field on every event, and the TypeScript client's EventSearchOptions.kind all use the bare class name.
F06.search-kind-shortF34.events-search-kind -
Deleting or sandbox-pausing a conversation leaves its open WebSockets silent instead of closing them
DELETE /api/conversations/{id} and POST /api/conversations/prepare-for-sandbox-pause close the conversation's EventService but not the WebSockets subscribed to it. A client on /sockets/events/{id} or /sockets/session/{id} (SDK or TypeScript client) gets no close frame and no further events, so it neither learns of the delete nor reconnects after a pause.
F06.ws-delete-closeF07.deleted-closeF12.sandbox-pause-websocket -
LLM switched in before the first run is not added to ConversationStats; its tokens and cost are lost
An LLM switched in (POST /api/conversations/{id}/switch_llm, /switch_profile, or LocalConversation.switch_llm()) before the conversation's first message or run is never added to ConversationStats. The run uses it, but its tokens and cost are missing from stats.usage_to_metrics and the combined totals, so cost displays and max_budget_per_run miss every call it makes.
F09.switch-llm-before-first-run -
switch_llm returns 200 but keeps the old LLM when the posted usage_id is already registered
POST /api/conversations/{id}/switch_llm answers 200 {"success": true} but keeps the old LLM when the posted usage_id is already in the conversation's LLM registry. Once the agent has initialized (any message, even with run: false), default is registered, and default is what saved profile configs carry and what usage_id falls back to when omitted.
F09.switch-llm-registered-usage-id -
Forks are saved without the model keys, so a fork's first run fails with LLMAuthenticationError
POST /api/conversations/{id}/fork (also behind RemoteConversation.fork) creates a fork whose persisted agent has no model keys. The server loads the fork's agent from that file, so every fork of a conversation whose model needs an API key fails on its first run.
F10.fork-keeps-credentialsF10.fork-run-memory -
/goal loop active at a graceful restart stays `running` afterwards instead of `interrupted`
When the agent server shuts down gracefully while a /goal loop is active, the loop is cancelled but no interrupted goal status is recorded. After the restart no loop runs (the conversation is paused), yet the last goal status still says active: true, status: "running", and /goal/stop cannot clear it.
F11.goal-restart-active -
execute_bash_command returns only the last chunk of output over 1 MiB; null exit_code past 100 chunks
POST /api/bash/execute_bash_command should run a command and return its result, but when the command prints more than 1 MiB the route returns only the last stored BashOutput chunk. Earlier output is dropped without any truncation signal.
F13.execute-large-output -
Concurrent download-trajectory requests for one conversation return 500s or truncated zips
GET /api/file/download-trajectory/{conversation_id} builds every request's zip at the same path, <conversations_path>/<hex>.zip, and deletes that path after each response. When two requests for the same conversation overlap, one request's zip write truncates the file another response is streaming, or one request's cleanup deletes the file another request is about to send.
F14.trajectory-concurrent -
download-trajectory returns 404 after POST /api/init sets conversations_path (expected 200 zip)
On a deferred_init (warm-pool) server, POST /api/init can move conversation storage by sending conversations_path. Conversations are then created and persisted under that path, and GET /api/conversations/{id} finds them, but both trajectory download routes look in the launch-time default directory and answer 404.
F14.trajectory-init-path -
GET /api/git/changes returns [] for a non-repo directory holding a repository, not its changes
GET /api/git/changes (and the conversation-scoped GET /api/conversations/{runtime_conversation_id}/git/changes) returns 200 [] when path is a directory that is not itself a git repository but holds a repository one level below it. That is the layout of a repository-backed conversation ({base}/{repo}), so a consumer that passes the workspace root (e.g. a Changes view) shows no changes while the repository has uncommitted edits.
F15.changes-repo-below-path -
git changes returns 400 for a modified file with a space in its name and mangles non-ASCII names
GET /api/git/changes and GET /api/git/commits/{sha}/changes mis-parse file names that contain a space or a non-ASCII character. One modified tracked my notes.md makes the whole request fail with 400, hiding every other change from the client's diff view.
F15.changes-special-names -
PUT /api/settings/secrets with '' or '**********' answers 200 and nulls the secret instead of 422
PUT /api/settings/secrets accepts a value that is empty, whitespace-only or the redaction placeholder **********, answers 200, and saves the secret with a null value. A client form that sends the placeholder back while editing only the description deletes the stored value with no error.
F18.secret-empty-value -
POST /api/settings/mcp/{key} with {} returns 201 and new conversations lose all MCP tools; expected 422
POST /api/settings/mcp/{settings_key} accepts a server body with neither command nor url (for example {}) and stores it as {"enabled": true}. While that entry exists, every new conversation that uses the stored settings gets no MCP tools at all, including tools from valid servers next to it.
F19.settings-empty-server -
Waiting on a locked profile or connection store freezes the whole server; /alive stalls up to 30 s
The /api/profiles, /api/agent-profiles, /api/meta-profiles and /api/llm/provider-connections handlers are async def but call file-locked stores synchronously. A request waiting for a store lock (up to the 30 s timeout) blocks the event loop, so every other route and socket stalls, /alive, /health and /ready included.
F20.store-busy-responsiveF21.busy-blocks-serverF22.store-busy-responsiveF23.store-busy-responsive -
Profile validate takes ~120 s for an unreachable base_url and ~27 min for a silent provider
POST /api/profiles/{name}/validate sends its 1-token pre-flight with the posted LLM's own retry and timeout policy. With the defaults (num_retries 5, backoff 8/16/32/64 s, timeout 300 s) a closed port takes about 120 s to answer, and a provider that accepts the connection but never replies holds the request for about 27 minutes.
F20.validate-unreachable-boundedF20.validate-hang-bounded -
PATCH provider connection with blank or "**********" api_key returns 200 and replaces the key, not 422
PATCH /api/llm/provider-connections/{id} refuses {"api_key": null} ("api_key cannot be cleared"), but accepts "", whitespace-only strings and the redacted placeholder ********** with 200. Blank values clear the shared key; the placeholder is stored as the literal key.
F23.conn-patch-blank-key -
/api/profiles/{name}/validate ignores provider_connection_id: linked draft valid:false, expected true
POST /api/profiles/{name}/validate does not resolve provider_connection_id. A draft profile that links to a saved provider connection, with no inline key, is checked without any key and reported as valid: false with an authentication error, even when the connection holds a working key.
F23.validate-linked-draft -
Skill/Canvas install with an unknown ref returns 200 when the git source is cached (expected 400)
POST /api/skills/install and POST /api/canvas-extensions/install reject a ref the git source does not have (400) only on the first fetch of that source. Once its clone is in the extensions cache, the same request answers 200, installs whatever commit the cache has checked out, and records the missing ref as requested_ref.
F25.install-unknown-refF28.install-ref-cached -
GET /api/skills/marketplace returns an empty list instead of the ~70 OpenHands/extensions entries
GET /api/skills/marketplace returns {"skills": []} on every agent server running the default configuration, so a client's Marketplace tab shows no installable skills. The public OpenHands/extensions repository lists about 70 entries (73 plugins at d008b81) in .plugin/marketplace.json, but the skills catalog only proceeds when marketplaces/default.json exists, and that file is no longer in the repository.
F25.marketplace-public -
Resumed conversation keeps a since-disabled plugin's skills and commands; only its hooks are dropped
An enabled installed plugin loads into a conversation at its first message. If the plugin is then disabled and the agent server restarts, the resumed conversation drops the plugin's hooks but keeps its skills and commands.
F26.conversation-resume-disabled -
Unimportable tool module on resume makes event routes 500 'Unknown kind' instead of dropping the tool
A conversation created with tool_module_qualnames stores events whose kinds come from that module. If the module cannot be imported after a restart, _prepare_persisted_runtime logs Failed to import module ...
F27.custom-tool-missing-on-resume -
Sub-agent file naming a missing skill makes the first message return 500 instead of being skipped
A file-based sub-agent whose frontmatter skills names a skill that does not exist breaks every conversation that loads it. POST /api/sub-agents lists the agent as valid, but the conversation's first message returns 500, and so does every later one.
F27.subagents-bad-skill -
Canvas backend outlives a SIGKILLed agent-server; restart reports it stopped and starts a second one
Canvas App backends run in their own session and are tracked only in memory. If the agent-server dies without its lifespan teardown (SIGKILL, OOM kill, crash), the backend survives with ppid 1 and the restarted server loses track of it.
F28.hard-restart-orphan -
App backend status hangs holding the app lock when a child outlives the leader; expected unhealthy
If a Canvas App backend's leader exits while a child it spawned still holds the inherited stdout/stderr pipes, GET /api/canvas-extensions/installed/{name}/backend waits for the child to exit, holding the app's lifecycle lock. A backend that spawns a helper and then crashes wedges its app: the Canvas UI polls status, and stop cannot run.
F28.status-exit-hang -
Canvas App backend stays 'unhealthy' after one slow health probe instead of returning to 'ready'
GET /api/canvas-extensions/installed/{name}/backend probes a ready backend's health path with a 1 s timeout. A single slow answer sets the state to unhealthy and it stays there: later status calls never probe again, even though the process is alive and /health answers at once.
F28.status-probe-latch -
/app-backends WebSocket bridge negotiates no subprotocol (expected the backend's choice, qa.v1)
The Canvas App backend bridge (WS /app-backends/{name}/{path}) does not negotiate WebSocket subprotocols: the client's Sec-WebSocket-Protocol offer is not forwarded to the app's backend, and the bridge's 101 response selects none. Browsers fail such a handshake (Chrome: "Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received"), so a Canvas App whose frontend uses a subprotocol (graphql-ws, many terminal protocols) cannot reach its backend over WebSocket.
F29.ws-subprotocol -
Continued /v1/chat/completions turn reports conversation-total usage (13/7/20), not its own (3/2/5)
POST /v1/chat/completions sent with X-OpenHands-ServerConversation-ID (continuing an existing conversation) returns a usage object holding the conversation's running total across all earlier turns, not the tokens spent by that request. A client that sums usage per request (billing, quotas, dashboards) re-counts every earlier turn on each continued turn.
F30.chat-usage-per-request -
A second server's first load of a shared conversation reverts meta.json to its stale startup copy
With two agent servers on one conversations directory, the second reads every meta.json once at startup. Its first load of a conversation (takeover from a dead owner, or a claim after the owner released it) starts from that copy and writes it back to meta.json.
F31.takeover-keeps-metadata -
RemoteState confirmation_policy/security_analyzer/agent fail or return None on the second read
On a RemoteConversation, state.confirmation_policy, state.security_analyzer and state.agent are right only on the first read after the cache is filled: that read strips kind from the cached dicts, so later reads raise or return None. Any SDK client that checks confirmation mode or the agent more than once between server snapshots gets exceptions or wrong answers.
F33.conversation-state-reread -
ConversationEventStream on Node 22 WebSocket stops after one refused reconnect instead of backing off
ConversationEventStream with reconnect: { enabled: true } stops retrying after its first refused reconnect attempt when it runs on Node 22's global WebSocket. Every agent-server restart produces such an attempt, so a Node consumer loses its event stream for good.
F34.event-stream-reconnect-native -
TS ConversationManager.createConversation ignores maxIterations/stuckDetection (server gets 500/true)
ConversationManager.createConversation(agent, { maxIterations, stuckDetection }) in the TypeScript client (@openhands/typescript-client) accepts both options but never sends them. The server always creates the conversation with max_iterations: 500 and stuck_detection: true.
F34.manager-create-options -
TS RemoteWorkspace.downloadAsBlob/fileDownload return 11 mangled bytes for a 5-byte binary file
RemoteWorkspace.fileDownload() in the TypeScript client fetches GET /api/file/download without a responseType, so the transport decodes the application/octet-stream body as UTF-8 text. Every invalid UTF-8 sequence becomes U+FFFD, so fileDownload(), downloadAsBlob() and the browser downloadAndSave() silently return corrupted content for any binary file, in Node and in browsers.
F34.workspace-binary-download -
TS WebSocket callback and bash clients never connect in Node: require('ws') is undefined in ESM build
In Node, RemoteConversation.startWebSocketClient() (via WebSocketCallbackClient) and BashWebSocketClient never open a socket. Both pick a constructor at import time from window.WebSocket or require('ws'); the package is ESM-only, so require is undefined, the error is swallowed, and Node 22's global WebSocket is never tried.
F34.ws-callback-clientF34.bash-websocket -
RemoteWorkspace.get_llm on a provider-linked profile lacks the connection's API key; run fails auth
RemoteWorkspace.get_llm(profile_name=...) for a profile linked to a provider connection returns an LLM with api_key=None and without the connection's base_url. A conversation built on it fails with LLMAuthenticationError, though activating the same profile runs on the connection's key. get_llm() with a linked active_profile takes the same path.
F20.sdk-get-llm-linked