Skip to content

[Example] Add retool tool-calling RL/SFT example (port slime → vime vLLM) - #382

Open
DiegoCao wants to merge 5 commits into
vllm-project:mainfrom
DiegoCao:examples/retool
Open

[Example] Add retool tool-calling RL/SFT example (port slime → vime vLLM)#382
DiegoCao wants to merge 5 commits into
vllm-project:mainfrom
DiegoCao:examples/retool

Conversation

@DiegoCao

Copy link
Copy Markdown

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 retool and search-r1 as SGLang-only after #39's sglang→vllm rename. Adding the directory back also un-dangles the examples/README.md link that has pointed at nothing since then.

The port

All the real work is the engine call in generate_with_retool.py:

SGLang (upstream) vime / vLLM
<router>/generate <router>/inference/v1/generate
{"input_ids", "sampling_params", "return_logprob": true} {"model", "token_ids", "sampling_params"}_build_inference_sampling_params emits logprobs: 1 and renames max_new_tokensmax_tokens
meta_info.output_token_logprobs choices[0].token_ids + choices[0].logprobs.content[i].logprob
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. #178 removed the shared _inference_generate_tokens_and_logprobs / _vllm_meta_from_generate_choice helpers from vime.rollout.vllm_rollout, and #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.

Three fixes on top of a straight port

1. Dropped --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 meant the example wrapped an already-templated prompt in its own user turn. Rendered against Qwen3-4B's real chat template, the model received:

<|im_start|>user<|im_start|>user
What is 12345 * 6789?<|im_end|>
<|im_start|>assistant
<|im_end|>
<|im_start|>assistant

Nested user turns 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.

Tests

tests/test_retool_generate.py — 51 tests, mocked engine, no GPU. Covers the request body (asserting SGLang's input_ids/return_logprob did not survive), 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 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 SEMAPHORE binds an alias that patching tool_sandbox.SEMAPHORE never 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.

rollout 0: response_len/mean 844.5  rollout_time 12.9s  truncated_ratio 0.0
rollout 1: response_len/mean 777.9  rollout_time 10.7s  truncated_ratio 0.0
rollout/rollout_log_probs -0.0430   rollout/raw_reward -0.3922
grad_norm 2.2928 / 2.8861          train exit=0

What this confirms about the port specifically:

  • The real /inference/v1/generate accepts the {model, token_ids, sampling_params}
    body and returns token_ids + logprobs.content[i].logprob in the shape
    _parse_vllm_choice expects.
  • rollout/rollout_log_probs = -0.043 is non-degenerate, so per-token logprobs
    genuinely flow through to training. Had the parse failed, this would be exactly
    0.0 (the zero-fill path) or the samples would have aborted.
  • The multi-turn tool loop runs end to end: the model emits a <tool_call>, the
    sandbox executes it, the <interpreter> block is appended as non-trainable
    tokens, and generation continues to Answer: \boxed{...}.
  • reward_func scores 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"}), but PythonSandbox runs code as a script rather
than 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. Making
the sandbox echo a trailing expression would change the training signal and
diverge from the published ReTool recipe, so it is left alone.

Known gaps

  • The SFT stage renders prompts with Qwen's official chat template (via 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-loss with --kl-loss-coef 0.00 is upstream's config and is preserved. Note placement_group.py creates the reference model on kl_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

@read-the-docs-community

read-the-docs-community Bot commented Aug 13, 2026

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread examples/retool/generate_with_retool.py Outdated
Comment on lines +118 to +119
# Replace newlines in string values with \n
json_str = json_str.replace("\n", "\\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment thread examples/retool/retool_qwen3_4b_sft.sh Outdated
--use-wandb
--wandb-project vime-dev
--wandb-group qwen3-4B-base-sft
--wandb-key ${WANDB_KEY}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
--wandb-key ${WANDB_KEY}
--wandb-key "${WANDB_KEY}"

Comment thread examples/retool/retool_qwen3_4b_rl.sh Outdated
--use-wandb
--wandb-project vime-dapo
--wandb-group qwen3-4B-test-multi-turn
--wandb-key ${WANDB_KEY}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
--wandb-key ${WANDB_KEY}
--wandb-key "${WANDB_KEY}"

try:
# Use subprocess to run code
process = subprocess.Popen(
["python3", script_path],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
["python3", script_path],
[sys.executable, script_path],

# Clean up temporary directory
try:
import shutil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Standard library modules like shutil (line 188) and sys (line 59) should be imported at the top of the file rather than inline inside functions or context managers, adhering to PEP 8 guidelines.

print(ds2[0])

# save to jsonl
ds2.to_json("/root/dapo-math-17k-processed/dapo_math_17k_cleaned.jsonl", orient="records", lines=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Comment on lines +9 to +17
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}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The convert_role function can be simplified by checking if the role is in a set of allowed roles, rather than using multiple if-elif branches that return the same value.

    def convert_role(role):
        if role not in {"user", "assistant", "system"}:
            raise ValueError(f"Unknown role: {role}")
        return role

Comment on lines +323 to +325
try:
import wandb

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Importing wandb inside the for turn loop on every iteration is inefficient and unidiomatic. We should import wandb once at the module level (or at the start of the function) using a try...except ImportError block, and then simply check if it is not None inside the loop.

sample.tokens = list(prompt_tokens_ids)
response = ""
response_token_ids = []
loss_masks = sample.loss_mask

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Comment thread examples/retool/README.md
Comment on lines +28 to +35
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The command hf download is used throughout the setup instructions, but the standard Hugging Face CLI command is huggingface-cli download. Unless hf is a pre-configured alias on the target system, users will encounter a command not found error. We should update these to use huggingface-cli download.

…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>
@DiegoCao DiegoCao mentioned this pull request Aug 13, 2026
14 tasks
DiegoCaoWork and others added 2 commits August 13, 2026 21:54
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>
@CalvinXKY

Copy link
Copy Markdown
Collaborator

For an example merge, the 2-rollout smoke is not enough, it only checks the vLLM contract and that one train step runs.
Please post a longer RL run with WandB (or equivalent) showing reward/eval converging (AIME is already wired at --eval-interval 20). Without that curve, we cannot tell whether the ported recipe actually trains.

@DiegoCao

Copy link
Copy Markdown
Author

@CalvinXKY Yes, we can add test trajectory beyond that.



class _FakeState:
def __init__(self, args):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need a test for it.

DiegoCaoWork and others added 2 commits August 14, 2026 18:13
`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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to add these, we port most of the code from slime

Comment thread examples/retool/README.md

## Notes on the vLLM port

This example was ported from slime's SGLang implementation. The rollout loop

@aoshen02 aoshen02 Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here as well. It is very clear that vime is a fork of slime

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants