Skip to content

server: support named tool_choice, and stop swallowing a trailing tool call into content - #26849

Closed
seyeong-han wants to merge 2 commits into
ggml-org:masterfrom
seyeong-han:fix/named-tool-choice
Closed

server: support named tool_choice, and stop swallowing a trailing tool call into content#26849
seyeong-han wants to merge 2 commits into
ggml-org:masterfrom
seyeong-han:fix/named-tool-choice

Conversation

@seyeong-han

Copy link
Copy Markdown

Two server-side tool-calling bugs, found while exercising the Muse Glimmer chat handler against a multi-turn agentic benchmark. The first is generic and affects every model; the second is specific to the Muse Glimmer parser.

1. Named tool_choice was silently downgraded to "auto"

tool_choice may be a string ("auto" / "none" / "required") or, to force one specific function, an object:

"tool_choice": {"type": "function", "function": {"name": "get_weather"}}

json_value() only understands the string form. Given the object it logs a type warning and returns the default "auto", so the request is served as if no choice had been made — nothing surfaces to the caller.

That is not cosmetic for templates whose grammar is lazy under "auto". The Muse Glimmer handler only arms its grammar once this trigger matches:

<|start|>assistant( to=(?!self<|message|>)(?!user<|message|>)[^<]*?<|message|>)

Asked to call a tool with empty arguments, the model writes the call without the <|start|>assistant header and never leaves the analysis channel:

to=skill<|message|><atem:function_calls>
<atem:invoke name="skill"></atem:invoke>
</atem:function_calls>

The trigger never fires, the grammar never arms, and the call stays buried in reasoning_contentfinish_reason: "stop", no tool_calls. Deterministic at temperature 0. tool_choice: "required" was unaffected because that path builds a non-lazy grammar.

Fix: parse the object form, narrow tools to the named function and map it to "required". The grammar is then non-lazy and admits only that call, which is the OpenAI semantics and needs no per-template changes. Malformed tool_choice now returns 400 instead of silently behaving as "auto".

2. A trailing tool call was swallowed into message.content

The Muse Glimmer parser read assistant content with until("<|eot|>"). The model routinely answers the user and calls a tool in one generation, and there is no <|eot|> before the call — so content ran to the end of the generation and absorbed the tool markup, which was then delivered to the user as chat text.

On a multi-turn agentic run this leaked raw tool markup into the visible reply on 43 turns across 19 of 113 tasks (~17% of tasks affected).

Fix: make content stop at <|eom|> / <atem:function_calls>, make the to=<tool><|message|> header optional, and allow content-then-tool-call with both delimiters optional.

Testing

Against the Muse Glimmer 30B bf16 weights:

case before after
named tool_choice, empty args finish_reason: stop, no tool_calls tool_calls: skill {}
named tool_choice, with args ok unchanged
named tool_choice, no-parameter tool ok unchanged
tool_choice: "required" (2 cases) ok unchanged
5 malformed tool_choice inputs silently treated as "auto" 400 with a specific message

Re-running the 19 tasks that leaked tool markup: 19/19 leaking → 0/19, and 43 leaked turns → 0 of 425.

…to auto

A named tool_choice — {"type":"function","function":{"name":"..."}} — was being
dropped. json_value() only understands the string form, so on the object it logged a
type warning and returned the default "auto". Nothing surfaced this to the caller.

For templates whose grammar is lazy under "auto" this is not cosmetic. The Muse
Glimmer handler only arms its grammar once the trigger

    <|start|>assistant( to=(?!self<|message|>)(?!user<|message|>)[^<]*?<|message|>)

matches. Asked to call a tool with empty arguments, the model writes

    to=skill<|message|><atem:function_calls>
    <atem:invoke name="skill"></atem:invoke>
    </atem:function_calls>

with no <|start|>assistant header — it never leaves the analysis channel. The trigger
therefore never fires, the grammar never arms, and the call stays buried in
reasoning_content: finish_reason "stop", no tool_calls. Deterministic at temperature 0.
With tool_choice "required" the same prompt works, because that path builds a
non-lazy grammar.

Parse the object form, narrow tools to the named function and map it to "required".
The grammar is then non-lazy and admits only that call, which is the OpenAI semantics
and needs no per-template changes. Malformed input now 400s instead of silently
behaving as "auto".

