Compare commits

..

6 Commits

Author SHA1 Message Date
c11eb348f9 Refactor: Make AgentLoop stateless, clean up architecture 2026-04-27 23:23:10 +08:00
1abac85034 集成技能功能到系统提示词框架
- 在 AgentLoop 中添加 SkillsLoader 支持\n- 在系统提示词构建中集成技能提示\n- 更新 Session 以传递 SkillsLoader\n- 修复所有编译错误和测试问题
2026-04-27 23:04:24 +08:00
8226e8429d Merge remote-tracking branch 'origin/main' 2026-04-27 22:47:37 +08:00
ac2333900a 重构: 添加技能加载和获取工具,优化技能管理 2026-04-26 23:35:06 +08:00
401a7b6473 初步实现skill 2026-04-26 23:18:23 +08:00
98259a7770 重构: 添加斜杠命令支持和命令菜单功能 2026-04-26 22:09:04 +08:00
15 changed files with 983 additions and 214 deletions

View File

@ -51,14 +51,17 @@ Channel → MessageBus → SessionManager → AgentLoop → (tools) → SessionM
| `session` | Conversation session lifecycle, dialog operations | `SessionManager`, `Session` | | `session` | Conversation session lifecycle, dialog operations | `SessionManager`, `Session` |
| `agent` | LLM call loop, tool execution, context compression | `AgentLoop` | | `agent` | LLM call loop, tool execution, context compression | `AgentLoop` |
| `providers` | LLM API clients (OpenAI-compatible, Anthropic) | `LLMProvider` trait, factory `create_provider()` | | `providers` | LLM API clients (OpenAI-compatible, Anthropic) | `LLMProvider` trait, factory `create_provider()` |
| `tools` | Agent tools (bash, file operations, http, web) | `ToolRegistry`, `Tool` trait | | `tools` | Agent tools (bash, file operations, http, web, get_skill) | `ToolRegistry`, `Tool` trait |
| `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` |
### Functional Boundaries ### Functional Boundaries
- **Channels** only send/receive messages via `MessageBus`; they know nothing about sessions or LLM - **Channels** only send/receive messages via `MessageBus`; they know nothing about sessions or LLM
- **MessageBus** is a pure async queue; it routes nothing, just passes messages - **MessageBus** is a pure async queue; it routes nothing, just passes messages
- **SessionManager** owns session state and dialog operations; it does NOT call LLM directly - **SessionManager** owns session state and dialog operations; it does NOT call LLM directly
- SessionManager is responsible for injecting skills prompt into conversation history
- **AgentLoop** receives dialog events from `SessionManager`, calls LLM via `providers`, executes tools, returns text responses - **AgentLoop** receives dialog events from `SessionManager`, calls LLM via `providers`, executes tools, returns text responses
- AgentLoop is stateless; all state is managed by Session/SessionManager
- **Providers** are pure HTTP clients; no bus/session/channel awareness - **Providers** are pure HTTP clients; no bus/session/channel awareness
- **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results - **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results
@ -70,4 +73,4 @@ Channel → MessageBus → SessionManager → AgentLoop → (tools) → SessionM
## Known Issues ## Known Issues
- `src/session/session.rs:657``LLMProviderConfig` struct requires `workspace_dir` but test helper at line 656-669 doesn't provide it; test code needs `workspace_dir: PathBuf::new()` added - (No known issues at this time)

View File

@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage}; use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId}; use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId};
use crate::protocol::{parse_inbound, WsInbound, WsOutbound}; use crate::protocol::{parse_inbound, WsInbound, WsOutbound, SlashCommandInfo};
use super::base::{Channel, ChannelError}; use super::base::{Channel, ChannelError};
use super::slash_command::parse_slash_command; use super::slash_command::parse_slash_command;
@ -435,6 +435,43 @@ impl CliChatChannel {
} }
} }
} }
WsInbound::GetSlashCommands => {
// Get commands from session manager via control message
let (reply_tx, mut reply_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage {
op: SessionCommand::GetSlashCommands {
channel: "cli_chat".to_string(),
chat_id: "".to_string(),
},
reply_tx,
}).await?;
if let Some(result) = reply_rx.recv().await {
match result {
Ok(SessionEvent::SlashCommandsList { commands }) => {
// Convert to SlashCommand to SlashCommandInfo
let command_infos: Vec<SlashCommandInfo> = commands.into_iter().map(|cmd| {
SlashCommandInfo {
name: cmd.name.to_string(),
description: cmd.description.to_string(),
aliases: cmd.aliases.iter().map(|&a| a.to_string()).collect(),
}
}).collect();
let _ = client.sender.send(WsOutbound::SlashCommandsList { commands: command_infos }).await;
}
Ok(SessionEvent::Error { code, message }) => {
let _ = client.sender.send(WsOutbound::Error { code, message }).await;
}
Err(e) => {
let _ = client.sender.send(WsOutbound::Error {
code: "GET_COMMANDS_ERROR".to_string(),
message: e.to_string()
}).await;
}
_ => {}
}
}
}
WsInbound::Ping => { WsInbound::Ping => {
let _ = client.sender.send(WsOutbound::Pong).await; let _ = client.sender.send(WsOutbound::Pong).await;
} }

View File

@ -12,7 +12,7 @@ use tokio::sync::{broadcast, RwLock};
use crate::bus::{MessageBus, MediaItem, OutboundMessage}; use crate::bus::{MessageBus, MediaItem, OutboundMessage};
use crate::channels::base::{Channel, ChannelError}; use crate::channels::base::{Channel, ChannelError};
use crate::config::{FeishuChannelConfig, LLMProviderConfig}; use crate::config::FeishuChannelConfig;
const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis"; const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis";
const FEISHU_WS_BASE: &str = "https://open.feishu.cn"; const FEISHU_WS_BASE: &str = "https://open.feishu.cn";

View File

