Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

967 lines
44 KiB
Python

# Copyright (c) ModelScope Contributors. All rights reserved.
# Multi-turn Rollout Schedulers for GRPO training.
import asyncio
import json
from abc import ABC
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from swift.infer_engine.protocol import (ChatCompletionResponse, ChatCompletionResponseChoice, RequestConfig,
RolloutInferRequest, RolloutOutput)
from swift.template import Messages
from swift.utils import remove_response
from .gym_env import Env, envs
if TYPE_CHECKING:
# Imported only for type hints; importing it at runtime pulls in vllm, which would make
# `swift.rollout` (and thus GRPO trainer init) hard-require vllm even when use_vllm=False.
from swift.infer_engine import GRPOVllmEngine
class RolloutScheduler(ABC):
# Single Turn Rollout Scheduler
def __init__(self,
infer_engine: Optional['GRPOVllmEngine'] = None,
max_turns: Optional[int] = None,
*args,
**kwargs):
self.infer_engine = infer_engine
# Tokenizer can be passed explicitly (e.g., in colocate mode where infer_engine may be None)
self._tokenizer = kwargs.get('tokenizer', None)
self.max_turns = max_turns
# ------------------------------------------------------------------
# Universal hooks — called by BOTH ``run()`` (server mode) and
# ``run_multi_turn()`` (colocate mode). Override these to inject
# environment lifecycle logic (e.g. gym env.reset / env.step) without
# overriding the full ``run()`` method.
#
# Hooks are async so that gym environments (whose reset/step are async)
# can be awaited directly. In server mode ``run()`` awaits them natively;
# in colocate mode ``run_multi_turn()`` drives them via a dedicated loop.
# ------------------------------------------------------------------
async def on_trajectory_start(self, requests: List['RolloutInferRequest']) -> None:
"""Called before the first inference turn to initialize per-trajectory state.
Mutate ``requests`` in place (e.g. inject env initial observation).
Default: no-op.
"""
pass
async def on_turn_end(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> Dict[str, Any]:
"""Called after assistant message is appended, before ``check_finished``.
Use this to advance environment state (e.g. ``env.step``) and surface
per-turn metadata.
Returns:
Dict with optional keys:
- 'done' (bool): if present, overrides ``check_finished`` result
- 'rollout_infos' (dict): merged into the trajectory's accumulated infos
Default: empty dict (no-op).
"""
return {}
async def async_infer(self,
infer_requests: List[Union['RolloutInferRequest', Dict[str, Any]]],
request_config: 'RequestConfig',
*,
use_tqdm: Optional[bool] = None,
**kwargs) -> List['RolloutOutput']:
"""
Perform asynchronous batched inference for multiple rollout requests.
This method serves as the main entry point for multi-round training inference.
It executes the `run` method for each inference request concurrently and
aggregates the results into a single flattened list.
Each inference request can be either a `RolloutInferRequest` instance or a
dictionary that can be converted into one. The results from all requests are
collected asynchronously using the underlying inference engine.
Args:
infer_requests (List[Union[RolloutInferRequest, Dict[str, Any]]]):
A list of inference requests. Each request can be either:
- A `RolloutInferRequest` object.
- A dictionary containing the fields required to initialize a
`RolloutInferRequest`.
request_config (RequestConfig):
Configuration object specifying inference settings. Must satisfy
`request_config.n == 1`, as only single-response generation is supported.
use_tqdm (Optional[bool], optional):
Whether to display a progress bar during batch inference.
If `None`, it defaults to `True` when there are multiple requests,
otherwise `False`.
**kwargs:
Additional arguments forwarded to the underlying `run` method.
Returns:
List[RolloutOutput]:
A list of RolloutOutput objects corresponding to the provided inference requests.
Raises:
AssertionError:
If `request_config.n` is not equal to `1`.
Notes:
- Internally, this method converts dict-based requests into
`RolloutInferRequest` instances.
- Uses `infer_engine._batch_infer_stream` to perform concurrent execution.
- The returned list is guaranteed to be flattened, even if individual
tasks return lists of responses.
"""
assert request_config.n == 1
async def _infer_async_single(infer_request: Union['RolloutInferRequest', Dict[str, Any]],
request_config: 'RequestConfig', **kwargs):
if isinstance(infer_request, Dict):
infer_request = RolloutInferRequest(**infer_request)
return await self.run(infer_request, request_config, **kwargs)
tasks = [_infer_async_single(infer_request, request_config, **kwargs) for infer_request in infer_requests]
if use_tqdm is None:
use_tqdm = len(infer_requests) > 1
# Execute all tasks and flatten the results
results = await self.infer_engine._batch_infer_stream(tasks, request_config.stream, use_tqdm, None)
# Flatten the results since each task may return a list
flattened_results = []
for result in results:
if isinstance(result, list):
flattened_results.extend(result)
else:
flattened_results.append(result)
return flattened_results
async def run(self, infer_request: 'RolloutInferRequest', request_config: 'RequestConfig',
**kwargs) -> 'RolloutOutput':
response: 'ChatCompletionResponse' = await self.infer_engine.infer_async(infer_request, request_config,
**kwargs)
response_token_ids = response.choices[0].token_ids
response_loss_mask = [1] * len(response_token_ids)
return RolloutOutput(
response=response,
messages=infer_request.messages,
response_token_ids=[response_token_ids],
response_loss_mask=[response_loss_mask],
rollout_infos={'num_turns': 1})
def __getattr__(self, key: str):
try:
return object.__getattribute__(self, key)
except AttributeError:
pass
try:
infer_engine = object.__getattribute__(self, 'infer_engine')
if hasattr(infer_engine, key):
return getattr(infer_engine, key)
if hasattr(infer_engine.engine, key):
return getattr(infer_engine.engine, key)
except AttributeError:
raise AttributeError(f'{type(self).__name__} object has no attribute {key}')
@property
def engine(self):
return self.infer_engine
@property
def tokenizer(self):
"""Get tokenizer, prioritizing explicitly passed tokenizer over infer_engine's tokenizer."""
if self._tokenizer is not None:
return self._tokenizer
if self.infer_engine is not None:
return self.infer_engine.tokenizer
return None
class MultiTurnScheduler(RolloutScheduler, ABC):
"""
Abstract base class for multi-turn rollout scheduling.
Provides default implementation for multi-turn conversation management with two customization approaches:
1. FULL CUSTOMIZATION:
Override the `run()` method to implement completely custom multi-turn logic.
- Gives full control over the rollout process
- Must handle all turn management and termination logic
2. PARTIAL CUSTOMIZATION:
Implement the required `step()` method and optionally override `check_finished()`
- Uses MultiTurnScheduler's run() method infrastructure
- Only need to implement turn transition logic in step()
- Optionally customize termination conditions
Note: You must implement at least one of these approaches in your subclass.
Options:
- If each round's response_token_ids are included in the RolloutOutput,
the Trainer can skip encoding the completion text into token_ids when calculating loss.
This avoids potential training inconsistencies due to asymmetric encode/decode behavior.
See: https://github.com/0russwest0/Agent-R1/issues/30#issuecomment-2826155367
- If both response_token_ids and response_loss_mask are returned in the RolloutOutput,
you can manually control the loss mask for each token.
The Trainer will use the provided loss_mask values directly when computing the loss.
Note: Returning response_loss_mask requires that response_token_ids are also returned,
as the two must be aligned in length for correct loss computation.
You can refer to MathTipsScheduler as an example of how to use response_token_ids and response_loss_mask.
Loss mask configuration:
During rollout, some parts of the completion (e.g., environment observations embedded in completion)
may need to be masked out from loss computation.
There are two supported strategies:
1. Use the built-in `loss_scale` parameter in ms-swift and do not return response token ids.
2. Return response_token_ids along with a corresponding response_loss_mask (of equal length) to indicate the loss mask for each token. # noqa
"""
async def run(self, infer_request: 'RolloutInferRequest', request_config: 'RequestConfig',
**kwargs) -> Union['RolloutOutput', List['RolloutOutput']]:
"""Execute multi-turn conversation rollout with built-in turn management logic.
This implements the default multi-turn interaction flow that can be overridden
to customize conversation handling behavior. The default logic provides:
1. Automatic conversation turn management and stopping conditions
2. Seamless message accumulation across multiple turns
3. Response token tracking and loss mask management
4. Configurable early stopping mechanisms
Args:
infer_request: The initial inference request containing conversation messages
request_config: Configuration parameters for the inference request
**kwargs: Additional inference parameters passed to the engine
Returns:
RolloutOutput containing the complete conversation history and metadata,
or a list of outputs for batched requests
Customization Approaches:
- Override check_finished() to implement custom stopping criteria
- Override step() to customize turn-to-turn transition logic
- Override this entire run() method for completely custom multi-turn behavior
Important Notes:
- Method overriding is only supported when using server mode (swift rollout)
with vllm_use_async_engine=True
- Custom implementations must maintain async/await compatibility
- Ensure proper handling of conversation state across turns
Example:
class CustomScheduler(MultiTurnScheduler):
async def run(self, infer_request, request_config, **kwargs):
# Implement custom multi-turn conversation logic
# Must return RolloutOutput or List[RolloutOutput]
...
"""
current_request = infer_request
await self.on_trajectory_start([current_request])
current_turn = 1
rollout_infos = {}
total_response_ids = []
total_response_loss_mask = []
total_rollout_logprobs = []
while True:
messages = current_request.messages
if current_turn == 1:
# If it's the first turn, remove the response
# Keep the original logic, but I think this step is unnecessary here.
remove_response(messages)
# Get model response
response: 'ChatCompletionResponse' = await self.infer_engine.infer_async(
current_request, request_config, **kwargs)
response_choice: 'ChatCompletionResponseChoice' = response.choices[0]
if current_turn > 1 and not messages[-1]['content']:
# The dummy assistant message was intentionally kept during `infer_async`
# to ensure correct history processing by the template.
# It is now removed before appending the new completion.
# otherwise, a syntax error would occur when executing messages[-1]['content'] += completion.
remove_response(messages)
# Update conversation history
completion = response_choice.message.content
is_continuation = False
if messages[-1]['role'] == 'assistant':
messages[-1]['content'] += completion
is_continuation = True
else:
messages.append({'role': 'assistant', 'content': completion})
# Check stopping conditions
turn_result = await self.on_turn_end(current_request, response_choice, current_turn)
if turn_result.get('rollout_infos'):
rollout_infos.update(turn_result['rollout_infos'])
should_stop = self.check_finished(current_request, response_choice, current_turn)
if 'done' in turn_result:
should_stop = turn_result['done']
# double-check if user forget to judge the max_turns
if self.max_turns:
should_stop = should_stop or (current_turn >= self.max_turns)
if should_stop:
# Collect final turn's data
current_logprobs = self._extract_logprobs_from_choice(response_choice)
final_token_ids = response_choice.token_ids
if is_continuation and total_response_ids:
# For continuation, extend the last turn's data
total_response_ids[-1].extend(final_token_ids)
if total_response_loss_mask:
total_response_loss_mask[-1].extend([1] * len(final_token_ids))
if total_rollout_logprobs and current_logprobs:
total_rollout_logprobs[-1].extend(current_logprobs)
elif not total_response_ids:
# First turn stopped immediately - need to initialize with final response data
if final_token_ids:
total_response_ids = [list(final_token_ids)]
total_response_loss_mask = [[1] * len(final_token_ids)]
if current_logprobs:
total_rollout_logprobs = [current_logprobs]
# Validate rollout_logprobs completeness: if logprobs are incomplete (missing for some turns),
# clear them to disable rollout importance sampling correction (which requires complete logprobs)
# Note: rollout_logprobs should match the number of loss_mask=1 tokens, not total response tokens
# because completion_mask in grpo_trainer is based on labels != -100, which corresponds to loss_mask=1
final_rollout_logprobs = total_rollout_logprobs
if total_rollout_logprobs:
total_logprob_count = sum(len(turn_lps) for turn_lps in total_rollout_logprobs)
if total_response_loss_mask:
# Check if the number of logprobs matches the number of loss_mask=1 tokens
total_loss_mask_1_count = sum(sum(mask) for mask in total_response_loss_mask)
if total_loss_mask_1_count != total_logprob_count:
# Incomplete logprobs, clear them
final_rollout_logprobs = []
else:
if total_response_ids:
total_response_id_count = sum(len(turn_ids) for turn_ids in total_response_ids)
if total_response_id_count != total_logprob_count:
final_rollout_logprobs = []
else:
final_rollout_logprobs = []
return RolloutOutput(
response=response,
messages=messages,
response_token_ids=total_response_ids,
response_loss_mask=total_response_loss_mask,
rollout_infos={
**rollout_infos, 'num_turns': current_turn
},
rollout_logprobs=final_rollout_logprobs,
)
# Prepare next turn
ret = self.step(current_request, response_choice, current_turn)
current_request: 'RolloutInferRequest' = ret['infer_request']
# Track response tokens and masks
return_token_id = False
if 'response_token_ids' in ret:
if is_continuation and total_response_ids:
total_response_ids[-1].extend(ret['response_token_ids'])
else:
total_response_ids.append(ret['response_token_ids'])
return_token_id = True
if 'response_loss_mask' in ret:
assert return_token_id, 'You must return response_token_ids if you want to return response_loss_mask'
assert len(ret['response_loss_mask']) == len(ret['response_token_ids']), \
'response_loss_mask must have the same length as response_token_ids'
if is_continuation and total_response_loss_mask:
total_response_loss_mask[-1].extend(ret['response_loss_mask'])
else:
total_response_loss_mask.append(ret['response_loss_mask'])
if 'rollout_infos' in ret:
# Always overwrite the rollout info for this step.
# If you need to keep all step-wise details, switch to append or merge instead.
rollout_infos.update(ret['rollout_infos'])
# Track rollout_logprobs for rollout importance sampling correction
# Prefer step's returned logprobs (which may be modified/truncated) over raw response_choice logprobs
if 'rollout_logprobs' in ret and ret['rollout_logprobs']:
current_logprobs = ret['rollout_logprobs']
else:
current_logprobs = self._extract_logprobs_from_choice(response_choice)
if current_logprobs:
if is_continuation and total_rollout_logprobs:
total_rollout_logprobs[-1].extend(current_logprobs)
else:
total_rollout_logprobs.append(current_logprobs)
current_turn += 1
def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> Dict:
"""
Handles transition between conversation turns.
Args:
infer_request: Current inference request
response_choice: Response from current turn
current_turn: Current turn number
Returns:
Dict[str, Any]: A dictionary containing inference results with the following structure:
- infer_request (required): Main inference request object
- response_token_ids (Optional[List[int]]): Token IDs of response for current rollout turn
- response_loss_mask (Optional[List[int]]): Loss mask for response tokens (same length as response_token_ids) # noqa
- rollout_logprobs (Optional[List[float]]): Log probabilities for response tokens.
If not provided, will be extracted from response_choice.logprobs as fallback.
Useful when modifying response content (e.g., adding prompts) to avoid logprob misalignment.
- rollout_infos (Optional[Dict[str, Any]]): Additional metadata (must be serializable)
"""
raise NotImplementedError(
'Please implement the `step` method in your MultiTurnScheduler subclass, or override the `run` method.')
def check_finished(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> bool:
"""
Default termination logic for checking if a multi-turn rollout should end.
This method is invoked by:
- The base class MultiTurnScheduler.run() method, OR
- Custom run() methods when explicitly called
Note: This is the default implementation that can be overridden by subclasses for custom termination logic.
Termination Conditions:
1. When response hits length limit (finish_reason == 'length')
2. When conversation reaches max_turns (if max_turns is set)
Args:
infer_request: The inference request object
response_choice: Contains generation results including finish_reason
current_turn: Current conversation turn count
Returns:
bool: True to terminate conversation, False to continue
"""
if response_choice.finish_reason == 'length':
return True
if self.max_turns and current_turn >= self.max_turns:
return True
return False
@staticmethod
def _extract_logprobs_from_choice(response_choice: 'ChatCompletionResponseChoice') -> List[float]:
"""Extract logprobs list from response choice for rollout importance sampling.
Args:
response_choice: The response choice containing logprobs
Returns:
List of logprob values, or empty list if not available
"""
if response_choice.logprobs is None:
return []
if 'content' in response_choice.logprobs:
return [item['logprob'] for item in response_choice.logprobs['content']]
return []
class ThinkingModelTipsScheduler(MultiTurnScheduler):
"""
Scheduler for multi-turn reasoning with Thinking class models.
Key Features:
1. Parses both "think" and "answer" content from each assistant response.
2. For each round, only the "think" content from the last round is retained in the message history.
3. Each round's conversation history is processed independently.
4. Returns a list of RolloutOutput objects, one for each round.
5. Please set `--loss_scale last_round` for training last round response.
The scheduler will automatically inject a tip prompt if the answer is incorrect, encouraging the model to recheck its reasoning. # noqa
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
acc_func = kwargs.get('acc_function', None)
if acc_func is None:
from swift.rewards.orm import MathAccuracy
acc_func = MathAccuracy()
self.acc_func = acc_func
self.tips_prompt = 'The answer is not correct, It seems You made a mistake, you need to recheck very carefully.'
async def run(self, infer_request: 'RolloutInferRequest', request_config: 'RequestConfig',
**kwargs) -> List['RolloutOutput']:
"""
Execute multi-turn inference for Thinking models.
Args:
infer_request (RolloutInferRequest): The initial inference request containing the conversation history.
request_config (RequestConfig): Configuration for the inference request.
**kwargs: Additional arguments for the inference engine.
Returns:
List[RolloutOutput]: A list of RolloutOutput objects, one for each reasoning round.
"""
current_request = infer_request
current_turn = 1
rollout_outputs = []
while True:
messages = current_request.messages
# Obtain model response for the current turn
response: 'ChatCompletionResponse' = await self.infer_engine.infer_async(
current_request, request_config, **kwargs)
response_choice: 'ChatCompletionResponseChoice' = response.choices[0]
completion = response_choice.message.content
# Append the assistant's response to the message history
messages.append({'role': 'assistant', 'content': completion})
# Construct the message history for this round, keeping only the last "think" content
messages_with_last_think = self._build_messages(messages)
# Create a RolloutOutput for the current round
round_output = RolloutOutput(
response=response,
messages=messages_with_last_think,
response_token_ids=response_choice.token_ids,
rollout_infos={'num_turns': current_turn})
# Store the output for this round
rollout_outputs.append(round_output)
# Determine whether to stop the multi-turn reasoning
should_stop = self.check_finished(current_request, response_choice, current_turn)
if should_stop:
break
# Prepare for the next turn by updating the inference request
ret = self.step(current_request, response_choice, current_turn)
current_request: 'RolloutInferRequest' = ret['infer_request']
current_turn += 1
return rollout_outputs
def check_finished(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> bool:
last_query = infer_request.messages[-2]['content']
# tips once
if self.tips_prompt in last_query:
return True
completion = response_choice.message.content
solution = infer_request.data_dict['solution']
acc = self.acc_func([completion], [solution])[0]
if acc == 1:
return True
return super().check_finished(infer_request, response_choice, current_turn)
def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> Dict:
infer_request.messages.append({'role': 'user', 'content': self.tips_prompt})
return {'infer_request': infer_request}
def _is_thinking_template(self) -> bool:
if not hasattr(self.infer_engine, 'template'):
return False
template = self.infer_engine.template
return template.template_meta.is_thinking
def _build_messages(self, original_messages: Messages) -> Messages:
"""
Build history for a specific round, keeping only the think content from the last round.
Args:
original_messages: Original conversation messages
Returns:
Messages: History for this specific round
"""
from copy import deepcopy
# If this is a thinking template, use the template's method to prepare messages
if self._is_thinking_template():
# Create a mock inputs object to use the template's _swift_prepare_inputs method
class MockInputs:
def __init__(self, messages):
self.messages = deepcopy(messages)
mock_inputs = MockInputs(original_messages)
# Set up the template for inference mode
template = self.infer_engine.template
# _swift_prepare_inputs will remove historical thinking content when in train mode, patch the mode here
original_mode = template.mode
template.mode = 'train'
# Use the template's method to prepare messages
template._swift_prepare_inputs(mock_inputs)
# Restore original mode
template.mode = original_mode
return mock_inputs.messages
else:
# Fallback to manual processing for non-thinking templates
round_messages = []
# Process messages in original order
for i, msg in enumerate(original_messages):
if msg['role'] == 'assistant' and isinstance(msg['content'], str) and i != len(original_messages) - 1:
# For assistant messages
assistant_no_think = msg['content'].split('</think>')[-1].strip()
round_messages.append(assistant_no_think)
else:
round_messages.append(deepcopy(msg))
return round_messages
class MathTipsScheduler(MultiTurnScheduler):
tips_prompt = 'But wait... It seems I made a mistake,'
def __init__(self, *args, **kwargs):
from swift.rewards.orm import MathAccuracy
super().__init__(*args, **kwargs)
self.acc_func = kwargs.get('acc_function', MathAccuracy())
# Cache the tokenized tips_prompt length for loss mask computation
self._tips_token_ids = None
def _get_tips_token_ids(self, tokenizer) -> List[int]:
"""Get tokenized tips_prompt (cached for efficiency)."""
if self._tips_token_ids is None:
# Tokenize without special tokens to get the raw token ids
self._tips_token_ids = tokenizer.encode(self.tips_prompt, add_special_tokens=False)
return self._tips_token_ids
def check_finished(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> bool:
last_completion = infer_request.messages[-1]['content']
# we only give tips once
if self.tips_prompt in last_completion:
return True
solution = infer_request.data_dict['solution']
acc = self.acc_func([last_completion], [solution])[0]
if acc == 1:
return True
return super().check_finished(infer_request, response_choice, current_turn)
def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> Dict:
completion = response_choice.message.content
response_token_ids = list(response_choice.token_ids) if response_choice.token_ids else []
# Extract logprobs from response_choice before any truncation
rollout_logprobs = self._extract_logprobs_from_choice(response_choice)
# Truncate completion at <answer> or </think> tags
truncate_idx = len(completion)
if '<answer>' in completion:
truncate_idx = min(truncate_idx, completion.index('<answer>'))
if '</think>' in completion:
truncate_idx = min(truncate_idx, completion.index('</think>'))
if truncate_idx < len(completion):
# Need to truncate token_ids and logprobs as well
truncated_completion = completion[:truncate_idx]
if response_token_ids and self.tokenizer is not None:
# Find the token index corresponding to the truncation point
# by decoding progressively until we reach or exceed the truncation point
token_truncate_idx = len(response_token_ids)
for i in range(1, len(response_token_ids) + 1):
decoded = self.tokenizer.decode(response_token_ids[:i], skip_special_tokens=False)
if len(decoded) >= truncate_idx:
token_truncate_idx = i
break
response_token_ids = response_token_ids[:token_truncate_idx]
# Truncate logprobs to match
if rollout_logprobs:
rollout_logprobs = rollout_logprobs[:token_truncate_idx]
completion = truncated_completion
# Add tips_prompt
completion += self.tips_prompt
# Compute loss_mask for tips tokens
# Note: rollout_logprobs should NOT include tips tokens because:
# 1. Tips tokens have loss_mask=0, so their labels will be -100
# 2. completion_mask = (labels != -100), so tips tokens won't be in completion_mask
# 3. rollout_logprobs must align with completion_mask, not response_token_ids
if response_token_ids and self.tokenizer is not None:
tips_token_ids = self._get_tips_token_ids(self.tokenizer)
# Loss mask: original tokens = 1, tips tokens = 0
response_loss_mask = [1] * len(response_token_ids) + [0] * len(tips_token_ids)
# Append tips token ids to response
response_token_ids = response_token_ids + tips_token_ids
# Do NOT extend rollout_logprobs for tips tokens - they are masked out in completion_mask
else:
response_loss_mask = []
# Update messages
if infer_request.messages[-1]['role'] == 'assistant':
if not infer_request.messages[-1]['content']:
# Multi-turn continuation: pop the dummy input we add in last turn
infer_request.messages.pop(-1)
infer_request.messages[-1]['content'] = completion
else:
infer_request.messages.append({'role': 'assistant', 'content': completion})
result = {'infer_request': infer_request}
if response_token_ids:
result['response_token_ids'] = response_token_ids
result['response_loss_mask'] = response_loss_mask
if rollout_logprobs:
result['rollout_logprobs'] = rollout_logprobs
return result
class GYMScheduler(MultiTurnScheduler):
"""Gym environment-driven scheduler using universal hooks.
Implements ``on_trajectory_start`` (env.reset) and ``on_turn_end`` (env.step)
to integrate gym environments into the multi-turn protocol. Works in both
server mode (``run()``) and colocate mode (``run_multi_turn()``).
"""
def __init__(self, infer_engine: Optional['GRPOVllmEngine'] = None, max_turns: Optional[int] = None, **kwargs):
super().__init__(infer_engine, max_turns, **kwargs)
self.gym_env_name = kwargs.get('gym_env', None)
# Per-trajectory state (keyed by uuid)
self._envs: Dict[str, Env] = {}
self._total_rewards: Dict[str, float] = {}
self._step_rewards: Dict[str, List[float]] = {}
self._pending_obs: Dict[str, Optional[str]] = {}
async def _close_and_remove(self, uuid: str) -> None:
"""Close env for a given uuid and remove all associated state."""
env = self._envs.pop(uuid, None)
if env is not None:
try:
await env.close()
except Exception:
pass
self._total_rewards.pop(uuid, None)
self._step_rewards.pop(uuid, None)
self._pending_obs.pop(uuid, None)
# ------------------------------------------------------------------
# Universal async hooks (called by both run() and run_multi_turn())
# ------------------------------------------------------------------
async def on_trajectory_start(self, requests: List['RolloutInferRequest']) -> None:
"""Create one env per request and seed messages with initial observation."""
async def _init_single(req: 'RolloutInferRequest') -> None:
uuid = req.uuid
if uuid in self._envs:
await self._close_and_remove(uuid)
env_config = (req.data_dict or {}).get('env_config', {}) if hasattr(req, 'data_dict') else {}
env = self._create_env(env_config)
observation, info, system_message = await env.reset(req)
messages: Messages = []
if system_message:
messages.append({'role': 'system', 'content': system_message})
messages.append({'role': 'user', 'content': observation})
req.messages = messages
self._envs[uuid] = env
self._total_rewards[uuid] = 0.0
self._step_rewards[uuid] = []
self._pending_obs[uuid] = None
await asyncio.gather(*[_init_single(req) for req in requests])
async def on_turn_end(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> Dict[str, Any]:
"""Advance the gym env, accumulate reward, and return done + rollout_infos."""
uuid = infer_request.uuid
env = self._envs.get(uuid)
if env is None:
return {'done': True, 'rollout_infos': {}}
next_obs, reward, done, info = await env.step(deepcopy(infer_request.messages))
self._total_rewards[uuid] = self._total_rewards.get(uuid, 0.0) + float(reward)
self._step_rewards.setdefault(uuid, []).append(float(reward))
self._pending_obs[uuid] = None if done else next_obs
rollout_infos: Dict[str, Any] = {
'total_reward': self._total_rewards[uuid],
'step_rewards': list(self._step_rewards.get(uuid, [])),
'gym_done': done,
}
if done:
await self._close_and_remove(uuid)
return {'done': done, 'rollout_infos': rollout_infos}
# ------------------------------------------------------------------
# Step hook (injects next observation for the next turn)
# ------------------------------------------------------------------
def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> Dict[str, Any]:
uuid = infer_request.uuid
next_obs = self._pending_obs.get(uuid)
if next_obs is not None:
infer_request.messages.append({'role': 'user', 'content': next_obs})
self._pending_obs[uuid] = None
return {'infer_request': infer_request}
# ------------------------------------------------------------------
# Env helpers
# ------------------------------------------------------------------
def _create_env(self, env_config: Dict) -> Env:
env_name = env_config.get('name', self.gym_env_name)
if env_name not in envs:
raise ValueError(f"Environment '{env_name}' not found. Available: {list(envs.keys())}")
return envs[env_name](env_config)
class OpenEnvScheduler(GYMScheduler):
"""GYMScheduler specialised for OpenEnv environments.
Unlike GYMScheduler which uses async ``Env`` instances, OpenEnvScheduler
uses :class:`OpenEnvWrapper` whose ``reset()`` / ``step()`` / ``close()``
are **synchronous** (blocking WebSocket I/O). Subclasses that override
``on_trajectory_start`` / ``on_turn_end`` should wrap sync wrapper calls
with ``asyncio.to_thread()`` to avoid blocking the event loop.
Action parsing (LLM text → dict) and observation formatting (dict → str)
are handled by overridable :meth:`parse_action` and :meth:`format_observation`
methods, eliminating the need for ``openenv_*`` command-line parameters.
All OpenEnv configuration (``base_url``, ``system_message``, ``reset_kwargs`` …)
comes from the dataset's per-row ``env_config``.
"""
def _create_env(self, env_config: Dict) -> Any:
"""Create an :class:`OpenEnvWrapper` (not an ``Env`` subclass)."""
from .openenv_wrapper import OpenEnvWrapper
return OpenEnvWrapper(env_config)
async def _close_and_remove(self, uuid: str) -> None:
"""Close wrapper for a given uuid and remove all associated state.
Wrapper.close() is synchronous; use ``asyncio.to_thread`` to avoid
blocking the event loop.
"""
import asyncio
wrapper = self._envs.pop(uuid, None)
if wrapper is not None:
try:
await asyncio.to_thread(wrapper.close)
except Exception:
pass
self._total_rewards.pop(uuid, None)
self._step_rewards.pop(uuid, None)
self._pending_obs.pop(uuid, None)
async def on_trajectory_start(self, requests: List['RolloutInferRequest']) -> None:
"""Create one wrapper per request, call ``reset()``, seed messages.
Uses a semaphore to limit concurrent environment creations (default 4)
to avoid overwhelming the OpenEnv server with simultaneous WebSocket connections.
"""
semaphore = asyncio.Semaphore(getattr(self, 'max_concurrent_envs', 4))
async def _init_single(req: 'RolloutInferRequest') -> None:
async with semaphore:
uuid = req.uuid
if uuid in self._envs:
await self._close_and_remove(uuid)
row_env_config = (req.data_dict or {}).get('env_config', {}) if hasattr(req, 'data_dict') else {}
env_config = {**getattr(self, 'env_config_defaults', {}), **row_env_config}
wrapper = self._create_env(env_config)
obs, metadata = wrapper.reset()
system_message = env_config.get('system_message', '')
messages: Messages = []
if system_message:
messages.append({'role': 'system', 'content': system_message})
messages.append({'role': 'user', 'content': self.format_observation(obs)})
req.messages = messages
self._envs[uuid] = wrapper
self._total_rewards[uuid] = 0.0
self._step_rewards[uuid] = []
self._pending_obs[uuid] = None
await asyncio.gather(*[_init_single(req) for req in requests])
async def on_turn_end(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
current_turn: int) -> Dict[str, Any]:
"""Parse LLM response, call ``wrapper.step()``, accumulate reward."""
uuid = infer_request.uuid
wrapper = self._envs.get(uuid)
if wrapper is None:
return {'done': True, 'rollout_infos': {}}
action_text = response_choice.message.content
action_dict = self.parse_action(action_text)
obs, reward, done, metadata = wrapper.step(action_dict)
self._total_rewards[uuid] = self._total_rewards.get(uuid, 0.0) + float(reward)
self._step_rewards.setdefault(uuid, []).append(float(reward))
next_obs = None if done else self.format_observation(obs)
self._pending_obs[uuid] = next_obs
rollout_infos: Dict[str, Any] = {
'total_reward': self._total_rewards[uuid],
'step_rewards': list(self._step_rewards.get(uuid, [])),
'gym_done': done,
}
if done:
await self._close_and_remove(uuid)
return {'done': done, 'rollout_infos': rollout_infos}
def parse_action(self, text: str) -> Dict[str, Any]:
"""Parse LLM response text into an OpenEnv action dict.
Default: try ``json.loads``, fall back to ``{"message": text}``.
"""
text = text.strip()
# Strip markdown code blocks (e.g. ```json ... ```)
if text.startswith('```'):
lines = text.splitlines()
if len(lines) >= 2 and lines[0].startswith('```') and lines[-1].strip().startswith('```'):
text = '\n'.join(lines[1:-1]).strip()
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
return {'message': str(parsed)}
except (json.JSONDecodeError, ValueError):
return {'message': text}
def format_observation(self, observation: Any) -> str:
"""Format OpenEnv observation into a string for the LLM.
Default: ``json.dumps``. Override in subclasses for environment-specific
formatting (e.g. extract a ``"question"`` field).
"""
try:
return json.dumps(observation, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return str(observation)
multi_turns = {
'math_tip_trick': MathTipsScheduler,
'gym_scheduler': GYMScheduler,
'openenv_scheduler': OpenEnvScheduler,
'thinking_tips_scheduler': ThinkingModelTipsScheduler,
}