Skip to content

feat(conversation): materialize non-inline multipart content to the workspace#4113

Draft
jpshackelford wants to merge 1 commit into
mainfrom
materialize-content-to-workspace
Draft

feat(conversation): materialize non-inline multipart content to the workspace#4113
jpshackelford wants to merge 1 commit into
mainfrom
materialize-content-to-workspace

Conversation

@jpshackelford

@jpshackelford jpshackelford commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Closes #4111.

When the agent is handed multipart content it cannot act on inline (an image today; files/audio/blobs later), the bytes never land on the sandbox filesystem, so file-oriented tasks (e.g. re-uploading an attachment via a REST API) fail — the agent hunts for a file that doesn't exist, or fabricates a placeholder.

This PR solves the retrieval half of the problem once, at the conversation layer, in a way that is agnostic to both the source and the type of the content, exactly as proposed in the issue.

What changed

1. A polymorphic materialization contract on BaseContent (mirrors the existing to_llm_dict() contract):

class BaseContent(BaseModel):
    def materialize(self, workspace) -> list[MaterializedRef]:
        return []  # default: nothing to write (e.g. TextContent)
  • ImageContent.materialize() decodes a data: URL or downloads an http(s):// URL (size-capped), writes it into the workspace, and returns the path(s).
  • New MaterializedRef model (path + optional source URL / mime type / size) is exported from openhands.sdk.
  • Bytes are written under <workspace>/.materialized/ with a content-addressed filename (SHA-256 prefix), so identical content always maps to the same file — making writes naturally idempotent. Writing goes through workspace.file_upload, so it works for both local and remote workspaces.

2. A single conversation-layer _on_event interceptor (LocalConversation._materializing), composed into the callback chain right alongside the existing _rules_injecting wrapper. As each event enters, it:

  • calls materialize() polymorphically on the event's content parts,
  • writes any bytes to the workspace, and
  • injects a lightweight TextContent pointer (the workspace path) into the event's extended_content, under the state lock, before persistence.

Because it dispatches on the BaseContent abstraction and runs for every event flowing through _on_event, it is:

  • source-agnostic — user messages and MCP/tool observations are both handled, and
  • type-agnostic — a new content type is supported by implementing materialize() on that type; the hook is never revisited.

Per-event materialization is deduped in memory by event id, so re-emitting an event (e.g. replay) never re-runs writes.

Acceptance criteria

  • materialize() contract on BaseContent with a working ImageContent implementation (data-URL decode + size-capped http(s) download).
  • A single conversation-layer _on_event interceptor materializes content into the workspace and injects path pointers into extended_content, before persistence, under the state lock.
  • Type-agnostic: calls materialize() polymorphically; a new content type needs no change here.
  • Source-agnostic: user messages and tool/MCP observations both handled by the one interceptor.
  • Unit tests cover: data-URL decode → file, http-URL download → file (size cap enforced, both via Content-Length and streamed bytes), a TextContent no-op, an event with no materializable parts, and idempotency across replay (no duplicate writes).

Out of scope (as noted in the issue)

  • Provider-specific upload recipes (belong in a skill).
  • Facet-B representation work (teaching MCP/integration producers to emit new content types) — this PR establishes the generic retrieval mechanism they'll plug into.

Testing

  • tests/sdk/llm/test_content_materialize.py (content-level) and tests/sdk/conversation/test_content_materialization.py (hook-level) — all pass.
  • Full tests/sdk/conversation/ (741) and tests/sdk/llm/ (881) suites pass.
  • ruff check, ruff format, and pyright clean on changed files.

This PR was created by an AI agent (OpenHands) on behalf of the user.

@jpshackelford can click here to continue refining the PR


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:2886882-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-2886882-python \
  ghcr.io/openhands/agent-server:2886882-python

All tags pushed for this build

ghcr.io/openhands/agent-server:2886882-golang-amd64
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-golang-amd64
ghcr.io/openhands/agent-server:materialize-content-to-workspace-golang-amd64
ghcr.io/openhands/agent-server:2886882-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:2886882-golang-arm64
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-golang-arm64
ghcr.io/openhands/agent-server:materialize-content-to-workspace-golang-arm64
ghcr.io/openhands/agent-server:2886882-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:2886882-java-amd64
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-java-amd64
ghcr.io/openhands/agent-server:materialize-content-to-workspace-java-amd64
ghcr.io/openhands/agent-server:2886882-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:2886882-java-arm64
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-java-arm64
ghcr.io/openhands/agent-server:materialize-content-to-workspace-java-arm64
ghcr.io/openhands/agent-server:2886882-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:2886882-python-amd64
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-python-amd64
ghcr.io/openhands/agent-server:materialize-content-to-workspace-python-amd64
ghcr.io/openhands/agent-server:2886882-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:2886882-python-arm64
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-python-arm64
ghcr.io/openhands/agent-server:materialize-content-to-workspace-python-arm64
ghcr.io/openhands/agent-server:2886882-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:2886882-golang
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-golang
ghcr.io/openhands/agent-server:materialize-content-to-workspace-golang
ghcr.io/openhands/agent-server:2886882-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:2886882-java
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-java
ghcr.io/openhands/agent-server:materialize-content-to-workspace-java
ghcr.io/openhands/agent-server:2886882-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:2886882-python
ghcr.io/openhands/agent-server:2886882f54bfcd03266927d768390e8723eed6cd-python
ghcr.io/openhands/agent-server:materialize-content-to-workspace-python
ghcr.io/openhands/agent-server:2886882-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 2886882-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 2886882-python-amd64) are also available if needed

Add a polymorphic materialization contract on BaseContent (mirroring
to_llm_dict) and a single conversation-layer interceptor that writes
non-inline multipart content (images today; files/audio/blobs later) to
the workspace and injects a path pointer into the event's
extended_content.

- BaseContent.materialize(workspace) -> list[MaterializedRef], default no-op
- ImageContent.materialize(): data-URL decode and size-capped http(s)
  download, written content-addressed for idempotency
- LocalConversation._materializing wraps the _on_event chain, mirroring
  _rules_injecting: source-agnostic (user messages + tool/MCP
  observations) and type-agnostic (dispatches on materialize())

Closes #4111

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk
   __init__.py28293%115–116
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py10549491%140, 680–681, 714, 731, 1038, 1062–1063, 1068, 1083, 1085, 1167, 1183, 1233, 1259, 1292, 1313, 1317–1323, 1382, 1402–1404, 1453, 1472–1474, 1796–1797, 1812, 1900–1901, 1904, 2037, 2040–2041, 2066, 2099–2100, 2103, 2109, 2190, 2197, 2200, 2203, 2207–2208, 2212–2213, 2216, 2223, 2248, 2252, 2255, 2274, 2326, 2329, 2368, 2375–2376, 2384, 2388–2390, 2397, 2434–2440, 2443, 2446, 2453, 2516, 2521, 2631, 2633, 2637–2638, 2649–2650, 2675, 2875, 2879, 2949, 2956–2957
openhands-sdk/openhands/sdk/llm
   message.py290499%490, 503–504, 512
openhands-sdk/openhands/sdk/llm/utils
   content_materialize.py74495%95, 127, 138–139
TOTAL35874994272% 

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.

Materialize non-inline multipart content (images, files, blobs) to the workspace at the conversation layer

2 participants