feat: skills platform ext (#8377)
This commit is contained in:
@@ -427,19 +427,13 @@ pub async fn get_slash_commands(
|
|||||||
|
|
||||||
let working_dir = query.working_dir.map(std::path::PathBuf::from);
|
let working_dir = query.working_dir.map(std::path::PathBuf::from);
|
||||||
for source in
|
for source in
|
||||||
goose::agents::platform_extensions::summon::list_installed_sources(working_dir.as_deref())
|
goose::agents::platform_extensions::skills::list_installed_skills(working_dir.as_deref())
|
||||||
{
|
{
|
||||||
if matches!(
|
commands.push(SlashCommand {
|
||||||
source.kind,
|
command: source.name,
|
||||||
goose::agents::platform_extensions::summon::SourceKind::Skill
|
help: source.description,
|
||||||
| goose::agents::platform_extensions::summon::SourceKind::BuiltinSkill
|
command_type: CommandType::Skill,
|
||||||
) {
|
});
|
||||||
commands.push(SlashCommand {
|
|
||||||
command: source.name,
|
|
||||||
help: source.description,
|
|
||||||
command_type: CommandType::Skill,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(SlashCommandsResponse { commands }))
|
Ok(Json(SlashCommandsResponse { commands }))
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
|
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
|
||||||
use super::platform_extensions::summon::{list_installed_sources, SourceKind};
|
use super::platform_extensions::skills::list_installed_skills;
|
||||||
|
use super::platform_extensions::SourceKind;
|
||||||
|
|
||||||
let working_dir = self
|
let working_dir = self
|
||||||
.config
|
.config
|
||||||
@@ -149,7 +150,7 @@ impl Agent {
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.map(|s| s.working_dir);
|
.map(|s| s.working_dir);
|
||||||
let sources = list_installed_sources(working_dir.as_deref());
|
let sources = list_installed_skills(working_dir.as_deref());
|
||||||
let skills: Vec<_> = sources
|
let skills: Vec<_> = sources
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill))
|
.filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill))
|
||||||
|
|||||||
@@ -6,16 +6,79 @@ pub mod code_execution;
|
|||||||
pub mod developer;
|
pub mod developer;
|
||||||
pub mod ext_manager;
|
pub mod ext_manager;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
pub mod skills;
|
||||||
pub mod summarize;
|
pub mod summarize;
|
||||||
pub mod summon;
|
pub mod summon;
|
||||||
pub mod todo;
|
pub mod todo;
|
||||||
pub mod tom;
|
pub mod tom;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::agents::mcp_client::McpClientTrait;
|
use crate::agents::mcp_client::McpClientTrait;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Source {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: SourceKind,
|
||||||
|
pub description: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub content: String,
|
||||||
|
pub supporting_files: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum SourceKind {
|
||||||
|
Subrecipe,
|
||||||
|
Recipe,
|
||||||
|
Skill,
|
||||||
|
Agent,
|
||||||
|
BuiltinSkill,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SourceKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SourceKind::Subrecipe => write!(f, "subrecipe"),
|
||||||
|
SourceKind::Recipe => write!(f, "recipe"),
|
||||||
|
SourceKind::Skill => write!(f, "skill"),
|
||||||
|
SourceKind::Agent => write!(f, "agent"),
|
||||||
|
SourceKind::BuiltinSkill => write!(f, "builtin skill"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
pub fn to_load_text(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"## {} ({})\n\n{}\n\n### Content\n\n{}",
|
||||||
|
self.name, self.kind, self.description, self.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_frontmatter<T: for<'de> Deserialize<'de>>(content: &str) -> Option<(T, String)> {
|
||||||
|
let parts: Vec<&str> = content.split("---").collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let yaml_content = parts[1].trim();
|
||||||
|
let metadata: T = match serde_yaml::from_str(yaml_content) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse frontmatter: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = parts[2..].join("---").trim().to_string();
|
||||||
|
Some((metadata, body))
|
||||||
|
}
|
||||||
|
|
||||||
pub use ext_manager::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
|
pub use ext_manager::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
|
||||||
|
|
||||||
@@ -189,6 +252,19 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
skills::EXTENSION_NAME,
|
||||||
|
PlatformExtensionDef {
|
||||||
|
name: skills::EXTENSION_NAME,
|
||||||
|
display_name: "Skills",
|
||||||
|
description: "Discover and provide skill instructions from filesystem and builtins",
|
||||||
|
default_enabled: true,
|
||||||
|
unprefixed_tools: true,
|
||||||
|
hidden: false,
|
||||||
|
client_factory: |ctx| Box::new(skills::SkillsClient::new(ctx).unwrap()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
map
|
map
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
use super::{parse_frontmatter, Source, SourceKind};
|
||||||
|
use crate::agents::builtin_skills;
|
||||||
|
use crate::agents::extension::PlatformExtensionContext;
|
||||||
|
use crate::agents::mcp_client::{Error, McpClientTrait};
|
||||||
|
use crate::agents::tool_execution::ToolCallContext;
|
||||||
|
use crate::config::paths::Paths;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use rmcp::model::{
|
||||||
|
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult,
|
||||||
|
ServerCapabilities, ServerNotification, Tool,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
pub static EXTENSION_NAME: &str = "skills";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SkillMetadata {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_skill_content(content: &str, path: PathBuf) -> Option<Source> {
|
||||||
|
let (metadata, body): (SkillMetadata, String) = parse_frontmatter(content)?;
|
||||||
|
|
||||||
|
if metadata.name.contains('/') {
|
||||||
|
warn!("Skill name '{}' contains '/', skipping", metadata.name);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Source {
|
||||||
|
name: metadata.name,
|
||||||
|
kind: SourceKind::Skill,
|
||||||
|
description: metadata.description,
|
||||||
|
path,
|
||||||
|
content: body,
|
||||||
|
supporting_files: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_skip_dir(path: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
path.file_name().and_then(|name| name.to_str()),
|
||||||
|
Some(".git") | Some(".hg") | Some(".svn")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_files_recursively<F, G>(
|
||||||
|
dir: &Path,
|
||||||
|
visited_dirs: &mut HashSet<PathBuf>,
|
||||||
|
should_descend: &mut G,
|
||||||
|
visit_file: &mut F,
|
||||||
|
) where
|
||||||
|
F: FnMut(&Path),
|
||||||
|
G: FnMut(&Path) -> bool,
|
||||||
|
{
|
||||||
|
let canonical_dir = match std::fs::canonicalize(dir) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !visited_dirs.insert(canonical_dir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if should_descend(&path) {
|
||||||
|
walk_files_recursively(&path, visited_dirs, should_descend, visit_file);
|
||||||
|
}
|
||||||
|
} else if path.is_file() {
|
||||||
|
visit_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_skills_from_dir(dir: &Path, seen: &mut HashSet<String>) -> Vec<Source> {
|
||||||
|
let mut skill_files = Vec::new();
|
||||||
|
let mut visited_dirs = HashSet::new();
|
||||||
|
|
||||||
|
walk_files_recursively(
|
||||||
|
dir,
|
||||||
|
&mut visited_dirs,
|
||||||
|
&mut |path| !should_skip_dir(path),
|
||||||
|
&mut |path| {
|
||||||
|
if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") {
|
||||||
|
skill_files.push(path.to_path_buf());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
for skill_file in skill_files {
|
||||||
|
let Some(skill_dir) = skill_file.parent() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let content = match std::fs::read_to_string(&skill_file) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to read skill file {}: {}", skill_file.display(), e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(mut source) = parse_skill_content(&content, skill_dir.to_path_buf()) {
|
||||||
|
if !seen.contains(&source.name) {
|
||||||
|
// Find supporting files in the skill directory
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut visited_support_dirs = HashSet::new();
|
||||||
|
walk_files_recursively(
|
||||||
|
skill_dir,
|
||||||
|
&mut visited_support_dirs,
|
||||||
|
&mut |path| !should_skip_dir(path) && !path.join("SKILL.md").is_file(),
|
||||||
|
&mut |path| {
|
||||||
|
if path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") {
|
||||||
|
files.push(path.to_path_buf());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
source.supporting_files = files;
|
||||||
|
|
||||||
|
seen.insert(source.name.clone());
|
||||||
|
sources.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_skills(working_dir: &Path) -> Vec<Source> {
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
|
let home = dirs::home_dir();
|
||||||
|
let config = Paths::config_dir();
|
||||||
|
|
||||||
|
let local_dirs = vec![
|
||||||
|
working_dir.join(".goose/skills"),
|
||||||
|
working_dir.join(".claude/skills"),
|
||||||
|
working_dir.join(".agents/skills"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let global_dirs: Vec<PathBuf> = [
|
||||||
|
home.as_ref().map(|h| h.join(".agents/skills")),
|
||||||
|
Some(config.join("skills")),
|
||||||
|
home.as_ref().map(|h| h.join(".claude/skills")),
|
||||||
|
home.as_ref().map(|h| h.join(".config/agents/skills")),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for dir in local_dirs {
|
||||||
|
sources.extend(scan_skills_from_dir(&dir, &mut seen));
|
||||||
|
}
|
||||||
|
for dir in global_dirs {
|
||||||
|
sources.extend(scan_skills_from_dir(&dir, &mut seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
for content in builtin_skills::get_all() {
|
||||||
|
if let Some(source) = parse_skill_content(content, PathBuf::new()) {
|
||||||
|
if !seen.contains(&source.name) {
|
||||||
|
seen.insert(source.name.clone());
|
||||||
|
sources.push(Source {
|
||||||
|
kind: SourceKind::BuiltinSkill,
|
||||||
|
..source
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sources
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec<Source> {
|
||||||
|
let dir = working_dir
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
discover_skills(&dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillsClient {
|
||||||
|
info: InitializeResult,
|
||||||
|
working_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillsClient {
|
||||||
|
pub fn new(context: PlatformExtensionContext) -> anyhow::Result<Self> {
|
||||||
|
let working_dir = context
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.working_dir.clone())
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
|
let mut instructions = String::new();
|
||||||
|
if context.session.is_some() {
|
||||||
|
let sources = discover_skills(&working_dir);
|
||||||
|
let mut skills: Vec<&Source> = sources
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.kind == SourceKind::Skill || s.kind == SourceKind::BuiltinSkill)
|
||||||
|
.collect();
|
||||||
|
skills.sort_by(|a, b| (&a.name, &a.path).cmp(&(&b.name, &b.path)));
|
||||||
|
|
||||||
|
if !skills.is_empty() {
|
||||||
|
instructions.push_str(
|
||||||
|
"\n\nYou have these skills at your disposal, when it is clear they can help you solve a problem or you are asked to use them:",
|
||||||
|
);
|
||||||
|
for skill in &skills {
|
||||||
|
instructions.push_str(&format!("\n• {} - {}", skill.name, skill.description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
|
||||||
|
.with_server_info(Implementation::new(EXTENSION_NAME, "1.0.0").with_title("Skills"))
|
||||||
|
.with_instructions(instructions);
|
||||||
|
|
||||||
|
Ok(Self { info, working_dir })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpClientTrait for SkillsClient {
|
||||||
|
async fn list_tools(
|
||||||
|
&self,
|
||||||
|
_session_id: &str,
|
||||||
|
_next_cursor: Option<String>,
|
||||||
|
_cancellation_token: CancellationToken,
|
||||||
|
) -> Result<ListToolsResult, Error> {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the skill to load. Use \"skill-name/path\" to load a supporting file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let tool = Tool::new(
|
||||||
|
"load_skill",
|
||||||
|
"Load a skill's full content into your context so you can follow its instructions.\n\n\
|
||||||
|
Skills are listed in your system instructions. When you need to use one, \
|
||||||
|
load it first to get the detailed instructions.\n\n\
|
||||||
|
Examples:\n\
|
||||||
|
- load_skill(name: \"gdrive\") → Loads the gdrive skill instructions\n\
|
||||||
|
- load_skill(name: \"my-skill/template.md\") → Loads a supporting file"
|
||||||
|
.to_string(),
|
||||||
|
schema.as_object().unwrap().clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ListToolsResult {
|
||||||
|
tools: vec![tool],
|
||||||
|
next_cursor: None,
|
||||||
|
meta: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_tool(
|
||||||
|
&self,
|
||||||
|
_ctx: &ToolCallContext,
|
||||||
|
name: &str,
|
||||||
|
arguments: Option<JsonObject>,
|
||||||
|
_cancellation_token: CancellationToken,
|
||||||
|
) -> Result<CallToolResult, Error> {
|
||||||
|
if name != "load_skill" {
|
||||||
|
return Ok(CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Unknown tool: {}",
|
||||||
|
name
|
||||||
|
))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill_name = arguments
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|args| args.get("name"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if skill_name.is_empty() {
|
||||||
|
return Ok(CallToolResult::error(vec![Content::text(
|
||||||
|
"Missing required parameter: name",
|
||||||
|
)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let skills = discover_skills(&self.working_dir);
|
||||||
|
|
||||||
|
// Direct skill match
|
||||||
|
if let Some(skill) = skills.iter().find(|s| s.name == skill_name) {
|
||||||
|
let mut output = format!(
|
||||||
|
"# Loaded Skill: {} ({})\n\n{}\n",
|
||||||
|
skill.name,
|
||||||
|
skill.kind,
|
||||||
|
skill.to_load_text()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !skill.supporting_files.is_empty() {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"\n## Supporting Files\n\nSkill directory: {}\n\n",
|
||||||
|
skill.path.display()
|
||||||
|
));
|
||||||
|
for file in &skill.supporting_files {
|
||||||
|
if let Ok(relative) = file.strip_prefix(&skill.path) {
|
||||||
|
let rel_str = relative.to_string_lossy().replace('\\', "/");
|
||||||
|
output.push_str(&format!(
|
||||||
|
"- {} → load_skill(name: \"{}/{}\")\n",
|
||||||
|
rel_str, skill.name, rel_str
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str("\n---\nThis knowledge is now available in your context.");
|
||||||
|
return Ok(CallToolResult::success(vec![Content::text(output)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supporting file match (skill_name contains '/')
|
||||||
|
if let Some((parent_skill_name, raw_relative_path)) = skill_name.split_once('/') {
|
||||||
|
let relative_path = raw_relative_path.replace('\\', "/");
|
||||||
|
if let Some(skill) = skills.iter().find(|s| {
|
||||||
|
s.name == parent_skill_name
|
||||||
|
&& matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill)
|
||||||
|
}) {
|
||||||
|
let canonical_skill_dir = skill
|
||||||
|
.path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| skill.path.clone());
|
||||||
|
|
||||||
|
for file_path in &skill.supporting_files {
|
||||||
|
let Ok(rel) = file_path.strip_prefix(&skill.path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if rel.to_string_lossy().replace('\\', "/") != relative_path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(match file_path.canonicalize() {
|
||||||
|
Ok(canonical) if canonical.starts_with(&canonical_skill_dir) => {
|
||||||
|
match std::fs::read_to_string(&canonical) {
|
||||||
|
Ok(content) => {
|
||||||
|
CallToolResult::success(vec![Content::text(format!(
|
||||||
|
"# Loaded: {}\n\n{}\n\n---\nFile loaded into context.",
|
||||||
|
skill_name, content
|
||||||
|
))])
|
||||||
|
}
|
||||||
|
Err(e) => CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Failed to read '{}': {}",
|
||||||
|
skill_name, e
|
||||||
|
))]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Refusing to load '{}': resolves outside the skill directory",
|
||||||
|
skill_name
|
||||||
|
))]),
|
||||||
|
Err(e) => CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Failed to resolve '{}': {}",
|
||||||
|
skill_name, e
|
||||||
|
))]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let available: Vec<String> = skill
|
||||||
|
.supporting_files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| {
|
||||||
|
f.strip_prefix(&skill.path)
|
||||||
|
.ok()
|
||||||
|
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||||
|
})
|
||||||
|
.take(10)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return Ok(if available.is_empty() {
|
||||||
|
CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Skill '{}' has no supporting files.",
|
||||||
|
skill.name
|
||||||
|
))])
|
||||||
|
} else {
|
||||||
|
CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"File '{}' not found. Available: {}",
|
||||||
|
skill_name,
|
||||||
|
available.join(", ")
|
||||||
|
))])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match — suggest similar skills
|
||||||
|
let suggestions: Vec<&str> = skills
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
s.name.to_lowercase().contains(&skill_name.to_lowercase())
|
||||||
|
|| skill_name.to_lowercase().contains(&s.name.to_lowercase())
|
||||||
|
})
|
||||||
|
.take(3)
|
||||||
|
.map(|s| s.name.as_str())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(if suggestions.is_empty() {
|
||||||
|
CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Skill '{}' not found.",
|
||||||
|
skill_name
|
||||||
|
))])
|
||||||
|
} else {
|
||||||
|
CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Skill '{}' not found. Did you mean: {}?",
|
||||||
|
skill_name,
|
||||||
|
suggestions.join(", ")
|
||||||
|
))])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_info(&self) -> Option<&InitializeResult> {
|
||||||
|
Some(&self.info)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subscribe(&self) -> mpsc::Receiver<ServerNotification> {
|
||||||
|
let (_tx, rx) = mpsc::channel(1);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_skill_from_filesystem() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let skill_dir = temp_dir.path().join(".goose/skills/my-skill");
|
||||||
|
fs::create_dir_all(&skill_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
skill_dir.join("SKILL.md"),
|
||||||
|
"---\nname: my-skill\ndescription: A test skill\n---\nDo the thing.",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let session = std::sync::Arc::new(crate::session::Session {
|
||||||
|
working_dir: temp_dir.path().to_path_buf(),
|
||||||
|
..crate::session::Session::default()
|
||||||
|
});
|
||||||
|
let client = SkillsClient::new(PlatformExtensionContext {
|
||||||
|
extension_manager: None,
|
||||||
|
session_manager: Arc::new(crate::session::SessionManager::instance()),
|
||||||
|
session: Some(session),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = ToolCallContext::new("test".to_string(), None, None);
|
||||||
|
let args: JsonObject =
|
||||||
|
serde_json::from_value(serde_json::json!({"name": "my-skill"})).unwrap();
|
||||||
|
let result = client
|
||||||
|
.call_tool(&ctx, "load_skill", Some(args), CancellationToken::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.is_error.unwrap_or(false));
|
||||||
|
let text = match &result.content[0].raw {
|
||||||
|
rmcp::model::RawContent::Text(t) => &t.text,
|
||||||
|
_ => panic!("expected text"),
|
||||||
|
};
|
||||||
|
assert!(text.contains("my-skill"));
|
||||||
|
assert!(text.contains("Do the thing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_skill_not_found_returns_error() {
|
||||||
|
let client = SkillsClient::new(PlatformExtensionContext {
|
||||||
|
extension_manager: None,
|
||||||
|
session_manager: Arc::new(crate::session::SessionManager::instance()),
|
||||||
|
session: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = ToolCallContext::new("test".to_string(), None, None);
|
||||||
|
let args: JsonObject =
|
||||||
|
serde_json::from_value(serde_json::json!({"name": "nonexistent"})).unwrap();
|
||||||
|
let result = client
|
||||||
|
.call_tool(&ctx, "load_skill", Some(args), CancellationToken::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_error.unwrap_or(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: crates/goose/src/agents/prompt_manager.rs
|
source: crates/goose/src/agents/prompt_manager.rs
|
||||||
|
assertion_line: 458
|
||||||
expression: system_prompt
|
expression: system_prompt
|
||||||
---
|
---
|
||||||
You are a general-purpose AI agent called goose, created by AAIF (Agentic AI Foundation).
|
You are a general-purpose AI agent called goose, created by AAIF (Agentic AI Foundation).
|
||||||
@@ -93,16 +94,19 @@ Use write and edit to efficiently make changes. Test and verify as appropriate.
|
|||||||
|
|
||||||
### Instructions
|
### Instructions
|
||||||
Manage agent sessions: list, view, start, send messages, and interrupt agents.
|
Manage agent sessions: list, view, start, send messages, and interrupt agents.
|
||||||
## summarize
|
## skills
|
||||||
|
|
||||||
|
|
||||||
## summon
|
|
||||||
|
|
||||||
### Instructions
|
### Instructions
|
||||||
|
|
||||||
|
|
||||||
You have these skills at your disposal, when it is clear they can help you solve a problem or you are asked to use them:
|
You have these skills at your disposal, when it is clear they can help you solve a problem or you are asked to use them:
|
||||||
• goose-doc-guide - Reference goose documentation to create, configure, or explain goose-specific features like recipes, extensions, sessions, and providers. You MUST fetch relevant goose docs before answering. You MUST NOT rely on training data or assumptions for any goose-specific fields, values, names, syntax, or commands.
|
• goose-doc-guide - Reference goose documentation to create, configure, or explain goose-specific features like recipes, extensions, sessions, and providers. You MUST fetch relevant goose docs before answering. You MUST NOT rely on training data or assumptions for any goose-specific fields, values, names, syntax, or commands.
|
||||||
|
## summarize
|
||||||
|
|
||||||
|
|
||||||
|
## summon
|
||||||
|
|
||||||
|
|
||||||
## todo
|
## todo
|
||||||
|
|
||||||
### Instructions
|
### Instructions
|
||||||
|
|||||||
Reference in New Issue
Block a user