Skip to content

[Bugfix][Tool Parser] LFM2: constrain forced tool_choice to the native format and parse hyphenated tool names - #64750

Open
tdakhran wants to merge 4 commits into
vllm-project:mainfrom
tdakhran:lfm2-tool-call-fixes
Open

tdakhran wants to merge 4 commits into
vllm-project:mainfrom
tdakhran:lfm2-tool-call-fixes

Conversation

@tdakhran

@tdakhran tdakhran commented Oct 30, 2026 •

Copy link
Copy Markdown

Overview

Fixes two lfm2 tool-parser bugs that together make LFM2.5 agents loop on the same tool call under tool_choice="required":

  1. Forced tool_choice (required/named) was decoded as a JSON tool-call list instead of LFM2's trained <|tool_call_start|>[name(k=v)]<|tool_call_end|> block. An lfm2 structural tag now constrains it to the native block.
  2. Tool names with hyphens (sub-task-ending) came back as content. They are now parsed.

Related: #57989 (the same hyphen fix for FunctionGemma).

Claims

  • On a ToolSandbox turn where the search already succeeded, LiquidAI/LFM2.5-2.6B repeats it in 28/30 samples on main and ends the subtask in 30/30 with this PR.
  • required/named calls are decoded in the native block and parsed by Lfm2ToolParser, like auto. auto is constrained only when a structural tag applies anyway (a strict tool, --tool-strict-level, parallel_tool_calls=false), and plain text stays valid.
  • Hyphenated names parse in streaming and non-streaming, including parallel blocks that mix them with plain names.

Validation

Reproducer. ToolSandbox's contacts task with tool_choice="required", at the turn after a successful search_contacts(relationship="friend"). The harness adds a sub-task-ending tool, which is the correct next call.

vllm serve LiquidAI/LFM2.5-2.6B --enable-auto-tool-choice --tool-call-parser lfm2 --reasoning-parser qwen3
curl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' -d @toolsandbox_repro.json \
  | jq -r '.choices[].message.tool_calls | map(.function.name) | join(",")' | sort | uniq -c
