zeroclaw/src/agent/loop_/parsing.rs
2026-02-28 13:32:13 -05:00

1694 lines
58 KiB
Rust

use crate::providers::ToolCall;
use regex::Regex;
use std::collections::HashSet;
use std::sync::LazyLock;
#[derive(Debug, Clone)]
pub(super) struct ParsedToolCall {
pub(super) name: String,
pub(super) arguments: serde_json::Value,
pub(super) tool_call_id: Option<String>,
}
pub(super) fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
match raw {
Some(serde_json::Value::String(s)) => serde_json::from_str::<serde_json::Value>(s)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
Some(value) => value.clone(),
None => serde_json::Value::Object(serde_json::Map::new()),
}
}
pub(super) fn raw_string_argument_hint(raw: Option<&serde_json::Value>) -> Option<&str> {
raw.and_then(|value| value.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
}
pub(super) fn normalize_shell_command_from_raw(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let unwrapped = trimmed
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| {
trimmed
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
})
.unwrap_or(trimmed)
.trim();
if unwrapped.is_empty() {
return None;
}
if (unwrapped.starts_with('{') && unwrapped.ends_with('}'))
|| (unwrapped.starts_with('[') && unwrapped.ends_with(']'))
{
return None;
}
if unwrapped.starts_with("http://") || unwrapped.starts_with("https://") {
return build_curl_command(unwrapped).or_else(|| Some(unwrapped.to_string()));
}
Some(unwrapped.to_string())
}
pub(super) fn normalize_shell_arguments(
arguments: serde_json::Value,
raw_string_hint: Option<&str>,
) -> serde_json::Value {
match arguments {
serde_json::Value::Object(mut map) => {
if map
.get("command")
.and_then(|v| v.as_str())
.map(str::trim)
.is_some_and(|cmd| !cmd.is_empty())
{
return serde_json::Value::Object(map);
}
for alias in [
"cmd",
"script",
"shell_command",
"command_line",
"bash",
"sh",
"input",
] {
if let Some(value) = map.get(alias).and_then(|v| v.as_str()) {
if let Some(command) = normalize_shell_command_from_raw(value) {
map.insert("command".to_string(), serde_json::Value::String(command));
return serde_json::Value::Object(map);
}
}
}
if let Some(url) = map
.get("url")
.or_else(|| map.get("http_url"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|url| !url.is_empty())
{
if let Some(command) = normalize_shell_command_from_raw(url) {
map.insert("command".to_string(), serde_json::Value::String(command));
return serde_json::Value::Object(map);
}
}
if let Some(raw) = raw_string_hint.and_then(normalize_shell_command_from_raw) {
map.insert("command".to_string(), serde_json::Value::String(raw));
}
serde_json::Value::Object(map)
}
serde_json::Value::String(raw) => normalize_shell_command_from_raw(&raw)
.map(|command| serde_json::json!({ "command": command }))
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
_ => raw_string_hint
.and_then(normalize_shell_command_from_raw)
.map(|command| serde_json::json!({ "command": command }))
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
}
}
pub(super) fn normalize_tool_arguments(
tool_name: &str,
arguments: serde_json::Value,
raw_string_hint: Option<&str>,
) -> serde_json::Value {
match map_tool_name_alias(tool_name) {
"shell" => normalize_shell_arguments(arguments, raw_string_hint),
_ => arguments,
}
}
pub(super) fn parse_tool_call_id(
root: &serde_json::Value,
function: Option<&serde_json::Value>,
) -> Option<String> {
function
.and_then(|func| func.get("id"))
.or_else(|| root.get("id"))
.or_else(|| root.get("tool_call_id"))
.or_else(|| root.get("call_id"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToString::to_string)
}
pub(super) fn canonicalize_json_for_tool_signature(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut keys: Vec<String> = map.keys().cloned().collect();
keys.sort_unstable();
let mut ordered = serde_json::Map::new();
for key in keys {
if let Some(child) = map.get(&key) {
ordered.insert(key, canonicalize_json_for_tool_signature(child));
}
}
serde_json::Value::Object(ordered)
}
serde_json::Value::Array(items) => serde_json::Value::Array(
items
.iter()
.map(canonicalize_json_for_tool_signature)
.collect(),
),
_ => value.clone(),
}
}
pub(super) fn tool_call_signature(name: &str, arguments: &serde_json::Value) -> (String, String) {
let canonical_args = canonicalize_json_for_tool_signature(arguments);
let args_json = serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
(name.trim().to_ascii_lowercase(), args_json)
}
pub(super) fn parse_tool_call_value(value: &serde_json::Value) -> Option<ParsedToolCall> {
if let Some(function) = value.get("function") {
let tool_call_id = parse_tool_call_id(value, Some(function));
let name = function
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if !name.is_empty() {
let raw_arguments = function
.get("arguments")
.or_else(|| function.get("parameters"));
let arguments = normalize_tool_arguments(
&name,
parse_arguments_value(raw_arguments),
raw_string_argument_hint(raw_arguments),
);
return Some(ParsedToolCall {
name,
arguments,
tool_call_id,
});
}
}
let tool_call_id = parse_tool_call_id(value, None);
let name = value
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if name.is_empty() {
return None;
}
let raw_arguments = value.get("arguments").or_else(|| value.get("parameters"));
let arguments = normalize_tool_arguments(
&name,
parse_arguments_value(raw_arguments),
raw_string_argument_hint(raw_arguments),
);
Some(ParsedToolCall {
name,
arguments,
tool_call_id,
})
}
pub(super) fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec<ParsedToolCall> {
let mut calls = Vec::new();
if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
for call in tool_calls {
if let Some(parsed) = parse_tool_call_value(call) {
calls.push(parsed);
}
}
if !calls.is_empty() {
return calls;
}
}
if let Some(message) = value.get("message") {
let nested = parse_tool_calls_from_json_value(message);
if !nested.is_empty() {
return nested;
}
}
if let Some(choices) = value.get("choices").and_then(|v| v.as_array()) {
for choice in choices {
if let Some(message) = choice.get("message") {
let nested = parse_tool_calls_from_json_value(message);
if !nested.is_empty() {
calls.extend(nested);
}
}
}
if !calls.is_empty() {
return calls;
}
}
if let Some(array) = value.as_array() {
for item in array {
if let Some(parsed) = parse_tool_call_value(item) {
calls.push(parsed);
}
}
return calls;
}
if let Some(parsed) = parse_tool_call_value(value) {
calls.push(parsed);
}
calls
}
fn extract_tool_text_from_json_value(value: &serde_json::Value) -> Option<String> {
if let Some(content) = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
{
return Some(content.to_string());
}
if let Some(message) = value.get("message") {
if let Some(content) = extract_tool_text_from_json_value(message) {
return Some(content);
}
}
if let Some(choices) = value.get("choices").and_then(|v| v.as_array()) {
for choice in choices {
if let Some(content) = extract_tool_text_from_json_value(choice) {
return Some(content);
}
}
}
None
}
pub(super) fn is_xml_meta_tag(tag: &str) -> bool {
let normalized = tag.to_ascii_lowercase();
matches!(
normalized.as_str(),
"tool_call"
| "toolcall"
| "tool-call"
| "invoke"
| "thinking"
| "thought"
| "analysis"
| "reasoning"
| "reflection"
)
}
/// Match opening XML tags: `<tag_name>`. Does NOT use backreferences.
static XML_OPEN_TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<([a-zA-Z_][a-zA-Z0-9_-]*)>").unwrap());
/// MiniMax XML invoke format:
/// `<invoke name="shell"><parameter name="command">pwd</parameter></invoke>`
static MINIMAX_INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)<invoke\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</invoke>"#)
.unwrap()
});
static MINIMAX_PARAMETER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<parameter\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</parameter>"#,
)
.unwrap()
});
/// Extracts all `<tag>…</tag>` pairs from `input`, returning `(tag_name, inner_content)`.
/// Handles matching closing tags without regex backreferences.
pub(super) fn extract_xml_pairs(input: &str) -> Vec<(&str, &str)> {
let mut results = Vec::new();
let mut search_start = 0;
while let Some(open_cap) = XML_OPEN_TAG_RE.captures(&input[search_start..]) {
let full_open = open_cap.get(0).unwrap();
let tag_name = open_cap.get(1).unwrap().as_str();
let open_end = search_start + full_open.end();
let closing_tag = format!("</{tag_name}>");
if let Some(close_pos) = input[open_end..].find(&closing_tag) {
let inner = &input[open_end..open_end + close_pos];
results.push((tag_name, inner.trim()));
search_start = open_end + close_pos + closing_tag.len();
} else {
search_start = open_end;
}
}
results
}
/// Parse XML-style tool calls in `<tool_call>` bodies.
/// Supports both nested argument tags and JSON argument payloads:
/// - `<memory_recall><query>...</query></memory_recall>`
/// - `<shell>{"command":"pwd"}</shell>`
pub(super) fn parse_xml_tool_calls(xml_content: &str) -> Option<Vec<ParsedToolCall>> {
let mut calls = Vec::new();
let trimmed = xml_content.trim();
if !trimmed.contains('<') || !trimmed.contains('>') {
return None;
}
for (tool_name_str, inner_content) in extract_xml_pairs(trimmed) {
if is_xml_meta_tag(tool_name_str) {
continue;
}
let tool_name = map_tool_name_alias(tool_name_str).to_string();
if inner_content.is_empty() {
continue;
}
let mut args = serde_json::Map::new();
if let Some(first_json) = extract_json_values(inner_content).into_iter().next() {
match first_json {
serde_json::Value::Object(object_args) => {
args = object_args;
}
other => {
args.insert("value".to_string(), other);
}
}
} else {
for (key_str, value) in extract_xml_pairs(inner_content) {
let key = key_str.to_string();
if is_xml_meta_tag(&key) {
continue;
}
if !value.is_empty() {
args.insert(key, serde_json::Value::String(value.to_string()));
}
}
if args.is_empty() {
args.insert(
"content".to_string(),
serde_json::Value::String(inner_content.to_string()),
);
}
}
let arguments = normalize_tool_arguments(
&tool_name,
serde_json::Value::Object(args),
Some(inner_content),
);
calls.push(ParsedToolCall {
name: tool_name,
arguments,
tool_call_id: None,
});
}
if calls.is_empty() {
None
} else {
Some(calls)
}
}
/// Parse MiniMax-style XML tool calls with attributed invoke/parameter tags.
pub(super) fn parse_minimax_invoke_calls(response: &str) -> Option<(String, Vec<ParsedToolCall>)> {
let mut calls = Vec::new();
let mut text_parts = Vec::new();
let mut last_end = 0usize;
for cap in MINIMAX_INVOKE_RE.captures_iter(response) {
let Some(full_match) = cap.get(0) else {
continue;
};
let before = response[last_end..full_match.start()].trim();
if !before.is_empty() {
text_parts.push(before.to_string());
}
let name = cap
.get(1)
.or_else(|| cap.get(2))
.map(|m| m.as_str().trim())
.filter(|v| !v.is_empty());
let body = cap.get(3).map(|m| m.as_str()).unwrap_or("").trim();
last_end = full_match.end();
let Some(name) = name else {
continue;
};
let mut args = serde_json::Map::new();
for param_cap in MINIMAX_PARAMETER_RE.captures_iter(body) {
let key = param_cap
.get(1)
.or_else(|| param_cap.get(2))
.map(|m| m.as_str().trim())
.unwrap_or_default();
if key.is_empty() {
continue;
}
let value = param_cap
.get(3)
.map(|m| m.as_str().trim())
.unwrap_or_default();
if value.is_empty() {
continue;
}
let parsed = extract_json_values(value).into_iter().next();
args.insert(
key.to_string(),
parsed.unwrap_or_else(|| serde_json::Value::String(value.to_string())),
);
}
if args.is_empty() {
if let Some(first_json) = extract_json_values(body).into_iter().next() {
match first_json {
serde_json::Value::Object(obj) => args = obj,
other => {
args.insert("value".to_string(), other);
}
}
} else if !body.is_empty() {
args.insert(
"content".to_string(),
serde_json::Value::String(body.to_string()),
);
}
}
calls.push(ParsedToolCall {
name: name.to_string(),
arguments: serde_json::Value::Object(args),
tool_call_id: None,
});
}
if calls.is_empty() {
return None;
}
let after = response[last_end..].trim();
if !after.is_empty() {
text_parts.push(after.to_string());
}
let text = text_parts
.join("\n")
.replace("<minimax:tool_call>", "")
.replace("</minimax:tool_call>", "")
.replace("<minimax:toolcall>", "")
.replace("</minimax:toolcall>", "")
.trim()
.to_string();
Some((text, calls))
}
const TOOL_CALL_OPEN_TAGS: [&str; 6] = [
"<tool_call>",
"<toolcall>",
"<tool-call>",
"<invoke>",
"<minimax:tool_call>",
"<minimax:toolcall>",
];
const TOOL_CALL_CLOSE_TAGS: [&str; 6] = [
"</tool_call>",
"</toolcall>",
"</tool-call>",
"</invoke>",
"</minimax:tool_call>",
"</minimax:toolcall>",
];
pub(super) fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
tags.iter()
.filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
.min_by_key(|(idx, _)| *idx)
}
pub(super) fn matching_tool_call_close_tag(open_tag: &str) -> Option<&'static str> {
match open_tag {
"<tool_call>" => Some("</tool_call>"),
"<toolcall>" => Some("</toolcall>"),
"<tool-call>" => Some("</tool-call>"),
"<invoke>" => Some("</invoke>"),
"<minimax:tool_call>" => Some("</minimax:tool_call>"),
"<minimax:toolcall>" => Some("</minimax:toolcall>"),
_ => None,
}
}
pub(super) fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> {
let trimmed = input.trim_start();
let trim_offset = input.len().saturating_sub(trimmed.len());
for (byte_idx, ch) in trimmed.char_indices() {
if ch != '{' && ch != '[' {
continue;
}
let slice = &trimmed[byte_idx..];
let mut stream = serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
if let Some(Ok(value)) = stream.next() {
let consumed = stream.byte_offset();
if consumed > 0 {
return Some((value, trim_offset + byte_idx + consumed));
}
}
}
None
}
pub(super) fn strip_leading_close_tags(mut input: &str) -> &str {
loop {
let trimmed = input.trim_start();
if !trimmed.starts_with("</") {
return trimmed;
}
let Some(close_end) = trimmed.find('>') else {
return "";
};
input = &trimmed[close_end + 1..];
}
}
/// Extract JSON values from a string.
///
/// # Security Warning
///
/// This function extracts ANY JSON objects/arrays from the input. It MUST only
/// be used on content that is already trusted to be from the LLM, such as
/// content inside `<invoke>` tags where the LLM has explicitly indicated intent
/// to make a tool call. Do NOT use this on raw user input or content that
/// could contain prompt injection payloads.
pub(super) fn extract_json_values(input: &str) -> Vec<serde_json::Value> {
let mut values = Vec::new();
let trimmed = input.trim();
if trimmed.is_empty() {
return values;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
values.push(value);
return values;
}
let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect();
let mut idx = 0;
while idx < char_positions.len() {
let (byte_idx, ch) = char_positions[idx];
if ch == '{' || ch == '[' {
let slice = &trimmed[byte_idx..];
let mut stream =
serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
if let Some(Ok(value)) = stream.next() {
let consumed = stream.byte_offset();
if consumed > 0 {
values.push(value);
let next_byte = byte_idx + consumed;
while idx < char_positions.len() && char_positions[idx].0 < next_byte {
idx += 1;
}
continue;
}
}
}
idx += 1;
}
values
}
/// Find the end position of a JSON object by tracking balanced braces.
pub(super) fn find_json_end(input: &str) -> Option<usize> {
let trimmed = input.trim_start();
let offset = input.len() - trimmed.len();
if !trimmed.starts_with('{') {
return None;
}
let mut depth = 0;
let mut in_string = false;
let mut escape_next = false;
for (i, ch) in trimmed.char_indices() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' if in_string => escape_next = true,
'"' => in_string = !in_string,
'{' if !in_string => depth += 1,
'}' if !in_string => {
depth -= 1;
if depth == 0 {
return Some(offset + i + ch.len_utf8());
}
}
_ => {}
}
}
None
}
/// Parse XML attribute-style tool calls from response text.
/// This handles MiniMax and similar providers that output:
/// ```xml
/// <minimax:toolcall>
/// <invoke name="shell">
/// <parameter name="command">ls</parameter>
/// </invoke>
/// </minimax:toolcall>
/// ```
pub(super) fn parse_xml_attribute_tool_calls(response: &str) -> Vec<ParsedToolCall> {
let mut calls = Vec::new();
// Regex to find <invoke name="toolname">...</invoke> blocks
static INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?s)<invoke\s+name="([^"]+)"[^>]*>(.*?)</invoke>"#).unwrap()
});
// Regex to find <parameter name="paramname">value</parameter>
static PARAM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"<parameter\s+name="([^"]+)"[^>]*>([^<]*)</parameter>"#).unwrap()
});
for cap in INVOKE_RE.captures_iter(response) {
let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
let inner = cap.get(2).map(|m| m.as_str()).unwrap_or("");
if tool_name.is_empty() {
continue;
}
let mut arguments = serde_json::Map::new();
for param_cap in PARAM_RE.captures_iter(inner) {
let param_name = param_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or("");
if !param_name.is_empty() {
arguments.insert(
param_name.to_string(),
serde_json::Value::String(param_value.to_string()),
);
}
}
if !arguments.is_empty() {
calls.push(ParsedToolCall {
name: map_tool_name_alias(tool_name).to_string(),
arguments: serde_json::Value::Object(arguments),
tool_call_id: None,
});
}
}
calls
}
/// Parse Perl/hash-ref style tool calls from response text.
/// This handles formats like:
/// ```text
/// TOOL_CALL
/// {tool => "shell", args => {
/// --command "ls -la"
/// --description "List current directory contents"
/// }}
/// /TOOL_CALL
/// ```
pub(super) fn parse_perl_style_tool_calls(response: &str) -> Vec<ParsedToolCall> {
let mut calls = Vec::new();
// Regex to find TOOL_CALL blocks - handle double closing braces }}
static PERL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)TOOL_CALL\s*\{(.+?)\}\}\s*/TOOL_CALL").unwrap());
// Regex to find tool => "name" in the content
static TOOL_NAME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"tool\s*=>\s*"([^"]+)""#).unwrap());
// Regex to find args => { ... } block
static ARGS_BLOCK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)args\s*=>\s*\{(.+?)\}").unwrap());
// Regex to find --key "value" pairs
static ARGS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"--(\w+)\s+"([^"]+)""#).unwrap());
for cap in PERL_RE.captures_iter(response) {
let content = cap.get(1).map(|m| m.as_str()).unwrap_or("");
// Extract tool name
let tool_name = TOOL_NAME_RE
.captures(content)
.and_then(|c| c.get(1))
.map(|m| m.as_str())
.unwrap_or("");
if tool_name.is_empty() {
continue;
}
// Extract args block
let args_block = ARGS_BLOCK_RE
.captures(content)
.and_then(|c| c.get(1))
.map(|m| m.as_str())
.unwrap_or("");
let mut arguments = serde_json::Map::new();
for arg_cap in ARGS_RE.captures_iter(args_block) {
let key = arg_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let value = arg_cap.get(2).map(|m| m.as_str()).unwrap_or("");
if !key.is_empty() {
arguments.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
}
if !arguments.is_empty() {
calls.push(ParsedToolCall {
name: map_tool_name_alias(tool_name).to_string(),
arguments: serde_json::Value::Object(arguments),
tool_call_id: None,
});
}
}
calls
}
/// Parse FunctionCall-style tool calls from response text.
/// This handles formats like:
/// ```text
/// <FunctionCall>
/// file_read
/// <code>path>/Users/kylelampa/Documents/zeroclaw/README.md</code>
/// </FunctionCall>
/// ```
pub(super) fn parse_function_call_tool_calls(response: &str) -> Vec<ParsedToolCall> {
let mut calls = Vec::new();
// Regex to find <FunctionCall> blocks
static FUNC_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)<FunctionCall>\s*(\w+)\s*<code>([^<]+)</code>\s*</FunctionCall>").unwrap()
});
for cap in FUNC_RE.captures_iter(response) {
let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
let args_text = cap.get(2).map(|m| m.as_str()).unwrap_or("");
if tool_name.is_empty() {
continue;
}
// Parse key>value pairs (e.g., path>/Users/.../file.txt)
let mut arguments = serde_json::Map::new();
for line in args_text.lines() {
let line = line.trim();
if let Some(pos) = line.find('>') {
let key = line[..pos].trim();
let value = line[pos + 1..].trim();
if !key.is_empty() && !value.is_empty() {
arguments.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
}
}
if !arguments.is_empty() {
calls.push(ParsedToolCall {
name: map_tool_name_alias(tool_name).to_string(),
arguments: serde_json::Value::Object(arguments),
tool_call_id: None,
});
}
}
calls
}
/// Parse GLM-style tool calls from response text.
/// Map tool name aliases from various LLM providers to ZeroClaw tool names.
/// This handles variations like "fileread" -> "file_read", "bash" -> "shell", etc.
pub(super) fn map_tool_name_alias(tool_name: &str) -> &str {
match tool_name {
// Shell variations (including GLM aliases that map to shell)
"shell" | "bash" | "sh" | "exec" | "command" | "cmd" | "browser_open" | "browser"
| "web_search" => "shell",
// Messaging variations
"send_message" | "sendmessage" => "message_send",
// File tool variations
"fileread" | "file_read" | "readfile" | "read_file" | "file" => "file_read",
"filewrite" | "file_write" | "writefile" | "write_file" => "file_write",
"filelist" | "file_list" | "listfiles" | "list_files" => "file_list",
// Memory variations
"memoryrecall" | "memory_recall" | "recall" | "memrecall" => "memory_recall",
"memorystore" | "memory_store" | "store" | "memstore" => "memory_store",
"memoryobserve" | "memory_observe" | "observe" | "memobserve" => "memory_observe",
"memoryforget" | "memory_forget" | "forget" | "memforget" => "memory_forget",
// HTTP variations
"http_request" | "http" | "fetch" | "curl" | "wget" => "http_request",
_ => tool_name,
}
}
fn is_probable_direct_xml_tool_name(name: &str) -> bool {
let normalized = name.trim().to_ascii_lowercase();
if normalized.is_empty() || is_xml_meta_tag(&normalized) {
return false;
}
if map_tool_name_alias(&normalized) != normalized {
return true;
}
normalized.contains('_')
|| matches!(
normalized.as_str(),
"shell"
| "http"
| "curl"
| "wget"
| "fetch"
| "browser"
| "message"
| "notify"
| "recall"
| "store"
| "forget"
| "search"
| "read"
| "write"
| "edit"
| "list"
)
}
pub(super) fn build_curl_command(url: &str) -> Option<String> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
if url.chars().any(char::is_whitespace) {
return None;
}
let escaped = url.replace('\'', r#"'\\''"#);
Some(format!("curl -s '{}'", escaped))
}
pub(super) fn parse_glm_style_tool_calls(
text: &str,
) -> Vec<(String, serde_json::Value, Option<String>)> {
let mut calls = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Format: tool_name/param>value or tool_name/{json}
if let Some(pos) = line.find('/') {
let tool_part = &line[..pos];
let rest = &line[pos + 1..];
if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
let tool_name = map_tool_name_alias(tool_part);
if let Some(gt_pos) = rest.find('>') {
let param_name = rest[..gt_pos].trim();
let value = rest[gt_pos + 1..].trim();
let arguments = match tool_name {
"shell" => {
if param_name == "url" {
let Some(command) = build_curl_command(value) else {
continue;
};
serde_json::json!({ "command": command })
} else if value.starts_with("http://") || value.starts_with("https://")
{
if let Some(command) = build_curl_command(value) {
serde_json::json!({ "command": command })
} else {
serde_json::json!({ "command": value })
}
} else {
serde_json::json!({ "command": value })
}
}
"http_request" => {
serde_json::json!({"url": value, "method": "GET"})
}
_ => serde_json::json!({ param_name: value }),
};
calls.push((tool_name.to_string(), arguments, Some(line.to_string())));
continue;
}
if rest.starts_with('{') {
if let Ok(json_args) = serde_json::from_str::<serde_json::Value>(rest) {
calls.push((tool_name.to_string(), json_args, Some(line.to_string())));
}
}
}
}
// Plain URL
if let Some(command) = build_curl_command(line) {
calls.push((
"shell".to_string(),
serde_json::json!({ "command": command }),
Some(line.to_string()),
));
}
}
calls
}
/// Return the canonical default parameter name for a tool.
///
/// When a model emits a shortened call like `shell>uname -a` (without an
/// explicit `/param_name`), we need to infer which parameter the value maps
/// to. This function encodes the mapping for known ZeroClaw tools.
pub(super) fn default_param_for_tool(tool: &str) -> &'static str {
match tool {
"shell" | "bash" | "sh" | "exec" | "command" | "cmd" => "command",
// All file tools default to "path"
"file_read" | "fileread" | "readfile" | "read_file" | "file" | "file_write"
| "filewrite" | "writefile" | "write_file" | "file_edit" | "fileedit" | "editfile"
| "edit_file" | "file_list" | "filelist" | "listfiles" | "list_files" => "path",
// Memory recall and forget both default to "query"
"memory_recall" | "memoryrecall" | "recall" | "memrecall" | "memory_forget"
| "memoryforget" | "forget" | "memforget" => "query",
"memory_store" | "memorystore" | "store" | "memstore" => "content",
"memory_observe" | "memoryobserve" | "observe" | "memobserve" => "observation",
// HTTP and browser tools default to "url"
"http_request" | "http" | "fetch" | "curl" | "wget" | "browser_open" | "browser"
| "web_search" => "url",
_ => "input",
}
}
/// Parse GLM-style shortened tool call bodies found inside `<tool_call>` tags.
///
/// Handles three sub-formats that GLM-4.7 emits:
///
/// 1. **Shortened**: `tool_name>value` — single value mapped via
/// [`default_param_for_tool`].
/// 2. **YAML-like multi-line**: `tool_name>\nkey: value\nkey: value` — each
/// subsequent `key: value` line becomes a parameter.
/// 3. **Attribute-style**: `tool_name key="value" [/]>` — XML-like attributes.
///
/// Returns `None` if the body does not match any of these formats.
pub(super) fn parse_glm_shortened_body(body: &str) -> Option<ParsedToolCall> {
let body = body.trim();
if body.is_empty() {
return None;
}
let function_style = body.find('(').and_then(|open| {
if body.ends_with(')') && open > 0 {
Some((body[..open].trim(), body[open + 1..body.len() - 1].trim()))
} else {
None
}
});
// Check attribute-style FIRST: `tool_name key="value" />`
// Must come before `>` check because `/>` contains `>` and would
// misparse the tool name in the first branch.
let (tool_raw, value_part) = if let Some((tool, args)) = function_style {
(tool, args)
} else if body.contains("=\"") {
// Attribute-style: split at first whitespace to get tool name
let split_pos = body.find(|c: char| c.is_whitespace()).unwrap_or(body.len());
let tool = body[..split_pos].trim();
let attrs = body[split_pos..]
.trim()
.trim_end_matches("/>")
.trim_end_matches('>')
.trim_end_matches('/')
.trim();
(tool, attrs)
} else if let Some(gt_pos) = body.find('>') {
// GLM shortened: `tool_name>value`
let tool = body[..gt_pos].trim();
let value = body[gt_pos + 1..].trim();
// Strip trailing self-close markers that some models emit
let value = value.trim_end_matches("/>").trim_end_matches('/').trim();
(tool, value)
} else {
return None;
};
// Validate tool name: must be alphanumeric + underscore only
let tool_raw = tool_raw.trim_end_matches(|c: char| c.is_whitespace());
if tool_raw.is_empty() || !tool_raw.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
let tool_name = map_tool_name_alias(tool_raw);
// Try attribute-style: `key="value" key2="value2"`
if value_part.contains("=\"") {
let mut args = serde_json::Map::new();
// Simple attribute parser: key="value" pairs
let mut rest = value_part;
while let Some(eq_pos) = rest.find("=\"") {
let key_start = rest[..eq_pos]
.rfind(|c: char| c.is_whitespace())
.map(|p| p + 1)
.unwrap_or(0);
let key = rest[key_start..eq_pos]
.trim()
.trim_matches(|c: char| c == ',' || c == ';');
let after_quote = &rest[eq_pos + 2..];
if let Some(end_quote) = after_quote.find('"') {
let value = &after_quote[..end_quote];
if !key.is_empty() {
args.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
rest = &after_quote[end_quote + 1..];
} else {
break;
}
}
if !args.is_empty() {
return Some(ParsedToolCall {
name: tool_name.to_string(),
arguments: serde_json::Value::Object(args),
tool_call_id: None,
});
}
}
// Try YAML-style multi-line: each line is `key: value`
if value_part.contains('\n') {
let mut args = serde_json::Map::new();
for line in value_part.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim();
let value = line[colon_pos + 1..].trim();
if !key.is_empty() && !value.is_empty() {
// Normalize boolean-like values
let json_value = match value {
"true" | "yes" => serde_json::Value::Bool(true),
"false" | "no" => serde_json::Value::Bool(false),
_ => serde_json::Value::String(value.to_string()),
};
args.insert(key.to_string(), json_value);
}
}
}
if !args.is_empty() {
return Some(ParsedToolCall {
name: tool_name.to_string(),
arguments: serde_json::Value::Object(args),
tool_call_id: None,
});
}
}
// Single-value shortened: `tool>value`
if !value_part.is_empty() {
let param = default_param_for_tool(tool_raw);
let arguments = match tool_name {
"shell" => {
if value_part.starts_with("http://") || value_part.starts_with("https://") {
if let Some(cmd) = build_curl_command(value_part) {
serde_json::json!({ "command": cmd })
} else {
serde_json::json!({ "command": value_part })
}
} else {
serde_json::json!({ "command": value_part })
}
}
"http_request" => serde_json::json!({"url": value_part, "method": "GET"}),
_ => serde_json::json!({ param: value_part }),
};
return Some(ParsedToolCall {
name: tool_name.to_string(),
arguments,
tool_call_id: None,
});
}
None
}
// ── Tool-Call Parsing ─────────────────────────────────────────────────────
// LLM responses may contain tool calls in multiple formats depending on
// the provider. Parsing follows a priority chain:
// 1. OpenAI-style JSON with `tool_calls` array (native API)
// 2. XML tags: <tool_call>, <toolcall>, <tool-call>, <invoke>
// 3. Markdown code blocks with `tool_call` language
// 4. GLM-style line-based format (e.g. `shell/command>ls`)
// SECURITY: We never fall back to extracting arbitrary JSON from the
// response body, because that would enable prompt-injection attacks where
// malicious content in emails/files/web pages mimics a tool call.
/// Parse tool calls from an LLM response that uses XML-style function calling.
///
/// Expected format (common with system-prompt-guided tool use):
/// ```text
/// <tool_call>
/// {"name": "shell", "arguments": {"command": "ls"}}
/// </tool_call>
/// ```
///
/// Also accepts common tag variants (`<toolcall>`, `<tool-call>`) for model
/// compatibility.
///
/// Also supports JSON with `tool_calls` array from OpenAI-format responses.
pub(super) fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
let mut text_parts = Vec::new();
let mut calls = Vec::new();
let mut remaining = response;
// First, try to parse as OpenAI-style JSON response with tool_calls array
// This handles providers like Minimax that return tool_calls in native JSON format
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response.trim()) {
calls = parse_tool_calls_from_json_value(&json_value);
if !calls.is_empty() {
// If we found tool_calls, extract any content field as text.
// Some providers wrap tool calls under `message` or `choices[*].message`.
if let Some(content) = extract_tool_text_from_json_value(&json_value) {
text_parts.push(content);
}
return (text_parts.join("\n"), calls);
}
}
if let Some((minimax_text, minimax_calls)) = parse_minimax_invoke_calls(response) {
if !minimax_calls.is_empty() {
return (minimax_text, minimax_calls);
}
}
// Fall back to XML-style tool-call tag parsing.
while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
// Everything before the tag is text
let before = &remaining[..start];
if !before.trim().is_empty() {
text_parts.push(before.trim().to_string());
}
let Some(close_tag) = matching_tool_call_close_tag(open_tag) else {
break;
};
let after_open = &remaining[start + open_tag.len()..];
if let Some(close_idx) = after_open.find(close_tag) {
let inner = &after_open[..close_idx];
let mut parsed_any = false;
// Try JSON format first
let json_values = extract_json_values(inner);
for value in json_values {
let parsed_calls = parse_tool_calls_from_json_value(&value);
if !parsed_calls.is_empty() {
parsed_any = true;
calls.extend(parsed_calls);
}
}
// If JSON parsing failed, try XML format (DeepSeek/GLM style)
if !parsed_any {
if let Some(xml_calls) = parse_xml_tool_calls(inner) {
calls.extend(xml_calls);
parsed_any = true;
}
}
if !parsed_any {
// GLM-style shortened body: `shell>uname -a` or `shell\ncommand: date`
if let Some(glm_call) = parse_glm_shortened_body(inner) {
calls.push(glm_call);
parsed_any = true;
}
}
if !parsed_any {
tracing::warn!(
"Malformed <tool_call>: expected tool-call object in tag body (JSON/XML/GLM)"
);
}
remaining = &after_open[close_idx + close_tag.len()..];
} else {
// Matching close tag not found — try cross-alias close tags first.
// Models sometimes mix open/close tag aliases (e.g. <tool_call>...</invoke>).
let mut resolved = false;
if let Some((cross_idx, cross_tag)) = find_first_tag(after_open, &TOOL_CALL_CLOSE_TAGS)
{
let inner = &after_open[..cross_idx];
let mut parsed_any = false;
// Try JSON
let json_values = extract_json_values(inner);
for value in json_values {
let parsed_calls = parse_tool_calls_from_json_value(&value);
if !parsed_calls.is_empty() {
parsed_any = true;
calls.extend(parsed_calls);
}
}
// Try XML
if !parsed_any {
if let Some(xml_calls) = parse_xml_tool_calls(inner) {
calls.extend(xml_calls);
parsed_any = true;
}
}
// Try GLM shortened body
if !parsed_any {
if let Some(glm_call) = parse_glm_shortened_body(inner) {
calls.push(glm_call);
parsed_any = true;
}
}
if parsed_any {
remaining = &after_open[cross_idx + cross_tag.len()..];
resolved = true;
}
}
if resolved {
continue;
}
// No cross-alias close tag resolved — fall back to JSON recovery
// from unclosed tags (brace-balancing).
if let Some(json_end) = find_json_end(after_open) {
if let Ok(value) =
serde_json::from_str::<serde_json::Value>(&after_open[..json_end])
{
let parsed_calls = parse_tool_calls_from_json_value(&value);
if !parsed_calls.is_empty() {
calls.extend(parsed_calls);
remaining = strip_leading_close_tags(&after_open[json_end..]);
continue;
}
}
}
if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) {
let parsed_calls = parse_tool_calls_from_json_value(&value);
if !parsed_calls.is_empty() {
calls.extend(parsed_calls);
remaining = strip_leading_close_tags(&after_open[consumed_end..]);
continue;
}
}
// Last resort: try GLM shortened body on everything after the open tag.
// The model may have emitted `<tool_call>shell>ls` with no close tag at all.
let glm_input = after_open.trim();
if let Some(glm_call) = parse_glm_shortened_body(glm_input) {
calls.push(glm_call);
remaining = "";
continue;
}
remaining = &remaining[start..];
break;
}
}
// If XML tags found nothing, try markdown code blocks with tool_call language.
// Models behind OpenRouter sometimes output ```tool_call ... ``` or hybrid
// ```tool_call ... </tool_call> instead of structured API calls or XML tags.
if calls.is_empty() {
static MD_TOOL_CALL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```|</tool[_-]?call>|</toolcall>|</invoke>|</minimax:toolcall>)",
)
.unwrap()
});
let mut md_text_parts: Vec<String> = Vec::new();
let mut last_end = 0;
for cap in MD_TOOL_CALL_RE.captures_iter(response) {
let full_match = cap.get(0).unwrap();
let before = &response[last_end..full_match.start()];
if !before.trim().is_empty() {
md_text_parts.push(before.trim().to_string());
}
let inner = &cap[1];
let json_values = extract_json_values(inner);
for value in json_values {
let parsed_calls = parse_tool_calls_from_json_value(&value);
calls.extend(parsed_calls);
}
last_end = full_match.end();
}
if !calls.is_empty() {
let after = &response[last_end..];
if !after.trim().is_empty() {
md_text_parts.push(after.trim().to_string());
}
text_parts = md_text_parts;
remaining = "";
}
}
// Try ```tool <name> format used by some providers (e.g., xAI grok)
// Example: ```tool file_write\n{"path": "...", "content": "..."}\n```
if calls.is_empty() {
static MD_TOOL_NAME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)```tool\s+(\w+)\s*\n(.*?)(?:```|$)").unwrap());
let mut md_text_parts: Vec<String> = Vec::new();
let mut last_end = 0;
for cap in MD_TOOL_NAME_RE.captures_iter(response) {
let full_match = cap.get(0).unwrap();
let before = &response[last_end..full_match.start()];
if !before.trim().is_empty() {
md_text_parts.push(before.trim().to_string());
}
let tool_name = &cap[1];
let inner = &cap[2];
// Try to parse the inner content as JSON arguments
let json_values = extract_json_values(inner);
if json_values.is_empty() {
// Log a warning if we found a tool block but couldn't parse arguments
tracing::warn!(
tool_name = %tool_name,
inner = %inner.chars().take(100).collect::<String>(),
"Found ```tool <name> block but could not parse JSON arguments"
);
} else {
for value in json_values {
let arguments = if value.is_object() {
value
} else {
serde_json::Value::Object(serde_json::Map::new())
};
calls.push(ParsedToolCall {
name: tool_name.to_string(),
arguments,
tool_call_id: None,
});
}
}
last_end = full_match.end();
}
if !calls.is_empty() {
let after = &response[last_end..];
if !after.trim().is_empty() {
md_text_parts.push(after.trim().to_string());
}
text_parts = md_text_parts;
remaining = "";
}
}
// Direct XML tool tags (without <tool_call> wrapper), e.g.:
// <shell>pwd</shell>
// <file_write><path>...</path><content>...</content></file_write>
if calls.is_empty() {
if let Some(xml_calls) = parse_xml_tool_calls(remaining) {
let direct_calls: Vec<ParsedToolCall> = xml_calls
.into_iter()
.filter(|call| is_probable_direct_xml_tool_name(&call.name))
.collect();
if !direct_calls.is_empty() {
let mut cleaned_text = remaining.to_string();
let parsed_names: HashSet<&str> =
direct_calls.iter().map(|call| call.name.as_str()).collect();
for (tag_name, _) in extract_xml_pairs(remaining) {
let canonical_tag = map_tool_name_alias(tag_name);
if !parsed_names.contains(tag_name) && !parsed_names.contains(canonical_tag) {
continue;
}
let open = format!("<{tag_name}>");
let close = format!("</{tag_name}>");
while let Some(start) = cleaned_text.find(&open) {
let search_from = start + open.len();
let Some(end_rel) = cleaned_text[search_from..].find(&close) else {
break;
};
let end = search_from + end_rel + close.len();
cleaned_text.replace_range(start..end, "");
}
}
calls.extend(direct_calls);
if !cleaned_text.trim().is_empty() {
text_parts.push(cleaned_text.trim().to_string());
}
remaining = "";
}
}
}
// XML attribute-style tool calls:
// <minimax:toolcall>
// <invoke name="shell">
// <parameter name="command">ls</parameter>
// </invoke>
// </minimax:toolcall>
if calls.is_empty() {
let xml_calls = parse_xml_attribute_tool_calls(remaining);
if !xml_calls.is_empty() {
let mut cleaned_text = remaining.to_string();
for call in xml_calls {
calls.push(call);
// Try to remove the XML from text
if let Some(start) = cleaned_text.find("<minimax:toolcall>") {
if let Some(end) = cleaned_text.find("</minimax:toolcall>") {
let end_pos = end + "</minimax:toolcall>".len();
if end_pos <= cleaned_text.len() {
cleaned_text =
format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
}
}
}
}
if !cleaned_text.trim().is_empty() {
text_parts.push(cleaned_text.trim().to_string());
}
remaining = "";
}
}
// Perl/hash-ref style tool calls:
// TOOL_CALL
// {tool => "shell", args => {
// --command "ls -la"
// --description "List current directory contents"
// }}
// /TOOL_CALL
if calls.is_empty() {
let perl_calls = parse_perl_style_tool_calls(remaining);
if !perl_calls.is_empty() {
let mut cleaned_text = remaining.to_string();
for call in perl_calls {
calls.push(call);
// Try to remove the TOOL_CALL block from text
while let Some(start) = cleaned_text.find("TOOL_CALL") {
if let Some(end) = cleaned_text.find("/TOOL_CALL") {
let end_pos = end + "/TOOL_CALL".len();
if end_pos <= cleaned_text.len() {
cleaned_text =
format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
}
} else {
break;
}
}
}
if !cleaned_text.trim().is_empty() {
text_parts.push(cleaned_text.trim().to_string());
}
remaining = "";
}
}
// <FunctionCall>
// file_read
// <code>path>/Users/...</code>
// </FunctionCall>
if calls.is_empty() {
let func_calls = parse_function_call_tool_calls(remaining);
if !func_calls.is_empty() {
let mut cleaned_text = remaining.to_string();
for call in func_calls {
calls.push(call);
// Try to remove the FunctionCall block from text
while let Some(start) = cleaned_text.find("<FunctionCall>") {
if let Some(end) = cleaned_text.find("</FunctionCall>") {
let end_pos = end + "</FunctionCall>".len();
if end_pos <= cleaned_text.len() {
cleaned_text =
format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
}
} else {
break;
}
}
}
if !cleaned_text.trim().is_empty() {
text_parts.push(cleaned_text.trim().to_string());
}
remaining = "";
}
}
// GLM-style tool calls (browser_open/url>https://..., shell/command>ls, etc.)
if calls.is_empty() {
let glm_calls = parse_glm_style_tool_calls(remaining);
if !glm_calls.is_empty() {
let mut cleaned_text = remaining.to_string();
for (name, args, raw) in &glm_calls {
calls.push(ParsedToolCall {
name: name.clone(),
arguments: args.clone(),
tool_call_id: None,
});
if let Some(r) = raw {
cleaned_text = cleaned_text.replace(r, "");
}
}
if !cleaned_text.trim().is_empty() {
text_parts.push(cleaned_text.trim().to_string());
}
remaining = "";
}
}
// SECURITY: We do NOT fall back to extracting arbitrary JSON from the response
// here. That would enable prompt injection attacks where malicious content
// (e.g., in emails, files, or web pages) could include JSON that mimics a
// tool call. Tool calls MUST be explicitly wrapped in either:
// 1. OpenAI-style JSON with a "tool_calls" array
// 2. ZeroClaw tool-call tags (<tool_call>, <toolcall>, <tool-call>)
// 3. Markdown code blocks with tool_call/toolcall/tool-call language
// 4. Explicit GLM line-based call formats (e.g. `shell/command>...`)
// This ensures only the LLM's intentional tool calls are executed.
// Remaining text after last tool call
if !remaining.trim().is_empty() {
text_parts.push(remaining.trim().to_string());
}
(text_parts.join("\n"), calls)
}
pub(super) fn detect_tool_call_parse_issue(
response: &str,
parsed_calls: &[ParsedToolCall],
) -> Option<String> {
if !parsed_calls.is_empty() {
return None;
}
let trimmed = response.trim();
if trimmed.is_empty() {
return None;
}
let looks_like_tool_payload = trimmed.contains("<tool_call")
|| trimmed.contains("<toolcall")
|| trimmed.contains("<tool-call")
|| trimmed.contains("<shell>")
|| trimmed.contains("<file_write>")
|| trimmed.contains("<file_read>")
|| trimmed.contains("<memory_recall>")
|| trimmed.contains("```tool_call")
|| trimmed.contains("```toolcall")
|| trimmed.contains("```tool-call")
|| trimmed.contains("```tool file_")
|| trimmed.contains("```tool shell")
|| trimmed.contains("```tool web_")
|| trimmed.contains("```tool memory_")
|| trimmed.contains("```tool ") // Generic ```tool <name> pattern
|| trimmed.contains("\"tool_calls\"")
|| trimmed.contains("TOOL_CALL")
|| trimmed.contains("<FunctionCall>");
if looks_like_tool_payload {
Some("response resembled a tool-call payload but no valid tool call could be parsed".into())
} else {
None
}
}
pub(super) fn parse_structured_tool_calls(tool_calls: &[ToolCall]) -> Vec<ParsedToolCall> {
tool_calls
.iter()
.map(|call| {
let name = call.name.clone();
let parsed = serde_json::from_str::<serde_json::Value>(&call.arguments)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
ParsedToolCall {
name: name.clone(),
arguments: normalize_tool_arguments(&name, parsed, Some(call.arguments.as_str())),
tool_call_id: Some(call.id.clone()),
}
})
.collect()
}