Tested against muse-glimmer-30B bf16:
  named tool_choice + empty args   finish_reason stop, no tool_calls -> tool_calls skill {}
  named tool_choice + args         unchanged, skill {command:invoke, skill_name:deploy}
  named tool_choice + no-arg tool  unchanged, list_skills {}
  tool_choice required x2          unchanged, translate_to_spanish / get_weather
  5 malformed tool_choice inputs   400 with specific messages

Note the model still emits the abortive call into reasoning_content; the grammar now
forces a well-formed call afterwards, so the API response is correct. The underlying
channel-transition bug is a model issue and is reported separately.
The model routinely answers the user and calls a tool in one generation:

    <prose to the user><|eom|><|start|>assistant to=<tool><|message|>
    <atem:function_calls>...</atem:function_calls>

The `final` rule read content with until("<|eot|>"). There is no <|eot|> before
the call, so content ran to the end of the turn and absorbed the tool markup.
The call was never parsed into tool_calls, and the raw ATEM/channel markup was
handed to the caller as chat text.

Measured on tau2-bench telecom (114 tasks, muse-glimmer-30B bf16, user simulator
muse-spark-1.2-aai2): 43 leaked assistant turns across 19 of 113 tasks — 16.8% of
tasks, 1.9% of all assistant turns. Details in P2455728274.

Three malformed shapes were observed, so the parser is made tolerant of all of
them rather than of one:

  1. <prose><|eom|><|start|>assistant to=<tool><|message|><atem:function_calls>
     — all 43 tau2 leaks
  2. <prose>\n<atem:function_calls> — no header, no delimiter
  3. to=<tool><|message|><atem:function_calls> inside the analysis channel with
     no <|start|> at all — P2455602726 repro 3

Changes, all in common_chat_params_init_muse_glimmer:

  - content stops at <|eot|>, <|eom|> or <atem:function_calls>
  - the " to=<tool><|message|>" call header is optional
  - a turn may be content followed by tool calls, with both the <|eom|> and the
    <|start|>assistant delimiters optional

Re-ran the same 19 tasks that leaked, with the fix:

  leaked tasks       19/19 (100%)  ->  0/19 (0%)
  leaked turns       43            ->  0     (of 425 assistant turns)
  pass^1             84.2%         ->  94.7%

The leak elimination is deterministic. The score movement is 2 tasks on n=19 in
a benchmark with a stochastic user simulator and no control re-run, so it is
suggestive only, not attributable.

Regressions: the three P2455602726 repros and the named/no-arg tool_choice
variants all still return correct tool_calls.

This makes the parser tolerant of malformed output; it does not make the model
emit canonical output. The underlying behaviour is worth fixing model-side.
@seyeong-han
seyeong-han requested review from a team as code owners August 10, 2026 15:26
@seyeong-han

Copy link
Copy Markdown
Author

cc @ngxson @pcuenca @albertodepaola — this is the upstream version of the tool-calling fixes we reviewed internally in huggingface/new-model-addition-onyx-llama.cpp#13. Same two commits, rebased onto master; I could not add you as reviewers directly since I do not have write access here.

@ggml-gh-bot

ggml-gh-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

Hi @seyeong-han, thanks for your contribution!

Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:

  • PR Template not respected: Please respect the template when creating a new pull request. Make sure to fill out all required sections.

  • AI-generated content: While code is allowed to be generated by AI, please write the PR description and commit messages on your own without the help of AI.


Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below.

@ggml-gh-bot ggml-gh-bot Bot added the draft PR will be changed to draft by github-actions bot label Aug 10, 2026
@github-actions
github-actions Bot marked this pull request as draft August 10, 2026 15:33
@github-actions github-actions Bot removed the draft PR will be changed to draft by github-actions bot label Aug 10, 2026
@seyeong-han
seyeong-han marked this pull request as ready for review August 10, 2026 16:25
Comment on lines +924 to +926
// Named choice is "call exactly this function", so narrow `tools` to that one entry
// and treat it as "required". The grammar then admits only that call, which is the
// OpenAI semantics and needs no per-template support.

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.

I am not privy to internal discussions, but AFAIK vLLM does not do this. In vLLM, all tools are kept in the context and the specified tool is forced via grammar constrained decoding. Filtering tools also has negative connotations for prefix caching, as two requests with the same system prompt, same tool definitions, but different tool_choice will trigger reprocessing of the system prompt.

@seyeong-han

Copy link
Copy Markdown
Author

This issue has been moved to #26879

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants