llama_cpp_bindings/tool_call_format/
mod.rs1pub mod bracketed_args;
2pub mod json_object;
3pub mod key_value_xml_tags;
4pub mod paired_quote_args;
5pub mod tool_call_format_outcome;
6pub mod xml_function_tags;
7
8pub use self::tool_call_format_outcome::ToolCallFormatOutcome;
9
10use llama_cpp_bindings_types::ToolCallArgsShape;
11use llama_cpp_bindings_types::ToolCallMarkers;
12
13use crate::error::ToolCallFormatFailure;
14
15#[must_use]
16pub fn try_parse(body: &str, markers: &ToolCallMarkers) -> ToolCallFormatOutcome {
17 if markers.open.is_empty() {
18 return ToolCallFormatOutcome::NoMatch;
19 }
20
21 let parsed: Result<Vec<_>, ToolCallFormatFailure> = match &markers.args_shape {
22 ToolCallArgsShape::BracketedJson(shape) => {
23 bracketed_args::parse(body, markers, shape).map_err(Into::into)
24 }
25 ToolCallArgsShape::JsonObject(shape) => json_object::parse(body, shape).map_err(Into::into),
26 ToolCallArgsShape::KeyValueXmlTags(shape) => {
27 key_value_xml_tags::parse(body, markers, shape).map_err(Into::into)
28 }
29 ToolCallArgsShape::PairedQuote(shape) => {
30 paired_quote_args::parse(body, markers, shape).map_err(Into::into)
31 }
32 ToolCallArgsShape::XmlTags(shape) => {
33 xml_function_tags::parse(body, shape).map_err(Into::into)
34 }
35 };
36
37 match parsed {
38 Ok(parsed) if parsed.is_empty() => ToolCallFormatOutcome::NoMatch,
39 Ok(parsed) => ToolCallFormatOutcome::Parsed(parsed),
40 Err(failure) => ToolCallFormatOutcome::Failed(failure),
41 }
42}
43
44#[cfg(test)]
45mod tests {
46 use llama_cpp_bindings_types::BracketedJsonShape;
47 use llama_cpp_bindings_types::KeyValueXmlTagsShape;
48 use llama_cpp_bindings_types::PairedQuoteShape;
49 use llama_cpp_bindings_types::ParsedToolCall;
50 use llama_cpp_bindings_types::ToolCallArgsShape;
51 use llama_cpp_bindings_types::ToolCallArguments;
52 use llama_cpp_bindings_types::ToolCallMarkers;
53 use llama_cpp_bindings_types::ToolCallValueQuote;
54 use llama_cpp_bindings_types::XmlTagsShape;
55 use serde_json::json;
56
57 use super::ToolCallFormatOutcome;
58 use super::try_parse;
59 use crate::error::BracketedArgsFailure;
60 use crate::error::ToolCallFormatFailure;
61
62 fn mistral3_markers() -> ToolCallMarkers {
63 ToolCallMarkers {
64 open: "[TOOL_CALLS]".to_owned(),
65 close: String::new(),
66 args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape {
67 name_args_separator: "[ARGS]".to_owned(),
68 }),
69 }
70 }
71
72 fn gemma4_markers() -> ToolCallMarkers {
73 ToolCallMarkers {
74 open: "<|tool_call>call:".to_owned(),
75 close: "}".to_owned(),
76 args_shape: ToolCallArgsShape::PairedQuote(PairedQuoteShape {
77 name_args_separator: "{".to_owned(),
78 value_quote: ToolCallValueQuote {
79 open: "<|\"|>".to_owned(),
80 close: "<|\"|>".to_owned(),
81 },
82 }),
83 }
84 }
85
86 fn qwen35_markers() -> ToolCallMarkers {
87 ToolCallMarkers {
88 open: "<tool_call>".to_owned(),
89 close: "</tool_call>".to_owned(),
90 args_shape: ToolCallArgsShape::XmlTags(XmlTagsShape {
91 function_open_prefix: "<function=".to_owned(),
92 function_close: "</function>".to_owned(),
93 parameter_open_prefix: "<parameter=".to_owned(),
94 parameter_close: "</parameter>".to_owned(),
95 }),
96 }
97 }
98
99 fn glm47_markers() -> ToolCallMarkers {
100 ToolCallMarkers {
101 open: "<tool_call>".to_owned(),
102 close: "</tool_call>".to_owned(),
103 args_shape: ToolCallArgsShape::KeyValueXmlTags(KeyValueXmlTagsShape {
104 key_open: "<arg_key>".to_owned(),
105 key_close: "</arg_key>".to_owned(),
106 value_open: "<arg_value>".to_owned(),
107 value_close: "</arg_value>".to_owned(),
108 }),
109 }
110 }
111
112 #[test]
113 fn dispatches_to_bracketed_args_for_mistral3_shape() {
114 let outcome = try_parse(
115 "[TOOL_CALLS]get_weather[ARGS]{\"location\":\"Paris\"}",
116 &mistral3_markers(),
117 );
118
119 assert_eq!(
120 outcome,
121 ToolCallFormatOutcome::Parsed(vec![ParsedToolCall::new(
122 String::new(),
123 "get_weather".to_owned(),
124 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
125 )]),
126 );
127 }
128
129 #[test]
130 fn dispatches_to_paired_quote_args_for_gemma4_shape() {
131 let outcome = try_parse(
132 "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
133 &gemma4_markers(),
134 );
135
136 assert_eq!(
137 outcome,
138 ToolCallFormatOutcome::Parsed(vec![ParsedToolCall::new(
139 String::new(),
140 "get_weather".to_owned(),
141 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
142 )]),
143 );
144 }
145
146 #[test]
147 fn dispatches_to_key_value_xml_tags_for_glm47_shape() {
148 let outcome = try_parse(
149 "<tool_call>get_weather<arg_key>location</arg_key><arg_value>Paris</arg_value></tool_call>",
150 &glm47_markers(),
151 );
152
153 assert_eq!(
154 outcome,
155 ToolCallFormatOutcome::Parsed(vec![ParsedToolCall::new(
156 String::new(),
157 "get_weather".to_owned(),
158 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
159 )]),
160 );
161 }
162
163 #[test]
164 fn dispatches_to_xml_function_tags_for_qwen35_shape() {
165 let outcome = try_parse(
166 "<function=get_weather><parameter=location>Paris</parameter></function>",
167 &qwen35_markers(),
168 );
169
170 assert_eq!(
171 outcome,
172 ToolCallFormatOutcome::Parsed(vec![ParsedToolCall::new(
173 String::new(),
174 "get_weather".to_owned(),
175 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
176 )]),
177 );
178 }
179
180 #[test]
181 fn no_match_when_open_marker_is_empty() {
182 let markers = ToolCallMarkers {
183 open: String::new(),
184 close: String::new(),
185 args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape {
186 name_args_separator: "[ARGS]".to_owned(),
187 }),
188 };
189
190 assert_eq!(
191 try_parse("[TOOL_CALLS]get_weather[ARGS]{}", &markers),
192 ToolCallFormatOutcome::NoMatch,
193 );
194 }
195
196 #[test]
197 fn no_match_when_body_lacks_markers() {
198 assert_eq!(
199 try_parse("plain text without tool calls", &mistral3_markers()),
200 ToolCallFormatOutcome::NoMatch,
201 );
202 }
203
204 #[test]
205 fn failed_when_inner_parser_returns_typed_failure() {
206 let outcome = try_parse("[TOOL_CALLS]get_weather[ARGS] ", &mistral3_markers());
207
208 assert_eq!(
209 outcome,
210 ToolCallFormatOutcome::Failed(ToolCallFormatFailure::BracketedArgs(
211 BracketedArgsFailure::UnterminatedArguments {
212 tool_name: "get_weather".to_owned(),
213 },
214 )),
215 );
216 }
217
218 #[test]
219 fn try_parse_returns_no_match_for_glm_input_under_qwen_markers() {
220 let glm_input = "<tool_call>get_weather\
221 <arg_key>location</arg_key>\
222 <arg_value>Paris</arg_value>\
223 </tool_call>";
224
225 assert_eq!(
226 try_parse(glm_input, &qwen35_markers()),
227 ToolCallFormatOutcome::NoMatch,
228 );
229 }
230
231 #[test]
232 fn try_parse_returns_no_match_for_plain_content_under_every_known_shape() {
233 use crate::tool_call_template_overrides::known_marker_candidates;
234
235 let plain_content = "Sorry, I cannot help with that request.";
236
237 for candidate in known_marker_candidates() {
238 assert_eq!(
239 try_parse(plain_content, &candidate),
240 ToolCallFormatOutcome::NoMatch,
241 "expected NoMatch for plain content under candidate {candidate:?}"
242 );
243 }
244 }
245
246 #[test]
247 fn duck_type_resolves_qwen_xml_input_via_xml_tags_shape_first() {
248 use llama_cpp_bindings_types::ToolCallArguments;
249
250 use crate::tool_call_template_overrides::known_marker_candidates;
251
252 let qwen_input = "<tool_call>\n\
253 <function=get_weather>\n\
254 <parameter=location>\n\
255 Paris\n\
256 </parameter>\n\
257 </function>\n\
258 </tool_call>";
259
260 let mut resolved = None;
261 for candidate in known_marker_candidates() {
262 if let ToolCallFormatOutcome::Parsed(calls) = try_parse(qwen_input, &candidate) {
263 resolved = Some((candidate.args_shape, calls));
264 break;
265 }
266 }
267
268 let (args_shape, calls) =
269 resolved.expect("Qwen XML input must resolve via at least one duck-type candidate");
270 assert_eq!(
271 args_shape,
272 ToolCallArgsShape::XmlTags(XmlTagsShape {
273 function_open_prefix: "<function=".to_owned(),
274 function_close: "</function>".to_owned(),
275 parameter_open_prefix: "<parameter=".to_owned(),
276 parameter_close: "</parameter>".to_owned(),
277 }),
278 "duck-type ordering must resolve Qwen XML via the XmlTags shape (most restrictive \
279 shape that requires `<function=`), got {args_shape:?}"
280 );
281 assert_eq!(calls.len(), 1);
282 assert_eq!(calls[0].name, "get_weather");
283 assert_eq!(
284 calls[0].arguments,
285 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
286 );
287 }
288
289 #[test]
290 fn duck_type_resolves_glm_input_via_key_value_xml_tags_shape() {
291 use llama_cpp_bindings_types::ToolCallArguments;
292
293 use crate::tool_call_template_overrides::known_marker_candidates;
294
295 let glm_input = "<tool_call>get_weather\
296 <arg_key>location</arg_key>\
297 <arg_value>Paris</arg_value>\
298 </tool_call>";
299
300 let mut resolved = None;
301 for candidate in known_marker_candidates() {
302 if let ToolCallFormatOutcome::Parsed(calls) = try_parse(glm_input, &candidate) {
303 resolved = Some((candidate.args_shape, calls));
304 break;
305 }
306 }
307
308 let (args_shape, calls) =
309 resolved.expect("GLM input must resolve via at least one duck-type candidate");
310 assert_eq!(
311 args_shape,
312 ToolCallArgsShape::KeyValueXmlTags(KeyValueXmlTagsShape {
313 key_open: "<arg_key>".to_owned(),
314 key_close: "</arg_key>".to_owned(),
315 value_open: "<arg_value>".to_owned(),
316 value_close: "</arg_value>".to_owned(),
317 }),
318 "GLM input must resolve via the KeyValueXmlTags shape, got {args_shape:?}"
319 );
320 assert_eq!(calls.len(), 1);
321 assert_eq!(calls[0].name, "get_weather");
322 assert_eq!(
323 calls[0].arguments,
324 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
325 );
326 }
327
328 #[test]
329 fn duck_type_resolves_mistral_input_via_bracketed_json_shape() {
330 use llama_cpp_bindings_types::ToolCallArguments;
331
332 use crate::tool_call_template_overrides::known_marker_candidates;
333
334 let mistral_input = r#"[TOOL_CALLS]get_weather[ARGS]{"location":"Paris"}"#;
335
336 let mut resolved = None;
337 for candidate in known_marker_candidates() {
338 if let ToolCallFormatOutcome::Parsed(calls) = try_parse(mistral_input, &candidate) {
339 resolved = Some((candidate.args_shape, calls));
340 break;
341 }
342 }
343
344 let (args_shape, calls) =
345 resolved.expect("Mistral input must resolve via at least one duck-type candidate");
346 assert_eq!(
347 args_shape,
348 ToolCallArgsShape::BracketedJson(BracketedJsonShape {
349 name_args_separator: "[ARGS]".to_owned(),
350 }),
351 "Mistral input must resolve via the BracketedJson shape; the candidate ordering must \
352 try BracketedJson before PairedQuote because PairedQuote's `{{` separator could \
353 greedily match Mistral's JSON args. Got {args_shape:?}"
354 );
355 assert_eq!(calls.len(), 1);
356 assert_eq!(calls[0].name, "get_weather");
357 assert_eq!(
358 calls[0].arguments,
359 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
360 );
361 }
362
363 #[test]
364 fn duck_type_resolves_gemma_input_via_paired_quote_shape() {
365 use llama_cpp_bindings_types::ToolCallArguments;
366
367 use crate::tool_call_template_overrides::known_marker_candidates;
368
369 let gemma_input = "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}";
370
371 let mut resolved = None;
372 for candidate in known_marker_candidates() {
373 if let ToolCallFormatOutcome::Parsed(calls) = try_parse(gemma_input, &candidate) {
374 resolved = Some((candidate.args_shape, calls));
375 break;
376 }
377 }
378
379 let (args_shape, calls) =
380 resolved.expect("Gemma input must resolve via at least one duck-type candidate");
381 assert_eq!(
382 args_shape,
383 ToolCallArgsShape::PairedQuote(PairedQuoteShape {
384 name_args_separator: "{".to_owned(),
385 value_quote: ToolCallValueQuote {
386 open: "<|\"|>".to_owned(),
387 close: "<|\"|>".to_owned(),
388 },
389 }),
390 "Gemma input must resolve via the PairedQuote shape, got {args_shape:?}"
391 );
392 assert_eq!(calls.len(), 1);
393 assert_eq!(calls[0].name, "get_weather");
394 assert_eq!(
395 calls[0].arguments,
396 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
397 );
398 }
399}