# Adapted from https://github.com/volcengine/verl/blob/cb809d66e46dfd3342d008628891a14a054fa424/recipe/retool/retool.py
import re
from typing import Any
try:
from jinja2 import Template
except ImportError as e:
raise ImportError("Jinja2 is required. Please install it with: pip install jinja2") from e
from slime.rollout.sglang_rollout import GenerateState
from slime.utils.http_utils import post
from slime.utils.types import Sample
# Import reward models
try:
from slime.rollout.rm_hub.math_dapo_utils import compute_score as math_dapo_compute_score
except ImportError as e:
raise ImportError("MathDapo is not installed") from e
# Import tool sandbox functionality
from tool_sandbox import SEMAPHORE, TOOL_CONFIGS, tool_registry
# Jinja2 template for tool-enabled conversations
TOOL_TEMPLATE = """<|im_start|>system
{%- if messages[0]['role'] == 'system' %}
{{- messages[0]['content'] }}
{%- else %}
You are a helpful assistant.
{%- endif %}
{%- if tools %}
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within XML tags:
{%- for tool in tools %}
{{- tool | tojson }}
{%- endfor %}
For each function call, return a json object with function name and arguments within XML tags:
{"name": , "arguments": }
{%- endif %}
<|im_end|>
{%- for message in messages %}
{%- if message['role'] == 'user' %}
<|im_start|>user
{{- message['content'] }}<|im_end|>
{%- elif message['role'] == 'assistant' %}
<|im_start|>assistant
{{- message['content'] }}<|im_end|>
{%- endif %}
{%- endfor %}
<|im_start|>assistant
"""
def format_conversation_with_tools(
prompt: str, tools: list[dict[str, Any]] = None, system_prompt: str = None, messages: list[dict[str, Any]] = None
) -> str:
"""Format conversation using Jinja2 template with tool support"""
template = Template(TOOL_TEMPLATE)
# Prepare messages
messages_to_render = []
# Always add system message - use provided one or default
if system_prompt:
system_content = system_prompt
else:
system_content = (
"You are a helpful assistant that can use Python "
"tools to solve mathematical problems. When you need "
"to perform calculations, use the code_interpreter "
"tool to execute code and get results."
)
messages_to_render.append({"role": "system", "content": system_content})
# Add user message if provided
if prompt:
messages_to_render.append({"role": "user", "content": prompt})
# Add assistant responses from previous turns if provided
if messages:
messages_to_render.extend(messages)
# Render template
formatted_text = template.render(messages=messages_to_render, tools=tools or [])
return formatted_text
def postprocess_predictions(prediction: str):
"""Extract action and content from prediction string"""
# Check for Answer: \boxed{...} format (only format we need for math_dapo)
# Use a more robust regex that handles nested braces
answer_pattern = r"Answer:\s*\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}"
answer_match = re.search(answer_pattern, prediction, re.DOTALL)
if answer_match:
content = answer_match.group(1).strip()
return "answer", content
# Then check for tags (new format from Jinja2 template)
tool_call_pattern = r"\s*(\{.*?\})\s*"
tool_call_match = re.search(tool_call_pattern, prediction, re.DOTALL)
if tool_call_match:
try:
import json
# Clean up the JSON string by removing newlines and extra
# whitespace
json_str = tool_call_match.group(1)
# Replace newlines in string values with \n
json_str = json_str.replace("\n", "\\n")
tool_call_data = json.loads(json_str)
tool_name = tool_call_data.get("name")
arguments = tool_call_data.get("arguments", {})
if tool_name == "code_interpreter":
code = arguments.get("code", "")
if code.strip():
return "code", code
except (json.JSONDecodeError, KeyError, AttributeError):
pass
# Then check for tags
code_pattern = r"(.*?)"
code_match = re.search(code_pattern, prediction, re.DOTALL)
if code_match:
content = code_match.group(1).strip()
return "code", content
# Finally check for ```python code blocks (lowest priority)
python_code_pattern = r"```python\s*(.*?)\s*```"
python_code_match = re.search(python_code_pattern, prediction, re.DOTALL)
if python_code_match:
content = python_code_match.group(1).strip()
return "code", content
return None, ""
def postprocess_responses(resp: str) -> str:
"""Post-process response to ensure tag completeness"""
# Handle tags (new format from Jinja2 template)
if "" in resp:
# Find the last occurrence of ...
tool_call_pattern = r"\s*\{.*?\}\s*"
matches = list(re.finditer(tool_call_pattern, resp, re.DOTALL))
if matches:
last_match = matches[-1]
return resp[: last_match.end()]
# Handle tags
if "" in resp:
return resp.split("")[0] + ""
# Handle ```python code blocks
if "```python" in resp:
# Find the last occurrence of ```python...```
python_pattern = r"```python\s*.*?```"
matches = list(re.finditer(python_pattern, resp, re.DOTALL))
if matches:
last_match = matches[-1]
return resp[: last_match.end()]
# Handle Answer: \boxed{...} format (only format we need for math_dapo)
if "Answer:" in resp and "\\boxed{" in resp:
# Find the last occurrence of Answer: \boxed{...} with nested braces support
answer_pattern = r"Answer:\s*\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}"
matches = list(re.finditer(answer_pattern, resp, re.DOTALL))
if matches:
last_match = matches[-1]
return resp[: last_match.end()]
return resp
async def execute_predictions(prediction: str) -> str:
"""Execute predictions and return results"""
action, content = postprocess_predictions(prediction)
if action == "code":
# Content is already the Python code (extracted by
# postprocess_predictions)
code = content.strip()
if code:
async with SEMAPHORE:
result = await tool_registry.execute_tool("code_interpreter", {"code": code})
next_obs = f"\n\n\n{result}\n\n\n"
done = False
else:
next_obs = "\n\n\nError: No Python code found" "\n\n\n"
done = False
elif action == "answer":
next_obs = ""
done = True
else:
next_obs = (
"\nMy previous action is invalid. "
"If I want to execute code, I should put the code between "
" and . "
"If I want to give the final answer, I should use the format "
"'Answer: \\boxed{answer}'. Let me try again.\n"
)
done = False
return next_obs, done
async def generate(args, sample: Sample, sampling_params) -> Sample:
"""Custom generation function supporting tool calls"""
assert not args.partial_rollout, "Partial rollout is not supported for " "this function at the moment."
# Retried samples (previously aborted / partial) arrive here with stale
# rollout state from the first attempt. Clear it so this generation starts
# clean; otherwise the concatenation below appends new tokens to old ones
# and downstream `slice_log_prob_with_cp` sees a length mismatch.
sample.rollout_log_probs = None
sample.rollout_top_p_token_ids = None
sample.rollout_top_p_token_offsets = None
sample.response = ""
sample.response_length = 0
sample.loss_mask = []
state = GenerateState(args)
url = f"http://{args.sglang_router_ip}:{args.sglang_router_port}/generate"
# Set up the initial prompt with system prompt and tools (outside the loop)
tool_specs = tool_registry.get_tool_specs()
prompt = format_conversation_with_tools(prompt=sample.prompt, tools=tool_specs)
prompt_tokens_ids = state.tokenizer(prompt, add_special_tokens=False)["input_ids"]
sample.tokens = list(prompt_tokens_ids)
response = ""
response_token_ids = []
loss_masks = sample.loss_mask
tool_call_count = 0 # Track actual tool call rounds
if args.rollout_max_context_len is not None:
max_context_length = args.rollout_max_context_len
else:
max_context_length = args.context_parallel_size * args.max_tokens_per_gpu
for turn in range(TOOL_CONFIGS["max_turns"]):
# Check if total length exceeds max context length
total_length = len(prompt_tokens_ids) + len(response_token_ids)
if total_length >= max_context_length:
sample.status = Sample.Status.TRUNCATED
break
# Clamp per-turn max_new_tokens to the remaining context budget so a
# single turn cannot push total_length past max_context_length. Without
# this, a turn can append up to rollout_max_response_len tokens on top
# of a total that was just barely under the cap, producing samples
# that exceed the training-side max_tokens_per_gpu * cp_size budget
# and crash the partition/batch code (asserts or OOMs on an oversized
# partition).
remaining_budget = max_context_length - total_length
per_turn_sampling_params = dict(sampling_params)
per_turn_sampling_params["max_new_tokens"] = min(
sampling_params.get("max_new_tokens", remaining_budget),
remaining_budget,
)
# Use token IDs instead of text
current_token_ids = prompt_tokens_ids + response_token_ids
payload = {
"input_ids": current_token_ids,
"sampling_params": per_turn_sampling_params,
"return_logprob": True, # Request log probabilities for training
}
# Log payload to wandb for debugging
try:
import wandb
if wandb.run is not None:
# Count available tools (from tool_specs)
available_tools = len(tool_specs)
# Count tools used in the current response
tools_used = response.count("")
wandb.log(
{
"debug/payload_length": len(prompt + response),
"debug/available_tools": available_tools,
"debug/tools_used": tools_used,
"debug/turn": turn,
}
)
except ImportError:
pass # wandb not available
output = await post(url, payload)
# Handle abort
if output["meta_info"]["finish_reason"]["type"] == "abort":
sample.status = Sample.Status.ABORTED
return sample
if "output_token_logprobs" in output["meta_info"]:
cur_response_token_ids = [item[1] for item in output["meta_info"]["output_token_logprobs"]]
cur_response = state.tokenizer.decode(cur_response_token_ids)
cur_log_probs = [item[0] for item in output["meta_info"]["output_token_logprobs"]]
else:
# sglang returned text but no output_token_logprobs — we cannot
# recover per-token logprobs for this turn, which would desync
# rollout_log_probs from response_token_ids and blow up
# `slice_log_prob_with_cp` downstream. Abort the sample so the
# fully_async rollout manager returns the whole group to the
# buffer for retry instead of poisoning the trainer.
sample.status = Sample.Status.ABORTED
return sample
response += cur_response
response_token_ids += cur_response_token_ids
sample.append_response_tokens(
args,
tokens=cur_response_token_ids,
log_probs=cur_log_probs,
trainable=True,
meta_info=output["meta_info"],
)
# Check length limit
if output["meta_info"]["finish_reason"]["type"] == "length":
break
next_obs, done = await execute_predictions(cur_response)
if done:
break
# Count tool calls (when we get interpreter output, it means a tool
# was called)
if "" in next_obs:
tool_call_count += 1
assert next_obs != "", "Next observation should not be empty."
obs_tokens_ids = state.tokenizer(next_obs, add_special_tokens=False)["input_ids"]
overflow = len(prompt_tokens_ids) + len(response_token_ids) + len(obs_tokens_ids) - max_context_length
truncated_by_observation = overflow > 0
if truncated_by_observation:
obs_tokens_ids = obs_tokens_ids[: max(0, len(obs_tokens_ids) - overflow)]
# Add dummy log probs for observation tokens (they won't be used due to loss_mask=0)
# Check if maximum tool call count reached
response_token_ids += obs_tokens_ids
sample.append_response_tokens(args, tokens=obs_tokens_ids, trainable=False)
if sample.rollout_log_probs is not None:
assert len(response_token_ids) == len(
sample.rollout_log_probs
), f"Token/logp length mismatch at turn {turn}: {len(response_token_ids)} tokens vs {len(sample.rollout_log_probs)} logps"
# Tool output is appended verbatim and can push total_length past
# max_context_length (the per-turn generation was clamped to the
# remaining budget, but tool output is unconstrained). Trim tail
# tokens so the final sample fits the training budget exactly.
if truncated_by_observation:
# Resync the text field from the trimmed token list so
# reward_func's `sample.prompt + sample.response` matches what
# the model was actually trained on. decode(tokenize(text)) can
# be lossy on some tokenizers (whitespace / special-token
# collapse), but reward_func's regex is whitespace-robust and
# the trainer sees tokens, not text — so the drift is safe.
response = state.tokenizer.decode(response_token_ids)
sample.status = Sample.Status.TRUNCATED
break
response += next_obs
if tool_call_count >= TOOL_CONFIGS["max_tool_calls"]:
break
# Set sample attributes
sample.tokens = prompt_tokens_ids + response_token_ids
sample.response_length = len(response_token_ids)
sample.response = response
sample.loss_mask = loss_masks
# Store payload information for wandb logging
sample.payload_text = prompt + response
sample.payload_has_system = "<|im_start|>system" in prompt + response
sample.payload_has_tools = "# Tools" in prompt + response
# Store tool call count for reward calculation
sample.tool_call_count = tool_call_count
# Set status
if sample.status is Sample.Status.PENDING:
match output["meta_info"]["finish_reason"]["type"]:
case "length":
sample.status = Sample.Status.TRUNCATED
case "abort":
sample.status = Sample.Status.ABORTED
case "stop":
sample.status = Sample.Status.COMPLETED
return sample
async def reward_func(args, sample, **kwargs):
"""Tool call reward function using math_dapo as primary reward model"""
if not isinstance(sample, Sample):
raise TypeError("Sample must be an instance of Sample class.")
# Build complete solution string
solution_str = sample.prompt + sample.response
# Get ground truth answer - label is a string, not a dict
ground_truth = sample.label if sample.label is not None else ""
# Get tool call count as num_turns
num_turns = getattr(sample, "tool_call_count", 0)
# use \\boxed{...} answer
result = math_dapo_compute_score(solution_str, ground_truth, strict_box_verify=True)
# encourage model to call tools
if result["score"] < 0:
tool_call_reward = (num_turns - 2) / 2 * 0.1
result["score"] = min(-0.6, result["score"] + tool_call_reward)
if result["pred"] is None:
result["pred"] = ""
return result