Add OpenAI tool calling to the chat service

#1
by nobuddhy - opened
CMakeLists.txt CHANGED
@@ -122,6 +122,11 @@ if(BUILD_TESTING)
122
  target_compile_features(ling3-chat-protocol-test PRIVATE cxx_std_20)
123
  target_include_directories(ling3-chat-protocol-test PRIVATE include third_party/nlohmann)
124
  add_test(NAME ling3-chat-protocol-test COMMAND ling3-chat-protocol-test)
 
 
 
 
 
125
  add_executable(ling3-router-test tests/router_test.cpp)
126
  target_link_libraries(ling3-router-test PRIVATE ling3_core)
127
  add_test(NAME ling3-router-test COMMAND ling3-router-test)
 
122
  target_compile_features(ling3-chat-protocol-test PRIVATE cxx_std_20)
123
  target_include_directories(ling3-chat-protocol-test PRIVATE include third_party/nlohmann)
124
  add_test(NAME ling3-chat-protocol-test COMMAND ling3-chat-protocol-test)
125
+ add_executable(ling3-chat-tools-test tests/chat_tools_test.cpp)
126
+ target_compile_features(ling3-chat-tools-test PRIVATE cxx_std_20)
127
+ target_include_directories(ling3-chat-tools-test PRIVATE include third_party/nlohmann)
128
+ add_test(NAME ling3-chat-tools-test COMMAND ling3-chat-tools-test
129
+ ${CMAKE_CURRENT_SOURCE_DIR}/tests/chat_tools_golden.json)
130
  add_executable(ling3-router-test tests/router_test.cpp)
131
  target_link_libraries(ling3-router-test PRIVATE ling3_core)
132
  add_test(NAME ling3-router-test COMMAND ling3-router-test)
include/ling3/chat_protocol.h CHANGED
@@ -3,14 +3,20 @@
3
  #include <nlohmann/json.hpp>
4
  #include <algorithm>
5
  #include <cmath>
 
6
  #include <cstdint>
 
7
  #include <limits>
8
  #include <stdexcept>
9
  #include <string>
 
 
10
  #include <vector>
11
 
12
  namespace ling3::chat {
13
  using Json = nlohmann::json;
 
 
14
  inline constexpr const char * kModel = "mindnano-ling3-tiny";
15
  struct Error : std::runtime_error {
16
  int status;
@@ -36,6 +42,8 @@ struct Request {
36
  bool enable_thinking = false, flow_ack = false;
37
  std::uint32_t seed = 0;
38
  std::vector<std::string> stops;
 
 
39
  };
40
  inline std::size_t OutputBudget(std::size_t prompt, std::size_t capacity,
41
  std::size_t requested) {
@@ -44,14 +52,43 @@ inline std::size_t OutputBudget(std::size_t prompt, std::size_t capacity,
44
  std::to_string(capacity) + " tokens, or is empty");
45
  return std::min(requested, capacity - prompt);
46
  }
47
- inline Request Parse(const Json & j) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  auto fail = [](std::string m) { throw Error(400, "invalid_parameter", std::move(m)); };
49
  if (!j.is_object()) fail("request must be a JSON object");
50
  for (auto it = j.begin(); it != j.end(); ++it) {
51
  const std::vector<std::string> supported {"model", "messages", "max_tokens",
52
  "max_completion_tokens", "stream", "stream_options", "temperature", "top_p",
53
  "seed", "stop", "n", "user", "cache_prompt", "top_k", "repeat_penalty",
54
- "enable_thinking", "flow_control", "session_id", "reuse_generated_state"};
 
55
  if (std::find(supported.begin(), supported.end(), it.key()) == supported.end())
56
  fail("unsupported parameter: " + it.key());
57
  }
@@ -131,10 +168,12 @@ inline Request Parse(const Json & j) {
131
  r.seed = j["seed"].get<std::uint32_t>();
132
  }
133
  if (j.contains("n") && (!j["n"].is_number_integer() || j["n"] != 1)) fail("only n=1 is supported");
 
 
134
  if (j.contains("user") && !j["user"].is_string()) fail("user must be a string");
135
  if (j.contains("user")) r.cache_user = j["user"].get<std::string>();
136
  if (j.contains("stop") && !j["stop"].is_null()) {
137
- const auto s = j["stop"].is_string() ? Json::array({j["stop"]}) : j["stop"];
138
  if (!s.is_array() || s.size() > 4) fail("stop must be a string or at most 4 strings");
139
  for (const auto & v : s) {
140
  if (!v.is_string() || v.get_ref<const std::string &>().empty() ||
@@ -142,59 +181,133 @@ inline Request Parse(const Json & j) {
142
  r.stops.push_back(v.get<std::string>());
143
  }
144
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  if (!j.contains("messages") || !j["messages"].is_array() || j["messages"].empty())
146
  fail("messages must be a non-empty array");
147
- const auto & messages = j["messages"];
148
- for (const auto & m : messages) {
149
- if (!m.is_object() || !m.contains("role") || !m["role"].is_string() ||
150
- !m.contains("content") || !m["content"].is_string())
151
- fail("messages require string role and content; text only");
152
- const auto role = m["role"].get<std::string>();
153
- if (role != "system" && role != "user" && role != "assistant") fail("unsupported message role");
 
 
 
154
  for (auto it = m.begin(); it != m.end(); ++it)
155
- if (it.key() != "role" && it.key() != "content") fail("unsupported message field: " + it.key());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  }
157
- if (messages.back()["role"] != "user") fail("last message must be a user message");
158
- // Bailing V3 text template. No hidden, server-owned conversation.
 
159
  const std::string thinking = r.enable_thinking ? "on" : "off";
160
  r.prompt = "<role>SYSTEM</role>";
161
  std::size_t start = 0;
162
- if (messages.front()["role"] == "system") {
163
- auto s = messages.front()["content"].get<std::string>();
 
 
164
  // The explicit API switch owns template control. Normalize conflicting
165
  // legacy instructions rather than producing two contradictory modes.
166
  for (const auto old : {std::string("detailed thinking off"), std::string("detailed thinking on")}) {
167
  std::size_t at = 0;
168
- while ((at = s.find(old, at)) != std::string::npos) {
169
  const auto replacement = "detailed thinking " + thinking;
170
- s.replace(at, old.size(), replacement); at += replacement.size();
171
  }
172
  }
173
- r.prompt += s;
174
- if (s.find("detailed thinking off") == std::string::npos && s.find("detailed thinking on") == std::string::npos)
175
- r.prompt += "\ndetailed thinking " + thinking;
176
  start = 1;
177
- } else r.prompt += "detailed thinking " + thinking;
 
 
 
 
 
 
 
 
178
  r.prompt += "<|role_end|>";
 
 
 
 
 
179
  for (std::size_t i = start; i < messages.size(); ++i) {
180
- const auto role = messages[i]["role"].get<std::string>();
181
- auto content = messages[i]["content"].get<std::string>();
182
- if (role == "assistant") {
183
- const auto close = content.find("</think>");
184
- if (close == std::string::npos) r.prompt += "<role>ASSISTANT</role>\n<think></think>";
185
- else {
186
- auto reasoning = content.substr(0, close);
187
- const auto open = reasoning.rfind("<think>");
188
- if (open != std::string::npos) reasoning.erase(0, open + 7);
189
- const auto first = reasoning.find_first_not_of('\n');
190
- reasoning = first == std::string::npos ? "" : reasoning.substr(first, reasoning.find_last_not_of('\n') - first + 1);
191
- content = content.substr(content.rfind("</think>") + 8);
192
- content.erase(0, std::min(content.find_first_not_of('\n'), content.size()));
193
- r.prompt += "<role>ASSISTANT</role>\n<think>" + reasoning + "</think>";
194
- }
 
 
195
  }
196
- else r.prompt += role == "user" ? "<role>HUMAN</role>" : "<role>SYSTEM</role>";
197
- r.prompt += content + "<|role_end|>";
 
 
 
198
  }
199
  r.prompt += r.enable_thinking ? "<role>ASSISTANT</role>\n<think>" : "<role>ASSISTANT</role>\n<think></think>";
200
  return r;
@@ -245,4 +358,95 @@ public:
245
  return TakeUtf8(utf8_, final);
246
  }
247
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  } // namespace ling3::chat
 
3
  #include <nlohmann/json.hpp>
4
  #include <algorithm>
5
  #include <cmath>
6
+ #include <cctype>
7
  #include <cstdint>
8
+ #include <functional>
9
  #include <limits>
10
  #include <stdexcept>
11
  #include <string>
12
+ #include <string_view>
13
+ #include <utility>
14
  #include <vector>
15
 
16
  namespace ling3::chat {
17
  using Json = nlohmann::json;
18
+ // Request bodies keep key order: the template renders tools as the client sent them.
19
+ using OrderedJson = nlohmann::ordered_json;
20
  inline constexpr const char * kModel = "mindnano-ling3-tiny";
21
  struct Error : std::runtime_error {
22
  int status;
 
42
  bool enable_thinking = false, flow_ack = false;
43
  std::uint32_t seed = 0;
44
  std::vector<std::string> stops;
45
+ OrderedJson tools = OrderedJson::array();
46
+ bool tool_calls = false, parallel_tool_calls = true; // parse calls from the output
47
  };
48
  inline std::size_t OutputBudget(std::size_t prompt, std::size_t capacity,
49
  std::size_t requested) {
 
52
  std::to_string(capacity) + " tokens, or is empty");
53
  return std::min(requested, capacity - prompt);
54
  }
55
+ // The template's tojson filter is Python json.dumps(ensure_ascii=False):
56
+ // ", " and ": " separators, keys in the order the client sent them.
57
+ inline std::string TemplateJson(const OrderedJson & v) {
58
+ if (!v.is_object() && !v.is_array()) return v.dump();
59
+ std::string s = v.is_object() ? "{" : "[";
60
+ for (auto it = v.begin(); it != v.end(); ++it) {
61
+ if (it != v.begin()) s += ", ";
62
+ if (v.is_object()) s += OrderedJson(it.key()).dump() + ": ";
63
+ s += TemplateJson(*it);
64
+ }
65
+ return s + (v.is_object() ? "}" : "]");
66
+ }
67
+ inline constexpr const char * kToolsHeader = "# Tools\n\nYou may call one or more functions to assist "
68
+ "with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>";
69
+ inline constexpr const char * kToolsFooter = "\n</tools>\n\nIf none of the functions can be used, point it "
70
+ "out. If the given question lacks the parameters required by the function, also point it out.\nIf you need "
71
+ "to use a function, for each function call, output the function name and arguments within the following XML "
72
+ "format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n"
73
+ "<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\n";
74
+ struct ToolCall { std::string name; OrderedJson arguments = OrderedJson::object(); };
75
+ // String values are raw text and others JSON, exactly as the template renders history.
76
+ inline std::string RenderToolCall(const ToolCall & call) {
77
+ std::string s = "<tool_call>" + call.name + "\n";
78
+ for (auto it = call.arguments.begin(); it != call.arguments.end(); ++it)
79
+ s += "<arg_key>" + it.key() + "</arg_key>\n<arg_value>" +
80
+ (it->is_string() ? it->get<std::string>() : TemplateJson(*it)) + "</arg_value>";
81
+ return s + "\n</tool_call>";
82
+ }
83
+ inline Request Parse(const OrderedJson & j) {
84
  auto fail = [](std::string m) { throw Error(400, "invalid_parameter", std::move(m)); };
85
  if (!j.is_object()) fail("request must be a JSON object");
86
  for (auto it = j.begin(); it != j.end(); ++it) {
87
  const std::vector<std::string> supported {"model", "messages", "max_tokens",
88
  "max_completion_tokens", "stream", "stream_options", "temperature", "top_p",
89
  "seed", "stop", "n", "user", "cache_prompt", "top_k", "repeat_penalty",
90
+ "enable_thinking", "flow_control", "session_id", "reuse_generated_state",
91
+ "tools", "tool_choice", "parallel_tool_calls", "store"};
92
  if (std::find(supported.begin(), supported.end(), it.key()) == supported.end())
93
  fail("unsupported parameter: " + it.key());
94
  }
 
168
  r.seed = j["seed"].get<std::uint32_t>();
169
  }
170
  if (j.contains("n") && (!j["n"].is_number_integer() || j["n"] != 1)) fail("only n=1 is supported");
171
+ // Agent clients send OpenAI's store flag; this server never stores completions.
172
+ if (j.contains("store") && !j["store"].is_boolean()) fail("store must be boolean");
173
  if (j.contains("user") && !j["user"].is_string()) fail("user must be a string");
174
  if (j.contains("user")) r.cache_user = j["user"].get<std::string>();
175
  if (j.contains("stop") && !j["stop"].is_null()) {
176
+ const auto s = j["stop"].is_string() ? OrderedJson::array({j["stop"]}) : j["stop"];
177
  if (!s.is_array() || s.size() > 4) fail("stop must be a string or at most 4 strings");
178
  for (const auto & v : s) {
179
  if (!v.is_string() || v.get_ref<const std::string &>().empty() ||
 
181
  r.stops.push_back(v.get<std::string>());
182
  }
183
  }
184
+ if (j.contains("tools") && !j["tools"].is_null()) {
185
+ if (!j["tools"].is_array()) fail("tools must be an array");
186
+ for (const auto & t : j["tools"])
187
+ if (!t.is_object() || !t.contains("type") || t["type"] != "function" || !t.contains("function") ||
188
+ !t["function"].is_object() || !t["function"].contains("name") || !t["function"]["name"].is_string() ||
189
+ t["function"]["name"].get_ref<const std::string &>().empty())
190
+ fail("tools must be named function tools");
191
+ r.tools = j["tools"];
192
+ }
193
+ // Calls are parsed after generation, so only auto and none can be honored.
194
+ const bool no_tool_choice = !j.contains("tool_choice") || j["tool_choice"].is_null();
195
+ if (!no_tool_choice && j["tool_choice"] != "auto" && j["tool_choice"] != "none")
196
+ fail("tool_choice must be auto or none");
197
+ if (j.contains("parallel_tool_calls")) {
198
+ if (!j["parallel_tool_calls"].is_boolean()) fail("parallel_tool_calls must be boolean");
199
+ r.parallel_tool_calls = j["parallel_tool_calls"].get<bool>();
200
+ }
201
+ r.tool_calls = !r.tools.empty() && (no_tool_choice || j["tool_choice"] == "auto");
202
  if (!j.contains("messages") || !j["messages"].is_array() || j["messages"].empty())
203
  fail("messages must be a non-empty array");
204
+ struct Message { std::string role, content, reasoning; std::vector<ToolCall> calls; };
205
+ std::vector<Message> messages;
206
+ for (const auto & m : j["messages"]) {
207
+ if (!m.is_object() || !m.contains("role") || !m["role"].is_string()) fail("messages require a string role");
208
+ Message message {m["role"].get<std::string>()};
209
+ const auto & role = message.role;
210
+ if (role != "system" && role != "user" && role != "assistant" && role != "tool") fail("unsupported message role");
211
+ std::vector<std::string> fields {"role", "content"};
212
+ if (role == "assistant") fields.insert(fields.end(), {"tool_calls", "reasoning_content"});
213
+ if (role == "tool") fields.insert(fields.end(), {"tool_call_id", "name"});
214
  for (auto it = m.begin(); it != m.end(); ++it)
215
+ if (std::find(fields.begin(), fields.end(), it.key()) == fields.end()) fail("unsupported message field: " + it.key());
216
+ // Only an assistant turn may omit content. Text parts are joined with
217
+ // newlines, as vLLM flattens them for string-content templates.
218
+ if (!m.contains("content") || m["content"].is_null()) {
219
+ if (role != "assistant") fail("messages require string or text-part content");
220
+ } else if (m["content"].is_string()) message.content = m["content"].get<std::string>();
221
+ else if (m["content"].is_array()) {
222
+ for (std::size_t k = 0; k < m["content"].size(); ++k) {
223
+ const auto & part = m["content"][k];
224
+ if (!part.is_object() || !part.contains("type") || part["type"] != "text" ||
225
+ !part.contains("text") || !part["text"].is_string()) fail("content parts must be text");
226
+ message.content += (k ? "\n" : "") + part["text"].get<std::string>();
227
+ }
228
+ } else fail("messages require string or text-part content");
229
+ if (role == "assistant" && m.contains("reasoning_content") && !m["reasoning_content"].is_null()) {
230
+ if (!m["reasoning_content"].is_string()) fail("reasoning_content must be a string");
231
+ message.reasoning = m["reasoning_content"].get<std::string>();
232
+ }
233
+ if (role == "assistant" && m.contains("tool_calls") && !m["tool_calls"].is_null()) {
234
+ if (!m["tool_calls"].is_array()) fail("tool_calls must be an array");
235
+ for (const auto & c : m["tool_calls"]) {
236
+ if (!c.is_object() || !c.contains("function") || !c["function"].is_object() ||
237
+ !c["function"].contains("name") || !c["function"]["name"].is_string())
238
+ fail("tool_calls require a function name");
239
+ ToolCall call {c["function"]["name"].get<std::string>()};
240
+ // OpenAI clients send arguments as a JSON string; the template iterates a mapping.
241
+ if (c["function"].contains("arguments")) {
242
+ const auto & a = c["function"]["arguments"];
243
+ call.arguments = a.is_string() ? OrderedJson::parse(a.get<std::string>(), nullptr, false) : a;
244
+ if (!call.arguments.is_object()) fail("tool call arguments must be a JSON object");
245
+ }
246
+ message.calls.push_back(std::move(call));
247
+ }
248
+ }
249
+ messages.push_back(std::move(message));
250
  }
251
+ if (messages.back().role != "user" && messages.back().role != "tool")
252
+ fail("last message must be a user or tool message");
253
+ // Bailing V3 template (chat_template.jinja). No hidden, server-owned conversation.
254
  const std::string thinking = r.enable_thinking ? "on" : "off";
255
  r.prompt = "<role>SYSTEM</role>";
256
  std::size_t start = 0;
257
+ std::string system;
258
+ bool system_mode = false;
259
+ if (messages.front().role == "system") {
260
+ system = messages.front().content;
261
  // The explicit API switch owns template control. Normalize conflicting
262
  // legacy instructions rather than producing two contradictory modes.
263
  for (const auto old : {std::string("detailed thinking off"), std::string("detailed thinking on")}) {
264
  std::size_t at = 0;
265
+ while ((at = system.find(old, at)) != std::string::npos) {
266
  const auto replacement = "detailed thinking " + thinking;
267
+ system.replace(at, old.size(), replacement); at += replacement.size();
268
  }
269
  }
270
+ system_mode = system.find("detailed thinking " + thinking) != std::string::npos;
 
 
271
  start = 1;
272
+ }
273
+ if (!r.tools.empty()) {
274
+ if (start) r.prompt += system + "\n";
275
+ r.prompt += kToolsHeader;
276
+ for (const auto & tool : r.tools) r.prompt += "\n" + TemplateJson(tool);
277
+ r.prompt += kToolsFooter;
278
+ if (!system_mode) r.prompt += "detailed thinking " + thinking;
279
+ } else if (start) r.prompt += system + (system_mode ? "" : "\ndetailed thinking " + thinking);
280
+ else r.prompt += "detailed thinking " + thinking;
281
  r.prompt += "<|role_end|>";
282
+ auto strip = [](std::string s, bool left, bool right) {
283
+ if (right) s.erase(s.find_last_not_of('\n') + 1);
284
+ if (left) s.erase(0, std::min(s.find_first_not_of('\n'), s.size()));
285
+ return s;
286
+ };
287
  for (std::size_t i = start; i < messages.size(); ++i) {
288
+ const auto & m = messages[i];
289
+ if (m.role == "tool") {
290
+ if (i == 0 || messages[i - 1].role != "tool") r.prompt += "<role>OBSERVATION</role>";
291
+ r.prompt += "\n<tool_response>\n" + m.content + "\n</tool_response>";
292
+ if (i + 1 == messages.size() || messages[i + 1].role != "tool") r.prompt += "<|role_end|>";
293
+ continue;
294
+ }
295
+ if (m.role != "assistant") {
296
+ r.prompt += (m.role == "user" ? "<role>HUMAN</role>" : "<role>SYSTEM</role>") + m.content + "<|role_end|>";
297
+ continue;
298
+ }
299
+ auto content = m.content, reasoning = m.reasoning;
300
+ if (reasoning.empty() && content.find("</think>") != std::string::npos) {
301
+ reasoning = strip(content.substr(0, content.find("</think>")), false, true);
302
+ if (const auto open = reasoning.rfind("<think>"); open != std::string::npos) reasoning.erase(0, open + 7);
303
+ reasoning = strip(reasoning, true, false);
304
+ content = strip(content.substr(content.rfind("</think>") + 8), true, false);
305
  }
306
+ r.prompt += reasoning.empty() ? "<role>ASSISTANT</role>\n<think></think>" + content
307
+ : "<role>ASSISTANT</role>\n<think>" + strip(reasoning, true, true) + "</think>" + strip(content, true, false);
308
+ for (std::size_t k = 0; k < m.calls.size(); ++k)
309
+ r.prompt += (k || !content.empty() ? "\n" : "") + RenderToolCall(m.calls[k]);
310
+ r.prompt += "<|role_end|>";
311
  }
312
  r.prompt += r.enable_thinking ? "<role>ASSISTANT</role>\n<think>" : "<role>ASSISTANT</role>\n<think></think>";
313
  return r;
 
358
  return TakeUtf8(utf8_, final);
359
  }
360
  };
361
+ // Declared string arguments stay raw text; other values parse as JSON and fall
362
+ // back to text, as llama.cpp's ling3 parser types them from the tool schema.
363
+ inline OrderedJson ArgumentValue(const OrderedJson & function, const std::string & key, const std::string & value) {
364
+ bool text = false, other = false;
365
+ std::function<void(const OrderedJson &)> scan = [&](const OrderedJson & schema) {
366
+ if (!schema.is_object()) return;
367
+ if (schema.contains("type"))
368
+ for (const auto & type : schema["type"].is_array() ? schema["type"] : OrderedJson::array({schema["type"]}))
369
+ (type == "string" ? text : other) = true;
370
+ if (schema.contains("enum") && schema["enum"].is_array())
371
+ for (const auto & choice : schema["enum"]) (choice.is_string() ? text : other) = true;
372
+ for (const auto * union_key : {"anyOf", "oneOf"})
373
+ if (schema.contains(union_key) && schema[union_key].is_array())
374
+ for (const auto & branch : schema[union_key]) scan(branch);
375
+ };
376
+ if (function.contains("parameters") && function["parameters"].is_object() &&
377
+ function["parameters"].contains("properties") && function["parameters"]["properties"].is_object() &&
378
+ function["parameters"]["properties"].contains(key)) scan(function["parameters"]["properties"][key]);
379
+ if (text && !other) return value;
380
+ auto parsed = OrderedJson::parse(value, nullptr, false);
381
+ return parsed.is_discarded() ? OrderedJson(value) : parsed;
382
+ }
383
+ // Bailing V3 calls: <tool_call>NAME\n<arg_key>K</arg_key>\n<arg_value>V</arg_value>...\n</tool_call>.
384
+ // Whitespace between the parts is optional and only declared tools are calls.
385
+ // Text after the last call is returned as trailing content, as llama.cpp does.
386
+ struct ToolCalls { bool ok = false; std::vector<ToolCall> calls; std::string trailing; };
387
+ inline ToolCalls ParseToolCalls(std::string_view text, const OrderedJson & tools, bool parallel) {
388
+ ToolCalls result;
389
+ auto space = [&] { while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) text.remove_prefix(1); };
390
+ auto take = [&](std::string_view tag) { if (!text.starts_with(tag)) return false; text.remove_prefix(tag.size()); return true; };
391
+ auto until = [&](std::string_view tag, std::string & out) {
392
+ const auto at = text.find(tag);
393
+ if (at == std::string_view::npos) return false;
394
+ out = text.substr(0, at); text.remove_prefix(at + tag.size()); return true;
395
+ };
396
+ while (space(), take("<tool_call>")) {
397
+ std::size_t n = 0;
398
+ while (n < text.size() && !std::isspace(static_cast<unsigned char>(text[n])) && text[n] != '<') ++n;
399
+ ToolCall call {std::string(text.substr(0, n))};
400
+ text.remove_prefix(n);
401
+ const OrderedJson * function = nullptr;
402
+ for (const auto & tool : tools) if (tool["function"]["name"] == call.name) function = &tool["function"];
403
+ if (!function) return {};
404
+ while (space(), !take("</tool_call>")) {
405
+ std::string key, value;
406
+ if (!take("<arg_key>") || !until("</arg_key>", key)) return {};
407
+ space();
408
+ if (!take("<arg_value>") || !until("</arg_value>", value)) return {};
409
+ call.arguments[key] = ArgumentValue(*function, key, value);
410
+ }
411
+ if (parallel || result.calls.empty()) result.calls.push_back(std::move(call));
412
+ }
413
+ if (result.calls.empty()) return {};
414
+ result.ok = true;
415
+ result.trailing = text;
416
+ return result;
417
+ }
418
+ // Holds back generated text from the first <tool_call> so a call never streams
419
+ // as content; with thinking on this also ends the reasoning, as in llama.cpp.
420
+ // The newline the template writes before a call is held too and dropped.
421
+ class ToolCallFilter {
422
+ std::string pending_, captured_;
423
+ bool enabled_, newline_ = false;
424
+ public:
425
+ explicit ToolCallFilter(bool enabled) : enabled_(enabled) {}
426
+ const std::string & captured() const { return captured_; }
427
+ // The exact held text, for output that does not parse as calls.
428
+ std::string Released() const { return (newline_ ? "\n" : "") + captured_; }
429
+ std::string Push(std::string_view piece, bool final = false) {
430
+ if (!enabled_) return std::string(piece);
431
+ if (!captured_.empty()) { captured_ += piece; return {}; }
432
+ pending_ += piece;
433
+ constexpr std::string_view tag = "<tool_call>";
434
+ if (const auto at = pending_.find(tag); at != std::string::npos) {
435
+ captured_ = pending_.substr(at);
436
+ pending_.resize(at);
437
+ newline_ = !pending_.empty() && pending_.back() == '\n';
438
+ if (newline_) pending_.pop_back();
439
+ return std::exchange(pending_, {});
440
+ }
441
+ std::size_t keep = 0;
442
+ if (!final) {
443
+ for (std::size_t k = std::min(tag.size() - 1, pending_.size()); k && !keep; --k)
444
+ if (pending_.ends_with(tag.substr(0, k))) keep = k;
445
+ if (keep < pending_.size() && pending_[pending_.size() - keep - 1] == '\n') ++keep;
446
+ }
447
+ auto out = pending_.substr(0, pending_.size() - keep);
448
+ pending_.erase(0, pending_.size() - keep);
449
+ return out;
450
+ }
451
+ };
452
  } // namespace ling3::chat
