Skip to main content

tool_parser/parsers/
sarashina.rs

1//! Sarashina tool call parser
2//!
3//! Parses tool calls emitted by SoftBank Sarashina models.
4//!
5//! # Format
6//!
7//! Sarashina models emit tool calls as a Python-literal list of dicts
8//! (single-quoted), optionally prefixed by a `<|tool_calls|>` marker:
9//! ```text
10//! <|tool_calls|>[{'name': 'get_weather', 'arguments': {'city': 'Tokyo'}}]
11//! ```
12//!
13//! In practice `<|tool_calls|>` is a special token (id 127) that is stripped
14//! during detokenization, so the parser usually sees only the bare list
15//! `[{'name': ..., 'arguments': {...}}]`. The parser therefore treats the
16//! marker as optional and also recognizes the bare leading list.
17//!
18//! The payload uses Python literal syntax (single quotes, `True`/`False`/`None`),
19//! so it is parsed with `rustpython_parser` — reusing the same converter as the
20//! pythonic parser — rather than `serde_json`.
21//!
22//! # Field Mapping
23//! - `name` → `name`
24//! - `arguments` (a dict) → `arguments` (JSON string)
25
26use async_trait::async_trait;
27use openai_protocol::common::Tool;
28use serde_json::Value;
29
30use crate::{
31    errors::ParserResult,
32    parsers::{
33        helpers,
34        pythonic::{expression_to_json, parse_python_expression},
35    },
36    traits::ToolParser,
37    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
38};
39
40const TOOL_CALLS_MARKER: &str = "<|tool_calls|>";
41
42/// State machine for Sarashina tool parsing.
43#[derive(Debug, Clone, Copy, PartialEq)]
44enum ParseState {
45    /// Looking for the start of a tool-call block.
46    Text,
47    /// Past the start, accumulating the Python-literal payload.
48    InCalls,
49}
50
51/// Sarashina tool call parser.
52///
53/// Handles `[{'name': ..., 'arguments': {...}}]`, with or without a leading
54/// `<|tool_calls|>` marker.
55pub struct SarashinaParser {
56    /// Current parsing state (streaming only).
57    state: ParseState,
58    /// Buffer for accumulating chunks across streaming calls.
59    buffer: String,
60    /// Index of the next tool call to stream.
61    stream_tool_index: usize,
62}
63
64impl SarashinaParser {
65    /// Create a new Sarashina parser.
66    pub fn new() -> Self {
67        Self {
68            state: ParseState::Text,
69            buffer: String::new(),
70            stream_tool_index: 0,
71        }
72    }
73
74    /// Whether `text` (already left-trimmed) begins a bare tool-call list,
75    /// i.e. a list whose first element is a dict: `[{` (allowing whitespace).
76    fn starts_bare_list(trimmed: &str) -> bool {
77        let mut chars = trimmed.chars();
78        if chars.next() != Some('[') {
79            return false;
80        }
81        matches!(chars.find(|c| !c.is_whitespace()), Some('{'))
82    }
83
84    /// Whether `text` (already left-trimmed) could still grow into a bare
85    /// tool-call list once more chunks arrive (streaming look-ahead).
86    fn maybe_bare_list_prefix(trimmed: &str) -> bool {
87        if trimmed.is_empty() || Self::starts_bare_list(trimmed) {
88            return true;
89        }
90        // `[` possibly followed by only whitespace — still waiting for the
91        // first `{` to arrive in a later chunk.
92        let mut chars = trimmed.chars();
93        chars.next() == Some('[') && chars.all(|c| c.is_whitespace())
94    }
95
96    /// Parse the Python-literal payload (a list of dicts, or a single dict)
97    /// into `ToolCall`s. Returns an empty vec when the payload is not a valid
98    /// tool-call structure.
99    fn parse_payload(payload: &str) -> Vec<ToolCall> {
100        let Ok(expr) = parse_python_expression(payload) else {
101            return vec![];
102        };
103        let Ok(value) = expression_to_json(&expr) else {
104            return vec![];
105        };
106
107        let items = match value {
108            Value::Array(arr) => arr,
109            single @ Value::Object(_) => vec![single],
110            _ => return vec![],
111        };
112
113        let mut calls = Vec::new();
114        for item in items {
115            let Value::Object(obj) = item else { continue };
116            let Some(name) = obj.get("name").and_then(|v| v.as_str()) else {
117                continue;
118            };
119            let arguments = obj
120                .get("arguments")
121                .map(|v| v.to_string())
122                .unwrap_or_else(|| "{}".to_string());
123            calls.push(ToolCall {
124                function: FunctionCall {
125                    name: name.to_string(),
126                    arguments,
127                },
128            });
129        }
130        calls
131    }
132
133    /// Find the byte offset just past the balanced bracket that starts at
134    /// `open_idx`, skipping over string literals. Returns `None` when the
135    /// bracket is not yet closed (partial payload during streaming).
136    fn find_balanced_end(s: &str, open_idx: usize) -> Option<usize> {
137        let bytes = s.as_bytes();
138        let open = bytes[open_idx];
139        let close = match open {
140            b'[' => b']',
141            b'{' => b'}',
142            _ => return None,
143        };
144
145        let mut depth = 0usize;
146        let mut in_str: Option<u8> = None;
147        let mut escaped = false;
148
149        for (i, &c) in bytes.iter().enumerate().skip(open_idx) {
150            if let Some(quote) = in_str {
151                if escaped {
152                    escaped = false;
153                } else if c == b'\\' {
154                    escaped = true;
155                } else if c == quote {
156                    in_str = None;
157                }
158            } else if c == b'\'' || c == b'"' {
159                in_str = Some(c);
160            } else if c == open {
161                depth += 1;
162            } else if c == close {
163                depth -= 1;
164                if depth == 0 {
165                    return Some(i + 1);
166                }
167            }
168        }
169        None
170    }
171
172    /// Try to extract a complete tool-call block from the head of the buffer
173    /// (streaming). Consumes the block on success and returns to `Text` state.
174    /// If the balanced block is not a valid tool-call list, the consumed span
175    /// is emitted as normal text rather than dropped.
176    fn try_extract_calls(&mut self) -> StreamingParseResult {
177        let Some(open) = self.buffer.find(['[', '{']) else {
178            return StreamingParseResult::default();
179        };
180        let Some(end) = Self::find_balanced_end(&self.buffer, open) else {
181            return StreamingParseResult::default();
182        };
183
184        let calls = Self::parse_payload(&self.buffer[open..end]);
185        let consumed = self.buffer[..end].to_string();
186        self.buffer.drain(..end);
187        self.state = ParseState::Text;
188
189        if calls.is_empty() {
190            // Balanced brackets but not a tool call — preserve as normal text.
191            return StreamingParseResult {
192                normal_text: consumed,
193                calls: vec![],
194            };
195        }
196
197        // Names are emitted as produced by the model; validation against the
198        // request's tool schemas is left to the caller.
199        let mut items = Vec::with_capacity(calls.len());
200        for call in calls {
201            items.push(ToolCallItem {
202                tool_index: self.stream_tool_index,
203                name: Some(call.function.name),
204                parameters: call.function.arguments,
205            });
206            self.stream_tool_index += 1;
207        }
208        StreamingParseResult {
209            normal_text: String::new(),
210            calls: items,
211        }
212    }
213}
214
215impl Default for SarashinaParser {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221#[async_trait]
222impl ToolParser for SarashinaParser {
223    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
224        // Payload comes after the marker if present, otherwise the bare text.
225        let (prefix, rest, had_marker) = match text.find(TOOL_CALLS_MARKER) {
226            Some(i) => (&text[..i], &text[i + TOOL_CALLS_MARKER.len()..], true),
227            None => ("", text, false),
228        };
229        // Text to return when there is no tool call. The marker (a special
230        // token) is dropped so it never leaks into user-visible output.
231        let no_call = || {
232            if had_marker {
233                format!("{prefix}{rest}").trim().to_string()
234            } else {
235                text.trim().to_string()
236            }
237        };
238
239        let Some(open) = rest.find(['[', '{']) else {
240            return Ok((no_call(), vec![]));
241        };
242
243        // Without a marker, only treat the payload as a tool call when it is the
244        // leading content (nothing but whitespace before the bracket), so a
245        // bracket inside ordinary prose is left untouched.
246        if !had_marker && !rest[..open].trim().is_empty() {
247            return Ok((no_call(), vec![]));
248        }
249
250        let Some(end) = Self::find_balanced_end(rest, open) else {
251            return Ok((no_call(), vec![]));
252        };
253
254        let tool_calls = Self::parse_payload(&rest[open..end]);
255        if tool_calls.is_empty() {
256            // Not a valid tool-call list — leave the surrounding text untouched.
257            return Ok((no_call(), vec![]));
258        }
259
260        let mut normal_text = prefix.to_string();
261        normal_text.push_str(&rest[end..]);
262        Ok((normal_text.trim().to_string(), tool_calls))
263    }
264
265    async fn parse_incremental(
266        &mut self,
267        chunk: &str,
268        _tools: &[Tool],
269    ) -> ParserResult<StreamingParseResult> {
270        self.buffer.push_str(chunk);
271
272        match self.state {
273            ParseState::Text => {
274                if let Some(pos) = self.buffer.find(TOOL_CALLS_MARKER) {
275                    let text_before = self.buffer[..pos].to_string();
276                    self.buffer.drain(..pos + TOOL_CALLS_MARKER.len());
277                    self.state = ParseState::InCalls;
278
279                    let mut result = self.try_extract_calls();
280                    result.normal_text = format!("{}{}", text_before, result.normal_text);
281                    return Ok(result);
282                }
283                if helpers::ends_with_partial_token(&self.buffer, TOOL_CALLS_MARKER).is_some() {
284                    // Possible partial marker at the tail — keep buffering.
285                    return Ok(StreamingParseResult::default());
286                }
287
288                // Marker-less: the detokenized reply is a bare tool-call list.
289                let trimmed = self.buffer.trim_start();
290                if Self::starts_bare_list(trimmed) {
291                    self.state = ParseState::InCalls;
292                    return Ok(self.try_extract_calls());
293                }
294                if Self::maybe_bare_list_prefix(trimmed) {
295                    // Ambiguous so far — wait for more chunks.
296                    return Ok(StreamingParseResult::default());
297                }
298
299                let normal_text = std::mem::take(&mut self.buffer);
300                Ok(StreamingParseResult {
301                    normal_text,
302                    calls: vec![],
303                })
304            }
305            ParseState::InCalls => Ok(self.try_extract_calls()),
306        }
307    }
308
309    fn has_tool_markers(&self, text: &str) -> bool {
310        text.contains(TOOL_CALLS_MARKER) || Self::starts_bare_list(text.trim_start())
311    }
312
313    fn reset(&mut self) {
314        self.state = ParseState::Text;
315        self.buffer.clear();
316        self.stream_tool_index = 0;
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[tokio::test]
325    async fn test_with_marker() {
326        let parser = SarashinaParser::new();
327        let input = "<|tool_calls|>[{'name': 'get_weather', 'arguments': {'city': 'Tokyo'}}]";
328        let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
329        assert_eq!(normal_text, "");
330        assert_eq!(tools.len(), 1);
331        assert_eq!(tools[0].function.name, "get_weather");
332        let args: Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
333        assert_eq!(args["city"], "Tokyo");
334    }
335
336    #[tokio::test]
337    async fn test_markerless_bare_list() {
338        // This is what the server actually sees (special token stripped).
339        let parser = SarashinaParser::new();
340        let input = "[{'name': 'get_weather', 'arguments': {'city': 'Tokyo'}}]";
341        let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
342        assert_eq!(normal_text, "");
343        assert_eq!(tools.len(), 1);
344        assert_eq!(tools[0].function.name, "get_weather");
345        let args: Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
346        assert_eq!(args["city"], "Tokyo");
347    }
348
349    #[tokio::test]
350    async fn test_leading_text_with_marker() {
351        let parser = SarashinaParser::new();
352        let input = "Let me check.<|tool_calls|>[{'name': 'ping', 'arguments': {}}]";
353        let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
354        assert_eq!(normal_text, "Let me check.");
355        assert_eq!(tools.len(), 1);
356        assert_eq!(tools[0].function.arguments, "{}");
357    }
358
359    #[tokio::test]
360    async fn test_multiple_tool_calls() {
361        let parser = SarashinaParser::new();
362        let input = "[{'name': 'search', 'arguments': {'q': 'rust'}}, \
363             {'name': 'get_weather', 'arguments': {'city': 'Paris'}}]";
364        let (_, tools) = parser.parse_complete(input).await.unwrap();
365        assert_eq!(tools.len(), 2);
366        assert_eq!(tools[0].function.name, "search");
367        assert_eq!(tools[1].function.name, "get_weather");
368    }
369
370    #[tokio::test]
371    async fn test_nested_arguments() {
372        let parser = SarashinaParser::new();
373        let input =
374            "[{'name': 'process', 'arguments': {'config': {'nested': {'values': [1, 2, 3]}}}}]";
375        let (_, tools) = parser.parse_complete(input).await.unwrap();
376        assert_eq!(tools.len(), 1);
377        let args: Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
378        assert_eq!(
379            args["config"]["nested"]["values"],
380            serde_json::json!([1, 2, 3])
381        );
382    }
383
384    #[tokio::test]
385    async fn test_python_literals_coerced() {
386        let parser = SarashinaParser::new();
387        let input = "[{'name': 'flags', 'arguments': {'a': True, 'b': False, 'c': None}}]";
388        let (_, tools) = parser.parse_complete(input).await.unwrap();
389        let args: Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
390        assert_eq!(args["a"], serde_json::json!(true));
391        assert_eq!(args["b"], serde_json::json!(false));
392        assert_eq!(args["c"], Value::Null);
393    }
394
395    #[tokio::test]
396    async fn test_apostrophe_in_value() {
397        let parser = SarashinaParser::new();
398        let input = "[{'name': 'say', 'arguments': {'text': 'it\\'s [done]'}}]";
399        let (_, tools) = parser.parse_complete(input).await.unwrap();
400        assert_eq!(tools.len(), 1);
401        let args: Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
402        assert_eq!(args["text"], "it's [done]");
403    }
404
405    #[tokio::test]
406    async fn test_plain_text_untouched() {
407        let parser = SarashinaParser::new();
408        let input = "The weather in Tokyo is sunny.";
409        let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
410        assert_eq!(tools.len(), 0);
411        assert_eq!(normal_text, "The weather in Tokyo is sunny.");
412    }
413
414    #[tokio::test]
415    async fn test_non_toolcall_list_untouched() {
416        // A bare list that is not a tool-call structure must be left as text.
417        let parser = SarashinaParser::new();
418        let input = "[1, 2, 3]";
419        let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
420        assert_eq!(tools.len(), 0);
421        assert_eq!(normal_text, "[1, 2, 3]");
422    }
423
424    #[tokio::test]
425    async fn test_has_tool_markers() {
426        let parser = SarashinaParser::new();
427        assert!(parser.has_tool_markers("<|tool_calls|>[]"));
428        assert!(parser.has_tool_markers("[{'name': 'x', 'arguments': {}}]"));
429        assert!(!parser.has_tool_markers("just text"));
430        assert!(!parser.has_tool_markers("[TOOL_CALLS]")); // Mistral format
431    }
432
433    #[tokio::test]
434    async fn test_streaming_markerless_chunked() {
435        let mut parser = SarashinaParser::new();
436        let chunks = [
437            "[{'name': 'get_",
438            "weather', 'arguments': {'city': 'To",
439            "kyo'}}]",
440        ];
441        let mut calls = Vec::new();
442        for c in chunks {
443            let r = parser.parse_incremental(c, &[]).await.unwrap();
444            calls.extend(r.calls);
445        }
446        assert_eq!(calls.len(), 1);
447        assert_eq!(calls[0].name.as_deref(), Some("get_weather"));
448        let args: Value = serde_json::from_str(&calls[0].parameters).unwrap();
449        assert_eq!(args["city"], "Tokyo");
450    }
451
452    #[tokio::test]
453    async fn test_streaming_with_marker_chunked() {
454        let mut parser = SarashinaParser::new();
455        let chunks = [
456            "Sure.",
457            "<|tool_ca",
458            "lls|>[{'name': 'ping', 'arg",
459            "uments': {}}]",
460        ];
461        let mut normal = String::new();
462        let mut calls = Vec::new();
463        for c in chunks {
464            let r = parser.parse_incremental(c, &[]).await.unwrap();
465            normal.push_str(&r.normal_text);
466            calls.extend(r.calls);
467        }
468        assert_eq!(normal, "Sure.");
469        assert_eq!(calls.len(), 1);
470        assert_eq!(calls[0].name.as_deref(), Some("ping"));
471    }
472
473    #[tokio::test]
474    async fn test_streaming_markerless_whitespace_split() {
475        // The `[` and the first `{` arrive in different chunks, separated by
476        // whitespace — the look-ahead must keep buffering, not flush as text.
477        let mut parser = SarashinaParser::new();
478        let chunks = ["[ ", "  {'name': 'ping', 'arguments': {}}]"];
479        let mut normal = String::new();
480        let mut calls = Vec::new();
481        for c in chunks {
482            let r = parser.parse_incremental(c, &[]).await.unwrap();
483            normal.push_str(&r.normal_text);
484            calls.extend(r.calls);
485        }
486        assert_eq!(normal, "");
487        assert_eq!(calls.len(), 1);
488        assert_eq!(calls[0].name.as_deref(), Some("ping"));
489    }
490
491    #[tokio::test]
492    async fn test_marker_not_leaked_on_malformed() {
493        // Marker present but payload unbalanced: no tool call, and the
494        // `<|tool_calls|>` special token must not leak into the text.
495        let parser = SarashinaParser::new();
496        let input = "before <|tool_calls|>[{'name': 'x'";
497        let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
498        assert_eq!(tools.len(), 0);
499        assert!(!normal_text.contains("<|tool_calls|>"));
500        assert!(normal_text.starts_with("before"));
501    }
502}