Skip to content

fix(rollout-gateway): parse tool calls with model-native response schemas, auto-detected from the chat template - #97

Merged
luyuzhe111 merged 7 commits into
mainfrom
verl_integ_refactor
Aug 4, 2026
Merged

fix(rollout-gateway): parse tool calls with model-native response schemas, auto-detected from the chat template#97
luyuzhe111 merged 7 commits into
mainfrom
verl_integ_refactor

Conversation

@luyuzhe111

@luyuzhe111 luyuzhe111 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The issue

The gateway's default derender parsed tool calls with a regex for one specific XML format (<tool_call><function=...>). Qwen3 emits JSON tool calls (<tool_call>{"name": ..., "arguments": ...}</tool_call>), so the regex never matched — and the failure was invisible three times over:

  1. parse_tool_uses hardcoded ill_formed=False, so no flag was raised;
  2. the raw <tool_call> block flowed back to the agent as plain text, so Strands saw a turn with no tool calls and simply ended the conversation;
  3. GSM8K reward only reads the final #### <answer>, and Qwen3-4B can solve GSM8K mentally — so the previous "validated" run reached ~0.93 val reward while never firing a single tool call. Every trajectory was single-turn; the multi-turn concatenation and tool loss-mask paths (the gateway's core purpose) had never actually been exercised.

The engine_kwargs.vllm.tool_call_parser=hermes flags in the run script were no-ops: verl's token-in/token-out channel never touches vLLM's chat-completions layer where that parser lives.

The fix

Make the default correct instead of adding configuration:

  1. Schema-based derender
  • New rollout_gateway/response_schemas.py: response-schema dicts vendored from huggingface/trl (Apache-2.0, baseline 7073af94, see NOTICE) covering Qwen2.5 / Qwen3 / Qwen3-Instruct-2507 / Qwen3-VL (JSON <tool_call>), Qwen3.5 / Qwen3.6 / Nemotron-3 (XML <function=>), GLM4-MoE, and GPT-OSS — plus a sha256(chat_template) → schema table.
  • HfTemplateRenderer hashes the tokenizer's chat template at construction; on a match it derenders the whole output in one tokenizer.parse_response(text, schema=...) pass — reasoning, text, and tool calls together, ordered by the schema's anchored regex (a literal <tool_call> inside a think block cannot corrupt the split). A schema is pure data; the parsing engine ships in transformers (>= 5.0).
  • Detection is byte-exact and loud: fine-tuned checkpoints inherit the chat template verbatim and keep resolving; an unrecognized template with tools in play now raises instead of silently guessing (the old regex remains available by explicit injection, tool_parser=parse_tool_uses). Malformed model output degrades in place to raw text with ill_formed=True — flagged, never a 500, never partial extraction.
  • Explicitly injected stage parsers disable detection entirely, so the slime backend's SGLang-detector path is unchanged.
  • The verl integration needed zero changes: gateway_host's existing bare HfTemplateRenderer(tokenizer) construction now does the right thing.
  1. Session-key contract cleanup

The agent previously derived its gateway session key from the ACR runtime session id (context.session_id). The trainer now passes it explicitly as _rollout.api_key (it still equals the ACR runtimeSessionId, so already-deployed agent images keep working), and rl_app.py reads it from the payload — removing a dead assignment left by the #91/#93 merge and keeping the agent free of any inference-infrastructure awareness.