src/portable_main.cpp CHANGED
@@ -166,7 +166,7 @@ Json CheckDevice() {
166
  return report;
167
  }
168
 
169
- struct Result { std::string text, reason = "length"; Json usage, metrics; };
170
  class Engine {
171
  ModelPackage package_;
172
  std::size_t context_;
@@ -457,6 +457,7 @@ public:
457
  const bool retain_generated = r.cache_prompt && r.reuse_generated_state;
458
  std::vector<std::uint32_t> evaluated_tokens = retain_generated ? tokens : std::vector<std::uint32_t>{};
459
  TextFilter filter(r.stops);
 
460
  std::mt19937 random(r.seed);
461
  std::vector<bool> seen(logits_.size(), false);
462
  for (auto token : tokens) if (token < seen.size()) seen[token] = true;
@@ -484,7 +485,7 @@ public:
484
  if (!generated) { first = last; first_ms = Ms(start, first); }
485
  ++generated; // includes EOS and stop tokens, like completion usage.
486
  if (token == package_.header().eos_token) { result.reason = "stop"; break; }
487
- if (!output(filter.Push(tokenizer_.Piece(token)))) break;
488
  if (filter.stopped) { result.reason = "stop"; break; }
489
  if (i + 1 < output_budget) {
490
  if (!flow_.Wait([&] { return !quitting && connected(); })) { aborted(); break; }
@@ -493,7 +494,18 @@ public:
493
  ++evaluated_steps_;
494
  }
495
  }
