[codex] Support raw image offload in v1 train client - #1746
Conversation
7556743 to
3f5bb1a
Compare
3f5bb1a to
de37650
Compare
Every image carries its ref, so no cache miss can occur. Removes _generate_with_image_ref_retry / _has_descriptor_only_images / _retryable_mm_error_type / _json_error_type / _RETRYABLE_MM_ERROR_TYPES; rollouts call generate() directly. Obsolete retry tests removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fload # Conflicts: # verifiers/v1/clients/train.py
ApprovabilityVerdict: Needs human review 2 blocking correctness issues found. This PR introduces new multimodal image offload functionality with significant changes to graph validation and legacy rollout handling. Unresolved review comments identify potential backwards compatibility breakage and missing field assignments that should be addressed before merging. You can customize Macroscope's approvability policy. Learn more. |
| return False | ||
|
|
||
|
|
||
| def _validate_raw_mm_item(item: Any) -> dict[str, Any]: |
There was a problem hiding this comment.
🟡 Medium v1/graph.py:76
_validate_raw_mm_item now unconditionally rejects processed multimodal payloads containing keys like pixel_values, and deserialize_multi_modal_data runs it on every multi_modal_data field during deserialization. Loading a previously persisted multimodal v1 trace whose sidecars contain pixel_values now raises TypeError instead of round-tripping, breaking backwards compatibility for existing saved rollouts. Consider allowing processed payloads through on the deserialization path (e.g. by skipping the processed-key check in the validator's before path) so old traces can still be loaded.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/graph.py around line 76:
`_validate_raw_mm_item` now unconditionally rejects processed multimodal payloads containing keys like `pixel_values`, and `deserialize_multi_modal_data` runs it on every `multi_modal_data` field during deserialization. Loading a previously persisted multimodal v1 trace whose sidecars contain `pixel_values` now raises `TypeError` instead of round-tripping, breaking backwards compatibility for existing saved rollouts. Consider allowing processed payloads through on the deserialization path (e.g. by skipping the processed-key check in the validator's `before` path) so old traces can still be loaded.
…fload # Conflicts: # verifiers/v1/cli/dashboard/eval.py
- prepare_images_inplace handles the full renderer part treaty: nested image_url dicts, direct-string image_url, direct image strings, and typed pydantic parts; non-string sources raise with the shape named. - Interception server labels prepare_messages failures as InterceptionError instead of misattributing them to the user simulator. - Test covers all shapes plus http rejection (skips until the renderers pin ships mm_store). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fload Reconciles this branch's raw-image offload work with main's independent retry-coalescing and harness-segment-resume refactor of the interception server, and its v1 trace/graph model evolution: - interception/server.py: main's single-shot handle_request (graph-atomicity retry coalescing) and _stream (record_call/error tracking, tools threading) are authoritative. Dropped this branch's obsolete mid-request user-simulator injection (session.user/session.opening, prepare_messages call sites) — main's d21100b moved that to Harness.launch/resume, so injected messages now re-enter as normal request bodies already covered by prepare_request_body (kept, re-wired at the top of handle_request). - graph.py: kept finish_reason/usage/multi_modal_data/previous_multi_modal_data (this branch) alongside main's SkipJsonSchema wrapping convention and commit()'s new (tools, -> assistant node id) signature. Caught and fixed an auto-merge dropping the FinishReason/Usage imports (caused a pydantic model-not-fully-defined failure at Trace construction). - trace.py: kept this branch's more accurate multi_modal_data docstring; took main's tuple-based bridge-mutation assertion in the test suite. - ARCHITECTURE.md: main deleted this file (moved to the shorter docs/v1/ architecture.md, which doesn't cover this depth of internals) — accepted the deletion; folded the one load-bearing fact (why multi_modal_data is JSON-excluded) into _NODE_DUMP_EXCLUDE's docstring instead of resurrecting a dedicated architecture doc. Full suite (with local renderers checkout, minus PRIME_API_KEY-gated e2e): all passing.
Its only callers were the interception server's mid-request user-simulator injection sites, which main's harness-segment-resume refactor removed — resumed user turns now re-enter as ordinary request bodies, already covered by prepare_request_body at request ingress.
…fload Picks up 35 main commits: the v1 structure cleanup (#2146) that retires StrictBaseModel and renames _NODE_DUMP_EXCLUDE -> EXCLUDE_FIELDS, the ruff 0.16 / ty tooling bump (#2147, #2148), runtime-on-agent + agent config stamped on the trace (#2106), Reward score/weight records (#2119), MCP tools for Codex (#2140), and assorted v1 fixes. Resolutions: - trace.py: main's restructure subsumes this branch's block wholesale — EXCLUDE_FIELDS already carries multi_modal_data, and TRACE_VERSION=1 is main's deliberate reset (this branch never touched it). Took main. - graph.py: kept the raw-mm sidecar validators and previous_multi_modal_data; adopted main's plain BaseModel now that StrictBaseModel is gone. - clients/train.py: kept the is_multimodal import — still used by the bridge path that threads previous_multi_modal_data. Ruff 0.16 flagged two spots this branch added that main's cleanup pass never saw: a constant getattr in the ingress offload walker, and the blind except at the prepare_request_body boundary (annotated noqa, per main's own convention at rollout boundaries).
…fload Reconciled train.py with main's process-shared renderer pool (#2218): adopted RendererSlot/ElasticRendererPool and the slot.run call structure (which subsumes this branch's _maybe_offload thread-hop), dropped main's multimodal bridging gate (raw refs make mm bridging safe — that is this PR's feature), and re-applied previous_multi_modal_data bridge kwargs inside the pooled bridge closure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fload One conflict in graph.py: main's per-token advantages field (#2245) landed adjacent to this branch's finish_reason on MessageNode — union of both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review finds:
- The ingress prep hook called prepare_request_body on session.ctx.client
(a config object, no such method), so every intercepted request failed
at ingress and image offload never ran. The live client is
session.client.
- content_to_parts dropped HF-style {type: image, image: url} parts that
the ingress offloader and the renderer part treaty both accept, so the
image was offloaded and then silently lost before rendering. Normalize
the shape to ImageUrlContentPart at the typing boundary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every image part is rewritten at ingress to the one wire shape —
{type: image_url, image_url: {url}} — with data images offloaded to
file:// run assets and anything else rejected loudly. Deletes the
multi-shape/pydantic tolerance from prepare_images_inplace (the walker
handles request-body dicts/lists only) and drops the v0 RendererClient
ingress call; v0 renderer-client multimodal is not a supported path.
Already-offloaded file:// URLs pass without importing renderers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
49a7dc1 to
4c6f31e
Compare
There was a problem hiding this comment.
🟡 Medium
verifiers/verifiers/v1/graph.py
Line 669 in 4c6f31e
_commit_turn constructs the assistant MessageNode via MessageNode.model_construct(...) but omits finish_reason=response.finish_reason and usage=response.usage, so every assistant node committed through PendingTurn.commit has finish_reason=None and usage=None — even when the Response carried them. Consumers of these newly added fields therefore cannot detect length truncation or retrieve the provider token accounting the fields are meant to preserve. Consider passing finish_reason=response.finish_reason and usage=response.usage into the MessageNode.model_construct call.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/graph.py around line 669:
`_commit_turn` constructs the assistant `MessageNode` via `MessageNode.model_construct(...)` but omits `finish_reason=response.finish_reason` and `usage=response.usage`, so every assistant node committed through `PendingTurn.commit` has `finish_reason=None` and `usage=None` — even when the `Response` carried them. Consumers of these newly added fields therefore cannot detect length truncation or retrieve the provider token accounting the fields are meant to preserve. Consider passing `finish_reason=response.finish_reason` and `usage=response.usage` into the `MessageNode.model_construct` call.
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 4c6f31e. Configure here.
| sampling_args=sampling.model_dump(exclude_none=True), | ||
| state_columns=["trajectory"], | ||
| ) | ||
| return await self._state_output_with_live_trajectory(state) |
There was a problem hiding this comment.
Legacy bridge bypasses public rollout entry points
Low Severity
Switching to the private _run_rollout_state/_run_group_states skips run_rollout/run_group, which EnvGroup overrides to route each rollout into its child environment. For a nested EnvGroup, the inner group's rubric then resolves the outer route name against its own child map, finds nothing, and silently scores the rollout 0.0. Server-mode delegation through env_client is also skipped on this path.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4c6f31e. Configure here.


Design update — inline/offload image storage
This PR now follows the prime-rl multimodal image storage policy:
offload: current behavior, rewrite base64 data images tofile://run assets and require file-backed image URLs.inline: keepdata:image/...;base64,...URLs in the message payload and validate them without rewriting.TrainClientnow calls the policy-aware image preparation helper, so prime-rl can be the single source of truth via environment/config propagation.Validation after latest push:
uv run pytest tests/v1/test_train_client_multimodal.py -qpassed (5 passed). Commit/push hooks also passed (ruff check,ruff format, generated AGENTS/CLAUDE check,ty).Design update — dropped the
None/cache-only image pathThis PR and its companions (prime-rl #2836 / verifiers #1746 / renderers #89) no longer use the "send
Nonefor already-cached images" mechanism. Every image carries its raw descriptor ref at every slot (current and prior turns);/inference/v1/generaterematerializes each ref from disk every request.Why: the
Nonepath coupled correctness to deployment (LRU cache present, single replica / DP-affinity, no eviction) and surfaced a miss as a hard vLLMEngineDeadError(qwen3-vl mrope dereferences aNoneimage_grid_thw) that the retry net couldn't catch across the engine→API IPC. Dropping it is deployment-agnostic (a miss is impossible) and non-hacky. vLLM'smm_hashencoder cache still skips the expensive GPU re-encode for free — we only forgo the cheap IPC/CPU-reprocess dedup.Validated: color-codeword (Qwen3-VL-4B) under DP=2, no affinity / no cache reliance: 0 crashes, 0
data=None, multi-turn accumulation correct, reward ~0.84. Also confirmed under TP.This repo: with every image carrying its ref, no cache miss can occur — removed the retry subsystem (
_generate_with_image_ref_retry,_has_descriptor_only_images,_retryable_mm_error_type,_json_error_type,_RETRYABLE_MM_ERROR_TYPES). Rollouts callrenderers.client.generatedirectly. Obsolete retry tests removed.Original description
Summary
pixel_values,image_embeds, andimage_featuresprime_raw_mm_itemenvelopes instead of descriptor-only Qwen payloadsCompanion PRs
Notes
Validation
ruff check,ruff format, generated AGENTS/CLAUDE check passed.ty (ci parity)passed./home/ubuntu/verifiers,/home/ubuntu/renderers, and/home/ubuntu/prime-rl-v1-raw-mm-offloadcompleted inference, env rollouts, train batch creation, trainer step 0, and decoded strict trainer-bound raw image refs.Update: ingress hardening (
2c2824ae)prepare_images_inplacenow covers the full renderer part treaty: nestedimage_urldicts, direct-stringimage_url, direct-stringimageparts, and typed pydantic parts. Non-string sources raise with the part shape named; renderer-side raw mode hard-requiresfile://(no second offload layer).prepare_messagesfailures asInterceptionErrorinstead of misattributing them to the user simulator.mm_store; passes against the sibling renderers checkout). Suite:839 passedwith pre-existingtest_envs/test_opencode_rlm_envfailures reproduced on the base branch.Update: merged main (
2b1627d03)Reconciled with verifiers
main(111 commits ahead at the merge-base — mostly unrelated v1 harness/multi-agent, taskset/environment, and trace/data-model churn; none of it touched this branch's actual offload files,verifiers/utils/multimodal.py/clients/renderer_client.py/types.py).interception/server.py: main'sd21100bea("resume replaces mid-request user injection") independently moved the user-simulator loop out of the interception server entirely, intoHarness.launch/resume, and added graph-atomicity retry coalescing tohandle_request/_stream. Took main's structure as authoritative; dropped this branch's now-obsoletesession.user/session.openingmid-request injection (and itsprepare_messagescall sites) since injected/resumed messages now re-enter as ordinary request bodies, already covered byprepare_request_body(kept, rewired to the top of the new single-shothandle_request).graph.py: keptfinish_reason/usage/multi_modal_data/previous_multi_modal_data()(this branch) alongside main'sSkipJsonSchemawrapping convention andcommit()'s new(tools) -> assistant_node_idsignature. Caught and fixed an auto-merge that silently dropped theFinishReason/Usageimports — surfaced as a pydantic "Tracenot fully defined" failure atTraceconstruction, not a textual conflict.trace.py: kept this branch's more accuratemulti_modal_datadocstring; took main's tuple-based bridge-mutation assertion in the test suite (strictly better — reuses already-computedprior_mm/prior_countsinstead of recomputing).verifiers/v1/ARCHITECTURE.md: main deleted this file (docs moved to a much shorterdocs/v1/architecture.mdthat doesn't cover this depth) — accepted the deletion; the one load-bearing fact (whymulti_modal_datais JSON-excluded) is now a docstring on_NODE_DUMP_EXCLUDEinstead of a dedicated doc.Validation: full suite passing with a local renderers checkout override (
uv run --with-editable ../renderers pytest tests/, minusPRIME_API_KEY-gated e2e/env tests) — includestest_prepare_images_inplace_offloads_every_image_part_shape, previously skipped pending a renderers pin withmm_store.Update: dropped the orphaned
prepare_messageshook (d9e79d6c)Main's harness-segment-resume refactor removed the interception server's mid-request user-simulator injection — the only call sites of this PR's
prepare_messageshook. Resumed user turns now re-enter the server as ordinary request bodies, already covered byprepare_request_bodyat request ingress, so the hook (baseClient+TrainClientoverride) is deleted rather than carried as dead code.Update: synced with main (
0e4166608)Merged verifiers
main(35 commits): the v1 structure cleanup (#2146) retiringStrictBaseModeland renaming_NODE_DUMP_EXCLUDE→EXCLUDE_FIELDS, the ruff 0.16 / ty tooling bump (#2147, #2148), runtime-on-agent with the agent config stamped on the trace (#2106),Rewardscore/weight records (#2119), MCP tools for Codex (#2140).Resolutions:
trace.py: main's restructure subsumes this branch's block wholesale —EXCLUDE_FIELDSalready carriesmulti_modal_data, andTRACE_VERSION = 1is main's deliberate reset (this branch never touched it). Took main.graph.py: kept the raw-mm sidecar validators andprevious_multi_modal_data; adopted main's plainBaseModelnow thatStrictBaseModelis gone.clients/train.py: kept theis_multimodalimport — still used by the bridge path that threadsprevious_multi_modal_data.Ruff 0.16 flagged two spots this branch added that main's own cleanup pass never saw: a constant
getattrin the ingress offload walker, and the blindexceptat theprepare_request_bodyboundary (annotated# noqa: BLE001, matching main's convention at rollout boundaries).Full suite with the sibling renderers checkout (minus
PRIME_API_KEY-gated e2e/envs): all passing.Update: merged main — renderer pool reconciliation (
ebc0e2f26)Merged verifiers main (25 commits: process-shared renderer pool #2218, serve CLI removal /
ServeConfigrename #2237, Harbor separate verifier envs #2152, unscored-reward None placeholders #2235, pydantic adapter reuse #2233, and more).One conflict, in
train.py, against #2218's restructure:RendererSlot/ElasticRendererPooland theslot.runcall structure wholesale. It subsumes this branch's_maybe_offloadthread-hop (main now thread-hops + locks all encode-side work, generalized).not _has_multimodal_content(prompt)incan_bridge). That gate exists because processed-tensor sidecars can't bridge safely; this PR's raw descriptor refs are exactly what makes multimodal bridging safe, so the gate is removed and theprevious_multi_modal_databridge kwargs are re-applied inside the pooledbridge()closure.prepare_request_bodyimage-offload hook auto-merged into the newTrainClientuntouched.Full suite: 913 passed, 74 skipped.
Update: merged main (
c9d1f134c)Merged verifiers main (11 commits: per-token advantages #2245, run info moved trace-to-episode #2244, ACP harnesses #2257, persistent harness runtimes #2249, and more). One conflict, in
graph.py: main'sadvantagesfield landed adjacent to this branch'sfinish_reasononMessageNode— union of both. Full suite: 913 passed.Update: review fixes — ingress hook + image-part parsing (
97b6af8bd)Two confirmed finds from Bugbot/Macroscope; the rest of the review items were assessed and intentionally skipped (impossible-deployment hardening, already-loud failure paths, and the deliberate raw-only trace contract):
session.ctx.clientis the client config; the live client issession.client. Every intercepted request died at ingress with anAttributeError-turned-rollout-failure, so image offload never ran on that path. One-word fix.{type: "image", image: url}parts were silently dropped (Macroscope, High): the ingress offloader and the renderer part treaty both accept the shape, butcontent_to_partskept onlytext/image_url— the image was offloaded, then lost before rendering. The typing boundary now normalizes it toImageUrlContentPart.Full suite: 913 passed.
Update: canonical image-part shape at ingress (
49a7dc1a0)Every image part is rewritten at v1 training ingress to exactly
{"type": "image_url", "image_url": {"url": ...}}— HF-style{type: image}normalizes into it, data images offload tofile://run assets, and anything else (http, non-string) is rejected loudly.prepare_images_inplaceloses the multi-shape/pydantic tolerance (walks request-body dicts/lists only), already-offloadedfile://URLs pass without importing renderers, and the v0RendererClientingress call is dropped — v0 renderer-client multimodal is explicitly not a supported path on this branch. One shape after ingress means dialects, renderers, and trace validation have a single place to look for an image. Full suite: 913 passed.Note
Medium Risk
Touches the v1 train interception path and multimodal trace wire format; mis-offload or descriptor validation would break multimodal rollouts, but eval/relay clients are unchanged and failures are explicit at ingress.
Overview
Adds v1 multimodal training ingress so chat requests normalize images before interception, rendering, and tracing all see the same lightweight refs.
prepare_images_inplacewalks request bodies, rewrites HF-style{type: "image"}parts to canonicalimage_url, offloadsdata:URLs tofile://run assets viarenderers.mm_store, and rejects unsupported sources (e.g.https://). The baseClientgainsprepare_request_body;TrainClientruns offload on chat dialect bodies in a thread; the interception server calls it at ingress and surfaces prep failures asInterceptionError.Multimodal sidecars move from processed tensors to raw descriptors. Graph serialization validates
raw_image_uri, rejects nestedpixel_values/image_embeds/image_features, and attributes hashes, items, and placeholders per introducing node.PendingTurn.previous_multi_modal_data()feeds incremental renderer bridging; the old “no bridge when prompt has images” guard is removed when the renderer is multimodal.Smaller follow-ons:
content_to_partsaccepts HFimageparts; legacy v0→v1 trace mapping preserves live trajectorymulti_modal_data; docstrings note raw descriptors instead of pixel tensors.Reviewed by Cursor Bugbot for commit 4c6f31e. Bugbot is set up for automated code reviews on this repo. Configure here.