Skip to content

Commit ef8f579

Browse files
committed
Fix remote environments & episode-driven pipeline
Signed-off-by: Teodor-Dumitru Ene <teodord.ene@gmail.com>
1 parent 3229533 commit ef8f579

3 files changed

Lines changed: 112 additions & 28 deletions

File tree

‎megatron/rl/agent/reward_only_agent.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,16 @@ async def prepare_group_rollout(
239239
) -> GroupRolloutParams:
240240

241241
prompt, golden = await self.get_prompt(validation=request.validation)
242+
return self.group_rollout_params(request, prompt=prompt, golden=golden)
242243

244+
def group_rollout_params(
245+
self,
246+
request: RolloutRequest | GroupedRolloutRequest,
247+
*,
248+
prompt: str | list[LLMChatMessage],
249+
golden: Any,
250+
) -> GroupRolloutParams:
251+
"""The params for one group's rollouts on an already sampled prompt."""
243252
# Every rollout runs as a (possibly multi-turn) episode over the group's shared prompt.
244253
return GroupRolloutParams(
245254
run_episode=functools.partial(self._run_episode, request, prompt=prompt, golden=golden),

‎megatron/rl/server/agent/fastapi_env_server.py‎

Lines changed: 75 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import asyncio
44
import socket
5+
from typing import Annotated, Any
56

67
import httpx
78
import yaml
89
from fastapi import FastAPI
9-
from pydantic import BaseModel, Field, PrivateAttr
10+
from fastapi.responses import JSONResponse
11+
from pydantic import BaseModel, Field, PrivateAttr, TypeAdapter
1012
from typing_extensions import Self
1113
from uvicorn import Config, Server
1214
from uvicorn.config import LOGGING_CONFIG
@@ -22,14 +24,25 @@
2224
EvaluationResponse,
2325
GroupedRolloutRequest,
2426
GroupRolloutParams,
25-
RolloutGenerator,
27+
Rollout,
2628
RolloutRequest,
2729
TokenRollout,
2830
)
2931
from ...agent.registry import get_agent_class
32+
from ...agent.reward_only_agent import RewardOnlyAgent
3033
from ...inference import InferenceInterface
3134
from ...inference.chat_interface import MegatronChatInterface
32-
from ..api import EnvironmentServer, RemoteEvaluationRequest, RemoteRolloutRequest
35+
from ..api import (
36+
EnvironmentServer,
37+
RemoteEpisodeRequest,
38+
RemoteEvaluationRequest,
39+
RemotePrompt,
40+
RemotePromptRequest,
41+
RemoteRolloutRequest,
42+
)
43+
44+
# A finished rollout on the wire; an empty placeholder validates as the first member.
45+
_ROLLOUT = TypeAdapter(Annotated[TokenRollout | Rollout, Field(union_mode="left_to_right")])
3346

3447

3548
def _bind(wire: BaseModel, request_cls: type, interface: InferenceInterface):
@@ -45,30 +58,49 @@ class FastAPIEnvServer(EnvironmentServer):
4558

4659
@classmethod
4760
def build_app(cls, env_cls: type[Agent], cls_args: dict, policy: InferenceInterface) -> FastAPI:
48-
"""Env server app; every route generates through `policy`."""
61+
"""Env server app: one environment instance generating through `policy`."""
4962
app = FastAPI()
50-
51-
if issubclass(env_cls, ContrastiveRolloutGenerator):
63+
env = env_cls(**cls_args)
64+
65+
@app.exception_handler(Exception)
66+
async def env_error(_, exc: Exception) -> JSONResponse:
67+
# The trainer sees the environment's failure the way a local agent would raise it.
68+
return JSONResponse(status_code=500, content={"detail": repr(exc)})
69+
70+
if isinstance(env, RewardOnlyAgent):
71+
72+
@app.post("/prompts/")
73+
async def sample_prompt(request: RemotePromptRequest) -> RemotePrompt:
74+
prompt, golden = await env.get_prompt(validation=request.validation)
75+
return RemotePrompt(prompt=prompt, golden=golden)
76+
77+
@app.post("/episodes/")
78+
async def run_episode(request: RemoteEpisodeRequest) -> dict[str, Any]:
79+
group_request = GroupedRolloutRequest(
80+
num_groups=1,
81+
rollouts_per_group=1,
82+
inference_interface=policy,
83+
generation_args=request.generation_args,
84+
validation=request.validation,
85+
)
86+
params = env.group_rollout_params(
87+
group_request, prompt=request.prompt, golden=request.golden
88+
)
89+
rollout = await params.build_rollout(await params.run_episode())
90+
return rollout.model_dump()
91+
92+
if isinstance(env, ContrastiveRolloutGenerator):
5293

5394
@app.post("/contrastive_rollouts/")
5495
async def contrastive_rollouts(
5596
request: RemoteRolloutRequest,
5697
) -> list[ContrastiveRollout]:
57-
env = env_cls(**cls_args)
5898
return await env.get_contrastive_rollouts(_bind(request, RolloutRequest, policy))
5999

60-
if issubclass(env_cls, RolloutGenerator):
61-
62-
@app.post("/rollouts/")
63-
async def rollouts(request: RemoteRolloutRequest) -> list[TokenRollout]:
64-
env = env_cls(**cls_args)
65-
return await env.get_reward_rollouts(_bind(request, RolloutRequest, policy))
66-
67-
if issubclass(env_cls, EvaluationAgent):
100+
if isinstance(env, EvaluationAgent):
68101

69102
@app.post("/evaluation/")
70103
async def run_evaluation(request: RemoteEvaluationRequest):
71-
env = env_cls(**cls_args)
72104
return await env.run_evaluation(_bind(request, EvaluationRequest, policy))
73105

74106
return app
@@ -115,23 +147,39 @@ async def get_contrastive_rollouts(self, request: RolloutRequest) -> list[Contra
115147
result = await self._post("/contrastive_rollouts/", wire)
116148
return [ContrastiveRollout.model_validate(r) for r in result]
117149

118-
async def prepare_group_rollout(self, request: GroupedRolloutRequest) -> GroupRolloutParams:
119-
raise NotImplementedError(
120-
"FastAPIEnvServer overrides get_reward_rollouts; prepare_group_rollout is not used."
150+
async def prepare_group_rollout(
151+
self, request: GroupedRolloutRequest | RolloutRequest
152+
) -> GroupRolloutParams:
153+
sampled = RemotePrompt.model_validate(
154+
await self._post("/prompts/", RemotePromptRequest(validation=request.validation))
155+
)
156+
episode_request = RemoteEpisodeRequest(
157+
prompt=sampled.prompt,
158+
golden=sampled.golden,
159+
generation_args=request.generation_args,
160+
validation=request.validation,
121161
)
122162

163+
async def run_episode() -> Rollout | TokenRollout:
164+
# The env server scores the episode itself, so the episode is already the rollout.
165+
return _ROLLOUT.validate_python(await self._post("/episodes/", episode_request))
166+
167+
async def build_rollout(rollout: Rollout | TokenRollout) -> Rollout | TokenRollout:
168+
return rollout
169+
170+
return GroupRolloutParams(run_episode=run_episode, build_rollout=build_rollout)
171+
123172
async def get_rollout_response(self, request, inference_request):
124173
raise NotImplementedError(
125-
"FastAPIEnvServer overrides get_reward_rollouts/run_evaluation; "
126-
"get_rollout_response is not used."
174+
"Episodes run on the env server; FastAPIEnvServer never generates itself."
127175
)
128176

129-
async def get_reward_rollouts(self, request: RolloutRequest) -> list[TokenRollout]:
130-
wire = RemoteRolloutRequest.model_validate(
131-
request.model_dump(exclude={"inference_interface"})
132-
)
133-
result = await self._post("/rollouts/", wire)
134-
return [TokenRollout.model_validate(r) for r in result]
177+
async def get_reward_rollouts(self, request: RolloutRequest) -> list[Rollout | TokenRollout]:
178+
async def single_rollout() -> Rollout | TokenRollout:
179+
params = await self.prepare_group_rollout(request)
180+
return await params.build_rollout(await params.run_episode())
181+
182+
return list(await asyncio.gather(*[single_rollout() for _ in range(request.num_rollouts)]))
135183

136184
async def run_evaluation(self, request: EvaluationRequest) -> EvaluationResponse:
137185
wire = RemoteEvaluationRequest.model_validate(

‎megatron/rl/server/api.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22

3+
from typing import Any
4+
35
from pydantic import BaseModel, Field
46
from typing_extensions import Self, Type
57

68
from .. import TypeLookupable
9+
10+
from ..__init__ import Request
711
from ..agent.api import EvaluationRequest, RolloutRequest
8-
from ..inference import InferenceInterface
12+
from ..inference import InferenceInterface, LLMChatMessage
913

1014

1115
class Server(TypeLookupable):
@@ -37,8 +41,31 @@ class EnvironmentServer(Server):
3741
...
3842

3943

44+
class RemotePromptRequest(BaseModel):
45+
"""Trainer -> env server: sample the prompt one group of rollouts will share."""
46+
47+
validation: bool = False
48+
49+
50+
class RemotePrompt(BaseModel):
51+
"""Env server -> trainer: a sampled prompt and the golden data that scores it."""
52+
53+
prompt: str | list[LLMChatMessage]
54+
golden: Any = None
55+
56+
4057
### Intentionally force `inference_interface` to be `None` in these subclasses.
4158

59+
60+
class RemoteEpisodeRequest(Request):
61+
"""Trainer -> env server: run one episode on a sampled prompt and score it."""
62+
63+
prompt: str | list[LLMChatMessage]
64+
golden: Any = None
65+
validation: bool = False
66+
inference_interface: None = None
67+
68+
4269
class RemoteRolloutRequest(RolloutRequest):
4370
inference_interface: None = None
4471

0 commit comments

Comments
 (0)