feat: 添加参数提取工具函数,支持处理字符串化 JSON 数组,优化技能管理和会话发送功能

This commit is contained in:
oudecheng 2026-05-21 17:00:22 +08:00
parent da9cec6d35
commit efc8af12eb
4 changed files with 103 additions and 38 deletions

View File

@ -763,6 +763,8 @@ impl AgentLoop {
iteration,
response_len = response.content.len(),
tool_calls_len = response.tool_calls.len(),
content = %response.content,
model = %response.model,
"LLM response received"
);
@ -782,9 +784,17 @@ impl AgentLoop {
}
// Execute tool calls
let tool_calls_json = serde_json::to_string_pretty(&response.tool_calls)
.unwrap_or_else(|_| response.tool_calls.iter()
.map(|tc| format!("{}({})", tc.name, tc.arguments))
.collect::<Vec<_>>()
.join(", "));
tracing::info!(
iteration,
count = response.tool_calls.len(),
content = %response.content,
tool_calls = %tool_calls_json,
model = %response.model,
"Tool calls detected, executing tools"
);

View File

@ -143,6 +143,44 @@ pub fn require_bool(args: &serde_json::Value, key: &str) -> Result<bool, String>
.ok_or_else(|| format!("Missing required parameter: {}", key))
}
/// Extract a string array parameter, handling both actual arrays and stringified JSON arrays.
pub fn extract_string_array(args: &serde_json::Value, key: &str) -> Option<Vec<String>> {
args.get(key).and_then(|v| {
if let Some(arr) = v.as_array() {
Some(
arr.iter()
.filter_map(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.collect(),
)
} else if let Some(s) = v.as_str() {
// Try to parse as JSON array string
serde_json::from_str::<Vec<serde_json::Value>>(s)
.ok()
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.filter(|result: &Vec<String>| !result.is_empty())
} else {
None
}
})
}
/// Extract a required string array parameter.
pub fn require_string_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
extract_string_array(args, key)
.filter(|arr| !arr.is_empty())
.ok_or_else(|| format!("Missing required parameter: {}", key))
}
/// Check if args is null and return an error result if so.
/// Returns the provided error message if args is null.
pub fn check_null_args(args: &serde_json::Value, tool_name: &str) -> Option<ToolResult> {

View File

@ -164,21 +164,39 @@ fn validate_context(context: &ToolContext) -> anyhow::Result<()> {
}
fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>> {
let attachment_paths = value
.as_array()
.ok_or_else(|| anyhow!("attachments must be an array of local file paths"))?;
// 支持两种格式:实际数组 或 字符串化的 JSON 数组
let paths = if let Some(arr) = value.as_array() {
arr
.iter()
.filter_map(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
} else if let Some(s) = value.as_str() {
// 尝试解析字符串化的 JSON 数组
serde_json::from_str::<Vec<serde_json::Value>>(s)
.ok()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
vec![]
};
let mut attachments = Vec::with_capacity(attachment_paths.len());
for path_value in attachment_paths {
let raw_path = path_value
.as_str()
.ok_or_else(|| anyhow!("attachments entries must be strings"))?
.trim();
if raw_path.is_empty() {
return Err(anyhow!("attachment paths must not be empty"));
}
if paths.is_empty() {
return Err(anyhow!("attachments must be an array of local file paths"));
}
let metadata = std::fs::metadata(raw_path)
let mut attachments = Vec::with_capacity(paths.len());
for raw_path in paths {
let metadata = std::fs::metadata(&raw_path)
.map_err(|err| anyhow!("failed to access attachment '{}': {}", raw_path, err))?;
if !metadata.is_file() {
return Err(anyhow!("attachment path is not a file: {}", raw_path));
@ -187,9 +205,9 @@ fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>
return Err(anyhow!("attachment file is empty: {}", raw_path));
}
let media_type = infer_media_type(raw_path);
let media_type = infer_media_type(&raw_path);
let mut item = MediaItem::new(raw_path.to_string(), media_type);
item.mime_type = mime_guess::from_path(raw_path)
item.mime_type = mime_guess::from_path(&raw_path)
.first_raw()
.map(ToOwned::to_owned);
attachments.push(item);
@ -319,4 +337,20 @@ mod tests {
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].media_type, "image");
}
#[test]
fn parse_attachments_handles_stringified_json_array() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"demo").unwrap();
let txt_path = file.path().with_extension("txt");
std::fs::rename(file.path(), &txt_path).unwrap();
// Test with stringified JSON array (like LLM might send)
let path_str = txt_path.to_string_lossy().to_string().replace("\\", "\\\\");
let json_string = format!("[\"{}\"]", path_str);
let attachments = parse_attachments(&json!(json_string)).unwrap();
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].media_type, "file");
}
}

View File

@ -4,6 +4,7 @@ use std::sync::Arc;
use crate::skills::{SkillRuntime, SkillScope};
use crate::tools::traits::{Tool, ToolResult};
use crate::tools::{extract_bool, extract_string_array};
pub struct SkillManageTool {
skills: Arc<SkillRuntime>,
@ -86,7 +87,7 @@ impl Tool for SkillManageTool {
}
};
let reload = args.get("reload").and_then(|v| v.as_bool()).unwrap_or(true);
let reload = extract_bool(&args, "reload").unwrap_or(true);
let scope = match args.get("scope").and_then(|v| v.as_str()) {
Some(value) => match SkillScope::parse(value) {
Some(scope) => scope,
@ -280,28 +281,10 @@ fn error_result(message: &str) -> ToolResult {
}
fn parse_disable_names(args: &serde_json::Value) -> Result<Vec<String>, String> {
let names = args
.get("names")
.ok_or_else(|| "disable requires names".to_string())?
.as_array()
.ok_or_else(|| "names must be an array of strings".to_string())?;
let mut parsed = Vec::new();
for item in names {
let name = item
.as_str()
.ok_or_else(|| "names must be an array of strings".to_string())?
.trim()
.to_string();
if name.is_empty() {
return Err("names must not contain empty values".to_string());
}
parsed.push(name);
}
if parsed.is_empty() {
return Err("names must not be empty".to_string());
}
Ok(parsed)
// 支持两种格式:实际数组 或 字符串化的 JSON 数组
extract_string_array(args, "names")
.filter(|arr| !arr.is_empty())
.ok_or_else(|| "disable requires names (array of strings)".to_string())
}
fn skill_change_payload(change: crate::skills::SkillAvailabilityChange) -> serde_json::Value {