diff --git a/rust/src/chat/src/renderer/kimi_k3/encoding.rs b/rust/src/chat/src/renderer/kimi_k3/encoding.rs index 189d68c101..242c1e84c2 100644 --- a/rust/src/chat/src/renderer/kimi_k3/encoding.rs +++ b/rust/src/chat/src/renderer/kimi_k3/encoding.rs @@ -126,7 +126,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) - resolved.sort_by_key(|item| (item.0, item.1)); } - for (xtml_index, (_, _, content, name)) in resolved.into_iter().enumerate() { + for (position, _, content, name) in resolved { let tool_name = name.as_deref().ok_or_else(|| { Error::ChatTemplate( "Kimi K3 tool messages need a resolvable tool name: \ @@ -135,7 +135,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) - .to_string(), ) })?; - write_tool_message(out, tool_name, xtml_index + 1, &content)?; + write_tool_message(out, tool_name, position, &content)?; } Ok(()) }; diff --git a/rust/src/chat/src/renderer/kimi_k3/tests.rs b/rust/src/chat/src/renderer/kimi_k3/tests.rs index 7bab90ddcf..d1fd6581f5 100644 --- a/rust/src/chat/src/renderer/kimi_k3/tests.rs +++ b/rust/src/chat/src/renderer/kimi_k3/tests.rs @@ -14,11 +14,11 @@ use vllm_tokenizer::Tokenizer; use vllm_tokenizer::test_utils::TestTokenizer; use super::KimiK3ChatRenderer; -use crate::AssistantContentBlock; use crate::ChatRenderer; use crate::renderer::kimi_k3::encoding::{CLOSE, END_OF_MSG, IMAGE_PLACEHOLDER, OPEN, SEP}; use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request}; use crate::request::{ChatContentPart, ChatMessage, GenerationPromptMode, ReasoningEffort}; +use crate::{AssistantContentBlock, AssistantToolCall}; const OPEN_ID: u32 = 256; const CLOSE_ID: u32 = 257; @@ -178,6 +178,35 @@ fn non_thinking_history_omits_reasoning_channel() { )); } +#[test] +fn partial_tool_results_keep_assistant_call_position() { + let mut request = crate::request::ChatRequest::for_test(); + request.messages = vec![ + ChatMessage::assistant_blocks(vec![ + AssistantContentBlock::ToolCall(AssistantToolCall { + id: "call-a".to_string(), + name: "weather".to_string(), + arguments: "{}".to_string(), + }), + AssistantContentBlock::ToolCall(AssistantToolCall { + id: "call-b".to_string(), + name: "search".to_string(), + arguments: "{}".to_string(), + }), + ]), + ChatMessage::tool_response("result-b", "call-b"), + ]; + request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt; + + let rendered = render_request(&request); + + assert!(rendered.contains("<|open|>call tool=\"search\" index=\"2\"<|sep|>")); + assert!( + rendered + .contains("<|open|>message role=\"tool\" tool=\"search\" index=\"2\"<|sep|>result-b") + ); +} + #[test] fn defaults_thinking_effort_to_max() { let rendered = render_request(&crate::request::ChatRequest::for_test()); diff --git a/rust/src/parser/src/unified/kimi_k3.rs b/rust/src/parser/src/unified/kimi_k3.rs index 7e71622588..a721b29f3f 100644 --- a/rust/src/parser/src/unified/kimi_k3.rs +++ b/rust/src/parser/src/unified/kimi_k3.rs @@ -148,9 +148,8 @@ enum KimiK3Mode { pub struct KimiK3UnifiedParser { buffer: String, mode: KimiK3Mode, - /// Parser-provided tool-call IDs (`{tool}:{zero_based_index}`) by tool - /// index; its length is also the count of emitted calls. - call_ids: Vec, + /// Number of calls emitted in the current response. + emitted_call_count: usize, tokenizer: DynTokenizer, open_token_id: u32, sep_token_id: u32, @@ -165,7 +164,7 @@ impl KimiK3UnifiedParser { Ok(Self { buffer: String::new(), mode: KimiK3Mode::default(), - call_ids: Vec::new(), + emitted_call_count: 0, tokenizer, open_token_id, sep_token_id, @@ -226,7 +225,7 @@ impl KimiK3UnifiedParser { } KimiK3Event::CallComplete { arguments } => { let mode = std::mem::replace(&mut self.mode, KimiK3Mode::Tools); - let KimiK3Mode::Call { name, index, .. } = mode else { + let KimiK3Mode::Call { name, .. } = mode else { return Err(parsing_failed!( "Kimi K3 call completion without an active tool call" )); @@ -237,8 +236,8 @@ impl KimiK3UnifiedParser { return Ok(()); } - let tool_index = self.call_ids.len(); - self.call_ids.push(tool_call_id_for(&name, index.as_deref())); + let tool_index = self.emitted_call_count; + self.emitted_call_count += 1; output.push_call(ToolCallDelta { tool_index, name: Some(name), @@ -251,7 +250,7 @@ impl KimiK3UnifiedParser { fn reset_state(&mut self) -> String { self.mode = KimiK3Mode::Idle; - self.call_ids.clear(); + self.emitted_call_count = 0; std::mem::take(&mut self.buffer) } } @@ -266,7 +265,7 @@ impl UnifiedParser for KimiK3UnifiedParser { fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { self.buffer.clear(); - self.call_ids.clear(); + self.emitted_call_count = 0; self.initialize_mode(prompt_token_ids); Ok(()) } @@ -279,10 +278,6 @@ impl UnifiedParser for KimiK3UnifiedParser { Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER) } - fn tool_call_id(&self, tool_index: usize) -> Option<&str> { - self.call_ids.get(tool_index).map(String::as_str) - } - fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> { self.buffer.push_str(chunk); @@ -313,7 +308,6 @@ impl UnifiedParser for KimiK3UnifiedParser { } } - // Keep call_ids so tool_call_id() stays available after the stream ends. self.mode = KimiK3Mode::Idle; Ok(output) } @@ -323,20 +317,6 @@ impl UnifiedParser for KimiK3UnifiedParser { } } -/// Build the API-side tool-call ID from the XTML one-based `index` attribute. -/// -/// The ID uses the zero-based call ordinal; XTML's message index stays -/// one-based when rendering tool result messages. -fn tool_call_id_for(name: &str, index: Option<&str>) -> String { - match index { - None => name.to_string(), - Some(raw) => match raw.parse::() { - Ok(one_based) => format!("{name}:{}", one_based - 1), - Err(_) => format!("{name}:{raw}"), - }, - } -} - /// Parse one Kimi K3 event from buffered streaming input. fn parse_next_kimi_k3_event( input: &mut KimiK3Input<'_>, @@ -762,7 +742,6 @@ mod tests { "hours": [8, 20], }) ); - assert_eq!(parser.tool_call_id(0), Some("get_weather:0")); } #[test] @@ -929,8 +908,6 @@ mod tests { assert_eq!(calls[1].tool_index, 1); assert_eq!(calls[1].name.as_deref(), Some("get_time")); assert_eq!(calls[1].arguments, "{}"); - assert_eq!(parser.tool_call_id(0), Some("get_weather:0")); - assert_eq!(parser.tool_call_id(1), Some("get_time:1")); } #[test] @@ -1018,11 +995,10 @@ mod tests { assert_eq!(calls.len(), 1); assert_eq!(calls[0].tool_index, 0); assert_eq!(calls[0].name.as_deref(), Some("real")); - assert_eq!(parser.tool_call_id(0), Some("real:1")); } #[test] - fn kimi_k3_tool_call_id_follows_index_attribute() { + fn kimi_k3_tool_indices_ignore_xtml_index_attribute() { let tools_body = format!( "{}{}{}", call("tool=\"first\" index=\"3\"", ""), @@ -1032,11 +1008,12 @@ mod tests { let text = thinking_output("t", "", &tools_body); let mut parser = test_parser(); - parser.parse_complete(&text).unwrap(); + let output = parser.parse_complete(&text).unwrap(); - assert_eq!(parser.tool_call_id(0), Some("first:2")); - assert_eq!(parser.tool_call_id(1), Some("second")); - assert_eq!(parser.tool_call_id(2), Some("third:x")); + assert_eq!( + output.calls().iter().map(|call| call.tool_index).collect::>(), + [0, 1, 2] + ); } #[test] diff --git a/tests/tool_use/test_kimi_k3_tool_parser.py b/tests/tool_use/test_kimi_k3_tool_parser.py index 94b49c178e..118c51db57 100644 --- a/tests/tool_use/test_kimi_k3_tool_parser.py +++ b/tests/tool_use/test_kimi_k3_tool_parser.py @@ -141,7 +141,6 @@ def test_extract_tool_calls_with_response_and_typed_arguments(): assert extracted.content == "answer" assert len(extracted.tool_calls) == 1 tool_call = extracted.tool_calls[0] - assert tool_call.id == "calc:0" assert tool_call.function.name == "calc" assert json.loads(tool_call.function.arguments) == { "x": 1, @@ -168,7 +167,6 @@ def test_delegating_parser_preserves_tool_calls_after_reasoning(): assert content == "answer" assert tool_calls is not None assert len(tool_calls) == 1 - assert tool_calls[0].id == "calc:0" assert tool_calls[0].name == "calc" assert json.loads(tool_calls[0].arguments) == {"x": 1} @@ -364,11 +362,19 @@ def test_streaming_split_markers_do_not_leak(): assert OPEN not in content assert SEP not in content assert len(tool_deltas) == 1 - assert tool_deltas[0].id == "calc:0" assert tool_deltas[0].function.name == "calc" assert json.loads(tool_deltas[0].function.arguments) == {"x": 1} +def test_tool_call_ids_are_unique_across_messages(): + output = _tools(_call("calc", 1)) + + first = KimiK3ToolParser(DummyTokenizer()).extract_tool_calls(output, _request()) + second = KimiK3ToolParser(DummyTokenizer()).extract_tool_calls(output, _request()) + + assert first.tool_calls[0].id != second.tool_calls[0].id + + def test_streaming_consumed_response_prefix_no_call_keeps_content(): parser = KimiK3ToolParser(DummyTokenizer()) request = _request() diff --git a/vllm/tool_parsers/kimi_k3_tool_parser.py b/vllm/tool_parsers/kimi_k3_tool_parser.py index a907073bae..9f688f2270 100644 --- a/vllm/tool_parsers/kimi_k3_tool_parser.py +++ b/vllm/tool_parsers/kimi_k3_tool_parser.py @@ -218,7 +218,6 @@ class KimiK3ToolParser(ToolParser): """ call_attrs = self._attrs(attrs) tool_name = call_attrs.get("tool", "") - tool_index = call_attrs.get("index", "") arguments: dict = {} for arg_match in self._arg_re.finditer(body): arg_attrs = self._attrs(arg_match["attrs"]) @@ -234,16 +233,7 @@ class KimiK3ToolParser(ToolParser): arguments[key] = raw_value if not tool_name: return None - tool_call_id = tool_name - if tool_index: - try: - tool_call_id = f"{tool_name}:{int(tool_index) - 1}" - except ValueError: - tool_call_id = f"{tool_name}:{tool_index}" - # id uses the API-side zero-based call ordinal; XTML's message index - # stays one-based when rendering tool result messages. return ToolCall( - id=tool_call_id, type="function", function=FunctionCall( name=tool_name,