diff --git a/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs b/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs
index 9a08b03..2b5b2f0 100644
--- a/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs
+++ b/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs
@@ -217,6 +217,32 @@ impl InvokeXml {
}
}
+/// Every call in a tag body that *is* invoke XML — `…`, what `DeepSeek` V4 writes when told to
+/// call tools inside `` tags. Empty unless the body opens with a
+/// named invoke, so an invoke quoted inside some other body (a JSON string,
+/// say) is not executed. Once the body does open with one, every later
+/// invoke in it is decoded too, exactly as the same text outside a tag is.
+pub(crate) fn decode_body(body: &str) -> Vec {
+ let body = body.trim_start();
+ if OPEN_RE
+ .as_ref()
+ .and_then(|re| re.find(body))
+ .is_none_or(|m| m.start() != 0)
+ {
+ return Vec::new();
+ }
+ let mut calls = Vec::new();
+ let mut from = 0;
+ while let Probe::Found(block) = InvokeXml::probe_decided(body, from, ScanMode::Batch) {
+ if let Decoded::Calls(found) = block.decoded {
+ calls.extend(found);
+ }
+ from = block.end;
+ }
+ calls
+}
+
/// Whether a wrapper tag is a closer or carries a DSML / namespace prefix —
/// either is unambiguous protocol furniture even with no invoke in sight.
fn is_closer_or_prefixed(tag: &str) -> bool {
diff --git a/crates/tinytools-agent/src/parse/grammar/tagged.rs b/crates/tinytools-agent/src/parse/grammar/tagged.rs
index f189894..bf2154e 100644
--- a/crates/tinytools-agent/src/parse/grammar/tagged.rs
+++ b/crates/tinytools-agent/src/parse/grammar/tagged.rs
@@ -590,6 +590,11 @@ pub(crate) fn decode_body(body: &str, options: &ParseOptions<'_>) -> Vec tags", `DeepSeek` V4 writes its
+// native invoke XML there. The tag claimed the block and found no JSON, so
+// the call was dropped as malformed even though the same invoke parses bare.
+
+#[test]
+fn a_named_invoke_wrapped_in_a_tool_call_tag_is_decoded() {
+ let raw = "Searching.\n\n\nrepos\n\n";
+ let outcome = super::parse_known(raw, &["tool_search"]);
+ assert_eq!(outcome.calls.len(), 1, "{:?}", outcome.calls);
+ assert_eq!(outcome.calls[0].name, "tool_search");
+ assert_eq!(
+ outcome.calls[0].arguments,
+ serde_json::json!({"query": "repos"})
+ );
+ assert_eq!(outcome.calls[0].source, CallSource::InvokeXml);
+ assert_eq!(outcome.text, "Searching.");
+}
+
+#[test]
+fn a_wrapped_invoke_with_string_attributes_is_decoded() {
+ let raw = concat!(
+ "\n\n",
+ "repos\n",
+ "5\n",
+ "\n"
+ );
+ let outcome = super::parse_known(raw, &["tool_search"]);
+ assert_eq!(outcome.calls.len(), 1, "{:?}", outcome.calls);
+ assert_eq!(
+ outcome.calls[0].arguments,
+ serde_json::json!({"query": "repos", "limit": 5})
+ );
+}
+
+/// A `` block, a line of narration, then the wrapped invoke inside a
+/// closed bare fence (no info string, so not protected).
+#[test]
+fn a_todo_block_then_a_closed_bare_fenced_wrapped_invoke_is_decoded() {
+ let raw = concat!(
+ "\n- [x] read the request\n- [ ] find the tool\n\n\n",
+ "Let me find the right tool.\n\n",
+ "```\n\n\n",
+ "list repositories\n",
+ "\n\n```"
+ );
+ let outcome = super::parse_known(raw, &["tool_search"]);
+ assert_eq!(outcome.calls.len(), 1, "{:?}", outcome.calls);
+ assert_eq!(outcome.calls[0].name, "tool_search");
+ assert_eq!(
+ outcome.calls[0].arguments,
+ serde_json::json!({"query": "list repositories"})
+ );
+}
+
+#[test]
+fn an_invoke_after_other_body_text_in_the_tag_is_not_decoded() {
+ let raw = "see ls";
+ let (_, calls) = parse(raw);
+ assert!(calls.is_empty(), "{calls:?}");
+}
+
+#[test]
+fn an_invoke_quoted_in_a_closed_json_tag_body_is_not_executed() {
+ let raw = "{\"name\":\"tool_search\",\"arguments\":{\"query\":\"rm -rf /\"}}";
+ let (_, calls) = parse(raw);
+ assert!(calls.iter().all(|c| c.name != "shell"), "{calls:?}");
+}
+
+/// Qwen3-Coder's native format inside the tag.
+#[test]
+fn a_function_equals_body_in_a_tool_call_tag_is_decoded() {
+ let raw = "Checking.\n\n\n\nrepos\n\n\n";
+ let outcome = super::parse_known(raw, &["tool_search"]);
+ assert_eq!(outcome.calls.len(), 1, "{:?}", outcome.calls);
+ assert_eq!(outcome.calls[0].name, "tool_search");
+ assert_eq!(
+ outcome.calls[0].arguments,
+ serde_json::json!({"query": "repos"})
+ );
+ assert_eq!(outcome.calls[0].source, CallSource::InvokeXml);
+ assert_eq!(outcome.text, "Checking.");
+}