From efc8af12ebabd61489fc1fbcafe7e05427994ac9 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Thu, 21 May 2026 17:00:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=84=E7=90=86=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E5=8C=96=20JSON=20=E6=95=B0=E7=BB=84=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=8A=80=E8=83=BD=E7=AE=A1=E7=90=86=E5=92=8C=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8F=91=E9=80=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/agent_loop.rs | 10 ++++++ src/tools/mod.rs | 38 +++++++++++++++++++++++ src/tools/session_send.rs | 64 ++++++++++++++++++++++++++++++--------- src/tools/skill_manage.rs | 29 ++++-------------- 4 files changed, 103 insertions(+), 38 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index ab82f87..8f27b80 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -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::>() + .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" ); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c3879dd..4c62faa 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -143,6 +143,44 @@ pub fn require_bool(args: &serde_json::Value, key: &str) -> Result .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> { + 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::>(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| !result.is_empty()) + } else { + None + } + }) +} + +/// Extract a required string array parameter. +pub fn require_string_array(args: &serde_json::Value, key: &str) -> Result, 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 { diff --git a/src/tools/session_send.rs b/src/tools/session_send.rs index d7ce9e8..a270185 100644 --- a/src/tools/session_send.rs +++ b/src/tools/session_send.rs @@ -164,21 +164,39 @@ fn validate_context(context: &ToolContext) -> anyhow::Result<()> { } fn parse_attachments(value: &serde_json::Value) -> anyhow::Result> { - 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::>() + } else if let Some(s) = value.as_str() { + // 尝试解析字符串化的 JSON 数组 + serde_json::from_str::>(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::>() + }) + .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 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"); + } } \ No newline at end of file diff --git a/src/tools/skill_manage.rs b/src/tools/skill_manage.rs index aa37e9b..6282d86 100644 --- a/src/tools/skill_manage.rs +++ b/src/tools/skill_manage.rs @@ -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, @@ -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, 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 {