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];
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;
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];
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];
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];
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];
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;
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];
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()..];
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.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.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        // The two tool-call markups are separated by a single `\n`. After the
582        // whitespace-preservation fix, that separating newline is emitted as a
583        // `Part::Text` between the two `Part::FunctionCall`s (byte-exact
584        // reconstruction per Requirements 2.5/2.6), yielding 3 parts.
585        let text = r#"<tool_call>{"name": "a", "arguments": {}}</tool_call>
586<tool_call>{"name": "b", "arguments": {"x": 1}}</tool_call>"#;
587        let parts = parse_text_tool_calls(text).unwrap();
588        assert_eq!(parts.len(), 3);
589        assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "a"));
590        assert!(matches!(&parts[1], Part::Text { text } if text == "\n"));
591        assert!(matches!(&parts[2], Part::FunctionCall { name, .. } if name == "b"));
592    }
593
594    #[test]
595    fn test_llama_format() {
596        let text = r#"<|python_tag|>{"name": "get_weather", "parameters": {"city": "NYC"}}"#;
597        let parts = parse_text_tool_calls(text).unwrap();
598        assert_eq!(parts.len(), 1);
599        match &parts[0] {
600            Part::FunctionCall { name, args, .. } => {
601                assert_eq!(name, "get_weather");
602                assert_eq!(args["city"], "NYC");
603            }
604            _ => panic!("expected FunctionCall"),
605        }
606    }
607
608    #[test]
609    fn test_mistral_nemo_format() {
610        let text = r#"[TOOL_CALLS][{"name": "search", "arguments": {"q": "rust"}}]"#;
611        let parts = parse_text_tool_calls(text).unwrap();
612        assert_eq!(parts.len(), 1);
613        match &parts[0] {
614            Part::FunctionCall { name, args, .. } => {
615                assert_eq!(name, "search");
616                assert_eq!(args["q"], "rust");
617            }
618            _ => panic!("expected FunctionCall"),
619        }
620    }
621
622    #[test]
623    fn test_no_tool_call_returns_none() {
624        assert!(parse_text_tool_calls("Hello, how can I help?").is_none());
625        assert!(parse_text_tool_calls("").is_none());
626    }
627
628    #[test]
629    fn test_contains_tool_call_tag() {
630        assert!(contains_tool_call_tag("<tool_call>"));
631        assert!(contains_tool_call_tag("text <tool_call> more"));
632        assert!(contains_tool_call_tag("<|python_tag|>"));
633        assert!(contains_tool_call_tag("[TOOL_CALLS]"));
634        assert!(!contains_tool_call_tag("normal text"));
635    }
636
637    // ===== Streaming buffer tests =====
638
639    #[test]
640    fn test_buffer_normal_text_emits_immediately() {
641        let mut buf = ToolCallBuffer::new();
642        match buf.push("Hello world") {
643            BufferAction::Emit(parts) => {
644                assert_eq!(parts.len(), 1);
645                assert!(matches!(&parts[0], Part::Text { text } if text == "Hello world"));
646            }
647            BufferAction::Buffering => panic!("should emit immediately"),
648        }
649    }
650
651    #[test]
652    fn test_buffer_complete_tool_call_in_one_chunk() {
653        let mut buf = ToolCallBuffer::new();
654        let text = r#"<tool_call>{"name": "search", "arguments": {"q": "rust"}}</tool_call>"#;
655        match buf.push(text) {
656            BufferAction::Emit(parts) => {
657                assert_eq!(parts.len(), 1);
658                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "search"));
659            }
660            BufferAction::Buffering => panic!("should emit parsed tool call"),
661        }
662    }
663
664    #[test]
665    fn test_buffer_tool_call_split_across_chunks() {
666        let mut buf = ToolCallBuffer::new();
667
668        // Chunk 1: prefix starts
669        assert!(matches!(buf.push("<tool_call>"), BufferAction::Buffering));
670
671        // Chunk 2: JSON body
672        assert!(matches!(
673            buf.push(r#"{"name": "get_weather", "arguments": {"city": "Tokyo"}}"#),
674            BufferAction::Buffering
675        ));
676
677        // Chunk 3: closing tag
678        match buf.push("</tool_call>") {
679            BufferAction::Emit(parts) => {
680                assert_eq!(parts.len(), 1);
681                assert!(
682                    matches!(&parts[0], Part::FunctionCall { name, .. } if name == "get_weather")
683                );
684            }
685            BufferAction::Buffering => panic!("should emit after closing tag"),
686        }
687    }
688
689    #[test]
690    fn test_buffer_text_then_tool_call() {
691        let mut buf = ToolCallBuffer::new();
692
693        // Normal text first
694        match buf.push("Let me check. ") {
695            BufferAction::Emit(parts) => {
696                assert_eq!(parts.len(), 1);
697                assert!(matches!(&parts[0], Part::Text { .. }));
698            }
699            BufferAction::Buffering => panic!("should emit text"),
700        }
701
702        // Then tool call
703        let tc = r#"<tool_call>{"name": "search", "arguments": {}}</tool_call>"#;
704        match buf.push(tc) {
705            BufferAction::Emit(parts) => {
706                assert_eq!(parts.len(), 1);
707                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "search"));
708            }
709            BufferAction::Buffering => panic!("should emit tool call"),
710        }
711    }
712
713    #[test]
714    fn test_buffer_flush_incomplete_as_text() {
715        let mut buf = ToolCallBuffer::new();
716        assert!(matches!(buf.push("<tool_call>partial"), BufferAction::Buffering));
717
718        // Stream ends without closing tag
719        let parts = buf.flush();
720        assert_eq!(parts.len(), 1);
721        assert!(matches!(&parts[0], Part::Text { text } if text.contains("<tool_call>")));
722    }
723
724    #[test]
725    fn test_buffer_flush_empty() {
726        let mut buf = ToolCallBuffer::new();
727        let parts = buf.flush();
728        assert!(parts.is_empty());
729    }
730
731    #[test]
732    fn test_buffer_partial_prefix_detection() {
733        let mut buf = ToolCallBuffer::new();
734        // "<tool" could be the start of "<tool_call>"
735        assert!(matches!(buf.push("<tool"), BufferAction::Buffering));
736        // Complete it
737        assert!(matches!(buf.push("_call>"), BufferAction::Buffering));
738        // Add body and close
739        match buf.push(r#"{"name":"x","arguments":{}}</tool_call>"#) {
740            BufferAction::Emit(parts) => {
741                assert_eq!(parts.len(), 1);
742                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "x"));
743            }
744            BufferAction::Buffering => panic!("should emit"),
745        }
746    }
747
748    // ===== DeepSeek format tests =====
749
750    #[test]
751    fn test_deepseek_json_fence() {
752        let text = "```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```";
753        let parts = parse_text_tool_calls(text).unwrap();
754        assert_eq!(parts.len(), 1);
755        match &parts[0] {
756            Part::FunctionCall { name, args, .. } => {
757                assert_eq!(name, "search");
758                assert_eq!(args["q"], "rust");
759            }
760            _ => panic!("expected FunctionCall"),
761        }
762    }
763
764    #[test]
765    fn test_deepseek_with_text_before() {
766        let text = "I'll search for that.\n```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```\n<|tool▁call▁end|>";
767        let parts = parse_text_tool_calls(text).unwrap();
768        assert!(!parts.is_empty());
769        let has_fn_call =
770            parts.iter().any(|p| matches!(p, Part::FunctionCall { name, .. } if name == "search"));
771        assert!(has_fn_call);
772    }
773
774    // ===== Gemma 4 format tests =====
775
776    #[test]
777    fn test_gemma4_simple() {
778        let text = "<|tool_call>call:get_weather{}<tool_call|>";
779        let parts = parse_text_tool_calls(text).unwrap();
780        assert_eq!(parts.len(), 1);
781        match &parts[0] {
782            Part::FunctionCall { name, .. } => assert_eq!(name, "get_weather"),
783            _ => panic!("expected FunctionCall"),
784        }
785    }
786
787    #[test]
788    fn test_gemma4_with_args() {
789        let text = "<|tool_call>call:get_weather{<|\"|>city<|\"|>:<|\"|>Tokyo<|\"|>}<tool_call|>";
790        let parts = parse_text_tool_calls(text).unwrap();
791        assert_eq!(parts.len(), 1);
792        match &parts[0] {
793            Part::FunctionCall { name, args, .. } => {
794                assert_eq!(name, "get_weather");
795                assert_eq!(args["city"], "Tokyo");
796            }
797            _ => panic!("expected FunctionCall"),
798        }
799    }
800
801    // ===== Action tag format tests =====
802
803    #[test]
804    fn test_action_tags() {
805        let text = "<|action_start|>{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}<|action_end|>";
806        let parts = parse_text_tool_calls(text).unwrap();
807        assert_eq!(parts.len(), 1);
808        match &parts[0] {
809            Part::FunctionCall { name, args, .. } => {
810                assert_eq!(name, "search");
811                assert_eq!(args["q"], "rust");
812            }
813            _ => panic!("expected FunctionCall"),
814        }
815    }
816
817    #[test]
818    fn test_action_tags_with_surrounding_text() {
819        let text = "Let me look that up. <|action_start|>{\"name\": \"search\", \"arguments\": {}}<|action_end|> Done.";
820        let parts = parse_text_tool_calls(text).unwrap();
821        assert!(parts.len() >= 2); // text + function call (+ optional trailing text)
822        let has_fn_call =
823            parts.iter().any(|p| matches!(p, Part::FunctionCall { name, .. } if name == "search"));
824        assert!(has_fn_call);
825    }
826
827    // ===== Whitespace-preservation regression tests =====
828
829    /// Stream a sequence of deltas through `ToolCallBuffer` (push each, then
830    /// flush) and return the concatenation of all emitted `Part::Text` values.
831    fn stream_and_reconstruct(deltas: &[&str]) -> String {
832        let mut buf = ToolCallBuffer::new();
833        let mut out = String::new();
834        let mut collect = |parts: Vec<Part>| {
835            for part in parts {
836                if let Part::Text { text } = part {
837                    out.push_str(&text);
838                }
839            }
840        };
841        for delta in deltas {
842            if let BufferAction::Emit(parts) = buf.push(delta) {
843                collect(parts);
844            }
845        }
846        collect(buf.flush());
847        out
848    }
849
850    #[test]
851    fn test_regression_markdown_structure_reconstructs() {
852        // Counterexample 1 (Requirements 2.1, 2.2, 2.3): whitespace-only deltas
853        // between visible text must be preserved so Markdown structure survives.
854        let reconstructed =
855            stream_and_reconstruct(&["Heading", "\n\n", "- first", "\n", "- second"]);
856        assert_eq!(reconstructed, "Heading\n\n- first\n- second");
857    }
858
859    #[test]
860    fn test_regression_single_space_reconstructs() {
861        // Counterexample 2 (Requirements 2.1, 2.4): a single-space delta between
862        // words must be preserved.
863        let reconstructed = stream_and_reconstruct(&["Hello", " ", "world"]);
864        assert_eq!(reconstructed, "Hello world");
865    }
866
867    #[test]
868    fn test_flush_as_emit_whitespace_only_emits_text() {
869        // Requirement 2.1: a whitespace-only delta must be emitted as Part::Text
870        // (previously dropped by the `text.trim().is_empty()` guard).
871        let mut buf = ToolCallBuffer::new();
872        match buf.push("\n\n") {
873            BufferAction::Emit(parts) => {
874                assert_eq!(parts.len(), 1);
875                assert!(matches!(&parts[0], Part::Text { text } if text == "\n\n"));
876            }
877            BufferAction::Buffering => panic!("whitespace-only delta should emit"),
878        }
879    }
880
881    #[test]
882    fn test_flush_whitespace_only_emits_text() {
883        // Requirement 2.2: whitespace-only buffered content at end-of-stream must
884        // be emitted by flush rather than discarded.
885        let mut buf = ToolCallBuffer::new();
886        // Place whitespace-only content into the buffer as if mid-tool-call
887        // buffering had accumulated it, then flush at end of stream.
888        buf.buffer.push_str("   \n\t");
889        buf.buffering = true;
890        let parts = buf.flush();
891        assert_eq!(parts.len(), 1);
892        assert!(matches!(&parts[0], Part::Text { text } if text == "   \n\t"));
893    }
894
895    // ===== Boundary-whitespace preservation across the six markup formats =====
896
897    #[test]
898    fn test_boundary_whitespace_qwen_preserved() {
899        // Leading/trailing whitespace around the markup is preserved, while the
900        // inner tool name/args stay trimmed (inner is `.trim()`ed before parse).
901        let text = "lead \n<tool_call>  {\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}  </tool_call>\n trail";
902        let parts = parse_text_tool_calls(text).unwrap();
903        assert_eq!(parts.len(), 3);
904        assert!(matches!(&parts[0], Part::Text { text } if text == "lead \n"));
905        match &parts[1] {
906            Part::FunctionCall { name, args, .. } => {
907                assert_eq!(name, "search");
908                assert_eq!(args["q"], "rust");
909            }
910            _ => panic!("expected FunctionCall"),
911        }
912        assert!(matches!(&parts[2], Part::Text { text } if text == "\n trail"));
913    }
914
915    #[test]
916    fn test_boundary_whitespace_llama_preserved() {
917        let text = "pre \n<|python_tag|>  {\"name\": \"get_weather\", \"parameters\": {\"city\": \"NYC\"}}";
918        let parts = parse_text_tool_calls(text).unwrap();
919        assert_eq!(parts.len(), 2);
920        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
921        match &parts[1] {
922            Part::FunctionCall { name, args, .. } => {
923                assert_eq!(name, "get_weather");
924                assert_eq!(args["city"], "NYC");
925            }
926            _ => panic!("expected FunctionCall"),
927        }
928    }
929
930    #[test]
931    fn test_boundary_whitespace_mistral_nemo_preserved() {
932        let text = "pre \n[TOOL_CALLS]  [{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}]";
933        let parts = parse_text_tool_calls(text).unwrap();
934        assert_eq!(parts.len(), 2);
935        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
936        match &parts[1] {
937            Part::FunctionCall { name, args, .. } => {
938                assert_eq!(name, "search");
939                assert_eq!(args["q"], "rust");
940            }
941            _ => panic!("expected FunctionCall"),
942        }
943    }
944
945    #[test]
946    fn test_boundary_whitespace_deepseek_preserved() {
947        let text = "pre \n```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```";
948        let parts = parse_text_tool_calls(text).unwrap();
949        assert_eq!(parts.len(), 2);
950        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
951        match &parts[1] {
952            Part::FunctionCall { name, args, .. } => {
953                assert_eq!(name, "search");
954                assert_eq!(args["q"], "rust");
955            }
956            _ => panic!("expected FunctionCall"),
957        }
958    }
959
960    #[test]
961    fn test_boundary_whitespace_gemma4_preserved() {
962        let text = "pre \n<|tool_call>call:get_weather{<|\"|>city<|\"|>:<|\"|>Tokyo<|\"|>}<tool_call|>\n post";
963        let parts = parse_text_tool_calls(text).unwrap();
964        assert_eq!(parts.len(), 3);
965        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
966        match &parts[1] {
967            Part::FunctionCall { name, args, .. } => {
968                assert_eq!(name, "get_weather");
969                assert_eq!(args["city"], "Tokyo");
970            }
971            _ => panic!("expected FunctionCall"),
972        }
973        assert!(matches!(&parts[2], Part::Text { text } if text == "\n post"));
974    }
975
976    #[test]
977    fn test_boundary_whitespace_action_tag_preserved() {
978        let text = "pre \n<|action_start|>  {\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}  <|action_end|>\n post";
979        let parts = parse_text_tool_calls(text).unwrap();
980        assert_eq!(parts.len(), 3);
981        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
982        match &parts[1] {
983            Part::FunctionCall { name, args, .. } => {
984                assert_eq!(name, "search");
985                assert_eq!(args["q"], "rust");
986            }
987            _ => panic!("expected FunctionCall"),
988        }
989        assert!(matches!(&parts[2], Part::Text { text } if text == "\n post"));
990    }
991}