Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions crates/tinytools-agent/src/parse/grammar/invoke_xml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,32 @@ impl InvokeXml {
}
}

/// Every call in a tag body that *is* invoke XML — `<tool_call><invoke
/// name="x">…</invoke></tool_call>`, what `DeepSeek` V4 writes when told to
/// call tools inside `<tool_call>` 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<ParsedToolCall> {
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 {
Expand Down
5 changes: 5 additions & 0 deletions crates/tinytools-agent/src/parse/grammar/tagged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,11 @@ pub(crate) fn decode_body(body: &str, options: &ParseOptions<'_>) -> Vec<ParsedT
let body = strip_call_prefix(body);
let is_known = |name: &str| options.knows(name);

let calls = super::invoke_xml::decode_body(strip_code_fence(body));
if !calls.is_empty() {
return calls;
}

if let Some(registry) = options.registry {
if let Some((name, arguments)) = crate::pformat::parse_call(body, registry) {
return vec![ParsedToolCall::new(name, arguments, CallSource::PFormat)];
Expand Down
85 changes: 85 additions & 0 deletions crates/tinytools-agent/src/parse/test/tagged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,3 +739,88 @@ fn recovery_does_not_execute_a_named_invoke_inside_malformed_json() {
let (_, calls) = parse(raw);
assert!(calls.is_empty(), "{calls:?}");
}

// ── Invoke XML inside the tag ───────────────────────────────────────────────
//
// Told to call tools "inside <tool_call> 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<tool_call>\n<invoke name=\"tool_search\">\n<parameter name=\"query\">repos</parameter>\n</invoke>\n</tool_call>";
let outcome = super::parse_known(raw, &["tool_search"]);
assert_eq!(outcome.calls.len(), 1, "{:?}", outcome.calls);
Comment thread
M3gA-Mind marked this conversation as resolved.
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!(
"<tool_call>\n<invoke name=\"tool_search\">\n",
"<parameter name=\"query\" string=\"true\">repos</parameter>\n",
"<parameter name=\"limit\" string=\"false\">5</parameter>\n",
"</invoke>\n</tool_call>"
);
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 `<todo>` 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!(
"<todo>\n- [x] read the request\n- [ ] find the tool\n</todo>\n\n",
"Let me find the right tool.\n\n",
"```\n<tool_call>\n<invoke name=\"tool_search\">\n",
"<parameter name=\"query\" string=\"true\">list repositories</parameter>\n",
"</invoke>\n</tool_call>\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 = "<tool_call>see <invoke name=\"shell\"><parameter name=\"command\">ls</parameter></invoke></tool_call>";
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 = "<tool_call>{\"name\":\"tool_search\",\"arguments\":{\"query\":\"<invoke name=\"shell\"><parameter name=\"command\">rm -rf /</parameter></invoke>\"}}</tool_call>";
Comment thread
M3gA-Mind marked this conversation as resolved.
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<tool_call>\n<function=tool_search>\n<parameter=query>\nrepos\n</parameter>\n</function>\n</tool_call>";
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.");
}
Loading