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 remaining.is_empty() {
126            return Ok((map, remaining));
127        }
128        if !close_marker.is_empty()
129            && let Some(after_close) = remaining.strip_prefix(close_marker)
130        {
131            return Ok((map, after_close));
132        }
133        if let Some(after_comma) = remaining.strip_prefix(',') {
134            remaining = after_comma.trim_start();
135            continue;
136        }
137
138        let Some(character) = remaining.chars().next() else {
139            return Ok((map, remaining));
140        };
141
142        return Err(PairedQuoteFailure::UnexpectedCharAfterValue {
143            tool_name: tool_name.to_owned(),
144            key,
145            character,
146        });
147    }
148}
149
150fn parse_one_call<'body>(
151    input: &'body str,
152    markers: &ToolCallMarkers,
153    shape: &PairedQuoteShape,
154) -> Result<ParseStep<'body>, PairedQuoteFailure> {
155    if input.is_empty() {
156        return Ok(ParseStep::Done);
157    }
158
159    let after_open = consume_optional_prefix(input, markers.open.as_str());
160
161    let Some((name_raw, after_separator)) =
162        split_at_separator(after_open, shape.name_args_separator.as_str())
163    else {
164        return Ok(ParseStep::Done);
165    };
166
167    let name = name_raw.trim().to_owned();
168    if name.is_empty() {
169        return Ok(ParseStep::Done);
170    }
171
172    let (args_object, after_args) = parse_args_body(
173        after_separator,
174        &shape.value_quote,
175        markers.close.as_str(),
176        &name,
177    )?;
178    let arguments_value = serde_json::Value::Object(args_object);
179
180    Ok(ParseStep::Call(
181        ParsedToolCall::new(
182            String::new(),
183            name,
184            ToolCallArguments::ValidJson(arguments_value),
185        ),
186        after_args,
187    ))
188}
189
190/// # Errors
191///
192/// Returns [`PairedQuoteFailure`] when the body looks like a paired-quote
193/// tool-call block (matches the open marker and separator) but contains a
194/// structural issue: empty key, unclosed quoted value, unexpected character
195/// after a value, or an unfinished argument block.
196pub fn parse(
197    body: &str,
198    markers: &ToolCallMarkers,
199    shape: &PairedQuoteShape,
200) -> Result<Vec<ParsedToolCall>, PairedQuoteFailure> {
201    if shape.name_args_separator.is_empty() {
202        return Ok(Vec::new());
203    }
204
205    let mut parsed = Vec::new();
206    let mut remaining = body.trim_start();
207
208    loop {
209        match parse_one_call(remaining, markers, shape)? {
210            ParseStep::Done => break,
211            ParseStep::Call(call, rest) => {
212                parsed.push(call);
213                remaining = rest.trim_start();
214            }
215        }
216    }
217
218    Ok(parsed)
219}
220
221#[cfg(test)]
222mod tests {
223    #![expect(
224        clippy::literal_string_with_formatting_args,
225        reason = "Gemma tool-call format literals contain braces that resemble format args"
226    )]
227
228    use llama_cpp_bindings_types::PairedQuoteShape;
229    use llama_cpp_bindings_types::ToolCallArgsShape;
230    use llama_cpp_bindings_types::ToolCallArguments;
231    use llama_cpp_bindings_types::ToolCallMarkers;
232    use llama_cpp_bindings_types::ToolCallValueQuote;
233    use serde_json::json;
234
235    use super::parse;
236    use crate::error::PairedQuoteFailure;
237
238    fn gemma4_markers() -> ToolCallMarkers {
239        ToolCallMarkers {
240            open: "<|tool_call>call:".to_owned(),
241            close: "}".to_owned(),
242            args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()),
243        }
244    }
245
246    fn gemma4_shape() -> PairedQuoteShape {
247        PairedQuoteShape {
248            name_args_separator: "{".to_owned(),
249            value_quote: ToolCallValueQuote {
250                open: "<|\"|>".to_owned(),
251                close: "<|\"|>".to_owned(),
252            },
253        }
254    }
255
256    #[test]
257    fn parses_single_quoted_string_argument_with_full_markers() {
258        let parsed = parse(
259            "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
260            &gemma4_markers(),
261            &gemma4_shape(),
262        )
263        .expect("must parse");
264
265        assert_eq!(parsed.len(), 1);
266        assert_eq!(parsed[0].name, "get_weather");
267        assert_eq!(
268            parsed[0].arguments,
269            ToolCallArguments::ValidJson(json!({"location": "Paris"})),
270        );
271    }
272
273    #[test]
274    fn parses_classifier_stripped_body_without_open_or_close() {
275        let parsed = parse(
276            "get_weather{location:<|\"|>Paris<|\"|>",
277            &gemma4_markers(),
278            &gemma4_shape(),
279        )
280        .expect("must parse");
281
282        assert_eq!(parsed.len(), 1);
283        assert_eq!(parsed[0].name, "get_weather");
284        assert_eq!(
285            parsed[0].arguments,
286            ToolCallArguments::ValidJson(json!({"location": "Paris"})),
287        );
288    }
289
290    #[test]
291    fn parses_multiple_quoted_string_arguments() {
292        let parsed = parse(
293            "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}",
294            &gemma4_markers(),
295            &gemma4_shape(),
296        )
297        .expect("must parse");
298
299        assert_eq!(parsed.len(), 1);
300        assert_eq!(
301            parsed[0].arguments,
302            ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})),
303        );
304    }
305
306    #[test]
307    fn parses_bare_numeric_value() {
308        let parsed = parse(
309            "<|tool_call>call:f{a:42}",
310            &gemma4_markers(),
311            &gemma4_shape(),
312        )
313        .expect("must parse");
314
315        assert_eq!(
316            parsed[0].arguments,
317            ToolCallArguments::ValidJson(json!({"a": 42})),
318        );
319    }
320
321    #[test]
322    fn parses_bare_boolean_value() {
323        let parsed = parse(
324            "<|tool_call>call:f{a:true}",
325            &gemma4_markers(),
326            &gemma4_shape(),
327        )
328        .expect("must parse");
329
330        assert_eq!(
331            parsed[0].arguments,
332            ToolCallArguments::ValidJson(json!({"a": true})),
333        );
334    }
335
336    #[test]
337    fn rejects_unclosed_quoted_value_with_typed_failure() {
338        let result = parse(
339            "<|tool_call>call:f{a:<|\"|>oops",
340            &gemma4_markers(),
341            &gemma4_shape(),
342        );
343
344        match result.expect_err("unclosed quote must produce a typed failure") {
345            PairedQuoteFailure::UnclosedQuotedValue { tool_name, key } => {
346                assert_eq!(tool_name, "f");
347                assert_eq!(key, "a");
348            }
349            other => panic!("expected UnclosedQuotedValue, got {other:?}"),
350        }
351    }
352
353    #[test]
354    fn rejects_unexpected_char_after_value_with_typed_failure() {
355        let result = parse(
356            "<|tool_call>call:f{a:<|\"|>v<|\"|>$bad}",
357            &gemma4_markers(),
358            &gemma4_shape(),
359        );
360
361        match result.expect_err("garbage after value must produce a typed failure") {
362            PairedQuoteFailure::UnexpectedCharAfterValue {
363                tool_name,
364                key,
365                character,
366            } => {
367                assert_eq!(tool_name, "f");
368                assert_eq!(key, "a");
369                assert_eq!(character, '$');
370            }
371            other => panic!("expected UnexpectedCharAfterValue, got {other:?}"),
372        }
373    }
374
375    #[test]
376    fn returns_empty_vec_for_empty_body() {
377        let parsed = parse("", &gemma4_markers(), &gemma4_shape()).expect("empty body must parse");
378        assert!(parsed.is_empty());
379    }
380
381    #[test]
382    fn returns_empty_vec_when_body_lacks_separator() {
383        let parsed = parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())
384            .expect("body without separator must parse");
385        assert!(parsed.is_empty());
386    }
387
388    #[test]
389    fn parses_args_body_terminated_by_end_of_input_after_quoted_value() {
390        let parsed = parse(
391            "<|tool_call>call:f{x:<|\"|>v<|\"|>",
392            &gemma4_markers(),
393            &gemma4_shape(),
394        )
395        .expect("end-of-input after quoted value must parse");
396
397        assert_eq!(
398            parsed[0].arguments,
399            ToolCallArguments::ValidJson(json!({"x": "v"})),
400        );
401    }
402
403    #[test]
404    fn parses_args_body_terminated_by_end_of_input_after_bare_value() {
405        let parsed = parse(
406            "<|tool_call>call:f{n:42",
407            &gemma4_markers(),
408            &gemma4_shape(),
409        )
410        .expect("end-of-input after bare value must parse");
411
412        assert_eq!(
413            parsed[0].arguments,
414            ToolCallArguments::ValidJson(json!({"n": 42})),
415        );
416    }
417
418    #[test]
419    fn rejects_empty_key_with_typed_failure() {
420        let result = parse(
421            "<|tool_call>call:f{:42}",
422            &gemma4_markers(),
423            &gemma4_shape(),
424        );
425
426        match result.expect_err("empty key must produce a typed failure") {
427            PairedQuoteFailure::EmptyKey { tool_name } => {
428                assert_eq!(tool_name, "f");
429            }
430            other => panic!("expected EmptyKey, got {other:?}"),
431        }
432    }
433
434    #[test]
435    fn rejects_args_body_without_key_colon_with_typed_failure() {
436        let result = parse(
437            "<|tool_call>call:f{noColonHere",
438            &gemma4_markers(),
439            &gemma4_shape(),
440        );
441
442        match result.expect_err("args body without colon must produce a typed failure") {
443            PairedQuoteFailure::UnclosedArgumentBlock { tool_name, state } => {
444                assert_eq!(tool_name, "f");
445                assert_eq!(state, "key");
446            }
447            other => panic!("expected UnclosedArgumentBlock, got {other:?}"),
448        }
449    }
450}