Skip to main content

a3s_code_core/llm/
text_tool_calls.rs

1//! Recover tool invocations that a model wrote into assistant text.
2//!
3//! Some providers return a finished text turn instead of structured
4//! `tool_calls`. The invocations still have a stable markup shape. Executing
5//! that markup is the tool call; leaving it in the transcript is a failed call.
6//!
7//! Dialects recovered here:
8//! - attribute form: `<tool_call>name key="…" /></tool_call>`
9//! - WorkBuddy bare: `<tool_call>name>…</name>` / `</tool_call>`
10//! - WorkBuddy tagged (hy3/`auto`): `<tool_call:id>name">…</invoke>`
11//! - Claude-style: `<invoke name="…">…</invoke>`
12//! - DeepSeek DSML (V3.2/V4): `<|DSML|invoke name="…">…</|DSML|invoke>`
13//!   (ASCII `||DSML||` accepted as a fallback for mangled streams)
14
15use super::types::{ContentBlock, Message, ToolCall};
16use serde_json::{Map, Value};
17
18/// Canonical DeepSeek DSML namespace token (`|` is U+FF5C).
19const DSML_NS: &str = "\u{FF5C}DSML\u{FF5C}";
20/// ASCII fallback seen when fullwidth pipes are mangled in transit/UI.
21const DSML_NS_ASCII: &str = "||DSML||";
22
23/// Turn leaked `<tool_call>` markup into structured tool-use blocks.
24///
25/// Existing structured calls are kept. Markup is removed from the visible
26/// text either way, so a protocol fragment is never the answer.
27pub(crate) fn recover_text_tool_calls(message: &mut Message) -> Vec<ToolCall> {
28    if message.role != "assistant" {
29        return message.tool_calls();
30    }
31    let already_structured = message
32        .content
33        .iter()
34        .any(|block| matches!(block, ContentBlock::ToolUse { .. }));
35    let mut recovered = Vec::new();
36    let mut next_content = Vec::with_capacity(message.content.len());
37    for block in message.content.drain(..) {
38        let ContentBlock::Text { text } = block else {
39            next_content.push(block);
40            continue;
41        };
42        let (prose, calls) = split_leaked_tool_calls(&text);
43        if !already_structured {
44            for call in calls {
45                let id = call
46                    .id
47                    .unwrap_or_else(|| format!("text-tool-{}", recovered.len() + 1));
48                recovered.push(ToolCall {
49                    id: id.clone(),
50                    name: call.name.clone(),
51                    args: call.input.clone(),
52                });
53                next_content.push(ContentBlock::ToolUse {
54                    id,
55                    name: call.name,
56                    input: call.input,
57                });
58            }
59        }
60        if !prose.is_empty() {
61            next_content.push(ContentBlock::Text { text: prose });
62        }
63    }
64    message.content = next_content;
65    if already_structured {
66        message.tool_calls()
67    } else {
68        recovered
69    }
70}
71
72/// Strip tool-protocol markup from assistant text without recovering calls.
73///
74/// Used by hosts that accumulate streamed deltas before recovery runs, so
75/// Auto-review does not treat DSML/`<tool_call>` blobs as product prose.
76pub fn strip_leaked_tool_protocol(text: &str) -> String {
77    let (prose, _) = split_leaked_tool_calls(text);
78    prose
79}
80
81/// Tool calls a model wrote as text instead of a structured tool response.
82///
83/// Each item is `(id, name, arguments)`.
84pub fn recover_leaked_tool_calls(text: &str) -> Vec<(String, String, Value)> {
85    split_leaked_tool_calls(text)
86        .1
87        .into_iter()
88        .enumerate()
89        .map(|(index, call)| {
90            (
91                call.id.unwrap_or_else(|| format!("leaked-{index}")),
92                call.name,
93                call.input,
94            )
95        })
96        .collect()
97}
98
99struct ParsedCall {
100    id: Option<String>,
101    name: String,
102    input: Value,
103}
104
105#[derive(Clone, Copy)]
106enum MarkupKind {
107    Tagged,
108    OpenToolCall,
109    Invoke,
110    DsmlInvoke,
111}
112
113fn split_leaked_tool_calls(text: &str) -> (String, Vec<ParsedCall>) {
114    let mut prose = String::new();
115    let mut calls = Vec::new();
116    let mut rest = text;
117    while let Some((kind, start)) = find_next_markup(rest) {
118        prose.push_str(&rest[..start]);
119        let at = &rest[start..];
120        match take_markup(kind, at) {
121            Some((call, consumed)) => {
122                calls.push(call);
123                rest = &at[consumed..];
124            }
125            None => {
126                // Advance one byte so a malformed fragment cannot loop forever.
127                let advance = at.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
128                prose.push_str(&at[..advance]);
129                rest = &at[advance..];
130            }
131        }
132    }
133    prose.push_str(rest);
134    let prose = scrub_wrapper_markup(&prose)
135        .lines()
136        .map(str::trim_end)
137        .collect::<Vec<_>>()
138        .join("\n")
139        .trim()
140        .to_string();
141    (prose, calls)
142}
143
144fn find_next_markup(text: &str) -> Option<(MarkupKind, usize)> {
145    let tagged = text.find("<tool_call:").map(|i| (MarkupKind::Tagged, i));
146    let open = text
147        .find("<tool_call>")
148        .map(|i| (MarkupKind::OpenToolCall, i));
149    let invoke = text.find("<invoke ").map(|i| (MarkupKind::Invoke, i));
150    let dsml = find_dsml_invoke(text).map(|i| (MarkupKind::DsmlInvoke, i));
151    [tagged, open, invoke, dsml]
152        .into_iter()
153        .flatten()
154        .min_by_key(|(_, i)| *i)
155}
156
157fn find_dsml_invoke(text: &str) -> Option<usize> {
158    let full = format!("<{DSML_NS}invoke ");
159    let ascii = format!("<{DSML_NS_ASCII}invoke ");
160    [text.find(&full), text.find(&ascii)]
161        .into_iter()
162        .flatten()
163        .min()
164}
165
166fn take_markup(kind: MarkupKind, at: &str) -> Option<(ParsedCall, usize)> {
167    match kind {
168        MarkupKind::Tagged => take_workbuddy_tagged(at),
169        MarkupKind::OpenToolCall => {
170            let after = &at["<tool_call>".len()..];
171            if looks_like_workbuddy_bare(after) {
172                take_workbuddy_bare(at)
173            } else {
174                take_attribute_tool_call(at)
175            }
176        }
177        MarkupKind::Invoke => take_claude_invoke(at),
178        MarkupKind::DsmlInvoke => take_dsml_invoke(at),
179    }
180}
181
182/// `<|DSML|invoke name="search">…</|DSML|invoke>` (V3.2/V4).
183fn take_dsml_invoke(at: &str) -> Option<(ParsedCall, usize)> {
184    let ns = if at.starts_with(&format!("<{DSML_NS}invoke ")) {
185        DSML_NS
186    } else if at.starts_with(&format!("<{DSML_NS_ASCII}invoke ")) {
187        DSML_NS_ASCII
188    } else {
189        return None;
190    };
191    let header_end = at.find('>')?;
192    let header = &at[..=header_end];
193    let name = xml_attr(header, "name")?;
194    if !is_tool_name(&name) {
195        return None;
196    }
197    let body_start = header_end + 1;
198    let after_header = &at[body_start..];
199    let close = format!("</{ns}invoke>");
200    let body_end = after_header.find(&close)?;
201    let body = &after_header[..body_end];
202    let consumed = body_start + body_end + close.len();
203    Some((
204        ParsedCall {
205            id: None,
206            name,
207            input: Value::Object(parse_dsml_parameter_tags(body, ns)),
208        },
209        consumed,
210    ))
211}
212
213fn parse_dsml_parameter_tags(body: &str, ns: &str) -> Map<String, Value> {
214    let mut params = Map::new();
215    let open = format!("<{ns}parameter ");
216    let close = format!("</{ns}parameter>");
217    let mut rest = body;
218    while let Some(start) = rest.find(&open) {
219        rest = &rest[start..];
220        let Some(header_end) = rest.find('>') else {
221            break;
222        };
223        let header = &rest[..=header_end];
224        let value_start = header_end + 1;
225        let Some(value_end) = rest[value_start..].find(&close) else {
226            break;
227        };
228        if let Some(name) = xml_attr(header, "name") {
229            let raw = decode_xml_entities(rest[value_start..value_start + value_end].trim());
230            let as_string = xml_attr(header, "string")
231                .map(|v| v.eq_ignore_ascii_case("true"))
232                .unwrap_or(false);
233            let value = if as_string {
234                Value::String(raw)
235            } else {
236                parse_parameter_value(&raw)
237            };
238            params.insert(name, value);
239        }
240        rest = &rest[value_start + value_end + close.len()..];
241    }
242    if params.is_empty() {
243        let trimmed = body.trim();
244        if trimmed.starts_with('{') {
245            if let Ok(Value::Object(map)) = serde_json::from_str(trimmed) {
246                return map;
247            }
248        }
249    }
250    params
251}
252
253/// `<tool_call:call_1>ls"><parameter …></invoke>`
254fn take_workbuddy_tagged(at: &str) -> Option<(ParsedCall, usize)> {
255    if !at.starts_with("<tool_call:") {
256        return None;
257    }
258    let after_prefix = &at["<tool_call:".len()..];
259    let id_end = after_prefix.find('>')?;
260    let id = after_prefix[..id_end].trim();
261    if id.is_empty()
262        || id.len() > 128
263        || !id
264            .chars()
265            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':'))
266    {
267        return None;
268    }
269    let after_id = &after_prefix[id_end + 1..];
270    let name_end = after_id.find("\">")?;
271    let name = decode_xml_entities(after_id[..name_end].trim());
272    if !is_tool_name(&name) {
273        return None;
274    }
275    let body_start = name_end + "\">".len();
276    let after_name = &after_id[body_start..];
277    let body_end = after_name.find("</invoke>")?;
278    let body = &after_name[..body_end];
279    let consumed = "<tool_call:".len() + id_end + 1 + body_start + body_end + "</invoke>".len();
280    Some((
281        ParsedCall {
282            id: Some(id.to_string()),
283            name,
284            input: Value::Object(parse_parameter_tags(body)),
285        },
286        consumed,
287    ))
288}
289
290/// `<tool_call>write><parameter …></write>`
291fn take_workbuddy_bare(at: &str) -> Option<(ParsedCall, usize)> {
292    if !at.starts_with("<tool_call>") {
293        return None;
294    }
295    let after = &at["<tool_call>".len()..];
296    let name_end = after.find('>')?;
297    let name = decode_xml_entities(after[..name_end].trim());
298    if !is_tool_name(&name) {
299        return None;
300    }
301    let body_start = name_end + 1;
302    let body_region = &after[body_start..];
303    let close_named = format!("</{name}>");
304    let (body_end, close_len) = if let Some(end) = body_region.find(&close_named) {
305        (end, close_named.len())
306    } else {
307        let end = body_region.find("</tool_call>")?;
308        (end, "</tool_call>".len())
309    };
310    let body = &body_region[..body_end];
311    let consumed = "<tool_call>".len() + body_start + body_end + close_len;
312    Some((
313        ParsedCall {
314            id: None,
315            name,
316            input: Value::Object(parse_parameter_tags(body)),
317        },
318        consumed,
319    ))
320}
321
322fn looks_like_workbuddy_bare(after_open: &str) -> bool {
323    let trimmed = after_open.trim_start();
324    let Some(gt) = trimmed.find('>') else {
325        return false;
326    };
327    let name = trimmed[..gt].trim();
328    is_tool_name(name) && !name.contains('=') && !name.contains(char::is_whitespace)
329}
330
331fn take_attribute_tool_call(at: &str) -> Option<(ParsedCall, usize)> {
332    if !at.starts_with("<tool_call>") {
333        return None;
334    }
335    let after = &at["<tool_call>".len()..];
336    let (body, body_consumed) = take_call_body(after);
337    let call = parse_call_body(body)?;
338    Some((call, "<tool_call>".len() + body_consumed))
339}
340
341fn take_claude_invoke(at: &str) -> Option<(ParsedCall, usize)> {
342    if !at.starts_with("<invoke ") {
343        return None;
344    }
345    let header_end = at.find('>')?;
346    let header = &at[..=header_end];
347    let name = xml_attr(header, "name")?;
348    if !is_tool_name(&name) {
349        return None;
350    }
351    let body_start = header_end + 1;
352    let after_header = &at[body_start..];
353    let body_end = after_header.find("</invoke>")?;
354    let body = &after_header[..body_end];
355    let consumed = body_start + body_end + "</invoke>".len();
356    Some((
357        ParsedCall {
358            id: None,
359            name,
360            input: Value::Object(parse_parameter_tags(body)),
361        },
362        consumed,
363    ))
364}
365
366fn take_call_body(after_open: &str) -> (&str, usize) {
367    if let Some(end) = after_open.find("</tool_call>") {
368        let body = after_open[..end].trim().trim_end_matches('/').trim();
369        return (body, end + "</tool_call>".len());
370    }
371    if let Some(end) = after_open.find("/>") {
372        return (after_open[..end].trim(), end + 2);
373    }
374    if let Some(end) = after_open.find("<tool_call>") {
375        return (after_open[..end].trim(), end);
376    }
377    (after_open.trim(), after_open.len())
378}
379
380fn parse_call_body(body: &str) -> Option<ParsedCall> {
381    let body = body.trim().trim_end_matches('/').trim();
382    if body.is_empty() {
383        return None;
384    }
385    let mut parts = body.split_whitespace();
386    let name = parts.next()?.trim();
387    if !is_tool_name(name) {
388        return None;
389    }
390    let args_src = body[name.len()..].trim();
391    Some(ParsedCall {
392        id: None,
393        name: name.to_string(),
394        input: parse_attributes(args_src),
395    })
396}
397
398fn parse_parameter_tags(body: &str) -> Map<String, Value> {
399    let mut params = Map::new();
400    let mut rest = body;
401    while let Some(start) = rest.find("<parameter ") {
402        rest = &rest[start..];
403        let Some(header_end) = rest.find('>') else {
404            break;
405        };
406        let header = &rest[..=header_end];
407        let value_start = header_end + 1;
408        let Some(value_end) = rest[value_start..].find("</parameter>") else {
409            break;
410        };
411        if let Some(name) = xml_attr(header, "name") {
412            let raw = decode_xml_entities(rest[value_start..value_start + value_end].trim());
413            params.insert(name, parse_parameter_value(&raw));
414        }
415        rest = &rest[value_start + value_end + "</parameter>".len()..];
416    }
417    params
418}
419
420fn xml_attr(tag: &str, attr: &str) -> Option<String> {
421    let needle = format!("{attr}=\"");
422    let start = tag.find(&needle)? + needle.len();
423    let rest = &tag[start..];
424    let end = rest.find('"')?;
425    Some(decode_xml_entities(&rest[..end]))
426}
427
428fn decode_xml_entities(value: &str) -> String {
429    value
430        .replace("&quot;", "\"")
431        .replace("&apos;", "'")
432        .replace("&lt;", "<")
433        .replace("&gt;", ">")
434        .replace("&amp;", "&")
435}
436
437fn parse_parameter_value(raw: &str) -> Value {
438    serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
439}
440
441fn scrub_wrapper_markup(prose: &str) -> String {
442    let mut out = prose.to_string();
443    for pattern in [
444        "<function_calls>",
445        "</function_calls>",
446        "</tool_calls>",
447        "</invoke>",
448        "<minimax:tool_call>",
449        "</minimax:tool_call>",
450    ] {
451        out = out.replace(pattern, "");
452    }
453    // Drop `<tool_calls:group_id>` open wrappers.
454    while let Some(start) = out.find("<tool_calls:") {
455        let Some(end) = out[start..].find('>') else {
456            break;
457        };
458        out.replace_range(start..start + end + 1, "");
459    }
460    // Drop DSML outer wrappers: `<|DSML|tool_calls>` / `</|DSML|tool_calls>` etc.
461    for ns in [DSML_NS, DSML_NS_ASCII] {
462        for name in ["tool_calls", "function_calls", "calls"] {
463            let open = format!("<{ns}{name}>");
464            let close = format!("</{ns}{name}>");
465            out = out.replace(&open, "").replace(&close, "");
466        }
467        out = out.replace(&format!("</{ns}invoke>"), "");
468        out = out.replace(&format!("</{ns}parameter>"), "");
469    }
470    out
471}
472
473fn is_tool_name(name: &str) -> bool {
474    let mut chars = name.chars();
475    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
476        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
477}
478
479fn parse_attributes(source: &str) -> Value {
480    let mut map = Map::new();
481    let bytes = source.as_bytes();
482    let mut index = 0;
483    while index < bytes.len() {
484        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
485            index += 1;
486        }
487        if index >= bytes.len() {
488            break;
489        }
490        let key_start = index;
491        while index < bytes.len() && is_attr_name_byte(bytes[index]) {
492            index += 1;
493        }
494        if key_start == index || index >= bytes.len() || bytes[index] != b'=' {
495            break;
496        }
497        let key = source[key_start..index].to_string();
498        index += 1;
499        let (value, next) = parse_attr_value(source, index);
500        map.insert(key, value);
501        index = next;
502    }
503    Value::Object(map)
504}
505
506fn is_attr_name_byte(byte: u8) -> bool {
507    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
508}
509
510fn parse_attr_value(source: &str, start: usize) -> (Value, usize) {
511    let bytes = source.as_bytes();
512    if start >= bytes.len() {
513        return (Value::String(String::new()), start);
514    }
515    let quote = bytes[start];
516    if quote == b'"' || quote == b'\'' {
517        let mut index = start + 1;
518        let mut value = String::new();
519        while index < bytes.len() {
520            let ch = source[index..].chars().next().unwrap_or('\0');
521            if ch == '\\' {
522                let rest = &source[index + 1..];
523                if let Some(next) = rest.chars().next() {
524                    value.push(next);
525                    index += ch.len_utf8() + next.len_utf8();
526                    continue;
527                }
528            }
529            if ch as u8 == quote && ch.is_ascii() {
530                return (Value::String(value), index + 1);
531            }
532            value.push(ch);
533            index += ch.len_utf8();
534        }
535        return (Value::String(value), source.len());
536    }
537    if quote == b'[' || quote == b'{' {
538        if let Some(end) = matching_closer(source, start) {
539            let raw = &source[start..=end];
540            if let Ok(value) = serde_json::from_str(raw) {
541                return (value, end + 1);
542            }
543            return (Value::String(raw.to_string()), end + 1);
544        }
545    }
546    let mut index = start;
547    while index < bytes.len() && !bytes[index].is_ascii_whitespace() {
548        index += 1;
549    }
550    let raw = &source[start..index];
551    (bare_value(raw), index)
552}
553
554fn matching_closer(source: &str, start: usize) -> Option<usize> {
555    let bytes = source.as_bytes();
556    let open = bytes.get(start).copied()?;
557    let close = if open == b'[' { b']' } else { b'}' };
558    let mut depth = 0;
559    let mut index = start;
560    let mut in_string = false;
561    let mut escaped = false;
562    while index < bytes.len() {
563        let byte = bytes[index];
564        if in_string {
565            if escaped {
566                escaped = false;
567            } else if byte == b'\\' {
568                escaped = true;
569            } else if byte == b'"' {
570                in_string = false;
571            }
572            index += 1;
573            continue;
574        }
575        if byte == b'"' {
576            in_string = true;
577        } else if byte == open {
578            depth += 1;
579        } else if byte == close {
580            depth -= 1;
581            if depth == 0 {
582                return Some(index);
583            }
584        }
585        index += 1;
586    }
587    None
588}
589
590fn bare_value(raw: &str) -> Value {
591    if raw == "true" {
592        Value::Bool(true)
593    } else if raw == "false" {
594        Value::Bool(false)
595    } else if raw == "null" {
596        Value::Null
597    } else if let Ok(number) = raw.parse::<i64>() {
598        Value::from(number)
599    } else if let Ok(number) = raw.parse::<f64>() {
600        serde_json::Number::from_f64(number)
601            .map(Value::Number)
602            .unwrap_or_else(|| Value::String(raw.to_string()))
603    } else {
604        Value::String(raw.to_string())
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn recovers_self_closing_markup_and_strips_it_from_prose() {
614        let mut message = Message::assistant(
615            "我来帮你查看今天的热搜。<tool_call>web_search query=\"今天热搜榜\" limit=\"15\" engines=[\"baidu\", \"sogou\"] timeout=\"30\" /></tool_call>",
616        );
617        let calls = recover_text_tool_calls(&mut message);
618        assert_eq!(calls.len(), 1);
619        assert_eq!(calls[0].name, "web_search");
620        assert_eq!(calls[0].args["query"], "今天热搜榜");
621        assert_eq!(calls[0].args["limit"], "15");
622        assert_eq!(calls[0].args["engines"][0], "baidu");
623        assert_eq!(message.text(), "我来帮你查看今天的热搜。");
624        assert!(message
625            .tool_calls()
626            .iter()
627            .any(|call| call.name == "web_search"));
628    }
629
630    #[test]
631    fn does_not_duplicate_structured_tool_calls() {
632        let mut message = Message::assistant("<tool_call>web_search query=\"x\" /></tool_call>");
633        message.content.push(ContentBlock::ToolUse {
634            id: "call-1".to_string(),
635            name: "web_search".to_string(),
636            input: serde_json::json!({"query": "x"}),
637        });
638        let calls = recover_text_tool_calls(&mut message);
639        assert_eq!(calls.len(), 1);
640        assert_eq!(calls[0].id, "call-1");
641        assert!(message.text().is_empty());
642    }
643
644    #[test]
645    fn recovers_workbuddy_tagged_tool_call_xml() {
646        let mut message = Message::assistant(
647            r#"I'll list the workspace.<tool_calls:group_1>
648<tool_call:call_1>ls">
649<parameter name="path">/work/a3s</parameter>
650</invoke>
651</function_calls>"#,
652        );
653        let calls = recover_text_tool_calls(&mut message);
654        assert_eq!(calls.len(), 1);
655        assert_eq!(calls[0].id, "call_1");
656        assert_eq!(calls[0].name, "ls");
657        assert_eq!(calls[0].args["path"], "/work/a3s");
658        assert_eq!(message.text(), "I'll list the workspace.");
659        assert!(!message.text().contains("<tool_call"));
660        assert!(!message.text().contains("</invoke>"));
661    }
662
663    #[test]
664    fn recovers_workbuddy_bare_tool_call_xml_with_named_close() {
665        let mut message = Message::assistant(
666            r#"Let me save this to memory.<tool_call>write>
667<parameter name="file_path">NOTE.md</parameter>
668<parameter name="content">mem_wb_token_violet_91</parameter>
669</write>"#,
670        );
671        let calls = recover_text_tool_calls(&mut message);
672        assert_eq!(calls.len(), 1);
673        assert_eq!(calls[0].name, "write");
674        assert_eq!(calls[0].args["file_path"], "NOTE.md");
675        assert_eq!(calls[0].args["content"], "mem_wb_token_violet_91");
676        assert_eq!(message.text(), "Let me save this to memory.");
677    }
678
679    #[test]
680    fn recovers_claude_invoke_markup() {
681        let mut message = Message::assistant(
682            r#"Checking.<function_calls>
683<invoke name="Read">
684<parameter name="file_path">README.md</parameter>
685</invoke>
686</function_calls>"#,
687        );
688        let calls = recover_text_tool_calls(&mut message);
689        assert_eq!(calls.len(), 1);
690        assert_eq!(calls[0].name, "Read");
691        assert_eq!(calls[0].args["file_path"], "README.md");
692        assert_eq!(message.text(), "Checking.");
693    }
694
695    #[test]
696    fn attribute_form_still_preferred_over_bare_false_positive() {
697        let mut message =
698            Message::assistant(r#"<tool_call>web_search query="today" limit="5" /></tool_call>"#);
699        let calls = recover_text_tool_calls(&mut message);
700        assert_eq!(calls.len(), 1);
701        assert_eq!(calls[0].name, "web_search");
702        assert_eq!(calls[0].args["query"], "today");
703        assert_eq!(calls[0].args["limit"], "5");
704    }
705    #[test]
706    fn recovers_deepseek_dsml_v4_tool_calls() {
707        let mut message = Message::assistant(
708            "正在检索。\n<|DSML|tool_calls>\n<|DSML|invoke name=\"search\">\n<|DSML|parameter name=\"mode\" string=\"true\">glob</|DSML|parameter>\n<|DSML|parameter name=\"query\" string=\"true\">测试一下</|DSML|parameter>\n<|DSML|parameter name=\"path\" string=\"true\">.a3s/kb/wiki/science</|DSML|parameter>\n<|DSML|parameter name=\"limit\" string=\"true\">8</|DSML|parameter>\n</|DSML|invoke>\n</|DSML|tool_calls>",
709        );
710        let calls = recover_text_tool_calls(&mut message);
711        assert_eq!(calls.len(), 1);
712        assert_eq!(calls[0].name, "search");
713        assert_eq!(calls[0].args["mode"], "glob");
714        assert_eq!(calls[0].args["query"], "测试一下");
715        assert_eq!(calls[0].args["path"], ".a3s/kb/wiki/science");
716        assert_eq!(calls[0].args["limit"], "8");
717        assert_eq!(message.text(), "正在检索。");
718        assert!(!message.text().contains("DSML"));
719    }
720
721    #[test]
722    fn recovers_ascii_mangled_dsml_namespace() {
723        let mut message = Message::assistant(
724            r#"<||DSML||calls><||DSML||invoke name="search"><||DSML||parameter name="query" string="true">x</||DSML||parameter></||DSML||invoke></||DSML||calls>"#,
725        );
726        let calls = recover_text_tool_calls(&mut message);
727        assert_eq!(calls.len(), 1);
728        assert_eq!(calls[0].name, "search");
729        assert_eq!(calls[0].args["query"], "x");
730        assert!(message.text().is_empty());
731    }
732
733    #[test]
734    fn strip_leaked_tool_protocol_drops_dsml_only_blobs() {
735        let raw = r#"<|DSML|tool_calls><|DSML|invoke name="search"><|DSML|parameter name="query" string="true">q</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"#;
736        assert!(strip_leaked_tool_protocol(raw).is_empty());
737        assert_eq!(strip_leaked_tool_protocol(&format!("hello {raw}")), "hello");
738    }
739}