use std::sync::Arc;
use async_trait::async_trait;
use meerkat_core::ToolDef;
use meerkat_core::skills::{SkillCollection, SkillDescriptor, SkillFilter, SkillId, SkillRuntime};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::builtin::{BuiltinTool, BuiltinToolError};
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
struct BrowseSkillsArgs {
#[serde(default)]
path: Option<String>,
#[serde(default)]
query: Option<String>,
}
pub struct BrowseSkillsTool {
engine: Arc<SkillRuntime>,
}
impl BrowseSkillsTool {
pub fn new(engine: Arc<SkillRuntime>) -> Self {
Self { engine }
}
}
fn partition_at_path(
path: &str,
skills: &[SkillDescriptor],
all_collections: &[SkillCollection],
) -> (Vec<Value>, Vec<Value>) {
let direct_skills: Vec<Value> = skills
.iter()
.filter(|s| {
let coll = s.id.collection().unwrap_or("");
coll == path
})
.map(|s| {
json!({
"id": s.id.0,
"canonical_key": canonical_key(&s.id),
"name": s.name,
"description": s.description,
})
})
.collect();
let subcollections: Vec<Value> = all_collections
.iter()
.filter(|c| {
if path.is_empty() {
!c.path.contains('/')
} else {
c.path.starts_with(path)
&& c.path.len() > path.len()
&& c.path.as_bytes().get(path.len()) == Some(&b'/')
&& !c.path[path.len() + 1..].contains('/')
}
})
.map(|c| {
json!({
"path": c.path,
"description": c.description,
"count": c.count,
})
})
.collect();
(subcollections, direct_skills)
}
fn canonical_key(id: &SkillId) -> Value {
match id.0.split_once('/') {
Some((source_uuid, skill_name)) => {
json!({ "source_uuid": source_uuid, "skill_name": skill_name })
}
None => json!({ "source_uuid": Value::Null, "skill_name": id.0 }),
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl BuiltinTool for BrowseSkillsTool {
fn name(&self) -> &'static str {
"browse_skills"
}
fn def(&self) -> ToolDef {
ToolDef {
name: "browse_skills".into(),
description: "Browse available skill collections or search for skills.".into(),
input_schema: crate::schema::schema_for::<BrowseSkillsArgs>(),
}
}
fn default_enabled(&self) -> bool {
false }
async fn call(&self, args: Value) -> Result<Value, BuiltinToolError> {
let query = args.get("query").and_then(|v| v.as_str());
let path = args.get("path").and_then(|v| v.as_str());
if let Some(query_str) = query {
let filter = SkillFilter {
query: Some(query_str.to_string()),
..Default::default()
};
let skills = self
.engine
.list_skills(&filter)
.await
.map_err(|e| BuiltinToolError::ExecutionFailed(e.to_string()))?;
let skill_values: Vec<Value> = skills
.iter()
.map(|s| {
json!({
"id": s.id.0,
"canonical_key": canonical_key(&s.id),
"name": s.name,
"description": s.description,
})
})
.collect();
return Ok(json!({
"type": "search",
"query": query_str,
"skills": skill_values,
}));
}
let browse_path = path.unwrap_or("");
let filter = if browse_path.is_empty() {
SkillFilter::default()
} else {
SkillFilter {
collection: Some(browse_path.to_string()),
..Default::default()
}
};
let skills = self
.engine
.list_skills(&filter)
.await
.map_err(|e| BuiltinToolError::ExecutionFailed(e.to_string()))?;
let collections = self
.engine
.collections()
.await
.map_err(|e| BuiltinToolError::ExecutionFailed(e.to_string()))?;
let (subcollections, direct_skills) = partition_at_path(browse_path, &skills, &collections);
Ok(json!({
"type": "listing",
"path": browse_path,
"subcollections": subcollections,
"skills": direct_skills,
}))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::manual_async_fn)]
mod tests {
use super::*;
use meerkat_core::skills::{
ResolvedSkill, SkillArtifact, SkillArtifactContent, SkillCollection, SkillDescriptor,
SkillEngine, SkillError, SkillId, SkillScope,
};
struct MockEngine {
skills: Vec<SkillDescriptor>,
}
impl MockEngine {
fn new(skills: Vec<(&str, &str)>) -> Self {
Self {
skills: skills
.into_iter()
.map(|(id, name)| SkillDescriptor {
id: SkillId(id.into()),
name: name.into(),
description: format!("Description for {name}"),
scope: SkillScope::Builtin,
..Default::default()
})
.collect(),
}
}
}
impl SkillEngine for MockEngine {
fn inventory_section(
&self,
) -> impl std::future::Future<Output = Result<String, SkillError>> + Send {
async move { Ok(String::new()) }
}
fn resolve_and_render(
&self,
ids: &[SkillId],
) -> impl std::future::Future<Output = Result<Vec<ResolvedSkill>, SkillError>> + Send
{
let ids = ids.to_vec();
async move {
let mut results = Vec::new();
for id in &ids {
if let Some(skill) = self.skills.iter().find(|s| &s.id == id) {
results.push(ResolvedSkill {
id: id.clone(),
name: skill.name.clone(),
rendered_body: format!("<skill id=\"{}\">body</skill>", id.0),
byte_size: 20,
});
} else {
return Err(SkillError::NotFound { id: id.clone() });
}
}
Ok(results)
}
}
fn collections(
&self,
) -> impl std::future::Future<Output = Result<Vec<SkillCollection>, SkillError>> + Send
{
async move { Ok(meerkat_core::skills::derive_collections(&self.skills)) }
}
fn list_skills(
&self,
filter: &SkillFilter,
) -> impl std::future::Future<Output = Result<Vec<SkillDescriptor>, SkillError>> + Send
{
async move { Ok(meerkat_core::skills::apply_filter(&self.skills, filter)) }
}
fn quarantined_diagnostics(
&self,
) -> impl std::future::Future<
Output = Result<Vec<meerkat_core::skills::SkillQuarantineDiagnostic>, SkillError>,
> + Send {
async move { Ok(Vec::new()) }
}
fn health_snapshot(
&self,
) -> impl std::future::Future<
Output = Result<meerkat_core::skills::SourceHealthSnapshot, SkillError>,
> + Send {
async move { Ok(meerkat_core::skills::SourceHealthSnapshot::default()) }
}
fn list_artifacts(
&self,
_id: &SkillId,
) -> impl std::future::Future<Output = Result<Vec<SkillArtifact>, SkillError>> + Send
{
async move { Ok(Vec::new()) }
}
fn read_artifact(
&self,
id: &SkillId,
_artifact_path: &str,
) -> impl std::future::Future<Output = Result<SkillArtifactContent, SkillError>> + Send
{
let id = id.clone();
async move { Err(SkillError::NotFound { id }) }
}
fn invoke_function(
&self,
id: &SkillId,
_function_name: &str,
_arguments: Value,
) -> impl std::future::Future<Output = Result<Value, SkillError>> + Send {
let id = id.clone();
async move { Err(SkillError::NotFound { id }) }
}
}
fn test_engine() -> Arc<SkillRuntime> {
Arc::new(SkillRuntime::new(Arc::new(MockEngine::new(vec![
("extraction/email", "email"),
("extraction/fiction", "fiction"),
("formatting/markdown", "markdown"),
("pdf-processing", "pdf-processing"),
]))))
}
#[tokio::test]
async fn test_browse_root_returns_listing() {
let tool = BrowseSkillsTool::new(test_engine());
let result = tool.call(json!({})).await.unwrap();
assert_eq!(result["type"], "listing");
assert_eq!(result["path"], "");
assert!(result["subcollections"].is_array());
assert!(result["skills"].is_array());
let subs = result["subcollections"].as_array().unwrap();
assert!(subs.iter().any(|s| s["path"] == "extraction"));
let skills = result["skills"].as_array().unwrap();
assert!(skills.iter().any(|s| s["id"] == "pdf-processing"));
}
#[tokio::test]
async fn test_browse_collection_returns_listing() {
let tool = BrowseSkillsTool::new(test_engine());
let result = tool.call(json!({"path": "extraction"})).await.unwrap();
assert_eq!(result["type"], "listing");
assert_eq!(result["path"], "extraction");
let skills = result["skills"].as_array().unwrap();
assert_eq!(skills.len(), 2);
assert!(skills.iter().any(|s| s["id"] == "extraction/email"));
assert!(skills.iter().any(|s| s["id"] == "extraction/fiction"));
}
#[tokio::test]
async fn test_browse_search_returns_search() {
let tool = BrowseSkillsTool::new(test_engine());
let result = tool.call(json!({"query": "email"})).await.unwrap();
assert_eq!(result["type"], "search");
assert_eq!(result["query"], "email");
let skills = result["skills"].as_array().unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0]["id"], "extraction/email");
}
#[tokio::test]
async fn test_browse_both_query_wins() {
let tool = BrowseSkillsTool::new(test_engine());
let result = tool
.call(json!({"path": "formatting", "query": "email"}))
.await
.unwrap();
assert_eq!(result["type"], "search");
assert_eq!(result["query"], "email");
}
#[tokio::test]
async fn test_browse_empty_collection() {
let tool = BrowseSkillsTool::new(test_engine());
let result = tool.call(json!({"path": "nonexistent"})).await.unwrap();
assert_eq!(result["type"], "listing");
let skills = result["skills"].as_array().unwrap();
assert!(skills.is_empty());
}
}