feat: add runtime skill management and persistence
This commit is contained in:
parent
393d980742
commit
0c724e37bb
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ reference/**
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
Cargo.lock
|
||||
.playwright-cli/
|
||||
|
||||
@ -9,6 +9,7 @@ dotenv = "0.15"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
regex = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
async-trait = "0.1"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
66
README.md
66
README.md
@ -0,0 +1,66 @@
|
||||
PicoBot
|
||||
|
||||
Skills (initial implementation)
|
||||
|
||||
PicoBot now supports filesystem skills.
|
||||
|
||||
Skill discovery locations:
|
||||
- Project skills: .picobot/skills/*/SKILL.md
|
||||
- User skills: ~/.picobot/skills/*/SKILL.md
|
||||
|
||||
Minimal required SKILL.md format:
|
||||
|
||||
---
|
||||
description: Summarize code architecture for Rust projects
|
||||
---
|
||||
Optional detailed instructions go here.
|
||||
|
||||
Notes:
|
||||
- The only required frontmatter field is description.
|
||||
- If name is missing, the folder name is used as the skill name.
|
||||
- Invalid skill files are skipped with warning logs.
|
||||
|
||||
How it is injected:
|
||||
- AgentLoop adds a system message containing the available skills list (name + description).
|
||||
- The model can call tool skill_activate with {"name":"<skill-name>"}.
|
||||
- PicoBot returns the skill body as tool output so the model can follow detailed instructions.
|
||||
|
||||
Skill management tool
|
||||
|
||||
PicoBot exposes a built-in tool named skill_manage for runtime skill administration.
|
||||
PicoBot also exposes a read-only tool named skill_list for listing discovered skills without mutation.
|
||||
|
||||
Supported actions:
|
||||
- list: List discovered skills
|
||||
- get: Read one skill by name
|
||||
- create: Create a skill under project or user scope
|
||||
- update: Update description and/or body for an existing skill
|
||||
- delete: Delete a skill directory
|
||||
- reload: Re-scan skill directories and refresh the in-memory catalog
|
||||
|
||||
Defaults:
|
||||
- scope defaults to project
|
||||
- reload defaults to true for create, update, and delete
|
||||
|
||||
Example payloads:
|
||||
|
||||
skill_list takes no parameters.
|
||||
|
||||
{"action":"list"}
|
||||
{"action":"create","scope":"project","name":"demo-skill","description":"Use when summarizing a Rust crate","body":"Step 1..."}
|
||||
{"action":"update","scope":"project","name":"demo-skill","description":"Use when reviewing a Rust crate"}
|
||||
{"action":"delete","scope":"project","name":"demo-skill"}
|
||||
{"action":"reload"}
|
||||
|
||||
Config (optional)
|
||||
|
||||
Add skills in config.json:
|
||||
|
||||
{
|
||||
"skills": {
|
||||
"enabled": true,
|
||||
"sources": ["project", "user"],
|
||||
"max_index_chars": 4000,
|
||||
"max_listed_skills": 32
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@ use crate::observability::{
|
||||
truncate_args, Observer, ObserverEvent, ToolExecutionOutcome,
|
||||
};
|
||||
use crate::providers::{create_provider, LLMProvider, ChatCompletionRequest, Message, ToolCall};
|
||||
use crate::skills::SkillRuntime;
|
||||
use crate::storage::SessionStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
use std::collections::VecDeque;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@ -220,6 +222,9 @@ fn chat_message_to_llm_message(m: &ChatMessage) -> Message {
|
||||
pub struct AgentLoop {
|
||||
provider: Box<dyn LLMProvider>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
skill_event_store: Option<Arc<SessionStore>>,
|
||||
skill_event_session_id: Option<String>,
|
||||
observer: Option<Arc<dyn Observer>>,
|
||||
max_iterations: usize,
|
||||
}
|
||||
@ -239,6 +244,9 @@ impl AgentLoop {
|
||||
Ok(Self {
|
||||
provider,
|
||||
tools: Arc::new(ToolRegistry::new()),
|
||||
skills: Arc::new(SkillRuntime::default()),
|
||||
skill_event_store: None,
|
||||
skill_event_session_id: None,
|
||||
observer: None,
|
||||
max_iterations,
|
||||
})
|
||||
@ -252,11 +260,40 @@ impl AgentLoop {
|
||||
Ok(Self {
|
||||
provider,
|
||||
tools,
|
||||
skills: Arc::new(SkillRuntime::default()),
|
||||
skill_event_store: None,
|
||||
skill_event_session_id: None,
|
||||
observer: None,
|
||||
max_iterations,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_tools_and_skills(
|
||||
provider_config: LLMProviderConfig,
|
||||
tools: Arc<ToolRegistry>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
) -> Result<Self, AgentError> {
|
||||
let max_iterations = provider_config.max_tool_iterations;
|
||||
let provider = create_provider(provider_config)
|
||||
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
provider,
|
||||
tools,
|
||||
skills,
|
||||
skill_event_store: None,
|
||||
skill_event_session_id: None,
|
||||
observer: None,
|
||||
max_iterations,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_skill_event_store(mut self, store: Arc<SessionStore>, session_id: String) -> Self {
|
||||
self.skill_event_store = Some(store);
|
||||
self.skill_event_session_id = Some(session_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set an observer for tracking events.
|
||||
pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
|
||||
self.observer = Some(observer);
|
||||
@ -287,17 +324,18 @@ impl AgentLoop {
|
||||
tracing::debug!(iteration, "Agent iteration started");
|
||||
|
||||
// Convert messages to LLM format
|
||||
let messages_for_llm: Vec<Message> = messages
|
||||
.iter()
|
||||
.map(chat_message_to_llm_message)
|
||||
.collect();
|
||||
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(messages.len() + 1);
|
||||
if let Some(skill_prompt) = self.skills.system_index_prompt() {
|
||||
messages_for_llm.push(Message::system(skill_prompt));
|
||||
}
|
||||
messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message));
|
||||
|
||||
// Build request
|
||||
let tools = if self.tools.has_tools() {
|
||||
Some(self.tools.get_definitions())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut tool_defs = self.tools.get_definitions();
|
||||
if let Some(skill_tool) = self.skills.skill_tool_definition() {
|
||||
tool_defs.push(skill_tool);
|
||||
}
|
||||
let tools = if tool_defs.is_empty() { None } else { Some(tool_defs) };
|
||||
|
||||
let request = ChatCompletionRequest {
|
||||
messages: messages_for_llm,
|
||||
@ -403,10 +441,11 @@ impl AgentLoop {
|
||||
messages.push(summary_request);
|
||||
|
||||
// Convert messages to LLM format
|
||||
let messages_for_llm: Vec<Message> = messages
|
||||
.iter()
|
||||
.map(chat_message_to_llm_message)
|
||||
.collect();
|
||||
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(messages.len() + 1);
|
||||
if let Some(skill_prompt) = self.skills.system_index_prompt() {
|
||||
messages_for_llm.push(Message::system(skill_prompt));
|
||||
}
|
||||
messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message));
|
||||
|
||||
let request = ChatCompletionRequest {
|
||||
messages: messages_for_llm,
|
||||
@ -529,6 +568,49 @@ impl AgentLoop {
|
||||
|
||||
/// Internal tool execution without event tracking.
|
||||
async fn execute_tool_internal(&self, tool_call: &ToolCall) -> ToolExecutionOutcome {
|
||||
if tool_call.name == "skill_activate" {
|
||||
let skill_name = match tool_call.arguments.get("name").and_then(|v| v.as_str()) {
|
||||
Some(name) if !name.trim().is_empty() => name,
|
||||
_ => {
|
||||
self.record_skill_event(
|
||||
"activation_failed",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"reason": "missing_name",
|
||||
"arguments": tool_call.arguments,
|
||||
}),
|
||||
);
|
||||
return ToolExecutionOutcome::failure(
|
||||
"Error: Missing required parameter: name".to_string(),
|
||||
Some("Missing required parameter: name".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return match self.skills.activation_payload(skill_name) {
|
||||
Ok(output) => {
|
||||
if let Ok(payload) = self.skills.activation_event_payload(skill_name) {
|
||||
self.record_skill_event("activated", Some(skill_name), payload);
|
||||
}
|
||||
ToolExecutionOutcome::success(output)
|
||||
}
|
||||
Err(err) => {
|
||||
self.record_skill_event(
|
||||
"activation_failed",
|
||||
Some(skill_name),
|
||||
serde_json::json!({
|
||||
"reason": err,
|
||||
"arguments": tool_call.arguments,
|
||||
}),
|
||||
);
|
||||
ToolExecutionOutcome::failure(
|
||||
format!("Error: {}", err),
|
||||
Some(err),
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let tool = match self.tools.get(&tool_call.name) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@ -561,6 +643,24 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_skill_event(
|
||||
&self,
|
||||
event_type: &str,
|
||||
skill_name: Option<&str>,
|
||||
payload: serde_json::Value,
|
||||
) {
|
||||
let (Some(store), Some(session_id)) = (
|
||||
self.skill_event_store.as_ref(),
|
||||
self.skill_event_session_id.as_ref(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = store.append_skill_event(Some(session_id), event_type, skill_name, &payload) {
|
||||
tracing::warn!(error = %err, event_type = %event_type, "Failed to record skill event");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -16,6 +16,47 @@ pub struct Config {
|
||||
pub client: ClientConfig,
|
||||
#[serde(default)]
|
||||
pub channels: HashMap<String, FeishuChannelConfig>,
|
||||
#[serde(default)]
|
||||
pub skills: SkillsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SkillsConfig {
|
||||
#[serde(default = "default_skills_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_skills_sources")]
|
||||
pub sources: Vec<String>,
|
||||
#[serde(default = "default_skills_max_index_chars")]
|
||||
pub max_index_chars: usize,
|
||||
#[serde(default = "default_skills_max_listed")]
|
||||
pub max_listed_skills: usize,
|
||||
}
|
||||
|
||||
fn default_skills_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_skills_sources() -> Vec<String> {
|
||||
vec!["project".to_string(), "user".to_string()]
|
||||
}
|
||||
|
||||
fn default_skills_max_index_chars() -> usize {
|
||||
4_000
|
||||
}
|
||||
|
||||
fn default_skills_max_listed() -> usize {
|
||||
32
|
||||
}
|
||||
|
||||
impl Default for SkillsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_skills_enabled(),
|
||||
sources: default_skills_sources(),
|
||||
max_index_chars: default_skills_max_index_chars(),
|
||||
max_listed_skills: default_skills_max_listed(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
@ -10,6 +10,7 @@ use crate::bus::{MessageBus, OutboundDispatcher};
|
||||
use crate::channels::ChannelManager;
|
||||
use crate::config::Config;
|
||||
use crate::logging;
|
||||
use crate::skills::SkillRuntime;
|
||||
use session::SessionManager;
|
||||
|
||||
pub struct GatewayState {
|
||||
@ -29,7 +30,9 @@ impl GatewayState {
|
||||
// Session TTL from config (default 4 hours)
|
||||
let session_ttl_hours = config.gateway.session_ttl_hours.unwrap_or(4);
|
||||
|
||||
let session_manager = SessionManager::new(session_ttl_hours, provider_config)?;
|
||||
let skills = Arc::new(SkillRuntime::from_config(config.skills.clone()));
|
||||
|
||||
let session_manager = SessionManager::new(session_ttl_hours, provider_config, skills)?;
|
||||
let channel_manager = ChannelManager::new();
|
||||
let bus = channel_manager.bus();
|
||||
|
||||
|
||||
@ -7,10 +7,11 @@ use crate::bus::ChatMessage;
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::agent::{AgentLoop, AgentError, ContextCompressor};
|
||||
use crate::protocol::WsOutbound;
|
||||
use crate::skills::SkillRuntime;
|
||||
use crate::storage::{SessionRecord, SessionStore, persistent_session_id};
|
||||
use crate::tools::{
|
||||
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
|
||||
HttpRequestTool, ToolRegistry, WebFetchTool,
|
||||
HttpRequestTool, SkillListTool, SkillManageTool, ToolRegistry, WebFetchTool,
|
||||
};
|
||||
|
||||
/// Session 按 channel 隔离,每个 channel 一个 Session
|
||||
@ -23,6 +24,7 @@ pub struct Session {
|
||||
pub user_tx: mpsc::Sender<WsOutbound>,
|
||||
provider_config: LLMProviderConfig,
|
||||
tools: Arc<ToolRegistry>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
compressor: ContextCompressor,
|
||||
store: Arc<SessionStore>,
|
||||
}
|
||||
@ -33,6 +35,7 @@ impl Session {
|
||||
provider_config: LLMProviderConfig,
|
||||
user_tx: mpsc::Sender<WsOutbound>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
store: Arc<SessionStore>,
|
||||
) -> Result<Self, AgentError> {
|
||||
Ok(Self {
|
||||
@ -42,6 +45,7 @@ impl Session {
|
||||
user_tx,
|
||||
provider_config: provider_config.clone(),
|
||||
tools,
|
||||
skills,
|
||||
compressor: ContextCompressor::new(provider_config.token_limit),
|
||||
store,
|
||||
})
|
||||
@ -177,9 +181,29 @@ impl Session {
|
||||
&self.compressor
|
||||
}
|
||||
|
||||
pub fn record_skill_offer(&self, chat_id: &str) -> Result<(), AgentError> {
|
||||
if self.skills.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.store
|
||||
.append_skill_event(
|
||||
Some(&self.persistent_session_id(chat_id)),
|
||||
"offered",
|
||||
None,
|
||||
&self.skills.offered_event_payload(),
|
||||
)
|
||||
.map_err(|err| AgentError::Other(format!("append skill event error: {}", err)))
|
||||
}
|
||||
|
||||
/// 创建一个临时的 AgentLoop 实例来处理消息
|
||||
pub fn create_agent(&self) -> Result<AgentLoop, AgentError> {
|
||||
AgentLoop::with_tools(self.provider_config.clone(), self.tools.clone())
|
||||
pub fn create_agent(&self, chat_id: &str) -> Result<AgentLoop, AgentError> {
|
||||
AgentLoop::with_tools_and_skills(
|
||||
self.provider_config.clone(),
|
||||
self.tools.clone(),
|
||||
self.skills.clone(),
|
||||
)
|
||||
.map(|agent| agent.with_skill_event_store(self.store.clone(), self.persistent_session_id(chat_id)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,6 +213,7 @@ pub struct SessionManager {
|
||||
inner: Arc<Mutex<SessionManagerInner>>,
|
||||
provider_config: LLMProviderConfig,
|
||||
tools: Arc<ToolRegistry>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
store: Arc<SessionStore>,
|
||||
}
|
||||
|
||||
@ -198,12 +223,14 @@ struct SessionManagerInner {
|
||||
session_ttl: Duration,
|
||||
}
|
||||
|
||||
fn default_tools() -> ToolRegistry {
|
||||
fn default_tools(skills: Arc<SkillRuntime>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(CalculatorTool::new());
|
||||
registry.register(FileReadTool::new());
|
||||
registry.register(FileWriteTool::new());
|
||||
registry.register(FileEditTool::new());
|
||||
registry.register(SkillListTool::new(skills.clone()));
|
||||
registry.register(SkillManageTool::new(skills));
|
||||
registry.register(BashTool::new());
|
||||
registry.register(HttpRequestTool::new(
|
||||
vec!["*".to_string()], // 允许所有域名,实际使用时建议限制
|
||||
@ -242,12 +269,20 @@ pub(crate) fn handle_in_chat_command(
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new(session_ttl_hours: u64, provider_config: LLMProviderConfig) -> Result<Self, AgentError> {
|
||||
pub fn new(
|
||||
session_ttl_hours: u64,
|
||||
provider_config: LLMProviderConfig,
|
||||
skills: Arc<SkillRuntime>,
|
||||
) -> Result<Self, AgentError> {
|
||||
let store = Arc::new(
|
||||
SessionStore::new()
|
||||
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
||||
);
|
||||
|
||||
if let Err(err) = store.append_skill_event(None, "discovered", None, &skills.discovery_event_payload()) {
|
||||
tracing::warn!(error = %err, "Failed to record skill discovery event");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(SessionManagerInner {
|
||||
sessions: HashMap::new(),
|
||||
@ -255,7 +290,8 @@ impl SessionManager {
|
||||
session_ttl: Duration::from_secs(session_ttl_hours * 3600),
|
||||
})),
|
||||
provider_config,
|
||||
tools: Arc::new(default_tools()),
|
||||
tools: Arc::new(default_tools(skills.clone())),
|
||||
skills,
|
||||
store,
|
||||
})
|
||||
}
|
||||
@ -268,6 +304,10 @@ impl SessionManager {
|
||||
self.store.clone()
|
||||
}
|
||||
|
||||
pub fn skills(&self) -> Arc<SkillRuntime> {
|
||||
self.skills.clone()
|
||||
}
|
||||
|
||||
pub fn create_cli_session(&self, title: Option<&str>) -> Result<SessionRecord, AgentError> {
|
||||
self.store
|
||||
.create_cli_session(title)
|
||||
@ -345,6 +385,7 @@ impl SessionManager {
|
||||
self.provider_config.clone(),
|
||||
user_tx,
|
||||
self.tools.clone(),
|
||||
self.skills.clone(),
|
||||
self.store.clone(),
|
||||
)
|
||||
.await?;
|
||||
@ -432,8 +473,10 @@ impl SessionManager {
|
||||
.compress_if_needed(history, &session_guard.provider_config)
|
||||
.await?;
|
||||
|
||||
session_guard.record_skill_offer(chat_id)?;
|
||||
|
||||
// 创建 agent 并处理
|
||||
let agent = session_guard.create_agent()?;
|
||||
let agent = session_guard.create_agent(chat_id)?;
|
||||
let result = agent.process(history).await?;
|
||||
|
||||
// 按真实顺序持久化 assistant tool_calls、tool 结果和最终 assistant 回复
|
||||
@ -497,12 +540,14 @@ mod tests {
|
||||
async fn test_handle_in_chat_command_resets_active_history_only() {
|
||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||
let (user_tx, _user_rx) = mpsc::channel(4);
|
||||
let tools = Arc::new(default_tools());
|
||||
let skills = Arc::new(SkillRuntime::default());
|
||||
let tools = Arc::new(default_tools(skills.clone()));
|
||||
let mut session = Session::new(
|
||||
"feishu".to_string(),
|
||||
test_provider_config(),
|
||||
user_tx,
|
||||
tools,
|
||||
skills,
|
||||
store.clone(),
|
||||
)
|
||||
.await
|
||||
|
||||
@ -40,6 +40,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
||||
provider_config,
|
||||
sender,
|
||||
state.session_manager.tools(),
|
||||
state.session_manager.skills(),
|
||||
state.session_manager.store(),
|
||||
)
|
||||
.await
|
||||
@ -180,7 +181,9 @@ async fn handle_inbound(
|
||||
}
|
||||
};
|
||||
|
||||
let agent = session_guard.create_agent()?;
|
||||
session_guard.record_skill_offer(&chat_id)?;
|
||||
|
||||
let agent = session_guard.create_agent(&chat_id)?;
|
||||
match agent.process(history).await {
|
||||
Ok(result) => {
|
||||
session_guard.append_persisted_messages(&chat_id, result.emitted_messages.clone())?;
|
||||
|
||||
@ -11,3 +11,4 @@ pub mod logging;
|
||||
pub mod observability;
|
||||
pub mod storage;
|
||||
pub mod tools;
|
||||
pub mod skills;
|
||||
|
||||
681
src/skills/mod.rs
Normal file
681
src/skills/mod.rs
Normal file
@ -0,0 +1,681 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::config::SkillsConfig;
|
||||
use crate::providers::{Tool, ToolFunction};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Skill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
pub source: SkillSource,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkillSource {
|
||||
User,
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkillScope {
|
||||
User,
|
||||
Project,
|
||||
}
|
||||
|
||||
impl SkillScope {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"user" => Some(Self::User),
|
||||
"project" => Some(Self::Project),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Project => "project",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkillScope> for SkillSource {
|
||||
fn from(value: SkillScope) -> Self {
|
||||
match value {
|
||||
SkillScope::User => SkillSource::User,
|
||||
SkillScope::Project => SkillSource::Project,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SkillRuntime {
|
||||
config: SkillsConfig,
|
||||
catalog: RwLock<SkillCatalog>,
|
||||
}
|
||||
|
||||
impl Default for SkillRuntime {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
config: SkillsConfig::default(),
|
||||
catalog: RwLock::new(SkillCatalog::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillRuntime {
|
||||
pub fn from_config(config: SkillsConfig) -> Self {
|
||||
let catalog = SkillCatalog::discover(&config);
|
||||
Self {
|
||||
config,
|
||||
catalog: RwLock::new(catalog),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reload(&self) -> Result<SkillCatalog, String> {
|
||||
let catalog = SkillCatalog::discover(&self.config);
|
||||
let mut guard = self.catalog.write().expect("skills rwlock poisoned");
|
||||
*guard = catalog.clone();
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.catalog.read().expect("skills rwlock poisoned").is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.catalog.read().expect("skills rwlock poisoned").len()
|
||||
}
|
||||
|
||||
pub fn system_index_prompt(&self) -> Option<String> {
|
||||
self.catalog.read().expect("skills rwlock poisoned").system_index_prompt()
|
||||
}
|
||||
|
||||
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
||||
self.catalog.read().expect("skills rwlock poisoned").discovery_event_payload()
|
||||
}
|
||||
|
||||
pub fn offered_event_payload(&self) -> serde_json::Value {
|
||||
self.catalog.read().expect("skills rwlock poisoned").offered_event_payload()
|
||||
}
|
||||
|
||||
pub fn skill_tool_definition(&self) -> Option<Tool> {
|
||||
self.catalog.read().expect("skills rwlock poisoned").skill_tool_definition()
|
||||
}
|
||||
|
||||
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
|
||||
self.catalog.read().expect("skills rwlock poisoned").activation_payload(name)
|
||||
}
|
||||
|
||||
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
|
||||
self.catalog.read().expect("skills rwlock poisoned").activation_event_payload(name)
|
||||
}
|
||||
|
||||
pub fn list_skills(&self) -> Vec<Skill> {
|
||||
self.catalog.read().expect("skills rwlock poisoned").skills.clone()
|
||||
}
|
||||
|
||||
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
||||
self.catalog
|
||||
.read()
|
||||
.expect("skills rwlock poisoned")
|
||||
.find_skill(name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn create_skill(
|
||||
&self,
|
||||
scope: SkillScope,
|
||||
name: &str,
|
||||
description: &str,
|
||||
body: &str,
|
||||
reload: bool,
|
||||
) -> Result<Skill, String> {
|
||||
validate_skill_name(name)?;
|
||||
let path = skill_file_path(scope, name)?;
|
||||
if path.exists() {
|
||||
return Err(format!("skill '{}' already exists at {}", name, path.display()));
|
||||
}
|
||||
|
||||
write_skill_file(&path, name, description, body)?;
|
||||
let skill = parse_skill_file(&path, scope.into())?;
|
||||
if reload {
|
||||
let _ = self.reload()?;
|
||||
}
|
||||
Ok(skill)
|
||||
}
|
||||
|
||||
pub fn update_skill(
|
||||
&self,
|
||||
scope: SkillScope,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
body: Option<&str>,
|
||||
reload: bool,
|
||||
) -> Result<Skill, String> {
|
||||
validate_skill_name(name)?;
|
||||
let path = skill_file_path(scope, name)?;
|
||||
if !path.exists() {
|
||||
return Err(format!("skill '{}' not found at {}", name, path.display()));
|
||||
}
|
||||
|
||||
let existing = parse_skill_file(&path, scope.into())?;
|
||||
let next_description = description.unwrap_or(&existing.description);
|
||||
let next_body = body.unwrap_or(&existing.body);
|
||||
|
||||
write_skill_file(&path, name, next_description, next_body)?;
|
||||
let skill = parse_skill_file(&path, scope.into())?;
|
||||
if reload {
|
||||
let _ = self.reload()?;
|
||||
}
|
||||
Ok(skill)
|
||||
}
|
||||
|
||||
pub fn delete_skill(&self, scope: SkillScope, name: &str, reload: bool) -> Result<PathBuf, String> {
|
||||
validate_skill_name(name)?;
|
||||
let dir = skill_dir_path(scope, name)?;
|
||||
if !dir.exists() {
|
||||
return Err(format!("skill '{}' not found at {}", name, dir.display()));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).map_err(|err| format!("failed to delete skill directory: {}", err))?;
|
||||
if reload {
|
||||
let _ = self.reload()?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillSource {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SkillSource::User => "user",
|
||||
SkillSource::Project => "project",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SkillCatalog {
|
||||
skills: Vec<Skill>,
|
||||
max_index_chars: usize,
|
||||
max_listed_skills: usize,
|
||||
}
|
||||
|
||||
impl Default for SkillCatalog {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
skills: Vec::new(),
|
||||
max_index_chars: 4_000,
|
||||
max_listed_skills: 32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillCatalog {
|
||||
pub fn discover(config: &SkillsConfig) -> Self {
|
||||
if !config.enabled {
|
||||
return Self {
|
||||
max_index_chars: config.max_index_chars,
|
||||
max_listed_skills: config.max_listed_skills,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let mut merged: HashMap<String, Skill> = HashMap::new();
|
||||
let mut sources_seen = 0usize;
|
||||
|
||||
// Load user first, then project. Project wins on conflicts.
|
||||
for source in source_order(&config.sources) {
|
||||
sources_seen += 1;
|
||||
let root = match source {
|
||||
SkillSource::User => user_skills_root(),
|
||||
SkillSource::Project => Some(cwd.join(".picobot").join("skills")),
|
||||
};
|
||||
|
||||
let Some(root) = root else { continue };
|
||||
for skill in load_skills_from_root(&root, source) {
|
||||
if let Some(existing) = merged.get(&skill.name) {
|
||||
tracing::warn!(
|
||||
skill = %skill.name,
|
||||
old_source = %existing.source.as_str(),
|
||||
new_source = %skill.source.as_str(),
|
||||
"Duplicate skill name found; overriding with later source"
|
||||
);
|
||||
}
|
||||
merged.insert(skill.name.clone(), skill);
|
||||
}
|
||||
}
|
||||
|
||||
let mut skills: Vec<Skill> = merged.into_values().collect();
|
||||
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
tracing::info!(
|
||||
sources_seen,
|
||||
discovered = skills.len(),
|
||||
"Skills discovery completed"
|
||||
);
|
||||
|
||||
Self {
|
||||
skills,
|
||||
max_index_chars: config.max_index_chars,
|
||||
max_listed_skills: config.max_listed_skills,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.skills.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.skills.len()
|
||||
}
|
||||
|
||||
pub fn system_index_prompt(&self) -> Option<String> {
|
||||
if self.skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut prompt = String::from(
|
||||
"You have access to project skills. Use a skill only when the user's request clearly matches the skill description.\nIf a skill is needed, call tool skill_activate with {\"name\": \"<skill-name>\"}.\nAvailable skills:\n",
|
||||
);
|
||||
|
||||
for skill in self.skills.iter().take(self.max_listed_skills) {
|
||||
let line = format!("- {}: {}\n", skill.name, skill.description);
|
||||
if prompt.len() + line.len() > self.max_index_chars {
|
||||
prompt.push_str("- ... (truncated)\n");
|
||||
break;
|
||||
}
|
||||
prompt.push_str(&line);
|
||||
}
|
||||
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
||||
self.catalog_event_payload()
|
||||
}
|
||||
|
||||
pub fn offered_event_payload(&self) -> serde_json::Value {
|
||||
self.catalog_event_payload()
|
||||
}
|
||||
|
||||
pub fn skill_tool_definition(&self) -> Option<Tool> {
|
||||
if self.skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Tool {
|
||||
tool_type: "function".to_string(),
|
||||
function: ToolFunction {
|
||||
name: "skill_activate".to_string(),
|
||||
description: "Load detailed instructions for a named skill discovered from SKILL.md files. Use when a task matches a listed skill description.".to_string(),
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Skill name from the available skills list"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
|
||||
let skill = self
|
||||
.find_skill(name)
|
||||
.ok_or_else(|| format!("skill '{}' not found", name))?;
|
||||
|
||||
if skill.body.is_empty() {
|
||||
return Ok(format!(
|
||||
"SKILL LOADED: {}\nDescription: {}\nNo additional body instructions found.",
|
||||
skill.name, skill.description
|
||||
));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"SKILL LOADED: {}\nDescription: {}\nSource: {}\nPath: {}\n\n{}",
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.source.as_str(),
|
||||
skill.path.display(),
|
||||
skill.body
|
||||
))
|
||||
}
|
||||
|
||||
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
|
||||
let skill = self
|
||||
.find_skill(name)
|
||||
.ok_or_else(|| format!("skill '{}' not found", name))?;
|
||||
|
||||
Ok(json!({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"source": skill.source.as_str(),
|
||||
"path": skill.path.display().to_string(),
|
||||
"body_chars": skill.body.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn find_skill(&self, name: &str) -> Option<&Skill> {
|
||||
self.skills.iter().find(|s| s.name == name)
|
||||
}
|
||||
|
||||
fn catalog_event_payload(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"count": self.skills.len(),
|
||||
"skills": self.skills.iter().map(|skill| json!({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"source": skill.source.as_str(),
|
||||
"path": skill.path.display().to_string(),
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn source_order(sources: &[String]) -> Vec<SkillSource> {
|
||||
let mut result = Vec::new();
|
||||
for source in sources {
|
||||
match source.as_str() {
|
||||
"user" => {
|
||||
if !result.contains(&SkillSource::User) {
|
||||
result.push(SkillSource::User);
|
||||
}
|
||||
}
|
||||
"project" => {
|
||||
if !result.contains(&SkillSource::Project) {
|
||||
result.push(SkillSource::Project);
|
||||
}
|
||||
}
|
||||
unknown => {
|
||||
tracing::warn!(source = %unknown, "Unknown skills source ignored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
vec![SkillSource::User, SkillSource::Project]
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_skill_name(name: &str) -> Result<(), String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err("skill name cannot be empty".to_string());
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||
return Err("skill name must not contain path separators or '..'".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn project_skills_root() -> Result<PathBuf, String> {
|
||||
let cwd = std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||
Ok(cwd.join(".picobot").join("skills"))
|
||||
}
|
||||
|
||||
fn user_skills_root() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|p| p.join(".picobot").join("skills"))
|
||||
}
|
||||
|
||||
fn root_for_scope(scope: SkillScope) -> Result<PathBuf, String> {
|
||||
match scope {
|
||||
SkillScope::User => user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string()),
|
||||
SkillScope::Project => project_skills_root(),
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_dir_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
|
||||
Ok(root_for_scope(scope)?.join(name))
|
||||
}
|
||||
|
||||
fn skill_file_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
|
||||
Ok(skill_dir_path(scope, name)?.join("SKILL.md"))
|
||||
}
|
||||
|
||||
fn render_skill_file(name: &str, description: &str, body: &str) -> Result<String, String> {
|
||||
if description.trim().is_empty() {
|
||||
return Err("description is required and cannot be empty".to_string());
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SkillFrontmatterOwned {
|
||||
name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
let yaml = serde_yaml::to_string(&SkillFrontmatterOwned {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
})
|
||||
.map_err(|err| format!("failed to render skill frontmatter: {}", err))?;
|
||||
|
||||
let yaml = yaml.trim_start_matches("---\n");
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
Ok(format!("---\n{}---\n", yaml))
|
||||
} else {
|
||||
Ok(format!("---\n{}---\n{}\n", yaml, body))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_skill_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> {
|
||||
let content = render_skill_file(name, description, body)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| format!("failed to create skill directory: {}", err))?;
|
||||
}
|
||||
fs::write(path, content).map_err(|err| format!("failed to write skill file: {}", err))
|
||||
}
|
||||
|
||||
fn load_skills_from_root(root: &Path, source: SkillSource) -> Vec<Skill> {
|
||||
let mut out = Vec::new();
|
||||
if !root.exists() {
|
||||
return out;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %root.display(), error = %err, "Failed to read skills directory");
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match parse_skill_file(&skill_md, source) {
|
||||
Ok(skill) => out.push(skill),
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SkillFrontmatter {
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read file: {}", e))?;
|
||||
|
||||
let (frontmatter_raw, body) = split_frontmatter(&content)
|
||||
.ok_or_else(|| "missing YAML frontmatter block".to_string())?;
|
||||
|
||||
let frontmatter: SkillFrontmatter = serde_yaml::from_str(frontmatter_raw)
|
||||
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
|
||||
|
||||
let description = frontmatter.description.trim();
|
||||
if description.is_empty() {
|
||||
return Err("description is required and cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let dir_name = path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown-skill".to_string());
|
||||
|
||||
let name = frontmatter
|
||||
.name
|
||||
.unwrap_or(dir_name)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(Skill {
|
||||
name,
|
||||
description: description.to_string(),
|
||||
body: body.trim().to_string(),
|
||||
source,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
|
||||
let rest = content.strip_prefix("---\n")?;
|
||||
let marker = "\n---\n";
|
||||
let idx = rest.find(marker)?;
|
||||
let frontmatter = &rest[..idx];
|
||||
let body = &rest[idx + marker.len()..];
|
||||
Some((frontmatter, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter() {
|
||||
let input = "---\ndescription: demo\n---\nhello";
|
||||
let (fm, body) = split_frontmatter(input).unwrap();
|
||||
assert!(fm.contains("description"));
|
||||
assert_eq!(body, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_file_requires_description() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join("demo");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
fs::write(&skill_md, "---\nname: demo\n---\ncontent").unwrap();
|
||||
|
||||
let err = parse_skill_file(&skill_md, SkillSource::Project).unwrap_err();
|
||||
assert!(err.contains("description"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_tool_definition_exists_when_skills_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join(".picobot").join("skills").join("demo");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
fs::write(
|
||||
root.join("SKILL.md"),
|
||||
"---\ndescription: demo skill\n---\nDo demo",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = load_skills_from_root(&dir.path().join(".picobot").join("skills"), SkillSource::Project);
|
||||
let catalog = SkillCatalog {
|
||||
skills,
|
||||
max_index_chars: 4000,
|
||||
max_listed_skills: 10,
|
||||
};
|
||||
|
||||
let tool = catalog.skill_tool_definition().unwrap();
|
||||
assert_eq!(tool.function.name, "skill_activate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activation_payload_contains_body() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join("demo");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
fs::write(
|
||||
&skill_md,
|
||||
"---\nname: demo\ndescription: demo skill\n---\nStep A\nStep B",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
|
||||
let catalog = SkillCatalog {
|
||||
skills: vec![skill],
|
||||
max_index_chars: 1000,
|
||||
max_listed_skills: 10,
|
||||
};
|
||||
|
||||
let payload = catalog.activation_payload("demo").unwrap();
|
||||
assert!(payload.contains("SKILL LOADED: demo"));
|
||||
assert!(payload.contains("Step A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_create_update_delete_reload() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let previous = std::env::current_dir().unwrap();
|
||||
std::env::set_current_dir(temp_dir.path()).unwrap();
|
||||
|
||||
let runtime = SkillRuntime::from_config(SkillsConfig {
|
||||
enabled: true,
|
||||
sources: vec!["project".to_string()],
|
||||
max_index_chars: 4000,
|
||||
max_listed_skills: 32,
|
||||
});
|
||||
|
||||
assert_eq!(runtime.len(), 0);
|
||||
|
||||
let created = runtime
|
||||
.create_skill(SkillScope::Project, "demo-skill", "demo desc", "line 1", true)
|
||||
.unwrap();
|
||||
assert_eq!(created.name, "demo-skill");
|
||||
assert_eq!(runtime.len(), 1);
|
||||
|
||||
let updated = runtime
|
||||
.update_skill(
|
||||
SkillScope::Project,
|
||||
"demo-skill",
|
||||
Some("updated desc"),
|
||||
Some("line 2"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.description, "updated desc");
|
||||
assert!(runtime.activation_payload("demo-skill").unwrap().contains("line 2"));
|
||||
|
||||
let deleted_path = runtime
|
||||
.delete_skill(SkillScope::Project, "demo-skill", true)
|
||||
.unwrap();
|
||||
assert!(!deleted_path.exists());
|
||||
assert_eq!(runtime.len(), 0);
|
||||
|
||||
std::env::set_current_dir(previous).unwrap();
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,16 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bus::ChatMessage;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillEventRecord {
|
||||
pub id: String,
|
||||
pub session_id: Option<String>,
|
||||
pub event_type: String,
|
||||
pub skill_name: Option<String>,
|
||||
pub payload: serde_json::Value,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StorageError {
|
||||
#[error("database error: {0}")]
|
||||
@ -97,6 +107,21 @@ impl SessionStore {
|
||||
ON messages(session_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_created
|
||||
ON messages(session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
skill_name TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_events_session_created
|
||||
ON skill_events(session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_events_type_created
|
||||
ON skill_events(event_type, created_at DESC);
|
||||
",
|
||||
)?;
|
||||
|
||||
@ -331,6 +356,67 @@ impl SessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_skill_event(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
event_type: &str,
|
||||
skill_name: Option<&str>,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<(), StorageError> {
|
||||
let conn = self.conn.lock().expect("session db mutex poisoned");
|
||||
conn.execute(
|
||||
"
|
||||
INSERT INTO skill_events (
|
||||
id, session_id, event_type, skill_name, payload_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
",
|
||||
params![
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
session_id,
|
||||
event_type,
|
||||
skill_name,
|
||||
serde_json::to_string(payload)?,
|
||||
current_timestamp(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_skill_events(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Vec<SkillEventRecord>, StorageError> {
|
||||
let conn = self.conn.lock().expect("session db mutex poisoned");
|
||||
let sql = if session_id.is_some() {
|
||||
"
|
||||
SELECT id, session_id, event_type, skill_name, payload_json, created_at
|
||||
FROM skill_events
|
||||
WHERE session_id = ?1
|
||||
ORDER BY created_at ASC
|
||||
"
|
||||
} else {
|
||||
"
|
||||
SELECT id, session_id, event_type, skill_name, payload_json, created_at
|
||||
FROM skill_events
|
||||
WHERE session_id IS NULL
|
||||
ORDER BY created_at ASC
|
||||
"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = if let Some(session_id) = session_id {
|
||||
stmt.query_map(params![session_id], map_skill_event_record)?
|
||||
} else {
|
||||
stmt.query_map([], map_skill_event_record)?
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
for row in rows {
|
||||
events.push(row?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub fn load_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
|
||||
let conn = self.conn.lock().expect("session db mutex poisoned");
|
||||
let cutoff_seq = active_reset_cutoff(&conn, session_id)?;
|
||||
@ -373,6 +459,26 @@ fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord
|
||||
})
|
||||
}
|
||||
|
||||
fn map_skill_event_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillEventRecord> {
|
||||
let payload_json: String = row.get(4)?;
|
||||
let payload = serde_json::from_str(&payload_json).map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
4,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(err),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(SkillEventRecord {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
event_type: row.get(2)?,
|
||||
skill_name: row.get(3)?,
|
||||
payload,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_sessions_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
if !has_column(conn, "sessions", "reset_cutoff_seq")? {
|
||||
conn.execute(
|
||||
@ -657,4 +763,38 @@ mod tests {
|
||||
assert_eq!(messages[0].tool_name.as_deref(), Some("file_write"));
|
||||
assert!(messages[0].tool_calls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_events_roundtrip() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
let session = store.create_cli_session(Some("skill-events")).unwrap();
|
||||
|
||||
store
|
||||
.append_skill_event(
|
||||
None,
|
||||
"discovered",
|
||||
None,
|
||||
&serde_json::json!({"count": 2}),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.append_skill_event(
|
||||
Some(&session.id),
|
||||
"activated",
|
||||
Some("code-review"),
|
||||
&serde_json::json!({"source": "project"}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global_events = store.list_skill_events(None).unwrap();
|
||||
assert_eq!(global_events.len(), 1);
|
||||
assert_eq!(global_events[0].event_type, "discovered");
|
||||
assert_eq!(global_events[0].payload["count"], 2);
|
||||
|
||||
let session_events = store.list_skill_events(Some(&session.id)).unwrap();
|
||||
assert_eq!(session_events.len(), 1);
|
||||
assert_eq!(session_events[0].event_type, "activated");
|
||||
assert_eq!(session_events[0].skill_name.as_deref(), Some("code-review"));
|
||||
assert_eq!(session_events[0].payload["source"], "project");
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ pub mod file_write;
|
||||
pub mod http_request;
|
||||
pub mod registry;
|
||||
pub mod schema;
|
||||
pub mod skill_manage;
|
||||
pub mod traits;
|
||||
pub mod web_fetch;
|
||||
|
||||
@ -17,5 +18,6 @@ pub use file_write::FileWriteTool;
|
||||
pub use http_request::HttpRequestTool;
|
||||
pub use registry::ToolRegistry;
|
||||
pub use schema::{CleaningStrategy, SchemaCleanr};
|
||||
pub use skill_manage::{SkillListTool, SkillManageTool};
|
||||
pub use traits::{Tool, ToolResult};
|
||||
pub use web_fetch::WebFetchTool;
|
||||
|
||||
249
src/tools/skill_manage.rs
Normal file
249
src/tools/skill_manage.rs
Normal file
@ -0,0 +1,249 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::skills::{SkillRuntime, SkillScope};
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
|
||||
pub struct SkillManageTool {
|
||||
skills: Arc<SkillRuntime>,
|
||||
}
|
||||
|
||||
pub struct SkillListTool {
|
||||
skills: Arc<SkillRuntime>,
|
||||
}
|
||||
|
||||
impl SkillManageTool {
|
||||
pub fn new(skills: Arc<SkillRuntime>) -> Self {
|
||||
Self { skills }
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillListTool {
|
||||
pub fn new(skills: Arc<SkillRuntime>) -> Self {
|
||||
Self { skills }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillManageTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_manage"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Manage PicoBot skills stored under .picobot/skills or ~/.picobot/skills. Supports actions: list, get, create, update, delete, reload."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "get", "create", "update", "delete", "reload"],
|
||||
"description": "Management action to perform"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["project", "user"],
|
||||
"description": "Skill scope for create/update/delete. Defaults to project."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Skill name"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Skill description used for discovery"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Skill body instructions"
|
||||
},
|
||||
"reload": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to reload the runtime catalog after mutation. Defaults to true.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let action = match args.get("action").and_then(|v| v.as_str()) {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
return Ok(error_result("Missing required parameter: action"));
|
||||
}
|
||||
};
|
||||
|
||||
let reload = args.get("reload").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let scope = match args.get("scope").and_then(|v| v.as_str()) {
|
||||
Some(value) => match SkillScope::parse(value) {
|
||||
Some(scope) => scope,
|
||||
None => return Ok(error_result("scope must be 'project' or 'user'")),
|
||||
},
|
||||
None => SkillScope::Project,
|
||||
};
|
||||
|
||||
let name = args.get("name").and_then(|v| v.as_str());
|
||||
|
||||
let result = match action {
|
||||
"list" => {
|
||||
list_skills_payload(&self.skills)
|
||||
}
|
||||
"get" => {
|
||||
let name = match name {
|
||||
Some(name) => name,
|
||||
None => return Ok(error_result("Missing required parameter: name")),
|
||||
};
|
||||
|
||||
match self.skills.get_skill(name) {
|
||||
Some(skill) => json!({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"body": skill.body,
|
||||
"source": match skill.source {
|
||||
crate::skills::SkillSource::User => "user",
|
||||
crate::skills::SkillSource::Project => "project",
|
||||
},
|
||||
"path": skill.path.display().to_string(),
|
||||
}),
|
||||
None => return Ok(error_result(&format!("skill '{}' not found", name))),
|
||||
}
|
||||
}
|
||||
"create" => {
|
||||
let name = match name {
|
||||
Some(name) => name,
|
||||
None => return Ok(error_result("Missing required parameter: name")),
|
||||
};
|
||||
let description = match args.get("description").and_then(|v| v.as_str()) {
|
||||
Some(value) => value,
|
||||
None => return Ok(error_result("Missing required parameter: description")),
|
||||
};
|
||||
let body = args.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match self.skills.create_skill(scope, name, description, body, reload) {
|
||||
Ok(skill) => json!({
|
||||
"status": "created",
|
||||
"name": skill.name,
|
||||
"path": skill.path.display().to_string(),
|
||||
"scope": scope.as_str(),
|
||||
"reloaded": reload,
|
||||
}),
|
||||
Err(err) => return Ok(error_result(&err)),
|
||||
}
|
||||
}
|
||||
"update" => {
|
||||
let name = match name {
|
||||
Some(name) => name,
|
||||
None => return Ok(error_result("Missing required parameter: name")),
|
||||
};
|
||||
let description = args.get("description").and_then(|v| v.as_str());
|
||||
let body = args.get("body").and_then(|v| v.as_str());
|
||||
if description.is_none() && body.is_none() {
|
||||
return Ok(error_result("update requires description or body"));
|
||||
}
|
||||
|
||||
match self.skills.update_skill(scope, name, description, body, reload) {
|
||||
Ok(skill) => json!({
|
||||
"status": "updated",
|
||||
"name": skill.name,
|
||||
"path": skill.path.display().to_string(),
|
||||
"scope": scope.as_str(),
|
||||
"reloaded": reload,
|
||||
}),
|
||||
Err(err) => return Ok(error_result(&err)),
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
let name = match name {
|
||||
Some(name) => name,
|
||||
None => return Ok(error_result("Missing required parameter: name")),
|
||||
};
|
||||
|
||||
match self.skills.delete_skill(scope, name, reload) {
|
||||
Ok(path) => json!({
|
||||
"status": "deleted",
|
||||
"name": name,
|
||||
"path": path.display().to_string(),
|
||||
"scope": scope.as_str(),
|
||||
"reloaded": reload,
|
||||
}),
|
||||
Err(err) => return Ok(error_result(&err)),
|
||||
}
|
||||
}
|
||||
"reload" => match self.skills.reload() {
|
||||
Ok(catalog) => json!({
|
||||
"status": "reloaded",
|
||||
"count": catalog.len(),
|
||||
}),
|
||||
Err(err) => return Ok(error_result(&err)),
|
||||
},
|
||||
_ => return Ok(error_result("Unsupported action")),
|
||||
};
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillListTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_list"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List currently discovered PicoBot skills as a read-only operation. Use this when you only need to inspect available skills without modifying them."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn read_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&list_skills_payload(&self.skills))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn error_result(message: &str) -> ToolResult {
|
||||
ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_skills_payload(skills: &Arc<SkillRuntime>) -> serde_json::Value {
|
||||
let skills = skills.list_skills();
|
||||
json!({
|
||||
"count": skills.len(),
|
||||
"skills": skills.into_iter().map(|skill| json!({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"source": match skill.source {
|
||||
crate::skills::SkillSource::User => "user",
|
||||
crate::skills::SkillSource::Project => "project",
|
||||
},
|
||||
"path": skill.path.display().to_string(),
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user