feat: add runtime skill management and persistence

This commit is contained in:
ooodc 2026-04-21 18:21:14 +08:00
parent 393d980742
commit 0c724e37bb
15 changed files with 1356 additions and 23 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ reference/**
AGENTS.md
CLAUDE.md
Cargo.lock
.playwright-cli/

View File

@ -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"] }

View File

@ -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
}
}

View File

@ -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)]

View File

@ -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)]

View File

@ -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();

View File

@ -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

View File

@ -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())?;

View File

@ -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
View 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();
}
}

View File

@ -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");
}
}

View File

@ -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
View 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<_>>()
})
}