feat: multimodal images through the v1 message graph + color-codeword-v1 - #1618
Conversation
Make a VLM trainable through the v1 message-graph trace, in two layers. Ingress: a content-part union (TextContentPart / ImageUrlContentPart, MessageContent) lets user/system messages hold images, and Task.instruction becomes `str | Messages` so a taskset can seed an image-bearing initial prompt (the default harness opts in via SUPPORTS_MESSAGE_INSTRUCTION; others reject a Messages instruction). The interception server and the v0 legacy bridge preserve image parts (shared `content_to_parts`) instead of flattening them to text, and `message_hash` hashes list content stably. Egress: TurnTokens / MessageNode carry the renderer's MultiModalData as a transient, serialization-excluded sidecar (offsets stored node-local); add_turn attributes each image to the node that introduced it; Branch.multi_modal_data merges the nodes' items and rebases offsets to branch-global. The pixel tensors never reach the wire or results.jsonl. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
By default the user simulator now runs inside the agent's (harness's) already- started runtime — reusing it via serve_tools(colocated=True, host_reachable=True) with its port published back to the host — instead of spawning a separate runtime per rollout. This removes the per-rollout runtime start/stop churn (and the startup races it caused) for multi-turn tasksets. UserConfig gains `colocated` (default True); set it False to give the user its own runtime, e.g. a remote sandbox that can't publish the colocated port back to the host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi-turn VLM decoding task (the v1 port of the v0 `color-codeword` env): each turn shows colored squares mapping to letters; the model accumulates the codeword and outputs it in full on the final turn. Turn-0 squares ride in the task's `Messages` instruction; later turns are injected by a colocated `vf.User`. Reward is an exact match of the final codeword, with a partial-match metric. Exercises multimodal images end-to-end through the v1 message graph. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(v1): occasional malloc_trim to bound worker RSS A rollout parses large base64 request bodies (e.g. screenshots) per turn and frees them, but glibc retains the freed arenas, so a long-lived eval / env-server worker's resting RSS climbs and never drops. Call malloc_trim(0) once every Nth finished rollout (gated in Episode.run) to hand those arenas back to the OS. Best-effort and resolved once: a no-op off glibc (musl, macOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(v1): run malloc_trim off the event loop malloc_trim(0) walks every arena's free lists and can block for tens of ms on a large heap. Called inline it stalls the whole event loop — and under the multiplexed env server, every concurrent rollout with it. Offload to a worker thread via asyncio.to_thread; ctypes releases the GIL during the call, so the heap walk runs concurrently with the loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- harness: express the capability flags (APPENDS_SYSTEM_PROMPT, SUPPORTS_*) as docstrings; tighten resolve_prompt. - graph/types: trim the multimodal transient-carrier comments to the essentials. - color-codeword-v1: hard-code MAX_TURNS / SEED as module constants, move the >=1 check into a Field(ge=...) validator, drop the redundant assert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review This PR introduces multimodal image support as a significant new feature, changing core message types and adding new processing logic across multiple framework files. An unresolved review comment also identifies a potential environment variable leakage bug that could affect runtime behavior. You can customize Macroscope's approvability policy. Learn more. |
… machinery Each image's renderer item is now attributed to the node whose message introduced it (by counting media content parts in prompt order) instead of mapping placeholder token offsets to node spans. Removes `_node_for_offset` and the node-local → branch-global offset rebasing in both `graph.add_turn` and `Branch.multi_modal_data` — those placeholders were never read (training uses `mm_items` + the token→type map). The reused prefix is skipped via a cursor (`num_reused`), so earlier turns' images aren't overwritten. Also: default harness drops the redundant inline flag comments, and program.py reads `sys.argv[1]` only in the no-INITIAL_MESSAGES branch (an image-prompt rollout passes INITIAL_MESSAGES and no argv, which previously raised IndexError). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 15ee62a. Configure here.
| env["INITIAL_MESSAGES"] = json.dumps( | ||
| [message_to_wire(m) for m in instruction] | ||
| ) | ||
| args = [""] |
There was a problem hiding this comment.
Stale INITIAL_MESSAGES env leaks
Medium Severity
For string instructions, DefaultHarness never sets INITIAL_MESSAGES, while program.py treats any non-empty parsed value as the chat seed. On the subprocess runtime, the child inherits the host os.environ, so a leftover INITIAL_MESSAGES from an earlier multimodal run can override the argv user prompt.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 15ee62a. Configure here.
…-v1 (PrimeIntellect-ai#1618) * feat(v1): carry multimodal images through the message graph Make a VLM trainable through the v1 message-graph trace, in two layers. Ingress: a content-part union (TextContentPart / ImageUrlContentPart, MessageContent) lets user/system messages hold images, and Task.instruction becomes `str | Messages` so a taskset can seed an image-bearing initial prompt (the default harness opts in via SUPPORTS_MESSAGE_INSTRUCTION; others reject a Messages instruction). The interception server and the v0 legacy bridge preserve image parts (shared `content_to_parts`) instead of flattening them to text, and `message_hash` hashes list content stably. Egress: TurnTokens / MessageNode carry the renderer's MultiModalData as a transient, serialization-excluded sidecar (offsets stored node-local); add_turn attributes each image to the node that introduced it; Branch.multi_modal_data merges the nodes' items and rebases offsets to branch-global. The pixel tensors never reach the wire or results.jsonl. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(v1): colocate the user simulator in the agent runtime By default the user simulator now runs inside the agent's (harness's) already- started runtime — reusing it via serve_tools(colocated=True, host_reachable=True) with its port published back to the host — instead of spawning a separate runtime per rollout. This removes the per-rollout runtime start/stop churn (and the startup races it caused) for multi-turn tasksets. UserConfig gains `colocated` (default True); set it False to give the user its own runtime, e.g. a remote sandbox that can't publish the colocated port back to the host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(v1): add the color-codeword-v1 taskset A multi-turn VLM decoding task (the v1 port of the v0 `color-codeword` env): each turn shows colored squares mapping to letters; the model accumulates the codeword and outputs it in full on the final turn. Turn-0 squares ride in the task's `Messages` instruction; later turns are injected by a colocated `vf.User`. Reward is an exact match of the final codeword, with a partial-match metric. Exercises multimodal images end-to-end through the v1 message graph. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: occasional malloc_trim to bound v1 worker RSS (PrimeIntellect-ai#1621) * perf(v1): occasional malloc_trim to bound worker RSS A rollout parses large base64 request bodies (e.g. screenshots) per turn and frees them, but glibc retains the freed arenas, so a long-lived eval / env-server worker's resting RSS climbs and never drops. Call malloc_trim(0) once every Nth finished rollout (gated in Episode.run) to hand those arenas back to the OS. Best-effort and resolved once: a no-op off glibc (musl, macOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(v1): run malloc_trim off the event loop malloc_trim(0) walks every arena's free lists and can block for tens of ms on a large heap. Called inline it stalls the whole event loop — and under the multiplexed env server, every concurrent rollout with it. Offload to a worker thread via asyncio.to_thread; ctypes releases the GIL during the call, so the heap walk runs concurrently with the loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): review-pass cleanups for multimodal support - harness: express the capability flags (APPENDS_SYSTEM_PROMPT, SUPPORTS_*) as docstrings; tighten resolve_prompt. - graph/types: trim the multimodal transient-carrier comments to the essentials. - color-codeword-v1: hard-code MAX_TURNS / SEED as module constants, move the >=1 check into a Field(ge=...) validator, drop the redundant assert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): attribute multimodal items by content part, drop offset machinery Each image's renderer item is now attributed to the node whose message introduced it (by counting media content parts in prompt order) instead of mapping placeholder token offsets to node spans. Removes `_node_for_offset` and the node-local → branch-global offset rebasing in both `graph.add_turn` and `Branch.multi_modal_data` — those placeholders were never read (training uses `mm_items` + the token→type map). The reused prefix is skipped via a cursor (`num_reused`), so earlier turns' images aren't overwritten. Also: default harness drops the redundant inline flag comments, and program.py reads `sys.argv[1]` only in the no-INITIAL_MESSAGES branch (an image-prompt rollout passes INITIAL_MESSAGES and no argv, which previously raised IndexError). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(v1): ruff-format graph.py multimodal attribution Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>


Summary
Multimodal (VLM) image support through the verifiers v1 message graph, so a VLM trains end to end.
TextContentPart/ImageUrlContentPart,MessageContent) letsUserMessage/SystemMessagehold images;Task.instruction: str | Messagesseeds an image-bearing initial prompt (the default harness opts in viaSUPPORTS_MESSAGE_INSTRUCTION, passed asINITIAL_MESSAGES). The interception server and the v0 legacy bridge preserve image parts (sharedcontent_to_parts) instead of flattening to text;message_hashhashes list content stably.MultiModalDatarides onTurnTokens/MessageNodeas a transient, serialization-excluded sidecar (offsets stored node-local);add_turnattributes each image to the node that introduced it;Branch.multi_modal_datamerges the nodes' items and rebases offsets to branch-global. Pixel tensors never reach the wire orresults.jsonl.UserConfig.colocated(default True) runs thevf.Userinside the agent's already-started runtime instead of spawning one per rollout — removes the per-rollout runtime churn (and the startup races it caused) for multi-turn tasksets.color-codeword-v1taskset: the v1 port of the v0color-codewordenv (multi-turn VLM codeword decoding; turn-0 squares in theMessagesinstruction, later turns via a colocatedvf.User).APPENDS_SYSTEM_PROMPT,SUPPORTS_*) as docstrings + tightenedresolve_prompt; trimmed the mm transient-carrier comments;color-codeword-v1hard-codesMAX_TURNS/SEEDas module constants with aField(ge=...)validator.Pairs with prime-rl#2766 (the training consumer + config).
Verification
Trainable 256/256every step (mm_kwargs reach the Qwen3-VL forward),Error 0.0%, clean finish; the two paths behave equivalently (v1 taskset rewards 0.86/0.58/0.68/0.55/0.46/0.54/0.39/0.38; v0 bridge 0.83/0.68/0.70/0.43/0.57/0.36/0.53/0.35).🤖 Generated with Claude Code
Note
Medium Risk
Widens
Task.instructionand changes ingress/persistence boundaries for images; harnesses withoutSUPPORTS_MESSAGE_INSTRUCTIONwill error on multimodal tasks, and training consumers depend on correct mm attribution.Overview
This PR wires multimodal images through verifiers v1 end-to-end and adds a reference VLM taskset.
Message model & ingress:
UserMessage/SystemMessagebodies becomeMessageContent(stror typed text +image_urlparts). Sharedcontent_to_partsreplaces flattening images to text in the interception server and v0 legacy bridge.message_to_wireemits native OpenAI multimodal shapes.Task.instructionis nowstr | Messages; harnesses opt in withSUPPORTS_MESSAGE_INSTRUCTION(default harness passes pre-built turns viaINITIAL_MESSAGES).Training graph: Renderer
MultiModalDataflows on transientTurnTokens/MessageNode(excluded from persistence).add_turnattributes each image to the node that introduced it;Branch.multi_modal_datamerges along the path formm_kwargs. List content is hashed canonically for graph dedup.Rollout ergonomics: User simulators default to colocated in the agent runtime (
UserConfig.colocated,serve_user(agent_runtime=...)), avoiding a separate runtime per rollout. Episodes calltrim_memory_periodically(glibcmalloc_trimevery N rollouts) to cap RSS after large base64 image bodies.New example:
color-codeword-v1— multi-turn color-square → letter codeword task; turn 0 images inMessagesinstruction, later turns via colocatedvf.UserMCP simulator.Reviewed by Cursor Bugbot for commit 15ee62a. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add multimodal image support to the v1 message graph and introduce
color_codeword_v1tasksetUserMessage,SystemMessage, andTask.instructionto accept lists of typed content parts (TextContentPart,ImageUrlContentPart) in addition to plain strings, propagating multimodal content through the OpenAI wire serialization path.message_hashandadd_turnin graph.py to correctly hash and attribute multimodal content parts to the graph nodes that introduced them.SUPPORTS_MESSAGE_INSTRUCTIONto harness.py and updates the default harness to serialize aMessagesinstruction asINITIAL_MESSAGESenv, with the program reading it on startup.colocatedsupport toUserConfigandserve_userso the user simulator can run inside the agent runtime instead of a separate process.Task.instructiontype is nowstr | Messages; harnesses that don't setSUPPORTS_MESSAGE_INSTRUCTION=Truewill raiseValueErrorif passed aMessagesinstruction.Macroscope summarized 15ee62a.