use regex::Regex;
use serde_json::Value;
use std::collections::HashMap;
use tracing::warn;
use uuid::Uuid;
use super::super::ToolDefinition;
use super::super::config::Glm47ParserConfig;
use super::parsed_value::{ParsedValue, coerce_integer_literal};
use super::response::{CalledFunction, ToolCallResponse, ToolCallType};
fn truncate_for_log(s: &str) -> String {
const MAX: usize = 200;
let mut out = String::with_capacity(MAX.min(s.len()) + 16);
let mut bytes = 0usize;
for ch in s.chars() {
if bytes >= MAX {
out.push('…');
break;
}
match ch {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
bytes += ch.len_utf8();
}
out
}
pub fn detect_tool_call_start_glm47(chunk: &str, config: &Glm47ParserConfig) -> bool {
let start_token = &config.tool_call_start;
let arg_key_start = &config.arg_key_start;
if chunk.contains(start_token.as_str()) || chunk.contains(arg_key_start.as_str()) {
return true;
}
for i in 1..start_token.len() {
if chunk.ends_with(&start_token[..i]) {
return true;
}
}
false
}
pub fn find_tool_call_end_position_glm47(chunk: &str, config: &Glm47ParserConfig) -> usize {
let start_token = &config.tool_call_start;
let end_token = &config.tool_call_end;
if !chunk.contains(start_token.as_str()) && chunk.contains(config.arg_key_start.as_str()) {
return find_bare_glm47_tool_call_end_position(chunk, config).unwrap_or(chunk.len());
}
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 !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
}
fn find_bare_glm47_tool_call_end_position(text: &str, config: &Glm47ParserConfig) -> Option<usize> {
let marker_idx = first_orphan_glm47_marker_index(text, config)?;
let before_marker = text[..marker_idx].trim_end();
let function_name_start = before_marker
.char_indices()
.rev()
.find(|(_, ch)| ch.is_whitespace())
.map(|(idx, ch)| idx + ch.len_utf8())
.unwrap_or(0);
let candidate_name = before_marker[function_name_start..].trim();
if candidate_name.is_empty()
|| !candidate_name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
return None;
}
let mut cursor = function_name_start;
let mut last_complete_end = None;
while cursor < text.len() {
let rest = &text[cursor..];
let trim_offset = rest.len() - rest.trim_start().len();
let call_start = cursor + trim_offset;
let tail = &text[call_start..];
let Some(end_pos) = tail.find(config.tool_call_end.as_str()) else {
break;
};
let call_end = call_start + end_pos + config.tool_call_end.len();
cursor = consume_glm47_close_markers(text, call_end, config);
last_complete_end = Some(cursor);
if first_orphan_glm47_marker_index(&text[cursor..], config).is_none() {
break;
}
}
last_complete_end
}
pub fn try_tool_call_parse_glm47(
message: &str,
config: &Glm47ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(Vec<ToolCallResponse>, Option<String>)> {
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: &Glm47ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(String, Vec<ToolCallResponse>)> {
let mut normal_parts = Vec::new();
let mut calls = Vec::new();
let mut cursor = 0;
let start_token = &config.tool_call_start;
let end_token = &config.tool_call_end;
if !text.contains(start_token.as_str())
&& let Some(marker_idx) = first_orphan_glm47_marker_index(text, config)
{
if let Some((prefix, mut parsed_calls)) =
recover_bare_glm47_calls(text, marker_idx, config, tools)?
{
let recovered_calls = parsed_calls.len();
warn!(
why = "bare_body_recovery",
recovered_calls,
recovered_bytes = text.len() - prefix.len(),
kept_prefix_bytes = prefix.len(),
"GLM-4.7 parser recovered complete bare call body/bodies without <tool_call> start"
);
calls.append(&mut parsed_calls);
return Ok((prefix, calls));
}
warn!(
why = "GLM-4.7 tool-call marker found without <tool_call> start; dropping orphan marker tail so wire tags do not leak into normal_text",
dropped_block = %truncate_for_log(&text[marker_idx..]),
"GLM-4.7 parser dropping orphan tool-call marker tail"
);
return Ok((orphan_glm47_prefix(text, marker_idx), calls));
}
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 parsed_calls)) =
recover_bare_glm47_calls_in_span(gap, config, tools)?
{
if calls.is_empty() {
normal_parts.push(prefix);
}
calls.append(&mut parsed_calls);
} else if calls.is_empty() {
if let Some(marker_idx) = first_orphan_glm47_marker_index(gap, config) {
normal_parts.push(orphan_glm47_prefix(gap, marker_idx));
} else {
normal_parts.push(gap.to_string());
}
}
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];
match parse_tool_call_block(block, config, tools) {
Ok(parsed_call) => calls.push(parsed_call),
Err(e) => {
warn!(
reason = %e,
why = "block has open + close fence but content failed to parse \
as a GLM-4.7 tool call (e.g. empty function name, \
missing <arg_key>, malformed args); dropping to avoid \
leaking wire tags through normal_text",
dropped_block = %truncate_for_log(block),
"GLM-4.7 parser dropping unparseable tool_call block"
);
}
}
cursor = abs_end;
} else {
let block = &text[abs_start..];
let arg_key_start = &config.arg_key_start;
if config.allow_eof_recovery && block.contains(arg_key_start.as_str()) {
match parse_tool_call_block(block, config, tools) {
Ok(parsed_call) => {
calls.push(parsed_call);
cursor = text.len();
continue;
}
Err(e) => {
warn!(
reason = %e,
why = "EOF recovery enabled and <arg_key> opener present, \
but parse_tool_call_block failed on the truncated \
tail; dropping to avoid leaking wire tags through \
normal_text",
dropped_block = %truncate_for_log(block),
"GLM-4.7 parser dropping truncated tool_call block (recovery attempt failed)"
);
}
}
} else {
let reason = if !config.allow_eof_recovery {
"allow_eof_recovery=false (production default for GLM-4.7 to match \
vLLM/SGLang on truncated tool calls)"
} else {
"no <arg_key> in the tail after the <tool_call> start fence, so the \
block does not look like a structurally-real GLM-4.7 tool call"
};
warn!(
why = %reason,
dropped_block = %truncate_for_log(block),
"GLM-4.7 parser dropping truncated tool_call block (no end fence)"
);
}
break;
}
} else {
let gap = &text[cursor..];
if let Some((prefix, mut parsed_calls)) =
recover_bare_glm47_calls_in_span(gap, config, tools)?
{
if calls.is_empty() {
normal_parts.push(prefix);
}
calls.append(&mut parsed_calls);
} else if calls.is_empty() {
if let Some(marker_idx) = first_orphan_glm47_marker_index(gap, config) {
normal_parts.push(orphan_glm47_prefix(gap, marker_idx));
} else {
normal_parts.push(gap.to_string());
}
}
break;
}
}
let normal_text = normal_parts.join("");
let normal_text = if calls.is_empty() {
normal_text.trim().to_string()
} else {
normal_text
};
Ok((normal_text, calls))
}
fn recover_bare_glm47_calls_in_span(
span: &str,
config: &Glm47ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Option<(String, Vec<ToolCallResponse>)>> {
let Some(marker_idx) = first_orphan_glm47_marker_index(span, config) else {
return Ok(None);
};
let recovered = recover_bare_glm47_calls(span, marker_idx, config, tools)?;
if let Some((prefix, parsed_calls)) = recovered {
let recovered_calls = parsed_calls.len();
warn!(
why = "bare_body_gap_recovery",
recovered_calls,
recovered_bytes = span.len() - prefix.len(),
kept_prefix_bytes = prefix.len(),
"GLM-4.7 parser recovered complete bare call body/bodies before a later <tool_call>"
);
return Ok(Some((prefix, parsed_calls)));
}
Ok(None)
}
fn first_orphan_glm47_marker_index(text: &str, config: &Glm47ParserConfig) -> Option<usize> {
[
config.tool_call_end.as_str(),
config.arg_key_start.as_str(),
config.arg_key_end.as_str(),
config.arg_value_start.as_str(),
config.arg_value_end.as_str(),
]
.into_iter()
.filter_map(|marker| text.find(marker))
.min()
}
fn orphan_glm47_prefix(text: &str, marker_idx: usize) -> String {
let prefix = text[..marker_idx].trim_end();
let token_start = prefix
.char_indices()
.rev()
.find(|(_, ch)| ch.is_whitespace())
.map(|(idx, ch)| idx + ch.len_utf8())
.unwrap_or(0);
let tail = &prefix[token_start..];
if !tail.is_empty()
&& tail
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
prefix[..token_start].trim().to_string()
} else {
prefix.trim().to_string()
}
}
fn recover_bare_glm47_calls(
text: &str,
marker_idx: usize,
config: &Glm47ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Option<(String, Vec<ToolCallResponse>)>> {
if !text[marker_idx..].contains(config.tool_call_end.as_str()) {
return Ok(None);
}
let before_marker = text[..marker_idx].trim_end();
let function_name_start = before_marker
.char_indices()
.rev()
.find(|(_, ch)| ch.is_whitespace())
.map(|(idx, ch)| idx + ch.len_utf8())
.unwrap_or(0);
let candidate_name = before_marker[function_name_start..].trim();
if candidate_name.is_empty()
|| !candidate_name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
return Ok(None);
}
let prefix = text[..function_name_start].to_string();
let mut cursor = function_name_start;
let mut calls = Vec::new();
while cursor < text.len() {
let rest = &text[cursor..];
let trim_offset = rest.len() - rest.trim_start().len();
cursor += trim_offset;
let tail = &text[cursor..];
let Some(end_pos) = tail.find(config.tool_call_end.as_str()) else {
break;
};
let call_end = cursor + end_pos + config.tool_call_end.len();
let wrapped = format!("{}{}", config.tool_call_start, &text[cursor..call_end]);
calls.push(parse_tool_call_block(&wrapped, config, tools)?);
cursor = call_end;
if is_glm47_close_marker_spam(&text[cursor..], config) {
warn!(
why = "orphan_close_marker_spam",
dropped_block = %truncate_for_log(&text[cursor..]),
"GLM-4.7 parser dropping orphan close-marker spam after recovered bare call"
);
break;
}
if first_orphan_glm47_marker_index(&text[cursor..], config).is_none() {
break;
}
}
if calls.is_empty() {
return Ok(None);
}
Ok(Some((prefix, calls)))
}
fn is_glm47_close_marker_spam(text: &str, config: &Glm47ParserConfig) -> bool {
let mut rest = text.trim_start();
let mut saw_close = false;
while let Some(after_close) = rest.strip_prefix(config.tool_call_end.as_str()) {
saw_close = true;
rest = after_close.trim_start();
}
saw_close && rest.is_empty()
}
fn consume_glm47_close_markers(text: &str, mut cursor: usize, config: &Glm47ParserConfig) -> usize {
loop {
let rest = &text[cursor..];
let trim_offset = rest.len() - rest.trim_start().len();
let close_start = cursor + trim_offset;
if !text[close_start..].starts_with(config.tool_call_end.as_str()) {
return cursor;
}
cursor = close_start + config.tool_call_end.len();
}
}
fn decode_xml_entities(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
}
fn coerce_value(raw: &str, schema_type: Option<&str>) -> ParsedValue {
let trimmed = raw.trim();
if (trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"'))
&& let Ok(v) = serde_json::from_str::<Value>(trimmed)
{
return v.into();
}
match schema_type {
Some("integer") | Some("int") => {
if let Some(value) = coerce_integer_literal(trimmed) {
return value;
}
}
Some("number") | Some("float") | Some("double") => {
if let Some(value) = coerce_integer_literal(trimmed) {
return value;
}
if let Ok(n) = trimmed.parse::<f64>()
&& let Some(num) = serde_json::Number::from_f64(n)
{
return Value::Number(num).into();
}
}
Some("boolean") | Some("bool") => match trimmed.to_lowercase().as_str() {
"true" | "1" | "yes" => return Value::Bool(true).into(),
"false" | "0" | "no" => return Value::Bool(false).into(),
_ => {}
},
Some("array") => {
if let Ok(v) = serde_json::from_str::<Value>(trimmed)
&& v.is_array()
{
return v.into();
}
let items: Vec<Value> = trimmed
.split(',')
.map(|s| Value::String(s.trim().to_string()))
.collect();
return Value::Array(items).into();
}
Some("null") if trimmed == "null" || trimmed == "None" || trimmed.is_empty() => {
return Value::Null.into();
}
_ => {}
}
Value::String(raw.to_string()).into()
}
fn get_param_schema_type<'a>(
tools: Option<&'a [ToolDefinition]>,
function_name: &str,
param_name: &str,
) -> Option<&'a str> {
let tool = tools?.iter().find(|t| t.name == function_name)?;
let schema = tool.parameters.as_ref()?;
let props = schema.get("properties")?;
let param = props.get(param_name)?;
param.get("type")?.as_str()
}
fn parse_tool_call_block(
block: &str,
config: &Glm47ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<ToolCallResponse> {
let start_token = &config.tool_call_start;
let end_token = &config.tool_call_end;
let after_start = block
.strip_prefix(start_token.as_str())
.ok_or_else(|| anyhow::anyhow!("Invalid tool call block format"))?;
let content = after_start
.strip_suffix(end_token.as_str())
.unwrap_or(after_start);
let arg_key_start = &config.arg_key_start;
let function_name = if let Some(pos) = content.find(arg_key_start.as_str()) {
content[..pos].trim().to_string()
} else {
content.trim().to_string()
};
if function_name.is_empty() {
anyhow::bail!("Empty function name in tool call");
}
let mut arguments = HashMap::new();
let args_section = &content[function_name.len()..];
let arg_key_start_escaped = regex::escape(&config.arg_key_start);
let arg_key_end_escaped = regex::escape(&config.arg_key_end);
let arg_value_start_escaped = regex::escape(&config.arg_value_start);
let arg_value_end_escaped = regex::escape(&config.arg_value_end);
let pattern = format!(
r"(?s){}([^<]+){}{}(.*?){}",
arg_key_start_escaped, arg_key_end_escaped, arg_value_start_escaped, arg_value_end_escaped
);
let regex = Regex::new(&pattern)?;
for cap in regex.captures_iter(args_section) {
let key = cap.get(1).map(|m| m.as_str().trim()).unwrap_or("");
let raw_value = cap.get(2).map(|m| m.as_str()).unwrap_or("");
if !key.is_empty() {
let decoded = decode_xml_entities(raw_value);
let schema_type = get_param_schema_type(tools, &function_name, key);
let json_value = coerce_value(&decoded, schema_type);
arguments.insert(key.to_string(), json_value);
}
}
if let Some(tools_list) = tools {
let tool_exists = tools_list.iter().any(|t| t.name == function_name);
if !tool_exists {
anyhow::bail!("Function '{}' not found in available tools", function_name);
}
}
Ok(ToolCallResponse {
id: Uuid::new_v4().to_string(),
tp: ToolCallType::Function,
function: CalledFunction {
name: function_name,
arguments: serde_json::to_string(&arguments)?,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn get_test_config() -> Glm47ParserConfig {
Glm47ParserConfig::default()
}
#[test] fn test_detect_tool_call_start() {
let config = get_test_config();
assert!(detect_tool_call_start_glm47(
"<tool_call>get_weather",
&config
));
assert!(detect_tool_call_start_glm47("Some text <tool", &config));
assert!(detect_tool_call_start_glm47("Some text <tool_c", &config));
assert!(!detect_tool_call_start_glm47("Just normal text", &config));
}
#[test] fn test_parse_simple_tool_call() {
let config = get_test_config();
let message = "<tool_call>get_weather<arg_key>location</arg_key><arg_value>San Francisco</arg_value></tool_call>";
let (calls, normal_text) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(
args.get("location").unwrap().as_str().unwrap(),
"San Francisco"
);
assert_eq!(normal_text, Some("".to_string()));
}
#[test] fn test_parse_tool_call_with_multiple_args() {
let config = get_test_config();
let message = "<tool_call>book_flight<arg_key>from</arg_key><arg_value>NYC</arg_value><arg_key>to</arg_key><arg_value>LAX</arg_value><arg_key>date</arg_key><arg_value>2026-03-15</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "book_flight");
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args.get("from").unwrap().as_str().unwrap(), "NYC");
assert_eq!(args.get("to").unwrap().as_str().unwrap(), "LAX");
assert_eq!(args.get("date").unwrap().as_str().unwrap(), "2026-03-15");
}
#[test] fn test_parse_tool_call_with_json_value() {
let config = get_test_config();
let message = r#"<tool_call>search<arg_key>filters</arg_key><arg_value>{"category": "books", "price_max": 50}</arg_value></tool_call>"#;
let (calls, _) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "search");
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
let filters = args.get("filters").unwrap();
assert!(filters.is_object());
}
#[test] fn test_parse_multiple_tool_calls() {
let config = get_test_config();
let message = "<tool_call>get_weather<arg_key>location</arg_key><arg_value>NYC</arg_value></tool_call><tool_call>get_time<arg_key>timezone</arg_key><arg_value>EST</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_time");
}
#[test] fn test_parse_with_normal_text() {
let config = get_test_config();
let message = "I'll check the weather for you. <tool_call>get_weather<arg_key>location</arg_key><arg_value>Paris</arg_value></tool_call>";
let (calls, normal_text) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(
normal_text,
Some("I'll check the weather for you. ".to_string())
);
}
#[test] fn test_parse_tool_call_no_args() {
let config = get_test_config();
let message = "<tool_call>get_current_time</tool_call>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_current_time");
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert!(args.is_empty());
}
#[test] fn test_find_tool_call_end_position() {
let config = get_test_config();
let chunk =
"<tool_call>func<arg_key>k</arg_key><arg_value>v</arg_value></tool_call>more text";
let end_pos = find_tool_call_end_position_glm47(chunk, &config);
assert_eq!(
&chunk[..end_pos],
"<tool_call>func<arg_key>k</arg_key><arg_value>v</arg_value></tool_call>"
);
}
#[test] fn test_find_tool_call_end_position_parallel() {
let config = get_test_config();
let chunk = "<tool_call>get_weather<arg_key>location</arg_key><arg_value>SF</arg_value></tool_call><tool_call>get_weather<arg_key>location</arg_key><arg_value>NYC</arg_value></tool_call>trailing";
let end_pos = find_tool_call_end_position_glm47(chunk, &config);
assert_eq!(
&chunk[..end_pos],
"<tool_call>get_weather<arg_key>location</arg_key><arg_value>SF</arg_value></tool_call><tool_call>get_weather<arg_key>location</arg_key><arg_value>NYC</arg_value></tool_call>",
"Must advance past ALL consecutive tool call blocks"
);
}
#[test] fn test_find_tool_call_end_position_parallel_with_whitespace() {
let config = get_test_config();
let chunk = "<tool_call>a<arg_key>k</arg_key><arg_value>1</arg_value></tool_call>\n<tool_call>b<arg_key>k</arg_key><arg_value>2</arg_value></tool_call>";
let end_pos = find_tool_call_end_position_glm47(chunk, &config);
assert_eq!(
end_pos,
chunk.len(),
"Must handle whitespace/newlines between consecutive blocks"
);
}
#[test] fn test_find_tool_call_end_position_parallel_second_incomplete() {
let config = get_test_config();
let chunk = "<tool_call>a<arg_key>k</arg_key><arg_value>1</arg_value></tool_call><tool_call>b<arg_key>k</arg_key><arg_value>2</arg_value>";
let end_pos = find_tool_call_end_position_glm47(chunk, &config);
assert_eq!(
&chunk[..end_pos],
"<tool_call>a<arg_key>k</arg_key><arg_value>1</arg_value></tool_call>",
"Must stop at first complete block when second is incomplete"
);
}
#[test] fn test_parse_text_then_parallel_calls() {
let config = get_test_config();
let message = "I'll check the weather for both cities at the same time!<tool_call>get_weather<arg_key>location</arg_key><arg_value>San Francisco</arg_value></tool_call><tool_call>get_weather<arg_key>location</arg_key><arg_value>New York</arg_value></tool_call>";
let (calls, normal_text) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 2, "Both parallel calls must be extracted");
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_weather");
let args0: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
let args1: HashMap<String, Value> =
serde_json::from_str(&calls[1].function.arguments).unwrap();
assert_eq!(
args0.get("location").unwrap().as_str().unwrap(),
"San Francisco"
);
assert_eq!(args1.get("location").unwrap().as_str().unwrap(), "New York");
let text = normal_text.unwrap();
assert_eq!(
text,
"I'll check the weather for both cities at the same time!"
);
}
#[test]
fn test_parse_two_bare_calls_without_start_markers_recovers_independently() {
let config = get_test_config();
let message = "I will check both. get_weather<arg_key>location</arg_key><arg_value>NYC</arg_value></tool_call>get_time<arg_key>timezone</arg_key><arg_value>EST</arg_value></tool_call>";
let (calls, normal_text) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_time");
let args0: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
let args1: HashMap<String, Value> =
serde_json::from_str(&calls[1].function.arguments).unwrap();
assert_eq!(args0["location"], "NYC");
assert_eq!(args1["timezone"], "EST");
assert_eq!(normal_text.unwrap(), "I will check both. ");
}
#[test] fn test_parse_multiline_arg_value() {
let config = get_test_config();
let message = "<tool_call>write_file<arg_key>path</arg_key><arg_value>/tmp/hello.py</arg_value><arg_key>content</arg_key><arg_value>#!/usr/bin/env python3\nprint(\"Hello, World!\")\n</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "write_file");
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args.get("path").unwrap().as_str().unwrap(), "/tmp/hello.py");
assert!(
args.contains_key("content"),
"content argument must be parsed even when it contains newlines"
);
let content = args.get("content").unwrap().as_str().unwrap();
assert!(content.contains("print(\"Hello, World!\")"));
}
#[test] fn test_malformed_tool_call() {
let config = get_test_config();
let message = "<tool_call>get_weather";
let result = try_tool_call_parse_glm47(message, &config, None);
assert!(result.is_ok());
let (calls, _) = result.unwrap();
assert_eq!(calls.len(), 0);
}
#[test] fn test_parse_no_end_tag_complete_args_recovers() {
let config = Glm47ParserConfig {
allow_eof_recovery: true,
..get_test_config()
};
let message = "<tool_call>get_weather<arg_key>location</arg_key><arg_value>NYC</arg_value>";
let (calls, _) = try_tool_call_parse_glm47(message, &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["location"], "NYC");
}
#[test] fn test_parse_no_end_tag_multiple_calls_recovers() {
let config = Glm47ParserConfig {
allow_eof_recovery: true,
..get_test_config()
};
let message = "<tool_call>get_weather<arg_key>city</arg_key><arg_value>NYC</arg_value></tool_call><tool_call>get_time<arg_key>tz</arg_key><arg_value>EST</arg_value>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_time");
}
#[test] fn test_unparseable_block_dropped_no_tag_leak() {
let config = get_test_config();
let tools = vec![ToolDefinition {
name: "get_weather".to_string(),
parameters: None,
strict: None,
}];
let message = "Here is the result: <tool_call>unknown_func<arg_key>x</arg_key><arg_value>1</arg_value></tool_call> done";
let (calls, normal_text) =
try_tool_call_parse_glm47(message, &config, Some(&tools)).unwrap();
assert_eq!(calls.len(), 0);
let text = normal_text.unwrap();
assert!(
!text.contains("unknown_func"),
"Unparseable block must be dropped to avoid tag leakage, got: {text}"
);
assert!(
!text.contains("<tool_call>") && !text.contains("<arg_key>"),
"Wire-format tags must not leak into normal_text, got: {text}"
);
assert!(
text.contains("Here is the result:") && text.contains("done"),
"Surrounding prose must be preserved, got: {text}"
);
}
#[test] fn test_xml_entity_decoding() {
let config = get_test_config();
let message = r#"<tool_call>write_file<arg_key>content</arg_key><arg_value>x < y && y > z</arg_value></tool_call>"#;
let (calls, _) = try_tool_call_parse_glm47(message, &config, None).unwrap();
assert_eq!(calls.len(), 1);
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(
args.get("content").unwrap().as_str().unwrap(),
"x < y && y > z"
);
}
#[test] fn test_type_coercion_with_schema() {
let config = get_test_config();
let tools = vec![ToolDefinition {
name: "set_temperature".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"degrees": {"type": "number"},
"enabled": {"type": "boolean"},
"count": {"type": "integer"},
"huge_integer": {"type": "integer"},
"large_count": {"type": "number"},
"huge_count": {"type": "number"},
"label": {"type": "string"}
}
})),
strict: None,
}];
let message = "<tool_call>set_temperature<arg_key>degrees</arg_key><arg_value>72.5</arg_value><arg_key>enabled</arg_key><arg_value>true</arg_value><arg_key>count</arg_key><arg_value>3</arg_value><arg_key>huge_integer</arg_key><arg_value>9223372036854775808</arg_value><arg_key>large_count</arg_key><arg_value>9007199254740993</arg_value><arg_key>huge_count</arg_key><arg_value>100000000000000000000</arg_value><arg_key>label</arg_key><arg_value>warm</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, Some(&tools)).unwrap();
assert_eq!(calls.len(), 1);
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args.get("degrees").unwrap().as_f64().unwrap(), 72.5);
assert!(args.get("enabled").unwrap().as_bool().unwrap());
assert_eq!(args.get("count").unwrap().as_i64().unwrap(), 3);
assert_eq!(
args.get("large_count").unwrap().as_i64().unwrap(),
9007199254740993
);
let raw_args: HashMap<String, Box<serde_json::value::RawValue>> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(raw_args["huge_integer"].get(), "9223372036854775808");
assert_eq!(raw_args["huge_count"].get(), "100000000000000000000");
assert_eq!(args.get("label").unwrap().as_str().unwrap(), "warm");
}
#[test] fn test_type_coercion_array_comma_separated() {
let config = get_test_config();
let tools = vec![ToolDefinition {
name: "tag_item".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"tags": {"type": "array"}
}
})),
strict: None,
}];
let message = "<tool_call>tag_item<arg_key>tags</arg_key><arg_value>rust, python, go</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, Some(&tools)).unwrap();
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
let tags = args.get("tags").unwrap().as_array().unwrap();
assert_eq!(tags.len(), 3);
assert_eq!(tags[0].as_str().unwrap(), "rust");
assert_eq!(tags[1].as_str().unwrap(), "python");
assert_eq!(tags[2].as_str().unwrap(), "go");
}
#[test] fn test_type_coercion_array_json() {
let config = get_test_config();
let tools = vec![ToolDefinition {
name: "tag_item".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"ids": {"type": "array"}
}
})),
strict: None,
}];
let message = r#"<tool_call>tag_item<arg_key>ids</arg_key><arg_value>[1, 2, 3]</arg_value></tool_call>"#;
let (calls, _) = try_tool_call_parse_glm47(message, &config, Some(&tools)).unwrap();
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
let ids = args.get("ids").unwrap().as_array().unwrap();
assert_eq!(ids.len(), 3);
assert_eq!(ids[0].as_i64().unwrap(), 1);
}
#[test] fn test_type_coercion_falls_back_to_string() {
let config = get_test_config();
let tools = vec![ToolDefinition {
name: "test_func".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"count": {"type": "integer"}
}
})),
strict: None,
}];
let message = "<tool_call>test_func<arg_key>count</arg_key><arg_value>not_a_number</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(message, &config, Some(&tools)).unwrap();
let args: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
assert!(
args.get("count").unwrap().is_string(),
"Should fall back to string when coercion fails"
);
}
#[test]
fn test_glm47_parser_output_independent_of_upstream_finish() {
let config = get_test_config();
let input = "<tool_call>get_weather<arg_key>location</arg_key><arg_value>NYC</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(input, &config, None).unwrap();
assert_eq!(calls.len(), 1);
}
#[test] fn test_parse_glm47_empty_and_whitespace_inputs() {
let config = get_test_config();
for input in &["", " ", "\n", "\t\n \t"] {
let (calls, normal) = try_tool_call_parse_glm47(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_glm47_duplicate_calls_same_name() {
let config = get_test_config();
let input = "<tool_call>get_weather<arg_key>location</arg_key><arg_value>NYC</arg_value></tool_call><tool_call>get_weather<arg_key>location</arg_key><arg_value>LA</arg_value></tool_call>";
let (calls, _) = try_tool_call_parse_glm47(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: HashMap<String, Value> =
serde_json::from_str(&calls[0].function.arguments).unwrap();
let args1: HashMap<String, Value> =
serde_json::from_str(&calls[1].function.arguments).unwrap();
assert_eq!(args0.get("location").unwrap().as_str().unwrap(), "NYC");
assert_eq!(args1.get("location").unwrap().as_str().unwrap(), "LA");
}
}