diff --git a/examples/retool/generate_with_retool.py b/examples/retool/generate_with_retool.py index f5b8ad268c2..6bd5d7de298 100644 --- a/examples/retool/generate_with_retool.py +++ b/examples/retool/generate_with_retool.py @@ -96,12 +96,11 @@ def format_conversation_with_tools( 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() + # Check for bare \boxed{...} (model may omit "Answer:" prefix) + boxed_pattern = r"\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}" + boxed_match = re.search(boxed_pattern, prediction, re.DOTALL) + if boxed_match: + content = boxed_match.group(1).strip() return "answer", content # Then check for tags (new format from Jinja2 template) @@ -168,14 +167,17 @@ def postprocess_responses(resp: str) -> str: 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()] + # Handle Answer: \boxed{...} or bare \boxed{...} + if "\\boxed{" in resp: + # Try "Answer: \boxed{...}" first, then bare "\boxed{...}" + for pattern in [ + r"Answer:\s*\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}", + r"\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}", + ]: + matches = list(re.finditer(pattern, resp, re.DOTALL)) + if matches: + last_match = matches[-1] + return resp[: last_match.end()] return resp @@ -203,7 +205,7 @@ async def execute_predictions(prediction: str) -> str: next_obs = ( "\nMy previous action is invalid. " "If I want to execute code, I should put the code between " - " and . " + " and . " "If I want to give the final answer, I should use the format " "'Answer: \\boxed{answer}'. Let me try again.\n" ) @@ -221,7 +223,12 @@ async def generate(args, sample: Sample, sampling_params) -> Sample: # 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) + + if isinstance(sample.prompt, str): + # Already formatted (e.g., by --apply-chat-template), use as-is to avoid double templating + prompt = sample.prompt + else: + prompt = format_conversation_with_tools(prompt=sample.prompt, tools=tool_specs) prompt_tokens_ids = state.tokenizer(prompt, add_special_tokens=False)["input_ids"] response = "" @@ -355,8 +362,7 @@ async def reward_func(args, sample, **kwargs): 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 + solution_str = sample.response # Get ground truth answer - label is a string, not a dict ground_truth = sample.label if sample.label is not None else "" diff --git a/examples/retool/retool_qwen3_4b_rl.sh b/examples/retool/retool_qwen3_4b_rl.sh index 838ce0e2c4c..99eeea3d3bc 100644 --- a/examples/retool/retool_qwen3_4b_rl.sh +++ b/examples/retool/retool_qwen3_4b_rl.sh @@ -31,7 +31,7 @@ CKPT_ARGS=( --ref-load /root/font-info/qwen3-4b-sft_torch_dist # --load /root/Qwen3-4B_miles/ --save /root/font-info/qwen3-4b-sft/qwen3-4b-sft-multi-turn/ - --save-interval 20 + --save-interval 200 --rotary-base 5000000 ) @@ -43,12 +43,12 @@ ROLLOUT_ARGS=( --rollout-shuffle --reward-key score --num-rollout 3000 - --rollout-batch-size 32 + --rollout-batch-size 16 --n-samples-per-prompt 8 --rollout-max-response-len 8192 --rollout-temperature 1 - --global-batch-size 256 + --global-batch-size 128 --balance-data ) @@ -98,8 +98,8 @@ OPTIMIZER_ARGS=( WANDB_ARGS=( --use-wandb - --wandb-project miles-dapo - --wandb-group qwen3-4B-test-multi-turn + --wandb-project miles-dev-retool-v2 + --wandb-group retool-v1-qwen3-4b-sft-new --wandb-key ${WANDB_KEY} ) @@ -117,6 +117,7 @@ MISC_ARGS=( --attention-softmax-in-fp32 # need to comment this when using model with MLA --attention-backend flash + --log-passrate ) CUSTOM_ARGS=( diff --git a/examples/retool_v2/README.md b/examples/retool_v2/README.md new file mode 100644 index 00000000000..1c9752c2b92 --- /dev/null +++ b/examples/retool_v2/README.md @@ -0,0 +1,31 @@ +# Retool v2 + +This example is an upgraded version of [retool](../retool), using the updated interfaces provided by the miles framework to implement multi-turn RL training with tool calls in a cleaner way. + +## Key Differences from v1 + +**v1 (retool)** requires manually implementing the full multi-turn conversation loop in `generate_with_retool.py`, directly depending on low-level `GenerateState` and `sglang_rollout` interfaces — resulting in verbose code tightly coupled to the framework internals. + +**v2 (retool_v2)** uses the framework's standard plugin interfaces. Users only need to implement three functions and mount them via command-line arguments: + +| Argument | Description | +|----------|-------------| +| `--custom-generate-function-path` | Uses the built-in `miles.rollout.generate_hub.multi_turn.generate` — no need to implement the multi-turn loop yourself | +| `--generate-tool-specs-path` | Declare tool definitions (user-implemented) | +| `--generate-execute-tool-function-path` | Implement tool execution logic (user-implemented) | +| `--custom-rm-path` | Implement the reward function (user-implemented) | + +Users only need to focus on business logic (tool definitions, tool execution, reward calculation). Multi-turn scheduling, token concatenation, loss masking, etc. are all handled by the framework. + +## Files + +- `tool_sandbox.py`: Tool definitions (`tool_specs`), tool execution (`execute_tool`), reward function (`reward_func`), and sandboxed safe execution environment +- `run_retool_multi_turn.py`: Training launch script + +## Quick Start + +```bash +python examples/retool_v2/run_retool_multi_turn.py +``` + +For data and model preparation, refer to the [retool v1 README](../retool/README.md). diff --git a/examples/retool_v2/run_retool_multi_turn.py b/examples/retool_v2/run_retool_multi_turn.py new file mode 100644 index 00000000000..1031eba7904 --- /dev/null +++ b/examples/retool_v2/run_retool_multi_turn.py @@ -0,0 +1,208 @@ +import os +from dataclasses import dataclass, field +from typing import Literal + +import typer + +import miles.utils.external_utils.command_utils as U + +WANDB_PROJECT = "miles-dev-retool-v2" +WANDB_GROUP = "sft-multi-turn-batch-32" + + +@dataclass +class ScriptArgs(U.ExecuteTrainConfig): + mode: Literal["normal", "debug_minimal"] = "normal" + run_id: str = field(default_factory=U.create_run_id) + hardware: Literal["H100", "GB200", "GB300"] = "H100" + num_gpus_per_node: int | None = None + use_sft_model: bool = True + save_path: str = "/root/Qwen3-4B_miles/retool_v2_multi_turn" + prompt_data: str = "/root/dapo-math-17k/dapo-math-17k.jsonl" + generate_max_turns: int = 16 + rollout_num_gpus_per_engine: int = 2 + extra_args: str = "" + + # resolved in __post_init__, not set by user + hf_checkpoint: str = field(init=False) + ref_load: str = field(init=False) + + def __post_init__(self): + self.num_gpus_per_node = self.num_gpus_per_node or U.NUM_GPUS_OF_HARDWARE[self.hardware] + if self.use_sft_model: + self.hf_checkpoint = "/root/font-info/qwen3-4b-sft" + self.ref_load = "/root/font-info/qwen3-4b-sft_torch_dist" + else: + self.hf_checkpoint = "/root/models/Qwen3-4B" + self.ref_load = "/root/models/Qwen3-4B_torch_dist" + + +def _get_wandb_args() -> str: + WANDB_API_KEY = os.environ.get("WANDB_API_KEY") + return ( + "--use-wandb " + f"--wandb-project {WANDB_PROJECT} " + f"--wandb-group {WANDB_GROUP} " + f"--wandb-key {WANDB_API_KEY} " + ) + + +def prepare(args: ScriptArgs): + U.exec_command("mkdir -p /root/dapo-math-17k /root/aime-2024") + U.exec_command("hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir /root/dapo-math-17k") + U.exec_command("hf download --repo-type dataset zhuzilin/aime-2024 --local-dir /root/aime-2024") + + if args.use_sft_model: + U.exec_command("mkdir -p /root/font-info") + U.exec_command(f"hf download font-info/qwen3-4b-sft-SGLang-RL --local-dir {args.hf_checkpoint}") + U.convert_checkpoint( + model_name="qwen3-4b-sft", + megatron_model_type="qwen3-4B", + num_gpus_per_node=args.num_gpus_per_node, + hf_checkpoint=args.hf_checkpoint, + dir_dst="/root/font-info", + ) + else: + U.exec_command("mkdir -p /root/models") + U.exec_command("hf download Qwen/Qwen3-4B --local-dir /root/models/Qwen3-4B") + U.convert_checkpoint( + model_name="Qwen3-4B", + megatron_model_type="qwen3-4B", + num_gpus_per_node=args.num_gpus_per_node, + dir_dst="/root/models", + ) + + +def execute(args: ScriptArgs): + megatron_model_type = "qwen3-4B" + + ckpt_args = ( + f"--hf-checkpoint {args.hf_checkpoint} " + f"--ref-load {args.ref_load} " + f"--save {args.save_path} " + f"--save-interval {2 if args.mode == 'debug_minimal' else 1000} " + f"{'--rotary-base 5000000 ' if args.use_sft_model else ''}" + ) + + custom_args = ( + "--custom-generate-function-path miles.rollout.generate_hub.multi_turn.generate " + "--generate-tool-specs-path examples.retool_v2.tool_sandbox.tool_specs " + "--generate-execute-tool-function-path examples.retool_v2.tool_sandbox.execute_tool " + "--generate-tool-call-parser qwen25 " + f"--generate-max-turns {args.generate_max_turns} " + "--log-multi-turn " + ) + + rollout_args = ( + f"--prompt-data {args.prompt_data} " + "--input-key prompt " + "--label-key label " + "--apply-chat-template " + "--rollout-shuffle " + "--custom-rm-path examples.retool_v2.tool_sandbox.reward_func " + "--reward-key score " + "--num-rollout 3000 " + "--rollout-batch-size 32 " + "--n-samples-per-prompt 8 " + f"--rollout-max-response-len {100 if args.mode == 'debug_minimal' else 8192} " + "--rollout-temperature 1 " + "--global-batch-size 256 " + "--balance-data " + ) + + eval_args = "" + if args.mode != "debug_minimal": + eval_args = ( + "--eval-interval 20 " + "--eval-prompt-data aime /root/aime-2024/aime-2024.jsonl " + "--n-samples-per-eval-prompt 16 " + "--eval-max-response-len 16384 " + "--eval-top-p 1 " + ) + + grpo_args = ( + "--advantage-estimator grpo " + "--use-kl-loss " + "--kl-loss-coef 0.00 " + "--kl-loss-type low_var_kl " + "--entropy-coef 0.00 " + "--eps-clip 0.2 " + "--eps-clip-high 0.28 " + ) + + optimizer_args = ( + "--optimizer adam " + "--lr 1e-6 " + "--lr-decay-style constant " + "--weight-decay 0.1 " + "--adam-beta1 0.9 " + "--adam-beta2 0.98 " + ) + + sglang_args = ( + f"--rollout-num-gpus-per-engine {args.rollout_num_gpus_per_engine} " "--sglang-mem-fraction-static 0.7 " + ) + + perf_args = ( + "--tensor-model-parallel-size 2 " + "--sequence-parallel " + "--pipeline-model-parallel-size 1 " + "--context-parallel-size 1 " + "--expert-model-parallel-size 1 " + "--expert-tensor-parallel-size 1 " + "--recompute-granularity full " + "--recompute-method uniform " + "--recompute-num-layers 1 " + "--use-dynamic-batch-size " + "--max-tokens-per-gpu 9216 " + ) + + misc_args = ( + f"--actor-num-nodes {args.num_nodes} " + f"--actor-num-gpus-per-node {args.num_gpus_per_node} " + "--colocate " + # default dropout in megatron is 0.1 + "--attention-dropout 0.0 " + "--hidden-dropout 0.0 " + # should be good for model performance + "--accumulate-allreduce-grads-in-fp32 " + "--attention-softmax-in-fp32 " + # need to comment this when using model with MLA + "--attention-backend flash " + "--log-passrate " + ) + + train_args = ( + f"{ckpt_args} " + f"{rollout_args} " + f"{optimizer_args} " + f"{grpo_args} " + f"{_get_wandb_args()} " + f"{perf_args} " + f"{eval_args} " + f"{sglang_args} " + f"{misc_args} " + f"{custom_args} " + f"{args.extra_args} " + ) + + U.execute_train( + train_args=train_args, + config=args, + num_gpus_per_node=args.num_gpus_per_node, + megatron_model_type=megatron_model_type, + extra_env_vars={ + "MILES_EXPERIMENTAL_ROLLOUT_REFACTOR": "1", + "PYTHONPATH": "/root/Megatron-LM/:/root/miles", + }, + ) + + +@U.dataclass_cli +def main(args: ScriptArgs): + prepare(args) + execute(args) + + +if __name__ == "__main__": + typer.run(main) diff --git a/examples/retool_v2/tool_sandbox.py b/examples/retool_v2/tool_sandbox.py new file mode 100644 index 00000000000..fc7a1dea450 --- /dev/null +++ b/examples/retool_v2/tool_sandbox.py @@ -0,0 +1,385 @@ +""" +copied from examples/retool/tool_sandbox.py +""" + +import asyncio +import gc +import os +import re +import subprocess +import tempfile +from contextlib import contextmanager +from typing import Any +import psutil + +from miles.rollout.rm_hub.math_dapo_utils import compute_score as math_dapo_compute_score +from miles.utils.types import Sample + +# Configuration for tool execution +TOOL_CONFIGS = { + "max_turns": 16, + "max_tool_calls": 16, + "tool_concurrency": 32, # Aggressive: 32 concurrent processes + # Python interpreter settings + "python_timeout": 120, # 2 minutes for complex calculations + "python_memory_limit": "4GB", # 4GB per Python process + "python_cpu_limit": 1, + # Memory management settings + "max_memory_usage": 12288, # 12GB total (75% of 16GB) + "cleanup_threshold": 6144, # 6GB + "aggressive_cleanup_threshold": 3072, # 3GB + "force_cleanup_threshold": 9216, # 9GB +} + +# Global semaphore for controlling concurrent tool executions +SEMAPHORE = asyncio.Semaphore(TOOL_CONFIGS["tool_concurrency"]) + + +def get_memory_usage() -> float: + """Get current memory usage in MB""" + process = psutil.Process() + return process.memory_info().rss / 1024 / 1024 + + +def cleanup_memory(): + """Force garbage collection to free memory""" + gc.collect() + + +def aggressive_cleanup_memory(): + """More aggressive memory cleanup""" + # Force multiple garbage collection cycles + for _ in range(3): + gc.collect() + + # Clear Python's internal caches + import sys + + # Note: sys.intern doesn't have a clear method, so we skip this + # Clear module cache if possible + if hasattr(sys, "modules"): + # Don't clear all modules, but clear some common ones that might cache data + modules_to_clear = ["numpy", "pandas", "matplotlib", "scipy"] + for module_name in modules_to_clear: + if module_name in sys.modules: + module = sys.modules[module_name] + if hasattr(module, "clear_cache"): + module.clear_cache() + + +def check_and_cleanup_memory(): + """Check memory usage and perform appropriate cleanup""" + current_memory = get_memory_usage() + + if current_memory > TOOL_CONFIGS["force_cleanup_threshold"]: + # Force aggressive cleanup + aggressive_cleanup_memory() + return f"Warning: High memory usage ({current_memory:.1f}MB), performed aggressive cleanup" + elif current_memory > TOOL_CONFIGS["cleanup_threshold"]: + # Normal cleanup + cleanup_memory() + return f"Info: Memory usage ({current_memory:.1f}MB), performed cleanup" + elif current_memory > TOOL_CONFIGS["aggressive_cleanup_threshold"]: + # Light cleanup + gc.collect() + return f"Info: Memory usage ({current_memory:.1f}MB), performed light cleanup" + + return None + + +class PythonSandbox: + """Python code sandbox, provides safe code execution environment""" + + def __init__(self, timeout: int = 10, memory_limit: str = "100MB"): + self.timeout = timeout + self.memory_limit = memory_limit + self.allowed_modules = { + "math", + "random", + "datetime", + "collections", + "itertools", + "functools", + "operator", + "statistics", + "decimal", + "fractions", + } + + def _check_code_safety(self, code: str) -> tuple[bool, str]: + """Check code safety by scanning for dangerous patterns""" + # Check for dangerous operations + dangerous_patterns = [ + r"import\s+os", + r"import\s+sys", + r"import\s+subprocess", + r"import\s+shutil", + r"import\s+glob", + r"import\s+pathlib", + r"__import__", + r"eval\s*\(", + r"exec\s*\(", + r"open\s*\(", + r"file\s*\(", + r"input\s*\(", + r"raw_input\s*\(", + r"compile\s*\(", + r"execfile\s*\(", + r"getattr\s*\(", + r"setattr\s*\(", + r"delattr\s*\(", + r"hasattr\s*\(", + r"globals\s*\(", + r"locals\s*\(", + r"vars\s*\(", + r"dir\s*\(", + r"type\s*\(", + r"isinstance\s*\(", + r"issubclass\s*\(", + r"super\s*\(", + r"property\s*\(", + r"staticmethod\s*\(", + r"classmethod\s*\(", + r"__\w+__", # double underscore methods + ] + + for pattern in dangerous_patterns: + if re.search(pattern, code, re.IGNORECASE): + return False, f"Code contains dangerous pattern: {pattern}" + + # Check imported modules + import_pattern = r"import\s+(\w+)" + from_pattern = r"from\s+(\w+)" + + imports = re.findall(import_pattern, code) + froms = re.findall(from_pattern, code) + + all_imports = set(imports + froms) + for imp in all_imports: + if imp not in self.allowed_modules: + return False, f"Import of '{imp}' is not allowed" + + return True, "Code is safe" + + @contextmanager + def _create_safe_environment(self): + """Create safe execution environment with temporary directory""" + # Create temporary directory + temp_dir = tempfile.mkdtemp(prefix="python_sandbox_") + + try: + # Create safe Python script + script_path = os.path.join(temp_dir, "code.py") + + # Set environment variables + env = os.environ.copy() + env["PYTHONPATH"] = temp_dir + env["PYTHONUNBUFFERED"] = "1" + + yield script_path, env, temp_dir + + finally: + # Clean up temporary directory + try: + import shutil + + shutil.rmtree(temp_dir) + except Exception: + pass + + async def execute_code(self, code: str) -> str: + """Execute Python code in sandbox with safety checks""" + # Check memory usage before execution + current_memory = get_memory_usage() + if current_memory > TOOL_CONFIGS["max_memory_usage"]: + aggressive_cleanup_memory() + return "Error: Memory usage too high, please try again" + + # Check code safety + is_safe, message = self._check_code_safety(code) + if not is_safe: + return f"Error: {message}" + + # Add necessary wrapper code with memory limits + # Properly indent the user code within the try block + # Handle indentation properly by adding 4 spaces to each line + indented_code = "\n".join(" " + line for line in code.split("\n")) + + wrapped_code = f"""import sys +import traceback +from io import StringIO +import resource + +# Set memory limit (4GB) +try: + resource.setrlimit(resource.RLIMIT_AS, (4 * 1024 * 1024 * 1024, -1)) +except Exception: + pass + +# Redirect stdout and stderr +old_stdout = sys.stdout +old_stderr = sys.stderr +stdout_capture = StringIO() +stderr_capture = StringIO() +sys.stdout = stdout_capture +sys.stderr = stderr_capture + +try: + # User code +{indented_code} + + # Get output + stdout_output = stdout_capture.getvalue() + stderr_output = stderr_capture.getvalue() + + # Restore standard output + sys.stdout = old_stdout + sys.stderr = old_stderr + + # Return result + result = "" + if stdout_output: + result += f"Output:\\n{{stdout_output}}" + if stderr_output: + result += f"\\nErrors:\\n{{stderr_output}}" + + print(result) + +except Exception as e: + # Restore standard output + sys.stdout = old_stdout + sys.stderr = old_stderr + + # Return error information + error_msg = f"Error: {{str(e)}}\\nTraceback:\\n{{traceback.format_exc()}}" + print(error_msg)""" + + with self._create_safe_environment() as (script_path, env, temp_dir): + # Write code to file + with open(script_path, "w") as f: + f.write(wrapped_code) + + try: + # Use subprocess to run code + process = subprocess.Popen( + ["python3", script_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + cwd=temp_dir, + text=True, + ) + + # Set timeout + try: + stdout, stderr = process.communicate(timeout=self.timeout) + + if process.returncode == 0: + result = stdout.strip() + else: + result = f"Error: Process exited with code {process.returncode}\n{stderr}" + + except subprocess.TimeoutExpired: + process.kill() + result = f"Error: Code execution timed out after {self.timeout} seconds" + + except Exception as e: + result = f"Error: Failed to execute code: {str(e)}" + + # Check memory usage after execution and cleanup if needed + cleanup_message = check_and_cleanup_memory() + if cleanup_message: + print(f"Memory cleanup: {cleanup_message}") + + return result + + +class ToolRegistry: + """Tool registry, manages available tools and their execution""" + + def __init__(self): + self.tools = {} + self.python_sandbox = PythonSandbox( + timeout=TOOL_CONFIGS["python_timeout"], memory_limit=TOOL_CONFIGS["python_memory_limit"] + ) + self._register_default_tools() + + def _register_default_tools(self): + """Register default tools in the registry""" + # Python code interpreter + self.register_tool( + "code_interpreter", + { + "type": "function", + "function": { + "name": "code_interpreter", + "description": "A tool for executing Python code in a safe sandbox environment.", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string", "description": "The Python code to execute"}}, + "required": ["code"], + }, + }, + }, + ) + + def register_tool(self, name: str, tool_spec: dict[str, Any]): + """Register a new tool in the registry""" + self.tools[name] = tool_spec + + def get_tool_specs(self) -> list[dict[str, Any]]: + """Get all tool specifications as a list""" + return list(self.tools.values()) + + async def execute_tool(self, tool_name: str, arguments: dict[str, Any]) -> str: + """Execute a tool call with the given arguments""" + if tool_name not in self.tools: + return f"Error: Tool '{tool_name}' not found" + + async with SEMAPHORE: + if tool_name == "code_interpreter": + return await self._execute_python(arguments) + else: + return f"Error: Tool '{tool_name}' not implemented" + + async def _execute_python(self, arguments: dict[str, Any]) -> str: + """Execute Python code using the sandbox""" + code = arguments.get("code", "") + if isinstance(code, list): + code = "\n".join(str(item) for item in code) + if not code.strip(): + return "Error: No code provided" + + # Execute code in sandbox + result = await self.python_sandbox.execute_code(code) + return result + + +tool_registry = ToolRegistry() + +tool_specs = tool_registry.get_tool_specs() + + +async def execute_tool(name: str, params: dict) -> str: + return await tool_registry.execute_tool(name, params) + + +# Reward function that encourages tool usage +async def reward_func(args, sample: Sample, **kwargs): + """Tool call reward function using math_dapo, with bonus for tool usage.""" + solution_str = sample.prompt + sample.response if isinstance(sample.prompt, str) else sample.response + ground_truth = sample.label if sample.label is not None else "" + tool_call_count = sample.metadata.get("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 = tool_call_count / 2 * 0.1 + result["score"] = min(-0.6, result["score"] + tool_call_reward) + + if result["pred"] is None: + result["pred"] = "" + + return result