# 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