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    #![expect(
221        clippy::literal_string_with_formatting_args,
222        reason = "Gemma tool-call format literals contain braces that resemble format args"
223    )]
224
225    use llama_cpp_bindings_types::PairedQuoteShape;
226    use llama_cpp_bindings_types::ToolCallArgsShape;
227    use llama_cpp_bindings_types::ToolCallArguments;
228    use llama_cpp_bindings_types::ToolCallMarkers;
229    use llama_cpp_bindings_types::ToolCallValueQuote;
230    use serde_json::json;
231
232    use super::parse;
233    use crate::error::PairedQuoteFailure;
234
235    fn gemma4_markers() -> ToolCallMarkers {
236        ToolCallMarkers {
237            open: "<|tool_call>call:".to_owned(),
238            close: "}".to_owned(),
239            args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()),
240        }
241    }
242
243    fn gemma4_shape() -> PairedQuoteShape {
244        PairedQuoteShape {
245            name_args_separator: "{".to_owned(),
246            value_quote: ToolCallValueQuote {
247                open: "<|\"|>".to_owned(),
248                close: "<|\"|>".to_owned(),
249            },
250        }
251    }
252
253    #[test]
254    fn parses_single_quoted_string_argument_with_full_markers() {
255        let parsed = parse(
256            "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
257            &gemma4_markers(),
258            &gemma4_shape(),
259        )
260        .expect("must parse");
261
262        assert_eq!(parsed.len(), 1);
263        assert_eq!(parsed[0].name, "get_weather");
264        assert_eq!(
265            parsed[0].arguments,
266            ToolCallArguments::ValidJson(json!({"location": "Paris"})),
267        );
268    }
269
270    #[test]
271    fn parses_classifier_stripped_body_without_open_or_close() {
272        let parsed = parse(
273            "get_weather{location:<|\"|>Paris<|\"|>",
274            &gemma4_markers(),
275            &gemma4_shape(),
276        )
277        .expect("must parse");
278
279        assert_eq!(parsed.len(), 1);
280        assert_eq!(parsed[0].name, "get_weather");
281        assert_eq!(
282            parsed[0].arguments,
283            ToolCallArguments::ValidJson(json!({"location": "Paris"})),
284        );
285    }
286
287    #[test]
288    fn parses_multiple_quoted_string_arguments() {
289        let parsed = parse(
290            "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}",
291            &gemma4_markers(),
292            &gemma4_shape(),
293        )
294        .expect("must parse");
295
296        assert_eq!(parsed.len(), 1);
297        assert_eq!(
298            parsed[0].arguments,
299            ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})),
300        );
301    }
302
303    #[test]
304    fn parses_bare_numeric_value() {
305        let parsed = parse(
306            "<|tool_call>call:f{a:42}",
307            &gemma4_markers(),
308            &gemma4_shape(),
309        )
310        .expect("must parse");
311
312        assert_eq!(
313            parsed[0].arguments,
314            ToolCallArguments::ValidJson(json!({"a": 42})),
315        );
316    }
317
318    #[test]
319    fn parses_bare_boolean_value() {
320        let parsed = parse(
321            "<|tool_call>call:f{a:true}",
322            &gemma4_markers(),
323            &gemma4_shape(),
324        )
325        .expect("must parse");
326
327        assert_eq!(
328            parsed[0].arguments,
329            ToolCallArguments::ValidJson(json!({"a": true})),
330        );
331    }
332
333    #[test]
334    fn rejects_unclosed_quoted_value_with_typed_failure() {
335        let result = parse(
336            "<|tool_call>call:f{a:<|\"|>oops",
337            &gemma4_markers(),
338            &gemma4_shape(),
339        );
340
341        assert_eq!(
342            result.expect_err("unclosed quote must produce a typed failure"),
343            PairedQuoteFailure::UnclosedQuotedValue {
344                tool_name: "f".to_owned(),
345                key: "a".to_owned(),
346            },
347        );
348    }
349
350    #[test]
351    fn rejects_unexpected_char_after_value_with_typed_failure() {
352        let result = parse(
353            "<|tool_call>call:f{a:<|\"|>v<|\"|>$bad}",
354            &gemma4_markers(),
355            &gemma4_shape(),
356        );
357
358        assert_eq!(
359            result.expect_err("garbage after value must produce a typed failure"),
360            PairedQuoteFailure::UnexpectedCharAfterValue {
361                tool_name: "f".to_owned(),
362                key: "a".to_owned(),
363                character: '$',
364            },
365        );
366    }
367
368    #[test]
369    fn returns_empty_vec_for_empty_body() {
370        let parsed = parse("", &gemma4_markers(), &gemma4_shape()).expect("empty body must parse");
371        assert!(parsed.is_empty());
372    }
373
374    #[test]
375    fn returns_empty_vec_when_body_lacks_separator() {
376        let parsed = parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())
377            .expect("body without separator must parse");
378        assert!(parsed.is_empty());
379    }
380
381    #[test]
382    fn parses_args_body_terminated_by_end_of_input_after_quoted_value() {
383        let parsed = parse(
384            "<|tool_call>call:f{x:<|\"|>v<|\"|>",
385            &gemma4_markers(),
386            &gemma4_shape(),
387        )
388        .expect("end-of-input after quoted value must parse");
389
390        assert_eq!(
391            parsed[0].arguments,
392            ToolCallArguments::ValidJson(json!({"x": "v"})),
393        );
394    }
395
396    #[test]
397    fn parses_args_body_terminated_by_end_of_input_after_bare_value() {
398        let parsed = parse(
399            "<|tool_call>call:f{n:42",
400            &gemma4_markers(),
401            &gemma4_shape(),
402        )
403        .expect("end-of-input after bare value must parse");
404
405        assert_eq!(
406            parsed[0].arguments,
407            ToolCallArguments::ValidJson(json!({"n": 42})),
408        );
409    }
410
411    #[test]
412    fn rejects_empty_key_with_typed_failure() {
413        let result = parse(
414            "<|tool_call>call:f{:42}",
415            &gemma4_markers(),
416            &gemma4_shape(),
417        );
418
419        assert_eq!(
420            result.expect_err("empty key must produce a typed failure"),
421            PairedQuoteFailure::EmptyKey {
422                tool_name: "f".to_owned(),
423            },
424        );
425    }
426
427    #[test]
428    fn rejects_args_body_without_key_colon_with_typed_failure() {
429        let result = parse(
430            "<|tool_call>call:f{noColonHere",
431            &gemma4_markers(),
432            &gemma4_shape(),
433        );
434
435        assert_eq!(
436            result.expect_err("args body without colon must produce a typed failure"),
437            PairedQuoteFailure::UnclosedArgumentBlock {
438                tool_name: "f".to_owned(),
439                state: "key",
440            },
441        );
442    }
443
444    #[test]
445    fn parses_empty_bare_value_as_null() {
446        let parsed = parse("<|tool_call>call:f{a:}", &gemma4_markers(), &gemma4_shape())
447            .expect("empty bare value must parse");
448
449        assert_eq!(
450            parsed[0].arguments,
451            ToolCallArguments::ValidJson(json!({"a": null})),
452        );
453    }
454
455    #[test]
456    fn parses_call_with_empty_args_body_terminated_by_end_of_input() {
457        let parsed = parse("<|tool_call>call:f{", &gemma4_markers(), &gemma4_shape())
458            .expect("empty args body must parse");
459
460        assert_eq!(parsed.len(), 1);
461        assert_eq!(parsed[0].name, "f");
462        assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
463    }
464
465    #[test]
466    fn parses_call_with_empty_args_body_closed_by_marker() {
467        let parsed = parse("<|tool_call>call:f{}", &gemma4_markers(), &gemma4_shape())
468            .expect("empty args body closed by marker must parse");
469
470        assert_eq!(parsed.len(), 1);
471        assert_eq!(parsed[0].name, "f");
472        assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
473    }
474
475    #[test]
476    fn stops_parsing_when_tool_name_is_empty() {
477        let parsed = parse(
478            "<|tool_call>call:{a:<|\"|>v<|\"|>}",
479            &gemma4_markers(),
480            &gemma4_shape(),
481        )
482        .expect("empty tool name must yield no calls");
483
484        assert!(parsed.is_empty());
485    }
486
487    #[test]
488    fn returns_empty_vec_when_separator_is_empty() {
489        let shape = PairedQuoteShape {
490            name_args_separator: String::new(),
491            value_quote: ToolCallValueQuote {
492                open: "<|\"|>".to_owned(),
493                close: "<|\"|>".to_owned(),
494            },
495        };
496        let parsed = parse(
497            "<|tool_call>call:f{a:<|\"|>v<|\"|>}",
498            &gemma4_markers(),
499            &shape,
500        )
501        .expect("empty separator must yield no calls");
502
503        assert!(parsed.is_empty());
504    }
505}