Skip to content

LFM2 tool calls: typed arguments, JSON list form, per-call streaming, Pythonic grammar for tool_choice - #1265

Closed
BlivionIaG wants to merge 9 commits into
ashhart:mainfrom
BlivionIaG:lfm2/tool-calls-typed
Closed

BlivionIaG wants to merge 9 commits into
ashhart:mainfrom
BlivionIaG:lfm2/tool-calls-typed

Conversation

@BlivionIaG

@BlivionIaG BlivionIaG commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

LFM2 tool calls: typed arguments, JSON list form, per-call streaming, Pythonic grammar for tool_choice

Follows #1246 and #1250.

Changes

  1. A call argument is read as the type its schema names.
    • 123 for a string parameter becomes "123".
    • "4" for an integer becomes 4, and 4.0 becomes 4.
    • 1 or 0 for a boolean becomes true or false.
    • A parameter that allows a string and an integer keeps the model's value.
  2. The JSON list form [{"name": "f", "arguments": {...}}] is read as calls.
    • parameters is accepted for arguments. arguments can be a JSON string.
    • The offered-tool check and the typing are the same as for the Python form.
    • Anything else stays text.
  3. A history call with "arguments": "" went into the prompt as _invalid_arguments=''.
    toolArguments in messages.zig now reads blank text as an empty object, as it does {}.
    This is shared code: the prompt changes the same way for every family, not only LFM2.
  4. Each call of a streamed LFM2 block is sent when it ends, not when the whole block ends.
    • Change is in tool_lfm2.zig. chat.zig, tool_stream.zig and every other format are unchanged.
    • An unclosed block is still held back. Only its complete calls are sent.
    • A call is sent when it ends, and the rest of its block is read after it.
      If a later part of the block is not a valid call, the stream drops that part.
      A reply that is not streamed treats a block with an invalid part as text.
  5. tool_choice required and named use a Pythonic grammar for LFM2: tool_grammar_lfm2.zig.
    • It forces <|tool_call_start|>[name(key=value, ...)]<|tool_call_end|> with the offered tools.
    • Arguments follow the schema's property order. Required keys are forced. Values are typed by their schema kind.
    • Several calls are allowed unless parallel_tool_calls is false.
    • It is used when a probe call renders as <|tool_call_start|>[tfprobe_fn(tfprobe_k=. Other families keep their grammar.
    • Limits: keys that are not Python identifiers are left out. A tool with no properties can only be called as name().
      The grammar writes the Python form only. Items inside arrays and objects are not typed by their schemas.
  6. Goldens: lfm2_typed, lfm2_json and lfm2_two, each whole, streamed, single and single streamed.
    The requests come from freeze_cases.py. The expected responses are written by hand from existing ones.
    For lfm2_typed and lfm2_json, the token counts and token_sha come from the candidate fake server.
    For lfm2_two, they come from fake_text.py, which reproduced the committed numbers of four existing goldens first.
    The goldens do not show timing. The unit tests show the byte at which each call is first sent.
    There is no golden for item 5: the golden fake server has no grammar library and no LFM2 template.

Tests

Head ddc3e53aa, on upstream main at 3d219d215. Apple M4 Pro, 48 GiB, macOS 26.5.1, Zig 0.17.0.

  • zig build test test-golden native -Dcpu=apple_m1 -Dversion=1.1.4: 449/449 steps succeeded, exit code 0.
    test-golden: 322/335 equal, 0 unexpected differences. Second set: 26/26 equal.
  • zig fmt --check: clean. python3 tools/zig/lean_check.py: 437 problems on this branch and 437 on main.
  • New unit tests: tool_lfm2.zig (typing, JSON list), tool_lfm2_stream_test.zig (byte boundaries, early send, refusals),
    tool_grammar_lfm2_test.zig (grammar text, replies that fit it parse through tool_lfm2 and tool_parse), messages.zig (item 3).
  • Item 5 with xgrammar (libtfgrammar.dylib, no model): the grammar compiled, accepted conforming replies and masked bad ones.
    This ran on a hand-patched copy of the generated grammar. The exact committed output was not run through it.

Live check

Checkpoint LiquidAI/LFM2.5-24B-A2B-MLX-4bit, revision 667408d494bde191c2cb9dd4f4549827816b9275.
Command: tensorfold-native serve <checkpoint> --prompt-cache-gib 0 (default sampling), libtfgrammar.dylib from a release archive beside the binary.
One model process.

Route Cases Result
/v1/chat/completions one call; two calls; both streamed; argument types; awkward string; parallel_tool_calls: false; tool_choice required and named; no-tool prompt; tool-result round trip as expected; no tool markup in content
/v1/messages whole; streamed; history with two tool_result blocks two tool_use blocks, stop_reason: tool_use; the follow-up uses both results
/v1/responses whole; streamed; history with two function_call_output items two function_call items; stream completes; the follow-up uses both results

Not measured

  • Whether the model ever writes the JSON list form. Unit tests and goldens cover it.
  • The time between the first and second call of a streamed reply on the live model.
  • CUDA and HIP. The change is in shared server code and runs in the CPU host tests.
  • Decode speed. The change is in text handling after decode.

Overlap

atc overlap --json (ATC 0.7.0, complete: false: some pull request files were unavailable):

LFM2 writes Python literals, so a model that means "123" or 4 can write
the other spelling. A value the schema does not take is now read as the
type it names: a string as a number, boolean or JSON value, a number as
its own text for a string, 4.0 as 4 for an integer, and 0 or 1 as a
boolean. A value that already fits, or that no reading fits, is kept.

The unit test runs strings, integers, numbers, booleans and a union
through the rewrite.
LFM2 can also write its calls as a JSON list, [{"name": "f",
"arguments": {...}}], between the same markers. A block whose list opens
with a brace is read as JSON: `parameters` stands for `arguments`, and
arguments given as a JSON string are parsed. The calls go through the
same offered-tool check and the same schema typing as the Pythonic form
and become the same DSML. A list that is not wholly offered calls stays
text, and an open block is still held back while the reply streams.

The tests cover the spellings, the typing, the refused lists, and the
byte-at-a-time stream against the whole reply.
A client may send a call's arguments as "" for a tool that takes none.
The template saw it as unparsable and rendered _invalid_arguments='' into
the prompt, so LFM2's history read now(_invalid_arguments='') instead of
now(). A blank string is now an empty mapping, as {} is.

lfm2_template_test.zig renders LFM2.5's own chat_template.jinja over a
history of one assistant message with two calls and two tool results,
with the arguments as a mapping and as a JSON string, and over no-argument
calls with a system message and the tool list.
lfm2_json is a JSON list of one call, written with `parameters` and a
string where the schema takes an integer; lfm2_typed is a Pythonic call
with a number where the schema takes a string and a string where it
takes an integer. Each runs whole, streamed, and with one call allowed,
and the request log gains the twelve requests.

The expected replies are lfm2_tool's with the arguments the typing
should give, {"limit":4} and {"query":"123","limit":4}, and the token
counts, token hash and draft counts of the longer scripts.
@BlivionIaG BlivionIaG changed the title LFM2 tool calls: typed arguments, the JSON list form, empty-string history arguments (follows #1250) LFM2 tool calls: typed arguments, JSON list form, empty-string history arguments Oct 25, 2026
@BlivionIaG

BlivionIaG commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Failing test before change 3 (toolArguments in messages.zig from main, the new test unchanged):

error: 'messages.test.a call's arguments become objects: blank text is no arguments, bad text is kept under _invalid_arguments' failed:
expected: {}
found:    {"_invalid_arguments": ""}

With the change the test passes: zig build test-server-cpu gives 738 passed, 3 skipped, 0 failed on the commit that added it.

Full run on head ddc3e53aa, alone on the Mac: zig build test test-golden native, 449/449 steps succeeded, exit code 0.
test-golden 322/335 equal, 0 unexpected differences; second set 26/26. Zig counted 1631/1638 tests for this run (7 skipped); it counts only the tests it re-ran.

@BlivionIaG
BlivionIaG marked this pull request as ready for review October 25, 2026 00:03
@BlivionIaG
BlivionIaG marked this pull request as draft October 25, 2026 00:04
…ugh a made-up LFM2 template

lfm2_template_test.zig and its template were not how the engine checks real
templates (tools/zig/template_parity.py does). A test beside the other
messages.zig tests now covers toolArguments: blank text is an empty object,
bad text stays under _invalid_arguments, omitted arguments stay omitted, and
the tool results of a two-call turn keep their order.
tool_choice "required" and named functions on a template that writes calls as
<|tool_call_start|>[name(key=value)]<|tool_call_end|> now compile a grammar for
that block: the offered names, keyword arguments typed by their schemas, one call
or several in one list. Other templates keep the JSON-style structural tag.
While a reply streams, a block of LFM2 calls is rewritten as one DSML
envelope per call that is complete, instead of being held until its
closing tag, so the stream sends call 1 before call 2 is finished.
The finished reply still becomes a single envelope, and a block that is
not a valid list stays text.

A call whose later siblings turn out not to be calls has already been
sent; what follows it in the block is dropped from the stream.
…ll allowed

lfm2_two writes two Pythonic calls in one block, the first ending in the
first round and the second starting after it. Each runs whole, streamed,
and with one call allowed, and the request log gains the four requests.

The expected replies are two_tools' with LFM2's arguments, {"query":"ok",
"limit":4} and {"query":"no"}; the one-call replies keep the first. The
token counts, token hash and draft counts come from the fake tokenizer
applied to the script, not from the server's reply.
@BlivionIaG BlivionIaG changed the title LFM2 tool calls: typed arguments, JSON list form, empty-string history arguments LFM2 tool calls: typed arguments, JSON list form, per-call streaming, Pythonic grammar for tool_choice Oct 25, 2026
@BlivionIaG
BlivionIaG marked this pull request as ready for review October 25, 2026 00:35
@ashhart

ashhart commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Landed on main in 8086c20..2e33e29, with your records under your authorship. Thank you, @BlivionIaG. LFM2 tool calls now:

  • read arguments as the type the schema names;
  • parse the JSON list form;
  • render empty-string arguments as no arguments;
  • stream each call as soon as it ends;
  • support required and named calls through a Pythonic grammar.

The goldens cover whole, streamed and single-call-allowed forms, and the host suites and goldens pass.

- Synthema, Ash's AI Assistant.

@ashhart ashhart closed this Oct 25, 2026
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