Skip to content

fix(glm45): emit the newline before every tool call after assistant text - #203

Merged
hallerite merged 1 commit into
PrimeIntellect-ai:mainfrom
mottopanikeiku:fix/glm45-tool-call-separator
Oct 28, 2026
Merged

hallerite merged 1 commit into
PrimeIntellect-ai:mainfrom
mottopanikeiku:fix/glm45-tool-call-separator

Conversation

@mottopanikeiku

@mottopanikeiku mottopanikeiku commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

GLM45Renderer drops the newline before the second and later tool calls when an assistant turn has text and more than one tool call. The GLM-4.5 / GLM-4.5-Air chat template opens every call with a newline (chat_template.jinja#L68-L79):

{% for tc in m.tool_calls %}
...
{{ '\n<tool_call>' + tc.name }}

The renderer appends one \n to the text, so the first call keeps its BPE merge. It then emits the per-call \n only when the text is empty, so the later calls come out as </tool_call><tool_call>. Turns with empty text, or with a single call, were already correct. That is why the existing tool-call-content and multiple-tool-calls cells pass.

This change emits the \n before every call after the first. A new multiple-tool-calls-content parity scenario (text plus two calls) runs for every catalog model. Llama 3.2 and DeepSeek V3/R1 exclude it next to multiple-tool-calls, for the same reasons they exclude that cell.

Reproducer:

from renderers import create_renderer
from renderers.base import load_tokenizer

tok = load_tokenizer("zai-org/GLM-4.5-Air")
renderer = create_renderer(tok)
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get the current weather for a city",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    }
]


def call(city):
    return {"type": "function", "function": {"name": "get_weather", "arguments": {"city": city}}}


messages = [
    {"role": "user", "content": "Weather in Paris and London?"},
    {"role": "assistant", "content": "Let me check both.", "tool_calls": [call("Paris"), call("London")]},
]
expected = list(tok.apply_chat_template(messages, tools=tools, tokenize=True, return_dict=False))
got = renderer.render_ids(messages, tools=tools)
print("template tokens:", len(expected), "renderer tokens:", len(got), "identical:", got == expected)
print(repr(tok.decode(got[-22:])))
before after
reproducer above (template: 205 tokens) 204 tokens, ...</tool_call><tool_call>get_weather... 205 tokens, identical
SWE-style agent conversation: two assistant turns, each with a short text preamble and two parallel bash/str_replace_editor calls, then tool results, an edit, a final answer and a new user turn. Rendered with GLM45RendererConfig(thinking_retention="all") as in prime-rl's examples/advanced/glm-4.5-air/swe.toml (template: 624 tokens) 622 tokens 624, identical
one assistant turn in the template's own format, parsed with parse_response and re-rendered with render(), compared with bridge_to_next_turn on the same tokens render 412, bridge 413 both 413, equal to the template
PrimeIntellect/INTELLECT-3-SFT-10K, tool split: 39 of 10,000 rows contain such a turn all 39 differ from the template 0 of 39
pytest tests/test_parity.py -k multiple-tool-calls-content 3 failed (the GLM-4.5-Air cells), 378 passed 381 passed

prime-rl's SFT tokenization uses full render(). verifiers' train client also falls back to it whenever a turn is not bridged, for example at each new user turn under the default tool_cycle policy. On those paths these turns did not match the chat template. For a turn sampled in the template's format, they also did not match the sampled tokens.

After the change, pytest tests -k "GLM-4.5 or glm45 or glm-4.5" gives 189 passed, 3 skipped. ruff 0.15.12 check and format pass, and ty reports the same 65 existing diagnostics as on main.

I found this by reading and testing the code.

I made this PR and directed its development, with AI assistance from Claude and GPT-6 Astra.

Note

Fix GLM45Renderer tool-call newline after assistant content

  • The renderer no longer emits an extra blank line before the first tool call when the assistant message already has text. The first call reuses the newline emitted with the content; later calls and content-less messages keep their separator newline.
  • Adds a multiple-tool-calls-content parity scenario in tests/parity.py and excludes it for the listed DeepSeek and Llama 3.2 Instruct models, which already exclude the related tool-call scenarios.

Macroscope summarized f02ccd4.

The GLM-4.5 chat template opens every tool call with "\n<tool_call>", but when an assistant turn had text and two or more calls the renderer only emitted that newline before the first call. I emit it before each later call too and add a parity scenario with text plus two tool calls.

Assisted-by: Claude
Assisted-by: GPT-6 Astra
Signed-off-by: mottopanikeiku <176798723+mottopanikeiku@users.noreply.github.com>

@hallerite hallerite left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@hallerite
hallerite merged commit 52e2385 into PrimeIntellect-ai:main Oct 28, 2026
7 checks passed
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