@ -10,7 +10,7 @@ use crossterm::{
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use futures_util::StreamExt; use futures_util::{SinkExt, StreamExt};
use ratatui::{prelude::CrosstermBackend, Terminal}; use ratatui::{prelude::CrosstermBackend, Terminal};
use std::io; use std::io;
use tokio_tungstenite::{connect_async, tungstenite::Message}; use tokio_tungstenite::{connect_async, tungstenite::Message};
@ -25,7 +25,6 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
app.ws_sender = Some(ws_sender); app.ws_sender = Some(ws_sender);
app.ws_receiver = Some(ws_receiver); app.ws_receiver = Some(ws_receiver);
// 初始化终端
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen)?;
@ -35,7 +34,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let result = run_app(&mut terminal, app).await; let result = run_app(&mut terminal, app).await;
// 清理终端 - 确保正确的顺序,忽略错误 // Cleanup terminal, ignore errors
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = terminal.show_cursor(); let _ = terminal.show_cursor();
@ -50,6 +49,14 @@ async fn run_app(
let mut ws_receiver = app.ws_receiver.take().unwrap(); let mut ws_receiver = app.ws_receiver.take().unwrap();
let mut event_reader = event::EventStream::new(); let mut event_reader = event::EventStream::new();
// Request command list on startup
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::GetSlashCommands;
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
}
loop { loop {
terminal.draw(|f| render_ui(f, &app))?; terminal.draw(|f| render_ui(f, &app))?;
@ -58,7 +65,7 @@ async fn run_app(
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) { if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
handle_ws_message(&mut app, outbound); handle_ws_message(&mut app, outbound).await;
} }
} }
Some(Ok(Message::Close(_))) | None => { Some(Ok(Message::Close(_))) | None => {
@ -83,7 +90,7 @@ async fn run_app(
Ok(()) Ok(())
} }
fn handle_ws_message(app: &mut App, outbound: WsOutbound) { async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
match outbound { match outbound {
WsOutbound::AssistantResponse { content, .. } => { WsOutbound::AssistantResponse { content, .. } => {
app.add_message(MessageRole::Assistant, content); app.add_message(MessageRole::Assistant, content);
@ -116,6 +123,12 @@ fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
WsOutbound::HistoryCleared { .. } => { WsOutbound::HistoryCleared { .. } => {
app.messages.clear(); app.messages.clear();
} }
_ => {} WsOutbound::SlashCommandsList { commands } => {
app.set_commands(commands);
}
WsOutbound::Pong => {}
WsOutbound::CommandExecuted { message } => {
app.add_message(MessageRole::System, message);
}
} }
} }

View File

@ -1,4 +1,4 @@
use crate::protocol::SessionSummary; use crate::protocol::{SessionSummary, SlashCommandInfo};
use std::collections::VecDeque; use std::collections::VecDeque;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
@ -53,6 +53,11 @@ pub struct App {
pub chat_scroll_offset: u16, pub chat_scroll_offset: u16,
pub session_scroll_offset: u16, pub session_scroll_offset: u16,
pub should_quit: bool, pub should_quit: bool,
// Command menu state
pub commands: Vec<SlashCommandInfo>,
pub show_command_menu: bool,
pub selected_command_idx: u16,
} }
impl App { impl App {
@ -71,6 +76,9 @@ impl App {
chat_scroll_offset: 0, chat_scroll_offset: 0,
session_scroll_offset: 0, session_scroll_offset: 0,
should_quit: false, should_quit: false,
commands: Vec::new(),
show_command_menu: false,
selected_command_idx: 0,
} }
} }
@ -152,4 +160,57 @@ impl App {
pub fn quit(&mut self) { pub fn quit(&mut self) {
self.should_quit = true; self.should_quit = true;
} }
// Command menu methods
pub fn set_commands(&mut self, commands: Vec<SlashCommandInfo>) {
self.commands = commands;
}
pub fn get_filtered_commands(&self) -> Vec<&SlashCommandInfo> {
let input_lower = self.input.to_lowercase();
self.commands
.iter()
.filter(|cmd| {
cmd.name.to_lowercase().contains(&input_lower)
|| cmd.description.to_lowercase().contains(&input_lower)
|| cmd
.aliases
.iter()
.any(|a| a.to_lowercase().contains(&input_lower))
})
.collect()
}
pub fn select_next_command(&mut self) {
let filtered = self.get_filtered_commands();
if !filtered.is_empty() {
self.selected_command_idx = (self.selected_command_idx + 1) % filtered.len() as u16;
}
}
pub fn select_prev_command(&mut self) {
let filtered = self.get_filtered_commands();
if !filtered.is_empty() {
self.selected_command_idx = if self.selected_command_idx == 0 {
filtered.len() as u16 - 1
} else {
self.selected_command_idx - 1
};
}
}
pub fn get_selected_command(&self) -> Option<&SlashCommandInfo> {
let filtered = self.get_filtered_commands();
filtered.get(self.selected_command_idx as usize).copied()
}
pub fn insert_command(&mut self) {
if let Some(cmd) = self.get_selected_command() {
// Use the first alias as the command to insert
if let Some(alias) = cmd.aliases.first() {
self.input = alias.clone();
self.input_cursor_pos = self.input.len();
}
}
}
} }

View File

