Files
iAs/src/tools/registry.rs
T

373 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 工具注册器
//!
//! 扫描每个工具目录下的 `specs/*.tool.yaml`,按 `name` 路由分发。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::context::MemoryStore;
use crate::tools::approval::ApprovalManager;
use crate::tools::executor;
use crate::tools::parser;
use crate::tools::spec::ToolSpec;
use serde_json::Value;
use tokio::sync::RwLock;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ToolSummary {
pub name: String,
pub desc: String,
pub params: Vec<ParamSummary>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ParamSummary {
pub name: String,
pub required: bool,
pub desc: String,
}
/// 解析工具目录。相对路径基于二进制所在目录。
fn resolve_dir(dir: &str) -> PathBuf {
let path = Path::new(dir);
if path.is_absolute() {
return path.to_path_buf();
}
let exe_dir = std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."));
let candidate = exe_dir.join(dir);
if candidate.exists() {
return candidate;
}
let dev = exe_dir.join("../..").join(dir);
if dev.exists() {
tracing::info!(target:"ias::registry","dev模式: {}", dev.display());
return dev;
}
path.to_path_buf()
}
pub struct Registry {
tools: HashMap<String, ToolSpec>,
}
pub struct RegistryManager {
root: String,
registry: RwLock<Registry>,
}
impl Registry {
/// 扫描工具根目录下各子目录的 `specs/*.tool.yaml`。
pub fn load(dir: &str) -> Self {
let root = resolve_dir(dir);
let mut tools = HashMap::new();
let entries = match std::fs::read_dir(&root) {
Ok(e) => e,
Err(e) => {
tracing::warn!(target:"ias::registry","读取目录失败 {}: {}", root.display(), e);
return Self { tools };
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let specs_dir = path.join("specs");
if !specs_dir.exists() {
continue;
}
let result = parser::parse_dir(&specs_dir);
for (file, err) in &result.errors {
tracing::warn!(target:"ias::registry","解析失败 {}: {}", file, err);
}
for mut spec in result.specs {
// 解析二进制路径
let binary = match &spec.path {
Some(p) => {
let p = p.trim_start_matches('/');
path.join(p)
}
None => {
// 默认: {tool_dir}/target/release/{tool}
path.join("target/release").join(&spec.tool)
}
};
if !binary.exists() {
tracing::warn!(target:"ias::registry","{} 二进制不存在: {}", spec.name, binary.display());
continue;
}
// 将绝对路径存入 path 字段(executor 直接使用)
spec.path = Some(binary.to_string_lossy().to_string());
tracing::info!(target:"ias::registry","注册: {} ({})", spec.name, spec.r#type);
tools.insert(spec.name.clone(), spec);
}
}
Self { tools }
}
pub fn list_tools(&self) -> Vec<ToolSummary> {
self.tools
.values()
.map(|s| ToolSummary {
name: s.name.clone(),
desc: s.desc.clone(),
params: s
.params
.iter()
.map(|p| ParamSummary {
name: p.name.clone(),
required: p.required,
desc: p.desc.clone(),
})
.collect(),
})
.collect()
}
pub fn get(&self, name: &str) -> Option<&ToolSpec> {
self.tools.get(name)
}
pub async fn dispatch(
&self,
name: &str,
params: &Value,
user_id: &str,
approved: bool,
approval: &Arc<ApprovalManager>,
memory: &Arc<MemoryStore>,
wechat_send: &(
dyn Fn(&str, &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
+ Send
+ Sync
),
) -> String {
let spec = match self.get(name) {
Some(s) => s,
None => {
tracing::warn!(target:"ias::registry","未知: {name}");
return format!("未知工具: {name}");
}
};
if let Some(err) = validate_required_params(spec, params) {
return err;
}
executor::execute_loop(
spec,
params,
user_id,
approved,
approval,
memory,
wechat_send,
)
.await
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.tools.len()
}
}
impl RegistryManager {
pub fn load(dir: impl Into<String>) -> Self {
let root = dir.into();
Self {
registry: RwLock::new(Registry::load(&root)),
root,
}
}
pub async fn reload(&self) -> usize {
let registry = Registry::load(&self.root);
let len = registry.len();
*self.registry.write().await = registry;
len
}
pub async fn list_tools(&self) -> Vec<ToolSummary> {
self.registry.read().await.list_tools()
}
pub async fn dispatch(
&self,
name: &str,
params: &Value,
user_id: &str,
approved: bool,
approval: &Arc<ApprovalManager>,
memory: &Arc<MemoryStore>,
wechat_send: &(
dyn Fn(&str, &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
+ Send
+ Sync
),
) -> String {
let spec = {
let registry = self.registry.read().await;
match registry.get(name) {
Some(spec) => spec.clone(),
None => {
tracing::warn!(target:"ias::registry","未知: {name}");
return format!("未知工具: {name}");
}
}
};
if let Some(err) = validate_required_params(&spec, params) {
return err;
}
executor::execute_loop(
&spec,
params,
user_id,
approved,
approval,
memory,
wechat_send,
)
.await
}
}
fn validate_required_params(spec: &ToolSpec, params: &Value) -> Option<String> {
let Some(obj) = params.as_object() else {
return Some(format!("工具 '{}' 参数必须是 JSON 对象", spec.name));
};
let missing: Vec<&str> = spec
.params
.iter()
.filter(|p| p.required)
.filter_map(|p| match obj.get(&p.name) {
Some(Value::Null) | None => Some(p.name.as_str()),
Some(Value::String(s)) if s.trim().is_empty() => Some(p.name.as_str()),
Some(_) => None,
})
.collect();
if missing.is_empty() {
None
} else {
Some(format!(
"缺少必填参数: {}(工具: {}",
missing.join(", "),
spec.name
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_load_specs_dir() {
let dir = std::env::temp_dir().join("ias_test_specs_dir");
let _ = std::fs::remove_dir_all(&dir);
let tool_dir = dir.join("mytool");
std::fs::create_dir_all(tool_dir.join("specs")).unwrap();
std::fs::create_dir_all(tool_dir.join("target/release")).unwrap();
std::fs::write(
tool_dir.join("specs/test.tool.yaml"),
r#"
name: test_tool
desc: 测试
type: http
tool: mytool
path: /target/release/mytool
"#,
)
.unwrap();
let mut f = std::fs::File::create(tool_dir.join("target/release/mytool")).unwrap();
f.write_all(b"fake").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = f.set_permissions(std::fs::Permissions::from_mode(0o755));
}
let registry = Registry::load(dir.to_str().unwrap());
assert_eq!(registry.len(), 1);
let tools = registry.list_tools();
assert_eq!(tools[0].name, "test_tool");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_real_tools() {
let dir = std::path::Path::new("tools");
if !dir.exists() {
return;
}
let registry = Registry::load("tools");
assert!(registry.len() >= 1, "至少 1 个工具");
}
#[test]
fn test_validate_required_params_missing() {
let spec = ToolSpec {
name: "weather_now".into(),
desc: "天气".into(),
r#type: "http".into(),
tool: "weather".into(),
command: None,
path: None,
risk_level: "low".into(),
timeout_secs: 10,
env: vec![],
params: vec![crate::tools::spec::ParamSpec {
name: "location".into(),
required: true,
desc: "城市".into(),
}],
};
let err = validate_required_params(&spec, &serde_json::json!({})).unwrap();
assert!(err.contains("location"));
}
#[test]
fn test_validate_required_params_present() {
let spec = ToolSpec {
name: "weather_now".into(),
desc: "天气".into(),
r#type: "http".into(),
tool: "weather".into(),
command: None,
path: None,
risk_level: "low".into(),
timeout_secs: 10,
env: vec![],
params: vec![crate::tools::spec::ParamSpec {
name: "location".into(),
required: true,
desc: "城市".into(),
}],
};
assert!(
validate_required_params(&spec, &serde_json::json!({"location": "北京"})).is_none()
);
}
}