Skip to content

Required tool calls in GLM's markup: xgrammar's glm_xml style - #1018

Open
Bizuayeu wants to merge 6 commits into
ashhart:mainfrom
Bizuayeu:pr/glm-tool-grammar
Open

Bizuayeu wants to merge 6 commits into
ashhart:mainfrom
Bizuayeu:pr/glm-tool-grammar

Conversation

@Bizuayeu

Copy link
Copy Markdown
Contributor

What this changes

Part of GLM-5.3-Flash on the native CUDA engine (#593, where the three stacked PRs for GLM's ranks are summed up): a forced tool call whose arguments are inside the tool's schema.

tool_choice: "required" and a named function were refused for every GLM template: its calls are <tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value>...</tool_call>, neither Qwen3-Coder's XML nor a JSON object, so tool_grammar.style found no style. The probe call's markup now picks a third style, glm, and a call's grammar is <tool_call> with the name, the arguments in xgrammar v0.2.8's glm_xml style (the same tags), then </tool_call>, with no blanks inside, as the template writes none. Stacked on #1017, which makes GLM enforce a grammar on CUDA.

Receipt

  • Environment: f8e47ac6 on GLM-5.3-Flash on CUDA: structured output (first_masked and held) #1017, built and tested as 38aa9875 (the same code, before the docs commits beneath it), the same machines, checkpoint and libtfgrammar as there; TP=2 with --parallel 2 --context 131072.
  • Exactness: tool_choice: "required" with one offered tool (get_weather, a required city string) and the prompt "What is the weather in Osaka?" gives one call, {"city":"Osaka"}, finish_reason tool_calls, parsed by the server's GLM call parser.
  • Decode and prompt speed: n/a, server-side grammar building only.
  • Tests run: zig build test-cuda-host 855/855 and zig build test (0 failures) at 38aa9875; the style and grammar tests in zig/src/server/tool_grammar.zig cover the GLM markup.
  • Also at TP=3, the same commits on 1.0.12 with two unrelated local changes (rank 0's NCCL id in a file for three hosts on /32 addresses until fabric: tensor parallelism across CUDA processes (bootstrap, NCCL, mailbox and RoCE collectives, plan link, fail-fast) (DeepSeek-V4.1 stack 3/14) #636, and a heat-wait ceiling), --context 1048576, one stream: the required call is get_weather({"city":"Osaka"}) again, finish_reason tool_calls.
  • Not run, and why: parallel_tool_calls with several calls in one reply; Metal (GLM's Metal engine does not enforce grammars).

Checklist

  • ✓ Every token still goes through the lane rounds.
  • ✓ Drafted output equals "draft": false under the grammar (GLM-5.3-Flash on CUDA: structured output (first_masked and held) #1017's receipt); this change only builds the grammar.
  • ✓ No precision traded for speed.
  • ✓ No GPU kernel changes.
  • ✓ Prompt processing is unchanged.
  • ✓ Platforms: CUDA GB10; Metal not run.
  • ✓ New tests: the GLM markup picks the glm style, and its grammar names the tool after <tool_call> with the glm_xml style. Before this change that markup had no style, which is the refusal above.
  • ✓ Comments and docstrings are one line.
  • ✓ No personal data, machine names, internal hosts or local paths; no AI attribution lines.

The lane backend fills Stream.prompt_rows, so the server stops refusing
prompt_logprobs for GLM. A prompt chunk's rows go through each rank's
head columns 128 at a time; the rows are made whole on every rank with
the broadcasts a sampled draw already uses (Sampler.gather), and rank 0
runs sample.cu's tf_logprob_rows on them. Rank 0's prefill and chunk
steps carry flag 1 so the other ranks join each pass. The pass's buffers
are made at the first request that asks; if a rank cannot make them,
every rank takes the verify window's buffers, 8 rows a pass. A pass with
prompt rows starts from position 0, as Nemotron's does.
The lane backend gives first_masked and held, so the server serves
response_format and required tool calls for GLM. Only rank 0's lane core
holds the grammar, and only rank 0's tokens are read (it sends them on),
so rank 0 alone masks: a verify or shared step under a grammar carries
flag 1, every rank gathers its draw's rows whole (Sampler.gather, as a
sampled draw does), and rank 0 runs sample.cu's tf_grammar_mask on them,
then tf_draw, or greedy's rule (tf_glm_top1 over the whole row: the
largest value, then the lowest id, as the per-rank merge picks). The
first token is drawn again from the prompt pass's last head row by a new
redraw step. The grammar rows go to host-mapped words before the step is
sent, so a refusal never strands the other ranks mid-broadcast. Draft
draws stay unmasked.
tool_choice "required" and a named function refused every GLM template:
its calls are <tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value>
...</tool_call>, neither Qwen3-Coder's XML nor a JSON object. The probe
call's markup now picks a third style, and the call's grammar is the
name after <tool_call>, the arguments in xgrammar v0.2.8's glm_xml (the
same tags; a string value written as it is, others as JSON, as the
template writes them), then </tool_call>, with no blanks between, as the
template has none.
@ashhart

ashhart commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Thanks @Bizuayeu for the required get_weather call passing at both TP=2 and TP=3. The GLM envelope and glm_xml API integration fit the existing grammar builder; no xgrammar implementation or tables are added here. We're taking it for 1.1.1 alongside #1016 and #1017, preserving the other formats and the shared required/named-tool policy. Host checks cover style detection, offered names and one-call versus parallel grammar construction. Before release we still need actual generated required, named and parallel calls, including nested and empty arguments, through the grammar library and GLM parser. Your receipt explicitly leaves parallel calls untested, so that remains a gate.

Posted by Ash's AI Assistant Synthena

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.

2 participants