Skip to main content

adk_model/
tool_call_parser.rs

1//! Text-based tool call parser for models that emit tool calls as text tags.
2//!
3//! Some models emit tool calls as text tags instead of structured `tool_calls`
4//! JSON when served through endpoints that don't support native tool calling
5//! (e.g., HuggingFace TGI without `--enable-auto-tool-choice`).
6//!
7//! This module detects and parses these text-based tool calls, converting
8//! them to proper `Part::FunctionCall` entries so the agent pipeline works
9//! regardless of the serving backend.
10//!
11//! ## Supported Formats
12//!
13//! - **Qwen/Hermes**: `<tool_call>{"name":"...", "arguments":{...}}</tool_call>`
14//! - **Qwen function tag**: `<tool_call><function=NAME>ARGS</function></tool_call>`
15//! - **Llama**: `<|python_tag|>{"name":"...", "parameters":{...}}`
16//! - **Mistral Nemo**: `[TOOL_CALLS][{"name":"...", "arguments":{...}}]`
17//! - **DeepSeek**: `` ```json\n{"name":"...","arguments":{...}}\n``` `` with `<|tool▁call▁end|>`
18//! - **Gemma 4**: `<|tool_call>call:NAME{key:<|"|>value<|"|>}<tool_call|>`
19//! - **Action tags**: `<|action_start|>JSON<|action_end|>`
20
21use adk_core::Part;
22
23/// Check if text contains a tool call tag that should be parsed.
24pub fn contains_tool_call_tag(text: &str) -> bool {
25    text.contains("<tool_call>")
26        || text.contains("<|tool_call>")
27        || text.contains("<|python_tag|>")
28        || text.contains("[TOOL_CALLS]")
29        || text.contains("<|tool▁call")
30        || text.contains("<|action_start|>")
31        || (text.contains("```json") && text.contains("\"name\""))
32}
33
34/// Parse text-based tool calls from model output.
35///
36/// Returns `Some(parts)` if tool calls were found and parsed, where `parts`
37/// contains `Part::FunctionCall` entries (and optionally `Part::Text` for
38/// any non-tool-call text before/after the tags).
39///
40/// Returns `None` if no tool call tags were detected.
41pub fn parse_text_tool_calls(text: &str) -> Option<Vec<Part>> {
42    if !contains_tool_call_tag(text) {
43        return None;
44    }
45
46    let mut parts = Vec::new();
47
48    // Try Qwen/Hermes format: <tool_call>JSON</tool_call>
49    if let Some(parsed) = parse_qwen_format(text, &mut parts) {
50        return Some(parsed);
51    }
52
53    // Try Llama format: <|python_tag|>JSON
54    if let Some(parsed) = parse_llama_format(text, &mut parts) {
55        return Some(parsed);
56    }
57
58    // Try Mistral Nemo format: [TOOL_CALLS][JSON]
59    if let Some(parsed) = parse_mistral_nemo_format(text, &mut parts) {
60        return Some(parsed);
61    }
62
63    // Try DeepSeek format: ```json\n{...}\n``` with optional <|tool▁call▁end|>
64    if let Some(parsed) = parse_deepseek_format(text) {
65        return Some(parsed);
66    }
67
68    // Try Gemma 4 format: <|tool_call>call:NAME{...}<tool_call|>
69    if let Some(parsed) = parse_gemma4_format(text) {
70        return Some(parsed);
71    }
72
73    // Try action tags: <|action_start|>JSON<|action_end|>
74    if let Some(parsed) = parse_action_tag_format(text) {
75        return Some(parsed);
76    }
77
78    None
79}
80
81/// Parse Qwen/Hermes format tool calls.
82///
83/// Handles two sub-formats:
84/// 1. JSON body: `<tool_call>{"name":"fn", "arguments":{...}}</tool_call>`
85/// 2. Function tag: `<tool_call><function=fn>ARGS</function></tool_call>`
86fn parse_qwen_format(text: &str, _parts: &mut Vec<Part>) -> Option<Vec<Part>> {
87    let mut result = Vec::new();
88    let mut remaining = text;
89
90    loop {
91        let start = remaining.find("<tool_call>")?;
92
93        // Add any text before the tool call
94        let before = remaining[..start].trim();
95        if !before.is_empty() {
96            result.push(Part::Text { text: before.to_string() });
97        }
98
99        let after_open = &remaining[start + "<tool_call>".len()..];
100        let end = after_open.find("</tool_call>")?;
101        let inner = after_open[..end].trim();
102
103        // Try JSON format first: {"name":"...", "arguments":{...}}
104        if let Some(part) = parse_json_tool_call(inner) {
105            result.push(part);
106        }
107        // Try function tag format: <function=NAME>ARGS</function>
108        else if let Some(part) = parse_function_tag(inner) {
109            result.push(part);
110        } else {
111            // Couldn't parse — keep as text
112            result.push(Part::Text {
113                text: remaining[start..start + "<tool_call>".len() + end + "</tool_call>".len()]
114                    .to_string(),
115            });
116        }
117
118        remaining = &after_open[end + "</tool_call>".len()..];
119        if remaining.trim().is_empty() || !remaining.contains("<tool_call>") {
120            let trailing = remaining.trim();
121            if !trailing.is_empty() {
122                result.push(Part::Text { text: trailing.to_string() });
123            }
124            break;
125        }
126    }
127
128    if result.is_empty() { None } else { Some(result) }
129}
130
131/// Parse `<function=NAME>ARGS</function>` tag.
132fn parse_function_tag(inner: &str) -> Option<Part> {
133    let func_start = inner.find("<function=")?;
134    let after_eq = &inner[func_start + "<function=".len()..];
135    let name_end = after_eq.find('>')?;
136    let name = after_eq[..name_end].trim().to_string();
137
138    let body_start = name_end + 1;
139    let func_end = after_eq.find("</function>")?;
140    let body = after_eq[body_start..func_end].trim();
141
142    let args = if body.is_empty() {
143        serde_json::json!({})
144    } else {
145        serde_json::from_str(body).unwrap_or_else(|_| serde_json::json!({}))
146    };
147
148    Some(Part::FunctionCall { name, args, id: None, thought_signature: None })
149}
150
151/// Parse JSON tool call: `{"name":"...", "arguments":{...}}`
152/// Also handles `{"function":"...", "parameters":{...}}` variant.
153fn parse_json_tool_call(json_str: &str) -> Option<Part> {
154    let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
155    let obj = value.as_object()?;
156
157    let name =
158        obj.get("name").or_else(|| obj.get("function")).and_then(|v| v.as_str())?.to_string();
159
160    let args = obj
161        .get("arguments")
162        .or_else(|| obj.get("parameters"))
163        .cloned()
164        .unwrap_or(serde_json::json!({}));
165
166    Some(Part::FunctionCall { name, args, id: None, thought_signature: None })
167}
168
169/// Parse Llama format: `<|python_tag|>{"name":"...", "parameters":{...}}`
170fn parse_llama_format(text: &str, _parts: &mut Vec<Part>) -> Option<Vec<Part>> {
171    let tag = "<|python_tag|>";
172    let start = text.find(tag)?;
173
174    let mut result = Vec::new();
175    let before = text[..start].trim();
176    if !before.is_empty() {
177        result.push(Part::Text { text: before.to_string() });
178    }
179
180    let json_str = text[start + tag.len()..].trim();
181    if let Some(part) = parse_json_tool_call(json_str) {
182        result.push(part);
183    } else {
184        return None;
185    }
186
187    Some(result)
188}
189
190/// Parse Mistral Nemo format: `[TOOL_CALLS][{"name":"...", "arguments":{...}}]`
191fn parse_mistral_nemo_format(text: &str, _parts: &mut Vec<Part>) -> Option<Vec<Part>> {
192    let tag = "[TOOL_CALLS]";
193    let start = text.find(tag)?;
194
195    let mut result = Vec::new();
196    let before = text[..start].trim();
197    if !before.is_empty() {
198        result.push(Part::Text { text: before.to_string() });
199    }
200
201    let json_str = text[start + tag.len()..].trim();
202    // Expect a JSON array of tool calls
203    let arr: Vec<serde_json::Value> = serde_json::from_str(json_str).ok()?;
204    for item in &arr {
205        let obj = item.as_object()?;
206        let name = obj.get("name").and_then(|v| v.as_str())?.to_string();
207        let args = obj
208            .get("arguments")
209            .or_else(|| obj.get("parameters"))
210            .cloned()
211            .unwrap_or(serde_json::json!({}));
212        result.push(Part::FunctionCall { name, args, id: None, thought_signature: None });
213    }
214
215    if result.is_empty() { None } else { Some(result) }
216}
217
218/// Parse DeepSeek format: ` ```json\n{"name":"...","arguments":{...}}\n``` `
219///
220/// DeepSeek models wrap tool calls in markdown JSON fences, optionally
221/// followed by `<|tool▁call▁end|>` (full-width Unicode delimiters).
222fn parse_deepseek_format(text: &str) -> Option<Vec<Part>> {
223    let fence_start = text.find("```json")?;
224    let json_start = fence_start + "```json".len();
225    let after_fence = &text[json_start..];
226    let fence_end = after_fence.find("```")?;
227    let json_str = after_fence[..fence_end].trim();
228
229    let mut result = Vec::new();
230    let before = text[..fence_start].trim();
231    if !before.is_empty() {
232        result.push(Part::Text { text: before.to_string() });
233    }
234
235    // Could be a single object or an array
236    if let Some(part) = parse_json_tool_call(json_str) {
237        result.push(part);
238    } else if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(json_str) {
239        for item in &arr {
240            if let Some(obj) = item.as_object() {
241                let name = obj
242                    .get("name")
243                    .or_else(|| obj.get("function"))
244                    .and_then(|v| v.as_str())?
245                    .to_string();
246                let args = obj
247                    .get("arguments")
248                    .or_else(|| obj.get("parameters"))
249                    .cloned()
250                    .unwrap_or(serde_json::json!({}));
251                result.push(Part::FunctionCall { name, args, id: None, thought_signature: None });
252            }
253        }
254    } else {
255        return None;
256    }
257
258    if result.is_empty() { None } else { Some(result) }
259}
260
261/// Parse Gemma 4 format: `<|tool_call>call:NAME{key:<|"|>value<|"|>}<tool_call|>`
262///
263/// Gemma 4 uses a non-JSON format with custom `<|"|>` string delimiters.
264/// The body after `call:NAME` is a key-value block using `<|"|>` to quote strings.
265fn parse_gemma4_format(text: &str) -> Option<Vec<Part>> {
266    let mut result = Vec::new();
267    let mut remaining = text;
268
269    loop {
270        let start = remaining.find("<|tool_call>")?;
271        let before = remaining[..start].trim();
272        if !before.is_empty() {
273            result.push(Part::Text { text: before.to_string() });
274        }
275
276        let after_open = &remaining[start + "<|tool_call>".len()..];
277        let end = after_open.find("<tool_call|>")?;
278        let inner = after_open[..end].trim();
279
280        // Parse: call:NAME{key:<|"|>value<|"|>, ...}
281        if let Some(call_body) = inner.strip_prefix("call:") {
282            let brace_start = call_body.find('{');
283            let name = if let Some(bs) = brace_start {
284                call_body[..bs].trim().to_string()
285            } else {
286                call_body.trim().to_string()
287            };
288
289            let args = if let Some(bs) = brace_start {
290                let args_raw = &call_body[bs..];
291                // Convert Gemma 4 format to JSON: replace <|"|> with "
292                let json_str = args_raw.replace("<|\"|>", "\"");
293                serde_json::from_str(&json_str).unwrap_or(serde_json::json!({}))
294            } else {
295                serde_json::json!({})
296            };
297
298            result.push(Part::FunctionCall { name, args, id: None, thought_signature: None });
299        }
300
301        remaining = &after_open[end + "<tool_call|>".len()..];
302        if remaining.trim().is_empty() || !remaining.contains("<|tool_call>") {
303            let trailing = remaining.trim();
304            if !trailing.is_empty() {
305                result.push(Part::Text { text: trailing.to_string() });
306            }
307            break;
308        }
309    }
310
311    if result.is_empty() { None } else { Some(result) }
312}
313
314/// Parse action tag format: `<|action_start|>JSON<|action_end|>`
315///
316/// Used by some models (e.g., InternLM, ChatGLM variants) that wrap
317/// tool calls in action start/end tags.
318fn parse_action_tag_format(text: &str) -> Option<Vec<Part>> {
319    let start_tag = "<|action_start|>";
320    let end_tag = "<|action_end|>";
321
322    let start = text.find(start_tag)?;
323    let mut result = Vec::new();
324
325    let before = text[..start].trim();
326    if !before.is_empty() {
327        result.push(Part::Text { text: before.to_string() });
328    }
329
330    let after_open = &text[start + start_tag.len()..];
331    let end = after_open.find(end_tag)?;
332    let inner = after_open[..end].trim();
333
334    if let Some(part) = parse_json_tool_call(inner) {
335        result.push(part);
336    } else {
337        return None;
338    }
339
340    let trailing = after_open[end + end_tag.len()..].trim();
341    if !trailing.is_empty() {
342        result.push(Part::Text { text: trailing.to_string() });
343    }
344
345    Some(result)
346}
347
348// ===== Streaming buffer for token-by-token tool call detection =====
349
350/// Prefixes that indicate a potential tool call is starting.
351const TOOL_CALL_PREFIXES: &[&str] = &[
352    "<tool_call",
353    "<|tool_call>",
354    "<|python_tag|",
355    "[TOOL_CALLS]",
356    "<|action_start|>",
357    "<\u{ff5c}\u{2581}tool", // <|tool (DeepSeek full-width)
358];
359
360/// Maximum buffer size before flushing as plain text (safety valve).
361const MAX_BUFFER_SIZE: usize = 4096;
362
363/// Streaming buffer that accumulates tokens and detects tool call boundaries.
364///
365/// Use this in streaming response handlers to buffer tokens when a tool call
366/// prefix is detected, then parse and emit `Part::FunctionCall` when the
367/// closing tag arrives.
368///
369/// # Example
370///
371/// ```rust,ignore
372/// let mut buffer = ToolCallBuffer::new();
373///
374/// for chunk in stream {
375///     match buffer.push(&chunk.text) {
376///         BufferAction::Emit(parts) => {
377///             for part in parts { yield part; }
378///         }
379///         BufferAction::Buffering => { /* still accumulating */ }
380///     }
381/// }
382/// // Flush any remaining content at end of stream
383/// for part in buffer.flush() { yield part; }
384/// ```
385pub struct ToolCallBuffer {
386    buffer: String,
387    buffering: bool,
388}
389
390/// Action returned by `ToolCallBuffer::push()`.
391pub enum BufferAction {
392    /// Emit these parts immediately (text or parsed tool calls).
393    Emit(Vec<Part>),
394    /// Still buffering — don't emit anything yet.
395    Buffering,
396}
397
398impl ToolCallBuffer {
399    /// Create a new empty buffer.
400    pub fn new() -> Self {
401        Self { buffer: String::new(), buffering: false }
402    }
403
404    /// Push a text chunk into the buffer.
405    ///
406    /// Returns `BufferAction::Emit` with parts to yield, or
407    /// `BufferAction::Buffering` if we're accumulating a potential tool call.
408    pub fn push(&mut self, text: &str) -> BufferAction {
409        self.buffer.push_str(text);
410
411        if self.buffering {
412            // Check if we have a complete tool call
413            if self.has_complete_tool_call() {
414                return self.try_parse_and_emit();
415            }
416            // Safety valve: if buffer is too large, flush as text
417            if self.buffer.len() > MAX_BUFFER_SIZE {
418                return self.flush_as_emit();
419            }
420            BufferAction::Buffering
421        } else {
422            // Check if this chunk starts or contains a tool call prefix
423            if self.starts_tool_call_prefix() {
424                self.buffering = true;
425                // Check if the complete tool call arrived in one chunk
426                if self.has_complete_tool_call() {
427                    return self.try_parse_and_emit();
428                }
429                BufferAction::Buffering
430            } else if self.has_partial_prefix() {
431                // Could be the start of a prefix split across chunks (e.g., "<tool" then "_call>")
432                self.buffering = true;
433                BufferAction::Buffering
434            } else {
435                // Normal text — emit immediately
436                self.flush_as_emit()
437            }
438        }
439    }
440
441    /// Flush any remaining buffered content as parts.
442    /// Call this when the stream ends.
443    pub fn flush(&mut self) -> Vec<Part> {
444        if self.buffer.is_empty() {
445            return Vec::new();
446        }
447
448        // Try to parse as tool calls one last time
449        if let Some(parts) = parse_text_tool_calls(&self.buffer) {
450            self.buffer.clear();
451            self.buffering = false;
452            return parts;
453        }
454
455        // Otherwise emit as text
456        let text = std::mem::take(&mut self.buffer);
457        self.buffering = false;
458        if text.trim().is_empty() { Vec::new() } else { vec![Part::Text { text }] }
459    }
460
461    fn starts_tool_call_prefix(&self) -> bool {
462        TOOL_CALL_PREFIXES.iter().any(|prefix| self.buffer.contains(prefix))
463    }
464
465    fn has_partial_prefix(&self) -> bool {
466        // Check if the buffer ends with a partial prefix like "<tool" or "<|python"
467        let buf = &self.buffer;
468        for prefix in TOOL_CALL_PREFIXES {
469            // Use char-based iteration to avoid slicing multi-byte Unicode
470            let prefix_chars: Vec<char> = prefix.chars().collect();
471            for i in 1..prefix_chars.len() {
472                let partial: String = prefix_chars[..i].iter().collect();
473                if buf.ends_with(&partial) {
474                    return true;
475                }
476            }
477        }
478        false
479    }
480
481    fn has_complete_tool_call(&self) -> bool {
482        (self.buffer.contains("<tool_call>") && self.buffer.contains("</tool_call>"))
483            || (self.buffer.contains("<|tool_call>") && self.buffer.contains("<tool_call|>"))
484            || (self.buffer.contains("<|python_tag|>")
485                && self.buffer.contains('\n')
486                && self.buffer.len() > "<|python_tag|>".len() + 5)
487            || (self.buffer.contains("[TOOL_CALLS]")
488                && self.buffer.contains(']')
489                && self.buffer.rfind(']') > self.buffer.find("[TOOL_CALLS]").map(|i| i + 12))
490            || (self.buffer.contains("```json") && self.buffer.matches("```").count() >= 2)
491            || (self.buffer.contains("<|action_start|>") && self.buffer.contains("<|action_end|>"))
492    }
493
494    fn try_parse_and_emit(&mut self) -> BufferAction {
495        if let Some(parts) = parse_text_tool_calls(&self.buffer) {
496            self.buffer.clear();
497            self.buffering = false;
498            BufferAction::Emit(parts)
499        } else {
500            // Couldn't parse — flush as text
501            self.flush_as_emit()
502        }
503    }
504
505    fn flush_as_emit(&mut self) -> BufferAction {
506        let text = std::mem::take(&mut self.buffer);
507        self.buffering = false;
508        if text.trim().is_empty() {
509            BufferAction::Emit(Vec::new())
510        } else {
511            BufferAction::Emit(vec![Part::Text { text }])
512        }
513    }
514}
515
516impl Default for ToolCallBuffer {
517    fn default() -> Self {
518        Self::new()
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_qwen_json_format() {
528        let text =
529            r#"<tool_call>{"name": "get_weather", "arguments": {"city": "Tokyo"}}</tool_call>"#;
530        let parts = parse_text_tool_calls(text).unwrap();
531        assert_eq!(parts.len(), 1);
532        match &parts[0] {
533            Part::FunctionCall { name, args, .. } => {
534                assert_eq!(name, "get_weather");
535                assert_eq!(args["city"], "Tokyo");
536            }
537            _ => panic!("expected FunctionCall"),
538        }
539    }
540
541    #[test]
542    fn test_qwen_function_tag_format() {
543        let text = r#"<tool_call><function=screenshot></function></tool_call>"#;
544        let parts = parse_text_tool_calls(text).unwrap();
545        assert_eq!(parts.len(), 1);
546        match &parts[0] {
547            Part::FunctionCall { name, args, .. } => {
548                assert_eq!(name, "screenshot");
549                assert_eq!(*args, serde_json::json!({}));
550            }
551            _ => panic!("expected FunctionCall"),
552        }
553    }
554
555    #[test]
556    fn test_qwen_function_tag_with_args() {
557        let text = r#"<tool_call><function=get_weather>{"city": "Tokyo"}</function></tool_call>"#;
558        let parts = parse_text_tool_calls(text).unwrap();
559        assert_eq!(parts.len(), 1);
560        match &parts[0] {
561            Part::FunctionCall { name, args, .. } => {
562                assert_eq!(name, "get_weather");
563                assert_eq!(args["city"], "Tokyo");
564            }
565            _ => panic!("expected FunctionCall"),
566        }
567    }
568
569    #[test]
570    fn test_text_before_tool_call() {
571        let text = r#"Let me check that for you.
572<tool_call>{"name": "search", "arguments": {"query": "rust"}}</tool_call>"#;
573        let parts = parse_text_tool_calls(text).unwrap();
574        assert_eq!(parts.len(), 2);
575        assert!(matches!(&parts[0], Part::Text { text } if text.contains("check that")));
576        assert!(matches!(&parts[1], Part::FunctionCall { name, .. } if name == "search"));
577    }
578
579    #[test]
580    fn test_multiple_tool_calls() {
581        let text = r#"<tool_call>{"name": "a", "arguments": {}}</tool_call>
582<tool_call>{"name": "b", "arguments": {"x": 1}}</tool_call>"#;
583        let parts = parse_text_tool_calls(text).unwrap();
584        assert_eq!(parts.len(), 2);
585        assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "a"));
586        assert!(matches!(&parts[1], Part::FunctionCall { name, .. } if name == "b"));
587    }
588
589    #[test]
590    fn test_llama_format() {
591        let text = r#"<|python_tag|>{"name": "get_weather", "parameters": {"city": "NYC"}}"#;
592        let parts = parse_text_tool_calls(text).unwrap();
593        assert_eq!(parts.len(), 1);
594        match &parts[0] {
595            Part::FunctionCall { name, args, .. } => {
596                assert_eq!(name, "get_weather");
597                assert_eq!(args["city"], "NYC");
598            }
599            _ => panic!("expected FunctionCall"),
600        }
601    }
602
603    #[test]
604    fn test_mistral_nemo_format() {
605        let text = r#"[TOOL_CALLS][{"name": "search", "arguments": {"q": "rust"}}]"#;
606        let parts = parse_text_tool_calls(text).unwrap();
607        assert_eq!(parts.len(), 1);
608        match &parts[0] {
609            Part::FunctionCall { name, args, .. } => {
610                assert_eq!(name, "search");
611                assert_eq!(args["q"], "rust");
612            }
613            _ => panic!("expected FunctionCall"),
614        }
615    }
616
617    #[test]
618    fn test_no_tool_call_returns_none() {
619        assert!(parse_text_tool_calls("Hello, how can I help?").is_none());
620        assert!(parse_text_tool_calls("").is_none());
621    }
622
623    #[test]
624    fn test_contains_tool_call_tag() {
625        assert!(contains_tool_call_tag("<tool_call>"));
626        assert!(contains_tool_call_tag("text <tool_call> more"));
627        assert!(contains_tool_call_tag("<|python_tag|>"));
628        assert!(contains_tool_call_tag("[TOOL_CALLS]"));
629        assert!(!contains_tool_call_tag("normal text"));
630    }
631
632    // ===== Streaming buffer tests =====
633
634    #[test]
635    fn test_buffer_normal_text_emits_immediately() {
636        let mut buf = ToolCallBuffer::new();
637        match buf.push("Hello world") {
638            BufferAction::Emit(parts) => {
639                assert_eq!(parts.len(), 1);
640                assert!(matches!(&parts[0], Part::Text { text } if text == "Hello world"));
641            }
642            BufferAction::Buffering => panic!("should emit immediately"),
643        }
644    }
645
646    #[test]
647    fn test_buffer_complete_tool_call_in_one_chunk() {
648        let mut buf = ToolCallBuffer::new();
649        let text = r#"<tool_call>{"name": "search", "arguments": {"q": "rust"}}</tool_call>"#;
650        match buf.push(text) {
651            BufferAction::Emit(parts) => {
652                assert_eq!(parts.len(), 1);
653                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "search"));
654            }
655            BufferAction::Buffering => panic!("should emit parsed tool call"),
656        }
657    }
658
659    #[test]
660    fn test_buffer_tool_call_split_across_chunks() {
661        let mut buf = ToolCallBuffer::new();
662
663        // Chunk 1: prefix starts
664        assert!(matches!(buf.push("<tool_call>"), BufferAction::Buffering));
665
666        // Chunk 2: JSON body
667        assert!(matches!(
668            buf.push(r#"{"name": "get_weather", "arguments": {"city": "Tokyo"}}"#),
669            BufferAction::Buffering
670        ));
671
672        // Chunk 3: closing tag
673        match buf.push("</tool_call>") {
674            BufferAction::Emit(parts) => {
675                assert_eq!(parts.len(), 1);
676                assert!(
677                    matches!(&parts[0], Part::FunctionCall { name, .. } if name == "get_weather")
678                );
679            }
680            BufferAction::Buffering => panic!("should emit after closing tag"),
681        }
682    }
683
684    #[test]
685    fn test_buffer_text_then_tool_call() {
686        let mut buf = ToolCallBuffer::new();
687
688        // Normal text first
689        match buf.push("Let me check. ") {
690            BufferAction::Emit(parts) => {
691                assert_eq!(parts.len(), 1);
692                assert!(matches!(&parts[0], Part::Text { .. }));
693            }
694            BufferAction::Buffering => panic!("should emit text"),
695        }
696
697        // Then tool call
698        let tc = r#"<tool_call>{"name": "search", "arguments": {}}</tool_call>"#;
699        match buf.push(tc) {
700            BufferAction::Emit(parts) => {
701                assert_eq!(parts.len(), 1);
702                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "search"));
703            }
704            BufferAction::Buffering => panic!("should emit tool call"),
705        }
706    }
707
708    #[test]
709    fn test_buffer_flush_incomplete_as_text() {
710        let mut buf = ToolCallBuffer::new();
711        assert!(matches!(buf.push("<tool_call>partial"), BufferAction::Buffering));
712
713        // Stream ends without closing tag
714        let parts = buf.flush();
715        assert_eq!(parts.len(), 1);
716        assert!(matches!(&parts[0], Part::Text { text } if text.contains("<tool_call>")));
717    }
718
719    #[test]
720    fn test_buffer_flush_empty() {
721        let mut buf = ToolCallBuffer::new();
722        let parts = buf.flush();
723        assert!(parts.is_empty());
724    }
725
726    #[test]
727    fn test_buffer_partial_prefix_detection() {
728        let mut buf = ToolCallBuffer::new();
729        // "<tool" could be the start of "<tool_call>"
730        assert!(matches!(buf.push("<tool"), BufferAction::Buffering));
731        // Complete it
732        assert!(matches!(buf.push("_call>"), BufferAction::Buffering));
733        // Add body and close
734        match buf.push(r#"{"name":"x","arguments":{}}</tool_call>"#) {
735            BufferAction::Emit(parts) => {
736                assert_eq!(parts.len(), 1);
737                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "x"));
738            }
739            BufferAction::Buffering => panic!("should emit"),
740        }
741    }
742
743    // ===== DeepSeek format tests =====
744
745    #[test]
746    fn test_deepseek_json_fence() {
747        let text = "```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```";
748        let parts = parse_text_tool_calls(text).unwrap();
749        assert_eq!(parts.len(), 1);
750        match &parts[0] {
751            Part::FunctionCall { name, args, .. } => {
752                assert_eq!(name, "search");
753                assert_eq!(args["q"], "rust");
754            }
755            _ => panic!("expected FunctionCall"),
756        }
757    }
758
759    #[test]
760    fn test_deepseek_with_text_before() {
761        let text = "I'll search for that.\n```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```\n<|tool▁call▁end|>";
762        let parts = parse_text_tool_calls(text).unwrap();
763        assert!(!parts.is_empty());
764        let has_fn_call =
765            parts.iter().any(|p| matches!(p, Part::FunctionCall { name, .. } if name == "search"));
766        assert!(has_fn_call);
767    }
768
769    // ===== Gemma 4 format tests =====
770
771    #[test]
772    fn test_gemma4_simple() {
773        let text = "<|tool_call>call:get_weather{}<tool_call|>";
774        let parts = parse_text_tool_calls(text).unwrap();
775        assert_eq!(parts.len(), 1);
776        match &parts[0] {
777            Part::FunctionCall { name, .. } => assert_eq!(name, "get_weather"),
778            _ => panic!("expected FunctionCall"),
779        }
780    }
781
782    #[test]
783    fn test_gemma4_with_args() {
784        let text = "<|tool_call>call:get_weather{<|\"|>city<|\"|>:<|\"|>Tokyo<|\"|>}<tool_call|>";
785        let parts = parse_text_tool_calls(text).unwrap();
786        assert_eq!(parts.len(), 1);
787        match &parts[0] {
788            Part::FunctionCall { name, args, .. } => {
789                assert_eq!(name, "get_weather");
790                assert_eq!(args["city"], "Tokyo");
791            }
792            _ => panic!("expected FunctionCall"),
793        }
794    }
795
796    // ===== Action tag format tests =====
797
798    #[test]
799    fn test_action_tags() {
800        let text = "<|action_start|>{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}<|action_end|>";
801        let parts = parse_text_tool_calls(text).unwrap();
802        assert_eq!(parts.len(), 1);
803        match &parts[0] {
804            Part::FunctionCall { name, args, .. } => {
805                assert_eq!(name, "search");
806                assert_eq!(args["q"], "rust");
807            }
808            _ => panic!("expected FunctionCall"),
809        }
810    }
811
812    #[test]
813    fn test_action_tags_with_surrounding_text() {
814        let text = "Let me look that up. <|action_start|>{\"name\": \"search\", \"arguments\": {}}<|action_end|> Done.";
815        let parts = parse_text_tool_calls(text).unwrap();
816        assert!(parts.len() >= 2); // text + function call (+ optional trailing text)
817        let has_fn_call =
818            parts.iter().any(|p| matches!(p, Part::FunctionCall { name, .. } if name == "search"));
819        assert!(has_fn_call);
820    }
821}