Validation

  • 46 gateway + 45 verl-backend tests (the schema paths are exercised against transformers' real parsing engine; a new always-on test_template_hashes_live.py verifies every vendored template hash against the actual hub templates — one repo per registered hash, incl. both Qwen3.5 think/nothink variants). Real-tokenizer probes: Qwen3-4B-Instruct-2507 auto-detects qwen3, Qwen3.6-35B-A3B auto-detects qwen3_5, both parse their native formats correctly.
  • E2E GSM8K GRPO run (stock verl.trainer.main_ppo, Qwen3-4B-Instruct-2507 full-FT on 8 GPUs, live AgentCore Runtime rollouts):
  • Tool calls now fire: step-0 validation num_turns mean 2.20 / max 5 (verl counts the user message, so num_turns=2 = one LLM turn with no tool call; >2 = tool loop). The previous run was uniformly 2 — no tool call ever — so this is the first real exercise of multi-turn concatenation and tool loss-mask boundaries.
  • Zero ill_formed turns, zero parse errors across all rollouts (~70k over two full epochs).
  • Step-0 val reward 0.585, reaching ~0.92 in one epoch. Note: training num_turns rises early (mean ~3.1 by step 6) then collapses back to 2.0 by step ~16 — the policy learns to drop the calculator, since the answer-only GSM8K reward gives tool use no advantage for a model that solves GSM8K mentally. That is a task/reward-design property, orthogonal to this fix; the parsing fix is what made those early tool turns possible (and trainable) at all.
  • agent.num_workers=2 also validated: two AgentLoopWorkers, each with its own gateway on an auto-assigned port, identical metrics.
Screenshot 2026-08-01 at 9 59 04 PM Screenshot 2026-08-01 at 10 42 36 PM

Also in this PR

  • Run script: removed the two no-op vLLM tool-parser flags (with a comment on where parsing actually happens).
  • gateway extra: transformers floor >=4.44>=5.0 (tokenizer.parse_response); jmespath declared (schema transforms). verl-experimental extras: transformers pinned 5.12.1 (compatible with the pinned verl 78bba31d and vllm 0.23.0).
  • NOTICE: TRL attribution. AGENTS.md: replaced a stale output_parser
    paragraph describing a never-implemented seam; added the TRL row to the
    vendoring/re-sync table.
  • Review cleanups: render() requests bare token ids (return_dict=False) instead of unwrapping a BatchEncoding; llama3 schema dropped (not a fine-tuning target); tautological renderer/schema tests removed in favor of golden-path + degradation-contract coverage.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

…onse, auto-detected from the chat template

The default derender's XML tool-call regex silently missed Qwen3's JSON
<tool_call> format: zero matches, ill_formed=False hardcoded, the agent saw a
plain-text turn and stopped — the validated GSM8K run was single-turn/no-tool
without any signal. Fix the default instead of adding config:

- response_schemas.py: response-schema dicts vendored from huggingface/trl
  (Apache-2.0, baseline 7073af94; see NOTICE) for qwen3 (JSON <tool_call>:
  Qwen2.5/Qwen3/Instruct-2507/VL), qwen3_5 (XML <function=>: Qwen3.5/3.6,
  Nemotron-3), llama3, glm4moe, gptoss — plus a sha256(chat_template) ->
  schema table for auto-detection. Byte-exact matching: fine-tunes inherit
  the template and keep resolving; a revised template stops matching loudly
  rather than misparsing silently.
- HfTemplateRenderer: when the tokenizer's template hash matches, derender the
  whole output in one tokenizer.parse_response pass (reasoning + text + tool
  calls, ordered by the schema's anchored regex, so a literal <tool_call>
  inside a think block cannot corrupt the split). Parse failures and
  structurally invalid tool calls degrade in place to raw text with
  ill_formed=True — flagged, never a 500, never partial extraction.
- No implicit tool parser anymore: with no matched schema and no injected
  tool_parser, a tools-bearing parse raises instead of guessing with the
  one-format regex (parsing.parse_tool_uses remains available by explicit
  injection). Injected stage parsers disable detection entirely — explicit
  wins (slime's SGLang detectors path is unchanged).
- Drop the two no-op vLLM parser flags from the GSM8K script: they configure
  vLLM's chat layer, which the token-in/token-out path never touches.
- gateway extra: transformers floor 4.44 -> 5.0 (tokenizer.parse_response and
  the schema engine shipped in 5.0.0); declare jmespath (schema transforms).

The verl integration needs zero changes: gateway_host's bare
HfTemplateRenderer(tokenizer) construction now does the right thing for every
covered model family.
…i_key

The agent previously derived its gateway session key from the ACR runtime
session id (context.session_id). Make the trainer supply it explicitly instead:
AgentCoreAgentLoop passes api_key=sid through RolloutClient's _rollout
overrides, and the math agent reads payload['_rollout'].get('api_key') — the
key is trainer-supplied configuration like base_url/model_id, not something
the agent infers from its runtime. This also removes rl_app.py's dead
assignment left by the #91/#93 merge (the payload-derived api_key was
immediately overwritten by context.session_id).

Backward compatible both ways: the loop's sid still doubles as the ACR
runtimeSessionId, so an already-deployed image reading context.session_id
produces the same key; and 'EMPTY' still covers local runs and evaluation
endpoints that ignore the api key.
…plate

Pass return_dict=False instead of accepting the BatchEncoding default and
unwrapping input_ids after the fact. The bundled attention mask is a padding
artifact the training backend builds itself when batching rows (verl's own
apply_chat_template wrapper defaults return_dict=False for the same reason).
The stub tokenizer now mirrors the real API's dict-by-default behavior, so
dropping the kwarg fails tests on behavior, not just the kwargs echo.
Remove LLAMA3_SCHEMA, its registry entry, and the Llama 3.1/3.2 template
hashes: Llama 3.x is not a fine-tuning target worth carrying a vendored
schema for. Also drops the only hub-gated repos from template verification,
so hash checks need no HF token.
…directly

test_render.py: drop test_tool_parser_ill_formed_propagates (asserted a
hard-coded lambda return, already covered by the stage-sequence test) and
test_recognized_chat_template_switches_to_schema_derender (duplicated the
schema-path coverage and leaked a fake hash into the global registry).

test_response_schemas.py: rewrite without the sentinel-template/hash-
registration machinery — tests assign the schema on the renderer directly.
Down to what only this repo can break: one golden-path test per vendored
schema dict (a transcription error in the hand-copied regexes would parse
wrong silently) and the _parse_with_schema degradation contract (parse
failures degrade to raw text flagged ill_formed, never an exception or a
silently dropped call). Adversarial regex cases are TRL's own test surface;
detection is covered live in test_template_hashes_live.py.
Detection is byte-exact and the offline unit tests bypass it, so a
mistranscribed registry hash — or a vendor revising a chat template
upstream — would silently drop real models into the no-schema rejection.
Fetch each registered hash's actual template from the hub (a few KB per
repo, never weights; one repo per hash, e.g. both Qwen3.5 think/nothink
variants) and assert it resolves to the expected schema. Always-on: runs
in the default suite and CI.
@luyuzhe111
luyuzhe111 requested a review from Linbo-Liu August 3, 2026 17:59

# Qwen2.5 / Qwen3 (thinking) / Qwen3-Instruct-2507 / Qwen3-VL: JSON tool calls inside
# <tool_call>...</tool_call>, optional <think> block, <|im_end|> end-of-turn.
QWEN3_SCHEMA = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

otherwise we will need to add TRL as a dependency : )

@luyuzhe111
luyuzhe111 merged commit dca5b97 into main Aug 4, 2026
7 checks passed
@luyuzhe111
luyuzhe111 deleted the verl_integ_refactor branch August 4, 2026 21:49
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.

3 participants