molt-rl 0.1.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- molt/__init__.py +14 -0
- molt/agents/__init__.py +44 -0
- molt/agents/_chat_server.py +466 -0
- molt/agents/base.py +474 -0
- molt/agents/chat_agent.py +280 -0
- molt/agents/distill_agent.py +59 -0
- molt/cli/__init__.py +16 -0
- molt/cli/common_args.py +183 -0
- molt/cli/train_rl_ray.py +1155 -0
- molt/cli/train_sft.py +299 -0
- molt/datasets/__init__.py +21 -0
- molt/datasets/prompts_dataset.py +143 -0
- molt/datasets/sft_dataset.py +334 -0
- molt/datasets/utils.py +117 -0
- molt/models/__init__.py +34 -0
- molt/models/actor.py +115 -0
- molt/models/base.py +838 -0
- molt/models/critic.py +170 -0
- molt/models/loss.py +467 -0
- molt/models/utils.py +240 -0
- molt/trainer/__init__.py +18 -0
- molt/trainer/algorithm/__init__.py +38 -0
- molt/trainer/algorithm/advantage.py +327 -0
- molt/trainer/algorithm/experience.py +332 -0
- molt/trainer/algorithm/kl_controller.py +47 -0
- molt/trainer/algorithm/replay_buffer.py +162 -0
- molt/trainer/fsdp/__init__.py +20 -0
- molt/trainer/fsdp/checkpoint.py +409 -0
- molt/trainer/fsdp/muon.py +166 -0
- molt/trainer/fsdp/optimizer_offload.py +110 -0
- molt/trainer/fsdp/packing.py +268 -0
- molt/trainer/fsdp/refit.py +48 -0
- molt/trainer/fsdp/strategy.py +733 -0
- molt/trainer/placement.py +47 -0
- molt/trainer/rl_trainer.py +1042 -0
- molt/trainer/rollout/__init__.py +18 -0
- molt/trainer/rollout/experience_maker.py +263 -0
- molt/trainer/rollout/router.py +405 -0
- molt/trainer/rollout/samples_generator.py +636 -0
- molt/trainer/sft_trainer.py +389 -0
- molt/trainer/vllm/__init__.py +33 -0
- molt/trainer/vllm/vllm_engine.py +565 -0
- molt/trainer/vllm/vllm_worker_wrap.py +118 -0
- molt/trainer/workers/__init__.py +18 -0
- molt/trainer/workers/actor_group.py +346 -0
- molt/trainer/workers/critic_actor.py +386 -0
- molt/trainer/workers/policy_actor.py +1015 -0
- molt/utils/__init__.py +23 -0
- molt/utils/config.py +44 -0
- molt/utils/distributed_sampler.py +172 -0
- molt/utils/distributed_util.py +44 -0
- molt/utils/logging_utils.py +155 -0
- molt/utils/seqlen_balancing.py +175 -0
- molt/utils/utils.py +105 -0
- molt/utils/vlm_utils.py +306 -0
- molt_rl-0.1.10.dist-info/METADATA +754 -0
- molt_rl-0.1.10.dist-info/RECORD +62 -0
- molt_rl-0.1.10.dist-info/WHEEL +5 -0
- molt_rl-0.1.10.dist-info/licenses/LICENSE +201 -0
- molt_rl-0.1.10.dist-info/licenses/NOTICE +10 -0
- molt_rl-0.1.10.dist-info/top_level.txt +2 -0
- tests/__init__.py +16 -0
molt/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
molt/agents/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Molt agents — Gymnasium-aligned public surface.
|
|
17
|
+
|
|
18
|
+
User writes ONE of:
|
|
19
|
+
- `class MyEnv(Env)` with async step() / reset() returning `Result`
|
|
20
|
+
- `class MyAgent(ChatAgent)` with async run(ctx) returning `Result`
|
|
21
|
+
|
|
22
|
+
User binds it via a Runner subclass and exports as `AgentRunner`:
|
|
23
|
+
- `class AgentRunner(StepEnvRunner): super().__init__(MyEnv)`
|
|
24
|
+
- `class AgentRunner(ChatAgentRunner): super().__init__(MyAgent)`
|
|
25
|
+
|
|
26
|
+
For the chat-agent path, build your own client from the session URL — either
|
|
27
|
+
`openai.AsyncOpenAI(base_url=ctx.base_url)` or
|
|
28
|
+
`anthropic.AsyncAnthropic(base_url=ctx.session_url)`. We don't wrap the SDKs —
|
|
29
|
+
use whichever directly; the server speaks both wires against the same engine.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from molt.agents.base import Env, Result, Runner, StepEnvRunner, Trajectory
|
|
33
|
+
from molt.agents.chat_agent import ChatAgent, ChatAgentRunner, ChatContext
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Env",
|
|
37
|
+
"ChatAgent",
|
|
38
|
+
"ChatAgentRunner",
|
|
39
|
+
"ChatContext",
|
|
40
|
+
"Result",
|
|
41
|
+
"Runner",
|
|
42
|
+
"StepEnvRunner",
|
|
43
|
+
"Trajectory",
|
|
44
|
+
]
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Internal chat forwarder over the vllm-router (imported by ``chat_agent.py``).
|
|
17
|
+
|
|
18
|
+
The agent talks plain **chat messages** to a loopback endpoint with a stock OpenAI or
|
|
19
|
+
Anthropic client. This server accumulates token-exact ``Trajectory`` segments per session (TITO),
|
|
20
|
+
exactly like the Env step runner: a segment renders its first prompt CLIENT-SIDE (the same
|
|
21
|
+
``apply_chat_template`` -> ``process_prompt_with_images`` path the RL dataset uses, so the
|
|
22
|
+
image-placeholder count is self-consistent with the vision encoder); each continuation turn appends
|
|
23
|
+
only the NEW turn's chat-template delta and generates over the EXACT accumulated tokens via the
|
|
24
|
+
shared router transport (``RouterGenerateClient`` — ``/inference/v1/generate``). Because we train
|
|
25
|
+
on the very tokens vLLM generated over, there is zero re-template drift even for models whose
|
|
26
|
+
chat template doesn't round-trip generated bytes (e.g. a ``</think>`` trim), so no fragmentation.
|
|
27
|
+
A normal multi-turn episode is ONE segment with one action range per turn; a fresh segment starts on
|
|
28
|
+
a compaction (the agent rewrites/shortens history to fit context) or when the template re-renders
|
|
29
|
+
prior turns non-prefix (e.g. Qwen3 drops a prior turn's <think> block once a newer user query
|
|
30
|
+
follows). All segments share the one ``rollout_id`` + reward the trainer assigns (``stitch_session``).
|
|
31
|
+
|
|
32
|
+
Three concerns, each a section below:
|
|
33
|
+
1. WIRE CODECS — OpenAI is canonical (identity decode); Anthropic is translated
|
|
34
|
+
to the same shape (``_decode_anthropic``) and the reply re-encoded per wire.
|
|
35
|
+
2. FORWARD — ``_run_turn`` grows the session trajectory by one turn and generates.
|
|
36
|
+
3. HTTP SERVER — uvicorn on the actor's OWN event loop + the session routes.
|
|
37
|
+
|
|
38
|
+
Runs on the rollout runner actor's own event loop. A stock client reaches a session
|
|
39
|
+
through the URL path prefix ``/s/<session_id>/v1`` that ``ChatAgentRunner`` builds.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import asyncio
|
|
45
|
+
import time
|
|
46
|
+
from copy import deepcopy
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from typing import Any
|
|
49
|
+
from uuid import uuid4
|
|
50
|
+
|
|
51
|
+
import uvicorn
|
|
52
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
53
|
+
from fastapi.responses import JSONResponse
|
|
54
|
+
|
|
55
|
+
from molt.agents.base import (
|
|
56
|
+
_TOKENIZE_EXECUTOR,
|
|
57
|
+
Trajectory,
|
|
58
|
+
_extract_generation_logprobs,
|
|
59
|
+
_tokenize_feedback,
|
|
60
|
+
_tokenize_observation,
|
|
61
|
+
)
|
|
62
|
+
from molt.utils.vlm_utils import (
|
|
63
|
+
estimate_vllm_input_expansion_delta,
|
|
64
|
+
load_images,
|
|
65
|
+
should_expand_image_placeholder,
|
|
66
|
+
split_image_placeholder,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ===========================================================================
|
|
71
|
+
# 1. Wire codecs — Anthropic request -> canonical (OpenAI) messages; replies back.
|
|
72
|
+
# OpenAI is the canonical shape, so its decode is identity (``lambda b: b``).
|
|
73
|
+
# ===========================================================================
|
|
74
|
+
def _content_to_text_and_images(content) -> tuple[str, list]:
|
|
75
|
+
"""One OpenAI message's ``content`` -> (flat text with a literal ``<image>`` per image,
|
|
76
|
+
loaded PIL images). Mirrors the dataset convention (user content is ``"<image>...problem"``);
|
|
77
|
+
the client-side chat template + processor then expand each ``<image>`` exactly as the step
|
|
78
|
+
runner does. A bare-string content passes through with no images."""
|
|
79
|
+
if isinstance(content, str):
|
|
80
|
+
return content, []
|
|
81
|
+
if not isinstance(content, list):
|
|
82
|
+
return ("" if content is None else str(content)), []
|
|
83
|
+
parts, pil = [], []
|
|
84
|
+
for item in content:
|
|
85
|
+
if not isinstance(item, dict):
|
|
86
|
+
parts.append(str(item))
|
|
87
|
+
elif item.get("type") == "text":
|
|
88
|
+
parts.append(item.get("text") or "")
|
|
89
|
+
elif item.get("type") == "image_url":
|
|
90
|
+
ref = item.get("image_url") or {}
|
|
91
|
+
url = ref.get("url") if isinstance(ref, dict) else ref
|
|
92
|
+
imgs = load_images(url) if url else []
|
|
93
|
+
pil.extend(imgs)
|
|
94
|
+
parts.append("<image>" * len(imgs))
|
|
95
|
+
return "".join(parts), pil
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _decode_anthropic(body: dict) -> dict:
|
|
99
|
+
"""Anthropic Messages request -> canonical (OpenAI-shaped) body the router
|
|
100
|
+
understands. Top-level ``system`` becomes the first canonical message; content
|
|
101
|
+
blocks are normalized below."""
|
|
102
|
+
messages = [{"role": "system", "content": body["system"]}] if body.get("system") else []
|
|
103
|
+
for m in body.get("messages", []):
|
|
104
|
+
content = m.get("content")
|
|
105
|
+
if isinstance(content, list):
|
|
106
|
+
content = [out for b in content for out in _anthropic_blocks(b)]
|
|
107
|
+
messages.append({"role": m.get("role"), "content": content})
|
|
108
|
+
return {**body, "messages": messages}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _anthropic_blocks(block) -> list:
|
|
112
|
+
"""One Anthropic content block -> list of canonical (OpenAI) blocks. image ->
|
|
113
|
+
``image_url`` (base64 source -> data URI); ``tool_result`` -> its inner
|
|
114
|
+
text/image blocks (an Anthropic harness returns tool output as a ``tool_result``
|
|
115
|
+
block, so without unwrapping the observation would be dropped)."""
|
|
116
|
+
if not isinstance(block, dict):
|
|
117
|
+
return [block]
|
|
118
|
+
btype = block.get("type")
|
|
119
|
+
if btype == "image":
|
|
120
|
+
src = block.get("source") or {}
|
|
121
|
+
url = src.get("url") if src.get("type") == "url" else f"data:{src.get('media_type')};base64,{src.get('data')}"
|
|
122
|
+
return [{"type": "image_url", "image_url": {"url": url}}]
|
|
123
|
+
if btype == "tool_result":
|
|
124
|
+
inner = block.get("content") # bare string, or a list of text/image blocks
|
|
125
|
+
if isinstance(inner, str):
|
|
126
|
+
return [{"type": "text", "text": inner}]
|
|
127
|
+
if isinstance(inner, list):
|
|
128
|
+
return [out for b in inner for out in _anthropic_blocks(b)]
|
|
129
|
+
return []
|
|
130
|
+
return [block]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# OpenAI's ChatCompletion schema accepts only these finish_reason values and the SDK
|
|
134
|
+
# validates them client-side; vLLM also emits abort/error/repetition (and None
|
|
135
|
+
# mid-stream), so any non-OpenAI reason maps to "stop" purely to keep the reply
|
|
136
|
+
# SDK-parseable (the agent never reads it; truncation uses the RAW reason elsewhere).
|
|
137
|
+
_OPENAI_FINISH_REASONS = frozenset({"stop", "length", "tool_calls", "content_filter", "function_call"})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _chat_completion_body(model_name: str, content: str, finish_reason: str) -> dict:
|
|
141
|
+
return {
|
|
142
|
+
"id": f"chatcmpl-{uuid4().hex[:24]}",
|
|
143
|
+
"object": "chat.completion",
|
|
144
|
+
"created": int(time.time()),
|
|
145
|
+
"model": model_name,
|
|
146
|
+
"choices": [
|
|
147
|
+
{
|
|
148
|
+
"index": 0,
|
|
149
|
+
"message": {"role": "assistant", "content": content},
|
|
150
|
+
"finish_reason": finish_reason if finish_reason in _OPENAI_FINISH_REASONS else "stop",
|
|
151
|
+
}
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Anthropic's Message schema validates stop_reason client-side too; map the raw vLLM
|
|
157
|
+
# reason into its vocab (anything else -> "end_turn").
|
|
158
|
+
_ANTHROPIC_STOP_REASONS = {"stop": "end_turn", "length": "max_tokens", "tool_calls": "tool_use"}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _anthropic_message_body(model_name: str, content: str, finish_reason: str) -> dict:
|
|
162
|
+
return {
|
|
163
|
+
"id": f"msg_{uuid4().hex[:24]}",
|
|
164
|
+
"type": "message",
|
|
165
|
+
"role": "assistant",
|
|
166
|
+
"model": model_name,
|
|
167
|
+
"content": [{"type": "text", "text": content}],
|
|
168
|
+
"stop_reason": _ANTHROPIC_STOP_REASONS.get(finish_reason, "end_turn"),
|
|
169
|
+
"stop_sequence": None,
|
|
170
|
+
"usage": {"input_tokens": 0, "output_tokens": 0},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _build_sampling_params(base_sampling, body: dict):
|
|
175
|
+
"""One vLLM SamplingParams per chat call: inherit THIS rollout's defaults (its train-vs-eval
|
|
176
|
+
temperature / max_tokens — see ``_Session.sampling_params``), honor the caller's temperature /
|
|
177
|
+
max_tokens, and force logprobs on (importance-sampling correction needs token logprobs)."""
|
|
178
|
+
sp = deepcopy(base_sampling)
|
|
179
|
+
requested = body.get("max_completion_tokens", body.get("max_tokens"))
|
|
180
|
+
if requested is not None:
|
|
181
|
+
sp.max_tokens = int(requested)
|
|
182
|
+
if body.get("temperature") is not None:
|
|
183
|
+
sp.temperature = float(body["temperature"])
|
|
184
|
+
if body.get("top_p") is not None:
|
|
185
|
+
sp.top_p = float(body["top_p"])
|
|
186
|
+
if sp.logprobs is None:
|
|
187
|
+
sp.logprobs = 1
|
|
188
|
+
return sp
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ===========================================================================
|
|
192
|
+
# 2. Per-session state — token-exact Trajectory segments per session (TITO).
|
|
193
|
+
# ===========================================================================
|
|
194
|
+
@dataclass
|
|
195
|
+
class _Session:
|
|
196
|
+
prompt: str
|
|
197
|
+
label: Any
|
|
198
|
+
images: list | None
|
|
199
|
+
# This rollout's id, reused as the router ``x-session-id`` so all turns (and each turn's
|
|
200
|
+
# render + generate) pin to one engine (consistent_hash) — see ``RouterGenerateClient.generate``.
|
|
201
|
+
session_id: str | None = None
|
|
202
|
+
# THIS rollout's SamplingParams (carries the train-vs-eval temperature / max_tokens for this
|
|
203
|
+
# call). Used as the per-turn default before the agent's own body overrides it; the fallback is
|
|
204
|
+
# ChatServerState.default_sampling, which is frozen at the FIRST execute — so an agent that
|
|
205
|
+
# doesn't re-send temperature would otherwise sample every later train/eval call at the
|
|
206
|
+
# first-caller's temperature (silent train/eval sampling-temperature mismatch).
|
|
207
|
+
sampling_params: Any = None
|
|
208
|
+
# Token-exact Trajectory segments for this episode. Normal multi-turn = ONE segment that later
|
|
209
|
+
# turns append to in place; a compaction (the agent rewrites/shortens history) or a non-prefix
|
|
210
|
+
# template re-render of prior turns starts a fresh segment. Empty until the first turn generates.
|
|
211
|
+
trajectories: list = field(default_factory=list)
|
|
212
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock) # serialize turns within a session
|
|
213
|
+
# Idempotent replay: a stock client retries a turn whose response was lost (e.g. a weight
|
|
214
|
+
# broadcast stalled the loop). The retry re-sends the SAME messages; we return the cached
|
|
215
|
+
# reply WITHOUT recording a second action, so retries can't double-count a turn.
|
|
216
|
+
last_messages: list | None = None
|
|
217
|
+
last_response: tuple | None = None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ChatServerState:
|
|
221
|
+
"""Per-actor state: the unified HTTP transport (over the router) + processor +
|
|
222
|
+
live session accumulators. ``transport`` is the shared ``RouterGenerateClient``
|
|
223
|
+
(``.generate(token_ids, sp, mm)`` + the shared ``.http`` session, base-URL'd to the router)."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, transport, processor, model_name: str, max_length: int, default_sampling):
|
|
226
|
+
self.transport = transport # RouterGenerateClient: .generate(token_ids, sp, mm) over /inference/v1/generate
|
|
227
|
+
self.processor = processor
|
|
228
|
+
self.model_name = model_name
|
|
229
|
+
self.max_length = max_length
|
|
230
|
+
self.default_sampling = default_sampling
|
|
231
|
+
# omni3 uses a literal ``<image>`` (pass content through); qwen3.6-VL uses ``<|image_pad|>``
|
|
232
|
+
# and needs the structured-content split. Mirrors the RL prompt dataset's rendering.
|
|
233
|
+
self.expand_image_placeholder = should_expand_image_placeholder(processor)
|
|
234
|
+
self.sessions: dict[str, _Session] = {}
|
|
235
|
+
|
|
236
|
+
def open(self, session_id: str, prompt, label, images, sampling_params=None):
|
|
237
|
+
self.sessions[session_id] = _Session(
|
|
238
|
+
prompt=prompt, label=label, images=images, session_id=session_id, sampling_params=sampling_params
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def discard(self, session_id: str):
|
|
242
|
+
self.sessions.pop(session_id, None)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ===========================================================================
|
|
246
|
+
# 3. Forward one turn -> grow the session's ONE token-exact Trajectory.
|
|
247
|
+
# ===========================================================================
|
|
248
|
+
def _messages_to_chat(state: ChatServerState, messages: list) -> tuple[list, list]:
|
|
249
|
+
"""OpenAI messages -> (ChatML rows with a literal ``<image>`` per image, loaded PIL images).
|
|
250
|
+
Split the literal placeholder into structured content for models whose image token isn't
|
|
251
|
+
``<image>`` (Qwen-VL), so the template renders it — mirrors the RL dataset's rendering."""
|
|
252
|
+
chat, pil_images = [], []
|
|
253
|
+
for m in messages:
|
|
254
|
+
text, imgs = _content_to_text_and_images(m.get("content"))
|
|
255
|
+
chat.append({"role": m.get("role"), "content": text})
|
|
256
|
+
pil_images.extend(imgs)
|
|
257
|
+
if state.expand_image_placeholder:
|
|
258
|
+
chat = [split_image_placeholder(m) for m in chat]
|
|
259
|
+
return chat, pil_images
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _run_turn(state: ChatServerState, session: _Session, body: dict) -> tuple[str, str]:
|
|
263
|
+
"""Grow the session's current token-exact Trajectory segment by one turn, then generate over its
|
|
264
|
+
EXACT accumulated tokens (TITO). A new segment (full CLIENT-SIDE render, the same
|
|
265
|
+
``apply_chat_template`` -> ``process_prompt_with_images`` path the RL dataset uses) starts the
|
|
266
|
+
episode AND resumes after a compaction; a normal continuation appends only the new chat-template
|
|
267
|
+
delta, so the trained tokens stay identical to what vLLM generated over — no re-template drift.
|
|
268
|
+
Returns ``(action_text, finish_reason)``."""
|
|
269
|
+
messages = body.get("messages") or []
|
|
270
|
+
if not messages:
|
|
271
|
+
raise HTTPException(status_code=400, detail="No messages provided")
|
|
272
|
+
if messages == session.last_messages: # retry of an already-recorded turn -> replay, don't re-record
|
|
273
|
+
return session.last_response
|
|
274
|
+
sp = _build_sampling_params(session.sampling_params or state.default_sampling, body)
|
|
275
|
+
kwargs = {"tools": body["tools"]} if body.get("tools") else {}
|
|
276
|
+
loop = asyncio.get_running_loop()
|
|
277
|
+
|
|
278
|
+
# Does this turn continue the running segment, or did the agent rewrite/shorten the history
|
|
279
|
+
# (compaction)? A normal turn only appends (assistant + new user/tool) onto the SAME prefix, so
|
|
280
|
+
# its messages extend last_messages; a compaction changes that prefix. Also false on turn 1.
|
|
281
|
+
extends = bool(session.trajectories) and messages[: len(session.last_messages)] == session.last_messages
|
|
282
|
+
chat, pil_images = _messages_to_chat(state, messages)
|
|
283
|
+
full = state.processor.apply_chat_template(chat, tokenize=False, add_generation_prompt=True, **kwargs)
|
|
284
|
+
if extends:
|
|
285
|
+
prefix = state.processor.apply_chat_template(chat[:-1], tokenize=False, add_generation_prompt=False, **kwargs)
|
|
286
|
+
# The suffix slice below is only sound when the prior turns render the same in `full` and
|
|
287
|
+
# `prefix`. A template can instead rewrite history in place — e.g. Qwen3 re-renders a prior
|
|
288
|
+
# assistant turn WITHOUT its <think> block once a newer user query follows — and the slice
|
|
289
|
+
# would silently drop or garble this turn's feedback. Treat that like a compaction.
|
|
290
|
+
extends = full.startswith(prefix)
|
|
291
|
+
if not extends:
|
|
292
|
+
# New segment (episode start OR post-compaction/re-render): render the prompt in full +
|
|
293
|
+
# tokenize (image count self-consistent with the vision encoder). process_prompt_with_images
|
|
294
|
+
# is a multi-second CPU op per image; run it off the loop so other sessions' generations overlap.
|
|
295
|
+
prompt_ids, mm_train_inputs, pil_images = await loop.run_in_executor(
|
|
296
|
+
_TOKENIZE_EXECUTOR, _tokenize_observation, state.processor, full, pil_images
|
|
297
|
+
)
|
|
298
|
+
image_budget = (
|
|
299
|
+
estimate_vllm_input_expansion_delta(state.processor, prompt_ids, mm_train_inputs, pil_images)
|
|
300
|
+
if pil_images
|
|
301
|
+
else 0
|
|
302
|
+
)
|
|
303
|
+
remaining = state.max_length - len(prompt_ids) - image_budget
|
|
304
|
+
if remaining <= 0: # prompt alone fills the context -> generate nothing; stitch drops empty rollouts
|
|
305
|
+
session.last_messages, session.last_response = messages, ("", "length")
|
|
306
|
+
return "", "length"
|
|
307
|
+
# Built but NOT yet attached to the session: a failed generate below must leave the session
|
|
308
|
+
# untouched (see the commit note) so a client retry re-runs cleanly.
|
|
309
|
+
traj = Trajectory(
|
|
310
|
+
prompt=session.prompt,
|
|
311
|
+
label=session.label,
|
|
312
|
+
images=session.images,
|
|
313
|
+
observation_text="",
|
|
314
|
+
observation_tokens=list(prompt_ids),
|
|
315
|
+
mm_train_inputs=mm_train_inputs,
|
|
316
|
+
pil_images=pil_images,
|
|
317
|
+
image_budget=image_budget,
|
|
318
|
+
rollout_log_probs=[0.0] * len(prompt_ids),
|
|
319
|
+
)
|
|
320
|
+
gen_tokens = traj.observation_tokens
|
|
321
|
+
else:
|
|
322
|
+
# Continuation: the new turn's tokens are the chat-template STRING suffix — the prior-turn
|
|
323
|
+
# renders land in the shared prefix and cancel (verified above), so the suffix is the new
|
|
324
|
+
# user/tool turn + generation prompt (committed as masked feedback -> token-exact segment).
|
|
325
|
+
traj = session.trajectories[-1]
|
|
326
|
+
delta_text = full[len(prefix) :]
|
|
327
|
+
new_pil = _content_to_text_and_images(messages[-1].get("content"))[1] # images added THIS turn
|
|
328
|
+
delta_ids = await loop.run_in_executor(
|
|
329
|
+
_TOKENIZE_EXECUTOR, _tokenize_feedback, state.processor, delta_text, new_pil, traj, state.max_length
|
|
330
|
+
)
|
|
331
|
+
# Generate over the accumulated tokens PLUS this turn's delta, but do NOT append the delta yet.
|
|
332
|
+
gen_tokens = traj.observation_tokens + delta_ids
|
|
333
|
+
remaining = state.max_length - len(gen_tokens) - traj.image_budget
|
|
334
|
+
if remaining <= 0: # grown context fills the window -> end truncated; prior turns are kept
|
|
335
|
+
traj.truncated = True
|
|
336
|
+
session.last_messages, session.last_response = messages, ("", "length")
|
|
337
|
+
return "", "length"
|
|
338
|
+
|
|
339
|
+
# Clamp generation to the remaining budget (the step runner clamps the same way).
|
|
340
|
+
if sp.max_tokens is None or sp.max_tokens > remaining:
|
|
341
|
+
sp.max_tokens = remaining
|
|
342
|
+
if getattr(sp, "min_tokens", 0) and sp.min_tokens > sp.max_tokens:
|
|
343
|
+
sp.min_tokens = sp.max_tokens
|
|
344
|
+
|
|
345
|
+
mm_data = {"image": traj.pil_images} if traj.pil_images else None
|
|
346
|
+
request_output, off_policy_len = await state.transport.generate(
|
|
347
|
+
gen_tokens, sp, multi_modal_data=mm_data, session_id=session.session_id
|
|
348
|
+
)
|
|
349
|
+
generation = request_output.outputs[0]
|
|
350
|
+
action_ids = list(generation.token_ids)
|
|
351
|
+
# /inference/v1/generate is token-only (generation.text == ""); decode the action text from the
|
|
352
|
+
# ids so the agent/grader sees it. skip_special_tokens=False keeps answer markers (<answer>,
|
|
353
|
+
# \boxed, tool tags) — matches observation_text decoding in base.py.
|
|
354
|
+
action_text = generation.text or (
|
|
355
|
+
state.processor.decode(action_ids, skip_special_tokens=False) if action_ids else ""
|
|
356
|
+
)
|
|
357
|
+
finish_reason = generation.finish_reason or "stop"
|
|
358
|
+
# a non-empty completion MUST carry aligned per-token logprobs — fails fast otherwise (IS correction).
|
|
359
|
+
action_logprobs = _extract_generation_logprobs(action_ids, generation.logprobs) if action_ids else []
|
|
360
|
+
|
|
361
|
+
# Commit the turn only now that generate succeeded. A failed generate above left the session
|
|
362
|
+
# untouched, so the stock client's retry (same messages) re-runs this turn cleanly — no
|
|
363
|
+
# double-appended delta, and no half-built segment to trip the extends check / crash on turn 1.
|
|
364
|
+
if not extends:
|
|
365
|
+
session.trajectories.append(traj)
|
|
366
|
+
else:
|
|
367
|
+
prev_action_text = session.last_response[0] if session.last_response else ""
|
|
368
|
+
traj.append_feedback(prev_action_text, delta_text, delta_ids)
|
|
369
|
+
traj.truncated = traj.truncated or finish_reason == "length"
|
|
370
|
+
# off_policy_len: leading tokens generated under stale weights when a broadcast landed
|
|
371
|
+
# mid-request (per-token IS still corrects them); the transport reports it, same as the step runner.
|
|
372
|
+
traj.append_action(action_ids, action_logprobs, off_policy_len=off_policy_len)
|
|
373
|
+
# R3: absorb this turn's prefill+gen routing by absolute position (first-writer-wins). The full
|
|
374
|
+
# prefill covers the prior action's trailing token too, so the turn-boundary backfills naturally.
|
|
375
|
+
traj.absorb_routing(request_output)
|
|
376
|
+
session.last_messages, session.last_response = messages, (action_text, finish_reason)
|
|
377
|
+
return action_text, finish_reason
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def stitch_session(state: ChatServerState, session_id: str, result):
|
|
381
|
+
"""Return the rollout's token-exact trajectory segments (usually ONE; more when the agent
|
|
382
|
+
compacted history mid-episode or the template re-rendered prior turns non-prefix), each stamped
|
|
383
|
+
with the terminal reward/score/info. Multi-turn
|
|
384
|
+
actions are separate action ranges within a segment (like the step runner); all segments share the
|
|
385
|
+
one ``rollout_id`` + reward the trainer assigns, so group baselines dedup them to one reward per
|
|
386
|
+
rollout. Empty list when nothing was generated (the first prompt alone exceeded max_length)."""
|
|
387
|
+
session = state.sessions.get(session_id)
|
|
388
|
+
if session is None:
|
|
389
|
+
raise RuntimeError(f"Unknown session {session_id} at stitch")
|
|
390
|
+
for traj in session.trajectories:
|
|
391
|
+
traj.reward = result.reward
|
|
392
|
+
traj.scores = result.score if result.score is not None else result.reward
|
|
393
|
+
traj.extra_logs = result.info or {}
|
|
394
|
+
if result.images is not None:
|
|
395
|
+
traj.images = result.images
|
|
396
|
+
# The agent's own cut (turn cap, budget) ends the episode, so it lands on the last segment
|
|
397
|
+
# only; truncated_rate counts segments, and the earlier ones were generated in full.
|
|
398
|
+
if session.trajectories and result.truncated:
|
|
399
|
+
session.trajectories[-1].truncated = True
|
|
400
|
+
return session.trajectories
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ===========================================================================
|
|
404
|
+
# 4. HTTP server — uvicorn on the runner actor's own event loop + session routes.
|
|
405
|
+
# ===========================================================================
|
|
406
|
+
class _AutoPortServer(uvicorn.Server):
|
|
407
|
+
"""uvicorn Server that reports the OS-assigned port when configured ``port=0``."""
|
|
408
|
+
|
|
409
|
+
def __init__(self, config: uvicorn.Config):
|
|
410
|
+
super().__init__(config)
|
|
411
|
+
self.actual_port: int | None = None
|
|
412
|
+
self._ready = asyncio.Event()
|
|
413
|
+
|
|
414
|
+
async def startup(self, sockets=None) -> None:
|
|
415
|
+
try:
|
|
416
|
+
await super().startup(sockets=sockets)
|
|
417
|
+
if self.servers and self.config.port == 0:
|
|
418
|
+
self.actual_port = self.servers[0].sockets[0].getsockname()[1]
|
|
419
|
+
else:
|
|
420
|
+
self.actual_port = self.config.port
|
|
421
|
+
finally:
|
|
422
|
+
self._ready.set()
|
|
423
|
+
|
|
424
|
+
async def get_port(self) -> int | None:
|
|
425
|
+
await self._ready.wait()
|
|
426
|
+
return self.actual_port
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def serve_on_current_loop(app, host: str = "127.0.0.1") -> tuple[int, asyncio.Task]:
|
|
430
|
+
"""Start uvicorn as a task on the CURRENT event loop. Returns ``(port, task)``."""
|
|
431
|
+
config = uvicorn.Config(app, host=host, port=0, log_level="warning")
|
|
432
|
+
server = _AutoPortServer(config)
|
|
433
|
+
task = asyncio.create_task(server.serve())
|
|
434
|
+
port = await server.get_port()
|
|
435
|
+
if port is None:
|
|
436
|
+
await task # surfaces the startup exception
|
|
437
|
+
raise RuntimeError("Chat server failed to start (no port reported)")
|
|
438
|
+
return port, task
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def mount_session_capture(app, state: ChatServerState) -> None:
|
|
442
|
+
"""Mount the session-scoped chat routes. The OpenAI and Anthropic wires forward
|
|
443
|
+
the SAME turn (``_run_turn``); they differ only in how the body is decoded to the
|
|
444
|
+
canonical shape and how the reply is encoded back."""
|
|
445
|
+
router = APIRouter()
|
|
446
|
+
|
|
447
|
+
async def _serve(session_id, request, decode, encode):
|
|
448
|
+
body = decode(await request.json())
|
|
449
|
+
session = state.sessions.get(session_id)
|
|
450
|
+
if session is None:
|
|
451
|
+
raise HTTPException(status_code=400, detail=f"Unknown or unopened session {session_id}")
|
|
452
|
+
# Serialize turns within a session: a stock client may retry, which would
|
|
453
|
+
# otherwise run a second turn concurrently and double-record the sample.
|
|
454
|
+
async with session.lock:
|
|
455
|
+
action_text, finish_reason = await _run_turn(state, session, body)
|
|
456
|
+
return JSONResponse(encode(state.model_name, action_text, finish_reason))
|
|
457
|
+
|
|
458
|
+
@router.post("/s/{session_id}/v1/chat/completions")
|
|
459
|
+
async def chat_sess(session_id: str, request: Request):
|
|
460
|
+
return await _serve(session_id, request, lambda b: b, _chat_completion_body)
|
|
461
|
+
|
|
462
|
+
@router.post("/s/{session_id}/v1/messages")
|
|
463
|
+
async def messages_sess(session_id: str, request: Request):
|
|
464
|
+
return await _serve(session_id, request, _decode_anthropic, _anthropic_message_body)
|
|
465
|
+
|
|
466
|
+
app.include_router(router)
|