496
- if (result.reason != "canceled") output(filter.Push({}, true));
 
 
 
 
 
 
 
 
 
 
 
497
  if (result.reason != "canceled" && !alive()) aborted();
498
  bool continuation_stored = false, continuation_skipped_budget = false;
499
  double continuation_save_ms = 0;
@@ -536,6 +548,7 @@ public:
536
  {"queue_ms", 0}, {"cached_tokens", cached_tokens}, {"warmed", true},
537
  {"cache_status", cache_status}, {"cache_restore_ms", cache_restore_ms},
538
  {"cache_save_ms", cache_save_ms}, {"prefix_cache_mib", prefix_cache_.bytes() / 1048576.0}};
 
539
  result.metrics["context_length"] = context_;
540
  result.metrics["reuse_generated_state"] = r.reuse_generated_state;
541
  result.metrics["generated_state_lineage"] = prefix_cache_.generated_lineage;
@@ -743,7 +756,7 @@ void Handle(int fd, Engine & engine, const Json & device) {
743
  }
744
  if (http.method != "POST" || http.path != "/v1/chat/completions")
745
  throw Error(404, "not_found", "endpoint not found");
746
- const auto request = Parse(Json::parse(http.body));
747
  const auto id = Id(); const auto created = std::time(nullptr);
748
  auto chunk = [&](Json delta, Json finish = nullptr) {
749
  Json out {{"id", id}, {"object", "chat.completion.chunk"}, {"created", created}, {"model", kModel},
@@ -764,14 +777,27 @@ void Handle(int fd, Engine & engine, const Json & device) {
764
  if (result.reason == "canceled") {
765
  error(Error(409, "request_canceled", "generation canceled; partial text is not a completed reply")); return;
766
  }
 
 
 
 
 
 
 
 
 
767
  if (request.stream) {
 
 
 
 
768
  chunk(Json::object(), result.reason);
769
  Json final {{"id", id}, {"object", "chat.completion.chunk"}, {"created", created}, {"model", kModel},
770
  {"choices", Json::array()}, {"mindnano_metrics", result.metrics}};
771
  if (request.include_usage) final["usage"] = result.usage;
772
  Send(fd, "data: " + final.dump() + "\n\ndata: [DONE]\n\n");
773
  } else Response(fd, 200, {{"id", id}, {"object", "chat.completion"}, {"created", created}, {"model", kModel},
774
- {"choices", Json::array({{{"index", 0}, {"message", {{"role", "assistant"}, {"content", result.text}}},
775
  {"finish_reason", result.reason}, {"logprobs", nullptr}}})},
776
  {"usage", result.usage}, {"mindnano_metrics", result.metrics}});
777
  } catch (const Error & e) { error(e); }
 
166
  return report;
167
  }
168
 
169
+ struct Result { std::string text, reason = "length"; Json usage, metrics; std::vector<ToolCall> tool_calls; };
170
  class Engine {
171
  ModelPackage package_;
172
  std::size_t context_;
 
457
  const bool retain_generated = r.cache_prompt && r.reuse_generated_state;
458
  std::vector<std::uint32_t> evaluated_tokens = retain_generated ? tokens : std::vector<std::uint32_t>{};
459
  TextFilter filter(r.stops);
460
+ ToolCallFilter calls(r.tool_calls);
461
  std::mt19937 random(r.seed);
462
  std::vector<bool> seen(logits_.size(), false);
463
  for (auto token : tokens) if (token < seen.size()) seen[token] = true;
 
485
  if (!generated) { first = last; first_ms = Ms(start, first); }
486
  ++generated; // includes EOS and stop tokens, like completion usage.
487
  if (token == package_.header().eos_token) { result.reason = "stop"; break; }
488
+ if (!output(calls.Push(filter.Push(tokenizer_.Piece(token))))) break;
489
  if (filter.stopped) { result.reason = "stop"; break; }
490
  if (i + 1 < output_budget) {
491
  if (!flow_.Wait([&] { return !quitting && connected(); })) { aborted(); break; }
 
494
  ++evaluated_steps_;
495
  }
496
  }
497
+ if (result.reason != "canceled") output(calls.Push(filter.Push({}, true), true));
498
+ // Held call text is parsed only once generation is complete; output that
499
+ // does not parse is released unchanged as content.
500
+ if (result.reason != "canceled" && !calls.captured().empty()) {
501
+ auto parsed = ParseToolCalls(calls.captured(), r.tools, r.parallel_tool_calls);
502
+ if (!parsed.ok) output(calls.Released());
503
+ else {
504
+ result.tool_calls = std::move(parsed.calls);
505
+ output(parsed.trailing);
506
+ if (result.reason == "stop") result.reason = "tool_calls";
507
+ }
508
+ }
509
  if (result.reason != "canceled" && !alive()) aborted();
510
  bool continuation_stored = false, continuation_skipped_budget = false;
511
  double continuation_save_ms = 0;
 
548
  {"queue_ms", 0}, {"cached_tokens", cached_tokens}, {"warmed", true},
549
  {"cache_status", cache_status}, {"cache_restore_ms", cache_restore_ms},
550
  {"cache_save_ms", cache_save_ms}, {"prefix_cache_mib", prefix_cache_.bytes() / 1048576.0}};
551
+ result.metrics["tool_calls"] = result.tool_calls.size();
552
  result.metrics["context_length"] = context_;
553
  result.metrics["reuse_generated_state"] = r.reuse_generated_state;
