Skip to content

feat: multimodal images through the v1 message graph + color-codeword-v1 - #1618

Merged
mikasenghaas merged 7 commits into
feat/nano-as-v1from
feat/v1-multimodal
Jun 11, 2026
Merged

feat: multimodal images through the v1 message graph + color-codeword-v1#1618
mikasenghaas merged 7 commits into
feat/nano-as-v1from
feat/v1-multimodal

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Jun 11, 2026

Copy link
Copy Markdown
Member

Summary

Multimodal (VLM) image support through the verifiers v1 message graph, so a VLM trains end to end.

  • Ingress: a content-part union (TextContentPart / ImageUrlContentPart, MessageContent) lets UserMessage/SystemMessage hold images; Task.instruction: str | Messages seeds an image-bearing initial prompt (the default harness opts in via SUPPORTS_MESSAGE_INSTRUCTION, passed as INITIAL_MESSAGES). The interception server and the v0 legacy bridge preserve image parts (shared content_to_parts) instead of flattening to text; message_hash hashes list content stably.
  • Egress: the renderer's MultiModalData rides on TurnTokens / MessageNode 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. Pixel tensors never reach the wire or results.jsonl.
  • User colocation: UserConfig.colocated (default True) runs the vf.User inside 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-v1 taskset: the v1 port of the v0 color-codeword env (multi-turn VLM codeword decoding; turn-0 squares in the Messages instruction, later turns via a colocated vf.User).
  • Review-pass cleanups: capability flags (APPENDS_SYSTEM_PROMPT, SUPPORTS_*) as docstrings + tightened resolve_prompt; trimmed the mm transient-carrier comments; color-codeword-v1 hard-codes MAX_TURNS/SEED as module constants with a Field(ge=...) validator.

Pairs with prime-rl#2766 (the training consumer + config).

Verification

  • v0 ↔ v1 parity: short 8-step 2-GPU runs of both paths train end to end — Trainable 256/256 every 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).
  • Reward is noisy over short runs (both start ~0.85 and drift down over 8 steps — a short-run/lr artifact present identically in the v0 baseline, not a v1 regression). The CI nightly (25 steps × 8 GPU) is the convergence test (reward → 0.85).
  • A unit test (in the prime-rl PR) pins the node-local → branch-global placeholder rebase and the exclude-from-wire invariant.

🤖 Generated with Claude Code


Note

Medium Risk
Widens Task.instruction and changes ingress/persistence boundaries for images; harnesses without SUPPORTS_MESSAGE_INSTRUCTION will 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 / SystemMessage bodies become MessageContent (str or typed text + image_url parts). Shared content_to_parts replaces flattening images to text in the interception server and v0 legacy bridge. message_to_wire emits native OpenAI multimodal shapes. Task.instruction is now str | Messages; harnesses opt in with SUPPORTS_MESSAGE_INSTRUCTION (default harness passes pre-built turns via INITIAL_MESSAGES).

Training graph: Renderer MultiModalData flows on transient TurnTokens / MessageNode (excluded from persistence). add_turn attributes each image to the node that introduced it; Branch.multi_modal_data merges along the path for mm_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 call trim_memory_periodically (glibc malloc_trim every 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 in Messages instruction, later turns via colocated vf.User MCP 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_v1 taskset

  • Widens UserMessage, SystemMessage, and Task.instruction to accept lists of typed content parts (TextContentPart, ImageUrlContentPart) in addition to plain strings, propagating multimodal content through the OpenAI wire serialization path.
  • Updates message_hash and add_turn in graph.py to correctly hash and attribute multimodal content parts to the graph nodes that introduced them.
  • Adds SUPPORTS_MESSAGE_INSTRUCTION to harness.py and updates the default harness to serialize a Messages instruction as INITIAL_MESSAGES env, with the program reading it on startup.
  • Introduces the color_codeword_v1 taskset: a multi-turn task where the model decodes a sequence of colored image squares into a letter codeword, with exact and partial match rewards.
  • Adds colocated support to UserConfig and serve_user so the user simulator can run inside the agent runtime instead of a separate process.
  • Risk: Task.instruction type is now str | Messages; harnesses that don't set SUPPORTS_MESSAGE_INSTRUCTION=True will raise ValueError if passed a Messages instruction.

Macroscope summarized 15ee62a.

mikasenghaas and others added 3 commits June 11, 2026 01:25
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>
Comment thread verifiers/v1/types.py
Comment thread packages/harnesses/harnesses/default/program.py
mikasenghaas and others added 2 commits June 10, 2026 19:45
* 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>
@mikasenghaas
mikasenghaas marked this pull request as ready for review June 11, 2026 06:04
@macroscopeapp

macroscopeapp Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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>
Comment thread verifiers/v1/harness.py
Comment thread verifiers/v1/task.py
Comment thread verifiers/v1/user.py
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mikasenghaas
mikasenghaas merged commit ef61543 into feat/nano-as-v1 Jun 11, 2026
5 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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 = [""]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 15ee62a. Configure here.

pull Bot pushed a commit to Stars1233/verifiers that referenced this pull request Jun 23, 2026
…-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>
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