Skip to main content

zeroclaw_tool_call_parser/
lib.rs

1//! Tool call parsing for LLM responses.
2//!
3//! Extracts structured tool calls from free-text LLM output. Handles a dozen
4//! different formats: JSON, XML `<tool_call>` tags, GLM-style shortened syntax,
5//! MiniMax `<invoke>` blocks, Perl-style `[TOOL_CALL]` blocks, markdown fences,
6//! OpenAI native format, and more.
7//!
8//! This crate has no dependency on agent state, memory, model_providers, or channels.
9//! It is pure text transformation.
10
11use regex::Regex;
12use std::{collections::HashSet, sync::LazyLock};
13
14/// A single parsed tool call extracted from LLM output.
15#[derive(Debug, Clone)]
16pub struct ParsedToolCall {
17    pub name: String,
18    pub arguments: serde_json::Value,
19    pub tool_call_id: Option<String>,
20}
21
22/// Internal tool protocol envelope variants that must not be treated as
23/// user-visible channel text.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ToolProtocolEnvelopeKind {
26    ToolCalls,
27    ToolCallsAlias,
28    FunctionCall,
29    ToolResult,
30    ResponsesFunctionCall,
31    TaggedToolCall,
32}
33
34fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
35    let initial = match raw {
36        Some(serde_json::Value::String(s)) => serde_json::from_str::<serde_json::Value>(s)
37            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
38        Some(value) => value.clone(),
39        None => serde_json::Value::Object(serde_json::Map::new()),
40    };
41    unwrap_nested_json_strings(initial)
42}
43
44/// Recursively unwrap stringified JSON objects/arrays nested inside tool arguments.
45/// Why: Gemini (and some other model_providers) sometimes double-encode nested object/array
46/// parameters as JSON strings inside the outer arguments payload, which breaks tools
47/// that expect `Value::Object` / `Value::Array` at those positions.
48fn unwrap_nested_json_strings(value: serde_json::Value) -> serde_json::Value {
49    match value {
50        serde_json::Value::Object(map) => {
51            let mut out = serde_json::Map::with_capacity(map.len());
52            for (k, v) in map {
53                out.insert(k, unwrap_nested_json_strings(v));
54            }
55            serde_json::Value::Object(out)
56        }
57        serde_json::Value::Array(items) => {
58            serde_json::Value::Array(items.into_iter().map(unwrap_nested_json_strings).collect())
59        }
60        serde_json::Value::String(s) => {
61            let trimmed = s.trim_start();
62            if trimmed.starts_with('{') || trimmed.starts_with('[') {
63                match serde_json::from_str::<serde_json::Value>(&s) {
64                    Ok(parsed) => unwrap_nested_json_strings(parsed),
65                    Err(_) => serde_json::Value::String(s),
66                }
67            } else {
68                serde_json::Value::String(s)
69            }
70        }
71        other => other,
72    }
73}
74
75fn parse_tool_call_id(
76    root: &serde_json::Value,
77    function: Option<&serde_json::Value>,
78) -> Option<String> {
79    function
80        .and_then(|func| func.get("id"))
81        .or_else(|| root.get("id"))
82        .or_else(|| root.get("tool_call_id"))
83        .or_else(|| root.get("call_id"))
84        .and_then(serde_json::Value::as_str)
85        .map(str::trim)
86        .filter(|id| !id.is_empty())
87        .map(ToString::to_string)
88}
89
90pub fn canonicalize_json_for_tool_signature(value: &serde_json::Value) -> serde_json::Value {
91    match value {
92        serde_json::Value::Object(map) => {
93            let mut keys: Vec<String> = map.keys().cloned().collect();
94            keys.sort_unstable();
95            let mut ordered = serde_json::Map::new();
96            for key in keys {
97                if let Some(child) = map.get(&key) {
98                    ordered.insert(key, canonicalize_json_for_tool_signature(child));
99                }
100            }
101            serde_json::Value::Object(ordered)
102        }
103        serde_json::Value::Array(items) => serde_json::Value::Array(
104            items
105                .iter()
106                .map(canonicalize_json_for_tool_signature)
107                .collect(),
108        ),
109        _ => value.clone(),
110    }
111}
112
113fn parse_tool_call_value(value: &serde_json::Value) -> Option<ParsedToolCall> {
114    if let Some(function) = value.get("function") {
115        let tool_call_id = parse_tool_call_id(value, Some(function));
116        let raw_name = function
117            .get("name")
118            .and_then(|v| v.as_str())
119            .unwrap_or("")
120            .trim();
121        let name = map_tool_name_alias(raw_name).to_string();
122        if !name.is_empty() {
123            let arguments = parse_arguments_value(
124                function
125                    .get("arguments")
126                    .or_else(|| function.get("parameters")),
127            );
128            return Some(ParsedToolCall {
129                name,
130                arguments,
131                tool_call_id,
132            });
133        }
134    }
135
136    let tool_call_id = parse_tool_call_id(value, None);
137    let raw_name = value
138        .get("name")
139        .and_then(|v| v.as_str())
140        .unwrap_or("")
141        .trim();
142    let name = map_tool_name_alias(raw_name).to_string();
143
144    if name.is_empty() {
145        return None;
146    }
147
148    let arguments =
149        parse_arguments_value(value.get("arguments").or_else(|| value.get("parameters")));
150    Some(ParsedToolCall {
151        name,
152        arguments,
153        tool_call_id,
154    })
155}
156
157fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec<ParsedToolCall> {
158    let mut calls = Vec::new();
159
160    if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
161        for call in tool_calls {
162            if let Some(parsed) = parse_tool_call_value(call) {
163                calls.push(parsed);
164            }
165        }
166
167        if !calls.is_empty() {
168            return calls;
169        }
170    }
171
172    if let Some(array) = value.as_array() {
173        for item in array {
174            if let Some(parsed) = parse_tool_call_value(item) {
175                calls.push(parsed);
176            }
177        }
178        return calls;
179    }
180
181    if let Some(parsed) = parse_tool_call_value(value) {
182        calls.push(parsed);
183    }
184
185    calls
186}
187
188fn has_non_empty_string(value: &serde_json::Value, key: &str) -> bool {
189    value
190        .get(key)
191        .and_then(serde_json::Value::as_str)
192        .is_some_and(|s| !s.trim().is_empty())
193}
194
195fn has_arguments_signal(value: &serde_json::Value) -> bool {
196    value.get("arguments").is_some() || value.get("parameters").is_some()
197}
198
199fn looks_like_tool_call_object(value: &serde_json::Value) -> bool {
200    if let Some(function) = value.get("function").and_then(serde_json::Value::as_object) {
201        let function = serde_json::Value::Object(function.clone());
202        return has_non_empty_string(&function, "name") && has_arguments_signal(&function);
203    }
204
205    has_non_empty_string(value, "name") && has_arguments_signal(value)
206}
207
208fn tool_call_array_has_protocol_shape(value: &serde_json::Value, key: &str) -> bool {
209    value
210        .get(key)
211        .and_then(serde_json::Value::as_array)
212        .is_some_and(|items| !items.is_empty() && items.iter().any(looks_like_tool_call_object))
213}
214
215fn has_tool_protocol_object_signal(value: &serde_json::Value) -> bool {
216    let Some(object) = value.as_object() else {
217        return false;
218    };
219
220    let has_args = has_arguments_signal(value);
221    let has_call_id = has_non_empty_string(value, "id")
222        || has_non_empty_string(value, "call_id")
223        || has_non_empty_string(value, "tool_call_id");
224
225    object
226        .get("function")
227        .and_then(serde_json::Value::as_object)
228        .is_some()
229        || (has_non_empty_string(value, "name") && has_args)
230        || (has_args && has_call_id)
231}
232
233fn tool_call_array_has_malformed_protocol_signal(value: &serde_json::Value, key: &str) -> bool {
234    value
235        .get(key)
236        .and_then(serde_json::Value::as_array)
237        .is_some_and(|items| !items.is_empty() && items.iter().any(has_tool_protocol_object_signal))
238}
239
240fn classify_tool_protocol_json_value(
241    value: &serde_json::Value,
242) -> Option<ToolProtocolEnvelopeKind> {
243    if value
244        .get("type")
245        .and_then(serde_json::Value::as_str)
246        .is_some_and(|ty| ty == "function_call")
247        && has_non_empty_string(value, "name")
248        && (has_arguments_signal(value) || has_non_empty_string(value, "call_id"))
249    {
250        return Some(ToolProtocolEnvelopeKind::ResponsesFunctionCall);
251    }
252
253    if tool_call_array_has_protocol_shape(value, "tool_calls") {
254        return Some(ToolProtocolEnvelopeKind::ToolCalls);
255    }
256
257    if tool_call_array_has_protocol_shape(value, "toolcalls") {
258        return Some(ToolProtocolEnvelopeKind::ToolCallsAlias);
259    }
260
261    if value
262        .get("function_call")
263        .is_some_and(looks_like_tool_call_object)
264    {
265        return Some(ToolProtocolEnvelopeKind::FunctionCall);
266    }
267
268    if has_non_empty_string(value, "tool_call_id")
269        && (value.get("content").is_some()
270            || value.get("result").is_some()
271            || value.get("output").is_some())
272    {
273        return Some(ToolProtocolEnvelopeKind::ToolResult);
274    }
275
276    None
277}
278
279fn json_value_mentions_known_tool(
280    value: &serde_json::Value,
281    known_tool_names: &HashSet<String>,
282) -> bool {
283    if known_tool_names.is_empty() {
284        return false;
285    }
286
287    let Some(object) = value.as_object() else {
288        return value.as_array().is_some_and(|items| {
289            items
290                .iter()
291                .any(|item| json_value_mentions_known_tool(item, known_tool_names))
292        });
293    };
294
295    let name_matches = |candidate: Option<&serde_json::Value>| {
296        candidate
297            .and_then(serde_json::Value::as_str)
298            .map(str::trim)
299            .filter(|name| !name.is_empty())
300            .is_some_and(|name| known_tool_names.contains(&name.to_ascii_lowercase()))
301    };
302
303    if name_matches(object.get("name")) {
304        return true;
305    }
306
307    if let Some(function) = object
308        .get("function")
309        .and_then(serde_json::Value::as_object)
310    {
311        let function = serde_json::Value::Object(function.clone());
312        if json_value_mentions_known_tool(&function, known_tool_names) {
313            return true;
314        }
315    }
316
317    if let Some(function_call) = object.get("function_call")
318        && json_value_mentions_known_tool(function_call, known_tool_names)
319    {
320        return true;
321    }
322
323    ["tool_calls", "toolcalls"].iter().any(|key| {
324        object
325            .get(*key)
326            .and_then(serde_json::Value::as_array)
327            .is_some_and(|items| {
328                items
329                    .iter()
330                    .any(|item| json_value_mentions_known_tool(item, known_tool_names))
331            })
332    })
333}
334
335pub fn tool_protocol_envelope_mentions_known_tool(
336    text: &str,
337    known_tool_names: &HashSet<String>,
338) -> bool {
339    if known_tool_names.is_empty() {
340        return false;
341    }
342
343    let trimmed = text.trim();
344    if trimmed.is_empty() {
345        return false;
346    }
347
348    if let Some(body) = json_fence_body(trimmed) {
349        return tool_protocol_envelope_mentions_known_tool(body, known_tool_names);
350    }
351
352    if starts_with_tool_protocol_tag_or_fence(trimmed) || contains_tool_protocol_tag_marker(trimmed)
353    {
354        let (_, calls) = parse_tool_calls(trimmed);
355        if calls
356            .iter()
357            .any(|call| known_tool_names.contains(&call.name.to_ascii_lowercase()))
358        {
359            return true;
360        }
361    }
362
363    serde_json::from_str::<serde_json::Value>(trimmed)
364        .is_ok_and(|value| json_value_mentions_known_tool(&value, known_tool_names))
365}
366
367fn has_malformed_tool_protocol_json_signal(value: &serde_json::Value) -> bool {
368    // Empty `tool_calls: []` is a valid strict-provider compatibility case;
369    // similar business JSON must also carry protocol-shaped fields before it
370    // is withheld from user-visible output.
371    tool_call_array_has_malformed_protocol_signal(value, "tool_calls")
372        || tool_call_array_has_malformed_protocol_signal(value, "toolcalls")
373        || value
374            .get("function_call")
375            .is_some_and(has_tool_protocol_object_signal)
376        || (value
377            .get("type")
378            .and_then(serde_json::Value::as_str)
379            .is_some_and(|ty| ty == "function_call")
380            && (has_non_empty_string(value, "name")
381                || has_non_empty_string(value, "call_id")
382                || has_arguments_signal(value)))
383        || (has_non_empty_string(value, "tool_call_id")
384            && (value.get("content").is_some()
385                || value.get("result").is_some()
386                || value.get("output").is_some()))
387}
388
389fn starts_with_tool_protocol_tag_or_fence(text: &str) -> bool {
390    let lower = text.trim_start().to_ascii_lowercase();
391    lower.starts_with("<tool_call")
392        || lower.starts_with("<toolcall")
393        || lower.starts_with("<tool-call")
394        || lower.starts_with("<invoke")
395        || lower.starts_with("<functioncall")
396        || lower.starts_with("<function_call")
397        || starts_with_tool_protocol_fence_lower(&lower)
398        || lower.starts_with("[tool_call]")
399}
400
401fn starts_with_tool_protocol_fence(text: &str) -> bool {
402    let lower = text.trim_start().to_ascii_lowercase();
403    starts_with_tool_protocol_fence_lower(&lower)
404}
405
406fn starts_with_tool_protocol_fence_lower(lower: &str) -> bool {
407    lower.starts_with("```tool_call")
408        || lower.starts_with("```toolcall")
409        || lower.starts_with("```tool-call")
410        || lower.starts_with("```invoke")
411        || starts_with_tool_name_fence_lower(lower)
412}
413
414fn starts_with_tool_name_fence_lower(lower: &str) -> bool {
415    let Some(rest) = lower.strip_prefix("```tool") else {
416        return false;
417    };
418    matches!(rest.chars().next(), Some(c) if c.is_whitespace() && c != '\n' && c != '\r')
419}
420
421fn contains_tool_protocol_tag_marker(text: &str) -> bool {
422    let lower = text.to_ascii_lowercase();
423    lower.contains("<tool_call")
424        || lower.contains("<toolcall")
425        || lower.contains("<tool-call")
426        || lower.contains("<invoke")
427        || lower.contains("<functioncall")
428        || lower.contains("<function_call")
429        || lower.contains("```tool_call")
430        || lower.contains("```toolcall")
431        || lower.contains("```tool-call")
432        || lower.contains("```invoke")
433        || lower.contains("```tool ")
434        || lower.contains("[tool_call]")
435}
436
437pub fn looks_like_tool_protocol_example(text: &str) -> bool {
438    let trimmed = text.trim();
439    if trimmed.is_empty() {
440        return false;
441    }
442
443    if let Some((body, visible_text)) = leading_json_fence_body_and_trailing_text(trimmed)
444        && classify_tool_protocol_envelope(body).is_some()
445        && has_example_context(visible_text)
446    {
447        return true;
448    }
449
450    if starts_with_tool_protocol_fence(trimmed) || contains_tool_protocol_tag_marker(trimmed) {
451        let (visible_text, calls) = parse_tool_calls(trimmed);
452        if !calls.is_empty() && has_example_context(&visible_text) {
453            return true;
454        }
455    }
456
457    false
458}
459
460fn has_example_context(text: &str) -> bool {
461    let lower = text.to_ascii_lowercase();
462    lower.contains("example")
463        || lower.contains("sample")
464        || lower.contains("示例")
465        // Common Chinese "for example" / "sample" markers. We keep this list
466        // intentionally small to avoid accidentally exempting real protocol leaks.
467        || lower.contains("例如")
468        || lower.contains("比如")
469        || lower.contains("举例")
470        || lower.contains("例子")
471        || lower.contains("比方说")
472        || lower.contains("譬如")
473}
474
475fn leading_json_fence_body_and_trailing_text(trimmed: &str) -> Option<(&str, &str)> {
476    let rest = trimmed.strip_prefix("```")?;
477    let first_newline = rest.find('\n')?;
478    let language = rest[..first_newline].trim().trim_end_matches('\r');
479    if !language.eq_ignore_ascii_case("json") {
480        return None;
481    }
482
483    let body_with_close = &rest[first_newline + 1..];
484    let close_start = body_with_close.find("```")?;
485    let body = body_with_close[..close_start].trim();
486    let trailing = body_with_close[close_start + 3..].trim();
487    (!body.is_empty() && !trailing.is_empty()).then_some((body, trailing))
488}
489
490pub fn contains_tool_protocol_tag_call(text: &str) -> bool {
491    if !contains_tool_protocol_tag_marker(text) || looks_like_tool_protocol_example(text) {
492        return false;
493    }
494
495    let (_, calls) = parse_tool_calls(text);
496    !calls.is_empty()
497}
498
499fn classify_tagged_tool_protocol_envelope(text: &str) -> Option<ToolProtocolEnvelopeKind> {
500    if !starts_with_tool_protocol_tag_or_fence(text) {
501        return None;
502    }
503    if looks_like_tool_protocol_example(text) {
504        return None;
505    }
506
507    let is_fence = starts_with_tool_protocol_fence(text);
508    let (visible_text, calls) = parse_tool_calls(text);
509    (!calls.is_empty() && (is_fence || visible_text.trim().is_empty()))
510        .then_some(ToolProtocolEnvelopeKind::TaggedToolCall)
511}
512
513fn looks_like_malformed_tagged_tool_protocol_envelope(text: &str) -> bool {
514    if !starts_with_tool_protocol_tag_or_fence(text) {
515        return false;
516    }
517    if looks_like_tool_protocol_example(text) {
518        return false;
519    }
520
521    let (visible_text, calls) = parse_tool_calls(text);
522    if !calls.is_empty() || !visible_text.trim().is_empty() {
523        return false;
524    }
525
526    let lower = text.to_ascii_lowercase();
527    lower.contains("arguments")
528        || lower.contains("parameters")
529        || lower.contains("function")
530        || lower.contains("name")
531        || lower.contains("call_id")
532        || lower.contains("tool_call_id")
533}
534
535fn has_malformed_tool_protocol_text_signal(text: &str) -> bool {
536    let trimmed = text.trim_start();
537    let lower = trimmed.to_ascii_lowercase();
538    let json_like =
539        trimmed.starts_with('{') || trimmed.starts_with('[') || lower.starts_with("```json");
540    if !json_like {
541        return false;
542    }
543
544    // Malformed text cannot be parsed into a Value, so keep the tool-result
545    // signal close to the valid-envelope shape to avoid business JSON false positives.
546    let has_tool_result_shape = text.contains("\"tool_call_id\"")
547        && (text.contains("\"content\"")
548            || text.contains("\"result\"")
549            || text.contains("\"output\""));
550    let has_protocol_container = text.contains("\"tool_calls\"")
551        || text.contains("\"toolcalls\"")
552        || text.contains("\"function_call\"");
553    let has_arguments = text.contains("\"arguments\"") || text.contains("\"parameters\"");
554    let has_call_id = text.contains("\"call_id\"") || text.contains("\"tool_call_id\"");
555
556    has_tool_result_shape || (has_protocol_container && has_arguments && has_call_id)
557}
558
559fn malformed_text_mentions_known_tool(text: &str, known_tool_names: &HashSet<String>) -> bool {
560    if known_tool_names.is_empty() {
561        return false;
562    }
563
564    static JSON_NAME_FIELD_RE: LazyLock<Regex> =
565        LazyLock::new(|| Regex::new(r#""name"\s*:\s*"([^"]+)""#).unwrap());
566
567    JSON_NAME_FIELD_RE.captures_iter(text).any(|cap| {
568        cap.get(1)
569            .map(|name| name.as_str().trim().to_ascii_lowercase())
570            .is_some_and(|name| known_tool_names.contains(&name))
571    })
572}
573
574fn has_malformed_tool_protocol_text_signal_for_known_tools(
575    text: &str,
576    known_tool_names: &HashSet<String>,
577) -> bool {
578    if has_malformed_tool_protocol_text_signal(text) {
579        return true;
580    }
581
582    let trimmed = text.trim_start();
583    let lower = trimmed.to_ascii_lowercase();
584    let json_like =
585        trimmed.starts_with('{') || trimmed.starts_with('[') || lower.starts_with("```json");
586    if !json_like {
587        return false;
588    }
589
590    let has_protocol_container = text.contains("\"tool_calls\"")
591        || text.contains("\"toolcalls\"")
592        || text.contains("\"function_call\"");
593    let has_arguments = text.contains("\"arguments\"") || text.contains("\"parameters\"");
594
595    has_protocol_container
596        && has_arguments
597        && malformed_text_mentions_known_tool(text, known_tool_names)
598}
599
600fn json_fence_body(trimmed: &str) -> Option<&str> {
601    let rest = trimmed.strip_prefix("```")?;
602    let first_newline = rest.find('\n')?;
603    let language = rest[..first_newline].trim().trim_end_matches('\r');
604    if !language.eq_ignore_ascii_case("json") {
605        return None;
606    }
607
608    let body_with_close = &rest[first_newline + 1..];
609    let close_start = body_with_close.rfind("```")?;
610    if !body_with_close[close_start + 3..].trim().is_empty() {
611        return None;
612    }
613    Some(body_with_close[..close_start].trim())
614}
615
616pub fn classify_tool_protocol_envelope(text: &str) -> Option<ToolProtocolEnvelopeKind> {
617    let trimmed = text.trim();
618    if trimmed.is_empty() {
619        return None;
620    }
621
622    if let Some(kind) = classify_tagged_tool_protocol_envelope(trimmed) {
623        return Some(kind);
624    }
625
626    if let Some(body) = json_fence_body(trimmed) {
627        return classify_tool_protocol_envelope(body);
628    }
629
630    let value = serde_json::from_str::<serde_json::Value>(trimmed).ok()?;
631    classify_tool_protocol_json_value(&value)
632}
633
634pub fn looks_like_tool_protocol_envelope(text: &str) -> bool {
635    let trimmed = text.trim();
636    if trimmed.is_empty() {
637        return false;
638    }
639
640    if classify_tool_protocol_envelope(trimmed).is_some() {
641        return true;
642    }
643
644    if let Some(body) = json_fence_body(trimmed) {
645        return looks_like_tool_protocol_envelope(body);
646    }
647
648    serde_json::from_str::<serde_json::Value>(trimmed)
649        .is_ok_and(|value| has_malformed_tool_protocol_json_signal(&value))
650}
651
652pub fn looks_like_malformed_tool_protocol_envelope(text: &str) -> bool {
653    let trimmed = text.trim();
654    if looks_like_tool_protocol_example(trimmed) {
655        return false;
656    }
657
658    if looks_like_malformed_tagged_tool_protocol_envelope(trimmed) {
659        return true;
660    }
661
662    let lower = trimmed.to_ascii_lowercase();
663    let json_like =
664        trimmed.starts_with('{') || trimmed.starts_with('[') || lower.starts_with("```json");
665    if trimmed.is_empty() || !json_like {
666        return false;
667    }
668
669    if let Some(body) = json_fence_body(trimmed) {
670        return looks_like_malformed_tool_protocol_envelope(body);
671    }
672
673    if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
674        return false;
675    }
676
677    has_malformed_tool_protocol_text_signal(trimmed)
678}
679
680pub fn looks_like_malformed_tool_protocol_envelope_for_known_tools(
681    text: &str,
682    known_tool_names: &HashSet<String>,
683) -> bool {
684    let trimmed = text.trim();
685    if looks_like_tool_protocol_example(trimmed) {
686        return false;
687    }
688
689    if looks_like_malformed_tool_protocol_envelope(trimmed) {
690        return true;
691    }
692
693    let lower = trimmed.to_ascii_lowercase();
694    let json_like =
695        trimmed.starts_with('{') || trimmed.starts_with('[') || lower.starts_with("```json");
696    if trimmed.is_empty() || !json_like {
697        return false;
698    }
699
700    if let Some(body) = json_fence_body(trimmed) {
701        return looks_like_malformed_tool_protocol_envelope_for_known_tools(body, known_tool_names);
702    }
703
704    if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
705        return false;
706    }
707
708    has_malformed_tool_protocol_text_signal_for_known_tools(trimmed, known_tool_names)
709}
710
711fn is_xml_meta_tag(tag: &str) -> bool {
712    let normalized = tag.to_ascii_lowercase();
713    matches!(
714        normalized.as_str(),
715        "tool_call"
716            | "toolcall"
717            | "tool-call"
718            | "invoke"
719            | "thinking"
720            | "thought"
721            | "analysis"
722            | "reasoning"
723            | "reflection"
724    )
725}
726
727/// Match opening XML tags: `<tag_name>`.  Does NOT use backreferences.
728static XML_OPEN_TAG_RE: LazyLock<Regex> =
729    LazyLock::new(|| Regex::new(r"<([a-zA-Z_][a-zA-Z0-9_-]*)>").unwrap());
730
731/// MiniMax XML invoke format:
732/// `<invoke name="shell"><parameter name="command">pwd</parameter></invoke>`
733static MINIMAX_INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
734    Regex::new(r#"(?is)<invoke\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</invoke>"#)
735        .unwrap()
736});
737
738static MINIMAX_PARAMETER_RE: LazyLock<Regex> = LazyLock::new(|| {
739    Regex::new(
740        r#"(?is)<parameter\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</parameter>"#,
741    )
742    .unwrap()
743});
744
745/// Extracts all `<tag>…</tag>` pairs from `input`, returning `(tag_name, inner_content)`.
746/// Handles matching closing tags without regex backreferences.
747fn extract_xml_pairs(input: &str) -> Vec<(&str, &str)> {
748    let mut results = Vec::new();
749    let mut search_start = 0;
750    while let Some(open_cap) = XML_OPEN_TAG_RE.captures(&input[search_start..]) {
751        let full_open = open_cap.get(0).unwrap();
752        let tag_name = open_cap.get(1).unwrap().as_str();
753        let open_end = search_start + full_open.end();
754
755        let closing_tag = format!("</{tag_name}>");
756        if let Some(close_pos) = input[open_end..].find(&closing_tag) {
757            let inner = &input[open_end..open_end + close_pos];
758            results.push((tag_name, inner.trim()));
759            search_start = open_end + close_pos + closing_tag.len();
760        } else {
761            search_start = open_end;
762        }
763    }
764    results
765}
766
767/// Parse XML-style tool calls in `<tool_call>` bodies.
768/// Supports both nested argument tags and JSON argument payloads:
769/// - `<memory_recall><query>...</query></memory_recall>`
770/// - `<shell>{"command":"pwd"}</shell>`
771fn parse_xml_tool_calls(xml_content: &str) -> Option<Vec<ParsedToolCall>> {
772    let mut calls = Vec::new();
773    let trimmed = xml_content.trim();
774
775    if !trimmed.starts_with('<') || !trimmed.contains('>') {
776        return None;
777    }
778
779    for (tool_name_str, inner_content) in extract_xml_pairs(trimmed) {
780        let tool_name = tool_name_str.to_string();
781        if is_xml_meta_tag(&tool_name) {
782            continue;
783        }
784
785        if inner_content.is_empty() {
786            continue;
787        }
788
789        let mut args = serde_json::Map::new();
790
791        if let Some(first_json) = extract_json_values(inner_content).into_iter().next() {
792            match first_json {
793                serde_json::Value::Object(object_args) => {
794                    args = object_args;
795                }
796                other => {
797                    args.insert("value".to_string(), other);
798                }
799            }
800        } else {
801            for (key_str, value) in extract_xml_pairs(inner_content) {
802                let key = key_str.to_string();
803                if is_xml_meta_tag(&key) {
804                    continue;
805                }
806                if !value.is_empty() {
807                    args.insert(key, serde_json::Value::String(value.to_string()));
808                }
809            }
810
811            if args.is_empty() {
812                args.insert(
813                    "content".to_string(),
814                    serde_json::Value::String(inner_content.to_string()),
815                );
816            }
817        }
818
819        calls.push(ParsedToolCall {
820            name: tool_name,
821            arguments: serde_json::Value::Object(args),
822            tool_call_id: None,
823        });
824    }
825
826    if calls.is_empty() { None } else { Some(calls) }
827}
828
829/// Parse MiniMax-style XML tool calls with attributed invoke/parameter tags.
830fn parse_minimax_invoke_calls(response: &str) -> Option<(String, Vec<ParsedToolCall>)> {
831    let mut calls = Vec::new();
832    let mut text_parts = Vec::new();
833    let mut last_end = 0usize;
834
835    for cap in MINIMAX_INVOKE_RE.captures_iter(response) {
836        let Some(full_match) = cap.get(0) else {
837            continue;
838        };
839
840        let before = response[last_end..full_match.start()].trim();
841        if !before.is_empty() {
842            text_parts.push(before.to_string());
843        }
844
845        let name = cap
846            .get(1)
847            .or_else(|| cap.get(2))
848            .map(|m| m.as_str().trim())
849            .filter(|v| !v.is_empty());
850        let body = cap.get(3).map(|m| m.as_str()).unwrap_or("").trim();
851        last_end = full_match.end();
852
853        let Some(name) = name else {
854            continue;
855        };
856
857        let mut args = serde_json::Map::new();
858        for param_cap in MINIMAX_PARAMETER_RE.captures_iter(body) {
859            let key = param_cap
860                .get(1)
861                .or_else(|| param_cap.get(2))
862                .map(|m| m.as_str().trim())
863                .unwrap_or_default();
864            if key.is_empty() {
865                continue;
866            }
867            let value = param_cap
868                .get(3)
869                .map(|m| m.as_str().trim())
870                .unwrap_or_default();
871            if value.is_empty() {
872                continue;
873            }
874
875            let parsed = extract_json_values(value).into_iter().next();
876            args.insert(
877                key.to_string(),
878                parsed.unwrap_or_else(|| serde_json::Value::String(value.to_string())),
879            );
880        }
881
882        if args.is_empty() {
883            if let Some(first_json) = extract_json_values(body).into_iter().next() {
884                match first_json {
885                    serde_json::Value::Object(obj) => args = obj,
886                    other => {
887                        args.insert("value".to_string(), other);
888                    }
889                }
890            } else if !body.is_empty() {
891                args.insert(
892                    "content".to_string(),
893                    serde_json::Value::String(body.to_string()),
894                );
895            }
896        }
897
898        calls.push(ParsedToolCall {
899            name: name.to_string(),
900            arguments: serde_json::Value::Object(args),
901            tool_call_id: None,
902        });
903    }
904
905    if calls.is_empty() {
906        return None;
907    }
908
909    let after = response[last_end..].trim();
910    if !after.is_empty() {
911        text_parts.push(after.to_string());
912    }
913
914    let text = text_parts
915        .join("\n")
916        .replace("<minimax:tool_call>", "")
917        .replace("</minimax:tool_call>", "")
918        .replace("<minimax:toolcall>", "")
919        .replace("</minimax:toolcall>", "")
920        .trim()
921        .to_string();
922
923    Some((text, calls))
924}
925
926const TOOL_CALL_OPEN_TAGS: [&str; 7] = [
927    "<tool_call>",
928    "<tool_calls>",
929    "<toolcall>",
930    "<tool-call>",
931    "<invoke>",
932    "<minimax:tool_call>",
933    "<minimax:toolcall>",
934];
935
936const TOOL_CALL_CLOSE_TAGS: [&str; 7] = [
937    "</tool_call>",
938    "</tool_calls>",
939    "</toolcall>",
940    "</tool-call>",
941    "</invoke>",
942    "</minimax:tool_call>",
943    "</minimax:toolcall>",
944];
945
946fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
947    tags.iter()
948        .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
949        .min_by_key(|(idx, _)| *idx)
950}
951
952fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> {
953    let trimmed = input.trim_start();
954    let trim_offset = input.len().saturating_sub(trimmed.len());
955
956    for (byte_idx, ch) in trimmed.char_indices() {
957        if ch != '{' && ch != '[' {
958            continue;
959        }
960
961        let slice = &trimmed[byte_idx..];
962        let mut stream = serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
963        if let Some(Ok(value)) = stream.next() {
964            let consumed = stream.byte_offset();
965            if consumed > 0 {
966                return Some((value, trim_offset + byte_idx + consumed));
967            }
968        }
969    }
970
971    None
972}
973
974fn strip_leading_close_tags(mut input: &str) -> &str {
975    loop {
976        let trimmed = input.trim_start();
977        if !trimmed.starts_with("</") {
978            return trimmed;
979        }
980
981        let Some(close_end) = trimmed.find('>') else {
982            return "";
983        };
984        input = &trimmed[close_end + 1..];
985    }
986}
987
988/// Extract JSON values from a string.
989///
990/// # Security Warning
991///
992/// This function extracts ANY JSON objects/arrays from the input. It MUST only
993/// be used on content that is already trusted to be from the LLM, such as
994/// content inside `<invoke>` tags where the LLM has explicitly indicated intent
995/// to make a tool call. Do NOT use this on raw user input or content that
996/// could contain prompt injection payloads.
997fn extract_json_values(input: &str) -> Vec<serde_json::Value> {
998    let mut values = Vec::new();
999    let trimmed = input.trim();
1000    if trimmed.is_empty() {
1001        return values;
1002    }
1003
1004    if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1005        values.push(value);
1006        return values;
1007    }
1008
1009    let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect();
1010    let mut idx = 0;
1011    while idx < char_positions.len() {
1012        let (byte_idx, ch) = char_positions[idx];
1013        if ch == '{' || ch == '[' {
1014            let slice = &trimmed[byte_idx..];
1015            let mut stream =
1016                serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
1017            if let Some(Ok(value)) = stream.next() {
1018                let consumed = stream.byte_offset();
1019                if consumed > 0 {
1020                    values.push(value);
1021                    let next_byte = byte_idx + consumed;
1022                    while idx < char_positions.len() && char_positions[idx].0 < next_byte {
1023                        idx += 1;
1024                    }
1025                    continue;
1026                }
1027            }
1028        }
1029        idx += 1;
1030    }
1031
1032    values
1033}
1034
1035/// Find the end position of a JSON object by tracking balanced braces.
1036fn find_json_end(input: &str) -> Option<usize> {
1037    let trimmed = input.trim_start();
1038    let offset = input.len() - trimmed.len();
1039
1040    if !trimmed.starts_with('{') {
1041        return None;
1042    }
1043
1044    let mut depth = 0;
1045    let mut in_string = false;
1046    let mut escape_next = false;
1047
1048    for (i, ch) in trimmed.char_indices() {
1049        if escape_next {
1050            escape_next = false;
1051            continue;
1052        }
1053
1054        match ch {
1055            '\\' if in_string => escape_next = true,
1056            '"' => in_string = !in_string,
1057            '{' if !in_string => depth += 1,
1058            '}' if !in_string => {
1059                depth -= 1;
1060                if depth == 0 {
1061                    return Some(offset + i + ch.len_utf8());
1062                }
1063            }
1064            _ => {}
1065        }
1066    }
1067
1068    None
1069}
1070
1071/// Parse XML attribute-style tool calls from response text.
1072/// This handles MiniMax and similar model_providers that output:
1073/// ```xml
1074/// <minimax:toolcall>
1075/// <invoke name="shell">
1076/// <parameter name="command">ls</parameter>
1077/// </invoke>
1078/// </minimax:toolcall>
1079/// ```
1080fn parse_xml_attribute_tool_calls(response: &str) -> Vec<ParsedToolCall> {
1081    let mut calls = Vec::new();
1082
1083    // Regex to find <invoke name="toolname">...</invoke> blocks
1084    static INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
1085        Regex::new(r#"(?s)<invoke\s+name="([^"]+)"[^>]*>(.*?)</invoke>"#).unwrap()
1086    });
1087
1088    // Regex to find <parameter name="paramname">value</parameter>
1089    static PARAM_RE: LazyLock<Regex> = LazyLock::new(|| {
1090        Regex::new(r#"<parameter\s+name="([^"]+)"[^>]*>([^<]*)</parameter>"#).unwrap()
1091    });
1092
1093    for cap in INVOKE_RE.captures_iter(response) {
1094        let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
1095        let inner = cap.get(2).map(|m| m.as_str()).unwrap_or("");
1096
1097        if tool_name.is_empty() {
1098            continue;
1099        }
1100
1101        let mut arguments = serde_json::Map::new();
1102
1103        for param_cap in PARAM_RE.captures_iter(inner) {
1104            let param_name = param_cap.get(1).map(|m| m.as_str()).unwrap_or("");
1105            let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or("");
1106
1107            if !param_name.is_empty() {
1108                arguments.insert(
1109                    param_name.to_string(),
1110                    serde_json::Value::String(param_value.to_string()),
1111                );
1112            }
1113        }
1114
1115        if !arguments.is_empty() {
1116            calls.push(ParsedToolCall {
1117                name: map_tool_name_alias(tool_name).to_string(),
1118                arguments: serde_json::Value::Object(arguments),
1119                tool_call_id: None,
1120            });
1121        }
1122    }
1123
1124    calls
1125}
1126
1127/// Parse Perl/hash-ref style tool calls from response text.
1128/// This handles formats like:
1129/// ```text
1130/// TOOL_CALL
1131/// {tool => "shell", args => {
1132///   --command "ls -la"
1133///   --description "List current directory contents"
1134/// }}
1135/// /TOOL_CALL
1136/// ```
1137/// Also handles the square bracket variant emitted by models like MiniMax 2.7:
1138/// ```text
1139/// [TOOL_CALL]{tool => "shell", args => {--command "echo hello"}}[/TOOL_CALL]
1140/// ```
1141fn parse_perl_style_tool_calls(response: &str) -> Vec<ParsedToolCall> {
1142    let mut calls = Vec::new();
1143
1144    // Regex to find TOOL_CALL blocks - handle double closing braces }}
1145    // Matches both `TOOL_CALL { ... }} /TOOL_CALL` and `[TOOL_CALL]{ ... }}[/TOOL_CALL]`
1146    static PERL_RE: LazyLock<Regex> = LazyLock::new(|| {
1147        Regex::new(r"(?s)(?:\[TOOL_CALL\]|TOOL_CALL)\s*\{(.+?)\}\}\s*(?:\[/TOOL_CALL\]|/TOOL_CALL)")
1148            .unwrap()
1149    });
1150
1151    // Regex to find tool => "name" in the content
1152    static TOOL_NAME_RE: LazyLock<Regex> =
1153        LazyLock::new(|| Regex::new(r#"tool\s*=>\s*"([^"]+)""#).unwrap());
1154
1155    // Regex to find args => { ... } block.
1156    // The closing brace is optional: in the square bracket variant [TOOL_CALL]{...}}[/TOOL_CALL]
1157    // the outer regex may consume the inner closing brace, so the args content may run to end of string.
1158    static ARGS_BLOCK_RE: LazyLock<Regex> =
1159        LazyLock::new(|| Regex::new(r"(?s)args\s*=>\s*\{(.+?)(?:\}|$)").unwrap());
1160
1161    // Regex to find --key "value" pairs
1162    static ARGS_RE: LazyLock<Regex> =
1163        LazyLock::new(|| Regex::new(r#"--(\w+)\s+"([^"]+)""#).unwrap());
1164
1165    for cap in PERL_RE.captures_iter(response) {
1166        let content = cap.get(1).map(|m| m.as_str()).unwrap_or("");
1167
1168        // Extract tool name
1169        let tool_name = TOOL_NAME_RE
1170            .captures(content)
1171            .and_then(|c| c.get(1))
1172            .map(|m| m.as_str())
1173            .unwrap_or("");
1174
1175        if tool_name.is_empty() {
1176            continue;
1177        }
1178
1179        // Extract args block
1180        let args_block = ARGS_BLOCK_RE
1181            .captures(content)
1182            .and_then(|c| c.get(1))
1183            .map(|m| m.as_str())
1184            .unwrap_or("");
1185
1186        let mut arguments = serde_json::Map::new();
1187
1188        for arg_cap in ARGS_RE.captures_iter(args_block) {
1189            let key = arg_cap.get(1).map(|m| m.as_str()).unwrap_or("");
1190            let value = arg_cap.get(2).map(|m| m.as_str()).unwrap_or("");
1191
1192            if !key.is_empty() {
1193                arguments.insert(
1194                    key.to_string(),
1195                    serde_json::Value::String(value.to_string()),
1196                );
1197            }
1198        }
1199
1200        if !arguments.is_empty() {
1201            calls.push(ParsedToolCall {
1202                name: map_tool_name_alias(tool_name).to_string(),
1203                arguments: serde_json::Value::Object(arguments),
1204                tool_call_id: None,
1205            });
1206        }
1207    }
1208
1209    calls
1210}
1211
1212/// Parse FunctionCall-style tool calls from response text.
1213/// This handles formats like:
1214/// ```text
1215/// <FunctionCall>
1216/// file_read
1217/// <code>path>/Users/kylelampa/Documents/zeroclaw/README.md</code>
1218/// </FunctionCall>
1219/// ```
1220fn parse_function_call_tool_calls(response: &str) -> Vec<ParsedToolCall> {
1221    let mut calls = Vec::new();
1222
1223    // Regex to find <FunctionCall> blocks
1224    static FUNC_RE: LazyLock<Regex> = LazyLock::new(|| {
1225        Regex::new(r"(?s)<FunctionCall>\s*(\w+)\s*<code>([^<]+)</code>\s*</FunctionCall>").unwrap()
1226    });
1227
1228    for cap in FUNC_RE.captures_iter(response) {
1229        let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
1230        let args_text = cap.get(2).map(|m| m.as_str()).unwrap_or("");
1231
1232        if tool_name.is_empty() {
1233            continue;
1234        }
1235
1236        // Parse key>value pairs (e.g., path>/Users/.../file.txt)
1237        let mut arguments = serde_json::Map::new();
1238        for line in args_text.lines() {
1239            let line = line.trim();
1240            if let Some(pos) = line.find('>') {
1241                let key = line[..pos].trim();
1242                let value = line[pos + 1..].trim();
1243                if !key.is_empty() && !value.is_empty() {
1244                    arguments.insert(
1245                        key.to_string(),
1246                        serde_json::Value::String(value.to_string()),
1247                    );
1248                }
1249            }
1250        }
1251
1252        if !arguments.is_empty() {
1253            calls.push(ParsedToolCall {
1254                name: map_tool_name_alias(tool_name).to_string(),
1255                arguments: serde_json::Value::Object(arguments),
1256                tool_call_id: None,
1257            });
1258        }
1259    }
1260
1261    calls
1262}
1263
1264/// Parse GLM-style tool calls from response text.
1265/// Map tool name aliases from various LLM model_providers to ZeroClaw tool names.
1266/// This handles variations like "fileread" -> "file_read", "bash" -> "shell", etc.
1267fn map_tool_name_alias(tool_name: &str) -> &str {
1268    // Strip any dotted namespace prefix (keep only the final segment).
1269    // Covers Gemini-emitted `default_api.<name>` and `tools.<name>`, plus
1270    // MCP-server-name prefixes like `google_workspace.search_gmail_messages`
1271    // that Gemini-via-OpenRouter also emits when the tool originates from
1272    // an MCP server. The registry is indexed by bare tool name, so we
1273    // normalize by taking the last segment.
1274    let tool_name = tool_name
1275        .rsplit_once('.')
1276        .map(|(_, suffix)| suffix)
1277        .unwrap_or(tool_name);
1278    match tool_name {
1279        // Shell variations (including GLM aliases that map to shell)
1280        "shell" | "bash" | "sh" | "exec" | "command" | "cmd" | "browser_open" | "browser"
1281        | "web_search" => "shell",
1282        // Messaging variations
1283        "send_message" | "sendmessage" => "message_send",
1284        // File tool variations
1285        "fileread" | "file_read" | "readfile" | "read_file" | "file" => "file_read",
1286        "filewrite" | "file_write" | "writefile" | "write_file" => "file_write",
1287        "filelist" | "file_list" | "listfiles" | "list_files" => "file_list",
1288        // Memory variations
1289        "memoryrecall" | "memory_recall" | "recall" | "memrecall" => "memory_recall",
1290        "memorystore" | "memory_store" | "store" | "memstore" => "memory_store",
1291        "memoryforget" | "memory_forget" | "forget" | "memforget" => "memory_forget",
1292        // HTTP variations
1293        "http_request" | "http" | "fetch" | "curl" | "wget" => "http_request",
1294        _ => tool_name,
1295    }
1296}
1297
1298fn build_curl_command(url: &str) -> Option<String> {
1299    if !(url.starts_with("http://") || url.starts_with("https://")) {
1300        return None;
1301    }
1302
1303    if url.chars().any(char::is_whitespace) {
1304        return None;
1305    }
1306
1307    let escaped = url.replace('\'', r#"'\\''"#);
1308    Some(format!("curl -s '{}'", escaped))
1309}
1310
1311fn parse_glm_style_tool_calls(text: &str) -> Vec<(String, serde_json::Value, Option<String>)> {
1312    let mut calls = Vec::new();
1313
1314    for line in text.lines() {
1315        let line = line.trim();
1316        if line.is_empty() {
1317            continue;
1318        }
1319
1320        // Format: tool_name/param>value or tool_name/{json}
1321        if let Some(pos) = line.find('/') {
1322            let tool_part = &line[..pos];
1323            let rest = &line[pos + 1..];
1324
1325            if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
1326                let tool_name = map_tool_name_alias(tool_part);
1327
1328                if let Some(gt_pos) = rest.find('>') {
1329                    let param_name = rest[..gt_pos].trim();
1330                    let value = rest[gt_pos + 1..].trim();
1331
1332                    let arguments = match tool_name {
1333                        "shell" => {
1334                            if param_name == "url" {
1335                                let Some(command) = build_curl_command(value) else {
1336                                    continue;
1337                                };
1338                                serde_json::json!({ "command": command })
1339                            } else if value.starts_with("http://") || value.starts_with("https://")
1340                            {
1341                                if let Some(command) = build_curl_command(value) {
1342                                    serde_json::json!({ "command": command })
1343                                } else {
1344                                    serde_json::json!({ "command": value })
1345                                }
1346                            } else {
1347                                serde_json::json!({ "command": value })
1348                            }
1349                        }
1350                        "http_request" => {
1351                            serde_json::json!({"url": value, "method": "GET"})
1352                        }
1353                        _ => serde_json::json!({ param_name: value }),
1354                    };
1355
1356                    calls.push((tool_name.to_string(), arguments, Some(line.to_string())));
1357                    continue;
1358                }
1359
1360                if rest.starts_with('{')
1361                    && let Ok(json_args) = serde_json::from_str::<serde_json::Value>(rest)
1362                {
1363                    calls.push((tool_name.to_string(), json_args, Some(line.to_string())));
1364                }
1365            }
1366        }
1367    }
1368
1369    calls
1370}
1371
1372/// Return the canonical default parameter name for a tool.
1373///
1374/// When a model emits a shortened call like `shell>uname -a` (without an
1375/// explicit `/param_name`), we need to infer which parameter the value maps
1376/// to. This function encodes the mapping for known ZeroClaw tools.
1377fn default_param_for_tool(tool: &str) -> &'static str {
1378    match tool {
1379        "shell" | "bash" | "sh" | "exec" | "command" | "cmd" => "command",
1380        // All file tools default to "path"
1381        "file_read" | "fileread" | "readfile" | "read_file" | "file" | "file_write"
1382        | "filewrite" | "writefile" | "write_file" | "file_edit" | "fileedit" | "editfile"
1383        | "edit_file" | "file_list" | "filelist" | "listfiles" | "list_files" => "path",
1384        // Memory recall/forget and web search tools all default to "query"
1385        "memory_recall" | "memoryrecall" | "recall" | "memrecall" | "memory_forget"
1386        | "memoryforget" | "forget" | "memforget" | "web_search_tool" | "web_search"
1387        | "websearch" | "search" => "query",
1388        "memory_store" | "memorystore" | "store" | "memstore" => "content",
1389        // HTTP and browser tools default to "url"
1390        "http_request" | "http" | "fetch" | "curl" | "wget" | "browser_open" | "browser" => "url",
1391        _ => "input",
1392    }
1393}
1394
1395/// Parse GLM-style shortened tool call bodies found inside `<tool_call>` tags.
1396///
1397/// Handles three sub-formats that GLM-4.7 emits:
1398///
1399/// 1. **Shortened**: `tool_name>value` — single value mapped via
1400///    [`default_param_for_tool`].
1401/// 2. **YAML-like multi-line**: `tool_name>\nkey: value\nkey: value` — each
1402///    subsequent `key: value` line becomes a parameter.
1403/// 3. **Attribute-style**: `tool_name key="value" [/]>` — XML-like attributes.
1404///
1405/// Returns `None` if the body does not match any of these formats.
1406fn parse_glm_shortened_body(body: &str) -> Option<ParsedToolCall> {
1407    let body = body.trim();
1408    if body.is_empty() {
1409        return None;
1410    }
1411
1412    let function_style = body.find('(').and_then(|open| {
1413        if body.ends_with(')') && open > 0 {
1414            Some((body[..open].trim(), body[open + 1..body.len() - 1].trim()))
1415        } else {
1416            None
1417        }
1418    });
1419
1420    // Check attribute-style FIRST: `tool_name key="value" />`
1421    // Must come before `>` check because `/>` contains `>` and would
1422    // misparse the tool name in the first branch.
1423    let (tool_raw, value_part) = if let Some((tool, args)) = function_style {
1424        (tool, args)
1425    } else if body.contains("=\"") {
1426        // Attribute-style: split at first whitespace to get tool name
1427        let split_pos = body.find(|c: char| c.is_whitespace()).unwrap_or(body.len());
1428        let tool = body[..split_pos].trim();
1429        let attrs = body[split_pos..]
1430            .trim()
1431            .trim_end_matches("/>")
1432            .trim_end_matches('>')
1433            .trim_end_matches('/')
1434            .trim();
1435        (tool, attrs)
1436    } else if let Some(gt_pos) = body.find('>') {
1437        // GLM shortened: `tool_name>value`
1438        let tool = body[..gt_pos].trim();
1439        let value = body[gt_pos + 1..].trim();
1440        // Strip trailing self-close markers that some models emit
1441        let value = value.trim_end_matches("/>").trim_end_matches('/').trim();
1442        (tool, value)
1443    } else {
1444        return None;
1445    };
1446
1447    // Validate tool name: must be alphanumeric + underscore only
1448    let tool_raw = tool_raw.trim_end_matches(|c: char| c.is_whitespace());
1449    if tool_raw.is_empty() || !tool_raw.chars().all(|c| c.is_alphanumeric() || c == '_') {
1450        return None;
1451    }
1452
1453    let tool_name = map_tool_name_alias(tool_raw);
1454
1455    // Try attribute-style: `key="value" key2="value2"`
1456    if value_part.contains("=\"") {
1457        let mut args = serde_json::Map::new();
1458        // Simple attribute parser: key="value" pairs
1459        let mut rest = value_part;
1460        while let Some(eq_pos) = rest.find("=\"") {
1461            let key_start = rest[..eq_pos]
1462                .rfind(|c: char| c.is_whitespace())
1463                .map(|p| p + 1)
1464                .unwrap_or(0);
1465            let key = rest[key_start..eq_pos]
1466                .trim()
1467                .trim_matches(|c: char| c == ',' || c == ';');
1468            let after_quote = &rest[eq_pos + 2..];
1469            if let Some(end_quote) = after_quote.find('"') {
1470                let value = &after_quote[..end_quote];
1471                if !key.is_empty() {
1472                    args.insert(
1473                        key.to_string(),
1474                        serde_json::Value::String(value.to_string()),
1475                    );
1476                }
1477                rest = &after_quote[end_quote + 1..];
1478            } else {
1479                break;
1480            }
1481        }
1482        if !args.is_empty() {
1483            return Some(ParsedToolCall {
1484                name: tool_name.to_string(),
1485                arguments: serde_json::Value::Object(args),
1486                tool_call_id: None,
1487            });
1488        }
1489    }
1490
1491    // Try YAML-style multi-line: each line is `key: value`
1492    if value_part.contains('\n') {
1493        let mut args = serde_json::Map::new();
1494        for line in value_part.lines() {
1495            let line = line.trim();
1496            if line.is_empty() {
1497                continue;
1498            }
1499            if let Some(colon_pos) = line.find(':') {
1500                let key = line[..colon_pos].trim();
1501                let value = line[colon_pos + 1..].trim();
1502                if !key.is_empty() && !value.is_empty() {
1503                    // Normalize boolean-like values
1504                    let json_value = match value {
1505                        "true" | "yes" => serde_json::Value::Bool(true),
1506                        "false" | "no" => serde_json::Value::Bool(false),
1507                        _ => serde_json::Value::String(value.to_string()),
1508                    };
1509                    args.insert(key.to_string(), json_value);
1510                }
1511            }
1512        }
1513        if !args.is_empty() {
1514            return Some(ParsedToolCall {
1515                name: tool_name.to_string(),
1516                arguments: serde_json::Value::Object(args),
1517                tool_call_id: None,
1518            });
1519        }
1520    }
1521
1522    // Single-value shortened: `tool>value`
1523    if !value_part.is_empty() {
1524        let param = default_param_for_tool(tool_raw);
1525        let arguments = match tool_name {
1526            "shell" => {
1527                if value_part.starts_with("http://") || value_part.starts_with("https://") {
1528                    if let Some(cmd) = build_curl_command(value_part) {
1529                        serde_json::json!({ "command": cmd })
1530                    } else {
1531                        serde_json::json!({ "command": value_part })
1532                    }
1533                } else {
1534                    serde_json::json!({ "command": value_part })
1535                }
1536            }
1537            "http_request" => serde_json::json!({"url": value_part, "method": "GET"}),
1538            _ => serde_json::json!({ param: value_part }),
1539        };
1540        return Some(ParsedToolCall {
1541            name: tool_name.to_string(),
1542            arguments,
1543            tool_call_id: None,
1544        });
1545    }
1546
1547    None
1548}
1549
1550// ── Tool-Call Parsing ─────────────────────────────────────────────────────
1551// LLM responses may contain tool calls in multiple formats depending on
1552// the model_provider. Parsing follows a priority chain:
1553//   1. OpenAI-style JSON with `tool_calls` array (native API)
1554//   2. XML tags: <tool_call>, <toolcall>, <tool-call>, <invoke>
1555//   3. Markdown code blocks with `tool_call` language
1556//   4. GLM-style line-based format (e.g. `shell/command>ls`)
1557// SECURITY: We never fall back to extracting arbitrary JSON from the
1558// response body, because that would enable prompt-injection attacks where
1559// malicious content in emails/files/web pages mimics a tool call.
1560
1561/// Parse tool calls from an LLM response that uses XML-style function calling.
1562///
1563/// Expected format (common with system-prompt-guided tool use):
1564/// ```text
1565/// <tool_call>
1566/// {"name": "shell", "arguments": {"command": "ls"}}
1567/// </tool_call>
1568/// ```
1569///
1570/// Also accepts common tag variants (`<toolcall>`, `<tool-call>`) for model
1571/// compatibility.
1572///
1573/// Also supports JSON with `tool_calls` array from OpenAI-format responses.
1574pub fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
1575    // Strip `<think>...</think>` blocks before parsing.  Qwen and other
1576    // reasoning models embed chain-of-thought inline in the response text;
1577    // these tags can interfere with `<tool_call>` extraction and must be
1578    // removed first.
1579    let cleaned = strip_think_tags(response);
1580    let response = cleaned.as_str();
1581
1582    let mut text_parts = Vec::new();
1583    let mut calls = Vec::new();
1584    let mut remaining = response;
1585
1586    // First, try to parse as OpenAI-style JSON response with tool_calls array
1587    // This handles model_providers like Minimax that return tool_calls in native JSON format
1588    if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response.trim()) {
1589        calls = parse_tool_calls_from_json_value(&json_value);
1590        if !calls.is_empty() {
1591            // If we found tool_calls, extract any content field as text
1592            if let Some(content) = json_value.get("content").and_then(|v| v.as_str())
1593                && !content.trim().is_empty()
1594            {
1595                text_parts.push(content.trim().to_string());
1596            }
1597            return (text_parts.join("\n"), calls);
1598        }
1599    }
1600
1601    if let Some((minimax_text, minimax_calls)) = parse_minimax_invoke_calls(response)
1602        && !minimax_calls.is_empty()
1603    {
1604        return (minimax_text, minimax_calls);
1605    }
1606
1607    // Fall back to XML-style tool-call tag parsing.
1608    while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
1609        // Everything before the tag is text
1610        let before = &remaining[..start];
1611        if !before.trim().is_empty() {
1612            text_parts.push(before.trim().to_string());
1613        }
1614
1615        let Some(close_tag) = (match open_tag {
1616            "<tool_call>" => Some("</tool_call>"),
1617            "<tool_calls>" => Some("</tool_calls>"),
1618            "<toolcall>" => Some("</toolcall>"),
1619            "<tool-call>" => Some("</tool-call>"),
1620            "<invoke>" => Some("</invoke>"),
1621            "<minimax:tool_call>" => Some("</minimax:tool_call>"),
1622            "<minimax:toolcall>" => Some("</minimax:toolcall>"),
1623            _ => None,
1624        }) else {
1625            break;
1626        };
1627
1628        let after_open = &remaining[start + open_tag.len()..];
1629        if let Some(close_idx) = after_open.find(close_tag) {
1630            let inner = &after_open[..close_idx];
1631            let mut parsed_any = false;
1632
1633            // Try JSON format first
1634            let json_values = extract_json_values(inner);
1635            for value in json_values {
1636                let parsed_calls = parse_tool_calls_from_json_value(&value);
1637                if !parsed_calls.is_empty() {
1638                    parsed_any = true;
1639                    calls.extend(parsed_calls);
1640                }
1641            }
1642
1643            // If JSON parsing failed, try XML format (DeepSeek/GLM style)
1644            if !parsed_any && let Some(xml_calls) = parse_xml_tool_calls(inner) {
1645                calls.extend(xml_calls);
1646                parsed_any = true;
1647            }
1648
1649            if !parsed_any {
1650                // GLM-style shortened body: `shell>uname -a` or `shell\ncommand: date`
1651                if let Some(glm_call) = parse_glm_shortened_body(inner) {
1652                    calls.push(glm_call);
1653                    parsed_any = true;
1654                }
1655            }
1656
1657            if !parsed_any {
1658                ::zeroclaw_log::record!(
1659                    WARN,
1660                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1661                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1662                    "Malformed <tool_call>: expected tool-call object in tag body (JSON/XML/GLM)"
1663                );
1664            }
1665
1666            remaining = &after_open[close_idx + close_tag.len()..];
1667        } else {
1668            // Matching close tag not found — try cross-alias close tags first.
1669            // Models sometimes mix open/close tag aliases (e.g. <tool_call>...</invoke>).
1670            let mut resolved = false;
1671            if let Some((cross_idx, cross_tag)) = find_first_tag(after_open, &TOOL_CALL_CLOSE_TAGS)
1672            {
1673                let inner = &after_open[..cross_idx];
1674                let mut parsed_any = false;
1675
1676                // Try JSON
1677                let json_values = extract_json_values(inner);
1678                for value in json_values {
1679                    let parsed_calls = parse_tool_calls_from_json_value(&value);
1680                    if !parsed_calls.is_empty() {
1681                        parsed_any = true;
1682                        calls.extend(parsed_calls);
1683                    }
1684                }
1685
1686                // Try XML
1687                if !parsed_any && let Some(xml_calls) = parse_xml_tool_calls(inner) {
1688                    calls.extend(xml_calls);
1689                    parsed_any = true;
1690                }
1691
1692                // Try GLM shortened body
1693                if !parsed_any && let Some(glm_call) = parse_glm_shortened_body(inner) {
1694                    calls.push(glm_call);
1695                    parsed_any = true;
1696                }
1697
1698                if parsed_any {
1699                    remaining = &after_open[cross_idx + cross_tag.len()..];
1700                    resolved = true;
1701                }
1702            }
1703
1704            if resolved {
1705                continue;
1706            }
1707
1708            // No cross-alias close tag resolved — fall back to JSON recovery
1709            // from unclosed tags (brace-balancing).
1710            if let Some(json_end) = find_json_end(after_open)
1711                && let Ok(value) =
1712                    serde_json::from_str::<serde_json::Value>(&after_open[..json_end])
1713            {
1714                let parsed_calls = parse_tool_calls_from_json_value(&value);
1715                if !parsed_calls.is_empty() {
1716                    calls.extend(parsed_calls);
1717                    remaining = strip_leading_close_tags(&after_open[json_end..]);
1718                    continue;
1719                }
1720            }
1721
1722            if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) {
1723                let parsed_calls = parse_tool_calls_from_json_value(&value);
1724                if !parsed_calls.is_empty() {
1725                    calls.extend(parsed_calls);
1726                    remaining = strip_leading_close_tags(&after_open[consumed_end..]);
1727                    continue;
1728                }
1729            }
1730
1731            // Last resort: try GLM shortened body on everything after the open tag.
1732            // The model may have emitted `<tool_call>shell>ls` with no close tag at all.
1733            let glm_input = after_open.trim();
1734            if let Some(glm_call) = parse_glm_shortened_body(glm_input) {
1735                calls.push(glm_call);
1736                remaining = "";
1737                continue;
1738            }
1739
1740            remaining = &remaining[start..];
1741            break;
1742        }
1743    }
1744
1745    // If XML tags found nothing, try markdown code blocks with tool_call language.
1746    // Models behind OpenRouter sometimes output ```tool_call ... ``` or hybrid
1747    // ```tool_call ... </tool_call> instead of structured API calls or XML tags.
1748    if calls.is_empty() {
1749        static MD_TOOL_CALL_RE: LazyLock<Regex> = LazyLock::new(|| {
1750            Regex::new(
1751                r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```|</tool[_-]?call>|</toolcall>|</invoke>|</minimax:toolcall>)",
1752            )
1753            .unwrap()
1754        });
1755        let mut md_text_parts: Vec<String> = Vec::new();
1756        let mut last_end = 0;
1757
1758        for cap in MD_TOOL_CALL_RE.captures_iter(response) {
1759            let full_match = cap.get(0).unwrap();
1760            let before = &response[last_end..full_match.start()];
1761            if !before.trim().is_empty() {
1762                md_text_parts.push(before.trim().to_string());
1763            }
1764            let inner = &cap[1];
1765            let json_values = extract_json_values(inner);
1766            for value in json_values {
1767                let parsed_calls = parse_tool_calls_from_json_value(&value);
1768                calls.extend(parsed_calls);
1769            }
1770            last_end = full_match.end();
1771        }
1772
1773        if !calls.is_empty() {
1774            let after = &response[last_end..];
1775            if !after.trim().is_empty() {
1776                md_text_parts.push(after.trim().to_string());
1777            }
1778            text_parts = md_text_parts;
1779            remaining = "";
1780        }
1781    }
1782
1783    // Try ```tool <name> format used by some model_providers (e.g., xAI grok)
1784    // Example: ```tool file_write\n{"path": "...", "content": "..."}\n```
1785    if calls.is_empty() {
1786        static MD_TOOL_NAME_RE: LazyLock<Regex> =
1787            LazyLock::new(|| Regex::new(r"(?s)```tool\s+(\w+)\s*\n(.*?)(?:```|$)").unwrap());
1788        let mut md_text_parts: Vec<String> = Vec::new();
1789        let mut last_end = 0;
1790
1791        for cap in MD_TOOL_NAME_RE.captures_iter(response) {
1792            let full_match = cap.get(0).unwrap();
1793            let before = &response[last_end..full_match.start()];
1794            if !before.trim().is_empty() {
1795                md_text_parts.push(before.trim().to_string());
1796            }
1797            let tool_name = &cap[1];
1798            let inner = &cap[2];
1799
1800            // Try to parse the inner content as JSON arguments
1801            let json_values = extract_json_values(inner);
1802            if json_values.is_empty() {
1803                // Log a warning if we found a tool block but couldn't parse arguments
1804                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"tool_name": tool_name, "inner": inner.chars().take(100).collect::<String>()})), "Found ```tool <name> block but could not parse JSON arguments");
1805            } else {
1806                for value in json_values {
1807                    let arguments = if value.is_object() {
1808                        value
1809                    } else {
1810                        serde_json::Value::Object(serde_json::Map::new())
1811                    };
1812                    calls.push(ParsedToolCall {
1813                        name: tool_name.to_string(),
1814                        arguments,
1815                        tool_call_id: None,
1816                    });
1817                }
1818            }
1819            last_end = full_match.end();
1820        }
1821
1822        if !calls.is_empty() {
1823            let after = &response[last_end..];
1824            if !after.trim().is_empty() {
1825                md_text_parts.push(after.trim().to_string());
1826            }
1827            text_parts = md_text_parts;
1828            remaining = "";
1829        }
1830    }
1831
1832    // XML attribute-style tool calls:
1833    // <minimax:toolcall>
1834    // <invoke name="shell">
1835    // <parameter name="command">ls</parameter>
1836    // </invoke>
1837    // </minimax:toolcall>
1838    if calls.is_empty() {
1839        let xml_calls = parse_xml_attribute_tool_calls(remaining);
1840        if !xml_calls.is_empty() {
1841            let mut cleaned_text = remaining.to_string();
1842            for call in xml_calls {
1843                calls.push(call);
1844                // Try to remove the XML from text
1845                if let Some(start) = cleaned_text.find("<minimax:toolcall>")
1846                    && let Some(end) = cleaned_text.find("</minimax:toolcall>")
1847                {
1848                    let end_pos = end + "</minimax:toolcall>".len();
1849                    if end_pos <= cleaned_text.len() {
1850                        cleaned_text =
1851                            format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1852                    }
1853                }
1854            }
1855            if !cleaned_text.trim().is_empty() {
1856                text_parts.push(cleaned_text.trim().to_string());
1857            }
1858            remaining = "";
1859        }
1860    }
1861
1862    // Perl/hash-ref style tool calls:
1863    // TOOL_CALL
1864    // {tool => "shell", args => {
1865    //   --command "ls -la"
1866    //   --description "List current directory contents"
1867    // }}
1868    // /TOOL_CALL
1869    if calls.is_empty() {
1870        let perl_calls = parse_perl_style_tool_calls(remaining);
1871        if !perl_calls.is_empty() {
1872            let mut cleaned_text = remaining.to_string();
1873            for call in perl_calls {
1874                calls.push(call);
1875                // Try to remove the TOOL_CALL block from text
1876                while let Some(start) = cleaned_text.find("TOOL_CALL") {
1877                    if let Some(end) = cleaned_text.find("/TOOL_CALL") {
1878                        let end_pos = end + "/TOOL_CALL".len();
1879                        if end_pos <= cleaned_text.len() {
1880                            cleaned_text =
1881                                format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1882                        }
1883                    } else {
1884                        break;
1885                    }
1886                }
1887            }
1888            if !cleaned_text.trim().is_empty() {
1889                text_parts.push(cleaned_text.trim().to_string());
1890            }
1891            remaining = "";
1892        }
1893    }
1894
1895    // <FunctionCall>
1896    // file_read
1897    // <code>path>/Users/...</code>
1898    // </FunctionCall>
1899    if calls.is_empty() {
1900        let func_calls = parse_function_call_tool_calls(remaining);
1901        if !func_calls.is_empty() {
1902            let mut cleaned_text = remaining.to_string();
1903            for call in func_calls {
1904                calls.push(call);
1905                // Try to remove the FunctionCall block from text
1906                while let Some(start) = cleaned_text.find("<FunctionCall>") {
1907                    if let Some(end) = cleaned_text.find("</FunctionCall>") {
1908                        let end_pos = end + "</FunctionCall>".len();
1909                        if end_pos <= cleaned_text.len() {
1910                            cleaned_text =
1911                                format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1912                        }
1913                    } else {
1914                        break;
1915                    }
1916                }
1917            }
1918            if !cleaned_text.trim().is_empty() {
1919                text_parts.push(cleaned_text.trim().to_string());
1920            }
1921            remaining = "";
1922        }
1923    }
1924
1925    // GLM-style tool calls (browser_open/url>https://..., shell/command>ls, etc.)
1926    if calls.is_empty() {
1927        let glm_calls = parse_glm_style_tool_calls(remaining);
1928        if !glm_calls.is_empty() {
1929            let mut cleaned_text = remaining.to_string();
1930            for (name, args, raw) in &glm_calls {
1931                calls.push(ParsedToolCall {
1932                    name: name.clone(),
1933                    arguments: args.clone(),
1934                    tool_call_id: None,
1935                });
1936                if let Some(r) = raw {
1937                    cleaned_text = cleaned_text.replace(r, "");
1938                }
1939            }
1940            if !cleaned_text.trim().is_empty() {
1941                text_parts.push(cleaned_text.trim().to_string());
1942            }
1943            remaining = "";
1944        }
1945    }
1946
1947    // SECURITY: We do NOT fall back to extracting arbitrary JSON from the response
1948    // here. That would enable prompt injection attacks where malicious content
1949    // (e.g., in emails, files, or web pages) could include JSON that mimics a
1950    // tool call. Tool calls MUST be explicitly wrapped in either:
1951    // 1. OpenAI-style JSON with a "tool_calls" array
1952    // 2. ZeroClaw tool-call tags (<tool_call>, <toolcall>, <tool-call>)
1953    // 3. Markdown code blocks with tool_call/toolcall/tool-call language
1954    // 4. Explicit GLM line-based call formats (e.g. `shell/command>...`)
1955    // This ensures only the LLM's intentional tool calls are executed.
1956
1957    // Remaining text after last tool call
1958    if !remaining.trim().is_empty() {
1959        text_parts.push(remaining.trim().to_string());
1960    }
1961
1962    (text_parts.join("\n"), calls)
1963}
1964
1965/// Remove `<think>...</think>` blocks from model output.
1966/// Qwen and other reasoning models embed chain-of-thought inline in the
1967/// response text using `<think>` tags.  These must be removed before parsing
1968/// tool-call tags or displaying output.
1969pub fn strip_think_tags(s: &str) -> String {
1970    let mut result = String::with_capacity(s.len());
1971    let mut rest = s;
1972    loop {
1973        if let Some(start) = rest.find("<think>") {
1974            result.push_str(&rest[..start]);
1975            if let Some(end) = rest[start..].find("</think>") {
1976                rest = &rest[start + end + "</think>".len()..];
1977            } else {
1978                // Unclosed tag: drop the rest to avoid leaking partial reasoning.
1979                break;
1980            }
1981        } else {
1982            result.push_str(rest);
1983            break;
1984        }
1985    }
1986    result.trim().to_string()
1987}
1988
1989/// Strip prompt-guided tool artifacts from visible output while preserving
1990/// raw model text in history for future turns.
1991pub fn strip_tool_result_blocks(text: &str) -> String {
1992    static TOOL_RESULT_RE: LazyLock<Regex> =
1993        LazyLock::new(|| Regex::new(r"(?s)<tool_result[^>]*>.*?</tool_result>").unwrap());
1994    static THINKING_RE: LazyLock<Regex> =
1995        LazyLock::new(|| Regex::new(r"(?s)<thinking>.*?</thinking>").unwrap());
1996    static THINK_RE: LazyLock<Regex> =
1997        LazyLock::new(|| Regex::new(r"(?s)<think>.*?</think>").unwrap());
1998    static TOOL_RESULTS_PREFIX_RE: LazyLock<Regex> =
1999        LazyLock::new(|| Regex::new(r"(?m)^\[Tool results\]\s*\n?").unwrap());
2000    static EXCESS_BLANK_LINES_RE: LazyLock<Regex> =
2001        LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
2002
2003    let result = TOOL_RESULT_RE.replace_all(text, "");
2004    let result = THINKING_RE.replace_all(&result, "");
2005    let result = THINK_RE.replace_all(&result, "");
2006    let result = TOOL_RESULTS_PREFIX_RE.replace_all(&result, "");
2007    let result = EXCESS_BLANK_LINES_RE.replace_all(result.trim(), "\n\n");
2008
2009    result.trim().to_string()
2010}
2011
2012pub fn detect_tool_call_parse_issue(
2013    response: &str,
2014    parsed_calls: &[ParsedToolCall],
2015) -> Option<String> {
2016    if !parsed_calls.is_empty() {
2017        return None;
2018    }
2019
2020    let trimmed = response.trim();
2021    if trimmed.is_empty() {
2022        return None;
2023    }
2024
2025    if looks_like_tool_protocol_envelope(trimmed) {
2026        return Some(
2027            "response resembled an internal tool protocol envelope but no valid tool call could be parsed"
2028                .into(),
2029        );
2030    }
2031
2032    if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
2033        return has_malformed_tool_protocol_json_signal(&value).then(|| {
2034            "response resembled an internal tool protocol envelope but no valid tool call could be parsed"
2035                .into()
2036        });
2037    }
2038
2039    if has_malformed_tool_protocol_text_signal(trimmed) {
2040        return Some(
2041            "response resembled an internal tool protocol envelope but no valid tool call could be parsed"
2042                .into(),
2043        );
2044    }
2045
2046    let contains_tool_payload_marker = trimmed.contains("<tool_call")
2047        || trimmed.contains("<toolcall")
2048        || trimmed.contains("<tool-call")
2049        || trimmed.contains("```tool_call")
2050        || trimmed.contains("```toolcall")
2051        || trimmed.contains("```tool-call")
2052        || trimmed.contains("```tool file_")
2053        || trimmed.contains("```tool shell")
2054        || trimmed.contains("```tool web_")
2055        || trimmed.contains("```tool memory_")
2056        || trimmed.contains("```tool ") // Generic ```tool <name> pattern
2057        || trimmed.contains("TOOL_CALL")
2058        || trimmed.contains("[TOOL_CALL]")
2059        || trimmed.contains("<FunctionCall>");
2060
2061    if contains_tool_payload_marker {
2062        if looks_like_tool_protocol_example(trimmed) {
2063            return None;
2064        }
2065        if contains_tool_protocol_tag_call(trimmed) {
2066            return Some(
2067                "response resembled a tool-call payload but no valid tool call could be parsed"
2068                    .into(),
2069            );
2070        }
2071
2072        let (visible_text, recovered_calls) = parse_tool_calls(trimmed);
2073        if !recovered_calls.is_empty() && !visible_text.trim().is_empty() {
2074            return None;
2075        }
2076        if !recovered_calls.is_empty() || visible_text.trim().is_empty() {
2077            return Some(
2078                "response resembled a tool-call payload but no valid tool call could be parsed"
2079                    .into(),
2080            );
2081        }
2082    }
2083
2084    if looks_like_malformed_tool_protocol_envelope(trimmed) {
2085        Some("response resembled a tool-call payload but no valid tool call could be parsed".into())
2086    } else {
2087        None
2088    }
2089}
2090
2091pub fn build_native_assistant_history_from_parsed_calls(
2092    text: &str,
2093    tool_calls: &[ParsedToolCall],
2094    reasoning_content: Option<&str>,
2095) -> Option<String> {
2096    // Strict provider validators (DeepSeek V4, NVIDIA NIM, ...) reject
2097    // assistant messages that carry `tool_calls: []`. When there are no
2098    // parsed calls, return None so the caller falls through to a plain
2099    // text assistant message. See #6298.
2100    if tool_calls.is_empty() {
2101        return None;
2102    }
2103
2104    let calls_json = tool_calls
2105        .iter()
2106        .map(|tc| {
2107            Some(serde_json::json!({
2108                "id": tc.tool_call_id.clone()?,
2109                "name": tc.name,
2110                "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string()),
2111            }))
2112        })
2113        .collect::<Option<Vec<_>>>()?;
2114
2115    let content = if text.trim().is_empty() {
2116        serde_json::Value::Null
2117    } else {
2118        serde_json::Value::String(text.trim().to_string())
2119    };
2120
2121    let mut obj = serde_json::json!({
2122        "content": content,
2123        "tool_calls": calls_json,
2124    });
2125
2126    if let Some(rc) = reasoning_content {
2127        obj.as_object_mut().unwrap().insert(
2128            "reasoning_content".to_string(),
2129            serde_json::Value::String(rc.to_string()),
2130        );
2131    }
2132
2133    Some(obj.to_string())
2134}
2135
2136#[cfg(test)]
2137mod tests {
2138    use super::*;
2139
2140    #[test]
2141    fn build_native_assistant_history_returns_none_for_empty_calls() {
2142        // Regression: strict providers (DeepSeek V4, NVIDIA NIM) reject
2143        // assistant messages carrying `tool_calls: []`. Empty input must
2144        // not produce a serialised assistant message with an empty array.
2145        // See #6298.
2146        let result = build_native_assistant_history_from_parsed_calls("answer text", &[], None);
2147        assert!(
2148            result.is_none(),
2149            "expected None for empty tool_calls slice, got {result:?}"
2150        );
2151    }
2152
2153    #[test]
2154    fn build_native_assistant_history_returns_none_for_empty_calls_with_reasoning() {
2155        // Even with reasoning_content set, an empty tool_calls slice must
2156        // collapse to None — the caller falls back to a plain assistant
2157        // message, and the reasoning round-trip happens through a separate
2158        // path that does not produce `tool_calls: []`.
2159        let result = build_native_assistant_history_from_parsed_calls(
2160            "answer text",
2161            &[],
2162            Some("deep thought"),
2163        );
2164        assert!(result.is_none());
2165    }
2166
2167    #[test]
2168    fn build_native_assistant_history_emits_tool_calls_when_non_empty() {
2169        // No-regression check: the normal path with a real parsed call
2170        // still produces a serialised assistant message and the
2171        // `tool_calls` field is a non-empty array.
2172        let calls = vec![ParsedToolCall {
2173            name: "shell".into(),
2174            arguments: serde_json::json!({"command": "pwd"}),
2175            tool_call_id: Some("call_1".into()),
2176        }];
2177        let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
2178        let s = result.expect("Some(_) for non-empty tool_calls");
2179        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
2180        assert_eq!(parsed["content"].as_str(), Some("answer"));
2181        let arr = parsed["tool_calls"].as_array().expect("tool_calls array");
2182        assert_eq!(arr.len(), 1);
2183        assert_eq!(arr[0]["name"].as_str(), Some("shell"));
2184    }
2185
2186    #[test]
2187    fn parse_arguments_value_unwraps_nested_object_string() {
2188        let raw = serde_json::json!({
2189            "service": "gmail",
2190            "params": "{\"maxResults\":3}"
2191        });
2192        let out = parse_arguments_value(Some(&raw));
2193        assert_eq!(out["service"], serde_json::json!("gmail"));
2194        assert_eq!(out["params"], serde_json::json!({"maxResults": 3}));
2195    }
2196
2197    #[test]
2198    fn parse_arguments_value_unwraps_nested_array_string() {
2199        let raw = serde_json::json!({ "items": "[1,2,3]" });
2200        let out = parse_arguments_value(Some(&raw));
2201        assert_eq!(out["items"], serde_json::json!([1, 2, 3]));
2202    }
2203
2204    #[test]
2205    fn parse_arguments_value_leaves_non_json_strings_alone() {
2206        let raw = serde_json::json!({
2207            "greeting": "hello",
2208            "answer": "42",
2209            "truthy": "true",
2210            "broken": "{not json"
2211        });
2212        let out = parse_arguments_value(Some(&raw));
2213        assert_eq!(out["greeting"], serde_json::json!("hello"));
2214        assert_eq!(out["answer"], serde_json::json!("42"));
2215        assert_eq!(out["truthy"], serde_json::json!("true"));
2216        assert_eq!(out["broken"], serde_json::json!("{not json"));
2217    }
2218
2219    #[test]
2220    fn parse_arguments_value_handles_double_encoding() {
2221        let inner = r#"{"params":"{\"maxResults\":3}"}"#;
2222        let raw = serde_json::Value::String(inner.to_string());
2223        let out = parse_arguments_value(Some(&raw));
2224        assert_eq!(out["params"], serde_json::json!({"maxResults": 3}));
2225    }
2226
2227    #[test]
2228    fn parse_tool_call_value_handles_gemini_double_encoded_params() {
2229        let inner = r#"{"service":"gmail","resource":"users","sub_resource":"messages","method":"list","params":"{\"maxResults\":3}"}"#;
2230        let call_json = serde_json::json!({
2231            "function": {
2232                "name": "google_workspace",
2233                "arguments": inner
2234            }
2235        });
2236        let parsed = parse_tool_call_value(&call_json).expect("expected a parsed call");
2237        assert_eq!(parsed.name, "google_workspace");
2238        assert_eq!(
2239            parsed.arguments["params"],
2240            serde_json::json!({"maxResults": 3})
2241        );
2242        assert_eq!(
2243            parsed.arguments["sub_resource"],
2244            serde_json::json!("messages")
2245        );
2246    }
2247
2248    #[test]
2249    fn parse_tool_calls_extracts_multiple_calls() {
2250        let response = r#"<tool_call>
2251{"name": "file_read", "arguments": {"path": "a.txt"}}
2252</tool_call>
2253<tool_call>
2254{"name": "file_read", "arguments": {"path": "b.txt"}}
2255</tool_call>"#;
2256
2257        let (_, calls) = parse_tool_calls(response);
2258        assert_eq!(calls.len(), 2);
2259        assert_eq!(calls[0].name, "file_read");
2260        assert_eq!(calls[1].name, "file_read");
2261    }
2262
2263    #[test]
2264    fn parse_tool_calls_returns_text_only_when_no_calls() {
2265        let response = "Just a normal response with no tools.";
2266        let (text, calls) = parse_tool_calls(response);
2267        assert_eq!(text, "Just a normal response with no tools.");
2268        assert!(calls.is_empty());
2269    }
2270
2271    #[test]
2272    fn parse_tool_calls_handles_malformed_json() {
2273        let response = r#"<tool_call>
2274not valid json
2275</tool_call>
2276Some text after."#;
2277
2278        let (text, calls) = parse_tool_calls(response);
2279        assert!(calls.is_empty());
2280        assert!(text.contains("Some text after."));
2281    }
2282
2283    #[test]
2284    fn parse_tool_calls_text_before_and_after() {
2285        let response = r#"Before text.
2286<tool_call>
2287{"name": "shell", "arguments": {"command": "echo hi"}}
2288</tool_call>
2289After text."#;
2290
2291        let (text, calls) = parse_tool_calls(response);
2292        assert!(text.contains("Before text."));
2293        assert!(text.contains("After text."));
2294        assert_eq!(calls.len(), 1);
2295    }
2296
2297    #[test]
2298    fn parse_tool_calls_handles_openai_format() {
2299        // OpenAI-style response with tool_calls array
2300        let response = r#"{"content": "Let me check that for you.", "tool_calls": [{"type": "function", "function": {"name": "shell", "arguments": "{\"command\": \"ls -la\"}"}}]}"#;
2301
2302        let (text, calls) = parse_tool_calls(response);
2303        assert_eq!(text, "Let me check that for you.");
2304        assert_eq!(calls.len(), 1);
2305        assert_eq!(calls[0].name, "shell");
2306        assert_eq!(
2307            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2308            "ls -la"
2309        );
2310    }
2311
2312    #[test]
2313    fn parse_tool_calls_handles_openai_format_multiple_calls() {
2314        let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"a.txt\"}"}}, {"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"b.txt\"}"}}]}"#;
2315
2316        let (_, calls) = parse_tool_calls(response);
2317        assert_eq!(calls.len(), 2);
2318        assert_eq!(calls[0].name, "file_read");
2319        assert_eq!(calls[1].name, "file_read");
2320    }
2321
2322    #[test]
2323    fn parse_tool_calls_openai_format_without_content() {
2324        // Some model_providers don't include content field with tool_calls
2325        let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}"#;
2326
2327        let (text, calls) = parse_tool_calls(response);
2328        assert!(text.is_empty()); // No content field
2329        assert_eq!(calls.len(), 1);
2330        assert_eq!(calls[0].name, "memory_recall");
2331    }
2332
2333    #[test]
2334    fn parse_tool_calls_preserves_openai_tool_call_ids() {
2335        let response = r#"{"tool_calls":[{"id":"call_42","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}"#;
2336        let (_, calls) = parse_tool_calls(response);
2337        assert_eq!(calls.len(), 1);
2338        assert_eq!(calls[0].tool_call_id.as_deref(), Some("call_42"));
2339    }
2340
2341    #[test]
2342    fn parse_tool_calls_handles_markdown_json_inside_tool_call_tag() {
2343        let response = r#"<tool_call>
2344```json
2345{"name": "file_write", "arguments": {"path": "test.py", "content": "print('ok')"}}
2346```
2347</tool_call>"#;
2348
2349        let (text, calls) = parse_tool_calls(response);
2350        assert!(text.is_empty());
2351        assert_eq!(calls.len(), 1);
2352        assert_eq!(calls[0].name, "file_write");
2353        assert_eq!(
2354            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
2355            "test.py"
2356        );
2357    }
2358
2359    #[test]
2360    fn parse_tool_calls_handles_noisy_tool_call_tag_body() {
2361        let response = r#"<tool_call>
2362I will now call the tool with this payload:
2363{"name": "shell", "arguments": {"command": "pwd"}}
2364</tool_call>"#;
2365
2366        let (text, calls) = parse_tool_calls(response);
2367        assert!(text.is_empty());
2368        assert_eq!(calls.len(), 1);
2369        assert_eq!(calls[0].name, "shell");
2370        assert_eq!(
2371            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2372            "pwd"
2373        );
2374    }
2375
2376    #[test]
2377    fn parse_tool_calls_handles_tool_call_inline_attributes_with_send_message_alias() {
2378        let response = r#"<tool_call>send_message channel="user_channel" message="Hello! How can I assist you today?"</tool_call>"#;
2379
2380        let (text, calls) = parse_tool_calls(response);
2381        assert!(text.is_empty());
2382        assert_eq!(calls.len(), 1);
2383        assert_eq!(calls[0].name, "message_send");
2384        assert_eq!(
2385            calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
2386            "user_channel"
2387        );
2388        assert_eq!(
2389            calls[0].arguments.get("message").unwrap().as_str().unwrap(),
2390            "Hello! How can I assist you today?"
2391        );
2392    }
2393
2394    #[test]
2395    fn parse_tool_calls_handles_tool_call_function_style_arguments() {
2396        let response = r#"<tool_call>message_send(channel="general", message="test")</tool_call>"#;
2397
2398        let (text, calls) = parse_tool_calls(response);
2399        assert!(text.is_empty());
2400        assert_eq!(calls.len(), 1);
2401        assert_eq!(calls[0].name, "message_send");
2402        assert_eq!(
2403            calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
2404            "general"
2405        );
2406        assert_eq!(
2407            calls[0].arguments.get("message").unwrap().as_str().unwrap(),
2408            "test"
2409        );
2410    }
2411
2412    #[test]
2413    fn parse_tool_calls_handles_xml_nested_tool_payload() {
2414        let response = r#"<tool_call>
2415<memory_recall>
2416<query>project roadmap</query>
2417</memory_recall>
2418</tool_call>"#;
2419
2420        let (text, calls) = parse_tool_calls(response);
2421        assert!(text.is_empty());
2422        assert_eq!(calls.len(), 1);
2423        assert_eq!(calls[0].name, "memory_recall");
2424        assert_eq!(
2425            calls[0].arguments.get("query").unwrap().as_str().unwrap(),
2426            "project roadmap"
2427        );
2428    }
2429
2430    #[test]
2431    fn parse_tool_calls_handles_plural_tool_calls_wrapper() {
2432        // Regression: Llama 4 Scout (via Groq) emits a plural `<tool_calls>`
2433        // wrapper rather than the singular `<tool_call>`. The parser must
2434        // enter it and execute the call instead of exposing raw XML. See #6875.
2435        let (text, calls) = parse_tool_calls(
2436            "<tool_calls>\n{\"name\":\"myserver__some_tool\",\"arguments\":{\"key\":\"value\"}}\n</tool_calls>",
2437        );
2438        assert_eq!(calls.len(), 1);
2439        assert_eq!(calls[0].name, "myserver__some_tool");
2440        assert_eq!(
2441            calls[0].arguments.get("key").unwrap().as_str().unwrap(),
2442            "value"
2443        );
2444        assert!(text.is_empty());
2445    }
2446
2447    #[test]
2448    fn parse_tool_calls_ignores_xml_thinking_wrapper() {
2449        let response = r#"<tool_call>
2450<thinking>Need to inspect memory first</thinking>
2451<memory_recall>
2452<query>recent deploy notes</query>
2453</memory_recall>
2454</tool_call>"#;
2455
2456        let (text, calls) = parse_tool_calls(response);
2457        assert!(text.is_empty());
2458        assert_eq!(calls.len(), 1);
2459        assert_eq!(calls[0].name, "memory_recall");
2460        assert_eq!(
2461            calls[0].arguments.get("query").unwrap().as_str().unwrap(),
2462            "recent deploy notes"
2463        );
2464    }
2465
2466    #[test]
2467    fn parse_tool_calls_handles_xml_with_json_arguments() {
2468        let response = r#"<tool_call>
2469<shell>{"command":"pwd"}</shell>
2470</tool_call>"#;
2471
2472        let (text, calls) = parse_tool_calls(response);
2473        assert!(text.is_empty());
2474        assert_eq!(calls.len(), 1);
2475        assert_eq!(calls[0].name, "shell");
2476        assert_eq!(
2477            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2478            "pwd"
2479        );
2480    }
2481
2482    #[test]
2483    fn parse_tool_calls_handles_markdown_tool_call_fence() {
2484        let response = r#"I'll check that.
2485```tool_call
2486{"name": "shell", "arguments": {"command": "pwd"}}
2487```
2488Done."#;
2489
2490        let (text, calls) = parse_tool_calls(response);
2491        assert_eq!(calls.len(), 1);
2492        assert_eq!(calls[0].name, "shell");
2493        assert_eq!(
2494            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2495            "pwd"
2496        );
2497        assert!(text.contains("I'll check that."));
2498        assert!(text.contains("Done."));
2499        assert!(!text.contains("```tool_call"));
2500    }
2501
2502    #[test]
2503    fn parse_tool_calls_handles_markdown_tool_call_hybrid_close_tag() {
2504        let response = r#"Preface
2505```tool-call
2506{"name": "shell", "arguments": {"command": "date"}}
2507</tool_call>
2508Tail"#;
2509
2510        let (text, calls) = parse_tool_calls(response);
2511        assert_eq!(calls.len(), 1);
2512        assert_eq!(calls[0].name, "shell");
2513        assert_eq!(
2514            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2515            "date"
2516        );
2517        assert!(text.contains("Preface"));
2518        assert!(text.contains("Tail"));
2519        assert!(!text.contains("```tool-call"));
2520    }
2521
2522    #[test]
2523    fn parse_tool_calls_handles_markdown_invoke_fence() {
2524        let response = r#"Checking.
2525```invoke
2526{"name": "shell", "arguments": {"command": "date"}}
2527```
2528Done."#;
2529
2530        let (text, calls) = parse_tool_calls(response);
2531        assert_eq!(calls.len(), 1);
2532        assert_eq!(calls[0].name, "shell");
2533        assert_eq!(
2534            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2535            "date"
2536        );
2537        assert!(text.contains("Checking."));
2538        assert!(text.contains("Done."));
2539    }
2540
2541    #[test]
2542    fn parse_tool_calls_handles_tool_name_fence_format() {
2543        //: xAI grok models use ```tool <name> format
2544        let response = r#"I'll write a test file.
2545```tool file_write
2546{"path": "/home/user/test.txt", "content": "Hello world"}
2547```
2548Done."#;
2549
2550        let (text, calls) = parse_tool_calls(response);
2551        assert_eq!(calls.len(), 1);
2552        assert_eq!(calls[0].name, "file_write");
2553        assert_eq!(
2554            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
2555            "/home/user/test.txt"
2556        );
2557        assert!(text.contains("I'll write a test file."));
2558        assert!(text.contains("Done."));
2559    }
2560
2561    #[test]
2562    fn parse_tool_calls_handles_tool_name_fence_shell() {
2563        //: Test shell command in ```tool shell format
2564        let response = r#"```tool shell
2565{"command": "ls -la"}
2566```"#;
2567
2568        let (_text, calls) = parse_tool_calls(response);
2569        assert_eq!(calls.len(), 1);
2570        assert_eq!(calls[0].name, "shell");
2571        assert_eq!(
2572            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2573            "ls -la"
2574        );
2575    }
2576
2577    #[test]
2578    fn parse_tool_calls_handles_multiple_tool_name_fences() {
2579        // Multiple tool calls in ```tool <name> format
2580        let response = r#"First, I'll write a file.
2581```tool file_write
2582{"path": "/tmp/a.txt", "content": "A"}
2583```
2584Then read it.
2585```tool file_read
2586{"path": "/tmp/a.txt"}
2587```
2588Done."#;
2589
2590        let (text, calls) = parse_tool_calls(response);
2591        assert_eq!(calls.len(), 2);
2592        assert_eq!(calls[0].name, "file_write");
2593        assert_eq!(calls[1].name, "file_read");
2594        assert!(text.contains("First, I'll write a file."));
2595        assert!(text.contains("Then read it."));
2596        assert!(text.contains("Done."));
2597    }
2598
2599    #[test]
2600    fn parse_tool_calls_handles_toolcall_tag_alias() {
2601        let response = r#"<toolcall>
2602{"name": "shell", "arguments": {"command": "date"}}
2603</toolcall>"#;
2604
2605        let (text, calls) = parse_tool_calls(response);
2606        assert!(text.is_empty());
2607        assert_eq!(calls.len(), 1);
2608        assert_eq!(calls[0].name, "shell");
2609        assert_eq!(
2610            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2611            "date"
2612        );
2613    }
2614
2615    #[test]
2616    fn parse_tool_calls_handles_tool_dash_call_tag_alias() {
2617        let response = r#"<tool-call>
2618{"name": "shell", "arguments": {"command": "whoami"}}
2619</tool-call>"#;
2620
2621        let (text, calls) = parse_tool_calls(response);
2622        assert!(text.is_empty());
2623        assert_eq!(calls.len(), 1);
2624        assert_eq!(calls[0].name, "shell");
2625        assert_eq!(
2626            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2627            "whoami"
2628        );
2629    }
2630
2631    #[test]
2632    fn parse_tool_calls_handles_invoke_tag_alias() {
2633        let response = r#"<invoke>
2634{"name": "shell", "arguments": {"command": "uptime"}}
2635</invoke>"#;
2636
2637        let (text, calls) = parse_tool_calls(response);
2638        assert!(text.is_empty());
2639        assert_eq!(calls.len(), 1);
2640        assert_eq!(calls[0].name, "shell");
2641        assert_eq!(
2642            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2643            "uptime"
2644        );
2645    }
2646
2647    #[test]
2648    fn parse_tool_calls_handles_minimax_invoke_parameter_format() {
2649        let response = r#"<minimax:tool_call>
2650<invoke name="shell">
2651<parameter name="command">sqlite3 /tmp/test.db ".tables"</parameter>
2652</invoke>
2653</minimax:tool_call>"#;
2654
2655        let (text, calls) = parse_tool_calls(response);
2656        assert!(text.is_empty());
2657        assert_eq!(calls.len(), 1);
2658        assert_eq!(calls[0].name, "shell");
2659        assert_eq!(
2660            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2661            r#"sqlite3 /tmp/test.db ".tables""#
2662        );
2663    }
2664
2665    #[test]
2666    fn parse_tool_calls_handles_minimax_invoke_with_surrounding_text() {
2667        let response = r#"Preface
2668<minimax:tool_call>
2669<invoke name='http_request'>
2670<parameter name='url'>https://example.com</parameter>
2671<parameter name='method'>GET</parameter>
2672</invoke>
2673</minimax:tool_call>
2674Tail"#;
2675
2676        let (text, calls) = parse_tool_calls(response);
2677        assert!(text.contains("Preface"));
2678        assert!(text.contains("Tail"));
2679        assert_eq!(calls.len(), 1);
2680        assert_eq!(calls[0].name, "http_request");
2681        assert_eq!(
2682            calls[0].arguments.get("url").unwrap().as_str().unwrap(),
2683            "https://example.com"
2684        );
2685        assert_eq!(
2686            calls[0].arguments.get("method").unwrap().as_str().unwrap(),
2687            "GET"
2688        );
2689    }
2690
2691    #[test]
2692    fn parse_tool_calls_handles_minimax_toolcall_alias_and_cross_close_tag() {
2693        let response = r#"<tool_call>
2694{"name":"shell","arguments":{"command":"date"}}
2695</minimax:toolcall>"#;
2696
2697        let (text, calls) = parse_tool_calls(response);
2698        assert!(text.is_empty());
2699        assert_eq!(calls.len(), 1);
2700        assert_eq!(calls[0].name, "shell");
2701        assert_eq!(
2702            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2703            "date"
2704        );
2705    }
2706
2707    #[test]
2708    fn parse_tool_calls_handles_perl_style_tool_call_blocks() {
2709        let response = r#"TOOL_CALL
2710{tool => "shell", args => { --command "uname -a" }}}
2711/TOOL_CALL"#;
2712
2713        let calls = parse_perl_style_tool_calls(response);
2714        assert_eq!(calls.len(), 1);
2715        assert_eq!(calls[0].name, "shell");
2716        assert_eq!(
2717            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2718            "uname -a"
2719        );
2720    }
2721
2722    #[test]
2723    fn parse_tool_calls_handles_square_bracket_tool_call_blocks() {
2724        let response =
2725            r#"[TOOL_CALL]{tool => "shell", args => {--command "echo hello"}}[/TOOL_CALL]"#;
2726
2727        let calls = parse_perl_style_tool_calls(response);
2728        assert_eq!(calls.len(), 1);
2729        assert_eq!(calls[0].name, "shell");
2730        assert_eq!(
2731            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2732            "echo hello"
2733        );
2734    }
2735
2736    #[test]
2737    fn parse_tool_calls_handles_square_bracket_multiline() {
2738        let response = r#"[TOOL_CALL]
2739{tool => "file_read", args => {
2740  --path "/tmp/test.txt"
2741  --description "Read test file"
2742}}
2743[/TOOL_CALL]"#;
2744
2745        let calls = parse_perl_style_tool_calls(response);
2746        assert_eq!(calls.len(), 1);
2747        assert_eq!(calls[0].name, "file_read");
2748        assert_eq!(
2749            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
2750            "/tmp/test.txt"
2751        );
2752        assert_eq!(
2753            calls[0]
2754                .arguments
2755                .get("description")
2756                .unwrap()
2757                .as_str()
2758                .unwrap(),
2759            "Read test file"
2760        );
2761    }
2762
2763    #[test]
2764    fn parse_tool_calls_recovers_unclosed_tool_call_with_json() {
2765        let response = r#"I will call the tool now.
2766<tool_call>
2767{"name": "shell", "arguments": {"command": "uptime -p"}}"#;
2768
2769        let (text, calls) = parse_tool_calls(response);
2770        assert!(text.contains("I will call the tool now."));
2771        assert_eq!(calls.len(), 1);
2772        assert_eq!(calls[0].name, "shell");
2773        assert_eq!(
2774            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2775            "uptime -p"
2776        );
2777    }
2778
2779    #[test]
2780    fn parse_tool_calls_recovers_mismatched_close_tag() {
2781        let response = r#"<tool_call>
2782{"name": "shell", "arguments": {"command": "uptime"}}
2783</arg_value>"#;
2784
2785        let (text, calls) = parse_tool_calls(response);
2786        assert!(text.is_empty());
2787        assert_eq!(calls.len(), 1);
2788        assert_eq!(calls[0].name, "shell");
2789        assert_eq!(
2790            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2791            "uptime"
2792        );
2793    }
2794
2795    #[test]
2796    fn parse_tool_calls_recovers_cross_alias_closing_tags() {
2797        let response = r#"<toolcall>
2798{"name": "shell", "arguments": {"command": "date"}}
2799</tool_call>"#;
2800
2801        let (text, calls) = parse_tool_calls(response);
2802        assert!(text.is_empty());
2803        assert_eq!(calls.len(), 1);
2804        assert_eq!(calls[0].name, "shell");
2805    }
2806
2807    #[test]
2808    fn parse_tool_calls_rejects_raw_tool_json_without_tags() {
2809        // SECURITY: Raw JSON without explicit wrappers should NOT be parsed
2810        // This prevents prompt injection attacks where malicious content
2811        // could include JSON that mimics a tool call.
2812        let response = r#"Sure, creating the file now.
2813{"name": "file_write", "arguments": {"path": "hello.py", "content": "print('hello')"}}"#;
2814
2815        let (text, calls) = parse_tool_calls(response);
2816        assert!(text.contains("Sure, creating the file now."));
2817        assert_eq!(
2818            calls.len(),
2819            0,
2820            "Raw JSON without wrappers should not be parsed"
2821        );
2822    }
2823
2824    #[test]
2825    fn parse_tool_calls_handles_empty_tool_result() {
2826        // Recovery: Empty tool_result tag should be handled gracefully
2827        let response = r#"I'll run that command.
2828<tool_result name="shell">
2829
2830</tool_result>
2831Done."#;
2832        let (text, calls) = parse_tool_calls(response);
2833        assert!(text.contains("Done."));
2834        assert!(calls.is_empty());
2835    }
2836
2837    #[test]
2838    fn strip_tool_result_blocks_removes_single_block() {
2839        let input = r#"<tool_result name="memory_recall" status="ok">
2840{"matches":["hello"]}
2841</tool_result>
2842Here is my answer."#;
2843        assert_eq!(strip_tool_result_blocks(input), "Here is my answer.");
2844    }
2845
2846    #[test]
2847    fn strip_tool_result_blocks_removes_multiple_blocks() {
2848        let input = r#"<tool_result name="memory_recall" status="ok">
2849{"matches":[]}
2850</tool_result>
2851<tool_result name="shell" status="ok">
2852done
2853</tool_result>
2854Final answer."#;
2855        assert_eq!(strip_tool_result_blocks(input), "Final answer.");
2856    }
2857
2858    #[test]
2859    fn strip_tool_result_blocks_removes_prefix() {
2860        let input =
2861            "[Tool results]\n<tool_result name=\"shell\" status=\"ok\">\nok\n</tool_result>\nDone.";
2862        assert_eq!(strip_tool_result_blocks(input), "Done.");
2863    }
2864
2865    #[test]
2866    fn strip_tool_result_blocks_removes_thinking() {
2867        let input = "<thinking>\nLet me think...\n</thinking>\nHere is the answer.";
2868        assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
2869    }
2870
2871    #[test]
2872    fn strip_tool_result_blocks_removes_think_tags() {
2873        let input = "<think>\nLet me reason...\n</think>\nHere is the answer.";
2874        assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
2875    }
2876
2877    #[test]
2878    fn parse_tool_calls_strips_think_before_tool_call() {
2879        // Qwen regression: <think> tags before <tool_call> tags should be
2880        // stripped, allowing the tool call to be parsed correctly.
2881        let response = "<think>I need to list files to understand the project</think>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}\n</tool_call>";
2882        let (text, calls) = parse_tool_calls(response);
2883        assert_eq!(
2884            calls.len(),
2885            1,
2886            "should parse tool call after stripping think tags"
2887        );
2888        assert_eq!(calls[0].name, "shell");
2889        assert_eq!(
2890            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2891            "ls"
2892        );
2893        assert!(text.is_empty(), "think content should not appear as text");
2894    }
2895
2896    #[test]
2897    fn parse_tool_calls_strips_think_only_returns_empty() {
2898        // When response is only <think> tags with no tool calls, should
2899        // return empty text and no calls.
2900        let response = "<think>Just thinking, no action needed</think>";
2901        let (text, calls) = parse_tool_calls(response);
2902        assert!(calls.is_empty());
2903        assert!(text.is_empty());
2904    }
2905
2906    #[test]
2907    fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
2908        let response = "<think>I need to check two things</think>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\n</tool_call>";
2909        let (_, calls) = parse_tool_calls(response);
2910        assert_eq!(calls.len(), 2);
2911        assert_eq!(
2912            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
2913            "date"
2914        );
2915        assert_eq!(
2916            calls[1].arguments.get("command").unwrap().as_str().unwrap(),
2917            "pwd"
2918        );
2919    }
2920
2921    #[test]
2922    fn strip_tool_result_blocks_preserves_clean_text() {
2923        let input = "Hello, this is a normal response.";
2924        assert_eq!(strip_tool_result_blocks(input), input);
2925    }
2926
2927    #[test]
2928    fn strip_tool_result_blocks_returns_empty_for_only_tags() {
2929        let input = "<tool_result name=\"memory_recall\" status=\"ok\">\n{}\n</tool_result>";
2930        assert_eq!(strip_tool_result_blocks(input), "");
2931    }
2932
2933    #[test]
2934    fn parse_arguments_value_handles_null() {
2935        // Recovery: null arguments are returned as-is (Value::Null)
2936        let value = serde_json::json!(null);
2937        let result = parse_arguments_value(Some(&value));
2938        assert!(result.is_null());
2939    }
2940
2941    #[test]
2942    fn parse_tool_calls_handles_empty_tool_calls_array() {
2943        // Recovery: Empty tool_calls array returns original response (no tool parsing)
2944        let response = r#"{"content": "Hello", "tool_calls": []}"#;
2945        let (text, calls) = parse_tool_calls(response);
2946        // When tool_calls is empty, the entire JSON is returned as text
2947        assert!(text.contains("Hello"));
2948        assert!(calls.is_empty());
2949    }
2950
2951    #[test]
2952    fn detect_tool_call_parse_issue_flags_malformed_payloads() {
2953        let response =
2954            "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}</tool_call>";
2955        let issue = detect_tool_call_parse_issue(response, &[]);
2956        assert!(
2957            issue.is_some(),
2958            "malformed tool payload should be flagged for diagnostics"
2959        );
2960    }
2961
2962    #[test]
2963    fn detect_tool_call_parse_issue_ignores_normal_text() {
2964        let issue = detect_tool_call_parse_issue("Thanks, done.", &[]);
2965        assert!(issue.is_none());
2966    }
2967
2968    #[test]
2969    fn detect_tool_call_parse_issue_ignores_empty_tool_calls_array() {
2970        let issue = detect_tool_call_parse_issue(r#"{"content":"Hello","tool_calls":[]}"#, &[]);
2971        assert!(issue.is_none());
2972    }
2973
2974    #[test]
2975    fn detect_tool_call_parse_issue_ignores_json_fenced_business_tool_calls() {
2976        let response = r#"```json
2977{"tool_calls":[{"service":"billing","count":2}]}
2978```"#;
2979        let issue = detect_tool_call_parse_issue(response, &[]);
2980        assert!(issue.is_none());
2981    }
2982
2983    #[test]
2984    fn detect_tool_call_parse_issue_ignores_tool_call_fenced_example() {
2985        let response = r#"```tool_call
2986{"name":"shell","arguments":{"command":"pwd"}}
2987```
2988This is an example, not an invocation."#;
2989
2990        let issue = detect_tool_call_parse_issue(response, &[]);
2991
2992        assert!(issue.is_none());
2993    }
2994
2995    #[test]
2996    fn detect_tool_call_parse_issue_flags_standalone_tool_call_fence() {
2997        let response = r#"```tool_call
2998{"name":"shell","arguments":{"command":"pwd"}}
2999```"#;
3000
3001        let issue = detect_tool_call_parse_issue(response, &[]);
3002
3003        assert!(issue.is_some());
3004    }
3005
3006    #[test]
3007    fn detect_tool_call_parse_issue_ignores_tool_call_tag_example() {
3008        let response = r#"<tool_call>
3009{"name":"shell","arguments":{"command":"pwd"}}
3010</tool_call>
3011This is an example, not an invocation."#;
3012
3013        let issue = detect_tool_call_parse_issue(response, &[]);
3014
3015        assert!(issue.is_none());
3016    }
3017
3018    #[test]
3019    fn detect_tool_call_parse_issue_flags_tagged_tool_call_with_trailing_text() {
3020        let response = r#"<tool_call>
3021{"name":"shell","arguments":{"command":"pwd"}}
3022</tool_call>
3023Done."#;
3024
3025        let issue = detect_tool_call_parse_issue(response, &[]);
3026
3027        assert!(issue.is_some());
3028    }
3029
3030    #[test]
3031    fn detect_tool_call_parse_issue_flags_json_fenced_tool_protocol() {
3032        let response = r#"```json
3033{"tool_calls":[{"name":"shell","arguments":{"command":"pwd"}}]}
3034```"#;
3035        let issue = detect_tool_call_parse_issue(response, &[]);
3036        assert!(issue.is_some());
3037    }
3038
3039    #[test]
3040    fn detect_tool_call_parse_issue_flags_malformed_tool_result_envelope() {
3041        let response = r#"{"tool_call_id":"call_1","content":"raw tool output""#;
3042        let issue = detect_tool_call_parse_issue(response, &[]);
3043        assert!(issue.is_some());
3044    }
3045
3046    #[test]
3047    fn detect_tool_call_parse_issue_ignores_malformed_tool_call_id_only_json() {
3048        let response = r#"{"tool_call_id":"support-case-1""#;
3049        let issue = detect_tool_call_parse_issue(response, &[]);
3050        assert!(issue.is_none());
3051    }
3052
3053    #[test]
3054    fn detect_tool_call_parse_issue_flags_malformed_nonempty_tool_calls_array() {
3055        let issue = detect_tool_call_parse_issue(
3056            r#"{"content":null,"tool_calls":[{"call_id":"call_1","arguments":"{}"}]}"#,
3057            &[],
3058        );
3059        assert!(issue.is_some());
3060    }
3061
3062    #[test]
3063    fn detect_tool_call_parse_issue_ignores_malformed_business_tool_calls_without_call_id() {
3064        for response in [
3065            r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}"#,
3066            r#"{"toolcalls":[{"name":"support_case","arguments":{"id":"A1"}}"#,
3067        ] {
3068            let issue = detect_tool_call_parse_issue(response, &[]);
3069
3070            assert!(
3071                issue.is_none(),
3072                "business JSON without a tool call id must not be treated as internal protocol: {response}"
3073            );
3074            assert!(
3075                !looks_like_malformed_tool_protocol_envelope(response),
3076                "business JSON without a tool call id must not be classified as malformed protocol: {response}"
3077            );
3078        }
3079    }
3080
3081    #[test]
3082    fn looks_like_tool_protocol_envelope_flags_malformed_nonempty_tool_calls_array() {
3083        assert!(looks_like_tool_protocol_envelope(
3084            r#"{"content":null,"tool_calls":[{"call_id":"call_1","arguments":"{}"}]}"#
3085        ));
3086        assert!(!looks_like_tool_protocol_envelope(
3087            r#"{"content":"Hello","tool_calls":[]}"#
3088        ));
3089    }
3090
3091    #[test]
3092    fn classify_tool_protocol_envelope_flags_internal_json_variants() {
3093        assert_eq!(
3094            classify_tool_protocol_envelope(
3095                r#"{"content":null,"tool_calls":[{"id":"call_1","name":"shell","arguments":"{}"}]}"#
3096            ),
3097            Some(ToolProtocolEnvelopeKind::ToolCalls)
3098        );
3099        assert_eq!(
3100            classify_tool_protocol_envelope(
3101                r#"{"toolcalls":[{"name":"shell","arguments":{"command":"pwd"}}]}"#
3102            ),
3103            Some(ToolProtocolEnvelopeKind::ToolCallsAlias)
3104        );
3105        assert_eq!(
3106            classify_tool_protocol_envelope(r#"{"tool_calls":[{"name":"shell","arguments":{}}]}"#),
3107            Some(ToolProtocolEnvelopeKind::ToolCalls)
3108        );
3109        assert_eq!(
3110            classify_tool_protocol_envelope(r#"{"toolcalls":[{"name":"shell","arguments":{}}]}"#),
3111            Some(ToolProtocolEnvelopeKind::ToolCallsAlias)
3112        );
3113        assert_eq!(
3114            classify_tool_protocol_envelope(
3115                r#"{"function_call":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}"#
3116            ),
3117            Some(ToolProtocolEnvelopeKind::FunctionCall)
3118        );
3119        assert_eq!(
3120            classify_tool_protocol_envelope(
3121                r#"{"tool_call_id":"call_1","content":"command output"}"#
3122            ),
3123            Some(ToolProtocolEnvelopeKind::ToolResult)
3124        );
3125        assert_eq!(
3126            classify_tool_protocol_envelope(
3127                r#"{"type":"function_call","call_id":"call_1","name":"shell","arguments":"{}"}"#
3128            ),
3129            Some(ToolProtocolEnvelopeKind::ResponsesFunctionCall)
3130        );
3131        assert_eq!(
3132            classify_tool_protocol_envelope(
3133                r#"```json
3134{"tool_calls":[{"name":"shell","arguments":{"command":"pwd"}}]}
3135```"#
3136            ),
3137            Some(ToolProtocolEnvelopeKind::ToolCalls)
3138        );
3139    }
3140
3141    #[test]
3142    fn classify_tool_protocol_envelope_preserves_tool_call_examples() {
3143        let fenced_example = r#"```tool_call
3144{"name":"shell","arguments":{"command":"pwd"}}
3145```
3146This is an example, not an invocation."#;
3147        let embedded_fenced_example = r#"Here is an example:
3148```tool_call
3149{"name":"shell","arguments":{"command":"pwd"}}
3150```"#;
3151        let embedded_fenced_example_cn = r#"例如:
3152```tool_call
3153{"name":"shell","arguments":{"command":"pwd"}}
3154```"#;
3155        let tag_example = r#"<tool_call>
3156{"name":"shell","arguments":{"command":"pwd"}}
3157</tool_call>
3158This is an example, not an invocation."#;
3159        let tag_example_cn = r#"比如:
3160<tool_call>
3161{"name":"shell","arguments":{"command":"pwd"}}
3162</tool_call>"#;
3163
3164        assert_eq!(classify_tool_protocol_envelope(fenced_example), None);
3165        assert!(!looks_like_tool_protocol_envelope(fenced_example));
3166        assert_eq!(
3167            classify_tool_protocol_envelope(embedded_fenced_example),
3168            None
3169        );
3170        assert!(!looks_like_tool_protocol_envelope(embedded_fenced_example));
3171        assert!(looks_like_tool_protocol_example(embedded_fenced_example));
3172        assert_eq!(
3173            classify_tool_protocol_envelope(embedded_fenced_example_cn),
3174            None
3175        );
3176        assert!(!looks_like_tool_protocol_envelope(
3177            embedded_fenced_example_cn
3178        ));
3179        assert!(looks_like_tool_protocol_example(embedded_fenced_example_cn));
3180        assert_eq!(classify_tool_protocol_envelope(tag_example), None);
3181        assert!(!looks_like_tool_protocol_envelope(tag_example));
3182        assert_eq!(classify_tool_protocol_envelope(tag_example_cn), None);
3183        assert!(!looks_like_tool_protocol_envelope(tag_example_cn));
3184        assert!(looks_like_tool_protocol_example(tag_example_cn));
3185    }
3186
3187    #[test]
3188    fn contains_tool_protocol_tag_call_flags_embedded_tool_call_fences() {
3189        let embedded = r#"Let me call it:
3190```tool_call
3191{"name":"shell","arguments":{"command":"pwd"}}
3192```
3193Done."#;
3194
3195        assert!(contains_tool_protocol_tag_call(embedded));
3196    }
3197
3198    #[test]
3199    fn classify_tool_protocol_envelope_flags_standalone_tool_fences() {
3200        let tool_call_fence = r#"```tool_call
3201{"name":"shell","arguments":{"command":"pwd"}}
3202```"#;
3203        let invoke_fence = r#"```invoke
3204{"name":"shell","arguments":{"command":"pwd"}}
3205```"#;
3206        let tool_name_fence = r#"```tool shell
3207{"command":"pwd"}
3208```"#;
3209
3210        assert_eq!(
3211            classify_tool_protocol_envelope(tool_call_fence),
3212            Some(ToolProtocolEnvelopeKind::TaggedToolCall)
3213        );
3214        assert!(looks_like_tool_protocol_envelope(tool_call_fence));
3215        assert_eq!(
3216            classify_tool_protocol_envelope(invoke_fence),
3217            Some(ToolProtocolEnvelopeKind::TaggedToolCall)
3218        );
3219        assert!(looks_like_tool_protocol_envelope(invoke_fence));
3220        assert_eq!(
3221            classify_tool_protocol_envelope(tool_name_fence),
3222            Some(ToolProtocolEnvelopeKind::TaggedToolCall)
3223        );
3224        assert!(looks_like_tool_protocol_envelope(tool_name_fence));
3225    }
3226
3227    #[test]
3228    fn classify_tool_protocol_envelope_preserves_top_level_arrays_without_protocol_marker() {
3229        assert!(!looks_like_tool_protocol_envelope(
3230            r#"[{"service":"billing","count":2}]"#
3231        ));
3232
3233        assert!(!looks_like_tool_protocol_envelope(
3234            r#"[{"name":"shell","arguments":{}}]"#
3235        ));
3236    }
3237
3238    #[test]
3239    fn classify_tool_protocol_envelope_preserves_top_level_schema_array() {
3240        let schema = r#"[{"name":"planner","parameters":{"goal":"string"}}]"#;
3241
3242        assert_eq!(classify_tool_protocol_envelope(schema), None);
3243        assert!(!looks_like_tool_protocol_envelope(schema));
3244    }
3245
3246    #[test]
3247    fn classify_tool_protocol_envelope_preserves_plain_user_json() {
3248        let profile = r#"{"name":"profile","parameters":{"timezone":"UTC"}}"#;
3249        assert_eq!(classify_tool_protocol_envelope(profile), None);
3250        assert!(!looks_like_tool_protocol_envelope(profile));
3251    }
3252
3253    #[test]
3254    fn looks_like_tool_protocol_envelope_preserves_plain_json_with_similar_keys() {
3255        let config = r#"{"function_call":false,"description":"disable the feature"}"#;
3256        assert!(!looks_like_tool_protocol_envelope(config));
3257
3258        let audit_log = r#"{"tool_calls":[{"service":"billing","count":2}]}"#;
3259        assert!(!looks_like_tool_protocol_envelope(audit_log));
3260
3261        let queued_case =
3262            r#"{"tool_calls":[{"id":"case-1","status":"queued","service":"billing"}]}"#;
3263        assert!(!looks_like_tool_protocol_envelope(queued_case));
3264
3265        let named_record =
3266            r#"{"tool_calls":[{"name":"planner","status":"queued","service":"workflow"}]}"#;
3267        assert!(!looks_like_tool_protocol_envelope(named_record));
3268    }
3269
3270    #[test]
3271    fn parse_tool_calls_handles_whitespace_only_name() {
3272        // Recovery: Whitespace-only tool name should return None
3273        let value = serde_json::json!({"function": {"name": "   ", "arguments": {}}});
3274        let result = parse_tool_call_value(&value);
3275        assert!(result.is_none());
3276    }
3277
3278    #[test]
3279    fn parse_tool_calls_handles_empty_string_arguments() {
3280        // Recovery: Empty string arguments should be handled
3281        let value = serde_json::json!({"name": "test", "arguments": ""});
3282        let result = parse_tool_call_value(&value);
3283        assert!(result.is_some());
3284        assert_eq!(result.unwrap().name, "test");
3285    }
3286
3287    #[test]
3288    fn parse_arguments_value_handles_invalid_json_string() {
3289        // Recovery: Invalid JSON string should return empty object
3290        let value = serde_json::Value::String("not valid json".to_string());
3291        let result = parse_arguments_value(Some(&value));
3292        assert!(result.is_object());
3293        assert!(result.as_object().unwrap().is_empty());
3294    }
3295
3296    #[test]
3297    fn parse_arguments_value_handles_none() {
3298        // Recovery: None arguments should return empty object
3299        let result = parse_arguments_value(None);
3300        assert!(result.is_object());
3301        assert!(result.as_object().unwrap().is_empty());
3302    }
3303
3304    #[test]
3305    fn parse_tool_calls_from_json_value_handles_empty_array() {
3306        // Recovery: Empty tool_calls array should return empty vec
3307        let value = serde_json::json!({"tool_calls": []});
3308        let result = parse_tool_calls_from_json_value(&value);
3309        assert!(result.is_empty());
3310    }
3311
3312    #[test]
3313    fn parse_tool_calls_from_json_value_handles_missing_tool_calls() {
3314        // Recovery: Missing tool_calls field should fall through
3315        let value = serde_json::json!({"name": "test", "arguments": {}});
3316        let result = parse_tool_calls_from_json_value(&value);
3317        assert_eq!(result.len(), 1);
3318    }
3319
3320    #[test]
3321    fn parse_tool_calls_from_json_value_handles_top_level_array() {
3322        // Recovery: Top-level array of tool calls
3323        let value = serde_json::json!([
3324            {"name": "tool_a", "arguments": {}},
3325            {"name": "tool_b", "arguments": {}}
3326        ]);
3327        let result = parse_tool_calls_from_json_value(&value);
3328        assert_eq!(result.len(), 2);
3329    }
3330
3331    #[test]
3332    fn parse_glm_style_browser_open_url() {
3333        let response = "browser_open/url>https://example.com";
3334        let calls = parse_glm_style_tool_calls(response);
3335        assert_eq!(calls.len(), 1);
3336        assert_eq!(calls[0].0, "shell");
3337        assert!(calls[0].1["command"].as_str().unwrap().contains("curl"));
3338        assert!(
3339            calls[0].1["command"]
3340                .as_str()
3341                .unwrap()
3342                .contains("example.com")
3343        );
3344    }
3345
3346    #[test]
3347    fn parse_glm_style_shell_command() {
3348        let response = "shell/command>ls -la";
3349        let calls = parse_glm_style_tool_calls(response);
3350        assert_eq!(calls.len(), 1);
3351        assert_eq!(calls[0].0, "shell");
3352        assert_eq!(calls[0].1["command"], "ls -la");
3353    }
3354
3355    #[test]
3356    fn parse_glm_style_http_request() {
3357        let response = "http_request/url>https://api.example.com/data";
3358        let calls = parse_glm_style_tool_calls(response);
3359        assert_eq!(calls.len(), 1);
3360        assert_eq!(calls[0].0, "http_request");
3361        assert_eq!(calls[0].1["url"], "https://api.example.com/data");
3362        assert_eq!(calls[0].1["method"], "GET");
3363    }
3364
3365    #[test]
3366    fn parse_glm_style_ignores_plain_url() {
3367        // A bare URL should NOT be interpreted as a tool call — this was
3368        // causing false positives when LLMs included URLs in normal text.
3369        let response = "https://example.com/api";
3370        let calls = parse_glm_style_tool_calls(response);
3371        assert!(
3372            calls.is_empty(),
3373            "plain URL must not be parsed as tool call"
3374        );
3375    }
3376
3377    #[test]
3378    fn parse_glm_style_json_args() {
3379        let response = r#"shell/{"command": "echo hello"}"#;
3380        let calls = parse_glm_style_tool_calls(response);
3381        assert_eq!(calls.len(), 1);
3382        assert_eq!(calls[0].0, "shell");
3383        assert_eq!(calls[0].1["command"], "echo hello");
3384    }
3385
3386    #[test]
3387    fn parse_glm_style_multiple_calls() {
3388        let response = r#"shell/command>ls
3389browser_open/url>https://example.com"#;
3390        let calls = parse_glm_style_tool_calls(response);
3391        assert_eq!(calls.len(), 2);
3392    }
3393
3394    #[test]
3395    fn parse_glm_style_tool_call_integration() {
3396        // Integration test: GLM format should be parsed in parse_tool_calls
3397        let response = "Checking...\nbrowser_open/url>https://example.com\nDone";
3398        let (text, calls) = parse_tool_calls(response);
3399        assert_eq!(calls.len(), 1);
3400        assert_eq!(calls[0].name, "shell");
3401        assert!(text.contains("Checking"));
3402        assert!(text.contains("Done"));
3403    }
3404
3405    #[test]
3406    fn parse_glm_style_rejects_non_http_url_param() {
3407        let response = "browser_open/url>javascript:alert(1)";
3408        let calls = parse_glm_style_tool_calls(response);
3409        assert!(calls.is_empty());
3410    }
3411
3412    #[test]
3413    fn parse_tool_calls_handles_unclosed_tool_call_tag() {
3414        let response = "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
3415        let (text, calls) = parse_tool_calls(response);
3416        assert_eq!(calls.len(), 1);
3417        assert_eq!(calls[0].name, "shell");
3418        assert_eq!(calls[0].arguments["command"], "pwd");
3419        assert_eq!(text, "Done");
3420    }
3421
3422    #[test]
3423    fn parse_tool_calls_empty_input_returns_empty() {
3424        let (text, calls) = parse_tool_calls("");
3425        assert!(calls.is_empty(), "empty input should produce no tool calls");
3426        assert!(text.is_empty(), "empty input should produce no text");
3427    }
3428
3429    #[test]
3430    fn parse_tool_calls_whitespace_only_returns_empty_calls() {
3431        let (text, calls) = parse_tool_calls("   \n\t  ");
3432        assert!(calls.is_empty());
3433        assert!(text.is_empty() || text.trim().is_empty());
3434    }
3435
3436    #[test]
3437    fn parse_tool_calls_nested_xml_tags_handled() {
3438        // Double-wrapped tool call should still parse the inner call
3439        let response = r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
3440        let (_text, calls) = parse_tool_calls(response);
3441        // Should find at least one tool call
3442        assert!(
3443            !calls.is_empty(),
3444            "nested XML tags should still yield at least one tool call"
3445        );
3446    }
3447
3448    #[test]
3449    fn parse_tool_calls_truncated_json_no_panic() {
3450        // Incomplete JSON inside tool_call tags
3451        let response = r#"<tool_call>{"name":"shell","arguments":{"command":"ls"</tool_call>"#;
3452        let (_text, _calls) = parse_tool_calls(response);
3453        // Should not panic — graceful handling of truncated JSON
3454    }
3455
3456    #[test]
3457    fn parse_tool_calls_empty_json_object_in_tag() {
3458        let response = "<tool_call>{}</tool_call>";
3459        let (_text, calls) = parse_tool_calls(response);
3460        // Empty JSON object has no name field — should not produce valid tool call
3461        assert!(
3462            calls.is_empty(),
3463            "empty JSON object should not produce a tool call"
3464        );
3465    }
3466
3467    #[test]
3468    fn parse_tool_calls_closing_tag_only_returns_text() {
3469        let response = "Some text </tool_call> more text";
3470        let (text, calls) = parse_tool_calls(response);
3471        assert!(
3472            calls.is_empty(),
3473            "closing tag only should not produce calls"
3474        );
3475        assert!(
3476            !text.is_empty(),
3477            "text around orphaned closing tag should be preserved"
3478        );
3479    }
3480
3481    #[test]
3482    fn parse_tool_calls_very_large_arguments_no_panic() {
3483        let large_arg = "x".repeat(100_000);
3484        let response = format!(
3485            r#"<tool_call>{{"name":"echo","arguments":{{"message":"{}"}}}}</tool_call>"#,
3486            large_arg
3487        );
3488        let (_text, calls) = parse_tool_calls(&response);
3489        assert_eq!(calls.len(), 1, "large arguments should still parse");
3490        assert_eq!(calls[0].name, "echo");
3491    }
3492
3493    #[test]
3494    fn parse_tool_calls_special_characters_in_arguments() {
3495        let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
3496        let (_text, calls) = parse_tool_calls(response);
3497        assert_eq!(calls.len(), 1);
3498        assert_eq!(calls[0].name, "echo");
3499    }
3500
3501    #[test]
3502    fn parse_tool_calls_text_with_embedded_json_not_extracted() {
3503        // Raw JSON without any tags should NOT be extracted as a tool call
3504        let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
3505        let (_text, calls) = parse_tool_calls(response);
3506        assert!(
3507            calls.is_empty(),
3508            "raw JSON in text without tags should not be extracted"
3509        );
3510    }
3511
3512    #[test]
3513    fn parse_tool_calls_multiple_formats_mixed() {
3514        // Mix of text and properly tagged tool call
3515        let response = r#"I'll help you with that.
3516
3517<tool_call>
3518{"name":"shell","arguments":{"command":"echo hello"}}
3519</tool_call>
3520
3521Let me check the result."#;
3522        let (text, calls) = parse_tool_calls(response);
3523        assert_eq!(
3524            calls.len(),
3525            1,
3526            "should extract one tool call from mixed content"
3527        );
3528        assert_eq!(calls[0].name, "shell");
3529        assert!(
3530            text.contains("help you"),
3531            "text before tool call should be preserved"
3532        );
3533    }
3534
3535    #[test]
3536    fn parse_tool_calls_cross_alias_close_tag_with_json() {
3537        // <tool_call> opened but closed with </invoke> — JSON body
3538        let input = r#"<tool_call>{"name": "shell", "arguments": {"command": "ls"}}</invoke>"#;
3539        let (text, calls) = parse_tool_calls(input);
3540        assert_eq!(calls.len(), 1);
3541        assert_eq!(calls[0].name, "shell");
3542        assert_eq!(calls[0].arguments["command"], "ls");
3543        assert!(text.is_empty());
3544    }
3545
3546    #[test]
3547    fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() {
3548        // <tool_call>shell>uname -a</invoke> — GLM shortened inside cross-alias tags
3549        let input = "<tool_call>shell>uname -a</invoke>";
3550        let (text, calls) = parse_tool_calls(input);
3551        assert_eq!(calls.len(), 1);
3552        assert_eq!(calls[0].name, "shell");
3553        assert_eq!(calls[0].arguments["command"], "uname -a");
3554        assert!(text.is_empty());
3555    }
3556
3557    #[test]
3558    fn parse_tool_calls_glm_shortened_body_in_matched_tags() {
3559        // <tool_call>shell>pwd</tool_call> — GLM shortened in matched tags
3560        let input = "<tool_call>shell>pwd</tool_call>";
3561        let (text, calls) = parse_tool_calls(input);
3562        assert_eq!(calls.len(), 1);
3563        assert_eq!(calls[0].name, "shell");
3564        assert_eq!(calls[0].arguments["command"], "pwd");
3565        assert!(text.is_empty());
3566    }
3567
3568    #[test]
3569    fn parse_tool_calls_glm_yaml_style_in_tags() {
3570        // <tool_call>shell>\ncommand: date\napproved: true</invoke>
3571        let input = "<tool_call>shell>\ncommand: date\napproved: true</invoke>";
3572        let (text, calls) = parse_tool_calls(input);
3573        assert_eq!(calls.len(), 1);
3574        assert_eq!(calls[0].name, "shell");
3575        assert_eq!(calls[0].arguments["command"], "date");
3576        assert_eq!(calls[0].arguments["approved"], true);
3577        assert!(text.is_empty());
3578    }
3579
3580    #[test]
3581    fn parse_tool_calls_attribute_style_in_tags() {
3582        // <tool_call>shell command="date" /></tool_call>
3583        let input = r#"<tool_call>shell command="date" /></tool_call>"#;
3584        let (text, calls) = parse_tool_calls(input);
3585        assert_eq!(calls.len(), 1);
3586        assert_eq!(calls[0].name, "shell");
3587        assert_eq!(calls[0].arguments["command"], "date");
3588        assert!(text.is_empty());
3589    }
3590
3591    #[test]
3592    fn parse_tool_calls_file_read_shortened_in_cross_alias() {
3593        // <tool_call>file_read path=".env" /></invoke>
3594        let input = r#"<tool_call>file_read path=".env" /></invoke>"#;
3595        let (text, calls) = parse_tool_calls(input);
3596        assert_eq!(calls.len(), 1);
3597        assert_eq!(calls[0].name, "file_read");
3598        assert_eq!(calls[0].arguments["path"], ".env");
3599        assert!(text.is_empty());
3600    }
3601
3602    #[test]
3603    fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() {
3604        // <tool_call>shell>ls -la (no close tag at all)
3605        let input = "<tool_call>shell>ls -la";
3606        let (text, calls) = parse_tool_calls(input);
3607        assert_eq!(calls.len(), 1);
3608        assert_eq!(calls[0].name, "shell");
3609        assert_eq!(calls[0].arguments["command"], "ls -la");
3610        assert!(text.is_empty());
3611    }
3612
3613    #[test]
3614    fn parse_tool_calls_text_before_cross_alias() {
3615        // Text before and after cross-alias tool call
3616        let input = "Let me check that.\n<tool_call>shell>uname -a</invoke>\nDone.";
3617        let (text, calls) = parse_tool_calls(input);
3618        assert_eq!(calls.len(), 1);
3619        assert_eq!(calls[0].name, "shell");
3620        assert_eq!(calls[0].arguments["command"], "uname -a");
3621        assert!(text.contains("Let me check that."));
3622        assert!(text.contains("Done."));
3623    }
3624
3625    #[test]
3626    fn parse_glm_shortened_body_url_to_curl() {
3627        // URL values for shell should be wrapped in curl
3628        let call = parse_glm_shortened_body("shell>https://example.com/api").unwrap();
3629        assert_eq!(call.name, "shell");
3630        let cmd = call.arguments["command"].as_str().unwrap();
3631        assert!(cmd.contains("curl"));
3632        assert!(cmd.contains("example.com"));
3633    }
3634
3635    #[test]
3636    fn parse_glm_shortened_body_browser_open_maps_to_shell_command() {
3637        // browser_open aliases to shell, and shortened calls must still emit
3638        // shell's canonical "command" argument.
3639        let call = parse_glm_shortened_body("browser_open>https://example.com").unwrap();
3640        assert_eq!(call.name, "shell");
3641        let cmd = call.arguments["command"].as_str().unwrap();
3642        assert!(cmd.contains("curl"));
3643        assert!(cmd.contains("example.com"));
3644    }
3645
3646    #[test]
3647    fn parse_glm_shortened_body_memory_recall() {
3648        // memory_recall>some query — default param is "query"
3649        let call = parse_glm_shortened_body("memory_recall>recent meetings").unwrap();
3650        assert_eq!(call.name, "memory_recall");
3651        assert_eq!(call.arguments["query"], "recent meetings");
3652    }
3653
3654    #[test]
3655    fn parse_glm_shortened_body_function_style_alias_maps_to_message_send() {
3656        let call =
3657            parse_glm_shortened_body(r#"sendmessage(channel="alerts", message="hi")"#).unwrap();
3658        assert_eq!(call.name, "message_send");
3659        assert_eq!(call.arguments["channel"], "alerts");
3660        assert_eq!(call.arguments["message"], "hi");
3661    }
3662
3663    #[test]
3664    fn parse_glm_shortened_body_rejects_empty() {
3665        assert!(parse_glm_shortened_body("").is_none());
3666        assert!(parse_glm_shortened_body("   ").is_none());
3667    }
3668
3669    #[test]
3670    fn parse_glm_shortened_body_rejects_invalid_tool_name() {
3671        // Tool names with special characters should be rejected
3672        assert!(parse_glm_shortened_body("not-a-tool>value").is_none());
3673        assert!(parse_glm_shortened_body("tool name>value").is_none());
3674    }
3675
3676    #[test]
3677    fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() {
3678        let calls = vec![ParsedToolCall {
3679            name: "shell".into(),
3680            arguments: serde_json::json!({"command": "pwd"}),
3681            tool_call_id: Some("call_2".into()),
3682        }];
3683        let result = build_native_assistant_history_from_parsed_calls(
3684            "answer",
3685            &calls,
3686            Some("deep thought"),
3687        );
3688        assert!(result.is_some());
3689        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
3690        assert_eq!(parsed["content"].as_str(), Some("answer"));
3691        assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought"));
3692        assert!(parsed["tool_calls"].is_array());
3693    }
3694
3695    #[test]
3696    fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() {
3697        let calls = vec![ParsedToolCall {
3698            name: "shell".into(),
3699            arguments: serde_json::json!({"command": "pwd"}),
3700            tool_call_id: Some("call_2".into()),
3701        }];
3702        let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
3703        assert!(result.is_some());
3704        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
3705        assert_eq!(parsed["content"].as_str(), Some("answer"));
3706        assert!(parsed.get("reasoning_content").is_none());
3707    }
3708
3709    // ═══════════════════════════════════════════════════════════════════════
3710
3711    // ═══════════════════════════════════════════════════════════════════════
3712    // Additional parser internals tests (moved from zeroclaw-runtime to keep
3713    // functions crate-private per Beta-tier API stability policy)
3714    // ═══════════════════════════════════════════════════════════════════════
3715
3716    #[test]
3717    fn parse_tool_call_value_handles_missing_name_field() {
3718        let value = serde_json::json!({"function": {"arguments": {}}});
3719        let result = parse_tool_call_value(&value);
3720        assert!(result.is_none());
3721    }
3722
3723    #[test]
3724    fn parse_tool_call_value_handles_top_level_name() {
3725        let value = serde_json::json!({"name": "test_tool", "arguments": {}});
3726        let result = parse_tool_call_value(&value);
3727        assert!(result.is_some());
3728        assert_eq!(result.unwrap().name, "test_tool");
3729    }
3730
3731    #[test]
3732    fn parse_tool_call_value_accepts_top_level_parameters_alias() {
3733        let value = serde_json::json!({
3734            "name": "schedule",
3735            "parameters": {"action": "create", "message": "test"}
3736        });
3737        let result = parse_tool_call_value(&value).expect("tool call should parse");
3738        assert_eq!(result.name, "schedule");
3739        assert_eq!(
3740            result.arguments.get("action").and_then(|v| v.as_str()),
3741            Some("create")
3742        );
3743    }
3744
3745    #[test]
3746    fn parse_tool_call_value_accepts_function_parameters_alias() {
3747        let value = serde_json::json!({
3748            "function": {
3749                "name": "shell",
3750                "parameters": {"command": "date"}
3751            }
3752        });
3753        let result = parse_tool_call_value(&value).expect("tool call should parse");
3754        assert_eq!(result.name, "shell");
3755        assert_eq!(
3756            result.arguments.get("command").and_then(|v| v.as_str()),
3757            Some("date")
3758        );
3759    }
3760
3761    #[test]
3762    fn parse_tool_call_value_preserves_tool_call_id_aliases() {
3763        let value = serde_json::json!({
3764            "call_id": "legacy_1",
3765            "function": {
3766                "name": "shell",
3767                "arguments": {"command": "date"}
3768            }
3769        });
3770        let result = parse_tool_call_value(&value).expect("tool call should parse");
3771        assert_eq!(result.tool_call_id.as_deref(), Some("legacy_1"));
3772    }
3773
3774    #[test]
3775    fn extract_json_values_handles_empty_string() {
3776        let result = extract_json_values("");
3777        assert!(result.is_empty());
3778    }
3779
3780    #[test]
3781    fn extract_json_values_handles_whitespace_only() {
3782        let result = extract_json_values(
3783            "   
3784	  ",
3785        );
3786        assert!(result.is_empty());
3787    }
3788
3789    #[test]
3790    fn extract_json_values_handles_multiple_objects() {
3791        let input = r#"{"a": 1}{"b": 2}{"c": 3}"#;
3792        let result = extract_json_values(input);
3793        assert_eq!(result.len(), 3);
3794    }
3795
3796    #[test]
3797    fn extract_json_values_handles_arrays() {
3798        let input = r#"[1, 2, 3]{"key": "value"}"#;
3799        let result = extract_json_values(input);
3800        assert_eq!(result.len(), 2);
3801    }
3802
3803    #[test]
3804    fn map_tool_name_alias_direct_coverage() {
3805        assert_eq!(map_tool_name_alias("bash"), "shell");
3806        assert_eq!(map_tool_name_alias("filelist"), "file_list");
3807        assert_eq!(map_tool_name_alias("memorystore"), "memory_store");
3808        assert_eq!(map_tool_name_alias("memoryforget"), "memory_forget");
3809        assert_eq!(map_tool_name_alias("http"), "http_request");
3810        assert_eq!(
3811            map_tool_name_alias("totally_unknown_tool"),
3812            "totally_unknown_tool"
3813        );
3814    }
3815
3816    #[test]
3817    fn map_tool_name_alias_strips_dotted_namespaces() {
3818        // Gemini-style static prefixes still work.
3819        assert_eq!(map_tool_name_alias("default_api.file_read"), "file_read");
3820        assert_eq!(map_tool_name_alias("tools.shell"), "shell");
3821
3822        // MCP-server-name prefixes (Gemini-via-OpenRouter also emits these
3823        // when the tool originates from an MCP server; the registry is
3824        // indexed by bare tool name, so we must strip them too).
3825        assert_eq!(
3826            map_tool_name_alias("google_workspace.search_gmail_messages"),
3827            "search_gmail_messages"
3828        );
3829
3830        // Only the final segment is kept even with multiple dots.
3831        assert_eq!(map_tool_name_alias("a.b.c.final"), "final");
3832
3833        // Stripped segment still runs through the alias table.
3834        assert_eq!(map_tool_name_alias("default_api.bash"), "shell");
3835
3836        // Names without any dot are unaffected.
3837        assert_eq!(map_tool_name_alias("file_read"), "file_read");
3838    }
3839
3840    #[test]
3841    fn default_param_for_tool_coverage() {
3842        assert_eq!(default_param_for_tool("shell"), "command");
3843        assert_eq!(default_param_for_tool("bash"), "command");
3844        assert_eq!(default_param_for_tool("file_read"), "path");
3845        assert_eq!(default_param_for_tool("memory_recall"), "query");
3846        assert_eq!(default_param_for_tool("memory_store"), "content");
3847        assert_eq!(default_param_for_tool("web_search_tool"), "query");
3848        assert_eq!(default_param_for_tool("web_search"), "query");
3849        assert_eq!(default_param_for_tool("search"), "query");
3850        assert_eq!(default_param_for_tool("http_request"), "url");
3851        assert_eq!(default_param_for_tool("browser_open"), "url");
3852        assert_eq!(default_param_for_tool("unknown_tool"), "input");
3853    }
3854}