554
  result.metrics["generated_state_lineage"] = prefix_cache_.generated_lineage;
 
756
  }
757
  if (http.method != "POST" || http.path != "/v1/chat/completions")
758
  throw Error(404, "not_found", "endpoint not found");
759
+ const auto request = Parse(OrderedJson::parse(http.body));
760
  const auto id = Id(); const auto created = std::time(nullptr);
761
  auto chunk = [&](Json delta, Json finish = nullptr) {
762
  Json out {{"id", id}, {"object", "chat.completion.chunk"}, {"created", created}, {"model", kModel},
 
777
  if (result.reason == "canceled") {
778
  error(Error(409, "request_canceled", "generation canceled; partial text is not a completed reply")); return;
779
  }
780
+ Json tool_calls = Json::array();
781
+ for (std::size_t i = 0; i < result.tool_calls.size(); ++i)
782
+ tool_calls.push_back({{"id", "call_" + id.substr(9) + "-" + std::to_string(i)}, {"type", "function"},
783
+ {"function", {{"name", result.tool_calls[i].name}, {"arguments", result.tool_calls[i].arguments.dump()}}}});
784
+ Json message {{"role", "assistant"}, {"content", result.text}};
785
+ if (!tool_calls.empty()) {
786
+ message["tool_calls"] = tool_calls;
787
+ if (result.text.empty()) message["content"] = nullptr;
788
+ }
789
  if (request.stream) {
790
+ for (std::size_t i = 0; i < tool_calls.size(); ++i) {
791
+ tool_calls[i]["index"] = i;
792
+ chunk({{"tool_calls", Json::array({tool_calls[i]})}});
793
+ }
794
  chunk(Json::object(), result.reason);
795
  Json final {{"id", id}, {"object", "chat.completion.chunk"}, {"created", created}, {"model", kModel},
796
  {"choices", Json::array()}, {"mindnano_metrics", result.metrics}};
797
  if (request.include_usage) final["usage"] = result.usage;
798
  Send(fd, "data: " + final.dump() + "\n\ndata: [DONE]\n\n");
799
  } else Response(fd, 200, {{"id", id}, {"object", "chat.completion"}, {"created", created}, {"model", kModel},
800
+ {"choices", Json::array({{{"index", 0}, {"message", message},
801
  {"finish_reason", result.reason}, {"logprobs", nullptr}}})},
802
  {"usage", result.usage}, {"mindnano_metrics", result.metrics}});
803
  } catch (const Error & e) { error(e); }
tests/chat_protocol_test.cpp CHANGED
@@ -33,7 +33,7 @@ int main() {
33
  "<role>HUMAN</role>你好<|role_end|><role>ASSISTANT</role>\n<think></think>你好!<|role_end|>"
34
  "<role>HUMAN</role>继续<|role_end|><role>ASSISTANT</role>\n<think></think>", "template mismatch");
35
  for (const auto & mutation : std::vector<Json>{
36
- {{"tools", Json::array()}}, {{"temperature", -1}}, {{"stream", "true"}},
37
  {{"max_tokens", -1}}, {{"max_completion_tokens", 1.5}}, {{"n", 2}},
38
  {{"model", "other"}}, {{"messages", Json::array()}}, {{"stop", ""}}, {{"cache_prompt", "false"}}}) {
39
  auto bad = body; bad.update(mutation); bool failed = false;
 
33
  "<role>HUMAN</role>你好<|role_end|><role>ASSISTANT</role>\n<think></think>你好!<|role_end|>"
34
  "<role>HUMAN</role>继续<|role_end|><role>ASSISTANT</role>\n<think></think>", "template mismatch");
35
  for (const auto & mutation : std::vector<Json>{
36
+ {{"tools", "read_file"}}, {{"tool_choice", "required"}}, {{"temperature", -1}}, {{"stream", "true"}},
37
  {{"max_tokens", -1}}, {{"max_completion_tokens", 1.5}}, {{"n", 2}},
38
  {{"model", "other"}}, {{"messages", Json::array()}}, {{"stop", ""}}, {{"cache_prompt", "false"}}}) {
39
  auto bad = body; bad.update(mutation); bool failed = false;
tests/chat_tools_golden.json ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "name": "no_tools_history",
4
+ "request": {
5
+ "model": "mindnano-ling3-tiny",
6
+ "messages": [
7
+ {
8
+ "role": "system",
9
+ "content": "请用中文回答"
10
+ },
11
+ {
12
+ "role": "user",
13
+ "content": "你好"
14
+ },
15
+ {
16
+ "role": "assistant",
17
+ "content": "你好!"
18
+ },
19
+ {
20
+ "role": "user",
21
+ "content": "继续"
22
+ }
23
+ ],
24
+ "enable_thinking": false
25
+ },
26
+ "prompt": "<role>SYSTEM</role>请用中文回答\ndetailed thinking off<|role_end|><role>HUMAN</role>你好<|role_end|><role>ASSISTANT</role>\n<think></think>你好!<|role_end|><role>HUMAN</role>继续<|role_end|><role>ASSISTANT</role>\n<think></think>"
27
+ },
28
+ {
29
+ "name": "tools_no_system",
30
+ "request": {
31
+ "model": "mindnano-ling3-tiny",
32
+ "messages": [
33
+ {
34
+ "role": "user",
35
+ "content": "Reply with OK."
36
+ }
37
+ ],
38
+ "enable_thinking": false,
39
+ "tools": [
40
+ {
41
+ "type": "function",
42
+ "function": {
43
+ "name": "read_file",
44
+ "description": "Read a file's contents.",
45
+ "parameters": {
46
+ "type": "object",
47
+ "properties": {
48
+ "path": {
49
+ "type": "string"
50
+ }
51
+ },
52
+ "required": [
53
+ "path"
54
+ ]
55
+ }
56
+ }
57
+ }
58
+ ]
59
+ },
60
+ "prompt": "<role>SYSTEM</role># Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"read_file\", \"description\": \"Read a file's contents.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}}\n</tools>\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\ndetailed thinking off<|role_end|><role>HUMAN</role>Reply with OK.<|role_end|><role>ASSISTANT</role>\n<think></think>"
61
+ },
62
+ {
63
+ "name": "tools_system",
64
+ "request": {
65
+ "model": "mindnano-ling3-tiny",
66
+ "messages": [
67
+ {
68
+ "role": "system",
69
+ "content": "You are terse."
70
+ },
71
+ {
72
+ "role": "user",
73
+ "content": "Use the tools to read /tmp/scratch.txt, then patch it to change 'foo' to 'bar'."
74
+ }
75
+ ],
76
+ "enable_thinking": false,
77
+ "tools": [
78
+ {
79
+ "type": "function",
80
+ "function": {
81
+ "name": "read_file",
82
+ "description": "Read a file's contents.",
83
+ "parameters": {
84
+ "type": "object",
85
+ "properties": {
86
+ "path": {
87
+ "type": "string"
88
+ }
89
+ },
90
+ "required": [
91
+ "path"
92
+ ]
93
+ }
94
+ }
95
+ },
96
+ {
97
+ "type": "function",
98
+ "function": {
99
+ "name": "patch",
100
+ "description": "Apply a small text patch to a file.",
101
+ "parameters": {
102
+ "type": "object",
103
+ "properties": {
104
+ "path": {
105
+ "type": "string"
106
+ },
107
+ "find": {
108
+ "type": "string"
109
+ },
110
+ "replace": {
111
+ "type": "string"
112
+ }
113
+ },
114
+ "required": [
115
+ "path",
116
+ "find",
117
+ "replace"
118
+ ]
119
+ }
120
+ }
121
+ }
122
+ ]
123
+ },
124
+ "prompt": "<role>SYSTEM</role>You are terse.\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"read_file\", \"description\": \"Read a file's contents.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}}\n{\"type\": \"function\", \"function\": {\"name\": \"patch\", \"description\": \"Apply a small text patch to a file.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"find\": {\"type\": \"string\"}, \"replace\": {\"type\": \"string\"}}, \"required\": [\"path\", \"find\", \"replace\"]}}}\n</tools>\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\ndetailed thinking off<|role_end|><role>HUMAN</role>Use the tools to read /tmp/scratch.txt, then patch it to change 'foo' to 'bar'.<|role_end|><role>ASSISTANT</role>\n<think></think>"
125
+ },
126
+ {
127
+ "name": "tools_system_thinking_phrase",
128
+ "request": {
129
+ "model": "mindnano-ling3-tiny",
130
+ "messages": [
131
+ {
132
+ "role": "system",
133
+ "content": "detailed thinking off\nBe brief."
134
+ },
135
+ {
136
+ "role": "user",
137
+ "content": "Read /tmp/a."
138
+ }
139
+ ],
140
+ "enable_thinking": false,
141
+ "tools": [
142
+ {
143
+ "type": "function",
144
+ "function": {
145
+ "name": "read_file",
146
+ "description": "Read a file's contents.",
147
+ "parameters": {
148
+ "type": "object",
149
+ "properties": {
150
+ "path": {
151
+ "type": "string"
152
+ }
153
+ },
154
+ "required": [
155
+ "path"
156
+ ]
157
+ }
158
+ }
159
+ }
160
+ ]
161
+ },
162
+ "prompt": "<role>SYSTEM</role>detailed thinking off\nBe brief.\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"read_file\", \"description\": \"Read a file's contents.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}}\n</tools>\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\n<|role_end|><role>HUMAN</role>Read /tmp/a.<|role_end|><role>ASSISTANT</role>\n<think></think>"
163
+ },
164
+ {
165
+ "name": "tool_loop",
166
+ "request": {
167
+ "model": "mindnano-ling3-tiny",
168
+ "messages": [
169
+ {
170
+ "role": "system",
171
+ "content": "You are terse."
172
+ },
173
+ {
174
+ "role": "user",
175
+ "content": "Fix /tmp/a.txt."
176
+ },
177
+ {
178
+ "role": "assistant",
179
+ "content": null,
180
+ "tool_calls": [
181
+ {
182
+ "id": "call_1",
183
+ "type": "function",
184
+ "function": {
185
+ "name": "read_file",
186
+ "arguments": "{\"path\": \"/tmp/a.txt\"}"
187
+ }
188
+ },
189
+ {
190
+ "id": "call_2",
191
+ "type": "function",
192
+ "function": {
193
+ "name": "run",
194
+ "arguments": "{\"command\": \"ls -l\", \"timeout\": 30, \"env\": {\"A\": \"1\", \"B\": [1, 2.5]}, \"dry\": false}"
195
+ }
196
+ }
197
+ ]
198
+ },
199
+ {
200
+ "role": "tool",
201
+ "tool_call_id": "call_1",
202
+ "content": "foo\n"
203
+ },
204
+ {
205
+ "role": "tool",
206
+ "tool_call_id": "call_2",
207
+ "content": "total 0"
208
+ },
209
+ {
210
+ "role": "assistant",
211
+ "content": "Read it.",
212
+ "tool_calls": [
213
+ {
214
+ "id": "call_3",
215
+ "type": "function",
216
+ "function": {
217
+ "name": "patch",
218
+ "arguments": "{\"path\": \"/tmp/a.txt\", \"find\": \"foo\", \"replace\": \"bar\\n\"}"
219
+ }
220
+ }
221
+ ]
222
+ },
223
+ {
224
+ "role": "tool",
225
+ "tool_call_id": "call_3",
226
+ "content": "ok"
227
+ }
228
+ ],
229
+ "enable_thinking": false,
230
+ "tools": [
231
+ {
232
+ "type": "function",
233
+ "function": {
234
+ "name": "read_file",
235
+ "description": "Read a file's contents.",
236
+ "parameters": {
237
+ "type": "object",
238
+ "properties": {
239
+ "path": {
240
+ "type": "string"
241
+ }
242
+ },
243
+ "required": [
244
+ "path"
245
+ ]
246
+ }
247
+ }
248
+ },
249
+ {
250
+ "type": "function",
251
+ "function": {
252
+ "name": "patch",
253
+ "description": "Apply a small text patch to a file.",
254
+ "parameters": {
255
+ "type": "object",
256
+ "properties": {
257
+ "path": {
258
+ "type": "string"
259
+ },
260
+ "find": {
261
+ "type": "string"
262
+ },
263
+ "replace": {
264
+ "type": "string"
265
+ }
266
+ },
267
+ "required": [
268
+ "path",
269
+ "find",
270
+ "replace"
271
+ ]
272
+ }
273
+ }
274
+ },
275
+ {
276
+ "type": "function",
277
+ "function": {
278
+ "name": "run",
279
+ "description": "运行命令 — 可设超时。",
280
+ "parameters": {
281
+ "type": "object",
282
+ "properties": {
283
+ "command": {
284
+ "type": "string"
285
+ },
286
+ "timeout": {
287
+ "type": "integer"
288
+ },
289
+ "env": {
290
+ "type": "object"
291
+ },
292
+ "dry": {
293
+ "type": "boolean"
294
+ }
295
+ },
296
+ "required": [
297
+ "command"
298
+ ]
299
+ }
300
+ }
301
+ }
302
+ ]
303
+ },
304
+ "prompt": "<role>SYSTEM</role>You are terse.\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"read_file\", \"description\": \"Read a file's contents.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}}\n{\"type\": \"function\", \"function\": {\"name\": \"patch\", \"description\": \"Apply a small text patch to a file.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"find\": {\"type\": \"string\"}, \"replace\": {\"type\": \"string\"}}, \"required\": [\"path\", \"find\", \"replace\"]}}}\n{\"type\": \"function\", \"function\": {\"name\": \"run\", \"description\": \"运行命令 — 可设超时。\", \"parameters\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}, \"timeout\": {\"type\": \"integer\"}, \"env\": {\"type\": \"object\"}, \"dry\": {\"type\": \"boolean\"}}, \"required\": [\"command\"]}}}\n</tools>\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\ndetailed thinking off<|role_end|><role>HUMAN</role>Fix /tmp/a.txt.<|role_end|><role>ASSISTANT</role>\n<think></think><tool_call>read_file\n<arg_key>path</arg_key>\n<arg_value>/tmp/a.txt</arg_value>\n</tool_call>\n<tool_call>run\n<arg_key>command</arg_key>\n<arg_value>ls -l</arg_value><arg_key>timeout</arg_key>\n<arg_value>30</arg_value><arg_key>env</arg_key>\n<arg_value>{\"A\": \"1\", \"B\": [1, 2.5]}</arg_value><arg_key>dry</arg_key>\n<arg_value>false</arg_value>\n</tool_call><|role_end|><role>OBSERVATION</role>\n<tool_response>\nfoo\n\n</tool_response>\n<tool_response>\ntotal 0\n</tool_response><|role_end|><role>ASSISTANT</role>\n<think></think>Read it.\n<tool_call>patch\n<arg_key>path</arg_key>\n<arg_value>/tmp/a.txt</arg_value><arg_key>find</arg_key>\n<arg_value>foo</arg_value><arg_key>replace</arg_key>\n<arg_value>bar\n</arg_value>\n</tool_call><|role_end|><role>OBSERVATION</role>\n<tool_response>\nok\n</tool_response><|role_end|><role>ASSISTANT</role>\n<think></think>"
305
+ },
306
+ {
307
+ "name": "thinking_tool_loop",
308
+ "request": {
309
+ "model": "mindnano-ling3-tiny",
310
+ "messages": [
311
+ {
312
+ "role": "user",
313
+ "content": "What is in /tmp/a?"
314
+ },
315
+ {
316
+ "role": "assistant",
317
+ "content": "",
318
+ "reasoning_content": "\nI should read it.\n",
319
+ "tool_calls": [
320
+ {
321
+ "id": "call_1",
322
+ "type": "function",
323
+ "function": {
324
+ "name": "read_file",
325
+ "arguments": "{\"path\": \"/tmp/a\"}"
326
+ }
327
+ }
328
+ ]
329
+ },
330
+ {
331
+ "role": "tool",
332
+ "tool_call_id": "call_1",
333
+ "content": "foo"
334
+ }
335
+ ],
336
+ "enable_thinking": true,
337
+ "tools": [
338
+ {
339
+ "type": "function",
340
+ "function": {
341
+ "name": "read_file",
342
+ "description": "Read a file's contents.",
343
+ "parameters": {
344
+ "type": "object",
345
+ "properties": {
346
+ "path": {
347
+ "type": "string"
348
+ }
349
+ },
350
+ "required": [
351
+ "path"
352
+ ]
353
+ }
354
+ }
355
+ }
356
+ ]
357
+ },
358
+ "prompt": "<role>SYSTEM</role># Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"read_file\", \"description\": \"Read a file's contents.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}}\n</tools>\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\ndetailed thinking on<|role_end|><role>HUMAN</role>What is in /tmp/a?<|role_end|><role>ASSISTANT</role>\n<think>I should read it.</think><tool_call>read_file\n<arg_key>path</arg_key>\n<arg_value>/tmp/a</arg_value>\n</tool_call><|role_end|><role>OBSERVATION</role>\n<tool_response>\nfoo\n</tool_response><|role_end|><role>ASSISTANT</role>\n<think>"
359
+ },
360
+ {
361
+ "name": "think_in_content",
362
+ "request": {
363
+ "model": "mindnano-ling3-tiny",
364
+ "messages": [
365
+ {
366
+ "role": "user",
367
+ "content": "Hi"
368
+ },
369
+ {
370
+ "role": "assistant",
371
+ "content": "<think>\nplan\n</think>\n\nHello."
372
+ },
373
+ {
374
+ "role": "user",
375
+ "content": "读取 /tmp/文件 ✓"
376
+ }
377
+ ],
378
+ "enable_thinking": true,
379
+ "tools": [
380
+ {
381
+ "type": "function",
382
+ "function": {
383
+ "name": "read_file",
384
+ "description": "Read a file's contents.",
385
+ "parameters": {
386
+ "type": "object",
387
+ "properties": {
388
+ "path": {
389
+ "type": "string"
390
+ }
391
+ },
392
+ "required": [
393
+ "path"
394
+ ]
395
+ }
396
+ }
397
+ }
398
+ ]
399
+ },
400
+ "prompt": "<role>SYSTEM</role># Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"read_file\", \"description\": \"Read a file's contents.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}}\n</tools>\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\ndetailed thinking on<|role_end|><role>HUMAN</role>Hi<|role_end|><role>ASSISTANT</role>\n<think>plan</think>Hello.<|role_end|><role>HUMAN</role>读取 /tmp/文件 ✓<|role_end|><role>ASSISTANT</role>\n<think>"
401
+ },
402
+ {
403
+ "name": "unicode_call",
404
+ "request": {
405
+ "model": "mindnano-ling3-tiny",
406
+ "messages": [
407
+ {
408
+ "role": "user",
409
+ "content": "打招呼"
410
+ },
411
+ {
412
+ "role": "assistant",
413
+ "content": null,
414
+ "tool_calls": [
415
+ {
416
+ "id": "call_1",
417
+ "type": "function",
418
+ "function": {
419
+ "name": "run",
420
+ "arguments": "{\"command\": \"echo 你好\"}"
421
+ }
422
+ }
423
+ ]
424
+ },
425
+ {
426
+ "role": "tool",
427
+ "tool_call_id": "call_1",
428
+ "content": "你好"
429
+ }
430
+ ],
431
+ "enable_thinking": false,
432
+ "tools": [
433
+ {
434
+ "type": "function",
435
+ "function": {
436
+ "name": "run",
437
+ "description": "运行命令 — 可设超时。",
438
+ "parameters": {
439
+ "type": "object",
440
+ "properties": {
441
+ "command": {
442
+ "type": "string"
443
+ },
444
+ "timeout": {
445
+ "type": "integer"
446
+ },
447
+ "env": {
448
+ "type": "object"
449
+ },
450
+ "dry": {
451
+ "type": "boolean"
452
+ }
453
+ },
454
+ "required": [
455
+ "command"
456
+ ]
457
+ }
458
+ }
459
+ }
460
+ ]
461
+ },
462
+ "prompt": "<role>SYSTEM</role># Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"run\", \"description\": \"运行命令 — 可设超时。\", \"parameters\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}, \"timeout\": {\"type\": \"integer\"}, \"env\": {\"type\": \"object\"}, \"dry\": {\"type\": \"boolean\"}}, \"required\": [\"command\"]}}}\n</tools>\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\n<tool_call>{function-name}\n<arg_key>{arg-key-1}</arg_key>\n<arg_value>{arg-value-1}</arg_value>\n<arg_key>{arg-key-2}</arg_key>\n<arg_value>{arg-value-2}</arg_value>\n...\n</tool_call>\ndetailed thinking off<|role_end|><role>HUMAN</role>打招呼<|role_end|><role>ASSISTANT</role>\n<think></think><tool_call>run\n<arg_key>command</arg_key>\n<arg_value>echo 你好</arg_value>\n</tool_call><|role_end|><role>OBSERVATION</role>\n<tool_response>\n你好\n</tool_response><|role_end|><role>ASSISTANT</role>\n<think></think>"
463
+ }
464
+ ]
tests/chat_tools_test.cpp ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "ling3/chat_protocol.h"
2
+ #include <fstream>
3
+ #include <iostream>
4
+
5
+ using namespace ling3::chat;
6
+ void Check(bool condition, const std::string & message) {
7
+ if (!condition) throw std::runtime_error(message);
8
+ }
9
+ int main(int argc, char ** argv) {
10
+ if (argc != 2) throw std::runtime_error("usage: ling3-chat-tools-test tests/chat_tools_golden.json");
11
+ // Prompts rendered by transformers from the official chat_template.jinja
12
+ // (tools/render_chat_tools_golden.py); the C++ renderer must match byte for byte.
13
+ std::ifstream file(argv[1]);
14
+ const auto golden = OrderedJson::parse(file);
15
+ auto find = [&](const std::string & name) -> const OrderedJson & {
16
+ for (const auto & c : golden) if (c["name"] == name) return c;
17
+ throw std::runtime_error("missing golden case " + name);
18
+ };
19
+ for (const auto & c : golden)
20
+ Check(Parse(c["request"]).prompt == c["prompt"].get<std::string>(),
21
+ "template mismatch: " + c["name"].get<std::string>());
22
+
23
+ auto body = find("tool_loop")["request"];
24
+ const auto & tools = body["tools"];
25
+ const auto prompt = find("tool_loop")["prompt"].get<std::string>();
26
+ Check(Parse(body).tool_calls && Parse(body).parallel_tool_calls, "tools must enable call parsing");
27
+ auto none = body; none["tool_choice"] = "none";
28
+ Check(!Parse(none).tool_calls && Parse(none).prompt == prompt, "tool_choice none still renders tools");
29
+ auto stored = body; stored["store"] = false;
30
+ Check(Parse(stored).prompt == prompt, "store flag rejected");
31
+ auto parts = body;
32
+ parts["messages"][1]["content"] = OrderedJson::array({{{"type", "text"}, {"text", "Fix /tmp/a.txt."}}});
33
+ Check(Parse(parts).prompt == prompt, "text-part content");
34
+ for (const auto & [pointer, value] : std::vector<std::pair<std::string, OrderedJson>>{
35
+ {"/tools", "read_file"}, {"/tools/0/type", "code"}, {"/tool_choice", "required"},
36
+ {"/parallel_tool_calls", "yes"}, {"/store", "no"}, {"/messages/2/tool_calls/0/function/arguments", "{oops"},
37
+ {"/messages/2/tool_calls/0/function/arguments", "[1]"}, {"/messages/3/content", nullptr},
38
+ {"/messages/3/id", "x"}, {"/messages/1/content", OrderedJson::array({{{"type", "image_url"}}})}}) {
39
+ auto bad = body; bad[OrderedJson::json_pointer(pointer)] = value; bool failed = false;
40
+ try { Parse(bad); } catch (const Error &) { failed = true; }
41
+ Check(failed, "invalid tool request accepted: " + pointer);
42
+ }
43
+
44
+ // Arguments are typed by the declared schema; a parsed call re-renders to
45
+ // the generated text, so the next turn's prompt keeps the cached prefix.
46
+ auto calls = ParseToolCalls("<tool_call>run\n<arg_key>command</arg_key>\n<arg_value>123</arg_value>"
47
+ "<arg_key>timeout</arg_key>\n<arg_value>30</arg_value><arg_key>env</arg_key>\n<arg_value>{\"A\": [1, 2.5]}"
48
+ "</arg_value><arg_key>dry</arg_key>\n<arg_value>false</arg_value>\n</tool_call>", tools, true);
49
+ Check(calls.ok && calls.calls.size() == 1 && calls.calls[0].name == "run", "call not parsed");
50
+ Check(calls.calls[0].arguments.dump() == R"({"command":"123","timeout":30,"env":{"A":[1,2.5]},"dry":false})",
51
+ "arguments not typed by schema");
52
+ for (const auto * name : {"tool_loop", "unicode_call", "thinking_tool_loop"})
53
+ for (const auto & m : find(name)["request"]["messages"]) {
54
+ if (!m.contains("tool_calls")) continue;
55
+ std::string generated;
56
+ for (const auto & c : m["tool_calls"])
57
+ generated += (generated.empty() ? "" : "\n") + RenderToolCall({c["function"]["name"].get<std::string>(),
58
+ OrderedJson::parse(c["function"]["arguments"].get<std::string>())});
59
+ Check(prompt.find(generated) != std::string::npos || std::string(name) != "tool_loop", "history render");
60
+ const auto parsed = ParseToolCalls(generated, find(name)["request"]["tools"], true);
61
+ Check(parsed.ok && parsed.calls.size() == m["tool_calls"].size(), "round trip parse");
62
+ std::string again;
63
+ for (const auto & c : parsed.calls) again += (again.empty() ? "" : "\n") + RenderToolCall(c);
64
+ Check(again == generated, std::string("round trip render: ") + name);
65
+ }
66
+ const std::string two = "<tool_call>read_file\n<arg_key>path</arg_key>\n<arg_value>/tmp/a</arg_value>\n</tool_call>\n"
67
+ "<tool_call>read_file <arg_key>path</arg_key><arg_value>/tmp/b</arg_value></tool_call>";
68
+ Check(ParseToolCalls(two, tools, true).calls.size() == 2, "parallel calls");
69
+ Check(ParseToolCalls(two, tools, false).calls.size() == 1, "parallel_tool_calls=false");
70
+ Check(ParseToolCalls(two, tools, true).calls[1].arguments["path"] == "/tmp/b", "whitespace-free call");
71
+ Check(!ParseToolCalls("<tool_call>unknown\n\n</tool_call>", tools, true).ok, "undeclared tool accepted");
72
+ Check(!ParseToolCalls("<tool_call>read_file\n<arg_key>path</arg_key>\n<arg_value>/tmp", tools, true).ok,
73
+ "truncated call accepted");
74
+ Check(ParseToolCalls("<tool_call>read_file\n\n</tool_call>\nDone.", tools, true).trailing == "Done.",
75
+ "trailing content lost");
76
+
77
+ // Streaming: call text and the newline before it are held, never sent as content.
78
+ ToolCallFilter filter(true);
79
+ std::string shown;
80
+ for (const auto * piece : {"Read it.", "\n<tool", "_call>patch\n<arg_key>path</arg_key>",
81
+ "\n<arg_value>/tmp/a</arg_value>\n</tool_call>"})
82
+ shown += filter.Push(piece);
83
+ shown += filter.Push({}, true);
84
+ Check(shown == "Read it." && filter.captured().starts_with("<tool_call>patch"), "call text streamed");
85
+ Check(filter.Released() == "\n" + filter.captured(), "held newline lost");
86
+ ToolCallFilter plain(true);
87
+ shown = plain.Push("a <tool");
88
+ shown += plain.Push("box>\n");
89
+ shown += plain.Push({}, true);
90
+ Check(shown == "a <toolbox>\n" && plain.captured().empty(), "false tag prefix withheld");
91
+ ToolCallFilter off(false);
92
+ Check(off.Push("<tool_call>x") == "<tool_call>x", "disabled filter held text");
93
+ std::cout << "chat tools: " << golden.size() << " golden prompts, parsing, round trip, streaming passed\n";
94
+ }
tools/render_chat_tools_golden.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Render OpenAI-style requests with the official Ling-3.0 chat template.
3
+
4
+ The output is the golden file for tests/chat_tools_test.cpp: each request is
5
+ rendered by transformers' own Jinja renderer, so the C++ prompt builder is
6
+ checked against the template rather than against a reading of it. The
7
+ template is inclusionAI/Ling-3.0-tiny@e3a47d5b986e7141b6efd62597d598ebb392060d
8
+ chat_template.jinja (pass its local path). Tool-call arguments travel as JSON
9
+ strings in the requests, as OpenAI clients send them; the template needs them
10
+ as mappings, as vLLM and llama.cpp convert them.
11
+ """
12
+ import argparse
13
+ import copy
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from transformers.utils.chat_template_utils import render_jinja_template
18
+
19
+ MODEL = "mindnano-ling3-tiny"
20
+ READ = {"type": "function", "function": {"name": "read_file", "description": "Read a file's contents.",
21
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}
22
+ PATCH = {"type": "function", "function": {"name": "patch", "description": "Apply a small text patch to a file.",
23
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "find": {"type": "string"},
24
+ "replace": {"type": "string"}}, "required": ["path", "find", "replace"]}}}
25
+ RUN = {"type": "function", "function": {"name": "run", "description": "运行命令 — 可设超时。",
26
+ "parameters": {"type": "object", "properties": {"command": {"type": "string"}, "timeout": {"type": "integer"},
27
+ "env": {"type": "object"}, "dry": {"type": "boolean"}}, "required": ["command"]}}}
28
+
29
+
30
+ def call(id_, name, **arguments):
31
+ return {"id": id_, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments, ensure_ascii=False)}}
32
+
33
+
34
+ CASES = [
35
+ ("no_tools_history", False, None, [
36
+ {"role": "system", "content": "请用中文回答"}, {"role": "user", "content": "你好"},
37
+ {"role": "assistant", "content": "你好!"}, {"role": "user", "content": "继续"}]),
38
+ ("tools_no_system", False, [READ], [{"role": "user", "content": "Reply with OK."}]),
39
+ ("tools_system", False, [READ, PATCH], [
40
+ {"role": "system", "content": "You are terse."},
41
+ {"role": "user", "content": "Use the tools to read /tmp/scratch.txt, then patch it to change 'foo' to 'bar'."}]),
42
+ ("tools_system_thinking_phrase", False, [READ], [
43
+ {"role": "system", "content": "detailed thinking off\nBe brief."}, {"role": "user", "content": "Read /tmp/a."}]),
44
+ ("tool_loop", False, [READ, PATCH, RUN], [
45
+ {"role": "system", "content": "You are terse."}, {"role": "user", "content": "Fix /tmp/a.txt."},
46
+ {"role": "assistant", "content": None, "tool_calls": [
47
+ call("call_1", "read_file", path="/tmp/a.txt"),
48
+ call("call_2", "run", command="ls -l", timeout=30, env={"A": "1", "B": [1, 2.5]}, dry=False)]},
49
+ {"role": "tool", "tool_call_id": "call_1", "content": "foo\n"},
50
+ {"role": "tool", "tool_call_id": "call_2", "content": "total 0"},
51
+ {"role": "assistant", "content": "Read it.", "tool_calls": [
52
+ call("call_3", "patch", path="/tmp/a.txt", find="foo", replace="bar\n")]},
53
+ {"role": "tool", "tool_call_id": "call_3", "content": "ok"}]),
54
+ ("thinking_tool_loop", True, [READ], [
55
+ {"role": "user", "content": "What is in /tmp/a?"},
56
+ {"role": "assistant", "content": "", "reasoning_content": "\nI should read it.\n",
57
+ "tool_calls": [call("call_1", "read_file", path="/tmp/a")]},
58
+ {"role": "tool", "tool_call_id": "call_1", "content": "foo"}]),
59
+ ("think_in_content", True, [READ], [
60
+ {"role": "user", "content": "Hi"},
61
+ {"role": "assistant", "content": "<think>\nplan\n</think>\n\nHello."},
62
+ {"role": "user", "content": "读取 /tmp/文件 ✓"}]),
63
+ ("unicode_call", False, [RUN], [
64
+ {"role": "user", "content": "打招呼"},
65
+ {"role": "assistant", "content": None, "tool_calls": [call("call_1", "run", command="echo 你好")]},
66
+ {"role": "tool", "tool_call_id": "call_1", "content": "你好"}]),
67
+ ]
68
+
69
+
70
+ def template_messages(messages):
71
+ messages = copy.deepcopy(messages)
72
+ for message in messages:
73
+ for tool_call in message.get("tool_calls", []):
74
+ tool_call["function"]["arguments"] = json.loads(tool_call["function"]["arguments"])
75
+ return messages
76
+
77
+
78
+ def main():
79
+ parser = argparse.ArgumentParser(description=__doc__)
80
+ parser.add_argument("--template", type=Path, required=True)
81
+ parser.add_argument("--output", type=Path, required=True)
82
+ args = parser.parse_args()
83
+ template = args.template.read_text()
84
+ golden = []
85
+ for name, thinking, tools, messages in CASES:
86
+ rendered, _ = render_jinja_template(conversations=[template_messages(messages)], tools=tools,
87
+ chat_template=template, add_generation_prompt=True,
88
+ enable_thinking=thinking)
89
+ request = {"model": MODEL, "messages": messages, "enable_thinking": thinking}
90
+ if tools: request["tools"] = tools
91
+ golden.append({"name": name, "request": request, "prompt": rendered[0]})
92
+ args.output.write_text(json.dumps(golden, ensure_ascii=False, indent=1) + "\n")
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()