feat(kimi_k3): wire up tool calling — XTML per Moonshot's reference renderer (#1143) - #1144
Conversation
Kimi K3 was the last flagship-class engine returning 400 for tools (JustVugg#1029, JustVugg#1143). The format is verified against Moonshot's normative renderer (encoding_k3.py in the checkpoint repo): tool use is pure XTML over the four existing special tokens — declarations as a type="tool-declare" system turn, assistant calls as tools > call > argument tags with per-argument XTML types (non-string values keep their exact JSON literal), results as role="tool" turns re-sorted into tool_calls order by tool_call_id, and tool_choice as internal tool-choice system messages. Gateway: render_chat_kimi lifts the 400 and extends K3CHAT1 with typed records (Y typed-system, O tool-result, B/F/V/J call history) — records, never pre-rendered text, because K3's rank-BPE segment boundaries are the tokenizer contract and tags must keep being constructed engine-side. parse_k3_tool_calls turns the re-emitted XTML back into OpenAI tool_calls, with the same unclosed-block recovery posture as the GLM path; the stream marker machinery gains the K3 marker so tool blocks never leak into client deltas. Engine: chat_build_wire parses the new records through the existing ChatB primitives plus cb_attr (attribute segmentation mirrors the reference: " key", '="', escaped value, '"' are each their own segment; & and " escape as &/"). The serve loop re-emits tool-structural XTML runs literally — everything else stays suppressed as before — so the gateway can parse structure the engine used to discard. Tests: test_k3_chat_tools.c renders the records against the tiny Kimi tokenizer (now carrying the four XTML tokens) and asserts the exact reference byte stream, including attr escaping and malformed-record refusal; gateway unit tests cover declaration/choice/history/results/ argument typing/json fallback/id-resort; test_openai_tools_k3_e2e.py runs the real gateway against a mock SERVE engine for both response shapes and chunk-straddling stream suppression. The Anthropic-surface test moves kimi from the reject list to a render assertion. Not covered here: a run against the real 2.8T checkpoint — the e2e layer is engine-mocked by design, and a real-model smoke on a K3-capable host is the honest final gate.
|
Timing: you opened this at 13:08 and my (b) call on #1143 landed after, so you wrote (a) exactly as you said you would and this is not you ignoring a decision. The offer to rework the output side is appreciated, and yes — I am holding to (b), with two caveats that matter given how today has gone. First caveat, and you should weigh it: I was confidently wrong twice in the last hour on #1136, and one of those wrong calls sent you to rewrite correct documentation into incorrect documentation. So do not treat this call as authoritative because it is mine. If the reasoning below does not survive contact with the codec, say so and I will move. The reasoning, restated so you can attack it. The engine sees The cost objection you raised in the design write-up was real when you wrote it and is not anymore: And the part I am not asking you to carry: GLM migrating to the same mechanism is ours. You are not fixing GLM's hole as the price of doing K3 correctly; you are declining to copy it, and we will make the two symmetric afterwards. On the rest of this PR, nothing changes and it is good work. The format side is the part I cannot check without the checkpoint and the part most likely to rot silently, so going to Since you say the rest is independent of the output choice: if the rework turns out to be larger than a day, split it — land the format, gateway records and tests behind a flag that keeps the 400, then flip it on with the sideband. Better than one PR ageing while dev moves under it, which is how #790 and #601 got where they are. Ping me when it is up and I will review the codec change the same day. |
|
Merged — and I owe you an apology, because I blocked this on a claim I had not verified. I told you that with option (a) a spoofed marker on K3 "is an action that runs". That is false, and I should have checked before writing it. The second thing I got wrong is the one that actually flips the decision. GLM has shipped this exact mechanism for months. So (a) on K3 does not open a new class of exposure, it extends an existing one — and if K3 had gone to the sideband while GLM kept markers, the gateway would carry two mechanisms until GLM migrated. This repo has already paid for that shape once: byte framing duplicated five times is how Windows binary mode silently disappeared from sibling engines (#748). Doing the sideband once, for both engines together, is better than doing K3 specially and retrofitting GLM afterwards. So the sideband work becomes ours and covers both engines. You are not reworking anything. To be explicit about the sequence, since I sent you back and forth today: you wrote (a) as announced, I asked for (b) on a bad premise, you offered to rework rather than argue, and the rework turned out to be unnecessary — the right call was the one you made at the start. The 852 lines here are unaffected by any of it: the format work against #1029 closes with this: Kimi K3 was the last flagship engine returning |
Implements #1143 (closes #1029's last flagship gap), option (a) from the design write-up — literal re-emission, GLM-precedent marker suppression. If (b) is preferred I'll rework the output side; everything else is independent of that choice.
What's in
Format — verified against
encoding_k3.pyin the Kimi-K3 checkpoint repo (the normative renderer; K3 has no jinja template). All four surfaces:tool-declaresystem turn,tools > call > argumentblocks with per-argument XTML types (non-strings keep their exact JSON literal —1e2stays1e2, one-level-deep parse mirrored from the reference),role="tool"result turns re-sorted intotool_callsorder bytool_call_id(the reference's normalization),tool_choicerequired/none/forced.Gateway —
K3CHAT1grows typed records (Y/O/B/F/V/J) instead of pre-rendered text: K3's rank-BPE segment boundaries are the tokenizer contract, so tags keep being constructed engine-side.parse_k3_tool_calls+ the stream-marker machinery handle the return path, with the GLM path's unclosed-block recovery posture.Engine —
chat_build_wireparses the records through the existingChatBprimitives pluscb_attr(attribute segmentation matches the reference exactly:" key",'="', escaped value,'"'each their own segment;&/"→&/"). The serve loop re-emits tool-structural runs only literally; every other XTML run stays suppressed exactly as before, so non-tool traffic is byte-identical to today.Tests (three layers)
test_k3_chat_tools.c— golden: records rendered against the tiny Kimi tokenizer (which now carries the four XTML tokens) must reproduce the reference byte stream exactly, incl. attr escaping and malformed-record refusal. 7/7.test_openai_tools_k3_e2e.py— real gateway subprocess vs mock SERVE engine: both response shapes, chunk-straddling stream suppression (markers split across 25 DATA frames),tool_choice=noneomits the declaration. 4/4.test_openai*163/163, Anthropic surface 28/28 (kimi moved from the reject-list test to a render assertion),make test-cgreen, zero-warning builds.Stated limits
tool_choice=nonefollows the V4 gateway precedent (tools simply not offered) rather than the reference's MUST-NOT message — noted in feat(kimi_k3): wire up tool calling — XTML format verified against Moonshot's reference renderer, design for review #1143, trivially changeable if you'd rather mirror the reference.