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//!
21//! Provider clients call [`parse_declared_tool_calls`] and
22//! [`ToolCallBuffer::for_declared_tools`], which accept a parse only when every
23//! call names a tool the request declared. A markdown ` ```json ` fence is
24//! ordinary model output, so the DeepSeek format also requires an `arguments`
25//! or `parameters` object.
26
27use std::collections::HashSet;
28
29use adk_core::Part;
30
31/// Check if text contains a tool call tag that should be parsed.
32pub fn contains_tool_call_tag(text: &str) -> bool {
33    text.contains("<tool_call>")
34        || text.contains("<|tool_call>")
35        || text.contains("<|python_tag|>")
36        || text.contains("[TOOL_CALLS]")
37        || text.contains("<|tool▁call")
38        || text.contains("<|action_start|>")
39        || (text.contains("```json") && text.contains("\"name\""))
40}
41
42/// Parse text-based tool calls from model output.
43///
44/// Returns `Some(parts)` if tool calls were found and parsed, where `parts`
45/// contains `Part::FunctionCall` entries (and optionally `Part::Text` for
46/// any non-tool-call text before/after the tags).
47///
48/// Returns `None` if no tool call tags were detected.
49pub fn parse_text_tool_calls(text: &str) -> Option<Vec<Part>> {
50    if !contains_tool_call_tag(text) {
51        return None;
52    }
53
54    let mut parts = Vec::new();
55
56    // Try Qwen/Hermes format: <tool_call>JSON</tool_call>
57    if let Some(parsed) = parse_qwen_format(text, &mut parts) {
58        return Some(parsed);
59    }
60
61    // Try Llama format: <|python_tag|>JSON
62    if let Some(parsed) = parse_llama_format(text, &mut parts) {
63        return Some(parsed);
64    }
65
66    // Try Mistral Nemo format: [TOOL_CALLS][JSON]
67    if let Some(parsed) = parse_mistral_nemo_format(text, &mut parts) {
68        return Some(parsed);
69    }
70
71    // Try DeepSeek format: ```json\n{...}\n``` with optional <|tool▁call▁end|>
72    if let Some(parsed) = parse_deepseek_format(text) {
73        return Some(parsed);
74    }
75
76    // Try Gemma 4 format: <|tool_call>call:NAME{...}<tool_call|>
77    if let Some(parsed) = parse_gemma4_format(text) {
78        return Some(parsed);
79    }
80
81    // Try action tags: <|action_start|>JSON<|action_end|>
82    if let Some(parsed) = parse_action_tag_format(text) {
83        return Some(parsed);
84    }
85
86    None
87}
88
89/// Parse text-based tool calls, accepting them only when they call declared tools.
90///
91/// Returns `None` — the caller keeps the text as text — when `declared_tools`
92/// is empty, when no tool call is detected, or when any parsed call names a
93/// tool outside `declared_tools`. This keeps ordinary output such as a JSON
94/// example with a `"name"` key from being turned into a tool call.
95///
96/// # Example
97///
98/// ```rust
99/// use std::collections::HashSet;
100///
101/// use adk_core::Part;
102/// use adk_model::tool_call_parser::parse_declared_tool_calls;
103///
104/// let declared = HashSet::from(["lookup".to_string()]);
105/// let example = "```json\n{\"name\": \"Alice\", \"age\": 30}\n```";
106/// assert!(parse_declared_tool_calls(example, &declared).is_none());
107///
108/// let call = "<tool_call>{\"name\": \"lookup\", \"arguments\": {\"id\": 7}}</tool_call>";
109/// let parts = parse_declared_tool_calls(call, &declared).unwrap();
110/// assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "lookup"));
111/// ```
112pub fn parse_declared_tool_calls(
113    text: &str,
114    declared_tools: &HashSet<String>,
115) -> Option<Vec<Part>> {
116    if declared_tools.is_empty() {
117        return None;
118    }
119    let parts = parse_text_tool_calls(text)?;
120    let all_declared = parts.iter().all(|part| match part {
121        Part::FunctionCall { name, .. } => declared_tools.contains(name),
122        _ => true,
123    });
124    let has_call = parts.iter().any(|part| matches!(part, Part::FunctionCall { .. }));
125    (all_declared && has_call).then_some(parts)
126}
127
128/// Parse Qwen/Hermes format tool calls.
129///
130/// Handles two sub-formats:
131/// 1. JSON body: `<tool_call>{"name":"fn", "arguments":{...}}</tool_call>`
132/// 2. Function tag: `<tool_call><function=fn>ARGS</function></tool_call>`
133fn parse_qwen_format(text: &str, _parts: &mut Vec<Part>) -> Option<Vec<Part>> {
134    let mut result = Vec::new();
135    let mut remaining = text;
136
137    loop {
138        let start = remaining.find("<tool_call>")?;
139
140        // Add any text before the tool call
141        let before = &remaining[..start];
142        if !before.is_empty() {
143            result.push(Part::Text { text: before.to_string() });
144        }
145
146        let after_open = &remaining[start + "<tool_call>".len()..];
147        let end = after_open.find("</tool_call>")?;
148        let inner = after_open[..end].trim();
149
150        // Try JSON format first: {"name":"...", "arguments":{...}}
151        if let Some(part) = parse_json_tool_call(inner) {
152            result.push(part);
153        }
154        // Try function tag format: <function=NAME>ARGS</function>
155        else if let Some(part) = parse_function_tag(inner) {
156            result.push(part);
157        } else {
158            // Couldn't parse — keep as text
159            result.push(Part::Text {
160                text: remaining[start..start + "<tool_call>".len() + end + "</tool_call>".len()]
161                    .to_string(),
162            });
163        }
164
165        remaining = &after_open[end + "</tool_call>".len()..];
166        if remaining.trim().is_empty() || !remaining.contains("<tool_call>") {
167            let trailing = remaining;
168            if !trailing.is_empty() {
169                result.push(Part::Text { text: trailing.to_string() });
170            }
171            break;
172        }
173    }
174
175    if result.is_empty() { None } else { Some(result) }
176}
177
178/// Parse `<function=NAME>ARGS</function>` tag.
179fn parse_function_tag(inner: &str) -> Option<Part> {
180    let func_start = inner.find("<function=")?;
181    let after_eq = &inner[func_start + "<function=".len()..];
182    let name_end = after_eq.find('>')?;
183    let name = after_eq[..name_end].trim().to_string();
184
185    let body_start = name_end + 1;
186    let func_end = after_eq.find("</function>")?;
187    let body = after_eq[body_start..func_end].trim();
188
189    let args = if body.is_empty() {
190        serde_json::json!({})
191    } else {
192        serde_json::from_str(body).unwrap_or_else(|_| serde_json::json!({}))
193    };
194
195    Some(Part::FunctionCall { name, args, id: None, thought_signature: None })
196}
197
198/// Parse JSON tool call: `{"name":"...", "arguments":{...}}`
199/// Also handles `{"function":"...", "parameters":{...}}` variant.
200fn parse_json_tool_call(json_str: &str) -> Option<Part> {
201    let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
202    let obj = value.as_object()?;
203
204    let name =
205        obj.get("name").or_else(|| obj.get("function")).and_then(|v| v.as_str())?.to_string();
206
207    let args = obj
208        .get("arguments")
209        .or_else(|| obj.get("parameters"))
210        .cloned()
211        .unwrap_or(serde_json::json!({}));
212
213    Some(Part::FunctionCall { name, args, id: None, thought_signature: None })
214}
215
216/// Parse Llama format: `<|python_tag|>{"name":"...", "parameters":{...}}`
217fn parse_llama_format(text: &str, _parts: &mut Vec<Part>) -> Option<Vec<Part>> {
218    let tag = "<|python_tag|>";
219    let start = text.find(tag)?;
220
221    let mut result = Vec::new();
222    let before = &text[..start];
223    if !before.is_empty() {
224        result.push(Part::Text { text: before.to_string() });
225    }
226
227    let json_str = text[start + tag.len()..].trim();
228    if let Some(part) = parse_json_tool_call(json_str) {
229        result.push(part);
230    } else {
231        return None;
232    }
233
234    Some(result)
235}
236
237/// Parse Mistral Nemo format: `[TOOL_CALLS][{"name":"...", "arguments":{...}}]`
238fn parse_mistral_nemo_format(text: &str, _parts: &mut Vec<Part>) -> Option<Vec<Part>> {
239    let tag = "[TOOL_CALLS]";
240    let start = text.find(tag)?;
241
242    let mut result = Vec::new();
243    let before = &text[..start];
244    if !before.is_empty() {
245        result.push(Part::Text { text: before.to_string() });
246    }
247
248    let json_str = text[start + tag.len()..].trim();
249    // Expect a JSON array of tool calls
250    let arr: Vec<serde_json::Value> = serde_json::from_str(json_str).ok()?;
251    for item in &arr {
252        let obj = item.as_object()?;
253        let name = obj.get("name").and_then(|v| v.as_str())?.to_string();
254        let args = obj
255            .get("arguments")
256            .or_else(|| obj.get("parameters"))
257            .cloned()
258            .unwrap_or(serde_json::json!({}));
259        result.push(Part::FunctionCall { name, args, id: None, thought_signature: None });
260    }
261
262    if result.is_empty() { None } else { Some(result) }
263}
264
265/// DeepSeek end-of-call markers that may follow the closing fence.
266const DEEPSEEK_END_MARKERS: &[&str] = &[
267    "<\u{ff5c}tool\u{2581}call\u{2581}end\u{ff5c}>",
268    "<\u{ff5c}tool\u{2581}calls\u{2581}end\u{ff5c}>",
269];
270
271/// Parse DeepSeek format: ` ```json\n{"name":"...","arguments":{...}}\n``` `
272///
273/// DeepSeek models wrap tool calls in markdown JSON fences, optionally
274/// followed by `<|tool▁call▁end|>` (full-width Unicode delimiters). A fence
275/// is also ordinary markdown, so every object must carry a name and an
276/// `arguments` or `parameters` object; text after the fence is kept.
277fn parse_deepseek_format(text: &str) -> Option<Vec<Part>> {
278    let fence_start = text.find("```json")?;
279    let json_start = fence_start + "```json".len();
280    let after_fence = &text[json_start..];
281    let fence_end = after_fence.find("```")?;
282    let json_str = after_fence[..fence_end].trim();
283
284    let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
285    let calls = match &value {
286        serde_json::Value::Array(items) => {
287            items.iter().map(parse_fenced_tool_call).collect::<Option<Vec<_>>>()?
288        }
289        object => vec![parse_fenced_tool_call(object)?],
290    };
291    if calls.is_empty() {
292        return None;
293    }
294
295    let mut result = Vec::new();
296    let before = &text[..fence_start];
297    if !before.is_empty() {
298        result.push(Part::Text { text: before.to_string() });
299    }
300    result.extend(calls);
301
302    let mut trailing = &after_fence[fence_end + "```".len()..];
303    while let Some(rest) =
304        DEEPSEEK_END_MARKERS.iter().find_map(|m| trailing.trim_start().strip_prefix(m))
305    {
306        trailing = rest;
307    }
308    if !trailing.is_empty() {
309        result.push(Part::Text { text: trailing.to_string() });
310    }
311
312    Some(result)
313}
314
315/// Parse one fenced tool-call object; `arguments`/`parameters` must be present
316/// and be an object, never defaulted.
317fn parse_fenced_tool_call(value: &serde_json::Value) -> Option<Part> {
318    let obj = value.as_object()?;
319    let name = obj.get("name").or_else(|| obj.get("function")).and_then(|v| v.as_str())?;
320    let args = obj.get("arguments").or_else(|| obj.get("parameters")).filter(|v| v.is_object())?;
321    Some(Part::FunctionCall {
322        name: name.to_string(),
323        args: args.clone(),
324        id: None,
325        thought_signature: None,
326    })
327}
328
329/// Parse Gemma 4 format: `<|tool_call>call:NAME{key:<|"|>value<|"|>}<tool_call|>`
330///
331/// Gemma 4 uses a non-JSON format with custom `<|"|>` string delimiters.
332/// The body after `call:NAME` is a key-value block using `<|"|>` to quote strings.
333fn parse_gemma4_format(text: &str) -> Option<Vec<Part>> {
334    let mut result = Vec::new();
335    let mut remaining = text;
336
337    loop {
338        let start = remaining.find("<|tool_call>")?;
339        let before = &remaining[..start];
340        if !before.is_empty() {
341            result.push(Part::Text { text: before.to_string() });
342        }
343
344        let after_open = &remaining[start + "<|tool_call>".len()..];
345        let end = after_open.find("<tool_call|>")?;
346        let inner = after_open[..end].trim();
347
348        // Parse: call:NAME{key:<|"|>value<|"|>, ...}
349        if let Some(call_body) = inner.strip_prefix("call:") {
350            let brace_start = call_body.find('{');
351            let name = if let Some(bs) = brace_start {
352                call_body[..bs].trim().to_string()
353            } else {
354                call_body.trim().to_string()
355            };
356
357            let args = if let Some(bs) = brace_start {
358                let args_raw = &call_body[bs..];
359                // Convert Gemma 4 format to JSON: replace <|"|> with "
360                let json_str = args_raw.replace("<|\"|>", "\"");
361                serde_json::from_str(&json_str).unwrap_or(serde_json::json!({}))
362            } else {
363                serde_json::json!({})
364            };
365
366            result.push(Part::FunctionCall { name, args, id: None, thought_signature: None });
367        }
368
369        remaining = &after_open[end + "<tool_call|>".len()..];
370        if remaining.trim().is_empty() || !remaining.contains("<|tool_call>") {
371            let trailing = remaining;
372            if !trailing.is_empty() {
373                result.push(Part::Text { text: trailing.to_string() });
374            }
375            break;
376        }
377    }
378
379    if result.is_empty() { None } else { Some(result) }
380}
381
382/// Parse action tag format: `<|action_start|>JSON<|action_end|>`
383///
384/// Used by some models (e.g., InternLM, ChatGLM variants) that wrap
385/// tool calls in action start/end tags.
386fn parse_action_tag_format(text: &str) -> Option<Vec<Part>> {
387    let start_tag = "<|action_start|>";
388    let end_tag = "<|action_end|>";
389
390    let start = text.find(start_tag)?;
391    let mut result = Vec::new();
392
393    let before = &text[..start];
394    if !before.is_empty() {
395        result.push(Part::Text { text: before.to_string() });
396    }
397
398    let after_open = &text[start + start_tag.len()..];
399    let end = after_open.find(end_tag)?;
400    let inner = after_open[..end].trim();
401
402    if let Some(part) = parse_json_tool_call(inner) {
403        result.push(part);
404    } else {
405        return None;
406    }
407
408    let trailing = &after_open[end + end_tag.len()..];
409    if !trailing.is_empty() {
410        result.push(Part::Text { text: trailing.to_string() });
411    }
412
413    Some(result)
414}
415
416// ===== Streaming buffer for token-by-token tool call detection =====
417
418/// Prefixes that indicate a potential tool call is starting.
419const TOOL_CALL_PREFIXES: &[&str] = &[
420    "<tool_call",
421    "<|tool_call>",
422    "<|python_tag|",
423    "[TOOL_CALLS]",
424    "<|action_start|>",
425    "<\u{ff5c}\u{2581}tool", // <|tool (DeepSeek full-width)
426];
427
428/// Maximum buffer size before flushing as plain text (safety valve).
429const MAX_BUFFER_SIZE: usize = 4096;
430
431/// Streaming buffer that accumulates tokens and detects tool call boundaries.
432///
433/// Use this in streaming response handlers to buffer tokens when a tool call
434/// prefix is detected, then parse and emit `Part::FunctionCall` when the
435/// closing tag arrives.
436///
437/// # Example
438///
439/// ```rust,ignore
440/// let mut buffer = ToolCallBuffer::new();
441///
442/// for chunk in stream {
443///     match buffer.push(&chunk.text) {
444///         BufferAction::Emit(parts) => {
445///             for part in parts { yield part; }
446///         }
447///         BufferAction::Buffering => { /* still accumulating */ }
448///     }
449/// }
450/// // Flush any remaining content at end of stream
451/// for part in buffer.flush() { yield part; }
452/// ```
453pub struct ToolCallBuffer {
454    buffer: String,
455    buffering: bool,
456    /// Tools the request declared; `None` accepts any parsed call.
457    declared_tools: Option<HashSet<String>>,
458}
459
460/// Action returned by `ToolCallBuffer::push()`.
461pub enum BufferAction {
462    /// Emit these parts immediately (text or parsed tool calls).
463    Emit(Vec<Part>),
464    /// Still buffering — don't emit anything yet.
465    Buffering,
466}
467
468impl ToolCallBuffer {
469    /// Create a new empty buffer that accepts a parsed call to any tool name.
470    pub fn new() -> Self {
471        Self { buffer: String::new(), buffering: false, declared_tools: None }
472    }
473
474    /// Create a buffer that emits a parsed call only when every call names one of
475    /// `declared_tools`, as [`parse_declared_tool_calls`] does.
476    ///
477    /// With no declared tools the buffer never holds text back: every chunk is
478    /// emitted as text immediately.
479    ///
480    /// # Example
481    ///
482    /// ```rust
483    /// use std::collections::HashSet;
484    ///
485    /// use adk_core::Part;
486    /// use adk_model::tool_call_parser::{BufferAction, ToolCallBuffer};
487    ///
488    /// let mut buffer = ToolCallBuffer::for_declared_tools(HashSet::new());
489    /// let BufferAction::Emit(parts) = buffer.push("<tool_call>{\"name\":\"x\"}</tool_call>") else {
490    ///     panic!("nothing is buffered without declared tools");
491    /// };
492    /// assert!(matches!(&parts[0], Part::Text { .. }));
493    /// ```
494    pub fn for_declared_tools(declared_tools: HashSet<String>) -> Self {
495        Self { buffer: String::new(), buffering: false, declared_tools: Some(declared_tools) }
496    }
497
498    /// Push a text chunk into the buffer.
499    ///
500    /// Returns `BufferAction::Emit` with parts to yield, or
501    /// `BufferAction::Buffering` if we're accumulating a potential tool call.
502    pub fn push(&mut self, text: &str) -> BufferAction {
503        self.buffer.push_str(text);
504
505        if self.declared_tools.as_ref().is_some_and(HashSet::is_empty) {
506            return self.flush_as_emit();
507        }
508
509        if self.buffering {
510            // Check if we have a complete tool call
511            if self.has_complete_tool_call() {
512                return self.try_parse_and_emit();
513            }
514            // A prefix-like chunk can become ordinary text once more data arrives.
515            if !self.starts_tool_call_prefix() && !self.has_partial_prefix() {
516                return self.flush_as_emit();
517            }
518            // Safety valve: if buffer is too large, flush as text
519            if self.buffer.len() > MAX_BUFFER_SIZE {
520                return self.flush_as_emit();
521            }
522            BufferAction::Buffering
523        } else {
524            // Check if this chunk starts or contains a tool call prefix
525            if self.starts_tool_call_prefix() {
526                self.buffering = true;
527                // Check if the complete tool call arrived in one chunk
528                if self.has_complete_tool_call() {
529                    return self.try_parse_and_emit();
530                }
531                BufferAction::Buffering
532            } else if self.has_partial_prefix() {
533                // Could be the start of a prefix split across chunks (e.g., "<tool" then "_call>")
534                self.buffering = true;
535                BufferAction::Buffering
536            } else {
537                // Normal text — emit immediately
538                self.flush_as_emit()
539            }
540        }
541    }
542
543    /// Flush any remaining buffered content as parts.
544    /// Call this when the stream ends.
545    pub fn flush(&mut self) -> Vec<Part> {
546        if self.buffer.is_empty() {
547            return Vec::new();
548        }
549
550        // Try to parse as tool calls one last time
551        if let Some(parts) = self.parse_buffer() {
552            self.buffer.clear();
553            self.buffering = false;
554            return parts;
555        }
556
557        // Otherwise emit as text
558        let text = std::mem::take(&mut self.buffer);
559        self.buffering = false;
560        if text.is_empty() { Vec::new() } else { vec![Part::Text { text }] }
561    }
562
563    fn parse_buffer(&self) -> Option<Vec<Part>> {
564        match &self.declared_tools {
565            Some(declared_tools) => parse_declared_tool_calls(&self.buffer, declared_tools),
566            None => parse_text_tool_calls(&self.buffer),
567        }
568    }
569
570    fn starts_tool_call_prefix(&self) -> bool {
571        TOOL_CALL_PREFIXES.iter().any(|prefix| self.buffer.contains(prefix))
572    }
573
574    fn has_partial_prefix(&self) -> bool {
575        // Check if the buffer ends with a partial prefix like "<tool" or "<|python"
576        let buf = &self.buffer;
577        for prefix in TOOL_CALL_PREFIXES {
578            // Use char-based iteration to avoid slicing multi-byte Unicode
579            let prefix_chars: Vec<char> = prefix.chars().collect();
580            for i in 1..prefix_chars.len() {
581                let partial: String = prefix_chars[..i].iter().collect();
582                if buf.ends_with(&partial) {
583                    return true;
584                }
585            }
586        }
587        false
588    }
589
590    fn has_complete_tool_call(&self) -> bool {
591        (self.buffer.contains("<tool_call>") && self.buffer.contains("</tool_call>"))
592            || (self.buffer.contains("<|tool_call>") && self.buffer.contains("<tool_call|>"))
593            || (self.buffer.contains("<|python_tag|>")
594                && self.buffer.contains('\n')
595                && self.buffer.len() > "<|python_tag|>".len() + 5)
596            || (self.buffer.contains("[TOOL_CALLS]")
597                && self.buffer.contains(']')
598                && self.buffer.rfind(']') > self.buffer.find("[TOOL_CALLS]").map(|i| i + 12))
599            || (self.buffer.contains("```json") && self.buffer.matches("```").count() >= 2)
600            || (self.buffer.contains("<|action_start|>") && self.buffer.contains("<|action_end|>"))
601    }
602
603    fn try_parse_and_emit(&mut self) -> BufferAction {
604        if let Some(parts) = self.parse_buffer() {
605            self.buffer.clear();
606            self.buffering = false;
607            BufferAction::Emit(parts)
608        } else {
609            // Couldn't parse — flush as text
610            self.flush_as_emit()
611        }
612    }
613
614    fn flush_as_emit(&mut self) -> BufferAction {
615        let text = std::mem::take(&mut self.buffer);
616        self.buffering = false;
617        if text.is_empty() {
618            BufferAction::Emit(Vec::new())
619        } else {
620            BufferAction::Emit(vec![Part::Text { text }])
621        }
622    }
623}
624
625impl Default for ToolCallBuffer {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn test_qwen_json_format() {
637        let text =
638            r#"<tool_call>{"name": "get_weather", "arguments": {"city": "Tokyo"}}</tool_call>"#;
639        let parts = parse_text_tool_calls(text).unwrap();
640        assert_eq!(parts.len(), 1);
641        match &parts[0] {
642            Part::FunctionCall { name, args, .. } => {
643                assert_eq!(name, "get_weather");
644                assert_eq!(args["city"], "Tokyo");
645            }
646            _ => panic!("expected FunctionCall"),
647        }
648    }
649
650    #[test]
651    fn test_qwen_function_tag_format() {
652        let text = r#"<tool_call><function=screenshot></function></tool_call>"#;
653        let parts = parse_text_tool_calls(text).unwrap();
654        assert_eq!(parts.len(), 1);
655        match &parts[0] {
656            Part::FunctionCall { name, args, .. } => {
657                assert_eq!(name, "screenshot");
658                assert_eq!(*args, serde_json::json!({}));
659            }
660            _ => panic!("expected FunctionCall"),
661        }
662    }
663
664    #[test]
665    fn test_qwen_function_tag_with_args() {
666        let text = r#"<tool_call><function=get_weather>{"city": "Tokyo"}</function></tool_call>"#;
667        let parts = parse_text_tool_calls(text).unwrap();
668        assert_eq!(parts.len(), 1);
669        match &parts[0] {
670            Part::FunctionCall { name, args, .. } => {
671                assert_eq!(name, "get_weather");
672                assert_eq!(args["city"], "Tokyo");
673            }
674            _ => panic!("expected FunctionCall"),
675        }
676    }
677
678    #[test]
679    fn test_text_before_tool_call() {
680        let text = r#"Let me check that for you.
681<tool_call>{"name": "search", "arguments": {"query": "rust"}}</tool_call>"#;
682        let parts = parse_text_tool_calls(text).unwrap();
683        assert_eq!(parts.len(), 2);
684        assert!(matches!(&parts[0], Part::Text { text } if text.contains("check that")));
685        assert!(matches!(&parts[1], Part::FunctionCall { name, .. } if name == "search"));
686    }
687
688    #[test]
689    fn test_multiple_tool_calls() {
690        // The two tool-call markups are separated by a single `\n`. After the
691        // whitespace-preservation fix, that separating newline is emitted as a
692        // `Part::Text` between the two `Part::FunctionCall`s (byte-exact
693        // reconstruction per Requirements 2.5/2.6), yielding 3 parts.
694        let text = r#"<tool_call>{"name": "a", "arguments": {}}</tool_call>
695<tool_call>{"name": "b", "arguments": {"x": 1}}</tool_call>"#;
696        let parts = parse_text_tool_calls(text).unwrap();
697        assert_eq!(parts.len(), 3);
698        assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "a"));
699        assert!(matches!(&parts[1], Part::Text { text } if text == "\n"));
700        assert!(matches!(&parts[2], Part::FunctionCall { name, .. } if name == "b"));
701    }
702
703    #[test]
704    fn test_llama_format() {
705        let text = r#"<|python_tag|>{"name": "get_weather", "parameters": {"city": "NYC"}}"#;
706        let parts = parse_text_tool_calls(text).unwrap();
707        assert_eq!(parts.len(), 1);
708        match &parts[0] {
709            Part::FunctionCall { name, args, .. } => {
710                assert_eq!(name, "get_weather");
711                assert_eq!(args["city"], "NYC");
712            }
713            _ => panic!("expected FunctionCall"),
714        }
715    }
716
717    #[test]
718    fn test_mistral_nemo_format() {
719        let text = r#"[TOOL_CALLS][{"name": "search", "arguments": {"q": "rust"}}]"#;
720        let parts = parse_text_tool_calls(text).unwrap();
721        assert_eq!(parts.len(), 1);
722        match &parts[0] {
723            Part::FunctionCall { name, args, .. } => {
724                assert_eq!(name, "search");
725                assert_eq!(args["q"], "rust");
726            }
727            _ => panic!("expected FunctionCall"),
728        }
729    }
730
731    #[test]
732    fn test_no_tool_call_returns_none() {
733        assert!(parse_text_tool_calls("Hello, how can I help?").is_none());
734        assert!(parse_text_tool_calls("").is_none());
735    }
736
737    #[test]
738    fn test_contains_tool_call_tag() {
739        assert!(contains_tool_call_tag("<tool_call>"));
740        assert!(contains_tool_call_tag("text <tool_call> more"));
741        assert!(contains_tool_call_tag("<|python_tag|>"));
742        assert!(contains_tool_call_tag("[TOOL_CALLS]"));
743        assert!(!contains_tool_call_tag("normal text"));
744    }
745
746    // ===== Streaming buffer tests =====
747
748    #[test]
749    fn test_buffer_normal_text_emits_immediately() {
750        let mut buf = ToolCallBuffer::new();
751        match buf.push("Hello world") {
752            BufferAction::Emit(parts) => {
753                assert_eq!(parts.len(), 1);
754                assert!(matches!(&parts[0], Part::Text { text } if text == "Hello world"));
755            }
756            BufferAction::Buffering => panic!("should emit immediately"),
757        }
758    }
759
760    #[test]
761    fn test_buffer_complete_tool_call_in_one_chunk() {
762        let mut buf = ToolCallBuffer::new();
763        let text = r#"<tool_call>{"name": "search", "arguments": {"q": "rust"}}</tool_call>"#;
764        match buf.push(text) {
765            BufferAction::Emit(parts) => {
766                assert_eq!(parts.len(), 1);
767                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "search"));
768            }
769            BufferAction::Buffering => panic!("should emit parsed tool call"),
770        }
771    }
772
773    #[test]
774    fn test_buffer_tool_call_split_across_chunks() {
775        let mut buf = ToolCallBuffer::new();
776
777        // Chunk 1: prefix starts
778        assert!(matches!(buf.push("<tool_call>"), BufferAction::Buffering));
779
780        // Chunk 2: JSON body
781        assert!(matches!(
782            buf.push(r#"{"name": "get_weather", "arguments": {"city": "Tokyo"}}"#),
783            BufferAction::Buffering
784        ));
785
786        // Chunk 3: closing tag
787        match buf.push("</tool_call>") {
788            BufferAction::Emit(parts) => {
789                assert_eq!(parts.len(), 1);
790                assert!(
791                    matches!(&parts[0], Part::FunctionCall { name, .. } if name == "get_weather")
792                );
793            }
794            BufferAction::Buffering => panic!("should emit after closing tag"),
795        }
796    }
797
798    #[test]
799    fn test_buffer_text_then_tool_call() {
800        let mut buf = ToolCallBuffer::new();
801
802        // Normal text first
803        match buf.push("Let me check. ") {
804            BufferAction::Emit(parts) => {
805                assert_eq!(parts.len(), 1);
806                assert!(matches!(&parts[0], Part::Text { .. }));
807            }
808            BufferAction::Buffering => panic!("should emit text"),
809        }
810
811        // Then tool call
812        let tc = r#"<tool_call>{"name": "search", "arguments": {}}</tool_call>"#;
813        match buf.push(tc) {
814            BufferAction::Emit(parts) => {
815                assert_eq!(parts.len(), 1);
816                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "search"));
817            }
818            BufferAction::Buffering => panic!("should emit tool call"),
819        }
820    }
821
822    #[test]
823    fn test_buffer_flush_incomplete_as_text() {
824        let mut buf = ToolCallBuffer::new();
825        assert!(matches!(buf.push("<tool_call>partial"), BufferAction::Buffering));
826
827        // Stream ends without closing tag
828        let parts = buf.flush();
829        assert_eq!(parts.len(), 1);
830        assert!(matches!(&parts[0], Part::Text { text } if text.contains("<tool_call>")));
831    }
832
833    #[test]
834    fn test_buffer_flush_empty() {
835        let mut buf = ToolCallBuffer::new();
836        let parts = buf.flush();
837        assert!(parts.is_empty());
838    }
839
840    #[test]
841    fn test_buffer_partial_prefix_detection() {
842        let mut buf = ToolCallBuffer::new();
843        // "<tool" could be the start of "<tool_call>"
844        assert!(matches!(buf.push("<tool"), BufferAction::Buffering));
845        // Complete it
846        assert!(matches!(buf.push("_call>"), BufferAction::Buffering));
847        // Add body and close
848        match buf.push(r#"{"name":"x","arguments":{}}</tool_call>"#) {
849            BufferAction::Emit(parts) => {
850                assert_eq!(parts.len(), 1);
851                assert!(matches!(&parts[0], Part::FunctionCall { name, .. } if name == "x"));
852            }
853            BufferAction::Buffering => panic!("should emit"),
854        }
855    }
856
857    #[test]
858    fn test_buffer_markdown_link_is_not_mistaken_for_tool_call() {
859        let mut buf = ToolCallBuffer::new();
860
861        assert!(matches!(buf.push("["), BufferAction::Buffering));
862
863        match buf.push("documentation](https://example.com) trailing text") {
864            BufferAction::Emit(parts) => {
865                assert_eq!(
866                    parts,
867                    vec![Part::Text {
868                        text: "[documentation](https://example.com) trailing text".to_string()
869                    }]
870                );
871            }
872            BufferAction::Buffering => panic!("markdown link must not stay buffered"),
873        }
874
875        assert!(buf.flush().is_empty());
876    }
877
878    #[test]
879    fn test_buffer_mistral_prefix_can_still_span_chunks() {
880        let mut buf = ToolCallBuffer::new();
881
882        assert!(matches!(buf.push("["), BufferAction::Buffering));
883        assert!(matches!(buf.push("TOOL"), BufferAction::Buffering));
884
885        match buf.push(r#"_CALLS][{"name":"search","arguments":{"q":"rust"}}]"#) {
886            BufferAction::Emit(parts) => {
887                assert_eq!(parts.len(), 1);
888                assert!(matches!(
889                    &parts[0],
890                    Part::FunctionCall { name, .. } if name == "search"
891                ));
892            }
893            BufferAction::Buffering => panic!("should emit parsed tool call"),
894        }
895    }
896
897    // ===== DeepSeek format tests =====
898
899    #[test]
900    fn test_deepseek_json_fence() {
901        let text = "```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```";
902        let parts = parse_text_tool_calls(text).unwrap();
903        assert_eq!(parts.len(), 1);
904        match &parts[0] {
905            Part::FunctionCall { name, args, .. } => {
906                assert_eq!(name, "search");
907                assert_eq!(args["q"], "rust");
908            }
909            _ => panic!("expected FunctionCall"),
910        }
911    }
912
913    #[test]
914    fn test_deepseek_with_text_before() {
915        let text = "I'll search for that.\n```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```\n<|tool▁call▁end|>";
916        let parts = parse_text_tool_calls(text).unwrap();
917        assert!(!parts.is_empty());
918        let has_fn_call =
919            parts.iter().any(|p| matches!(p, Part::FunctionCall { name, .. } if name == "search"));
920        assert!(has_fn_call);
921    }
922
923    fn declared(names: &[&str]) -> HashSet<String> {
924        names.iter().map(|name| (*name).to_string()).collect()
925    }
926
927    #[test]
928    fn json_example_without_declared_tools_stays_text() {
929        let text = "Here is the record:\n```json\n{\"name\":\"Alice\",\"age\":30}\n```\nDone.";
930
931        assert_eq!(parse_declared_tool_calls(text, &declared(&[])), None);
932    }
933
934    #[test]
935    fn json_example_with_other_declared_tool_stays_text() {
936        let text = "Here is the record:\n```json\n{\"name\":\"Alice\",\"age\":30}\n```\nDone.";
937
938        assert_eq!(parse_declared_tool_calls(text, &declared(&["lookup"])), None);
939        // The fence carries no arguments, so it is not a call even without the filter.
940        assert_eq!(parse_text_tool_calls(text), None);
941    }
942
943    #[test]
944    fn fenced_call_to_undeclared_tool_stays_text() {
945        let text = "```json\n{\"name\":\"delete_all\",\"arguments\":{}}\n```";
946
947        assert_eq!(parse_declared_tool_calls(text, &declared(&["lookup"])), None);
948    }
949
950    #[test]
951    fn fenced_call_to_declared_tool_parses_and_keeps_trailing_text() {
952        let text = "Checking.\n```json\n{\"name\":\"lookup\",\"arguments\":{\"id\":7}}\n```\nI will report back.";
953
954        assert_eq!(
955            parse_declared_tool_calls(text, &declared(&["lookup"])),
956            Some(vec![
957                Part::Text { text: "Checking.\n".to_string() },
958                Part::FunctionCall {
959                    name: "lookup".to_string(),
960                    args: serde_json::json!({"id": 7}),
961                    id: None,
962                    thought_signature: None,
963                },
964                Part::Text { text: "\nI will report back.".to_string() },
965            ])
966        );
967    }
968
969    #[test]
970    fn deepseek_end_marker_is_not_kept_as_text() {
971        let text = "```json\n{\"name\":\"lookup\",\"arguments\":{}}\n```\n<|tool▁call▁end|>";
972
973        assert_eq!(
974            parse_text_tool_calls(text),
975            Some(vec![Part::FunctionCall {
976                name: "lookup".to_string(),
977                args: serde_json::json!({}),
978                id: None,
979                thought_signature: None,
980            }])
981        );
982    }
983
984    #[test]
985    fn declared_buffer_keeps_undeclared_calls_as_text() {
986        let text = "<tool_call>{\"name\":\"rm\",\"arguments\":{}}</tool_call>";
987        let mut buffer = ToolCallBuffer::for_declared_tools(declared(&["lookup"]));
988
989        let BufferAction::Emit(parts) = buffer.push(text) else {
990            panic!("a complete tag is resolved immediately");
991        };
992        assert_eq!(parts, vec![Part::Text { text: text.to_string() }]);
993    }
994
995    // ===== Gemma 4 format tests =====
996
997    #[test]
998    fn test_gemma4_simple() {
999        let text = "<|tool_call>call:get_weather{}<tool_call|>";
1000        let parts = parse_text_tool_calls(text).unwrap();
1001        assert_eq!(parts.len(), 1);
1002        match &parts[0] {
1003            Part::FunctionCall { name, .. } => assert_eq!(name, "get_weather"),
1004            _ => panic!("expected FunctionCall"),
1005        }
1006    }
1007
1008    #[test]
1009    fn test_gemma4_with_args() {
1010        let text = "<|tool_call>call:get_weather{<|\"|>city<|\"|>:<|\"|>Tokyo<|\"|>}<tool_call|>";
1011        let parts = parse_text_tool_calls(text).unwrap();
1012        assert_eq!(parts.len(), 1);
1013        match &parts[0] {
1014            Part::FunctionCall { name, args, .. } => {
1015                assert_eq!(name, "get_weather");
1016                assert_eq!(args["city"], "Tokyo");
1017            }
1018            _ => panic!("expected FunctionCall"),
1019        }
1020    }
1021
1022    // ===== Action tag format tests =====
1023
1024    #[test]
1025    fn test_action_tags() {
1026        let text = "<|action_start|>{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}<|action_end|>";
1027        let parts = parse_text_tool_calls(text).unwrap();
1028        assert_eq!(parts.len(), 1);
1029        match &parts[0] {
1030            Part::FunctionCall { name, args, .. } => {
1031                assert_eq!(name, "search");
1032                assert_eq!(args["q"], "rust");
1033            }
1034            _ => panic!("expected FunctionCall"),
1035        }
1036    }
1037
1038    #[test]
1039    fn test_action_tags_with_surrounding_text() {
1040        let text = "Let me look that up. <|action_start|>{\"name\": \"search\", \"arguments\": {}}<|action_end|> Done.";
1041        let parts = parse_text_tool_calls(text).unwrap();
1042        assert!(parts.len() >= 2); // text + function call (+ optional trailing text)
1043        let has_fn_call =
1044            parts.iter().any(|p| matches!(p, Part::FunctionCall { name, .. } if name == "search"));
1045        assert!(has_fn_call);
1046    }
1047
1048    // ===== Whitespace-preservation regression tests =====
1049
1050    /// Stream a sequence of deltas through `ToolCallBuffer` (push each, then
1051    /// flush) and return the concatenation of all emitted `Part::Text` values.
1052    fn stream_and_reconstruct(deltas: &[&str]) -> String {
1053        let mut buf = ToolCallBuffer::new();
1054        let mut out = String::new();
1055        let mut collect = |parts: Vec<Part>| {
1056            for part in parts {
1057                if let Part::Text { text } = part {
1058                    out.push_str(&text);
1059                }
1060            }
1061        };
1062        for delta in deltas {
1063            if let BufferAction::Emit(parts) = buf.push(delta) {
1064                collect(parts);
1065            }
1066        }
1067        collect(buf.flush());
1068        out
1069    }
1070
1071    #[test]
1072    fn test_regression_markdown_structure_reconstructs() {
1073        // Counterexample 1 (Requirements 2.1, 2.2, 2.3): whitespace-only deltas
1074        // between visible text must be preserved so Markdown structure survives.
1075        let reconstructed =
1076            stream_and_reconstruct(&["Heading", "\n\n", "- first", "\n", "- second"]);
1077        assert_eq!(reconstructed, "Heading\n\n- first\n- second");
1078    }
1079
1080    #[test]
1081    fn test_regression_single_space_reconstructs() {
1082        // Counterexample 2 (Requirements 2.1, 2.4): a single-space delta between
1083        // words must be preserved.
1084        let reconstructed = stream_and_reconstruct(&["Hello", " ", "world"]);
1085        assert_eq!(reconstructed, "Hello world");
1086    }
1087
1088    #[test]
1089    fn test_flush_as_emit_whitespace_only_emits_text() {
1090        // Requirement 2.1: a whitespace-only delta must be emitted as Part::Text
1091        // (previously dropped by the `text.trim().is_empty()` guard).
1092        let mut buf = ToolCallBuffer::new();
1093        match buf.push("\n\n") {
1094            BufferAction::Emit(parts) => {
1095                assert_eq!(parts.len(), 1);
1096                assert!(matches!(&parts[0], Part::Text { text } if text == "\n\n"));
1097            }
1098            BufferAction::Buffering => panic!("whitespace-only delta should emit"),
1099        }
1100    }
1101
1102    #[test]
1103    fn test_flush_whitespace_only_emits_text() {
1104        // Requirement 2.2: whitespace-only buffered content at end-of-stream must
1105        // be emitted by flush rather than discarded.
1106        let mut buf = ToolCallBuffer::new();
1107        // Place whitespace-only content into the buffer as if mid-tool-call
1108        // buffering had accumulated it, then flush at end of stream.
1109        buf.buffer.push_str("   \n\t");
1110        buf.buffering = true;
1111        let parts = buf.flush();
1112        assert_eq!(parts.len(), 1);
1113        assert!(matches!(&parts[0], Part::Text { text } if text == "   \n\t"));
1114    }
1115
1116    // ===== Boundary-whitespace preservation across the six markup formats =====
1117
1118    #[test]
1119    fn test_boundary_whitespace_qwen_preserved() {
1120        // Leading/trailing whitespace around the markup is preserved, while the
1121        // inner tool name/args stay trimmed (inner is `.trim()`ed before parse).
1122        let text = "lead \n<tool_call>  {\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}  </tool_call>\n trail";
1123        let parts = parse_text_tool_calls(text).unwrap();
1124        assert_eq!(parts.len(), 3);
1125        assert!(matches!(&parts[0], Part::Text { text } if text == "lead \n"));
1126        match &parts[1] {
1127            Part::FunctionCall { name, args, .. } => {
1128                assert_eq!(name, "search");
1129                assert_eq!(args["q"], "rust");
1130            }
1131            _ => panic!("expected FunctionCall"),
1132        }
1133        assert!(matches!(&parts[2], Part::Text { text } if text == "\n trail"));
1134    }
1135
1136    #[test]
1137    fn test_boundary_whitespace_llama_preserved() {
1138        let text = "pre \n<|python_tag|>  {\"name\": \"get_weather\", \"parameters\": {\"city\": \"NYC\"}}";
1139        let parts = parse_text_tool_calls(text).unwrap();
1140        assert_eq!(parts.len(), 2);
1141        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
1142        match &parts[1] {
1143            Part::FunctionCall { name, args, .. } => {
1144                assert_eq!(name, "get_weather");
1145                assert_eq!(args["city"], "NYC");
1146            }
1147            _ => panic!("expected FunctionCall"),
1148        }
1149    }
1150
1151    #[test]
1152    fn test_boundary_whitespace_mistral_nemo_preserved() {
1153        let text = "pre \n[TOOL_CALLS]  [{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}]";
1154        let parts = parse_text_tool_calls(text).unwrap();
1155        assert_eq!(parts.len(), 2);
1156        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
1157        match &parts[1] {
1158            Part::FunctionCall { name, args, .. } => {
1159                assert_eq!(name, "search");
1160                assert_eq!(args["q"], "rust");
1161            }
1162            _ => panic!("expected FunctionCall"),
1163        }
1164    }
1165
1166    #[test]
1167    fn test_boundary_whitespace_deepseek_preserved() {
1168        let text = "pre \n```json\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n```";
1169        let parts = parse_text_tool_calls(text).unwrap();
1170        assert_eq!(parts.len(), 2);
1171        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
1172        match &parts[1] {
1173            Part::FunctionCall { name, args, .. } => {
1174                assert_eq!(name, "search");
1175                assert_eq!(args["q"], "rust");
1176            }
1177            _ => panic!("expected FunctionCall"),
1178        }
1179    }
1180
1181    #[test]
1182    fn test_boundary_whitespace_gemma4_preserved() {
1183        let text = "pre \n<|tool_call>call:get_weather{<|\"|>city<|\"|>:<|\"|>Tokyo<|\"|>}<tool_call|>\n post";
1184        let parts = parse_text_tool_calls(text).unwrap();
1185        assert_eq!(parts.len(), 3);
1186        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
1187        match &parts[1] {
1188            Part::FunctionCall { name, args, .. } => {
1189                assert_eq!(name, "get_weather");
1190                assert_eq!(args["city"], "Tokyo");
1191            }
1192            _ => panic!("expected FunctionCall"),
1193        }
1194        assert!(matches!(&parts[2], Part::Text { text } if text == "\n post"));
1195    }
1196
1197    #[test]
1198    fn test_boundary_whitespace_action_tag_preserved() {
1199        let text = "pre \n<|action_start|>  {\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}  <|action_end|>\n post";
1200        let parts = parse_text_tool_calls(text).unwrap();
1201        assert_eq!(parts.len(), 3);
1202        assert!(matches!(&parts[0], Part::Text { text } if text == "pre \n"));
1203        match &parts[1] {
1204            Part::FunctionCall { name, args, .. } => {
1205                assert_eq!(name, "search");
1206                assert_eq!(args["q"], "rust");
1207            }
1208            _ => panic!("expected FunctionCall"),
1209        }
1210        assert!(matches!(&parts[2], Part::Text { text } if text == "\n post"));
1211    }
1212}