@ -0,0 +1,52 @@
use crate::client::tui::app::App;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let filtered = app.get_filtered_commands();
if filtered.is_empty() {
return;
}
let items: Vec<ListItem> = filtered
.iter()
.enumerate()
.map(|(i, cmd)| {
let is_selected = i == app.selected_command_idx as usize;
let style = if is_selected {
Style::default()
.fg(Color::White)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
// Show first alias as the command
let alias = cmd.aliases.first().map(|a| a.as_str()).unwrap_or(&cmd.name);
ListItem::new(Line::from(vec![
Span::styled(alias, style.clone()),
Span::styled(" - ", Style::default().fg(Color::Gray)),
Span::styled(&cmd.description, style),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title("Commands")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(list, area);
}

View File

@ -1,4 +1,5 @@
pub mod chat_history; pub mod chat_history;
pub mod command_menu;
pub mod help_popup; pub mod help_popup;
pub mod input_area; pub mod input_area;
pub mod session_list; pub mod session_list;

View File

@ -15,6 +15,38 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
return; return;
} }
if app.show_command_menu {
match key.code {
KeyCode::Esc => {
app.show_command_menu = false;
app.selected_command_idx = 0;
}
KeyCode::Up => {
app.select_prev_command();
}
KeyCode::Down => {
app.select_next_command();
}
KeyCode::Enter => {
app.insert_command();
app.show_command_menu = false;
app.selected_command_idx = 0;
}
KeyCode::Tab => {
app.insert_command();
}
_ => {
// Handle normal input and check if menu should stay open
handle_normal_input(app, key).await;
}
}
return;
}
handle_normal_input(app, key).await;
}
async fn handle_normal_input(app: &mut App, key: KeyEvent) {
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => { KeyCode::Esc | KeyCode::Char('q') => {
app.quit(); app.quit();
@ -24,9 +56,23 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
app.input_insert_char(c); app.input_insert_char(c);
// Show command menu when input starts with /
if !app.show_command_menu && (app.input == "/" || (app.input.len() > 1 && app.input.starts_with('/'))) {
app.show_command_menu = true;
app.selected_command_idx = 0;
} else if app.show_command_menu && !app.input.starts_with('/') {
app.show_command_menu = false;
}
} }
KeyCode::Backspace => { KeyCode::Backspace => {
app.input_delete_char(); app.input_delete_char();
// Hide menu if input no longer starts with /
if app.show_command_menu && !app.input.starts_with('/') {
app.show_command_menu = false;
app.selected_command_idx = 0;
}
} }
KeyCode::Left => { KeyCode::Left => {
app.input_move_cursor_left(); app.input_move_cursor_left();
@ -48,6 +94,8 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
} }
KeyCode::Enter => { KeyCode::Enter => {
let input = app.take_input(); let input = app.take_input();
app.show_command_menu = false;
app.selected_command_idx = 0;
if !input.is_empty() { if !input.is_empty() {
process_input(app, input).await; process_input(app, input).await;
} }
@ -57,94 +105,6 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
} }
async fn process_input(app: &mut App, input: String) { async fn process_input(app: &mut App, input: String) {
let trimmed = input.trim();
if let Some(cmd) = parse_command(trimmed) {
match cmd {
InputCommand::Quit => {
app.quit();
}
InputCommand::Help => {
app.toggle_help();
}
InputCommand::Clear => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::ClearHistory {
chat_id: None,
session_id: Some(session_id.clone()),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
InputCommand::New(title) => {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::CreateSession { title };
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
InputCommand::Sessions => {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::ListSessions {
include_archived: true,
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
InputCommand::Use(session_id) => {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::LoadSession { session_id };
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
InputCommand::Rename(title) => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::RenameSession {
session_id: Some(session_id.clone()),
title,
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
InputCommand::Archive => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::ArchiveSession {
session_id: Some(session_id.clone()),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
InputCommand::Delete => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::DeleteSession {
session_id: Some(session_id.clone()),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
}
} else {
app.add_message(MessageRole::User, input.clone()); app.add_message(MessageRole::User, input.clone());
if let Some(sender) = &mut app.ws_sender { if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::UserInput { let inbound = WsInbound::UserInput {
@ -158,39 +118,3 @@ async fn process_input(app: &mut App, input: String) {
} }
} }
} }
}
enum InputCommand {
Quit,
Help,
Clear,
New(Option<String>),
Sessions,
Use(String),
Rename(String),
Archive,
Delete,
}
fn parse_command(input: &str) -> Option<InputCommand> {
if !input.starts_with('/') {
return None;
}
let parts: Vec<&str> = input.splitn(2, char::is_whitespace).collect();
let cmd = parts[0];
let arg = parts.get(1).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
match cmd {
"/quit" | "/exit" | "/q" => Some(InputCommand::Quit),
"/help" | "/?" => Some(InputCommand::Help),
"/clear" | "/reset" => Some(InputCommand::Clear),
"/new" => Some(InputCommand::New(arg)),
"/sessions" => Some(InputCommand::Sessions),
"/use" => arg.map(InputCommand::Use),
"/rename" => arg.map(InputCommand::Rename),
"/archive" => Some(InputCommand::Archive),
"/delete" => Some(InputCommand::Delete),
_ => None,
}
}

View File

@ -1,12 +1,10 @@
use crate::client::tui::app::App; use crate::client::tui::app::App;
use crate::client::tui::components::*;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame, Frame,
}; };
use super::components::*;
pub fn render_ui(f: &mut Frame, app: &App) { pub fn render_ui(f: &mut Frame, app: &App) {
let size = f.size(); let size = f.size();
let chunks = Layout::default() let chunks = Layout::default()
@ -30,9 +28,27 @@ pub fn render_ui(f: &mut Frame, app: &App) {
input_area::render(f, chunks[2], app); input_area::render(f, chunks[2], app);
// Render command menu if needed - position above input area
if app.show_command_menu && !app.get_filtered_commands().is_empty() {
let menu_area = menu_above_input(chunks[2]);
command_menu::render(f, menu_area, app);
}
if app.show_help { if app.show_help {
let area = centered_rect(60, 60, size); let help_area = centered_rect(60, 60, size);
help_popup::render(f, area); help_popup::render(f, help_area);
}
}
fn menu_above_input(input_area: Rect) -> Rect {
let max_commands = 6; // Show up to 6 commands
let menu_height = max_commands + 2; // +2 for borders
Rect {
x: input_area.x + 1,
y: input_area.y.saturating_sub(menu_height),
width: input_area.width.saturating_sub(2),
height: menu_height,
} }
} }

View File

@ -10,4 +10,5 @@ pub mod channels;
pub mod logging; pub mod logging;
pub mod observability; pub mod observability;
pub mod storage; pub mod storage;
pub mod skills;
pub mod tools; pub mod tools;

View File

@ -12,6 +12,13 @@ pub struct SessionSummary {
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlashCommandInfo {
pub name: String,
pub description: String,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum WsInbound { pub enum WsInbound {
@ -43,9 +50,7 @@ pub enum WsInbound {
include_archived: bool, include_archived: bool,
}, },
#[serde(rename = "load_session")] #[serde(rename = "load_session")]
LoadSession { LoadSession { session_id: String },
session_id: String,
},
#[serde(rename = "rename_session")] #[serde(rename = "rename_session")]
RenameSession { RenameSession {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -62,6 +67,8 @@ pub enum WsInbound {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>, session_id: Option<String>,
}, },
#[serde(rename = "get_slash_commands")]
GetSlashCommands,
#[serde(rename = "ping")] #[serde(rename = "ping")]
Ping, Ping,
} }
@ -70,7 +77,11 @@ pub enum WsInbound {
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum WsOutbound { pub enum WsOutbound {
#[serde(rename = "assistant_response")] #[serde(rename = "assistant_response")]
AssistantResponse { id: String, content: String, role: String }, AssistantResponse {
id: String,
content: String,
role: String,
},
#[serde(rename = "error")] #[serde(rename = "error")]
Error { code: String, message: String }, Error { code: String, message: String },
#[serde(rename = "session_established")] #[serde(rename = "session_established")]
@ -97,6 +108,8 @@ pub enum WsOutbound {
SessionDeleted { session_id: String }, SessionDeleted { session_id: String },
#[serde(rename = "history_cleared")] #[serde(rename = "history_cleared")]
HistoryCleared { session_id: String }, HistoryCleared { session_id: String },
#[serde(rename = "slash_commands_list")]
SlashCommandsList { commands: Vec<SlashCommandInfo> },
#[serde(rename = "pong")] #[serde(rename = "pong")]
Pong, Pong,
#[serde(rename = "command_executed")] #[serde(rename = "command_executed")]

View File

@ -1,19 +1,23 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use uuid::Uuid; use uuid::Uuid;
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::agent::{AgentLoop, AgentError, ContextCompressor}; use crate::agent::{AgentLoop, AgentError, ContextCompressor};
use crate::agent::context_compressor::ContextCompressionConfig;
use crate::protocol::WsOutbound; use crate::protocol::WsOutbound;
use crate::providers::{create_provider, LLMProvider}; use crate::providers::{create_provider, LLMProvider};
use crate::session::session_id::{UnifiedSessionId, DEFAULT_DIALOG_ID}; use crate::session::session_id::{UnifiedSessionId, DEFAULT_DIALOG_ID};
use crate::session::events::DialogInfo; use crate::session::events::DialogInfo;
use crate::skills::SkillsLoader;
use crate::storage::{SessionRecord, SessionStore}; use crate::storage::{SessionRecord, SessionStore};
use crate::tools::{ use crate::tools::{
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
HttpRequestTool, ToolRegistry, WebFetchTool, GetSkillTool, HttpRequestTool, ToolRegistry, WebFetchTool,
}; };
/// Generate a short ID (8 characters) from a UUID /// Generate a short ID (8 characters) from a UUID
@ -46,6 +50,11 @@ impl Session {
.map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?; .map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?;
let provider: Arc<dyn LLMProvider> = Arc::from(provider_box); let provider: Arc<dyn LLMProvider> = Arc::from(provider_box);
let compressor_config = ContextCompressionConfig {
protect_first_n: 2,
..Default::default()
};
Ok(Self { Ok(Self {
id, id,
messages: Vec::new(), messages: Vec::new(),
@ -53,7 +62,7 @@ impl Session {
provider_config: provider_config.clone(), provider_config: provider_config.clone(),
provider: provider.clone(), provider: provider.clone(),
tools, tools,
compressor: ContextCompressor::new(provider.clone(), provider_config.token_limit), compressor: ContextCompressor::with_config(provider.clone(), provider_config.token_limit, compressor_config),
store, store,
}) })
} }
@ -180,6 +189,7 @@ pub struct SessionManager {
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
skills_loader: Arc<SkillsLoader>,
} }
struct SessionManagerInner { struct SessionManagerInner {
@ -189,7 +199,7 @@ struct SessionManagerInner {
session_ttl: Duration, session_ttl: Duration,
} }
fn default_tools() -> ToolRegistry { fn create_default_tools(skills_loader: Arc<SkillsLoader>) -> ToolRegistry {
let mut registry = ToolRegistry::new(); let mut registry = ToolRegistry::new();
registry.register(CalculatorTool::new()); registry.register(CalculatorTool::new());
registry.register(FileReadTool::new()); registry.register(FileReadTool::new());
@ -197,12 +207,13 @@ fn default_tools() -> ToolRegistry {
registry.register(FileEditTool::new()); registry.register(FileEditTool::new());
registry.register(BashTool::new()); registry.register(BashTool::new());
registry.register(HttpRequestTool::new( registry.register(HttpRequestTool::new(
vec!["*".to_string()], // 允许所有域名,实际使用时建议限制 vec!["*".to_string()],
1_000_000, // max_response_size 1_000_000,
30, // timeout_secs 30,
false, // allow_private_hosts false,
)); ));
registry.register(WebFetchTool::new(50_000, 30)); // max_chars, timeout_secs registry.register(WebFetchTool::new(50_000, 30));
registry.register(GetSkillTool::new(skills_loader));
registry registry
} }
@ -241,6 +252,12 @@ impl SessionManager {
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?, .map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
); );
let skills_loader = SkillsLoader::new();
skills_loader.load_skills();
let skills_loader = Arc::new(skills_loader);
let tools = Arc::new(create_default_tools(skills_loader.clone()));
Ok(Self { Ok(Self {
inner: Arc::new(Mutex::new(SessionManagerInner { inner: Arc::new(Mutex::new(SessionManagerInner {
sessions: HashMap::new(), sessions: HashMap::new(),
@ -248,8 +265,9 @@ impl SessionManager {
session_ttl: Duration::from_secs(session_ttl_hours * 3600), session_ttl: Duration::from_secs(session_ttl_hours * 3600),
})), })),
provider_config, provider_config,
tools: Arc::new(default_tools()), tools,
store, store,
skills_loader,
}) })
} }
@ -271,7 +289,6 @@ impl SessionManager {
chat_id: &str, chat_id: &str,
current_session_id: Option<&UnifiedSessionId>, current_session_id: Option<&UnifiedSessionId>,
) -> Result<(Option<UnifiedSessionId>, String), AgentError> { ) -> Result<(Option<UnifiedSessionId>, String), AgentError> {
// 查找匹配的 command
let cmd = SLASH_COMMANDS let cmd = SLASH_COMMANDS
.iter() .iter()
.find(|c| c.name == command) .find(|c| c.name == command)
@ -279,7 +296,6 @@ impl SessionManager {
match cmd.name { match cmd.name {
"reset" => { "reset" => {
// Archive current session if exists
if let Some(sid) = current_session_id { if let Some(sid) = current_session_id {
let unified_str = sid.to_string(); let unified_str = sid.to_string();
self.store self.store
@ -287,7 +303,6 @@ impl SessionManager {
.map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?; .map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?;
} }
// Create new dialog
let (new_id, _title) = self.create_session(channel, chat_id, None).await?; let (new_id, _title) = self.create_session(channel, chat_id, None).await?;
Ok((Some(new_id), "Starting a fresh conversation...".to_string())) Ok((Some(new_id), "Starting a fresh conversation...".to_string()))
} }
@ -338,20 +353,15 @@ impl SessionManager {
pub fn clear_session_messages(&self, session_id: &str) -> Result<(), AgentError> { pub fn clear_session_messages(&self, session_id: &str) -> Result<(), AgentError> {
self.store self.store
.clear_messages(session_id) .clear_messages(session_id)
.map_err(|err| AgentError::Other(format!("clear session error: {}", err))) .map_err(|err| AgentError::Other(format!("clear session messages error: {}", err)))
} }
pub fn load_session_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, AgentError> { pub fn load_session_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, AgentError> {
self.store self.store
.load_messages(session_id) .load_messages(session_id)
.map_err(|err| AgentError::Other(format!("load messages error: {}", err))) .map_err(|err| AgentError::Other(format!("load session messages error: {}", err)))
} }
// =========================================================================
// Dialog management methods (UnifiedSessionId based)
// =========================================================================
/// Create a new session (dialog) and return (session_id, title)
pub async fn create_session( pub async fn create_session(
&self, &self,
channel: &str, channel: &str,
@ -368,12 +378,10 @@ impl SessionManager {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.unwrap_or_else(|| format!("Dialog {}", &dialog_id)); .unwrap_or_else(|| format!("Dialog {}", &dialog_id));
// Ensure storage record exists
self.store self.store
.ensure_channel_session(channel, chat_id, &dialog_id) .ensure_channel_session(channel, chat_id, &dialog_id)
.map_err(|err| AgentError::Other(format!("create session error: {}", err)))?; .map_err(|err| AgentError::Other(format!("create session error: {}", err)))?;
// Create session instance
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100); let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
let session = Session::new( let session = Session::new(
unified_id.clone(), unified_id.clone(),
@ -381,8 +389,7 @@ impl SessionManager {
user_tx, user_tx,
self.tools.clone(), self.tools.clone(),
self.store.clone(), self.store.clone(),
) ).await?;
.await?;
let arc = Arc::new(Mutex::new(session)); let arc = Arc::new(Mutex::new(session));
let inner = &mut *self.inner.lock().await; let inner = &mut *self.inner.lock().await;
@ -392,21 +399,16 @@ impl SessionManager {
Ok((unified_id, title)) Ok((unified_id, title))
} }
/// Get or create a session by UnifiedSessionId
pub async fn get_or_create_session(&self, unified_id: &UnifiedSessionId) -> Result<Arc<Mutex<Session>>, AgentError> { pub async fn get_or_create_session(&self, unified_id: &UnifiedSessionId) -> Result<Arc<Mutex<Session>>, AgentError> {
let session_id_str = unified_id.to_string(); let session_id_str = unified_id.to_string();
let inner = &mut *self.inner.lock().await; let inner = &mut *self.inner.lock().await;
// Check if session exists
if let Some(session) = inner.sessions.get(&session_id_str) { if let Some(session) = inner.sessions.get(&session_id_str) {
// Update timestamp
inner.session_timestamps.insert(session_id_str, Instant::now()); inner.session_timestamps.insert(session_id_str, Instant::now());
return Ok(session.clone()); return Ok(session.clone());
} }
// Check if session exists in storage
if let Ok(Some(_)) = self.store.get_session(&session_id_str) { if let Ok(Some(_)) = self.store.get_session(&session_id_str) {
// Create session instance from storage
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100); let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
let session = Session::new( let session = Session::new(
unified_id.clone(), unified_id.clone(),
@ -414,8 +416,7 @@ impl SessionManager {
user_tx, user_tx,
self.tools.clone(), self.tools.clone(),
self.store.clone(), self.store.clone(),
) ).await?;
.await?;
let arc = Arc::new(Mutex::new(session)); let arc = Arc::new(Mutex::new(session));
inner.sessions.insert(session_id_str.clone(), arc.clone()); inner.sessions.insert(session_id_str.clone(), arc.clone());
@ -423,7 +424,6 @@ impl SessionManager {
return Ok(arc); return Ok(arc);
} }
// Session doesn't exist - create new directly
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100); let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
let session = Session::new( let session = Session::new(
unified_id.clone(), unified_id.clone(),
@ -431,8 +431,7 @@ impl SessionManager {
user_tx, user_tx,
self.tools.clone(), self.tools.clone(),
self.store.clone(), self.store.clone(),
) ).await?;
.await?;
let arc = Arc::new(Mutex::new(session)); let arc = Arc::new(Mutex::new(session));
inner.sessions.insert(session_id_str.clone(), arc.clone()); inner.sessions.insert(session_id_str.clone(), arc.clone());
@ -440,7 +439,6 @@ impl SessionManager {
Ok(arc) Ok(arc)
} }
/// List all dialogs for a chat scope (internal)
async fn list_dialogs_for_chat( async fn list_dialogs_for_chat(
&self, &self,
channel: &str, channel: &str,
@ -454,7 +452,6 @@ impl SessionManager {
let dialogs: Vec<DialogInfo> = records let dialogs: Vec<DialogInfo> = records
.into_iter() .into_iter()
.filter(|r| { .filter(|r| {
// Filter to only dialogs for this chat_id
if let Some(sid) = UnifiedSessionId::parse(&r.id) { if let Some(sid) = UnifiedSessionId::parse(&r.id) {
sid.chat_id == chat_id sid.chat_id == chat_id
} else { } else {
@ -477,7 +474,6 @@ impl SessionManager {
Ok(dialogs) Ok(dialogs)
} }
/// Get the most recent dialog for a chat scope (from storage)
pub async fn get_most_recent_dialog( pub async fn get_most_recent_dialog(
&self, &self,
channel: &str, channel: &str,
@ -501,14 +497,12 @@ impl SessionManager {
Ok(most_recent.map(|r| UnifiedSessionId::parse(&r.id).unwrap())) Ok(most_recent.map(|r| UnifiedSessionId::parse(&r.id).unwrap()))
} }
/// Rename a dialog
pub fn rename_dialog(&self, session_id: &UnifiedSessionId, title: &str) -> Result<(), AgentError> { pub fn rename_dialog(&self, session_id: &UnifiedSessionId, title: &str) -> Result<(), AgentError> {
self.store self.store
.rename_session(&session_id.to_string(), title) .rename_session(&session_id.to_string(), title)
.map_err(|err| AgentError::Other(format!("rename dialog error: {}", err))) .map_err(|err| AgentError::Other(format!("rename dialog error: {}", err)))
} }
/// Create a new dialog (wrapper for create_session to match gateway interface)
pub async fn create_dialog( pub async fn create_dialog(
&self, &self,
channel: &str, channel: &str,
@ -518,7 +512,6 @@ impl SessionManager {
self.create_session(channel, chat_id, title).await self.create_session(channel, chat_id, title).await
} }
/// Get current dialog for a chat (wrapper for get_most_recent_dialog)
pub async fn get_current_dialog( pub async fn get_current_dialog(
&self, &self,
channel: &str, channel: &str,
@ -527,8 +520,6 @@ impl SessionManager {
self.get_most_recent_dialog(channel, chat_id).await self.get_most_recent_dialog(channel, chat_id).await
} }
/// Switch to a different dialog - not applicable in new architecture
/// Each Session IS a dialog, so switching is just loading that session
pub async fn switch_dialog( pub async fn switch_dialog(
&self, &self,
_channel: &str, _channel: &str,
@ -538,7 +529,6 @@ impl SessionManager {
Err(AgentError::Other("switch_dialog not applicable in new architecture".to_string())) Err(AgentError::Other("switch_dialog not applicable in new architecture".to_string()))
} }
/// List all dialogs for a chat scope (returns tuple for gateway compatibility)
pub async fn list_dialogs( pub async fn list_dialogs(
&self, &self,
channel: &str, channel: &str,
@ -550,28 +540,24 @@ impl SessionManager {
Ok((dialogs, current.map(|id| id.to_string()))) Ok((dialogs, current.map(|id| id.to_string())))
} }
/// Archive a dialog
pub fn archive_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> { pub fn archive_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
self.store self.store
.archive_session(&session_id.to_string()) .archive_session(&session_id.to_string())
.map_err(|err| AgentError::Other(format!("archive dialog error: {}", err))) .map_err(|err| AgentError::Other(format!("archive dialog error: {}", err)))
} }
/// Delete a dialog
pub fn delete_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> { pub fn delete_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
self.store self.store
.delete_session(&session_id.to_string()) .delete_session(&session_id.to_string())
.map_err(|err| AgentError::Other(format!("delete dialog error: {}", err))) .map_err(|err| AgentError::Other(format!("delete dialog error: {}", err)))
} }
/// Clear dialog history
pub fn clear_dialog_history(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> { pub fn clear_dialog_history(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
self.store self.store
.clear_messages(&session_id.to_string()) .clear_messages(&session_id.to_string())
.map_err(|err| AgentError::Other(format!("clear dialog history error: {}", err))) .map_err(|err| AgentError::Other(format!("clear dialog history error: {}", err)))
} }
/// 处理消息:路由到对应 session 的 agent
pub async fn handle_message( pub async fn handle_message(
&self, &self,
channel: &str, channel: &str,
@ -581,21 +567,14 @@ impl SessionManager {
content: &str, content: &str,
media: Vec<crate::bus::MediaItem>, media: Vec<crate::bus::MediaItem>,
) -> Result<String, AgentError> { ) -> Result<String, AgentError> {
// 确定 dialog_id
let dialog_id = dialog_id.unwrap_or(DEFAULT_DIALOG_ID); let dialog_id = dialog_id.unwrap_or(DEFAULT_DIALOG_ID);
// 获取或创建 session
let unified_id = UnifiedSessionId::new(channel, chat_id, dialog_id); let unified_id = UnifiedSessionId::new(channel, chat_id, dialog_id);
let session = self.get_or_create_session(&unified_id).await?; let session = self.get_or_create_session(&unified_id).await?;
// 处理消息
let response: String = { let response: String = {
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
// 确保 session 持久化记录存在
session_guard.ensure_persistent_session()?; session_guard.ensure_persistent_session()?;
// 添加用户消息到历史
let media_refs: Vec<String> = media.iter().map(|m| m.path.clone()).collect(); let media_refs: Vec<String> = media.iter().map(|m| m.path.clone()).collect();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
if !media_refs.is_empty() { if !media_refs.is_empty() {
@ -606,20 +585,24 @@ impl SessionManager {
session_guard.add_message(user_message.clone()); session_guard.add_message(user_message.clone());
session_guard.append_message(&user_message)?; session_guard.append_message(&user_message)?;
// 加载历史
session_guard.load_history()?; session_guard.load_history()?;
// 压缩历史(如果需要) let mut history = session_guard.get_history().to_vec();
let history = session_guard.get_history().to_vec();
let skills_prompt = self.skills_loader.build_skills_prompt();
if !skills_prompt.is_empty() {
let skills_message = ChatMessage::system(skills_prompt);
history.insert(0, skills_message);
tracing::debug!("Injected skills into context");
}
let history = session_guard.compressor let history = session_guard.compressor
.compress_if_needed(history) .compress_if_needed(history)
.await?; .await?;
// 创建 agent 并处理
let agent = session_guard.create_agent()?; let agent = session_guard.create_agent()?;
let result = agent.process(history).await?; let result = agent.process(history).await?;
// 持久化 assistant 消息
for msg in &result.emitted_messages { for msg in &result.emitted_messages {
session_guard.append_message(msg)?; session_guard.append_message(msg)?;
} }
@ -638,7 +621,6 @@ impl SessionManager {
Ok(response) Ok(response)
} }
/// 清除指定 session 的所有历史
pub async fn clear_session_history(&self, unified_id: &UnifiedSessionId) -> Result<(), AgentError> { pub async fn clear_session_history(&self, unified_id: &UnifiedSessionId) -> Result<(), AgentError> {
let session = self.get_or_create_session(unified_id).await?; let session = self.get_or_create_session(unified_id).await?;
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
@ -666,6 +648,7 @@ mod tests {
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
} }
} }
} }

504
src/skills/mod.rs Normal file
View File

@ -0,0 +1,504 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
/// Skill definition
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub content: String,
pub always: bool,
pub path: Option<PathBuf>,
}
struct SkillMarkdownMeta {
name: Option<String>,
description: Option<String>,
always: Option<bool>,
}
#[derive(Clone)]
struct SkillsState {
loaded_skills: Vec<Skill>,
last_picobot_mtime: Option<SystemTime>,
last_agent_mtime: Option<SystemTime>,
last_load_time: SystemTime,
}
impl Default for SkillsState {
fn default() -> Self {
Self {
loaded_skills: Vec::new(),
last_picobot_mtime: None,
last_agent_mtime: None,
last_load_time: SystemTime::now(),
}
}
}
/// Skills loader - loads skills from multiple directories
#[derive(Clone)]
pub struct SkillsLoader {
picobot_skills_dir: PathBuf,
agent_skills_dir: PathBuf,
state: Arc<Mutex<SkillsState>>,
}
impl SkillsLoader {
/// Create a new loader with default paths
pub fn new() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Self {
picobot_skills_dir: home.join(".picobot/skills"),
agent_skills_dir: home.join(".agent/skills"),
state: Arc::new(Mutex::new(SkillsState::default())),
}
}
#[cfg(test)]
pub(crate) fn new_for_testing(picobot_dir: PathBuf, agent_dir: PathBuf) -> Self {
Self {
picobot_skills_dir: picobot_dir,
agent_skills_dir: agent_dir,
state: Arc::new(Mutex::new(SkillsState::default())),
}
}
/// Load all skills from both directories and record modification times
pub fn load_skills(&self) {
let mut state = self.state.lock().unwrap();
state.loaded_skills.clear();
// Load from ~/.picobot/skills
if self.picobot_skills_dir.exists() {
let loaded = self.load_skills_from_dir(&self.picobot_skills_dir);
tracing::debug!(
dir = %self.picobot_skills_dir.display(),
count = loaded.len(),
"Loaded skills from picobot directory"
);
state.loaded_skills.extend(loaded);
state.last_picobot_mtime = Self::get_dir_mtime(&self.picobot_skills_dir);
}
// Load from ~/.agent/skills
if self.agent_skills_dir.exists() {
let loaded = self.load_skills_from_dir(&self.agent_skills_dir);
tracing::debug!(
dir = %self.agent_skills_dir.display(),
count = loaded.len(),
"Loaded skills from agent directory"
);
state.loaded_skills.extend(loaded);
state.last_agent_mtime = Self::get_dir_mtime(&self.agent_skills_dir);
}
state.last_load_time = SystemTime::now();
if state.loaded_skills.is_empty() {
tracing::debug!("No skills found in any skills directory");
} else {
tracing::info!(count = state.loaded_skills.len(), "Loaded {} skills total", state.loaded_skills.len());
}
}
/// Check if skills directories have been modified since last load
fn has_changed(&self) -> bool {
let state = self.state.lock().unwrap();
let picobot_changed = if self.picobot_skills_dir.exists() {
let current_mtime = Self::get_dir_mtime(&self.picobot_skills_dir);
current_mtime != state.last_picobot_mtime
} else {
false
};
let agent_changed = if self.agent_skills_dir.exists() {
let current_mtime = Self::get_dir_mtime(&self.agent_skills_dir);
current_mtime != state.last_agent_mtime
} else {
false
};
picobot_changed || agent_changed
}
/// Reload skills if changes are detected
pub fn reload_if_changed(&self) -> bool {
if self.has_changed() {
tracing::info!("Skills directories changed, reloading...");
self.load_skills();
true
} else {
false
}
}
/// Get the latest modification time of a directory or any of its children
fn get_dir_mtime(dir: &Path) -> Option<SystemTime> {
let mut max_mtime = None;
if let Ok(metadata) = std::fs::metadata(dir) {
if let Ok(mtime) = metadata.modified() {
max_mtime = Some(mtime);
}
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Ok(metadata) = std::fs::metadata(&path) {
if let Ok(mtime) = metadata.modified() {
if max_mtime.map_or(true, |current| mtime > current) {
max_mtime = Some(mtime);
}
}
}
}
}
max_mtime
}
/// Get a copy of loaded skills (checks for changes first)
pub fn get_loaded_skills(&self) -> Vec<Skill> {
self.reload_if_changed();
let state = self.state.lock().unwrap();
state.loaded_skills.clone()
}
/// Get skills marked as always (checks for changes first)
pub fn get_always_skills(&self) -> Vec<Skill> {
self.reload_if_changed();
let state = self.state.lock().unwrap();
state.loaded_skills.iter().filter(|s| s.always).cloned().collect()
}
/// Get a specific skill by name (checks for changes first)
pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.reload_if_changed();
let state = self.state.lock().unwrap();
state.loaded_skills.iter().find(|s| s.name == name).cloned()
}
/// List all skills (name + description) (checks for changes first)
pub fn list_skills(&self) -> Vec<(String, String)> {
self.reload_if_changed();
let state = self.state.lock().unwrap();
state.loaded_skills
.iter()
.map(|s| (s.name.clone(), s.description.clone()))
.collect()
}
/// Build XML summary of all skills (for progressive disclosure) (checks for changes first)
pub fn build_skills_summary(&self) -> String {
self.reload_if_changed();
let state = self.state.lock().unwrap();
if state.loaded_skills.is_empty() {
return String::new();
}
let mut lines = vec!["<skills>".to_string()];
for skill in &state.loaded_skills {
if skill.always {
continue;
}
lines.push(" <skill>".to_string());
lines.push(format!(" <name>{}</name>", escape_xml(&skill.name)));
lines.push(format!(
" <description>{}</description>",
escape_xml(&skill.description)
));
if let Some(path) = &skill.path {
lines.push(format!(" <path>{}</path>", escape_xml(&path.to_string_lossy())));
}
lines.push(" </skill>".to_string());
}
lines.push("</skills>".to_string());
lines.join("\n")
}
/// Build prompt for always-injected skills (checks for changes first)
pub fn build_always_skills_prompt(&self) -> String {
self.reload_if_changed();
let state = self.state.lock().unwrap();
let always_skills: Vec<_> = state.loaded_skills.iter().filter(|s| s.always).collect();
if always_skills.is_empty() {
return String::new();
}
let mut parts = Vec::new();
for skill in always_skills {
parts.push(format!("## Skill: {}\n\n{}", skill.name, skill.content));
}
parts.join("\n\n---\n\n")
}
/// Build full skills prompt combining always skills and summary (checks for changes first)
pub fn build_skills_prompt(&self) -> String {
self.reload_if_changed();
let state = self.state.lock().unwrap();
let mut prompt = String::new();
let always_skills: Vec<_> = state.loaded_skills.iter().filter(|s| s.always).collect();
if !always_skills.is_empty() {
let mut parts = Vec::new();
for skill in always_skills {
parts.push(format!("## Skill: {}\n\n{}", skill.name, skill.content));
}
prompt.push_str(&parts.join("\n\n---\n\n"));
prompt.push_str("\n\n");
}
let has_other_skills = state.loaded_skills.iter().any(|s| !s.always);
if has_other_skills {
prompt.push_str("## Available Skills\n\n");
prompt.push_str("Skills teach the agent how to use specific capabilities. Use the `get_skill` tool to load a skill's full content when needed.\n\n");
let mut lines = vec!["<skills>".to_string()];
for skill in &state.loaded_skills {
if skill.always {
continue;
}
lines.push(" <skill>".to_string());
lines.push(format!(" <name>{}</name>", escape_xml(&skill.name)));
lines.push(format!(
" <description>{}</description>",
escape_xml(&skill.description)
));
if let Some(path) = &skill.path {
lines.push(format!(" <path>{}</path>", escape_xml(&path.to_string_lossy())));
}
lines.push(" </skill>".to_string());
}
lines.push("</skills>".to_string());
prompt.push_str(&lines.join("\n"));
}
prompt
}
/// Load skills from a specific directory
fn load_skills_from_dir(&self, dir: &Path) -> Vec<Skill> {
let mut skills = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
tracing::warn!(dir = %dir.display(), "Failed to read skills directory");
return skills;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_file = path.join("SKILL.md");
if !skill_file.exists() {
continue;
}
match std::fs::read_to_string(&skill_file) {
Ok(content) => {
match self.parse_skill(&path, &content) {
Some(skill) => {
tracing::debug!(
skill = %skill.name,
path = %skill_file.display(),
always = skill.always,
"Loaded skill"
);
skills.push(skill);
}
None => {
tracing::warn!(
path = %skill_file.display(),
"Failed to parse skill"
);
}
}
}
Err(e) => {
tracing::warn!(
path = %skill_file.display(),
error = %e,
"Failed to read skill file"
);
}
}
}
skills
}
/// Parse a skill from markdown content
fn parse_skill(&self, dir: &Path, content: &str) -> Option<Skill> {
let (meta, body) = self.parse_skill_markdown(content);
let name = meta.name.or_else(|| {
dir.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})?;
let description = meta
.description
.unwrap_or_else(|| extract_description(&body));
Some(Skill {
name,
description,
content: body,
always: meta.always.unwrap_or(false),
path: Some(dir.to_path_buf()),
})
}
/// Parse skill markdown, extracting frontmatter and body
fn parse_skill_markdown(&self, content: &str) -> (SkillMarkdownMeta, String) {
let normalized = content.replace("\r\n", "\n");
if let Some(stripped) = normalized.strip_prefix("---\n") {
if let Some(idx) = stripped.find("\n---\n") {
let frontmatter = stripped[..idx].to_string();
let body = stripped[idx + 5..].trim().to_string();
let meta = self.parse_frontmatter(&frontmatter);
return (meta, body);
}
if let Some(frontmatter) = stripped.strip_suffix("\n---") {
return (self.parse_frontmatter(frontmatter), String::new());
}
}
(SkillMarkdownMeta::default(), normalized)
}
/// Parse simple YAML-like frontmatter
fn parse_frontmatter(&self, content: &str) -> SkillMarkdownMeta {
let mut meta = SkillMarkdownMeta::default();
for line in content.lines() {
let Some((key, val)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let val = val.trim().trim_matches('"').trim_matches('\'');
match key {
"name" => meta.name = Some(val.to_string()),
"description" => meta.description = Some(val.to_string()),
"always" => {
meta.always = match val.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
};
}
_ => {}
}
}
meta
}
}
impl Default for SkillsLoader {
fn default() -> Self {
Self::new()
}
}
impl Default for SkillMarkdownMeta {
fn default() -> Self {
Self {
name: None,
description: None,
always: None,
}
}
}
/// Extract first non-empty, non-heading line as description
fn extract_description(content: &str) -> String {
content
.lines()
.find(|line| !line.starts_with('#') && !line.trim().is_empty())
.map(|l| l.trim().to_string())
.unwrap_or_else(|| "No description".to_string())
}
/// Escape XML special characters
fn escape_xml(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => result.push_str("&amp;"),
'<' => result.push_str("&lt;"),
'>' => result.push_str("&gt;"),
'"' => result.push_str("&quot;"),
'\'' => result.push_str("&apos;"),
_ => result.push(c),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_skill_without_frontmatter() {
let loader = SkillsLoader::new();
let content = "# My Skill\n\nThis is the content.";
let (meta, body) = loader.parse_skill_markdown(content);
assert!(meta.name.is_none());
assert!(meta.description.is_none());
assert!(body.contains("My Skill"));
}
#[test]
fn test_parse_skill_with_frontmatter() {
let loader = SkillsLoader::new();
let content = r#"---
name: test-skill
description: A test skill
always: true
---
# Test Skill
This is the content.
"#;
let (meta, body) = loader.parse_skill_markdown(content);
assert_eq!(meta.name, Some("test-skill".to_string()));
assert_eq!(meta.description, Some("A test skill".to_string()));
assert_eq!(meta.always, Some(true));
assert!(body.contains("Test Skill"));
}
#[test]
fn test_escape_xml() {
assert_eq!(escape_xml("a & b"), "a &amp; b");
assert_eq!(escape_xml("<tag>"), "&lt;tag&gt;");
assert_eq!(escape_xml("\"quote\""), "&quot;quote&quot;");
}
#[test]
fn test_extract_description() {
assert_eq!(
extract_description("# Title\n\nFirst line of content."),
"First line of content."
);
assert_eq!(extract_description("# Title"), "No description");
}
}

159
src/tools/get_skill.rs Normal file
View File

@ -0,0 +1,159 @@
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::skills::{Skill, SkillsLoader};
use crate::tools::traits::{Tool, ToolResult};
pub struct GetSkillTool {
skills_loader: Arc<SkillsLoader>,
}
impl GetSkillTool {
pub fn new(skills_loader: Arc<SkillsLoader>) -> Self {
Self { skills_loader }
}
fn format_skill(&self, skill: &Skill) -> String {
let mut result = format!("# Skill: {}\n\n{}", skill.name, skill.description);
if let Some(path) = &skill.path {
result.push_str(&format!(
"\n\n**Skill Root Directory:** `{}`\n\nAll files and references in this skill are relative to this directory.",
path.to_string_lossy()
));
}
result.push_str(&format!("\n\n---\n\n{}", skill.content));
result
}
}
#[async_trait]
impl Tool for GetSkillTool {
fn name(&self) -> &str {
"get_skill"
}
fn description(&self) -> &str {
"Get complete content and guidance for a specified skill. Use this when you need detailed instructions for a specific type of task."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "Name of the skill to retrieve"
}
},
"required": ["skill_name"]
})
}
fn read_only(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let skill_name = match args.get("skill_name").and_then(|v| v.as_str()) {
Some(name) => name,
None => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Missing required parameter: skill_name".to_string()),
});
}
};
match self.skills_loader.get_skill(skill_name) {
Some(skill) => {
let formatted = self.format_skill(&skill);
Ok(ToolResult {
success: true,
output: formatted,
error: None,
})
}
None => {
let available = self.skills_loader.list_skills();
let available_str = if available.is_empty() {
"No skills available".to_string()
} else {
available
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Skill '{}' not found. Available skills: {}",
skill_name, available_str
)),
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
#[tokio::test]
async fn test_get_existing_skill() {
let temp_dir = tempdir().unwrap();
let skill_dir = temp_dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
let mut skill_file = File::create(skill_dir.join("SKILL.md")).unwrap();
writeln!(skill_file, "---").unwrap();
writeln!(skill_file, "name: test-skill").unwrap();
writeln!(skill_file, "description: A test skill").unwrap();
writeln!(skill_file, "---").unwrap();
writeln!(skill_file, "# Test Skill").unwrap();
writeln!(skill_file, "This is the test content.").unwrap();
let mut loader = SkillsLoader::new_for_testing(
temp_dir.path().to_path_buf(),
PathBuf::from("/nonexistent"),
);
loader.load_skills();
let tool = GetSkillTool::new(Arc::new(loader));
let result = tool
.execute(json!({ "skill_name": "test-skill" }))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("test-skill"));
assert!(result.output.contains("test content"));
}
#[tokio::test]
async fn test_get_nonexistent_skill() {
let loader = SkillsLoader::new();
let tool = GetSkillTool::new(Arc::new(loader));
let result = tool
.execute(json!({ "skill_name": "nonexistent" }))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.is_some());
}
}

View File

@ -3,6 +3,7 @@ pub mod calculator;
pub mod file_edit; pub mod file_edit;
pub mod file_read; pub mod file_read;
pub mod file_write; pub mod file_write;
pub mod get_skill;
pub mod http_request; pub mod http_request;
pub mod registry; pub mod registry;
pub mod schema; pub mod schema;
@ -14,6 +15,7 @@ pub use calculator::CalculatorTool;
pub use file_edit::FileEditTool; pub use file_edit::FileEditTool;
pub use file_read::FileReadTool; pub use file_read::FileReadTool;
pub use file_write::FileWriteTool; pub use file_write::FileWriteTool;
pub use get_skill::GetSkillTool;
pub use http_request::HttpRequestTool; pub use http_request::HttpRequestTool;
pub use registry::ToolRegistry; pub use registry::ToolRegistry;
pub use schema::{CleaningStrategy, SchemaCleanr}; pub use schema::{CleaningStrategy, SchemaCleanr};