Dedupe and organize skills/sources (#8731)

This commit is contained in:
Jack Amadeo
2026-04-23 15:07:45 -04:00
committed by GitHub
parent 586fa2b3c8
commit 5dd9f08d57
28 changed files with 1245 additions and 786 deletions
@@ -1,12 +0,0 @@
use include_dir::{include_dir, Dir};
static BUILTIN_SKILLS_DIR: Dir =
include_dir!("$CARGO_MANIFEST_DIR/src/agents/builtin_skills/skills");
pub fn get_all() -> Vec<&'static str> {
BUILTIN_SKILLS_DIR
.files()
.filter(|f| f.path().extension().is_some_and(|ext| ext == "md"))
.filter_map(|f| f.contents_utf8())
.collect()
}
@@ -1,56 +0,0 @@
---
name: goose-doc-guide
description: 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.
---
Use this skill when working with **goose-specific features**:
- Creating or editing recipes
- Configuring extensions or providers
- Explaining how goose features work
- Any goose configuration or setup task
Do NOT use this skill for:
- General coding tasks unrelated to goose
- Running existing recipes (just run them directly)
## Steps (COMPLETE ALL BEFORE RESPONDING)
1. **Fetch official docs**
- Fetch the doc map from `https://goose-docs.ai/goose-docs-map.md`
- Search the doc map for pages relevant to the user's topic and get the paths for these pages
- Use the EXACT paths from the doc map. For example:
- If doc map shows: `docs/guides/sessions/session-management.md`
- Fetch: `https://goose-docs.ai/docs/guides/sessions/session-management.md`
- Do NOT modify or guess paths.
- **ONLY fetch paths that are explicitly listed in the doc map - do not guess or infer URLs**
- Make multiple fetch calls in parallel and save to temp files
- Use the temp files for subsequent searches instead of re-fetching
2. **Create/modify content**
- For goose configuration files:
- Consult schema/field reference documentation first
- **Search the fetched docs to extract the complete schema for each element you plan to use**
- Extract example snippets to understand usage patterns
- Create your configuration based on reference specs, following example patterns
- **⚠️ STOP: Before showing the user, verify output content MUST match the schema and reference in the goose official documentation:**
- [ ] Field names match exactly as shown in docs
- [ ] Required fields/properties are present
- [ ] Value formats match examples (YAML/JSON syntax, data types, etc.)
- **If ANY verification fails, revise and repeat this step until ALL verifications pass**
- **DO NOT present unverified output to the user**
3. **MANDATORY VERIFICATION - CHECK ALL THESE ITEMS BEFORE STEP 4**
Before writing your final answer:
- [ ] You MUST NOT rely on training data or assumptions for any goose-specific fields, values, names, syntax, or commands.
- [ ] **Did you include "How to Use", CLI commands, or usage instructions?**
- If YES and user didn't ask for it → **REMOVE IT NOW**
- If YES and user asked for it → verify exact commands from fetched docs before including
- [ ] List all goose-specific items in your answer (commands, fields, syntax, values, how to use, explanations, etc.)
- [ ] For each item, verify it is correct according to the fetched docs. If not found, either fetch the relevant docs NOW and verify, or remove it (if user asked for it, state "I could not find documentation for [X]").
4. **Provide your answer and include a "Verification Completed" section**
- For EACH goose-specific item in your response, cite the specific doc file where you verified it
5. **List documentation links**
- Only include docs actually used
- Remove `.md` suffix from URLs
- Example: If you fetched `https://goose-docs.ai/docs/guides/sessions/session-management.md`, list it as `https://goose-docs.ai/docs/guides/sessions/session-management`
+4 -4
View File
@@ -140,8 +140,8 @@ impl Agent {
}
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
use super::platform_extensions::skills::list_installed_skills;
use super::platform_extensions::SourceKind;
use crate::skills::list_installed_skills;
use goose_sdk::custom_requests::SourceType;
let working_dir = self
.config
@@ -153,7 +153,7 @@ impl Agent {
let sources = list_installed_skills(working_dir.as_deref());
let skills: Vec<_> = sources
.iter()
.filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill))
.filter(|s| matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill))
.collect();
let mut output = String::new();
@@ -165,7 +165,7 @@ impl Agent {
} else {
output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len()));
for skill in &skills {
let kind_label = if skill.kind == SourceKind::BuiltinSkill {
let kind_label = if skill.source_type == SourceType::BuiltinSkill {
" *(builtin)*"
} else {
""
-1
View File
@@ -1,5 +1,4 @@
mod agent;
pub(crate) mod builtin_skills;
pub mod container;
pub mod execute_commands;
pub mod extension;
@@ -6,74 +6,16 @@ pub mod code_execution;
pub mod developer;
pub mod ext_manager;
pub mod orchestrator;
pub mod skills;
pub mod summarize;
pub mod summon;
pub mod todo;
pub mod tom;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::agents::mcp_client::McpClientTrait;
use crate::session::Session;
use once_cell::sync::Lazy;
use serde::Deserialize;
#[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,
) -> Result<Option<(T, String)>, serde_yaml::Error> {
let parts: Vec<&str> = content.split("---").collect();
if parts.len() < 3 {
return Ok(None);
}
let yaml_content = parts[1].trim();
let metadata: T = serde_yaml::from_str(yaml_content)?;
let body = parts[2..].join("---").trim().to_string();
Ok(Some((metadata, body)))
}
pub use ext_manager::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
@@ -248,15 +190,15 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
);
map.insert(
skills::EXTENSION_NAME,
crate::skills::EXTENSION_NAME,
PlatformExtensionDef {
name: skills::EXTENSION_NAME,
name: crate::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()),
client_factory: |ctx| Box::new(crate::skills::SkillsClient::new(ctx).unwrap()),
},
);
@@ -1,506 +0,0 @@
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) = match parse_frontmatter(content) {
Ok(Some(parsed)) => parsed,
Ok(None) => return None,
Err(e) => {
warn!("Failed to parse skill frontmatter: {}", e);
return None;
}
};
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));
}
}
@@ -1,4 +1,3 @@
use super::{parse_frontmatter, Source, SourceKind};
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait};
use crate::agents::subagent_handler::{run_subagent_task, OnMessageCallback, SubagentRunParams};
@@ -13,8 +12,10 @@ use crate::recipe::local_recipes::load_local_recipe_file;
use crate::recipe::{Recipe, Settings, RECIPE_FILE_EXTENSIONS};
use crate::session::extension_data::EnabledExtensionsState;
use crate::session::SessionType;
use crate::sources::parse_frontmatter;
use anyhow::Result;
use async_trait::async_trait;
use goose_sdk::custom_requests::{SourceEntry, SourceType};
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta,
ServerCapabilities, ServerNotification, Tool,
@@ -33,11 +34,11 @@ use tracing::{info, warn};
pub static EXTENSION_NAME: &str = "summon";
fn kind_plural(kind: SourceKind) -> &'static str {
fn kind_plural(kind: SourceType) -> &'static str {
match kind {
SourceKind::Subrecipe => "Subrecipes",
SourceKind::Recipe => "Recipes",
SourceKind::Agent => "Agents",
SourceType::Subrecipe => "Subrecipes",
SourceType::Recipe => "Recipes",
SourceType::Agent => "Agents",
_ => "Other",
}
}
@@ -95,7 +96,7 @@ struct AgentMetadata {
model: Option<String>,
}
fn parse_agent_content(content: &str, path: &Path) -> Option<Source> {
fn parse_agent_content(content: &str, path: &Path) -> Option<SourceEntry> {
let (metadata, body): (AgentMetadata, String) = match parse_frontmatter(content) {
Ok(Some(parsed)) => parsed,
Ok(None) => return None,
@@ -119,20 +120,21 @@ fn parse_agent_content(content: &str, path: &Path) -> Option<Source> {
format!("Agent{}", model_info)
});
Some(Source {
Some(SourceEntry {
source_type: SourceType::Agent,
name: metadata.name,
kind: SourceKind::Agent,
description,
path: path.to_path_buf(),
content: body,
directory: path.to_string_lossy().into_owned(),
global: false,
supporting_files: Vec::new(),
})
}
fn scan_recipes_from_dir(
dir: &Path,
kind: SourceKind,
sources: &mut Vec<Source>,
kind: SourceType,
sources: &mut Vec<SourceEntry>,
seen: &mut std::collections::HashSet<String>,
) {
let entries = match std::fs::read_dir(dir) {
@@ -164,12 +166,13 @@ fn scan_recipes_from_dir(
match Recipe::from_file_path(&path) {
Ok(recipe) => {
seen.insert(name.clone());
sources.push(Source {
sources.push(SourceEntry {
source_type: kind,
name,
kind,
description: recipe.description.clone(),
path: path.clone(),
content: recipe.instructions.clone().unwrap_or_default(),
directory: path.to_string_lossy().into_owned(),
global: false,
supporting_files: Vec::new(),
});
}
@@ -182,7 +185,7 @@ fn scan_recipes_from_dir(
fn scan_agents_from_dir(
dir: &Path,
sources: &mut Vec<Source>,
sources: &mut Vec<SourceEntry>,
seen: &mut std::collections::HashSet<String>,
) {
let entries = match std::fs::read_dir(dir) {
@@ -218,8 +221,8 @@ fn scan_agents_from_dir(
}
}
pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
let mut sources: Vec<Source> = Vec::new();
pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<SourceEntry> {
let mut sources: Vec<SourceEntry> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let home = dirs::home_dir();
@@ -266,7 +269,7 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
.collect();
for dir in local_recipe_dirs {
scan_recipes_from_dir(&dir, SourceKind::Recipe, &mut sources, &mut seen);
scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen);
}
for dir in local_agent_dirs {
@@ -274,7 +277,7 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
}
for dir in global_recipe_dirs {
scan_recipes_from_dir(&dir, SourceKind::Recipe, &mut sources, &mut seen);
scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen);
}
for dir in global_agent_dirs {
@@ -315,7 +318,7 @@ fn is_session_id(s: &str) -> bool {
pub struct SummonClient {
info: InitializeResult,
context: PlatformExtensionContext,
source_cache: Mutex<Option<(Instant, PathBuf, Vec<Source>)>>,
source_cache: Mutex<Option<(Instant, PathBuf, Vec<SourceEntry>)>>,
background_tasks: Mutex<HashMap<String, BackgroundTask>>,
completed_tasks: Mutex<HashMap<String, CompletedTask>>,
notification_subscribers: Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>>,
@@ -477,11 +480,11 @@ impl SummonClient {
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec<Source> {
async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec<SourceEntry> {
let fs_sources = self.get_filesystem_sources(working_dir).await;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut sources: Vec<Source> = Vec::new();
let mut sources: Vec<SourceEntry> = Vec::new();
self.add_subrecipes(session_id, &mut sources, &mut seen)
.await;
@@ -493,11 +496,11 @@ impl SummonClient {
}
}
sources.sort_by(|a, b| (&a.kind, &a.name).cmp(&(&b.kind, &b.name)));
sources.sort_by(|a, b| (&a.source_type, &a.name).cmp(&(&b.source_type, &b.name)));
sources
}
async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec<Source> {
async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec<SourceEntry> {
let mut cache = self.source_cache.lock().await;
if let Some((cached_at, cached_dir, sources)) = cache.as_ref() {
if cached_dir == working_dir && cached_at.elapsed() < Duration::from_secs(60) {
@@ -514,11 +517,11 @@ impl SummonClient {
session_id: &str,
name: &str,
working_dir: &Path,
) -> Result<Option<Source>, String> {
) -> Result<Option<SourceEntry>, String> {
let sources = self.get_sources(session_id, working_dir).await;
if let Some(mut source) = sources.iter().find(|s| s.name == name).cloned() {
if source.kind == SourceKind::Subrecipe && source.content.is_empty() {
if source.source_type == SourceType::Subrecipe && source.content.is_empty() {
source.content = self.load_subrecipe_content(session_id, &source.name).await;
}
return Ok(Some(source));
@@ -557,14 +560,14 @@ impl SummonClient {
}
}
fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec<Source> {
fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec<SourceEntry> {
discover_filesystem_sources(working_dir)
}
async fn add_subrecipes(
&self,
session_id: &str,
sources: &mut Vec<Source>,
sources: &mut Vec<SourceEntry>,
seen: &mut std::collections::HashSet<String>,
) {
let session = match self
@@ -590,12 +593,13 @@ impl SummonClient {
let description = self.build_subrecipe_description(sr).await;
sources.push(Source {
sources.push(SourceEntry {
source_type: SourceType::Subrecipe,
name: sr.name.clone(),
kind: SourceKind::Subrecipe,
description,
path: PathBuf::from(&sr.path),
content: String::new(),
directory: sr.path.clone(),
global: false,
supporting_files: Vec::new(),
});
}
@@ -841,8 +845,8 @@ impl SummonClient {
}
}
for kind in [SourceKind::Subrecipe, SourceKind::Recipe, SourceKind::Agent] {
let kind_sources: Vec<_> = sources.iter().filter(|s| s.kind == kind).collect();
for kind in [SourceType::Subrecipe, SourceType::Recipe, SourceType::Agent] {
let kind_sources: Vec<_> = sources.iter().filter(|s| s.source_type == kind).collect();
if !kind_sources.is_empty() {
output.push_str(&format!("\n{}:\n", kind_plural(kind)));
for source in kind_sources {
@@ -875,7 +879,7 @@ impl SummonClient {
let output = format!(
"# Loaded: {} ({})\n\n{}\n\n---\nThis knowledge is now available in your context.",
source.name, source.kind, content
source.name, source.source_type, content
);
Ok(vec![Content::text(output)])
@@ -1080,16 +1084,16 @@ impl SummonClient {
.await?
.ok_or_else(|| format!("Source '{}' not found", source_name))?;
let mut recipe = match source.kind {
SourceKind::Recipe | SourceKind::Subrecipe => {
let mut recipe = match source.source_type {
SourceType::Recipe | SourceType::Subrecipe => {
self.build_recipe_from_source(&source, params, session_id)
.await?
}
SourceKind::Agent => self.build_recipe_from_agent(&source, params)?,
SourceType::Agent => self.build_recipe_from_agent(&source, params)?,
_ => {
return Err(format!(
"Source '{}' has kind '{}' which cannot be delegated from summon",
source_name, source.kind
source_name, source.source_type
))
}
};
@@ -1108,7 +1112,7 @@ impl SummonClient {
async fn build_recipe_from_source(
&self,
source: &Source,
source: &SourceEntry,
params: &DelegateParams,
session_id: &str,
) -> Result<Recipe, String> {
@@ -1119,7 +1123,7 @@ impl SummonClient {
.await
.map_err(|e| format!("Failed to get session: {}", e))?;
if source.kind == SourceKind::Subrecipe {
if source.source_type == SourceType::Subrecipe {
let sub_recipes = session.recipe.as_ref().and_then(|r| r.sub_recipes.as_ref());
if let Some(sub_recipes) = sub_recipes {
@@ -1156,7 +1160,7 @@ impl SummonClient {
}
}
let recipe_file = load_local_recipe_file(source.path.to_str().unwrap_or(""))
let recipe_file = load_local_recipe_file(&source.directory)
.map_err(|e| format!("Failed to load recipe '{}': {}", source.name, e))?;
let param_values: Vec<(String, String)> = params
@@ -1186,13 +1190,13 @@ impl SummonClient {
fn build_recipe_from_agent(
&self,
source: &Source,
source: &SourceEntry,
params: &DelegateParams,
) -> Result<Recipe, String> {
let agent_content = if source.path.as_os_str().is_empty() {
let agent_content = if source.directory.is_empty() {
return Err("Agent source has no path".to_string());
} else {
std::fs::read_to_string(&source.path)
std::fs::read_to_string(&source.directory)
.map_err(|e| format!("Failed to read agent file: {}", e))?
};
@@ -1747,14 +1751,14 @@ You review code."#;
let recipe = sources
.iter()
.find(|s| s.name == "deploy" && s.kind == SourceKind::Recipe)
.find(|s| s.name == "deploy" && s.source_type == SourceType::Recipe)
.unwrap();
assert_eq!(recipe.description, "Deploy to production");
assert_eq!(recipe.content, "Run deploy steps");
let agent = sources
.iter()
.find(|s| s.name == "reviewer" && s.kind == SourceKind::Agent)
.find(|s| s.name == "reviewer" && s.source_type == SourceType::Agent)
.unwrap();
assert_eq!(agent.description, "Code reviewer");
assert!(agent.content.contains("You review code"));