Skip to content

server: parse LFM2's Pythonic tool calls - #1250

Closed
outcastofmusic wants to merge 1 commit into
ashhart:mainfrom
outcastofmusic:fix/lfm2-tool-calls
Closed

outcastofmusic wants to merge 1 commit into
ashhart:mainfrom
outcastofmusic:fix/lfm2-tool-calls

Conversation

@outcastofmusic

Copy link
Copy Markdown
Contributor

What this changes

LFM2.5-24B-A2B's tool calls (<|tool_call_start|>[name(key=value)]<|tool_call_end|>) now come back as tool_calls, whole and streamed. Before, the server returned them as content. Fixes #1246.

The new tool_lfm2.zig rewrites each complete block of offered calls as the DSML block that the parser and the streamer already read, as tool_minimax.zig in #1235 does for MiniMax. Positional arguments take the schema's property order. Each value reads as JSON, then as a Python literal. A block that does not parse, or that names a tool the request did not offer, stays as text.

Two differences from #1235's hook:

  • chat.zig rewrites the answer after the think split. The split works on byte offsets, which a rewrite of the whole text would shift. A call the reasoning only mentions is also left alone.
  • While a reply streams, an open block is held back. The streamer reads on from where it stopped, so it has to meet the block as DSML the first time. Without the hold-back, a stream split at every byte sent 0 of 3 calls.

A value that mixes Python's True with JSON's null inside one container does not parse, because pythonLiteral reads only Python's spelling inside containers. That block stays as text.

Overlaps: #1235 also adds an import, a hook in chat.zig, and a reply_text.calls entry, so the two conflict on neighbouring lines. #1243 sizes a leads table by calls.len, which needs one more &.{} entry for this pair.

Receipt

  • Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision: main at 6244c62 plus this commit, Zig 0.17.0, Linux aarch64 with a GB10. No checkpoint: the change is in server text handling.
  • Exactness, from tools/bench_concurrent.py --alone --serial or token_sha against "draft": false: n/a. Tokens do not change; only how the reply text becomes content and calls.
  • Decode speed before and after, from tools/bench_openai.py: n/a. The rewrite runs on reply text after decode, and returns at once when the reply holds no opener.
  • Prompt speed before and after: n/a, the change cannot reach prompt processing.
  • Tests run, with pass, fail and skip counts: zig build test-cuda-host: 210/210 steps, 1343/1346 tests pass, 3 skipped. zig build test-golden: 310/323 equal, 0 unexpected differences; the 13 are on the existing known list. The four new lfm2_tool goldens (whole, stream, single, single-stream) fail with the chat.zig hook removed. The 5 tests in tool_lfm2.zig cover positional and keyword arguments, nested values, two calls, an unknown tool, a block cut off mid-stream, and streamed calls that equal the whole reply's calls.
  • Not run, and why: the Metal suite and a real LFM2 checkpoint. Upstream serves lfm2_moe on Metal, and I have no Mac.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo.
  • No precision traded for speed. If the bits change, the description says which sums change and why.
  • A new or changed GPU kernel is registered with the kernel contract (docs/kernel-contract.md); each backend's SUMMARY line is in the receipt. (No kernel changes.)
  • Prompt processing is no slower than the last release.
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency.
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

LFM2.5-24B-A2B (lfm2_moe) writes its calls as a Python list between
<|tool_call_start|> and <|tool_call_end|>. No parser read that markup,
so the server returned it as message content with no tool_calls, and a
streamed reply showed it as prose.

tool_lfm2.zig rewrites each complete block of offered calls as the DSML
block the parser and the streamer already read, as tool_minimax.zig does
for MiniMax-M3. A scanner that knows strings and brackets splits the
calls and their arguments. Positional arguments take the schema's
property order, and each value reads as JSON, then as a Python literal.
A block that does not parse, or that names a tool the request did not
offer, stays text.

chat.zig rewrites the answer after the think split, in the streamed and
the whole reply, so the reasoning's byte offsets hold and a call the
reasoning mentions is not made. While a reply streams, an open block is
held back: the streamer reads on from where it stopped, so it must first
meet the block as DSML. reply_text.calls gains the pair, so the hider
covers the block as LFM2 writes it.

The goldens add lfm2_tool whole, streamed, and with one call allowed.
ashhart added a commit that referenced this pull request Oct 24, 2026
…, a finished reply's unclosed LFM2 block stays text, comments within the lean limits
@ashhart

ashhart commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Landed on main as 7b4a62c, thank you @outcastofmusic. LFM2's Pythonic tool calls are parsed into OpenAI tool_calls. With #1243 beside it, a finished reply's unclosed LFM2 block stays text; the maintainer child (d5ee53e) aligns your test with that and shortens four comments to the lean limit. We set the author to your GitHub noreply address.

- Synthema, Ash's AI Assistant.

@eXsoR65

eXsoR65 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Real-model validation of the LFM2 tool-call parser

I validated the merged tool_lfm2 parser on a real LFM2.5 checkpoint on Apple Silicon.

Environment

  • TensorFold d1f58cf5 (tensorfold-native 1.1.4), built from main, not a release archive
  • macOS 26.5.1, Apple M4 Pro, 48 GB
  • Xcode 26.6 (17F113), Metal 32023.883, Zig 0.17.0
  • LiquidAI/LFM2.5-24B-A2B-MLX-8bit, revision 67ab417dd02d0c9545802a726df668620eae049d
  • context 81,920, parallel 1

Build and tests

  • zig build native -Dcpu=apple_m1: 55/55 steps succeeded
  • zig build test-tool-parse-host: 38/38 pass
  • zig build test-golden: 310/323 equal, 0 unexpected differences; second corpus 26/26

Served requests (temperature: 0, reasoning_effort: none unless noted)

Request Result
get_weather(city='Paris') finish_reason: "tool_calls", content: null, {"city":"Paris"}
get_weather(...), get_time(...) two calls in order, both arguments correct
typed search {"query":"a, b (c)","limit":5,"exact":true,"opts":{"case":false}}
parallel_tool_calls: false one call only
reasoning_effort: high reasoning content plus a parsed tool call
streaming tool-call head and argument deltas, finish_reason: "tool_calls", empty content, no raw `<

No unexpected behavior or failures were observed.

Caveat: the source build does not include libtfgrammar.dylib, so this run exercised automatic tool calls but not structured output or tool_choice: "required". The 81,920 context was loaded and served, but the 80K prefill benchmark was not rerun.

This is validation only; I’m not proposing code changes.

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.

server: LFM2.5-24B-A2B tool calls arrive as message content

3 participants