Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 48 additions & 14 deletions src/crates/core/src/agentic/tools/implementations/skill_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use crate::util::errors::{BitFunError, BitFunResult};
use async_trait::async_trait;
use log::debug;
use serde_json::{json, Value};
use std::path::Path;

// Use skills module
use super::skills::{get_skill_registry, SkillLocation};
Expand Down Expand Up @@ -56,12 +55,31 @@ Important:
)
}

async fn build_description(&self, workspace_root: Option<&Path>) -> String {
async fn build_description_for_context(
&self,
context: Option<&ToolUseContext>,
) -> String {
let registry = get_skill_registry();
let available_skills = match workspace_root {
Some(workspace_root) => {
let available_skills = match context {
Some(ctx) if ctx.is_remote() => {
if let Some(fs) = ctx.ws_fs() {
let root = ctx
.workspace
.as_ref()
.map(|w| w.root_path_string())
.unwrap_or_default();
registry
.get_enabled_skills_xml_for_remote_workspace(fs, &root)
.await
} else {
registry
.get_enabled_skills_xml_for_workspace(ctx.workspace_root())
.await
}
}
Some(ctx) => {
registry
.get_enabled_skills_xml_for_workspace(Some(workspace_root))
.get_enabled_skills_xml_for_workspace(ctx.workspace_root())
.await
}
None => registry.get_enabled_skills_xml().await,
Expand All @@ -78,19 +96,18 @@ impl Tool for SkillTool {
}

async fn description(&self) -> BitFunResult<String> {
Ok(self.build_description(None).await)
Ok(self.build_description_for_context(None).await)
}

async fn description_with_context(
&self,
context: Option<&ToolUseContext>,
) -> BitFunResult<String> {
let mut s = self
.build_description(context.and_then(|ctx| ctx.workspace_root()))
.await;
if context.map(|c| c.is_remote()).unwrap_or(false) {
let mut s = self.build_description_for_context(context).await;
if context.map(|c| c.is_remote()).unwrap_or(false) && context.and_then(|c| c.ws_fs()).is_none()
{
s.push_str(
"\n\n**Remote workspace:** Project-level skills under `.bitfun/skills` on the **server** may not appear in the list above because this index is built from the local workspace view. Use **Read** / **Glob** on the remote tree if you need a skill file that is not listed.",
"\n\n**Remote workspace:** Project-level skills on the server could not be indexed (workspace I/O unavailable). Use **Read** / **Glob** on the remote tree if needed.",
);
}
Ok(s)
Expand Down Expand Up @@ -170,9 +187,26 @@ impl Tool for SkillTool {

// Find and load skill through registry
let registry = get_skill_registry();
let skill_data = registry
.find_and_load_skill_for_workspace(skill_name, context.workspace_root())
.await?;
let skill_data = if context.is_remote() {
if let Some(ws_fs) = context.ws_fs() {
let root = context
.workspace
.as_ref()
.map(|w| w.root_path_string())
.unwrap_or_default();
registry
.find_and_load_skill_for_remote_workspace(skill_name, ws_fs, &root)
.await?
} else {
registry
.find_and_load_skill_for_workspace(skill_name, context.workspace_root())
.await?
}
} else {
registry
.find_and_load_skill_for_workspace(skill_name, context.workspace_root())
.await?
};

let location_str = match skill_data.location {
SkillLocation::User => "user",
Expand Down
120 changes: 120 additions & 0 deletions src/crates/core/src/agentic/tools/implementations/skills/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use super::builtin::ensure_builtin_skills_installed;
use super::types::{SkillData, SkillInfo, SkillLocation};
use crate::agentic::workspace::WorkspaceFileSystem;
use crate::infrastructure::get_path_manager_arc;
use crate::util::errors::{BitFunError, BitFunResult};
use log::{debug, error};
Expand Down Expand Up @@ -382,6 +383,97 @@ impl SkillRegistry {
.collect()
}

/// Remote SSH workspace: merge **client** user-level skills with **server** project skills (via SFTP).
pub async fn get_enabled_skills_xml_for_remote_workspace(
&self,
fs: &dyn WorkspaceFileSystem,
remote_root: &str,
) -> Vec<String> {
self.scan_skill_map_for_remote_workspace(fs, remote_root)
.await
.into_values()
.filter(|skill| skill.enabled)
.map(|skill| skill.to_xml_desc())
.collect()
}

async fn scan_skills_in_remote_dir(
fs: &dyn WorkspaceFileSystem,
dir: &str,
level: SkillLocation,
) -> Vec<SkillInfo> {
let mut skills = Vec::new();
let entries = match fs.read_dir(dir).await {
Ok(e) => e,
Err(_) => return skills,
};
for e in entries {
if !e.is_dir || e.is_symlink {
continue;
}
let skill_md = format!("{}/SKILL.md", e.path.trim_end_matches('/'));
if !fs.is_file(&skill_md).await.unwrap_or(false) {
continue;
}
match fs.read_file_text(&skill_md).await {
Ok(content) => match SkillData::from_markdown(
e.path.clone(),
&content,
level,
false,
) {
Ok(skill_data) => {
skills.push(SkillInfo {
name: skill_data.name,
description: skill_data.description,
path: skill_data.path,
level,
enabled: skill_data.enabled,
});
}
Err(err) => {
error!("Failed to parse SKILL.md in {}: {}", e.path, err);
}
},
Err(err) => {
debug!("Failed to read {}: {}", skill_md, err);
}
}
}
skills
}

async fn scan_remote_project_skills(
fs: &dyn WorkspaceFileSystem,
remote_root: &str,
) -> Vec<SkillInfo> {
let mut skills = Vec::new();
let root = remote_root.trim_end_matches('/');
for (parent, sub) in PROJECT_SKILL_SUBDIRS {
let skill_dir = format!("{}/{}/{}", root, parent, sub);
if !fs.is_dir(&skill_dir).await.unwrap_or(false) {
continue;
}
let mut part =
Self::scan_skills_in_remote_dir(fs, &skill_dir, SkillLocation::Project).await;
skills.append(&mut part);
}
skills
}

/// User skills from this machine plus project skills from the remote workspace root.
pub async fn scan_skill_map_for_remote_workspace(
&self,
fs: &dyn WorkspaceFileSystem,
remote_root: &str,
) -> HashMap<String, SkillInfo> {
let mut map = self.scan_skill_map_for_workspace(None).await;
for info in Self::scan_remote_project_skills(fs, remote_root).await {
map.insert(info.name.clone(), info);
}
map
}

pub async fn find_and_load_skill_for_workspace(
&self,
skill_name: &str,
Expand All @@ -406,4 +498,32 @@ impl SkillRegistry {

SkillData::from_markdown(info.path.clone(), &content, info.level, true)
}

/// Load skill when the workspace is remote: reads SKILL.md via [`WorkspaceFileSystem`].
pub async fn find_and_load_skill_for_remote_workspace(
&self,
skill_name: &str,
fs: &dyn WorkspaceFileSystem,
remote_root: &str,
) -> BitFunResult<SkillData> {
let map = self.scan_skill_map_for_remote_workspace(fs, remote_root).await;
let info = map
.get(skill_name)
.ok_or_else(|| BitFunError::tool(format!("Skill '{}' not found", skill_name)))?;

if !info.enabled {
return Err(BitFunError::tool(format!(
"Skill '{}' is disabled",
skill_name
)));
}

let skill_md_path = format!("{}/SKILL.md", info.path.trim_end_matches('/'));
let content = fs
.read_file_text(&skill_md_path)
.await
.map_err(|e| BitFunError::tool(format!("Failed to read skill file: {}", e)))?;

SkillData::from_markdown(info.path.clone(), &content, info.level, true)
}
}
50 changes: 50 additions & 0 deletions src/crates/core/src/agentic/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ impl WorkspaceBinding {
// traits instead of checking is_remote themselves.
// ============================================================

/// One row from [`WorkspaceFileSystem::read_dir`] (POSIX paths when backend is remote SSH).
#[derive(Debug, Clone)]
pub struct WorkspaceDirEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub is_symlink: bool,
}

/// Unified file system operations that work for both local and remote workspaces.
#[async_trait]
pub trait WorkspaceFileSystem: Send + Sync {
Expand All @@ -104,6 +113,8 @@ pub trait WorkspaceFileSystem: Send + Sync {
async fn exists(&self, path: &str) -> anyhow::Result<bool>;
async fn is_file(&self, path: &str) -> anyhow::Result<bool>;
async fn is_dir(&self, path: &str) -> anyhow::Result<bool>;
/// List immediate children (non-recursive). Symlinks may be included; callers often skip them.
async fn read_dir(&self, path: &str) -> anyhow::Result<Vec<WorkspaceDirEntry>>;
}

/// Unified shell execution for both local and remote workspaces.
Expand Down Expand Up @@ -180,6 +191,28 @@ impl WorkspaceFileSystem for LocalWorkspaceFs {
Err(_) => Ok(false),
}
}

async fn read_dir(&self, path: &str) -> anyhow::Result<Vec<WorkspaceDirEntry>> {
let mut out = Vec::new();
let mut rd = tokio::fs::read_dir(path).await?;
while let Ok(Some(entry)) = rd.next_entry().await {
let p = entry.path();
let meta = tokio::fs::symlink_metadata(&p).await?;
if meta.file_type().is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let path_str = p.to_string_lossy().to_string();
let is_dir = meta.is_dir();
out.push(WorkspaceDirEntry {
name,
path: path_str,
is_dir,
is_symlink: false,
});
}
Ok(out)
}
}

/// Local shell implementation of `WorkspaceShell`.
Expand Down Expand Up @@ -271,6 +304,23 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs {
.await
.map_err(|e| anyhow::anyhow!("{}", e))
}

async fn read_dir(&self, path: &str) -> anyhow::Result<Vec<WorkspaceDirEntry>> {
let entries = self
.file_service
.read_dir(&self.connection_id, path)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(entries
.into_iter()
.map(|e| WorkspaceDirEntry {
name: e.name,
path: e.path,
is_dir: e.is_dir,
is_symlink: e.is_symlink,
})
.collect())
}
}

/// SSH-backed shell implementation.
Expand Down
Loading