toolsandbox_repro.json (the model's default sampling, n=30)
{
  "model": "LiquidAI/LFM2.5-2.6B",
  "messages": [
    {
      "role": "system",
      "content": "Execute only the current subtask in the final user message. Earlier user/assistant pairs are completed prerequisites: the user message is the prerequisite objective and the assistant message is its final result. Use them only as context. Never attempt later subtasks.\n\nRules:\n- Use only exposed tools through API tool calls; never output plain text or textual tool-call JSON.\n- Call task tools as needed, including repeated calls, and wait for each response. Continue until the complete objective is satisfied.\n- Never fabricate, guess, or assume data. Use only user-message or tool-response values, even when another value seems obvious.\n- Include only needed optional arguments known from a user message or tool response. Omit unknown optional arguments; never use placeholders such as `0`, `\"\"`, or `null`.\n- Do not ask the user for credentials or task data; retrieve them with available tools.\n- Call `sub-task-ending` exactly once after task tools, or immediately if none are needed.\n\n`sub-task-ending` arguments:\n- Required top-level `status`: `Completed`, `UnknownError`, or `MissingRequiredInputOrTool`.\n- Required `data`: a non-empty string.\n- `Completed`: ONLY if every objective requirement was achieved. Failed, partial, or unverified outcomes are NEVER `Completed`, even when `data` explains them. Include all requested result values.\n- `MissingRequiredInputOrTool`: a required capability or information cannot be obtained; state what is missing in `data`.\n- `UnknownError`: an unexpected failure that may be corrected with available information and tools; describe it in `data`."
    },
    {
      "role": "user",
      "content": "Search for all contacts to identify friends"
    },
    {
      "role": "assistant",
      "content": "",
      "tool_calls": [
        {
          "id": "call_search",
          "type": "function",
          "function": {
            "name": "search_contacts",
            "arguments": "{\"relationship\": \"friend\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_search",
      "content": "{\"status\":\"ok\",\"tool\":\"search_contacts\",\"http_status\":200,\"response\":{\"tool_name\":\"search_contacts\",\"arguments\":{\"relationship\":\"friend\"},\"result\":[{\"person_id\":\"9e137f06-916a-5310-8174-cf0b7e9f7054\",\"name\":\"Fredrik Thordendal\",\"phone_number\":\"+12453344098\",\"relationship\":\"friend\",\"is_self\":false},{\"person_id\":\"a22e1984-6c6c-530c-8831-c3ea3b5138e7\",\"name\":\"John Petrucci\",\"phone_number\":\"+1234560987\",\"relationship\":\"friend\",\"is_self\":false}],\"tool_error\":null,\"tool_trace\":[]}}"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "search_contacts",
        "description": "Search for a contact person based on provided arguments Each field has a search criteria of either\n1. Exact value matching\n2. Fuzzy string matching with a predefined threshold\nSearch results contains all contact entries that matched all criteria",
        "parameters": {
          "type": "object",
          "properties": {
            "person_id": {
              "type": "string",
              "description": "String format unique identifier for the contact person, will be exact matched"
            },
            "name": {
              "type": "string",
              "description": "Name of contact person, will be fuzzy matched"
            },
            "phone_number": {
              "type": "string",
              "description": "Phone number of contact person, will be exact matched"
            },
            "relationship": {
              "type": "string",
              "description": "Relationship between user and this contact, will be fuzzy matched"
            },
            "is_self": {
              "type": "boolean",
              "description": "If the requested contact corresponds to the current user. Will be exact matched"
            }
          },
          "required": []
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "sub-task-ending",
        "description": "End the current executor subtask. Call this exactly once after the subtask has completed, failed unexpectedly, or cannot continue because a required capability or required information is unavailable.",
        "parameters": {
          "type": "object",
          "properties": {
            "status": {
              "type": "string",
              "enum": [
                "Completed",
                "UnknownError",
                "MissingRequiredInputOrTool"
              ],
              "description": "Required top-level subtask status. Use exactly one of Completed, UnknownError, or MissingRequiredInputOrTool."
            },
            "data": {
              "type": "string",
              "description": "Required non-empty text. For Completed include the complete result and every value requested by the objective. For failures describe the problem precisely."
            }
          },
          "required": [
            "status",
            "data"
          ]
        }
      }
    }
  ],
  "tool_choice": "required",
  "temperature": 0.1,
  "top_k": 50,
  "repetition_penalty": 1.1,
  "n": 30
}
main this PR
curl above, 30 samples search_contacts repeated in 28 sub-task-ending in 30
full agent loop, 10 runs × up to 8 turns repeat loop in 7 0

Tests. vllm/vllm-openai:latest (v0.32.0) on an RTX 4070 (8 GB). E2E: main's or this branch's two changed files copied over the installed ones. Unit: this branch's Python tree with the image's compiled extensions.

# before (main)
$ pytest tests/entrypoints/openai/chat_completion/test_lfm2_tool_calls.py
E   AssertionError: assert [] == ['get-weather']
E   AssertionError: assert 124905 in [67, 266, 229, 206, 2718, 463, ...]   # no <|tool_call_start|>
4 failed

# after (this branch)
$ pytest tests/entrypoints/openai/chat_completion/test_lfm2_tool_calls.py
4 passed
$ pytest tests/tool_parsers/test_lfm2_tool_parser.py tests/tool_parsers/test_structural_tag_registry.py
348 passed

New tests cover hyphenated names (parser), the lfm2 grammar's accept/reject cases, the real token stream and a DelegatingParser round trip (registry), and an e2e test on LFM2.5-2.6B that runs in the existing entrypoints/openai/chat_completion CI step.

Details

Forced tool_choice. Lfm2ToolParser had no structural_tag_model, so forced calls got the generic JSON schema and bypassed the parser. Outside its trained format the model copies its previous call: after </think>, the JSON prefix [{"name": " gives search 0.53 / sub 0.32 at the first token, while <|tool_call_start|>[ gives sub 1.00. The fix registers get_lfm2_structural_tag (like hermes, longcat and plamo3; nothing shared changes): the start token, then [ + an allowed name + (, then free text up to the end token. The sentinels are strings because request validation compiles the tag without a tokenizer, and the arguments use AnyTextFormat because LFM2 tokens such as (city span the forced prefix. VLLM_ENFORCE_STRICT_TOOL_CALLING=0 restores the old behavior.

Hyphenated names. ast.parse reads sub-task-ending(...) as a subtraction. Requested names that aren't identifiers are swapped for _lfm2_tool_alias_N at call positions before parsing and restored afterwards. Names not in request.tools are left alone.

Limitations. Only the first call's name is constrained. Further calls and all arguments are free text, so arguments aren't checked against the schema, even for strict tools. parallel_tool_calls=false is enforced after parsing, not by the grammar.

Docs. New LFM2.5 section in docs/features/tool_calling.md. The LFM rows in docs/models/supported_models.md now list LFM2.5 checkpoints; the deprecated LFM2 and LFM2-VL examples are dropped.

Not a duplicate. No open upstream PR covers LFM2 tool calling or an lfm2 structural tag. #52900 (Rust-frontend LFM2 parser) is unaffected.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify

mergify Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--64750.org.readthedocs.build/en/64750/

@mergify mergify Bot added documentation Improvements or additions to documentation tool-calling bug Something isn't working labels Oct 30, 2026
@github-project-automation github-project-automation Bot moved this to Unsorted queue in Tool Calling (arpera) Oct 30, 2026
@github-project-automation github-project-automation Bot moved this to Unsorted queue in Tool Calling Oct 30, 2026
@tdakhran
tdakhran force-pushed the lfm2-tool-call-fixes branch from b60b517 to 7272745 Compare October 30, 2026 18:28
for tool in tools
]
),
AnyTextFormat(),

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.

Without excludes, the free text can contain <|tool_call_start|> again, e.g. <|tool_call_start|>[get_weather(city='x')<|tool_call_start|>]<|tool_call_end|>. The parser slices from the first start sentinel, ast.parse fails, and the whole output falls back to content.

Can we use AnyTextFormat(excludes=[_LFM2_TOOL_CALL_START, _LFM2_TOOL_CALL_END]) here, like the kimi_k3 builder does?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in cda3af3

if tool_call.function.name == alias:
tool_call.function.name = name
if alias in tool_call.function.arguments:
tool_call.function.arguments = tool_call.function.arguments.replace(

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.

_lfm2_tool_alias_1 is a prefix of _lfm2_tool_alias_10, and this replaces in insertion order, so with 11 or more hyphenated tools in one block an alias inside a string argument gets corrupted. I hit {"note": "then, tool-b0(y)"} for an argument that should have been then, tool-k(y).

A terminated placeholder like _lfm2_tool_alias_{n}_, or replacing longest alias first, would fix it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

great catch! I didn't test with more than 11 tool calls, will fix

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cda3af3

@tdakhran
tdakhran force-pushed the lfm2-tool-call-fixes branch from 7272745 to cda3af3 Compare October 31, 2026 10:38

@smiyamoto3 smiyamoto3 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.

Thanks for the update. I think a named tool_choice can now return extra calls, since the grammar pins only the first [name(. With end-task named, <|tool_call_start|>[end-task(), made_up(x=1)]<|tool_call_end|> is accepted and parses to both calls. On main it is always one end-task call. Could a named tool_choice keep only the first call after parsing, like parallel_tool_calls=false does?

Comment thread vllm/tool_parsers/lfm2_tool_parser.py Outdated
for tool in getattr(request, "tools", None) or []:
function = getattr(tool, "function", None)
name = getattr(function, "name", None) or getattr(tool, "name", None)
if not name or all(part.isidentifier() for part in name.split(".")):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: names that are Python keywords (import, class, lambda) skip the alias and still fail ast.parse. Adding and not keyword.iskeyword(part) here fixes them, and the lfm2 tests still pass.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good catch, thanks, will address

tdakhran and others added 4 commits November 2, 2026 10:57
OpenAI tool names may contain `-`, and the LFM2/LFM2.5 chat templates
render them verbatim, so the model emits e.g.
`<|tool_call_start|>[sub-task-ending(status='Completed')]<|tool_call_end|>`.
Python parses `sub-task-ending(...)` as a subtraction rather than a call,
so the lfm2 parser rejected the block ("Tool output must be a list of
function calls") and returned the call as content. In a parallel block
the non-streaming path dropped every call, and streaming kept only the
calls before the hyphenated one.

Before parsing, swap each requested tool name that is not a dotted
identifier for a placeholder at call positions (after `[` or `,`), and
restore it in the parsed name and in any argument string the swap
touched, so the round trip is exact. Applied to both the non-streaming
and streaming paths. Names the request does not offer are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tarek Dakhran <tarek@liquid.ai>
…he native pythonic format

With `tool_choice="required"` or a named function, the lfm2 parser fell
back to the generic JSON schema (`[{"name": ..., "parameters": ...}]`),
so LFM2/LFM2.5 had to emit a tool-call format it was never trained on
instead of `<|tool_call_start|>[name(k=v)]<|tool_call_end|>`, and the
lfm2 parser was bypassed entirely.

Off its trained format the model tends to copy its previous call. On
ToolSandbox scenario 032 with LFM2.5-2.6B, after a successful search
result the model re-sent the identical search in 30/30 samples under
the JSON constraint; forced into its native block it ended the task in
30/30. A multi-turn replay of that scenario repeated the call in 7/10
runs before this change and 0/10 after.

Register an `lfm2` structural tag: the start sentinel, then `[` plus an
allowed tool name and `(`, then free text up to the end sentinel. The
existing extract_tool_calls reads the block back, as in auto mode.
The sentinels are matched as strings (request validation compiles the
tag without a tokenizer), and the arguments use AnyTextFormat, since
AnyTokensFormat rejects a token that spans the end of `[name(`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tarek Dakhran <tarek@liquid.ai>
…x-free

- End the lfm2 structural tag with `)]<|tool_call_end|>` and exclude both
  sentinels from the free text, so an unclosed call or a nested start
  sentinel is no longer grammatical under required/named tool_choice.
- Terminate hyphenated-name aliases (`_lfm2_tool_alias_{n}_`) so that, with
  11+ hyphenated tools, `_lfm2_tool_alias_1` no longer restores inside
  `_lfm2_tool_alias_10` in string arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tarek Dakhran <tarek@liquid.ai>
…word names

- Keep only the first tool call for a named tool_choice in
  `maybe_filter_parallel_tool_calls`, as already done for
  `parallel_tool_calls=false`. The lfm2 grammar pins only the first call's
  name, so `[end-task(), made_up(x=1)]` used to return both calls.
- Alias tool names that are Python keywords (`import`, `class`, `None`)
  like hyphenated ones, since they are identifiers but fail `ast.parse`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tarek Dakhran <tarek@liquid.ai>
@tdakhran
tdakhran force-pushed the lfm2-tool-call-fixes branch from cda3af3 to 9f02a6c Compare November 2, 2026 10:00
@tdakhran
tdakhran requested a review from njhill as a code owner November 2, 2026 10:00
@mergify mergify Bot added the frontend label Nov 2, 2026
@tdakhran

tdakhran commented Nov 2, 2026

Copy link
Copy Markdown
Author

@smiyamoto3 , thank you for the review, addressed and rebased

@smiyamoto3 smiyamoto3 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.

LGTM. Named now streams one call too.

This branch has not been deployed

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

Labels

bug Something isn't working documentation Improvements or additions to documentation frontend tool-calling

Projects

Status: Unsorted queue

Development

Successfully merging this pull request may close these issues.

3 participants