配置: - rustfmt.toml: 固化 max_width=100 / 4 空格缩进,cargo fmt 全量格式化 - Cargo.toml: 配置 [lints.rust] 与 [lints.clippy] 渐进式规则 - .github/workflows/ci.yml: Rust(fmt+clippy+test) + 前端(eslint+tsc+test) 双平台 CI - Makefile: 新增 check/fmt/fix 目标,clippy 对齐 --all-targets --all-features - web: eslint flat config + prettier 配置 + package.json 脚本与依赖 - src/main.rs: loop→while 修复 clippy::never_loop 对抗性审查发现并修复: - eslint 缺 caughtErrorsIgnorePattern 导致 catch(_) 误报为 error - 前端 lint 未接入 CI,现已补上 Lint 步骤 - Makefile 与 CI 的 clippy flags 不一致,已对齐
190 lines
5.5 KiB
Rust
190 lines
5.5 KiB
Rust
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::json;
|
|
|
|
use crate::skills::SkillRuntime;
|
|
use crate::storage::SkillEventRepository;
|
|
use crate::tools::check_null_args;
|
|
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
|
|
|
pub struct SkillActivateTool {
|
|
skills: Arc<SkillRuntime>,
|
|
events: Arc<dyn SkillEventRepository>,
|
|
}
|
|
|
|
impl SkillActivateTool {
|
|
pub fn new(skills: Arc<SkillRuntime>, events: Arc<dyn SkillEventRepository>) -> Self {
|
|
Self { skills, events }
|
|
}
|
|
|
|
fn record_event(
|
|
&self,
|
|
context: &ToolContext,
|
|
event_type: &str,
|
|
skill_name: Option<&str>,
|
|
payload: &serde_json::Value,
|
|
) {
|
|
if let Err(err) = self.events.append_skill_event(
|
|
context.session_id.as_deref(),
|
|
event_type,
|
|
skill_name,
|
|
payload,
|
|
) {
|
|
tracing::warn!(error = %err, event_type, skill_name, "Failed to record skill activation event");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for SkillActivateTool {
|
|
fn name(&self) -> &str {
|
|
"skill_activate"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Load detailed instructions for a named skill discovered from SKILL.md files. Use when a task matches a listed skill description."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Skill name from the available skills list"
|
|
}
|
|
},
|
|
"required": ["name"]
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
self.execute_with_context(&ToolContext::default(), args)
|
|
.await
|
|
}
|
|
|
|
async fn execute_with_context(
|
|
&self,
|
|
context: &ToolContext,
|
|
args: serde_json::Value,
|
|
) -> anyhow::Result<ToolResult> {
|
|
if let Some(result) = check_null_args(&args, "skill_activate") {
|
|
return Ok(result);
|
|
}
|
|
|
|
let skill_name = match args.get("name").and_then(|value| value.as_str()) {
|
|
Some(name) if !name.trim().is_empty() => name,
|
|
_ => {
|
|
self.record_event(
|
|
context,
|
|
"activation_failed",
|
|
None,
|
|
&json!({
|
|
"reason": "missing_name",
|
|
"arguments": args,
|
|
}),
|
|
);
|
|
return Ok(error_result("Missing required parameter: name"));
|
|
}
|
|
};
|
|
|
|
match self.skills.activation_payload(skill_name) {
|
|
Ok(output) => {
|
|
if let Ok(payload) = self.skills.activation_event_payload(skill_name) {
|
|
self.record_event(context, "activated", Some(skill_name), &payload);
|
|
}
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output,
|
|
error: None,
|
|
})
|
|
}
|
|
Err(err) => {
|
|
self.record_event(
|
|
context,
|
|
"activation_failed",
|
|
Some(skill_name),
|
|
&json!({
|
|
"reason": err,
|
|
"arguments": args,
|
|
}),
|
|
);
|
|
Ok(error_result(&err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn error_result(message: &str) -> ToolResult {
|
|
ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some(message.to_string()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::storage::SessionStore;
|
|
|
|
const TEST_CHANNEL: &str = "test-channel";
|
|
|
|
#[tokio::test]
|
|
async fn test_skill_activate_records_failed_activation_event() {
|
|
let skills = Arc::new(SkillRuntime::default());
|
|
let store = Arc::new(SessionStore::in_memory().unwrap());
|
|
store
|
|
.ensure_channel_session(TEST_CHANNEL, "chat-1")
|
|
.unwrap();
|
|
let tool = SkillActivateTool::new(skills, store.clone());
|
|
let context = ToolContext {
|
|
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
|
..ToolContext::default()
|
|
};
|
|
|
|
let result = tool
|
|
.execute_with_context(&context, json!({ "name": "demo" }))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert!(result.error.unwrap().contains("not found"));
|
|
|
|
let events = store
|
|
.list_skill_events(Some(&format!("{}:chat-1", TEST_CHANNEL)))
|
|
.unwrap();
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0].event_type, "activation_failed");
|
|
assert_eq!(events[0].skill_name.as_deref(), Some("demo"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_skill_activate_handles_null_args() {
|
|
let skills = Arc::new(SkillRuntime::default());
|
|
let store = Arc::new(SessionStore::in_memory().unwrap());
|
|
store
|
|
.ensure_channel_session(TEST_CHANNEL, "chat-1")
|
|
.unwrap();
|
|
let tool = SkillActivateTool::new(skills, store.clone());
|
|
let context = ToolContext {
|
|
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
|
..ToolContext::default()
|
|
};
|
|
|
|
let result = tool
|
|
.execute_with_context(&context, serde_json::Value::Null)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert!(
|
|
result
|
|
.error
|
|
.unwrap()
|
|
.contains("Missing required parameters")
|
|
);
|
|
}
|
|
}
|