Skip to content

[Bug]: [Parser] Kimi-K3 tool-call IDs are message-scoped and repeat across turns #50295

Description

@w200kr

Your current environment

vLLM built from main (VLLM_TARGET_DEVICE=empty), CPU-only container, no GPU and no model
weights — the K3 tool parser is pure text processing, so the reproduction needs only a vLLM
install.

🐛 Describe the bug

KimiK3ToolParser._decode_call builds the tool-call ID from the XTML index attribute, which
is the call's position within one assistant message. It restarts every message, so the same
tool called on successive turns gets the same ID.

Reproduction

from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest

OPEN, CLOSE, SEP = "<|open|>", "<|close|>", "<|sep|>"

class DummyTokenizer:
    def get_vocab(self): return {}
    def encode(self, text, add_special_tokens=False): return [ord(c) for c in text]

def arg(k, t, v):     return f'{OPEN}argument key="{k}" type="{t}"{SEP}{v}{CLOSE}argument{SEP}'
def call(tool, i, *a): return f'{OPEN}call tool="{tool}" index="{i}"{SEP}{"".join(a)}{CLOSE}call{SEP}'
def tools(*c):        return f'{OPEN}tools{SEP}{"".join(c)}{CLOSE}tools{SEP}'

req = ChatCompletionRequest(
    model="m", messages=[], tool_choice="auto",
    tools=[{"type": "function",
            "function": {"name": "get_weather",
                         "parameters": {"type": "object", "properties": {}}}}])

p = KimiK3ToolParser(DummyTokenizer())

# two separate assistant messages, each with one call at index 1
a = p.extract_tool_calls(tools(call("get_weather", 1, arg("city", "string", "Tokyo"))), req)
b = p.extract_tool_calls(tools(call("get_weather", 1, arg("city", "string", "Paris"))), req)
print("msg 1 ->", [c.id for c in a.tool_calls])
print("msg 2 ->", [c.id for c in b.tool_calls])

# one message, two calls
c = p.extract_tool_calls(tools(call("get_weather", 1, arg("city", "string", "Tokyo")),
                               call("get_weather", 2, arg("city", "string", "Paris"))), req)
print("one msg ->", [x.id for x in c.tool_calls])

Output:

msg 1 -> ['get_weather:0']
msg 2 -> ['get_weather:0']
one msg -> ['get_weather:0', 'get_weather:1']

vLLM itself is unaffected: the K3 renderer rebuilds tool_call_id_index per assistant message
and writes a recomputed positional index into XTML, so duplicate IDs never reach the model.
What breaks is downstream — consumers that key tool results by ID cannot tell the calls apart.

Changing the ID looks safe

The renderer uses the API-side ID only to match tool results back to the preceding assistant
calls; it never renders the literal value, and the XTML index is regenerated from call
position (renderer/kimi_k3/encoding.rs). Moonshot's
encoding_k3.py describes
the same contract — it matches results by an "opaque tool_call_id" and notes that "K3 drops
the func:index format requirement".

The change

Stop deriving the API-side ID from the XTML index, and let the existing fallbacks take over:

  • Rust — in rust/src/parser/src/unified/kimi_k3.rs, stop populating the ID so
    tool_call_id() returns None; process_tool_item in
    rust/src/chat/src/output/default/unified.rs then falls back to generate_tool_call_id().
    (call_ids doubles as the emitted-call counter, so it stays.)
  • Python — omit the explicit id= when constructing ToolCall; its own
    Field(default_factory=make_tool_call_id) yields chatcmpl-tool-<uuid>. The streaming path
    carries the same value through DeltaToolCall(id=tc.id, ...).

The parser test assertions pin the current IDs and would move with it; the renderer fixtures
take the ID as opaque client input and are unaffected.

Not K2/K2.5 — there the ID is prompt text and the parser preserves the model-generated
value verbatim (rust/src/parser/src/tool/kimi_k2.rs).

K3 landed in #50104 (2026-07-28) and #50093 (2026-07-29); the latest release, v0.26.0, predates
both, so the ID format has not shipped yet.

Happy to open a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions