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