use std::collections::HashMap;
use num_traits::ToPrimitive;
use regex::Regex;
use serde_json::Value;
use uuid::Uuid;
use super::super::ToolDefinition;
use super::super::config::XmlParserConfig;
use super::parsed_value::{ParsedValue, is_integer_literal, raw_number_literal};
use super::response::{CalledFunction, ToolCallResponse, ToolCallType};
fn build_block_pattern(start: &str, end: &str, strict: bool) -> String {
let start = regex::escape(start);
let end = regex::escape(end);
if strict {
format!(r"(?s){}([^>]+)>(.*?){}", start, end)
} else {
format!(r"(?s){}([^>]+)>(.*?)(?:{}|$)", start, end)
}
}
fn strip_quotes(s: &str) -> &str {
let trimmed = s.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
}
}
pub fn detect_tool_call_start_xml(chunk: &str, config: &XmlParserConfig) -> bool {
let start_token = &config.tool_call_start_token;
if chunk.contains(start_token.as_str()) || config.is_bare_function_mode(chunk) {
return true;
}
for token in [start_token, &config.function_start_token] {
for i in 1..token.len() {
if chunk.ends_with(&token[..i]) {
return true;
}
}
}
false
}
pub fn find_tool_call_end_position_xml(chunk: &str, config: &XmlParserConfig) -> usize {
let (start_token, end_token) = if config.is_bare_function_mode(chunk) {
(&config.function_start_token, &config.function_end_token)
} else {
(&config.tool_call_start_token, &config.tool_call_end_token)
};
let Some(first_end) = chunk.find(end_token.as_str()) else {
return chunk.len();
};
let mut cursor = first_end + end_token.len();
loop {
let rest = &chunk[cursor..];
let trimmed = rest.trim_start();
if config.is_bare_function_mode(chunk) && trimmed.starts_with(end_token.as_str()) {
let trim_offset = rest.len() - trimmed.len();
cursor += trim_offset + end_token.len();
continue;
}
if config.is_bare_function_mode(chunk)
&& trimmed.starts_with(config.tool_call_end_token.as_str())
{
let trim_offset = rest.len() - trimmed.len();
cursor += trim_offset + config.tool_call_end_token.len();
break;
}
if !trimmed.starts_with(start_token.as_str()) {
break;
}
let trim_offset = rest.len() - trimmed.len();
let search_from = cursor + trim_offset + start_token.len();
if let Some(end_pos) = chunk[search_from..].find(end_token.as_str()) {
cursor = search_from + end_pos + end_token.len();
} else {
break;
}
}
cursor
}
pub fn try_tool_call_parse_xml(
message: &str,
config: &XmlParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(Vec<ToolCallResponse>, Option<String>)> {
if config.passthrough_when_no_function
&& !message.contains(config.function_start_token.as_str())
&& !message.contains(config.tool_call_start_token.as_str())
{
return Ok((vec![], Some(message.to_string())));
}
if config.is_bare_function_mode(message)
&& (message.contains(config.function_end_token.as_str()) || config.allow_eof_recovery)
&& (message.contains(config.tool_call_end_token.as_str()) || config.allow_eof_recovery)
{
let calls = parse_tool_call_block(message, config, tools).unwrap_or_default();
if !calls.is_empty() {
let prefix = message
.split_once(config.function_start_token.as_str())
.map(|(p, _)| p.to_string())
.unwrap_or_default();
return Ok((calls, Some(prefix)));
}
}
if config.strict_match
&& !message.contains(config.tool_call_start_token.as_str())
&& let Some(prefix) = prefix_before_orphan_xml_marker(message, config)
{
return Ok((vec![], Some(prefix)));
}
let (normal_text, tool_calls) = extract_tool_calls(message, config, tools)?;
let normal_content = if normal_text.is_empty() {
Some("".to_string())
} else {
Some(normal_text)
};
Ok((tool_calls, normal_content))
}
fn extract_tool_calls(
text: &str,
config: &XmlParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(String, Vec<ToolCallResponse>)> {
let mut normal_text = String::new();
let mut calls = Vec::new();
let mut cursor = 0;
let start_token = &config.tool_call_start_token;
let end_token = &config.tool_call_end_token;
while cursor < text.len() {
if let Some(start_pos) = text[cursor..].find(start_token.as_str()) {
let abs_start = cursor + start_pos;
let gap = &text[cursor..abs_start];
if let Some((prefix, mut recovered_calls)) =
recover_bare_xml_calls_in_span(gap, config, tools)?
{
if calls.is_empty() {
normal_text.push_str(&prefix);
}
calls.append(&mut recovered_calls);
} else if calls.is_empty() {
normal_text.push_str(gap);
}
if let Some(end_pos) = text[abs_start..].find(end_token.as_str()) {
let abs_end = abs_start + end_pos + end_token.len();
let block = &text[abs_start..abs_end];
if let Ok(mut parsed_calls) = parse_tool_call_block(block, config, tools) {
calls.append(&mut parsed_calls);
}
cursor = abs_end;
} else {
let block = &text[abs_start..];
let function_start = &config.function_start_token;
let looks_like_tool_call = block.contains(function_start.as_str())
|| block.contains(config.parameter_start_token.as_str());
if config.allow_eof_recovery
&& looks_like_tool_call
&& let Ok(mut parsed_calls) = parse_tool_call_block(block, config, tools)
&& !parsed_calls.is_empty()
{
calls.append(&mut parsed_calls);
break;
}
if calls.is_empty() && !looks_like_tool_call {
normal_text.push_str(&text[abs_start..]);
}
break;
}
} else {
let gap = &text[cursor..];
if let Some((prefix, mut recovered_calls)) =
recover_bare_xml_calls_in_span(gap, config, tools)?
{
if calls.is_empty() {
normal_text.push_str(&prefix);
}
calls.append(&mut recovered_calls);
} else if calls.is_empty() {
normal_text.push_str(gap);
}
break;
}
}
let normal_text = if calls.is_empty() {
normal_text.trim().to_string()
} else {
normal_text
};
Ok((normal_text, calls))
}
fn prefix_before_orphan_xml_marker(text: &str, config: &XmlParserConfig) -> Option<String> {
[
config.tool_call_end_token.as_str(),
config.function_start_token.as_str(),
config.function_end_token.as_str(),
config.parameter_start_token.as_str(),
config.parameter_end_token.as_str(),
]
.into_iter()
.filter_map(|marker| text.find(marker))
.min()
.map(|idx| text[..idx].trim().to_string())
}
fn recover_bare_xml_calls_in_span(
span: &str,
config: &XmlParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Option<(String, Vec<ToolCallResponse>)>> {
if !config.backoff_when_no_wrapper {
return Ok(None);
}
let Some(marker_idx) = span.find(config.function_start_token.as_str()) else {
return Ok(None);
};
let tail = &span[marker_idx..];
let has_inner_close = tail.contains(config.function_end_token.as_str());
let has_outer_close = tail.contains(config.tool_call_end_token.as_str());
if (!has_inner_close || !has_outer_close) && !config.allow_eof_recovery {
return Ok(None);
}
let calls = parse_tool_call_block(tail, config, tools)?;
if calls.is_empty() {
return Ok(None);
}
tracing::warn!(
why = "bare_function_gap_recovery",
recovered_calls = calls.len(),
recovered_bytes = tail.len(),
kept_prefix_bytes = marker_idx,
"XML recovery: recovered complete bare function block(s) before a later outer wrapper"
);
Ok(Some((span[..marker_idx].to_string(), calls)))
}
fn parse_tool_call_block(
block: &str,
config: &XmlParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Vec<ToolCallResponse>> {
let function_regex = Regex::new(&build_block_pattern(
&config.function_start_token,
&config.function_end_token,
config.strict_match,
))?;
let parameter_regex = Regex::new(&build_block_pattern(
&config.parameter_start_token,
&config.parameter_end_token,
config.strict_match,
))?;
let mut results = Vec::new();
for func_cap in function_regex.captures_iter(block) {
let function_name_raw = func_cap.get(1).map(|m| m.as_str().trim()).unwrap_or("");
let function_name = strip_quotes(function_name_raw);
let function_body = func_cap.get(2).map(|m| m.as_str()).unwrap_or("");
if function_name.is_empty() {
continue;
}
let function_terminated = func_cap
.get(0)
.is_some_and(|m| m.as_str().contains(config.function_end_token.as_str()));
if !function_terminated
&& let Some(open_idx) = function_body.rfind(config.parameter_start_token.as_str())
{
let after_value = &function_body[open_idx..];
let value_bounded = after_value.contains(config.parameter_end_token.as_str())
|| after_value.contains(config.tool_call_end_token.as_str());
if !value_bounded {
continue;
}
}
let param_config = get_arguments_config(function_name, tools);
let mut parameters: HashMap<String, ParsedValue> = HashMap::new();
for param_cap in parameter_regex.captures_iter(function_body) {
let param_name_raw = param_cap.get(1).map(|m| m.as_str().trim()).unwrap_or("");
let param_name = strip_quotes(param_name_raw);
let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or("");
if !param_name.is_empty() {
let parsed_value =
convert_param_value(param_value, param_name, ¶m_config, function_name);
parameters.insert(param_name.to_string(), parsed_value);
}
}
let arguments_json = serde_json::to_string(¶meters)?;
let tool_call = ToolCallResponse {
id: format!("call-{}", Uuid::new_v4()),
tp: ToolCallType::Function,
function: CalledFunction {
name: function_name.to_string(),
arguments: arguments_json,
},
};
results.push(tool_call);
}
Ok(results)
}
fn get_arguments_config(
func_name: &str,
tools: Option<&[ToolDefinition]>,
) -> HashMap<String, Value> {
let Some(tools) = tools else {
return HashMap::new();
};
for tool in tools {
if tool.name == func_name {
if let Some(params) = &tool.parameters {
if let Some(properties) = params.get("properties") {
if let Some(props_obj) = properties.as_object() {
return props_obj
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
}
} else if let Some(params_obj) = params.as_object() {
return params_obj
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
}
}
return HashMap::new();
}
}
tracing::warn!("Tool '{}' is not defined in the tools list.", func_name);
HashMap::new()
}
fn convert_param_value(
param_value: &str,
param_name: &str,
param_config: &HashMap<String, Value>,
func_name: &str,
) -> ParsedValue {
let param_value = html_unescape(param_value.trim());
if param_value.to_lowercase() == "null" {
return Value::Null.into();
}
if !param_config.contains_key(param_name) {
tracing::debug!(
"Parsed parameter '{}' is not defined in the tool parameters for tool '{}', directly returning the string value.",
param_name,
func_name
);
return Value::String(param_value).into();
}
let param_schema = param_config.get(param_name);
let param_type = param_schema
.and_then(|v| v.get("type"))
.and_then(|t| t.as_str())
.map(|t| t.to_lowercase())
.unwrap_or_else(|| {
if param_schema
.map(|v| v.get("anyOf").is_some() || v.get("oneOf").is_some())
.unwrap_or(false)
{
"object".to_string()
} else {
"string".to_string()
}
});
match param_type.as_str() {
"string" | "str" | "text" | "varchar" | "char" | "enum" => {
Value::String(param_value).into()
}
t if t.starts_with("int")
|| t.starts_with("uint")
|| t.starts_with("long")
|| t.starts_with("short")
|| t.starts_with("unsigned") =>
{
match param_value.parse::<i64>() {
Ok(int_val) => Value::Number(int_val.into()).into(),
Err(_) => {
tracing::warn!(
"Parsed value '{}' of parameter '{}' is not an integer in tool '{}', degenerating to string.",
param_value,
param_name,
func_name
);
Value::String(param_value).into()
}
}
}
t if t.starts_with("num") || t.starts_with("float") => {
if is_integer_literal(¶m_value) {
if let Ok(int_val) = param_value.parse::<i64>() {
Value::Number(int_val.into()).into()
} else if let Some(raw) = raw_number_literal(¶m_value) {
raw
} else {
Value::String(param_value).into()
}
} else {
match param_value.parse::<f64>() {
Ok(float_val) => {
if float_val.fract() == 0.0 && float_val.is_finite() {
if let Some(int_val) = float_val.to_i64() {
Value::Number(int_val.into()).into()
} else if let Some(raw) = raw_number_literal(¶m_value) {
raw
} else {
Value::String(param_value).into()
}
} else if let Some(num) = serde_json::Number::from_f64(float_val) {
Value::Number(num).into()
} else {
tracing::warn!(
"Parsed value '{}' of parameter '{}' is not a valid float in tool '{}', degenerating to string.",
param_value,
param_name,
func_name
);
Value::String(param_value).into()
}
}
Err(_) => {
tracing::warn!(
"Parsed value '{}' of parameter '{}' is not a float in tool '{}', degenerating to string.",
param_value,
param_name,
func_name
);
Value::String(param_value).into()
}
}
}
}
"boolean" | "bool" | "binary" => {
let lower_val = param_value.to_lowercase();
if lower_val != "true" && lower_val != "false" {
tracing::warn!(
"Parsed value '{}' of parameter '{}' is not a boolean (`true` or `false`) in tool '{}', degenerating to false.",
param_value,
param_name,
func_name
);
}
Value::Bool(lower_val == "true").into()
}
t if t == "object"
|| t == "array"
|| t == "arr"
|| t.starts_with("dict")
|| t.starts_with("list") =>
{
if let Ok(json_val) = serde_json::from_str::<Value>(¶m_value) {
return json_val.into();
}
tracing::warn!(
"Parsed value '{}' of parameter '{}' cannot be parsed with json.loads in tool '{}', will try other methods to parse it.",
param_value,
param_name,
func_name
);
if let Ok(json_val) = try_literal_eval(¶m_value) {
return json_val.into();
}
tracing::warn!(
"Parsed value '{}' of parameter '{}' cannot be converted via Python `ast.literal_eval()` in tool '{}', degenerating to string.",
param_value,
param_name,
func_name
);
Value::String(param_value).into()
}
_ => {
if let Ok(json_val) = try_literal_eval(¶m_value) {
return json_val.into();
}
tracing::warn!(
"Parsed value '{}' of parameter '{}' cannot be converted via Python `ast.literal_eval()` in tool '{}', degenerating to string.",
param_value,
param_name,
func_name
);
Value::String(param_value).into()
}
}
}
fn try_literal_eval(s: &str) -> Result<Value, ()> {
if let Ok(val) = serde_json::from_str::<Value>(s) {
return Ok(val);
}
let normalized = s
.replace('\'', "\"") .replace("True", "true")
.replace("False", "false")
.replace("None", "null");
serde_json::from_str::<Value>(&normalized).map_err(|_| ())
}
#[allow(dead_code)]
fn safe_parse_value(raw: &str) -> serde_json::Value {
let unescaped = html_unescape(raw.trim());
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&unescaped) {
return value;
}
if let Ok(num) = unescaped.parse::<i64>() {
return serde_json::Value::Number(num.into());
}
if let Ok(num) = unescaped.parse::<f64>()
&& let Some(num_val) = serde_json::Number::from_f64(num)
{
return serde_json::Value::Number(num_val);
}
match unescaped.to_lowercase().as_str() {
"true" => return serde_json::Value::Bool(true),
"false" => return serde_json::Value::Bool(false),
"null" | "none" => return serde_json::Value::Null,
_ => {}
}
serde_json::Value::String(unescaped.trim_matches('\n').to_string())
}
fn html_unescape(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test] fn test_detect_tool_call_start() {
let config = XmlParserConfig::default();
assert!(detect_tool_call_start_xml("<tool_call>", &config));
assert!(detect_tool_call_start_xml("text <tool_call>", &config));
assert!(detect_tool_call_start_xml("<tool_c", &config)); assert!(detect_tool_call_start_xml("<", &config)); assert!(!detect_tool_call_start_xml("no tool call here", &config));
assert!(!detect_tool_call_start_xml("toolcall", &config));
}
#[test] fn test_find_tool_call_end_position() {
let config = XmlParserConfig::default();
let text = "<tool_call><function=test></function></tool_call>more text";
let pos = find_tool_call_end_position_xml(text, &config);
assert_eq!(pos, 49); assert_eq!(&text[pos..], "more text");
let text_no_end = "<tool_call><function=test>";
let pos = find_tool_call_end_position_xml(text_no_end, &config);
assert_eq!(pos, text_no_end.len());
}
#[test] fn test_find_tool_call_end_position_parallel_calls() {
let config = XmlParserConfig::default();
let two_calls = "<tool_call><function=foo><parameter=x>1</parameter></function></tool_call>\
<tool_call><function=bar><parameter=y>2</parameter></function></tool_call>\
trailing";
let pos = find_tool_call_end_position_xml(two_calls, &config);
assert!(
&two_calls[..pos].ends_with("</tool_call>"),
"should end at last </tool_call>, got: {:?}",
&two_calls[..pos]
);
assert_eq!(&two_calls[pos..], "trailing");
let three_calls = "<tool_call><function=a></function></tool_call>\n\
<tool_call><function=b></function></tool_call>\n\
<tool_call><function=c></function></tool_call> done";
let pos3 = find_tool_call_end_position_xml(three_calls, &config);
assert!(
&three_calls[..pos3].ends_with("</tool_call>"),
"should end at last </tool_call>, got: {:?}",
&three_calls[..pos3]
);
assert_eq!(three_calls[pos3..].trim(), "done");
let incomplete = "<tool_call><function=a></function></tool_call>\
<tool_call><function=b>"; let pos_inc = find_tool_call_end_position_xml(incomplete, &config);
let first_end = "<tool_call><function=a></function></tool_call>".len();
assert_eq!(
pos_inc, first_end,
"should stop at end of first complete call when second is incomplete"
);
}
#[test]
fn test_number_coercion_does_not_saturate_large_whole_values() {
let input = r#"<tool_call>
<function=set_limit>
<parameter=count>100000000000000000000</parameter>
<parameter=precise_count>9007199254740993</parameter>
</function>
</tool_call>"#;
let tools = vec![ToolDefinition {
name: "set_limit".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"count": {"type": "number"},
"precise_count": {"type": "number"}
}
})),
strict: None,
}];
let (calls, normal) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
let args: HashMap<String, Box<serde_json::value::RawValue>> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(normal, Some("".to_string()));
assert_eq!(calls[0].function.name, "set_limit");
assert_eq!(args["count"].get(), "100000000000000000000");
assert_eq!(args["precise_count"].get(), "9007199254740993");
}
#[rstest] #[case(r#"{"key": "value"}"#, serde_json::json!({"key": "value"}), "JSON object")]
#[case(r#"[1, 2, 3]"#, serde_json::json!([1, 2, 3]), "JSON array")]
#[case("42", serde_json::json!(42), "integer")]
#[case("3.15", serde_json::json!(3.15), "float")]
#[case("true", serde_json::json!(true), "boolean true")]
#[case("false", serde_json::json!(false), "boolean false")]
#[case("null", serde_json::json!(null), "null")]
#[case("hello", serde_json::json!("hello"), "unquoted string")]
#[case(" text ", serde_json::json!("text"), "trimmed string")]
fn test_safe_parse_value(
#[case] input: &str,
#[case] expected: serde_json::Value,
#[case] _description: &str,
) {
assert_eq!(safe_parse_value(input), expected);
}
#[rstest] #[case("<div>", "<div>", "HTML tags")]
#[case("a & b", "a & b", "ampersand")]
#[case(""quoted"", "\"quoted\"", "quotes")]
fn test_html_unescape(#[case] input: &str, #[case] expected: &str, #[case] _description: &str) {
assert_eq!(html_unescape(input), expected);
}
#[test] fn test_parse_simple_tool_call() {
let input = r#"<tool_call>
<function=execute_bash>
<parameter=command>
pwd && ls
</parameter>
</function>
</tool_call>"#;
let (calls, normal) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "execute_bash");
assert_eq!(normal, Some("".to_string()));
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["command"], "pwd && ls");
}
#[test] fn test_parse_multiple_parameters() {
let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
San Francisco
</parameter>
<parameter=state>
CA
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["city"], "San Francisco");
assert_eq!(args["state"], "CA");
assert_eq!(args["unit"], "fahrenheit");
}
#[test] fn test_parse_with_normal_text() {
let input = r#"I'll help you with that. <tool_call>
<function=get_weather>
<parameter=city>
Dallas
</parameter>
</function>
</tool_call> Let me check that for you."#;
let (calls, normal) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(normal, Some("I'll help you with that. ".to_string()));
}
#[test] fn test_parse_multiple_tool_calls() {
let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
Dallas
</parameter>
</function>
</tool_call>
<tool_call>
<function=get_weather>
<parameter=city>
Orlando
</parameter>
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_weather");
let args0: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
let args1: serde_json::Value = serde_json::from_str(&calls[1].function.arguments).unwrap();
assert_eq!(args0["city"], "Dallas");
assert_eq!(args1["city"], "Orlando");
}
#[test] fn test_parse_json_parameter_value() {
let tools = vec![ToolDefinition {
name: "process_data".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"config": {"type": "object"}
}
})),
strict: None,
}];
let input = r#"<tool_call>
<function=process_data>
<parameter=config>
{"setting": "value", "count": 42}
</parameter>
</function>
</tool_call>"#;
let (calls, _) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
assert_eq!(calls.len(), 1);
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert!(args["config"].is_object());
assert_eq!(args["config"]["setting"], "value");
assert_eq!(args["config"]["count"], 42);
}
#[test] fn test_parse_no_tool_calls() {
let input = "This is just normal text without any tool calls.";
let (calls, normal) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 0);
assert_eq!(normal, Some(input.to_string()));
}
#[test] fn test_parse_malformed_tool_call() {
let input = r#"<tool_call>
<function=incomplete>
<parameter=test>
value
</tool_call>"#;
let result = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None);
assert!(result.is_ok());
}
#[test] fn test_parse_missing_parameter_closing_tag() {
let input = r#"<tool_call>
<function=execute_bash>
<parameter=command>
ls -la
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "execute_bash");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["command"], "ls -la");
}
#[test] fn test_parse_missing_function_closing_tag() {
let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
Boston
</parameter>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["city"], "Boston");
}
#[test] fn test_parse_missing_both_closing_tags() {
let input = r#"<tool_call>
<function=run_query>
<parameter=sql>
SELECT * FROM users
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "run_query");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["sql"], "SELECT * FROM users\n</tool_call>");
}
#[test] fn test_parse_multiple_parameters_missing_closing_tags() {
let input = r#"<tool_call>
<function=search>
<parameter=query>
rust programming
<parameter=limit>
10
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "search");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["query"], "rust programming\n<parameter=limit>\n10");
}
#[test] fn test_parse_qwen3_no_outer_close_recovers() {
let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
NYC
</parameter>
</function>"#;
let config = XmlParserConfig {
allow_eof_recovery: true,
..XmlParserConfig::default()
};
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["city"], "NYC");
}
#[test]
fn test_parse_qwen3_bare_function_partial_no_recovery_returns_no_calls() {
let input = "<function=get_weather>\n<parameter=city>\nNY";
let config = XmlParserConfig {
backoff_when_no_wrapper: true,
..XmlParserConfig::default()
};
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert!(
calls.is_empty(),
"back-off must not fire on partial input without recovery (streaming jail leak)",
);
}
#[test]
fn test_parse_qwen3_bare_function_complete_without_outer_close_waits_in_streaming() {
let input = "<function=get_weather>\n<parameter=city>\nNYC\n</parameter>\n</function>";
let config = XmlParserConfig {
backoff_when_no_wrapper: true,
..XmlParserConfig::default()
};
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert!(calls.is_empty());
}
#[test]
fn test_parse_qwen3_bare_function_outer_close_streaming_recovers() {
let input = "<function=get_weather>\n<parameter=city>\nNYC\n</parameter>\n</function>\n</tool_call>";
let config = XmlParserConfig {
backoff_when_no_wrapper: true,
..XmlParserConfig::default()
};
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
}
#[test]
fn test_parse_qwen3_bare_function_truncated_value_dropped() {
let input = "<function=get_weather>\n<parameter=city>\nNY";
let config = XmlParserConfig {
backoff_when_no_wrapper: true,
allow_eof_recovery: true,
..XmlParserConfig::default()
};
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 0);
}
#[test]
fn test_parse_qwen3_bare_function_complete_value_recovers() {
let input = "<function=get_weather>\n<parameter=city>\nNY</parameter>";
let config = XmlParserConfig {
backoff_when_no_wrapper: true,
allow_eof_recovery: true,
..XmlParserConfig::default()
};
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["city"], "NY");
}
#[test]
fn test_parse_qwen3_no_outer_close_drops_suffix() {
let input = "<tool_call>\n<function=get_weather>\n<parameter=city>\nNYC\n</parameter>\n</function>\nTRAILING NOTE";
let config = XmlParserConfig {
allow_eof_recovery: true,
..XmlParserConfig::default()
};
let (calls, normal) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(normal, Some("".to_string()));
}
#[test] fn test_parse_minimax_m2_no_outer_close_recovers_complete_inner_call() {
let config = XmlParserConfig {
tool_call_start_token: "<minimax:tool_call>".to_string(),
tool_call_end_token: "</minimax:tool_call>".to_string(),
function_start_token: "<invoke name=".to_string(),
function_end_token: "</invoke>".to_string(),
parameter_start_token: "<parameter name=".to_string(),
parameter_end_token: "</parameter>".to_string(),
allow_eof_recovery: true,
strict_match: true,
passthrough_when_no_function: false,
backoff_when_no_wrapper: true,
};
let input = r#"<minimax:tool_call><invoke name="get_weather"><parameter name="city">NYC</parameter></invoke>"#;
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["city"], "NYC");
}
#[test] fn test_schema_aware_type_conversion() {
let tools = vec![ToolDefinition {
name: "multi_param_func".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"param1": {"type": "string"},
"param2": {"type": "float"},
"param3": {"type": "integer"},
"param4": {"type": "boolean"},
"param5": {"type": "object"},
"param6": {"type": "array"},
"param7": {"type": "null"},
"param8": {"type": "other_type"}
},
"required": ["param1", "param2", "param3", "param4", "param5", "param6", "param7", "param8"]
})),
strict: None,
}];
let input = r#"<tool_call>
<function=multi_param_func>
<parameter=param1>42</parameter>
<parameter=param2>41.9</parameter>
<parameter=param3>42</parameter>
<parameter=param4>true</parameter>
<parameter=param5>{"key": "value"}</parameter>
<parameter=param6>[1, 2, 3]</parameter>
<parameter=param7>null</parameter>
<parameter=param8>{'arg1': 3, 'arg2': [1, 2]}</parameter>
</function>
</tool_call>"#;
let (calls, _) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "multi_param_func");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["param1"], "42");
assert_eq!(args["param2"], 41.9);
assert_eq!(args["param3"], 42);
assert_eq!(args["param4"], true);
assert_eq!(args["param5"], serde_json::json!({"key": "value"}));
assert_eq!(args["param6"], serde_json::json!([1, 2, 3]));
assert_eq!(args["param7"], serde_json::Value::Null);
assert_eq!(
args["param8"],
serde_json::json!({"arg1": 3, "arg2": [1, 2]})
);
}
#[test] fn test_schema_aware_type_conversion_fallback() {
let tools = vec![ToolDefinition {
name: "test_func".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"int_param": {"type": "integer"},
"float_param": {"type": "float"},
"bool_param": {"type": "boolean"}
}
})),
strict: None,
}];
let input = r#"<tool_call>
<function=test_func>
<parameter=int_param>not_an_int</parameter>
<parameter=float_param>not_a_float</parameter>
<parameter=bool_param>not_a_bool</parameter>
</function>
</tool_call>"#;
let (calls, _) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
assert_eq!(calls.len(), 1);
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["int_param"], "not_an_int");
assert_eq!(args["float_param"], "not_a_float");
assert_eq!(args["bool_param"], false);
}
#[test] fn test_anyof_param_parsed_as_object_not_string() {
let tools = vec![ToolDefinition {
name: "get_weather".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"required": ["location"],
"properties": {
"location": {
"anyOf": [
{
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
},
{
"type": "object",
"properties": {
"lat": {"type": "number"},
"lon": {"type": "number"}
},
"required": ["lat", "lon"]
}
]
}
}
})),
strict: None,
}];
let input = r#"<tool_call>
<function=get_weather>
<parameter=location>
{"city": "Paris"}
</parameter>
</function>
</tool_call>"#;
let (calls, _) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
assert_eq!(calls.len(), 1);
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert!(
args["location"].is_object(),
"Expected location to be an object, got: {}",
args["location"]
);
assert_eq!(args["location"]["city"], "Paris");
}
#[test] fn test_no_schema_fallback_behavior() {
let input = r#"<tool_call>
<function=unknown_func>
<parameter=param1>42</parameter>
<parameter=param2>true</parameter>
<parameter=param3>hello</parameter>
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["param1"], "42");
assert_eq!(args["param2"], "true");
assert_eq!(args["param3"], "hello");
}
fn minimax_m2_config() -> XmlParserConfig {
XmlParserConfig {
tool_call_start_token: "<minimax:tool_call>".to_string(),
tool_call_end_token: "</minimax:tool_call>".to_string(),
function_start_token: "<invoke name=".to_string(),
function_end_token: "</invoke>".to_string(),
parameter_start_token: "<parameter name=".to_string(),
parameter_end_token: "</parameter>".to_string(),
allow_eof_recovery: false,
strict_match: true,
passthrough_when_no_function: false,
backoff_when_no_wrapper: true,
}
}
#[test] fn test_parse_qwen3_empty_args() {
let input = r#"<tool_call>
<function=current_time>
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "current_time");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args, serde_json::json!({}));
}
#[test] fn test_parse_minimax_m2_empty_args() {
let config = minimax_m2_config();
let input =
r#"<minimax:tool_call><invoke name="current_time"></invoke></minimax:tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "current_time");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args, serde_json::json!({}));
}
#[test]
fn test_xml_qwen3_parser_output_independent_of_upstream_finish() {
let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
NYC
</parameter>
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 1);
}
#[test]
fn test_xml_minimax_m2_parser_output_independent_of_upstream_finish() {
let config = minimax_m2_config();
let input = r#"<minimax:tool_call><invoke name="get_weather"><parameter name="city">NYC</parameter></invoke></minimax:tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
}
#[test] fn test_parse_qwen3_empty_and_whitespace_inputs() {
for input in &["", " ", "\n", "\t\n \t"] {
let (calls, normal) =
try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert!(
calls.is_empty(),
"Empty/whitespace input must yield no calls (input={:?})",
input
);
assert_eq!(
normal.as_deref(),
Some(""),
"Empty/whitespace input collapses to empty normal_text (input={:?})",
input
);
}
}
#[test] fn test_parse_minimax_m2_empty_and_whitespace_inputs() {
let config = minimax_m2_config();
for input in &["", " ", "\n", "\t\n \t"] {
let (calls, normal) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert!(
calls.is_empty(),
"Empty/whitespace input must yield no calls (input={:?})",
input
);
assert_eq!(
normal.as_deref(),
Some(""),
"Empty/whitespace input collapses to empty normal_text (input={:?})",
input
);
}
}
#[test] fn test_parse_qwen3_duplicate_calls_same_name() {
let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
NYC
</parameter>
</function>
<function=get_weather>
<parameter=city>
LA
</parameter>
</function>
</tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
assert_eq!(calls.len(), 2, "Both duplicate-name calls must be returned");
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_weather");
assert_ne!(
calls[0].id, calls[1].id,
"Duplicate calls must have distinct ids"
);
let args0: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
let args1: serde_json::Value = serde_json::from_str(&calls[1].function.arguments).unwrap();
assert_eq!(args0["city"], "NYC");
assert_eq!(args1["city"], "LA");
}
#[test] fn test_parse_minimax_m2_duplicate_calls_same_name() {
let config = minimax_m2_config();
let input = r#"<minimax:tool_call><invoke name="get_weather"><parameter name="city">NYC</parameter></invoke><invoke name="get_weather"><parameter name="city">LA</parameter></invoke></minimax:tool_call>"#;
let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
assert_eq!(calls.len(), 2, "Both duplicate-name calls must be returned");
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_weather");
assert_ne!(
calls[0].id, calls[1].id,
"Duplicate calls must have distinct ids"
);
let args0: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
let args1: serde_json::Value = serde_json::from_str(&calls[1].function.arguments).unwrap();
assert_eq!(args0["city"], "NYC");
assert_eq!(args1["city"], "LA");
}
}