feat(sdk): StreamContext mints the stream identity and closes every stream - #4822
feat(sdk): StreamContext mints the stream identity and closes every stream#4822VascoSch92 wants to merge 19 commits into
Conversation
compose_callbacks put caller callbacks (e.g. a PubSub publish) ahead of the default callback that appends the event to state, so a socket could be told about an event before EventLog had durably written it. Run the default callback first so persistence always happens before publish, and stop it there if append raises. EventLog.append and ConversationState.append_event now return the sequence number the log assigned, so a subsequent get_index lookup for the same event is no longer needed.
The legacy /sockets/events/{id} endpoint ships the durable record itself over
the wire: sockets.py sends event.model_dump(...), event_store.py persists
event.model_dump_json(...), and remote_conversation.py decodes with
Event.model_validate. One class, extra="forbid", three roles - so every
additive wire field is a storage migration, which is why a token delta cannot
carry a stream identity today.
This adds a second endpoint whose frames are envelopes rather than events. The
Event rides inside one as an untouched payload (verified byte-identical to
what the legacy endpoint sends), and the protocol's own fields - seq, stream
identity, progress - live on the envelope where adding a field costs nothing.
The URL is the protocol version: a client speaks one endpoint or the other, so
there is no handshake and no dual-decode path. The legacy endpoint is
unchanged.
Three fixes come with it:
- History and live traffic no longer interleave. The subscriber registers in
buffering mode, the high-water seq is read after that, and the buffer is
flushed against it once paged replay finishes. No state lock is needed:
subscribe-then-mark plus a seq filter gives the same guarantee without
adding a seventh consumer to ConversationState's FIFOLock.
- A slow consumer can no longer wedge the publisher. Admission is synchronous
and byte-bounded, and the socket is awaited only by this connection's single
writer task. Over budget drops the connection, not a frame, because the
client resumes losslessly with after_seq.
- StreamingDeltaEvent is filtered rather than forwarded. Deltas do not belong
on the durable channel.
seq is the event's index in the log, already on disk as the {idx:05d}
component of event-{idx:05d}-{event_id}.json, so exposing it needs no
migration and no SDK change.
ItemStarted/Delta/ItemAborted are defined and carried but nothing produces
them yet; that needs StreamContext at the four streaming entry points.
…ataclasses - Condense the session_protocol module docstring and the endpoint's replay-boundary comment to their load-bearing content. - Type MAX_FRAME_BYTES/MAX_PENDING_BYTES/REPLAY_PAGE_SIZE as Final[int]. - Make _ConnectionWriter and _SessionSubscriber slots=True, frozen=True; the handful of fields each still mutates after construction now go through object.__setattr__, the standard escape hatch for a frozen dataclass with a small amount of controlled internal state. - Drop the plain-comment section dividers.
receive_json decodes with json.loads, so a non-JSON text frame raises JSONDecodeError rather than WebSocketDisconnect. Only the latter was caught, so a single garbage frame unwound _inbound_loop and escaped the handler. The legacy endpoint absorbs this in its one broad except and answers the client; do the same here via the ErrorFrame path that already exists for a bad message body, and keep the connection open.
…fakes The reorder made two comments wrong: the chain is now visualizer -> default(persist) -> user callbacks, and the visualizer still renders ahead of persist, so say that rather than claiming nothing is announced first. The two EventLog test doubles still declared append() -> None, which no longer matches the int contract they inherit; return the assigned index.
Review follow-ups on the endpoint itself, none of which the unit tests could reach because nothing executed the handler: - Replay yields per frame, not per page. The writer task is the only thing that drains pending bytes, so replaying a whole page without letting it run could exhaust the budget on a page of large events and drop a client that was never slow. - _read_page tolerates OSError, not just FileNotFoundError, so a transient read error on the networked filesystems EventLog warns about costs one event rather than the connection. - aclose() no longer swallows a cancellation aimed at the calling task, which was hiding server shutdown. - A conversation closed between the lookup and get_conversation() now closes 4004 like every other failure path instead of raising out of the handler. Adds five end-to-end tests against the real server (reusing the wsproto harness): the opening sync frame, durable frames carrying seq over the wire, lossless resume via after_seq, 4004 on an unknown conversation, and surviving a malformed inbound frame. Also drops the size-limits comment divider, keeping the note itself.
…at never ran Two real defects found reviewing the endpoint: _seq_of treated every ConversationStateUpdateEvent as unpersisted. Only the snapshot subscribe_to_events synthesises on connect is; the rest are appended like anything else (append_event stores them and merely skips advancing HEAD, verified against a live conversation). Deciding by type stripped their real seq and defeated the seq <= through_seq dedupe in go_live, so a state update caught in the replay window arrived twice -- once from disk, once from the buffer. Decide by lookup instead, and keep the published-before-persist warning for the events it was meant for. go_live was handed the high-water mark even when no replay ran. A live-only client (no after_seq) got nothing from disk, so a buffered event below the mark was discarded as "already replayed" and lost for good, since such a client has no cursor to recover it with. Also: clean up the writer and socket if subscribe_to_events fails after registering, and consume the receiver's result when it completes in the same wakeup as a writer failure, which was leaking "Task exception was never retrieved" noise on every racing close.
No behaviour change. Cuts the narrative, the contrasts with how the legacy endpoint does it, and the restatement of what the code already says, keeping the non-obvious why: why no lock at the replay boundary, why send is synchronous, why seq is decided by lookup, why the budget is provisional. Also drops a leftover divider comment in the unit tests.
…/streaming-4681-session-socket
Both _ConnectionWriter and _SessionSubscriber declare fields with init=False and write them after construction, so frozen only forbade plain assignment and bought six object.__setattr__ calls. Nothing hashes, copies or compares them: PubSub keys subscribers by a generated uuid. The bypass also hid the writes from pyright, since object.__setattr__ is untyped.
…tream A streamed turn left the agent on two channels that knew nothing about each other: token deltas with a random id and no stream identity, and the durable event appended when the turn resolved. A client had to re-marry them by comparing strings, and a stream that died left an open slot forever. StreamContext mints the id when the stream opens and hands it to whichever durable event the stream became — the MessageEvent, or the first ActionEvent when the turn ends in tool calls. Minting is not a write: Event.id is already client-minted in-process, so this changes only when uuid4() runs. Wrapped at all four streaming entry points (Agent.step/astep and both ACP equivalents), each in a try/finally that emits exactly one ItemAborted if no durable event claimed the id, so cancellation, a provider failure, a policy rejection and an unhandled exception all retire the slot. Deltas are masked from a snapshot compiled once at open: resolved values only, single-pass, no lock or blocking I/O while streaming. The LLM stream path was not masked at all before this. Progress rides its own fan-out in the event service and reaches the session socket as the ItemStarted/Delta/ItemAborted frames #4681 defined but nothing produced. The event bus, the legacy socket and every on_token consumer are untouched: the raw chunk still passes through unchanged. Fixes #4682
Endpoint auditContract: pinned release artifact
Actionable client-only calls (0)none Actionable server-only operations (18)
Documented non-divergences (13)Client calls intentionally absent from the filtered contract (11)
Reason: Operational Agent Server endpoints intentionally excluded from the filtered public release artifact.
Reason: Client-ahead API stacked on the pending Agent Server meta-profiles implementation.
Reason: Client-ahead API stacked on the pending Agent Server pre-flight LLM validation endpoint. Server operations covered by an exposed browser URL (2)
Reason: RemoteWorkspace.startWorkspaceSession exposes these authenticated URLs for browser iframe and file requests; they are not HttpClient method calls. |
Coverage Report •
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
A retry that died before its first token stranded the slot. new_attempt() cleared the per-attempt "started" flag and close() keyed its abort on that flag, so the attempt that had already streamed was never retired. close() now keys on whether the item was ever opened. A raising progress sink escaped close(), which runs from __exit__ — so a broken subscriber could replace the exception a failing step was already unwinding, or fail a step that had succeeded. Emission is now contained. The agent handed the LLM a wrapper that was never None, which defeated the fallback in llm.completion that degrades a stream=True model to a non-streaming call when no on_token is wired (#4014). StreamContext now reports no callback when nothing consumes one.
Takes stream_context.py to 100% statement coverage and adds the two assertions that were structural rather than behavioural: that a registered secret really is masked in the deltas a real Agent step produces, and that the async ACP entry point retires its slot the same way the sync one does.
The module docstring restated the issue; several method docstrings restated the wire protocol that session_protocol.py already documents. Keeps the non-obvious why — why minting up front is safe, why the LLM callback may be None, why close() keys on ever-opened — and drops the rest.
_split_chunk used getattr for every field; only reasoning_content needs it, because litellm deletes that attribute when the provider omits it. The rest is direct access on a typed chunk now, and the one remaining getattr says why it is there. Drops the session-socket docstring paragraph about progress frames: the subscriber already explains at the drop site why a delta never rides the durable channel.
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
🔴 Needs improvement
Two material issues remain in the core stream guarantees: split-token secrets can reach the wire unmasked, and stream ownership is committed before the durable event is known to exist.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🔴 HIGH — this changes the agent/server streaming path and currently has both a secret-disclosure path and a stranded-slot failure mode. Do not auto-merge; a human maintainer should validate the corrected streaming semantics and run lightweight evals because this touches agent execution behavior.
VERDICT:
❌ Needs rework: Fix the masking and retirement ordering before merging.
KEY INSIGHT:
A streamed slot can only be declared safe once all bytes are masked across chunk boundaries and the matching durable event has actually crossed the persistence boundary.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
| attempt=self._attempt, | ||
| order=next(self._order), | ||
| kind=kind, | ||
| content=self._mask(text), |
There was a problem hiding this comment.
🔴 Security: masking each chunk independently leaks secrets split across token boundaries. For a registered hunter2, two normal deltas hunt and er2 both pass this masker unchanged, and the client reconstructs the full secret on the wire. I reproduced that with this StreamContext: joining its emitted StreamDelta.content values produces hunter2. The later durable-event masking cannot retract already delivered bytes. Please use a streaming-aware masker that retains enough trailing input to match across chunk boundaries (and flushes safely when the stream closes), and add a regression test where the secret is split across chunks.
|
This comment was posted by an AI agent (OpenHands). |
…ly once durable Two defects found in review of the streaming identity work. Masking each chunk independently cannot see a secret the provider splits across token boundaries: for a registered `hunter2`, the deltas `hunt` and `er2` both pass a per-chunk masker unchanged and the client reassembles the value on the wire. Subword tokenization makes that the common case, not the edge one. `StreamOutputMask` accumulates instead, releasing only the prefix that no further input can change: it holds back the longest registered value minus one character, so any match starting before the cut is already maximal. `compile_output_mask` becomes `compile_stream_mask` and returns it; the tail is flushed when the slot is claimed or aborted. `claim()` retired the slot before the durable event existed. It set the claimed flag, then the event was constructed and emitted, and `on_event` can raise while persisting — callbacks are composed without a guard, so any callback that raises reaches it. `__exit__` then saw a claimed slot, emitted no `ItemAborted`, and left the client holding a slot no durable event would ever close: the exact failure this work exists to prevent. Reserving the id and retiring the slot are now separate, and the three durable-event sites commit only after `on_event` returns. Tests: the split value is masked across chunks and with one character per chunk; a claim that never commits still aborts, both on the context and end-to-end through a real Agent whose callback raises while persisting. Both new assertions fail without the corresponding fix.
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Reviewed at head d3017a3. The two defects flagged in the prior all-hands-bot review (against bc008f0) are now addressed by the second commit:
- Split-token secrets are now masked.
StreamOutputMaskholds backmax_len - 1characters per masker and re-emits onflush(), so a secret split across chunk boundaries reassembles before release.compile_stream_mask()sorts values longest-first so an overlapping shorter value can't leave a suffix unmasked. The "only already-resolved values" limitation is a documented, load-bearing tradeoff (masks without resolving on the hot sync path, which runs under the state lock), and the durableMessageEventremains the authoritative, fully-masked record. - The slot is retired only after the durable event crosses
on_event.claim()reserves the id andcommit()is now called afteron_event(...)in the message, action, and ACP-finish paths, so a persistence failure leaves the slot open for the__exit__abort (covered bytest_a_persistence_failure_after_streaming_retires_the_slot).
No material code defects found in this pass: the claim/commit invariant is coherent, token_callback preserves the #4014 degradation when nothing consumes stream output, on_chunk forwards the raw chunk byte-for-byte so existing on_token consumers see no change, and the progress fan-out is distinct from the event bus (webhooks/telemetry/legacy endpoint unaffected).
agent.py, response_dispatch.py, acp_agent.py) — the durable MessageEvent/ActionEvent now carry a StreamContext-minted id, and the token callback is routed through StreamContext.on_chunk. Although the text delivered to on_token consumers and the persisted event content are unchanged, the repo review policy classifies anything that could plausibly affect agent execution as eval-risk. No eval-monitor link or human "eval looks good" confirmation is present, so this needs a human maintainer to run lightweight evals before merge.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ 🟡 MEDIUM — no correctness/security defect identified, but the change is in the agent streaming hot path and lacks eval validation against the review policy. A human maintainer should confirm no benchmark regression.
VERDICT: ✅ Worth merging pending human eval confirmation — core logic is sound; the remaining item is process (eval evidence), not code.
KEY INSIGHT: The design correctly treats the durable event as the single source of truth for slot retirement, with progress frames as recoverable, best-effort UX.
|
@all-hands-bot fix the conflicts |
Co-authored-by: openhands <openhands@all-hands.dev>
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
HUMAN:
Step 5 of the streaming epic, stacked on #4807. This is the piece that gives a
stream an identity the client can close it by.
AGENT:
Why
A streamed turn leaves the agent on two channels that know nothing about each
other.
StreamingDeltaEventcarries text, a randomidand a timestamp, andnothing else — no stream identity, no attempt number, no pointer to the message
that supersedes it. So the browser re-marries them by comparing strings, and a
stream that dies (cancellation, provider failure, policy rejection, an
unhandled exception) leaves an open slot on the client forever.
Agent.astepalone has threeexcept … returnbranches that emit an errormessage and return without ever touching the stream.
Two smaller things came out of the same path. The LLM stream path masked
nothing at all, so a registered secret appearing in a token delta reached the
wire in cleartext. And
chunk.id/choice.indexare in scope atevent_service.py's delta callback and never read — litellm hands us theprovider's identity and we drop it.
Summary
StreamContext(openhands-sdk/openhands/sdk/agent/stream_context.py) owns onestreaming slot: it mints the id, stamps and masks each delta, and closes the
slot exactly once.
Event.idis already client-minted in-process(
event/base.py,default_factory=lambda: str(uuid.uuid4())); the log neverassigns ids, it receives events that already have one. This changes only
when
uuid4()runs. Same bytes, one append, same ordering. If the streamdies the id is simply never used and nothing on disk referenced it.
claim()hands theminted id to the
MessageEvent, or to the firstActionEventwhen the turnends in tool calls — the streamed text is that action's thought, so a
tool-calling turn closes on a
Durableframe rather than aborting. Claiminghappens at the construction site, so an error path that never builds the
event leaves the slot open for the abort.
__exit__emits exactly oneItemAbortedunless the id was claimed. pi enforces the same invariant one level lower —
its stream function may not throw. This is that rule retrofitted onto code
that can.
open(), so a stepthat never streams (early condensation return, non-streaming model) has
nothing to abort.
Agent.step,Agent.astepand both ACPequivalents. Both agents stream on their synchronous path too, so an
async-only fix covers half the problem. The ACP bridge is also why this cannot
live in
llm.py: it callson_token(text)with a barestrand never entersthe LLM layer.
SecretRegistry.compile_output_mask()— asnapshot of the already-resolved values, single-pass, no lock and no I/O while
streaming.
mask_secrets_in_outputresolves uncached sources first, and itsown comment warns
get_value()"may do blocking network I/O"; on the syncpath that would run under the state lock. Trade-off: a secret registered
after the stream opened is not masked in that stream's deltas, but it is
masked in the durable message, which is the authoritative one.
of
chunk.idmid-item is a re-stream: the context bumpsattemptandrestarts
order. The ACP prompt-retry loops callnew_attempt()directly.chunk_idandchoice_indexride along on the delta frame as corroboration.Delivery, without touching the event bus
Progress frames are not events. They ride a second fan-out on
EventService(
subscribe_to_stream_progress), and_ProgressSubscriberinsession_socket.pymaps them one-for-one onto theItemStarted/Delta/ItemAbortedenvelopes #4807 defined but nothing produced. Nothing is added toPubSub[Event], so webhooks, telemetry and the legacy/sockets/events/{id}endpoint see no new traffic.
StreamContext.on_chunkforwards the raw chunk downstream unchanged beforeemitting its own frames, so
on_tokenconsumers — the CLI, the legacy socket,examples/01_standalone_sdk/29_llm_streaming.py, any user callback — arebyte-for-byte unaffected.
AgentBase.stepkeeps its signature; the sink is readoff the conversation (
LocalConversation.on_stream, newstream_callbacks=argument, appended last), so no third-party
AgentBasesubclass breaks.No progress frame is buffered against the replay boundary and none is replayed:
a dropped one costs a repaint, and the real text arrives with the durable event.
Issue Number
Fixes #4682
How to Test
uv run pytest tests/sdk -q— 6026 passed, 9 skipped, 10 xfailed.uv run pytest tests/agent_server -q— 2085 passed, 13 deselected.uv run pre-commit run --files <changed>— ruff format, ruff lint,pycodestyle, pyright, import rules, Tool registration all pass.
uv run pytest tests/cross tests/tools -q— 5 failures, all reproduced onthe base branch with this branch's code absent (two terminal-session tests,
two live-server tests;
test_ctrl_cpasses in isolation on both and flakesunder load).
uv run python .github/scripts/check_docstrings.py— core API files pass, nowarnings on any file this PR touches.
New coverage:
tests/sdk/agent/test_stream_context.py(13) — lazy open; exactly one abortper opened slot across a provider failure, a cancellation and a clean return
with no durable event;
claim()is once; a retry re-streams the sameitem_idunder a higherattemptwithorderrestarted; reasoning and textare separately ordered; masking; the raw chunk still reaches
on_token; asink that raises does not fail the turn.
tests/sdk/agent/test_stream_identity.py(5) — through a realAgentandLocalConversation: theMessageEventcarries the minted id on bothstepand
astep; a tool-calling turn retires on its firstActionEvent; aprovider failure retires with one abort; a step that never reaches the
provider opens nothing.
tests/sdk/agent/test_acp_agent.py— the ACP turn'sFinishActioneventcarries the minted id, and the context is released with the turn.
tests/agent_server/test_session_socket.py(2) — the three frames reach thewire with their identity and the provider corroboration; an oversized delta
drops the connection rather than a durable frame.
tests/agent_server/test_event_streaming.py— progress reaches its ownsubscribers and nothing on the event bus.
tests/sdk/conversation/test_secrets_manager.py(2) — the snapshot masks onlyalready-resolved values and never calls an unresolved source; longest
overlapping value wins.
What is not covered
Stated plainly so a reviewer knows where to look hardest:
five
test_agent_server_wsproto.pytests exercise the handler (so the newsubscribe/unsubscribe runs on every session-socket connection), but producing
a delta needs a streaming LLM inside a server that those tests start as a
separate process. The path is covered in two halves instead: publish reaches
its subscribers (
test_event_streaming.py) and a subscriber reaches the wire(
test_session_socket.py).litellm retry behavior. See the note below on why it is inferred at all.
new_attempt()calls are untested — reaching themneeds injected connection errors against a live ACP subprocess.
MaxSubscribersErrorbranch for progress is untested.stream_context.pyandsession_protocol.pyare at 100% statement coverage.Video/Screenshots
N/A — no UI surface.
Design Doc
N/A — the module docstring in
stream_context.pycarries the rationale inline.Type
Notes
Second commit is a self-review pass that found three defects in the
first, each with a regression test:
new_attempt()clears the per-attempt "started" flag andclose()keyedits abort on that flag, so the attempt that had streamed was never
retired — the exact invariant this PR exists to establish.
close()nowkeys on whether the item was ever opened.
close(), which runs from__exit__, soa broken subscriber could replace the exception a failing step was
unwinding, or fail a step that had succeeded.
None, which defeats thefallback in
llm.completionthat degrades astream=Truemodel to anon-streaming call when no
on_tokenis wired (Profile-launched conversations never enable LLM streaming — on_token crash on switch_llm #4014).StreamContextnow reports no callback when nothing consumes one, so a caller with no
token and no stream callbacks gets exactly today's behavior.
Merge order: fix(sdk): persist events before publishing them, return the assigned seq #4806 (Streaming step 2: append_event returns its sequence number; publish moves after persist #4680), then feat(agent-server): add /sockets/session/{id} with a non-Event envelope #4807 (Streaming step 3+4: /sockets/session/{id} — envelope wire, cursor, paged replay, byte-budget admission #4681), then this one. This branch
is based on
vasco/streaming-4681-session-socket, so the diff here is onlythe step-5 work.
One fix outside the issue's scope, and it was load-bearing. The new
agent-level tests made
tests/sdkfail 17 tests in modules they never touch,with
Duplicate class definition … ClientAction_persist_navigate_to. Thecause is in
utils/models.py:clear_subclass_cache()bumped the generationcounter but left the stale
_concrete_cache/_checked_cacheentries inplace, and each of those is a strong reference to every subclass it
captured. So a dynamically-created class survived the test that unregistered
it, and the next rebuild saw two classes with one name. The same failure
reproduces on
main's own files with no new code at all:uv run pytest tests/sdk/agent/test_agent_content_policy_violation.py tests/sdk/conversation/local/test_client_tools_persistence.py tests/sdk/tool/test_tool_serialization.py→ 6 failed.clear_subclass_cache()now drops theentries, and
_wipe_client_tool_globalscalls it. Happy to split this out ifyou would rather it landed on its own.
The attempt boundary is inferred, not reported. The retry wrapper in
llm.pyownsattempt, but it has no per-call channel to the token callback,so the context detects a new attempt from a change in
chunk.id. This is aheuristic: a provider that varies its completion id mid-response would read as
a retry. Documented at the detection site.
The ACP bridge still masks each chunk itself before
on_token, so astreamed ACP delta is masked twice now (idempotent). Removing the bridge's own
masking is [ACP on Cloud] Streaming-redaction for split-chunk secrets on the live ACP token relay OpenHands#15720's business, not this PR's.
Deltas still publish as
StreamingDeltaEventon the event bus for the legacyendpoint. Deleting that channel is step 7 (Streaming step 7: stop emitting StreamingDeltaEvent and freeze the legacy events endpoint #4683).
🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)
• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server
Variants & Base Images
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimnikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:71e3e1a-pythonRun
All tags pushed for this build
About Multi-Architecture Support
71e3e1a-python) is a multi-arch manifest supporting both amd64 and arm6471e3e1a-python-amd64) are also available if needed