Repository navigation
Conversation
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: 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. 🚀 |
|
Documentation preview: https://vllm--64750.org.readthedocs.build/en/64750/ |
b60b517 to
7272745
Compare
| for tool in tools | ||
| ] | ||
| ), | ||
| AnyTextFormat(), |
There was a problem hiding this comment.
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?
| 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( |
There was a problem hiding this comment.
_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.
There was a problem hiding this comment.
great catch! I didn't test with more than 11 tool calls, will fix
7272745 to
cda3af3
Compare
smiyamoto3
left a comment
There was a problem hiding this comment.
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?
| 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(".")): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
good catch, thanks, will address
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>
cda3af3 to
9f02a6c
Compare
|
@smiyamoto3 , thank you for the review, addressed and rebased |
smiyamoto3
left a comment
There was a problem hiding this comment.
LGTM. Named now streams one call too.
Overview
Fixes two
lfm2tool-parser bugs that together make LFM2.5 agents loop on the same tool call undertool_choice="required":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. Anlfm2structural tag now constrains it to the native block.sub-task-ending) came back as content. They are now parsed.Related: #57989 (the same hyphen fix for FunctionGemma).
Claims
LiquidAI/LFM2.5-2.6Brepeats it in 28/30 samples onmainand ends the subtask in 30/30 with this PR.required/named calls are decoded in the native block and parsed byLfm2ToolParser, likeauto.autois constrained only when a structural tag applies anyway (astricttool,--tool-strict-level,parallel_tool_calls=false), and plain text stays valid.Validation
Reproducer. ToolSandbox's contacts task with
tool_choice="required", at the turn after a successfulsearch_contacts(relationship="friend"). The harness adds asub-task-endingtool, which is the correct next call.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 }mainsearch_contactsrepeated in 28sub-task-endingin 30Tests.
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.New tests cover hyphenated names (parser), the
lfm2grammar's accept/reject cases, the real token stream and aDelegatingParserround trip (registry), and an e2e test onLFM2.5-2.6Bthat runs in the existingentrypoints/openai/chat_completionCI step.Details
Forced tool_choice.
Lfm2ToolParserhad nostructural_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": "givessearch0.53 /sub0.32 at the first token, while<|tool_call_start|>[givessub1.00. The fix registersget_lfm2_structural_tag(likehermes,longcatandplamo3; 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 useAnyTextFormatbecause LFM2 tokens such as(cityspan the forced prefix.VLLM_ENFORCE_STRICT_TOOL_CALLING=0restores the old behavior.Hyphenated names.
ast.parsereadssub-task-ending(...)as a subtraction. Requested names that aren't identifiers are swapped for_lfm2_tool_alias_Nat call positions before parsing and restored afterwards. Names not inrequest.toolsare 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
stricttools.parallel_tool_calls=falseis enforced after parsing, not by the grammar.Docs. New LFM2.5 section in
docs/features/tool_calling.md. The LFM rows indocs/models/supported_models.mdnow 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
lfm2structural tag. #52900 (Rust-frontend LFM2 parser) is unaffected.Pull Request Checklist
I used vLLM's
/pr-checklistskill. (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.