[Example] Add retool tool-calling RL/SFT example (port slime → vime vLLM) - #382
[Example] Add retool tool-calling RL/SFT example (port slime → vime vLLM)#382DiegoCao wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the 'retool' example, porting tool-enabled language model generation from SFT to RL using vime's vLLM router. It includes custom generation and reward functions, a safe Python execution sandbox, data preprocessing scripts, training shell scripts, and comprehensive unit tests. The code review highlights several critical issues and improvement opportunities: potential JSON corruption from global newline replacement, argument parsing bugs in shell scripts when WANDB_KEY is unset, portability and cross-platform compatibility concerns (such as hardcoded interpreter paths, Unix-specific modules, and absolute file paths), security vulnerabilities in the regex-based sandbox checks, and potential file descriptor leaks. Additionally, the reviewer suggested code cleanups, such as avoiding inline imports, removing redundant variables, optimizing loop-level imports, and correcting CLI commands in the documentation.
| # Replace newlines in string values with \n | ||
| json_str = json_str.replace("\n", "\\n") |
There was a problem hiding this comment.
The global replacement of \n with \\n on json_str will corrupt any multi-line JSON structure (e.g., JSON with newlines between keys or pretty-printed JSON), making it invalid JSON and causing json.loads to fail. If the intent is to only escape literal newlines within string values, a global replace is unsafe. Since standard-conforming LLMs should output valid JSON with escaped newlines, we should avoid global replacement or only apply it defensively when direct parsing fails.
| --use-wandb | ||
| --wandb-project vime-dev | ||
| --wandb-group qwen3-4B-base-sft | ||
| --wandb-key ${WANDB_KEY} |
There was a problem hiding this comment.
If WANDB_KEY is empty or unset, the unquoted ${WANDB_KEY} will expand to nothing. This causes --wandb-key to consume the next argument in the command line as its value, corrupting the argument parsing and causing the script to fail. We should double-quote it as "${WANDB_KEY}" to ensure it expands to an empty string if unset.
| --wandb-key ${WANDB_KEY} | |
| --wandb-key "${WANDB_KEY}" |
| --use-wandb | ||
| --wandb-project vime-dapo | ||
| --wandb-group qwen3-4B-test-multi-turn | ||
| --wandb-key ${WANDB_KEY} |
There was a problem hiding this comment.
If WANDB_KEY is empty or unset, the unquoted ${WANDB_KEY} will expand to nothing. This causes --wandb-key to consume the next argument in the command line (e.g., from PERF_ARGS) as its value, corrupting the argument parsing and causing the script to fail. We should double-quote it as "${WANDB_KEY}" to ensure it expands to an empty string if unset.
| --wandb-key ${WANDB_KEY} | |
| --wandb-key "${WANDB_KEY}" |
| try: | ||
| # Use subprocess to run code | ||
| process = subprocess.Popen( | ||
| ["python3", script_path], |
There was a problem hiding this comment.
Using "python3" hardcodes the interpreter executable, which might not be available or might point to a different Python environment than the one running the main process (especially in virtual environments or different OS platforms). We should use sys.executable instead of "python3" to ensure the subprocess runs with the same Python interpreter.
| ["python3", script_path], | |
| [sys.executable, script_path], |
| # Clean up temporary directory | ||
| try: | ||
| import shutil | ||
|
|
| print(ds2[0]) | ||
|
|
||
| # save to jsonl | ||
| ds2.to_json("/root/dapo-math-17k-processed/dapo_math_17k_cleaned.jsonl", orient="records", lines=True) |
There was a problem hiding this comment.
The output path /root/dapo-math-17k-processed/dapo_math_17k_cleaned.jsonl is hardcoded to an absolute path in the /root directory. This makes the script non-portable and will fail on systems where the user is not root or /root is not writable/present. We should use a relative path (similar to sft_data_processing.py) or allow configuring the output path via an argument.
| def convert_role(role): | ||
| if role == "user": | ||
| return "user" | ||
| elif role == "assistant": | ||
| return "assistant" | ||
| elif role == "system": | ||
| return "system" | ||
| else: | ||
| raise ValueError(f"Unknown role: {role}") |
There was a problem hiding this comment.
| try: | ||
| import wandb | ||
|
|
There was a problem hiding this comment.
| sample.tokens = list(prompt_tokens_ids) | ||
| response = "" | ||
| response_token_ids = [] | ||
| loss_masks = sample.loss_mask |
There was a problem hiding this comment.
The local variable loss_masks is assigned to sample.loss_mask here and then re-assigned back to sample.loss_mask at line 426, but it is never modified or used anywhere else in the function. This is redundant and can lead to bugs if sample.loss_mask is re-assigned inside append_response_tokens. We should remove loss_masks and let append_response_tokens manage sample.loss_mask directly.
| hf download --repo-type dataset JoeYing/ReTool-SFT --local-dir /root/JoeYing/ReTool-SFT | ||
| hf download Qwen/Qwen3-4B-Instruct-2507 --local-dir /root/Qwen/Qwen3-4B-Instruct-2507 | ||
|
|
||
| # For RL part | ||
| hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir /root/dapo-math-17k | ||
| hf download --repo-type dataset zhuzilin/aime-2024 --local-dir /root/aime-2024 | ||
| # download our SFT model if you want to skip SFT | ||
| hf download font-info/qwen3-4b-sft-SGLang-RL --local-dir /root/font-info/qwen3-4b-sft |
There was a problem hiding this comment.
…vLLM) Re-adds `examples/retool`, the ReTool (tool-calling math RL, SFT -> RL) end-to-end example, ported from slime's SGLang implementation to vime's vLLM rollout path. vllm-project#74 removed it as SGLang-only; this brings it back on `/inference/v1/generate`. The port itself is confined to the engine call in `generate_with_retool.py`: SGLang vime / vLLM ------------------------------------ ------------------------------------- <router>/generate <router>/inference/v1/generate {"input_ids", "sampling_params", {"model", "token_ids", "return_logprob": true} "sampling_params"} (the builder emits logprobs:1 and renames max_new_tokens -> max_tokens) meta_info.output_token_logprobs choices[0].token_ids + choices[0].logprobs.content[i] meta_info.finish_reason.type choices[0].finish_reason (bare string, normalized to the nested shape) The choice parse is inlined as `_parse_vllm_choice` rather than imported: vllm-project#178 removed the shared `_inference_generate_tokens_and_logprobs` / `_vllm_meta_from_generate_choice` helpers from `vime.rollout.vllm_rollout`, and vllm-project#184 established that callers parse the choice locally (as `vllm_streaming_rollout` and `vime/agent/adapters/common.py` both do). `tool_sandbox.py`, `requirements.txt`, `rl_data_preprocess.py` and `sft_data_processing.py` are engine-agnostic and vendored unchanged. Shell scripts get the usual sglang->vllm treatment (pkill pattern, VLLM_ARGS, --vllm-gpu-memory-utilization, SCRIPT_DIR/REPO_ROOT, vime.rollout.sft_rollout, wandb/ckpt renames) plus `MODEL_ARGS_ROTARY_BASE=5000000` instead of a duplicate `--rotary-base`, and drops a dead `${EVAL_ARGS[@]}` the upstream SFT script expands but never defines. Adding the directory also un-dangles the `examples/README.md` link that has pointed at nothing since vllm-project#74. Three fixes on top of a straight port: 1. Drop `--apply-chat-template` from the RL script. `format_conversation_with_tools` renders the whole `<|im_start|>...<|im_end|>` conversation itself, so templating in the data loader too made the example wrap an already-templated prompt in its own `user` turn. Rendered against Qwen3-4B's real template, the model received nested `<|im_start|>user<|im_start|>user` plus a spurious empty assistant turn. 2. Take the tool-concurrency semaphore exactly once. `tool_sandbox.SEMAPHORE` is a plain, non-reentrant `asyncio.Semaphore`, and upstream acquires it in both `execute_predictions` and `ToolRegistry.execute_tool` -- two permits per tool call, which hangs the tool path (a single call self-deadlocks at `tool_concurrency == 1`). The limit now lives only in the registry, its natural owner. 3. Abort instead of zero-filling missing logprobs. `vllm_rollout.generate` substitutes `[0.0] * len(tokens)` when the engine reports no per-token logprobs. Here a missing or length-mismatched logprob array marks the sample ABORTED so the group returns to the buffer, rather than desyncing `rollout_log_probs` from the response tokens and silently corrupting the importance ratio. Adds `tests/test_retool_generate.py` (51 tests, mocked engine, no GPU) covering the request body, all five finish_reason mappings, the multi-turn tool loop, loss-mask/logprob alignment, context-budget clamping, observation-overflow truncation, stale-state reset on retry, prompt structure, prediction parsing, sandbox safety and reward shaping. Registered in the existing CPU CI step, with jinja2 added to that step's installs rather than relying on it arriving transitively via torch. Each of the three fixes above was mutation-tested: reintroducing the bug makes the corresponding tests fail, and the first version of the concurrency test did not (`from tool_sandbox import SEMAPHORE` binds an alias that patching `tool_sandbox.SEMAPHORE` never reaches), so it was rewritten around a counting semaphore installed on both names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Hangrui Cao <hangrui.cao@zoom.us>
60c993a to
c60d114
Compare
Three high-severity findings from the automated review on vllm-project#382, each verified before fixing: 1. `postprocess_predictions` escaped newlines unconditionally before `json.loads`. That rescues raw newlines *inside* the "code" string (invalid JSON), but corrupts pretty-printed tool calls, where the newlines sit *between* tokens -- a backslash there is a parse error. Confirmed: a pretty-printed `<tool_call>` failed to parse and silently degraded into the "My previous action is invalid" reprompt, wasting a turn. Now parses first and escapes only as a fallback, so both shapes work. 2/3. `--wandb-key ${WANDB_KEY}` was unquoted in both scripts. With WANDB_KEY unset the word disappears, so `--wandb-key` swallows the next flag as its value. Verified in bash: the array expands to `--use-wandb --wandb-key --tensor-model-parallel-size 2`. Quoting yields an empty-string argument instead. (The same latent issue exists in other examples, e.g. eval_multi_task and geo3k_vlm; left alone here.) Adds two tests pinning both JSON shapes. Mutation-tested: restoring the unconditional escape fails the pretty-printed test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Hangrui Cao <hangrui.cao@zoom.us>
Drops comments that restated the code they sat above or carried PR archaeology into the source: - `_parse_vllm_choice`: the vllm-project#178/vllm-project#184 rationale for inlining belongs in the PR, not the docstring. - The payload comment re-described `_build_inference_sampling_params`. - Shortened the three notes documenting deliberate divergence from upstream (semaphore, logprob abort, no --apply-chat-template) to the reason only; a future sync still needs the why, just not the walkthrough. - Dropped the justification for quoting ${WANDB_KEY} -- quoting is idiomatic. - Test docstrings that repeated the assertion beneath them. Upstream's own comments are left untouched to keep the vendored files diffable against slime. Net -33 comment lines; still 53 tests passing, and the three fixes remain mutation-guarded (reintroducing any of them fails its tests). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Hangrui Cao <hangrui.cao@zoom.us>
|
For an example merge, the 2-rollout smoke is not enough, it only checks the vLLM contract and that one train step runs. |
|
@CalvinXKY Yes, we can add test trajectory beyond that. |
|
|
||
|
|
||
| class _FakeState: | ||
| def __init__(self, args): |
There was a problem hiding this comment.
We don't need a test for it.
`test_concurrent_tool_calls_reach_the_configured_concurrency` asserted `peak == limit` while the tasks were held open only by `asyncio.sleep(0.01)`. On a loaded runner the first sleep can expire before the last task starts, so the peak lands below the limit and the test fails spuriously. It now gates on an `asyncio.Event` barrier: each call blocks inside the critical section until `limit` of them are in there together, which makes the peak an invariant rather than a scheduling race. The double-acquire bug still trips it -- only limit//2 callers fit, so the barrier can never fill and the wait times out. Also stops the rollout tests spawning real `python3` subprocesses. They exercise the turn loop, not the sandbox, and a subprocess per tool call made them slow and dependent on the runner (`test_generate_stops_at_max_tool_calls` alone spawned 16). An autouse fixture stubs the sandbox; two new tests cover real execution and real rejection explicitly, using a fresh PythonSandbox to sidestep the stub. All test phases now sum to 0.06s, down from ~0.6s. 55 tests, and both concurrency tests still fail if the double-acquire is reintroduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Hangrui Cao <hangrui.cao@zoom.us>
The bounded RL run crashed at the first AIME eval:
File "examples/retool/generate_with_retool.py", in reward_func
solution_str = sample.prompt + sample.response
TypeError: can only concatenate list (not "str") to list
Both datasets this example wires up store `prompt` as a list of chat messages,
not a string -- DAPO-Math-17k for training and AIME-2024 for eval -- and
`vime.utils.data._build_messages` passes a list through untouched when
`--apply-chat-template` is off.
This also corrects the earlier commit that dropped `--apply-chat-template`. That
removal was right about the symptom (the flag templates in the data loader and
the example then wraps the result in its own `user` turn, so the model sees
nested `<|im_start|>user<|im_start|>user`) but wrong about the remedy: with the
flag off, `sample.prompt` stays a list, which renders as its own repr in the
prompt and raises TypeError in the reward. The earlier verification used a
synthetic dataset whose `prompt` was a string, so neither failure showed up.
`split_prompt` now normalizes both shapes, feeding the user text to
`format_conversation_with_tools` and any system message through its existing
`system_prompt` parameter. The chat template is applied exactly once and
`reward_func` always gets a string, so the flag stays off.
Adds 5 tests over the real data shapes. Mutation-tested: reverting either call
site fails its test. The first version of the generate-side test did not catch
it -- passing the list to the template renders a repr that still contains the
question text -- so it now asserts the repr artifacts are absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Hangrui Cao <hangrui.cao@zoom.us>
| @@ -0,0 +1,485 @@ | |||
| # Adapted from https://github.com/volcengine/verl/blob/cb809d66e46dfd3342d008628891a14a054fa424/recipe/retool/retool.py | |||
| # Ported from slime's SGLang-based example to vime's vLLM ``/inference/v1/generate`` path. | |||
There was a problem hiding this comment.
No need to add these, we port most of the code from slime
|
|
||
| ## Notes on the vLLM port | ||
|
|
||
| This example was ported from slime's SGLang implementation. The rollout loop |
There was a problem hiding this comment.
And here as well. It is very clear that vime is a fork of slime
What
Re-adds
examples/retool— the ReTool tool-calling math RL example (SFT → RL) — ported from slime's SGLang implementation to vime's vLLM rollout path.This is a re-add, not a new example: #74 deleted
retoolandsearch-r1as SGLang-only after #39's sglang→vllm rename. Adding the directory back also un-dangles theexamples/README.mdlink that has pointed at nothing since then.The port
All the real work is the engine call in
generate_with_retool.py:<router>/generate<router>/inference/v1/generate{"input_ids", "sampling_params", "return_logprob": true}{"model", "token_ids", "sampling_params"}—_build_inference_sampling_paramsemitslogprobs: 1and renamesmax_new_tokens→max_tokensmeta_info.output_token_logprobschoices[0].token_ids+choices[0].logprobs.content[i].logprobmeta_info.finish_reason.typechoices[0].finish_reason(bare string, normalized to the nested shape)The choice parse is inlined as
_parse_vllm_choicerather than imported. #178 removed the shared_inference_generate_tokens_and_logprobs/_vllm_meta_from_generate_choicehelpers fromvime.rollout.vllm_rollout, and #184 established that callers parse the choice locally — asvllm_streaming_rolloutandvime/agent/adapters/common.pyboth do.tool_sandbox.py,requirements.txt,rl_data_preprocess.pyandsft_data_processing.pyare engine-agnostic and vendored unchanged.Three fixes on top of a straight port
1. Dropped
--apply-chat-templatefrom the RL script.format_conversation_with_toolsrenders the whole<|im_start|>…<|im_end|>conversation itself, so templating in the data loader too meant the example wrapped an already-templated prompt in its ownuserturn. Rendered against Qwen3-4B's real chat template, the model received:Nested
userturns plus a spurious empty assistant turn.2. Take the tool-concurrency semaphore exactly once.
tool_sandbox.SEMAPHOREis a plain, non-reentrantasyncio.Semaphore, and upstream acquires it in bothexecute_predictionsandToolRegistry.execute_tool— two permits per tool call, which hangs the tool path (a single call self-deadlocks attool_concurrency == 1). The limit now lives only in the registry, its natural owner.3. Abort instead of zero-filling missing logprobs.
vllm_rollout.generatesubstitutes[0.0] * len(tokens)when the engine reports no per-token logprobs. Here a missing or length-mismatched logprob array marks the sampleABORTEDso the group returns to the buffer, rather than desyncingrollout_log_probsfrom the response tokens and silently corrupting the importance ratio.Tests
tests/test_retool_generate.py— 51 tests, mocked engine, no GPU. Covers the request body (asserting SGLang'sinput_ids/return_logprobdid not survive), all fivefinish_reasonmappings, the multi-turn tool loop, loss-mask/logprob alignment, context-budget clamping, observation-overflow truncation, stale-state reset on retry, prompt structure, prediction parsing, sandbox safety and reward shaping.Registered in the existing CPU CI step, with
jinja2added to that step's installs rather than relying on it arriving transitively via torch.Each of the three fixes was mutation-tested — reintroducing the bug makes the corresponding tests fail. That mattered: the first version of the concurrency test passed even with the bug restored, because
from tool_sandbox import SEMAPHOREbinds an alias that patchingtool_sandbox.SEMAPHOREnever reaches. It was rewritten around a counting semaphore installed on both names.Validation
Unit tests above, plus an end-to-end smoke run on 8xH100 80GB (Qwen3-4B, GRPO,
2 rollouts x 8 prompts x 4 samples) against a live vLLM router — enough to
exercise the request/response contract and the tool loop, not a convergence run.
What this confirms about the port specifically:
/inference/v1/generateaccepts the{model, token_ids, sampling_params}body and returns
token_ids+logprobs.content[i].logprobin the shape_parse_vllm_choiceexpects.rollout/rollout_log_probs = -0.043is non-degenerate, so per-token logprobsgenuinely flow through to training. Had the parse failed, this would be exactly
0.0(the zero-fill path) or the samples would have aborted.<tool_call>, thesandbox executes it, the
<interpreter>block is appended as non-trainabletokens, and generation continues to
Answer: \boxed{...}.reward_funcscores the result and two optimizer steps complete.One observation worth recording, which is upstream behaviour and deliberately
not changed here: the model naturally emits bare expressions
(
{"code": "64345 + 3436"}), butPythonSandboxruns code as a script ratherthan a REPL, so an expression yields no stdout and the tool returns an empty
<interpreter>block. The model then falls back to its own arithmetic. Makingthe sandbox echo a trailing expression would change the training signal and
diverge from the published ReTool recipe, so it is left alone.
Known gaps
sft_rollout+--input-key messages), while the RL stage uses this example's own template, which Jinja renders without the trailing newline after<|im_start|>assistant. That SFT/RL prompt mismatch is upstream's design and is preserved here rather than silently changed.--use-kl-losswith--kl-loss-coef 0.00is upstream's config and is preserved. Noteplacement_group.pycreates the reference model onkl_coef != 0 or use_kl_loss, so the ref model is loaded and forward-passed while its KL term is multiplied by zero — dropping the flag would reclaim ~4 GB/GPU at no change to the loss.🤖 Generated with Claude Code