Skip to main content

llama_cpp_bindings/tool_call_format/
paired_quote_args.rs

1use llama_cpp_bindings_types::PairedQuoteShape;
2use llama_cpp_bindings_types::ParsedToolCall;
3use llama_cpp_bindings_types::ToolCallArguments;
4use llama_cpp_bindings_types::ToolCallMarkers;
5use llama_cpp_bindings_types::ToolCallValueQuote;
6
7use crate::error::PairedQuoteFailure;
8
9enum ParseStep<'body> {
10    Done,
11    Call(ParsedToolCall, &'body str),
12}
13
14fn consume_optional_prefix<'body>(input: &'body str, literal: &str) -> &'body str {
15    input.strip_prefix(literal).unwrap_or(input)
16}
17
18fn split_at_separator<'body>(
19    input: &'body str,
20    separator: &str,
21) -> Option<(&'body str, &'body str)> {
22    let (name_raw, after_separator) = input.split_once(separator)?;
23    Some((name_raw, after_separator))
24}
25
26fn bare_value_to_json(text: &str) -> serde_json::Value {
27    if text.is_empty() {
28        return serde_json::Value::Null;
29    }
30    serde_json::from_str::<serde_json::Value>(text)
31        .ok()
32        .unwrap_or_else(|| serde_json::Value::String(text.to_owned()))
33}
34
35fn find_bare_value_end(input: &str, close_marker: &str) -> usize {
36    for (byte_index, character) in input.char_indices() {
37        if character == ',' {
38            return byte_index;
39        }
40        if !close_marker.is_empty() && input[byte_index..].starts_with(close_marker) {
41            return byte_index;
42        }
43    }
44
45    input.len()
46}
47
48fn parse_one_key<'body>(
49    input: &'body str,
50    tool_name: &str,
51) -> Result<(String, &'body str), PairedQuoteFailure> {
52    let Some((key_raw, after_colon)) = input.split_once(':') else {
53        return Err(PairedQuoteFailure::UnclosedArgumentBlock {
54            tool_name: tool_name.to_owned(),
55            state: "key",
56        });
57    };
58    let key = key_raw.trim().to_owned();
59    if key.is_empty() {
60        return Err(PairedQuoteFailure::EmptyKey {
61            tool_name: tool_name.to_owned(),
62        });
63    }
64
65    Ok((key, after_colon))
66}
67
68fn parse_one_value<'body>(
69    input: &'body str,
70    value_quote: &ToolCallValueQuote,
71    close_marker: &str,
72    tool_name: &str,
73    key: &str,
74) -> Result<(serde_json::Value, &'body str), PairedQuoteFailure> {
75    let trimmed = input.trim_start();
76
77    if !value_quote.open.is_empty()
78        && !value_quote.close.is_empty()
79        && let Some(after_open) = trimmed.strip_prefix(value_quote.open.as_str())
80    {
81        let Some(close_position) = after_open.find(value_quote.close.as_str()) else {
82            return Err(PairedQuoteFailure::UnclosedQuotedValue {
83                tool_name: tool_name.to_owned(),
84                key: key.to_owned(),
85            });
86        };
87        let value_text = after_open[..close_position].to_owned();
88        let after_close = &after_open[close_position + value_quote.close.len()..];
89
90        return Ok((serde_json::Value::String(value_text), after_close));
91    }
92
93    let bare_end = find_bare_value_end(trimmed, close_marker);
94    let bare_text = trimmed[..bare_end].trim();
95    let value = bare_value_to_json(bare_text);
96
97    Ok((value, &trimmed[bare_end..]))
98}
99
100fn parse_args_body<'body>(
101    input: &'body str,
102    value_quote: &ToolCallValueQuote,
103    close_marker: &str,
104    tool_name: &str,
105) -> Result<(serde_json::Map<String, serde_json::Value>, &'body str), PairedQuoteFailure> {
106    let mut map = serde_json::Map::new();
107    let mut remaining = input.trim_start();
108
109    loop {
110        if remaining.is_empty() {
111            return Ok((map, remaining));
112        }
113        if !close_marker.is_empty()
114            && let Some(after_close) = remaining.strip_prefix(close_marker)
115        {
116            return Ok((map, after_close));
117        }
118
119        let (key, after_key) = parse_one_key(remaining, tool_name)?;
120        let (value, after_value) =
121            parse_one_value(after_key, value_quote, close_marker, tool_name, &key)?;
122        map.insert(key.clone(), value);
123
124        remaining = after_value.trim_start();
125        if !close_marker.is_empty()
126            && let Some(after_close) = remaining.strip_prefix(close_marker)
127        {
128            return Ok((map, after_close));
129        }
130        if let Some(after_comma) = remaining.strip_prefix(',') {
131            remaining = after_comma.trim_start();
132            continue;
133        }
134
135        let Some(character) = remaining.chars().next() else {
136            return Ok((map, remaining));
137        };
138
139        return Err(PairedQuoteFailure::UnexpectedCharAfterValue {
140            tool_name: tool_name.to_owned(),
141            key,
142            character,
143        });
144    }
145}
146
147fn parse_one_call<'body>(
148    input: &'body str,
149    markers: &ToolCallMarkers,
150    shape: &PairedQuoteShape,
151) -> Result<ParseStep<'body>, PairedQuoteFailure> {
152    if input.is_empty() {
153        return Ok(ParseStep::Done);
154    }
155
156    let after_open = consume_optional_prefix(input, markers.open.as_str());
157
158    let Some((name_raw, after_separator)) =
159        split_at_separator(after_open, shape.name_args_separator.as_str())
160    else {
161        return Ok(ParseStep::Done);
162    };
163
164    let name = name_raw.trim().to_owned();
165    if name.is_empty() {
166        return Ok(ParseStep::Done);
167    }
168
169    let (args_object, after_args) = parse_args_body(
170        after_separator,
171        &shape.value_quote,
172        markers.close.as_str(),
173        &name,
174    )?;
175    let arguments_value = serde_json::Value::Object(args_object);
176
177    Ok(ParseStep::Call(
178        ParsedToolCall::new(
179            String::new(),
180            name,
181            ToolCallArguments::ValidJson(arguments_value),
182        ),
183        after_args,
184    ))
185}
186
187/// # Errors
188///
189/// Returns [`PairedQuoteFailure`] when the body looks like a paired-quote
190/// tool-call block (matches the open marker and separator) but contains a
191/// structural issue: empty key, unclosed quoted value, unexpected character
192/// after a value, or an unfinished argument block.
193pub fn parse(
194    body: &str,
195    markers: &ToolCallMarkers,
196    shape: &PairedQuoteShape,
197) -> Result<Vec<ParsedToolCall>, PairedQuoteFailure> {
198    if shape.name_args_separator.is_empty() {
199        return Ok(Vec::new());
200    }
201
202    let mut parsed = Vec::new();
203    let mut remaining = body.trim_start();
204
205    loop {
206        match parse_one_call(remaining, markers, shape)? {
207            ParseStep::Done => break,
208            ParseStep::Call(call, rest) => {
209                parsed.push(call);
210                remaining = rest.trim_start();
211            }
212        }
213    }
214
215    Ok(parsed)
216}
217
218#[cfg(test)]
219mod tests {
220    use llama_cpp_bindings_types::PairedQuoteShape;
221    use llama_cpp_bindings_types::ToolCallArgsShape;
222    use llama_cpp_bindings_types::ToolCallArguments;
223    use llama_cpp_bindings_types::ToolCallMarkers;
224    use llama_cpp_bindings_types::ToolCallValueQuote;
225    use serde_json::json;
226
227    use super::parse;
228    use crate::error::PairedQuoteFailure;
229
230    fn gemma4_markers() -> ToolCallMarkers {
231        ToolCallMarkers {
232            open: "<|tool_call>call:".to_owned(),
233            close: "}".to_owned(),
234            args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()),
235        }
236    }
237
238    fn gemma4_shape() -> PairedQuoteShape {
239        PairedQuoteShape {
240            name_args_separator: "{".to_owned(),
241            value_quote: ToolCallValueQuote {
242                open: "<|\"|>".to_owned(),
243                close: "<|\"|>".to_owned(),
244            },
245        }
246    }
247
248    #[test]
249    fn parses_single_quoted_string_argument_with_full_markers() {
250        let parsed = parse(
251            "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
252            &gemma4_markers(),
253            &gemma4_shape(),
254        )
255        .expect("must parse");
256
257        assert_eq!(parsed.len(), 1);
258        assert_eq!(parsed[0].name, "get_weather");
259        assert_eq!(
260            parsed[0].arguments,
261            ToolCallArguments::ValidJson(json!({"location": "Paris"})),
262        );
263    }
264
265    #[test]
266    fn parses_classifier_stripped_body_without_open_or_close() {
267        let parsed = parse(
268            "get_weather{location:<|\"|>Paris<|\"|>",
269            &gemma4_markers(),
270            &gemma4_shape(),
271        )
272        .expect("must parse");
273
274        assert_eq!(parsed.len(), 1);
275        assert_eq!(parsed[0].name, "get_weather");
276        assert_eq!(
277            parsed[0].arguments,
278            ToolCallArguments::ValidJson(json!({"location": "Paris"})),
279        );
280    }
281
282    #[test]
283    fn parses_multiple_quoted_string_arguments() {
284        let parsed = parse(
285            "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}",
286            &gemma4_markers(),
287            &gemma4_shape(),
288        )
289        .expect("must parse");
290
291        assert_eq!(parsed.len(), 1);
292        assert_eq!(
293            parsed[0].arguments,
294            ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})),
295        );
296    }
297
298    #[test]
299    fn parses_bare_numeric_value() {
300        let parsed = parse(
301            "<|tool_call>call:f{a:42}",
302            &gemma4_markers(),
303            &gemma4_shape(),
304        )
305        .expect("must parse");
306
307        assert_eq!(
308            parsed[0].arguments,
309            ToolCallArguments::ValidJson(json!({"a": 42})),
310        );
311    }
312
313    #[test]
314    fn parses_bare_boolean_value() {
315        let parsed = parse(
316            "<|tool_call>call:f{a:true}",
317            &gemma4_markers(),
318            &gemma4_shape(),
319        )
320        .expect("must parse");
321
322        assert_eq!(
323            parsed[0].arguments,
324            ToolCallArguments::ValidJson(json!({"a": true})),
325        );
326    }
327
328    #[test]
329    fn rejects_unclosed_quoted_value_with_typed_failure() {
330        let result = parse(
331            "<|tool_call>call:f{a:<|\"|>oops",
332            &gemma4_markers(),
333            &gemma4_shape(),
334        );
335
336        assert_eq!(
337            result.expect_err("unclosed quote must produce a typed failure"),
338            PairedQuoteFailure::UnclosedQuotedValue {
339                tool_name: "f".to_owned(),
340                key: "a".to_owned(),
341            },
342        );
343    }
344
345    #[test]
346    fn rejects_unexpected_char_after_value_with_typed_failure() {
347        let result = parse(
348            "<|tool_call>call:f{a:<|\"|>v<|\"|>$bad}",
349            &gemma4_markers(),
350            &gemma4_shape(),
351        );
352
353        assert_eq!(
354            result.expect_err("garbage after value must produce a typed failure"),
355            PairedQuoteFailure::UnexpectedCharAfterValue {
356                tool_name: "f".to_owned(),
357                key: "a".to_owned(),
358                character: '$',
359            },
360        );
361    }
362
363    #[test]
364    fn returns_empty_vec_for_empty_body() {
365        let parsed = parse("", &gemma4_markers(), &gemma4_shape()).expect("empty body must parse");
366        assert!(parsed.is_empty());
367    }
368
369    #[test]
370    fn returns_empty_vec_when_body_lacks_separator() {
371        let parsed = parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())
372            .expect("body without separator must parse");
373        assert!(parsed.is_empty());
374    }
375
376    #[test]
377    fn parses_args_body_terminated_by_end_of_input_after_quoted_value() {
378        let parsed = parse(
379            "<|tool_call>call:f{x:<|\"|>v<|\"|>",
380            &gemma4_markers(),
381            &gemma4_shape(),
382        )
383        .expect("end-of-input after quoted value must parse");
384
385        assert_eq!(
386            parsed[0].arguments,
387            ToolCallArguments::ValidJson(json!({"x": "v"})),
388        );
389    }
390
391    #[test]
392    fn parses_args_body_terminated_by_end_of_input_after_bare_value() {
393        let parsed = parse(
394            "<|tool_call>call:f{n:42",
395            &gemma4_markers(),
396            &gemma4_shape(),
397        )
398        .expect("end-of-input after bare value must parse");
399
400        assert_eq!(
401            parsed[0].arguments,
402            ToolCallArguments::ValidJson(json!({"n": 42})),
403        );
404    }
405
406    #[test]
407    fn rejects_empty_key_with_typed_failure() {
408        let result = parse(
409            "<|tool_call>call:f{:42}",
410            &gemma4_markers(),
411            &gemma4_shape(),
412        );
413
414        assert_eq!(
415            result.expect_err("empty key must produce a typed failure"),
416            PairedQuoteFailure::EmptyKey {
417                tool_name: "f".to_owned(),
418            },
419        );
420    }
421
422    #[test]
423    fn rejects_args_body_without_key_colon_with_typed_failure() {
424        let result = parse(
425            "<|tool_call>call:f{noColonHere",
426            &gemma4_markers(),
427            &gemma4_shape(),
428        );
429
430        assert_eq!(
431            result.expect_err("args body without colon must produce a typed failure"),
432            PairedQuoteFailure::UnclosedArgumentBlock {
433                tool_name: "f".to_owned(),
434                state: "key",
435            },
436        );
437    }
438
439    #[test]
440    fn parses_empty_bare_value_as_null() {
441        let parsed = parse("<|tool_call>call:f{a:}", &gemma4_markers(), &gemma4_shape())
442            .expect("empty bare value must parse");
443
444        assert_eq!(
445            parsed[0].arguments,
446            ToolCallArguments::ValidJson(json!({"a": null})),
447        );
448    }
449
450    #[test]
451    fn parses_call_with_empty_args_body_terminated_by_end_of_input() {
452        let parsed = parse("<|tool_call>call:f{", &gemma4_markers(), &gemma4_shape())
453            .expect("empty args body must parse");
454
455        assert_eq!(parsed.len(), 1);
456        assert_eq!(parsed[0].name, "f");
457        assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
458    }
459
460    #[test]
461    fn parses_call_with_empty_args_body_closed_by_marker() {
462        let parsed = parse("<|tool_call>call:f{}", &gemma4_markers(), &gemma4_shape())
463            .expect("empty args body closed by marker must parse");
464
465        assert_eq!(parsed.len(), 1);
466        assert_eq!(parsed[0].name, "f");
467        assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
468    }
469
470    #[test]
471    fn stops_parsing_when_tool_name_is_empty() {
472        let parsed = parse(
473            "<|tool_call>call:{a:<|\"|>v<|\"|>}",
474            &gemma4_markers(),
475            &gemma4_shape(),
476        )
477        .expect("empty tool name must yield no calls");
478
479        assert!(parsed.is_empty());
480    }
481
482    #[test]
483    fn returns_empty_vec_when_separator_is_empty() {
484        let shape = PairedQuoteShape {
485            name_args_separator: String::new(),
486            value_quote: ToolCallValueQuote {
487                open: "<|\"|>".to_owned(),
488                close: "<|\"|>".to_owned(),
489            },
490        };
491        let parsed = parse(
492            "<|tool_call>call:f{a:<|\"|>v<|\"|>}",
493            &gemma4_markers(),
494            &shape,
495        )
496        .expect("empty separator must yield no calls");
497
498        assert!(parsed.is_empty());
499    }
500}