Skip to main content

llama_cpp_bindings/tool_call_format/
mod.rs

1pub 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}