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.
Files changed (62) hide show
  1. molt/__init__.py +14 -0
  2. molt/agents/__init__.py +44 -0
  3. molt/agents/_chat_server.py +466 -0
  4. molt/agents/base.py +474 -0
  5. molt/agents/chat_agent.py +280 -0
  6. molt/agents/distill_agent.py +59 -0
  7. molt/cli/__init__.py +16 -0
  8. molt/cli/common_args.py +183 -0
  9. molt/cli/train_rl_ray.py +1155 -0
  10. molt/cli/train_sft.py +299 -0
  11. molt/datasets/__init__.py +21 -0
  12. molt/datasets/prompts_dataset.py +143 -0
  13. molt/datasets/sft_dataset.py +334 -0
  14. molt/datasets/utils.py +117 -0
  15. molt/models/__init__.py +34 -0
  16. molt/models/actor.py +115 -0
  17. molt/models/base.py +838 -0
  18. molt/models/critic.py +170 -0
  19. molt/models/loss.py +467 -0
  20. molt/models/utils.py +240 -0
  21. molt/trainer/__init__.py +18 -0
  22. molt/trainer/algorithm/__init__.py +38 -0
  23. molt/trainer/algorithm/advantage.py +327 -0
  24. molt/trainer/algorithm/experience.py +332 -0
  25. molt/trainer/algorithm/kl_controller.py +47 -0
  26. molt/trainer/algorithm/replay_buffer.py +162 -0
  27. molt/trainer/fsdp/__init__.py +20 -0
  28. molt/trainer/fsdp/checkpoint.py +409 -0
  29. molt/trainer/fsdp/muon.py +166 -0
  30. molt/trainer/fsdp/optimizer_offload.py +110 -0
  31. molt/trainer/fsdp/packing.py +268 -0
  32. molt/trainer/fsdp/refit.py +48 -0
  33. molt/trainer/fsdp/strategy.py +733 -0
  34. molt/trainer/placement.py +47 -0
  35. molt/trainer/rl_trainer.py +1042 -0
  36. molt/trainer/rollout/__init__.py +18 -0
  37. molt/trainer/rollout/experience_maker.py +263 -0
  38. molt/trainer/rollout/router.py +405 -0
  39. molt/trainer/rollout/samples_generator.py +636 -0
  40. molt/trainer/sft_trainer.py +389 -0
  41. molt/trainer/vllm/__init__.py +33 -0
  42. molt/trainer/vllm/vllm_engine.py +565 -0
  43. molt/trainer/vllm/vllm_worker_wrap.py +118 -0
  44. molt/trainer/workers/__init__.py +18 -0
  45. molt/trainer/workers/actor_group.py +346 -0
  46. molt/trainer/workers/critic_actor.py +386 -0
  47. molt/trainer/workers/policy_actor.py +1015 -0
  48. molt/utils/__init__.py +23 -0
  49. molt/utils/config.py +44 -0
  50. molt/utils/distributed_sampler.py +172 -0
  51. molt/utils/distributed_util.py +44 -0
  52. molt/utils/logging_utils.py +155 -0
  53. molt/utils/seqlen_balancing.py +175 -0
  54. molt/utils/utils.py +105 -0
  55. molt/utils/vlm_utils.py +306 -0
  56. molt_rl-0.1.10.dist-info/METADATA +754 -0
  57. molt_rl-0.1.10.dist-info/RECORD +62 -0
  58. molt_rl-0.1.10.dist-info/WHEEL +5 -0
  59. molt_rl-0.1.10.dist-info/licenses/LICENSE +201 -0
  60. molt_rl-0.1.10.dist-info/licenses/NOTICE +10 -0
  61. molt_rl-0.1.10.dist-info/top_level.txt +2 -0
  62. 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.
@@ -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)