[Rust Frontend] Align tool rendering for Kimi K3 (#50540)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Aug 4, 2026 at 16:03 UTC 0b1c151cbcacd12e207f2a91e4ca9f5b6961ec8a
4 files changed +85 -9
rust/src/chat/src/renderer/kimi_k3/encoding.rs
+9 -4
@@ -291,12 +291,17 @@ fn write_tool_declare(
291 let mut specs = Vec::with_capacity(tools.len());
292 for tool in tools {
293 let mut function = Map::new();
294 - function.insert(
295 - "description".to_string(),
296 - Value::String(tool.description.clone().unwrap_or_default()),
297 - );
294 + if let Some(description) = &tool.description {
295 + function.insert(
296 + "description".to_string(),
297 + Value::String(description.clone()),
298 + );
299 + }
300 function.insert("name".to_string(), Value::String(tool.name.clone()));
301 function.insert("parameters".to_string(), sort_json(&tool.parameters));
302 + if let Some(strict) = tool.strict {
303 + function.insert("strict".to_string(), Value::Bool(strict));
304 + }
305 specs.push(json!({
306 "function": Value::Object(function),
307 "type": "function",
rust/src/chat/src/renderer/kimi_k3/fixtures/dynamic_system_tool_declare_input.json
+7 -2
@@ -5,6 +5,7 @@
5 "function": {
6 "name": "get_weather",
7 "description": "weather",
8 + "strict": true,
9 "parameters": {
10 "type": "object",
11 "properties": {
@@ -34,12 +35,16 @@
35 "type": "function",
36 "function": {
37 "name": "calc",
37 - "description": "calculator",
38 + "strict": false,
39 "parameters": {
40 "type": "object",
41 "properties": {
42 "x": {
42 - "type": "number"
43 + "type": [
44 + "number",
45 + "null"
46 + ],
47 + "default": null
48 }
49 },
50 "required": [
rust/src/chat/src/renderer/kimi_k3/fixtures/dynamic_system_tool_declare_output.txt
+2 -2
@@ -2,7 +2,7 @@
2 Here are the available tools, described in JSONSchema.
3
4 ```json
5 -[{"function":{"description":"weather","name":"get_weather","parameters":{"properties":{"city":{"type":"string"},"days":{"type":"number"}},"required":["city"],"type":"object"}},"type":"function"}]
5 +[{"function":{"description":"weather","name":"get_weather","parameters":{"properties":{"city":{"type":"string"},"days":{"type":"number"}},"required":["city"],"type":"object"},"strict":true},"type":"function"}]
6 ```<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="thinking-effort"<|sep|>`thinking_effort` guides on how much to think in your thinking channel (not including the response channel), supported values include `low`, `medium`, `high`, and `max`.
7 Now the system is invoked with `thinking_effort=max`.<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>hi<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="tool-declare"<|sep|>## New Tools Available
8 The system dynamically extends the toolset via lazy-loading.
@@ -10,5 +10,5 @@ You have access to all existing and extended tools.
10 Here are the specs for the extended tools.
11
12 ```json
13 -[{"function":{"description":"calculator","name":"calc","parameters":{"properties":{"x":{"type":"number"}},"required":["x"],"type":"object"}},"type":"function"}]
13 +[{"function":{"name":"calc","parameters":{"properties":{"x":{"default":null,"type":["number","null"]}},"required":["x"],"type":"object"},"strict":false},"type":"function"}]
14 ```<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>use calc<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>
\ No newline at end of file
rust/src/chat/src/renderer/kimi_k3/tests.rs
+67 -1
@@ -17,7 +17,9 @@ use super::KimiK3ChatRenderer;
17 use crate::ChatRenderer;
18 use crate::renderer::kimi_k3::encoding::{CLOSE, END_OF_MSG, IMAGE_PLACEHOLDER, OPEN, SEP};
19 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
20 -use crate::request::{ChatContentPart, ChatMessage, GenerationPromptMode, ReasoningEffort};
20 +use crate::request::{
21 + ChatContentPart, ChatMessage, ChatTool, GenerationPromptMode, ReasoningEffort,
22 +};
23 use crate::{AssistantContentBlock, AssistantToolCall};
24
25 const OPEN_ID: u32 = 256;
@@ -90,6 +92,70 @@ fn golden_dynamic_system_tool_declare() {
92 assert_golden("dynamic_system_tool_declare");
93 }
94
95 +#[test]
96 +fn tool_declare_omits_absent_fields_and_preserves_parameter_nulls() {
97 + let mut request = crate::request::ChatRequest::for_test();
98 + request.tools = vec![ChatTool {
99 + name: "lookup".to_string(),
100 + description: None,
101 + parameters: json!({
102 + "type": "object",
103 + "properties": {
104 + "limit": {
105 + "type": ["integer", "null"],
106 + "default": null
107 + }
108 + }
109 + }),
110 + strict: None,
111 + }];
112 +
113 + let rendered = render_request(&request);
114 +
115 + assert!(rendered.contains(
116 + r#"[{"function":{"name":"lookup","parameters":{"properties":{"limit":{"default":null,"type":["integer","null"]}},"type":"object"}},"type":"function"}]"#
117 + ));
118 + assert!(!rendered.contains(r#""description":"#));
119 + assert!(!rendered.contains(r#""strict":"#));
120 +}
121 +
122 +#[test]
123 +fn tool_declare_preserves_present_optional_fields() {
124 + let mut request = crate::request::ChatRequest::for_test();
125 + request.tools = vec![ChatTool {
126 + name: "lookup".to_string(),
127 + description: Some("Look up a record".to_string()),
128 + parameters: json!({"type": "object"}),
129 + strict: Some(false),
130 + }];
131 +
132 + let rendered = render_request(&request);
133 +
134 + assert!(rendered.contains(
135 + r#"[{"function":{"description":"Look up a record","name":"lookup","parameters":{"type":"object"},"strict":false},"type":"function"}]"#
136 + ));
137 +}
138 +
139 +#[test]
140 +fn malformed_tool_arguments_render_as_raw_text() {
141 + let malformed = " {\"city\":\"Beijing\" ";
142 + let mut request = crate::request::ChatRequest::for_test();
143 + request.messages = vec![ChatMessage::assistant_blocks(vec![
144 + AssistantContentBlock::ToolCall(AssistantToolCall {
145 + id: "call-1".to_string(),
146 + name: "weather".to_string(),
147 + arguments: malformed.to_string(),
148 + }),
149 + ])];
150 + request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
151 +
152 + let rendered = render_request(&request);
153 +
154 + assert!(rendered.contains(&format!(
155 + "<|open|>json type=\"object\"<|sep|>{malformed}<|close|>json<|sep|>"
156 + )));
157 +}
158 +
159 #[test]
160 fn token_writer_protects_literal_control_and_media_markers() {
161 let tokenizer = Arc::new(test_tokenizer());