diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 7599ce37e8..443fd25355 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -400,11 +400,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { }; agent - .apply_recipe_components( - recipe.and_then(|r| r.sub_recipes.clone()), - recipe.and_then(|r| r.response.clone()), - true, - ) + .apply_recipe_components(recipe.and_then(|r| r.response.clone()), true) .await; let new_provider = match create(&provider_name, model_config).await { diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 46c8c3d5f6..f198998a85 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -163,11 +163,7 @@ pub async fn apply_recipe_to_agent( include_final_output_tool: bool, ) -> Option { agent - .apply_recipe_components( - recipe.sub_recipes.clone(), - recipe.response.clone(), - include_final_output_tool, - ) + .apply_recipe_components(recipe.response.clone(), include_final_output_tool) .await; recipe.instructions.as_ref().map(|instructions| { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 798f54e25f..2a3ecc8e33 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -20,10 +20,6 @@ use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_ use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME; use crate::agents::prompt_manager::PromptManager; use crate::agents::retry::{RetryManager, RetryResult}; -use crate::agents::subagent_task_config::TaskConfig; -use crate::agents::subagent_tool::{ - create_subagent_tool, handle_subagent_tool, SUBAGENT_TOOL_NAME, -}; use crate::agents::types::{FrontendTool, SessionConfig, SharedProvider, ToolResultReceiver}; use crate::config::permission::PermissionManager; use crate::config::{get_enabled_extensions, Config, GooseMode}; @@ -42,11 +38,11 @@ use crate::permission::permission_judge::PermissionCheckResult; use crate::permission::PermissionConfirmation; use crate::providers::base::Provider; use crate::providers::errors::ProviderError; -use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe}; +use crate::recipe::{Author, Recipe, Response, Settings}; use crate::scheduler_trait::SchedulerTrait; use crate::security::security_inspector::SecurityInspector; use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; -use crate::session::{Session, SessionManager, SessionType}; +use crate::session::{Session, SessionManager}; use crate::tool_inspection::ToolInspectionManager; use crate::tool_monitor::RepetitionInspector; use crate::utils::is_token_cancelled; @@ -118,7 +114,6 @@ pub struct Agent { pub config: AgentConfig, pub extension_manager: Arc, - pub(super) sub_recipes: Mutex>, pub(super) final_output_tool: Arc>>, pub(super) frontend_tools: Mutex>, pub(super) frontend_instructions: Mutex>, @@ -204,7 +199,6 @@ impl Agent { provider: provider.clone(), config, extension_manager: Arc::new(ExtensionManager::new(provider.clone(), session_manager)), - sub_recipes: Mutex::new(HashMap::new()), final_output_tool: Arc::new(Mutex::new(None)), frontend_tools: Mutex::new(HashMap::new()), frontend_instructions: Mutex::new(None), @@ -446,23 +440,11 @@ impl Agent { self.extend_system_prompt(final_output_system_prompt).await; } - pub async fn add_sub_recipes(&self, sub_recipes_to_add: Vec) { - let mut sub_recipes = self.sub_recipes.lock().await; - for sr in sub_recipes_to_add { - sub_recipes.insert(sr.name.clone(), sr); - } - } - pub async fn apply_recipe_components( &self, - sub_recipes: Option>, response: Option, include_final_output: bool, ) { - if let Some(sub_recipes) = sub_recipes { - self.add_sub_recipes(sub_recipes).await; - } - if include_final_output { if let Some(response) = response { self.add_final_output_tool(response).await; @@ -479,18 +461,6 @@ impl Agent { cancellation_token: Option, session: &Session, ) -> (String, Result) { - // Prevent subagents from creating other subagents - if session.session_type == SessionType::SubAgent && tool_call.name == SUBAGENT_TOOL_NAME { - return ( - request_id, - Err(ErrorData::new( - ErrorCode::INVALID_REQUEST, - "Subagents cannot create other subagents".to_string(), - None, - )), - ); - } - if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME { let arguments = tool_call .arguments @@ -525,49 +495,7 @@ impl Agent { } debug!("WAITING_TOOL_START: {}", tool_call.name); - let result: ToolCallResult = if tool_call.name == SUBAGENT_TOOL_NAME { - let provider = match self.provider().await { - Ok(p) => p, - Err(_) => { - return ( - request_id, - Err(ErrorData::new( - ErrorCode::INTERNAL_ERROR, - "Provider is required".to_string(), - None, - )), - ); - } - }; - - let extensions = self.get_extension_configs().await; - - let max_turns_from_recipe = session - .recipe - .as_ref() - .and_then(|r| r.settings.as_ref()) - .and_then(|s| s.max_turns); - - let task_config = - TaskConfig::new(provider, &session.id, &session.working_dir, extensions) - .with_max_turns(max_turns_from_recipe); - let sub_recipes = self.sub_recipes.lock().await.clone(); - - let arguments = tool_call - .arguments - .clone() - .map(Value::Object) - .unwrap_or(Value::Object(serde_json::Map::new())); - - handle_subagent_tool( - &self.config, - arguments, - task_config, - sub_recipes, - session.working_dir.clone(), - cancellation_token, - ) - } else if self.is_frontend_tool(&tool_call.name).await { + let result: ToolCallResult = if self.is_frontend_tool(&tool_call.name).await { // For frontend tools, return an error indicating we need frontend execution ToolCallResult::from(Err(ErrorData::new( ErrorCode::INTERNAL_ERROR, @@ -795,30 +723,6 @@ impl Agent { Ok(()) } - pub async fn subagents_enabled(&self, session_id: &str) -> bool { - if self.config.goose_mode != GooseMode::Auto { - return false; - } - let context = self.extension_manager.get_context(); - if matches!( - context - .session_manager - .get_session(session_id, false) - .await - .ok() - .map(|session| session.session_type), - Some(SessionType::SubAgent) - ) { - return false; - } - !self - .extension_manager - .list_extensions() - .await - .map(|ext| ext.is_empty()) - .unwrap_or(true) - } - pub async fn list_tools(&self, session_id: &str, extension_name: Option) -> Vec { let mut prefixed_tools = self .extension_manager @@ -826,7 +730,6 @@ impl Agent { .await .unwrap_or_default(); - let subagents_enabled = self.subagents_enabled(session_id).await; if (extension_name.is_none() || extension_name.as_deref() == Some("platform")) && self.config.scheduler_service.is_some() { @@ -837,12 +740,6 @@ impl Agent { if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { prefixed_tools.push(final_output_tool.tool()); } - - if subagents_enabled { - let sub_recipes = self.sub_recipes.lock().await; - let sub_recipes_vec: Vec<_> = sub_recipes.values().cloned().collect(); - prefixed_tools.push(create_subagent_tool(&sub_recipes_vec)); - } } prefixed_tools diff --git a/crates/goose/src/agents/builtin_skills/mod.rs b/crates/goose/src/agents/builtin_skills/mod.rs deleted file mode 100644 index a94bddc625..0000000000 --- a/crates/goose/src/agents/builtin_skills/mod.rs +++ /dev/null @@ -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_builtin_skills() -> 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() -} diff --git a/crates/goose/src/agents/builtin_skills/skills/goose_doc_guide.md b/crates/goose/src/agents/builtin_skills/skills/goose_doc_guide.md deleted file mode 100644 index 268d23cb51..0000000000 --- a/crates/goose/src/agents/builtin_skills/skills/goose_doc_guide.md +++ /dev/null @@ -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://block.github.io/goose/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://block.github.io/goose/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://block.github.io/goose/docs/guides/sessions/session-management.md`, list it as `https://block.github.io/goose/docs/guides/sessions/session-management` diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index e2219e028c..2c115249aa 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -378,7 +378,7 @@ impl Agent { Err(e) => return Err(anyhow!("Failed to build recipe: {}", e)), }; - self.apply_recipe_components(recipe.sub_recipes.clone(), recipe.response.clone(), true) + self.apply_recipe_components(recipe.response.clone(), true) .await; let prompt = [recipe.instructions.as_deref(), recipe.prompt.as_deref()] diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 7ce3c1f4e5..0ab201d37c 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -2,7 +2,7 @@ use crate::agents::apps_extension; use crate::agents::chatrecall_extension; use crate::agents::code_execution_extension; use crate::agents::extension_manager_extension; -use crate::agents::skills_extension; +use crate::agents::summon_extension; use crate::agents::todo_extension; use std::collections::HashMap; @@ -95,13 +95,13 @@ pub static PLATFORM_EXTENSIONS: Lazy ); map.insert( - skills_extension::EXTENSION_NAME, + summon_extension::EXTENSION_NAME, PlatformExtensionDef { - name: skills_extension::EXTENSION_NAME, - display_name: "Skills", - description: "Load and use skills from relevant directories", + name: summon_extension::EXTENSION_NAME, + display_name: "Summon", + description: "Load knowledge and delegate tasks to subagents", default_enabled: true, - client_factory: |ctx| Box::new(skills_extension::SkillsClient::new(ctx).unwrap()), + client_factory: |ctx| Box::new(summon_extension::SummonClient::new(ctx).unwrap()), }, ); diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 7bea2baf7c..85f69b47b8 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -1,6 +1,5 @@ mod agent; pub(crate) mod apps_extension; -mod builtin_skills; pub(crate) mod chatrecall_extension; pub(crate) mod code_execution_extension; pub mod container; @@ -18,11 +17,10 @@ pub mod prompt_manager; mod reply_parts; pub mod retry; mod schedule_tool; -pub(crate) mod skills_extension; pub mod subagent_execution_tool; -pub mod subagent_handler; -mod subagent_task_config; -pub mod subagent_tool; +pub(crate) mod subagent_handler; +pub(crate) mod subagent_task_config; +pub(crate) mod summon_extension; pub(crate) mod todo_extension; mod tool_execution; pub mod types; diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 15664f6473..1b9b0d0ad4 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -8,8 +8,7 @@ use tracing::debug; use super::super::agents::Agent; use crate::agents::code_execution_extension::EXTENSION_NAME as CODE_EXECUTION_EXTENSION; -use crate::agents::skills_extension::EXTENSION_NAME as SKILLS_EXTENSION; -use crate::agents::subagent_tool::SUBAGENT_TOOL_NAME; +use crate::agents::summon_extension::EXTENSION_NAME as SUMMON_EXTENSION; use crate::conversation::message::{Message, MessageContent, ToolRequest}; use crate::conversation::Conversation; use crate::providers::base::{stream_from_single_message, MessageStream, Provider, ProviderUsage}; @@ -127,11 +126,9 @@ impl Agent { .await; if code_execution_active { let code_exec_prefix = format!("{CODE_EXECUTION_EXTENSION}__"); - let skills_prefix = format!("{SKILLS_EXTENSION}__"); + let summon_prefix = format!("{SUMMON_EXTENSION}__"); tools.retain(|tool| { - tool.name.starts_with(&code_exec_prefix) - || tool.name.starts_with(&skills_prefix) - || tool.name == SUBAGENT_TOOL_NAME + tool.name.starts_with(&code_exec_prefix) || tool.name.starts_with(&summon_prefix) }); } @@ -157,7 +154,6 @@ impl Agent { .with_extension_and_tool_counts(extension_count, tool_count) .with_code_execution_mode(code_execution_active) .with_hints(working_dir) - .with_enable_subagents(self.subagents_enabled(session_id).await) .build(); // Handle toolshim if enabled diff --git a/crates/goose/src/agents/skills_extension.rs b/crates/goose/src/agents/skills_extension.rs deleted file mode 100644 index 1b41d11f7c..0000000000 --- a/crates/goose/src/agents/skills_extension.rs +++ /dev/null @@ -1,865 +0,0 @@ -use super::builtin_skills; -use crate::agents::extension::PlatformExtensionContext; -use crate::agents::mcp_client::{Error, McpClientTrait}; -use crate::config::paths::Paths; -use anyhow::Result; -use async_trait::async_trait; -use indoc::indoc; -use rmcp::model::{ - CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, - ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations, ToolsCapability, -}; -use schemars::{schema_for, JsonSchema}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use tokio_util::sync::CancellationToken; - -pub static EXTENSION_NAME: &str = "skills"; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -struct LoadSkillParams { - name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct SkillMetadata { - name: String, - description: String, -} - -#[derive(Debug, Clone)] -struct Skill { - metadata: SkillMetadata, - body: String, - directory: PathBuf, - supporting_files: Vec, -} - -pub struct SkillsClient { - info: InitializeResult, - skills: HashMap, -} - -impl SkillsClient { - pub fn new(_context: PlatformExtensionContext) -> Result { - let info = InitializeResult { - protocol_version: ProtocolVersion::V_2025_03_26, - capabilities: ServerCapabilities { - tasks: None, - tools: Some(ToolsCapability { - list_changed: Some(false), - }), - resources: None, - prompts: None, - completions: None, - experimental: None, - logging: None, - }, - server_info: Implementation { - name: EXTENSION_NAME.to_string(), - title: Some("Skills".to_string()), - version: "1.0.0".to_string(), - icons: None, - website_url: None, - }, - instructions: Some(String::new()), - }; - - let mut skills = Self::load_builtin_skills(); - - let directories = Self::get_default_skill_directories() - .into_iter() - .filter(|d| d.exists()) - .collect::>(); - let fs_skills = Self::discover_skills_in_directories(&directories); - skills.extend(fs_skills); - - let mut client = Self { info, skills }; - client.info.instructions = Some(client.generate_instructions()); - Ok(client) - } - - fn load_builtin_skills() -> HashMap { - let mut skills = HashMap::new(); - for content in builtin_skills::get_all_builtin_skills() { - if let Ok((metadata, body)) = Self::parse_frontmatter(content) { - skills.insert( - metadata.name.clone(), - Skill { - metadata, - body, - directory: PathBuf::new(), - supporting_files: vec![], - }, - ); - } - } - skills - } - - fn get_default_skill_directories() -> Vec { - let mut dirs = Vec::new(); - - if let Some(home) = dirs::home_dir() { - dirs.push(home.join(".claude/skills")); - dirs.push(home.join(".config/agents/skills")); - } - - dirs.push(Paths::config_dir().join("skills")); - - if let Ok(working_dir) = std::env::current_dir() { - dirs.push(working_dir.join(".claude/skills")); - dirs.push(working_dir.join(".goose/skills")); - dirs.push(working_dir.join(".agents/skills")); - } - - dirs - } - - fn parse_skill_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - - let (metadata, body) = Self::parse_frontmatter(&content)?; - - let directory = path - .parent() - .ok_or_else(|| anyhow::anyhow!("Skill file has no parent directory"))? - .to_path_buf(); - - let supporting_files = Self::find_supporting_files(&directory, path)?; - - Ok(Skill { - metadata, - body, - directory, - supporting_files, - }) - } - - fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String)> { - let parts: Vec<&str> = content.split("---").collect(); - - if parts.len() < 3 { - return Err(anyhow::anyhow!("Invalid frontmatter format")); - } - - let yaml_content = parts[1].trim(); - let metadata: SkillMetadata = serde_yaml::from_str(yaml_content)?; - - let body = parts[2..].join("---").trim().to_string(); - - Ok((metadata, body)) - } - - fn find_supporting_files(directory: &Path, skill_file: &Path) -> Result> { - let mut files = Vec::new(); - - if let Ok(entries) = std::fs::read_dir(directory) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() && path != skill_file { - files.push(path); - } else if path.is_dir() { - if let Ok(sub_entries) = std::fs::read_dir(&path) { - for sub_entry in sub_entries.flatten() { - let sub_path = sub_entry.path(); - if sub_path.is_file() { - files.push(sub_path); - } - } - } - } - } - } - - Ok(files) - } - - fn discover_skills_in_directories(directories: &[PathBuf]) -> HashMap { - let mut skills = HashMap::new(); - - for dir in directories { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let skill_file = path.join("SKILL.md"); - if skill_file.exists() { - if let Ok(skill) = Self::parse_skill_file(&skill_file) { - skills.insert(skill.metadata.name.clone(), skill); - } - } - } - } - } - } - - skills - } - - fn generate_instructions(&self) -> String { - if self.skills.is_empty() { - return String::new(); - } - - let mut instructions = String::from("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:\n\n"); - - let mut skill_list: Vec<_> = self.skills.iter().collect(); - skill_list.sort_by_key(|(name, _)| *name); - - for (name, skill) in skill_list { - instructions.push_str(&format!("- {}: {}\n", name, skill.metadata.description)); - } - - instructions - } - - async fn handle_load_skill( - &self, - arguments: Option, - ) -> Result, String> { - let skill_name = arguments - .as_ref() - .ok_or("Missing arguments")? - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: name")?; - - let skill = self - .skills - .get(skill_name) - .ok_or_else(|| format!("Skill '{}' not found", skill_name))?; - - let mut response = format!("# Skill: {}\n\n{}\n\n", skill.metadata.name, skill.body); - - if !skill.supporting_files.is_empty() { - response.push_str(&format!( - "## Supporting Files\n\nSkill directory: {}\n\n", - skill.directory.display() - )); - response.push_str("The following supporting files are available:\n"); - for file in &skill.supporting_files { - if let Ok(relative) = file.strip_prefix(&skill.directory) { - response.push_str(&format!("- {}\n", relative.display())); - } - } - response.push_str("\nUse the view file tools to access these files as needed, or run scripts as directed with dev extension.\n"); - } - - Ok(vec![Content::text(response)]) - } - - fn get_tools() -> Vec { - let schema = schema_for!(LoadSkillParams); - let schema_value = - serde_json::to_value(schema).expect("Failed to serialize LoadSkillParams schema"); - - let input_schema = schema_value - .as_object() - .expect("Schema should be an object") - .clone(); - - vec![Tool::new( - "loadSkill".to_string(), - indoc! {r#" - Load a skill by name and return its content. - - This tool loads the specified skill and returns its body content along with - information about any supporting files in the skill directory. - "#} - .to_string(), - input_schema, - ) - .annotate(ToolAnnotations { - title: Some("Load skill".to_string()), - read_only_hint: Some(true), - destructive_hint: Some(false), - idempotent_hint: Some(true), - open_world_hint: Some(false), - })] - } -} - -#[async_trait] -impl McpClientTrait for SkillsClient { - async fn list_tools( - &self, - _session_id: &str, - _next_cursor: Option, - _cancellation_token: CancellationToken, - ) -> Result { - let tools = if self.skills.is_empty() { - Vec::new() - } else { - Self::get_tools() - }; - Ok(ListToolsResult { - tools, - next_cursor: None, - meta: None, - }) - } - - async fn call_tool( - &self, - _session_id: &str, - name: &str, - arguments: Option, - _cancellation_token: CancellationToken, - ) -> Result { - let content = match name { - "loadSkill" => self.handle_load_skill(arguments).await, - _ => Err(format!("Unknown tool: {}", name)), - }; - - match content { - Ok(content) => Ok(CallToolResult::success(content)), - Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( - "Error: {}", - error - ))])), - } - } - - fn get_info(&self) -> Option<&InitializeResult> { - Some(&self.info) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_parse_frontmatter() { - let content = r#"--- -name: test-skill -description: A test skill ---- - -# Test Skill - -This is the body of the skill. -"#; - - let (metadata, body) = SkillsClient::parse_frontmatter(content).unwrap(); - assert_eq!(metadata.name, "test-skill"); - assert_eq!(metadata.description, "A test skill"); - assert!(body.contains("# Test Skill")); - assert!(body.contains("This is the body of the skill.")); - } - - #[test] - fn test_parse_frontmatter_missing() { - let content = "# No frontmatter here"; - assert!(SkillsClient::parse_frontmatter(content).is_err()); - } - - #[test] - fn test_parse_frontmatter_unclosed() { - let content = r#"--- -name: test -description: test -"#; - assert!(SkillsClient::parse_frontmatter(content).is_err()); - } - - #[test] - fn test_parse_frontmatter_with_extra_fields() { - let content = r#"--- -name: test-skill -description: A test skill -author: Test Author -version: 1.0.0 -tags: - - test - - example -extra_field: some value ---- - -# Test Skill - -This is the body of the skill. -"#; - - let (metadata, body) = SkillsClient::parse_frontmatter(content).unwrap(); - assert_eq!(metadata.name, "test-skill"); - assert_eq!(metadata.description, "A test skill"); - assert!(body.contains("# Test Skill")); - assert!(body.contains("This is the body of the skill.")); - } - - #[test] - fn test_parse_skill_file() { - let temp_dir = TempDir::new().unwrap(); - let skill_dir = temp_dir.path().join("test-skill"); - fs::create_dir(&skill_dir).unwrap(); - - let skill_file = skill_dir.join("SKILL.md"); - fs::write( - &skill_file, - r#"--- -name: test-skill -description: A test skill ---- - -# Test Skill Content -"#, - ) - .unwrap(); - - fs::write(skill_dir.join("helper.py"), "print('hello')").unwrap(); - fs::create_dir(skill_dir.join("templates")).unwrap(); - fs::write(skill_dir.join("templates/template.txt"), "template").unwrap(); - - let skill = SkillsClient::parse_skill_file(&skill_file).unwrap(); - assert_eq!(skill.metadata.name, "test-skill"); - assert_eq!(skill.metadata.description, "A test skill"); - assert!(skill.body.contains("# Test Skill Content")); - assert_eq!(skill.supporting_files.len(), 2); - } - - #[test] - fn test_discover_skills() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir(&skills_dir).unwrap(); - - let skill1_dir = skills_dir.join("test-skill-one-a1b2c3"); - fs::create_dir(&skill1_dir).unwrap(); - fs::write( - skill1_dir.join("SKILL.md"), - r#"--- -name: test-skill-one-a1b2c3 -description: First test skill ---- -Body 1 -"#, - ) - .unwrap(); - - let skill2_dir = skills_dir.join("test-skill-two-d4e5f6"); - fs::create_dir(&skill2_dir).unwrap(); - fs::write( - skill2_dir.join("SKILL.md"), - r#"--- -name: test-skill-two-d4e5f6 -description: Second test skill ---- -Body 2 -"#, - ) - .unwrap(); - - let skill3_dir = skills_dir.join("test-skill-three-g7h8i9"); - fs::create_dir(&skill3_dir).unwrap(); - fs::write( - skill3_dir.join("SKILL.md"), - r#"--- -name: test-skill-three-g7h8i9 -description: Third test skill ---- -Body 3 -"#, - ) - .unwrap(); - - let skills = SkillsClient::discover_skills_in_directories(&[skills_dir]); - - assert_eq!(skills.len(), 3); - assert!(skills.contains_key("test-skill-one-a1b2c3")); - assert!(skills.contains_key("test-skill-two-d4e5f6")); - assert!(skills.contains_key("test-skill-three-g7h8i9")); - } - - #[test] - fn test_discover_skills_from_multiple_directories() { - let temp_dir = TempDir::new().unwrap(); - - let dir1 = temp_dir.path().join("dir1"); - fs::create_dir(&dir1).unwrap(); - let skill1_dir = dir1.join("skill-from-dir1"); - fs::create_dir(&skill1_dir).unwrap(); - fs::write( - skill1_dir.join("SKILL.md"), - r#"--- -name: skill-from-dir1 -description: Skill from directory 1 ---- -Content from dir1 -"#, - ) - .unwrap(); - - let dir2 = temp_dir.path().join("dir2"); - fs::create_dir(&dir2).unwrap(); - let skill2_dir = dir2.join("skill-from-dir2"); - fs::create_dir(&skill2_dir).unwrap(); - fs::write( - skill2_dir.join("SKILL.md"), - r#"--- -name: skill-from-dir2 -description: Skill from directory 2 ---- -Content from dir2 -"#, - ) - .unwrap(); - - let dir3 = temp_dir.path().join("dir3"); - fs::create_dir(&dir3).unwrap(); - let skill3_dir = dir3.join("skill-from-dir3"); - fs::create_dir(&skill3_dir).unwrap(); - fs::write( - skill3_dir.join("SKILL.md"), - r#"--- -name: skill-from-dir3 -description: Skill from directory 3 ---- -Content from dir3 -"#, - ) - .unwrap(); - - let skills = SkillsClient::discover_skills_in_directories(&[dir1, dir2, dir3]); - - assert_eq!(skills.len(), 3); - assert!(skills.contains_key("skill-from-dir1")); - assert!(skills.contains_key("skill-from-dir2")); - assert!(skills.contains_key("skill-from-dir3")); - - assert_eq!( - skills.get("skill-from-dir1").unwrap().metadata.description, - "Skill from directory 1" - ); - assert_eq!( - skills.get("skill-from-dir2").unwrap().metadata.description, - "Skill from directory 2" - ); - assert_eq!( - skills.get("skill-from-dir3").unwrap().metadata.description, - "Skill from directory 3" - ); - } - - #[test] - fn test_empty_instructions_when_no_skills() { - let temp_dir = TempDir::new().unwrap(); - let empty_dir = temp_dir.path().join("empty"); - fs::create_dir(&empty_dir).unwrap(); - - let skills = SkillsClient::discover_skills_in_directories(&[empty_dir]); - assert_eq!(skills.len(), 0); - - let mut client = SkillsClient { - info: InitializeResult { - protocol_version: ProtocolVersion::V_2025_03_26, - capabilities: ServerCapabilities { - tasks: None, - tools: Some(ToolsCapability { - list_changed: Some(false), - }), - resources: None, - prompts: None, - completions: None, - experimental: None, - logging: None, - }, - server_info: Implementation { - name: EXTENSION_NAME.to_string(), - title: Some("Skills".to_string()), - version: "1.0.0".to_string(), - icons: None, - website_url: None, - }, - instructions: Some(String::new()), - }, - skills, - }; - - let instructions = client.generate_instructions(); - assert_eq!(instructions, ""); - assert!(instructions.is_empty()); - - client.info.instructions = Some(instructions); - assert_eq!(client.info.instructions.as_ref().unwrap(), ""); - } - - #[tokio::test] - async fn test_no_tools_when_no_skills() { - let temp_dir = TempDir::new().unwrap(); - let empty_dir = temp_dir.path().join("empty"); - fs::create_dir(&empty_dir).unwrap(); - - let skills = SkillsClient::discover_skills_in_directories(&[empty_dir]); - assert_eq!(skills.len(), 0); - - let client = SkillsClient { - info: InitializeResult { - protocol_version: ProtocolVersion::V_2025_03_26, - capabilities: ServerCapabilities { - tasks: None, - tools: Some(ToolsCapability { - list_changed: Some(false), - }), - resources: None, - prompts: None, - completions: None, - experimental: None, - logging: None, - }, - server_info: Implementation { - name: EXTENSION_NAME.to_string(), - title: Some("Skills".to_string()), - version: "1.0.0".to_string(), - icons: None, - website_url: None, - }, - instructions: Some(String::new()), - }, - skills, - }; - - let result = client - .list_tools("test-session-id", None, CancellationToken::new()) - .await - .unwrap(); - assert_eq!(result.tools.len(), 0); - } - - #[tokio::test] - async fn test_tools_available_when_skills_exist() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir(&skills_dir).unwrap(); - - let skill_dir = skills_dir.join("test-skill"); - fs::create_dir(&skill_dir).unwrap(); - fs::write( - skill_dir.join("SKILL.md"), - r#"--- -name: test-skill -description: A test skill ---- -Content -"#, - ) - .unwrap(); - - let skills = SkillsClient::discover_skills_in_directories(&[skills_dir]); - assert_eq!(skills.len(), 1); - - let client = SkillsClient { - info: InitializeResult { - protocol_version: ProtocolVersion::V_2025_03_26, - capabilities: ServerCapabilities { - tasks: None, - tools: Some(ToolsCapability { - list_changed: Some(false), - }), - resources: None, - prompts: None, - completions: None, - experimental: None, - logging: None, - }, - server_info: Implementation { - name: EXTENSION_NAME.to_string(), - title: Some("Skills".to_string()), - version: "1.0.0".to_string(), - icons: None, - website_url: None, - }, - instructions: Some(String::new()), - }, - skills, - }; - - let result = client - .list_tools("test-session-id", None, CancellationToken::new()) - .await - .unwrap(); - assert_eq!(result.tools.len(), 1); - assert_eq!(result.tools[0].name, "loadSkill"); - } - - #[test] - fn test_instructions_with_skills() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir(&skills_dir).unwrap(); - - let skill1_dir = skills_dir.join("alpha-skill"); - fs::create_dir(&skill1_dir).unwrap(); - fs::write( - skill1_dir.join("SKILL.md"), - r#"--- -name: alpha-skill -description: First skill alphabetically ---- -Content -"#, - ) - .unwrap(); - - let skill2_dir = skills_dir.join("beta-skill"); - fs::create_dir(&skill2_dir).unwrap(); - fs::write( - skill2_dir.join("SKILL.md"), - r#"--- -name: beta-skill -description: Second skill alphabetically ---- -Content -"#, - ) - .unwrap(); - - let skills = SkillsClient::discover_skills_in_directories(&[skills_dir]); - assert_eq!(skills.len(), 2); - - let mut client = SkillsClient { - info: InitializeResult { - protocol_version: ProtocolVersion::V_2025_03_26, - capabilities: ServerCapabilities { - tasks: None, - tools: Some(ToolsCapability { - list_changed: Some(false), - }), - resources: None, - prompts: None, - completions: None, - experimental: None, - logging: None, - }, - server_info: Implementation { - name: EXTENSION_NAME.to_string(), - title: Some("Skills".to_string()), - version: "1.0.0".to_string(), - icons: None, - website_url: None, - }, - instructions: Some(String::new()), - }, - skills, - }; - - let instructions = client.generate_instructions(); - assert!(!instructions.is_empty()); - assert!(instructions.contains("You have these skills at your disposal")); - assert!(instructions.contains("alpha-skill: First skill alphabetically")); - assert!(instructions.contains("beta-skill: Second skill alphabetically")); - - let lines: Vec<&str> = instructions.lines().collect(); - let alpha_line = lines - .iter() - .position(|l| l.contains("alpha-skill")) - .unwrap(); - let beta_line = lines.iter().position(|l| l.contains("beta-skill")).unwrap(); - assert!(alpha_line < beta_line); - - client.info.instructions = Some(instructions); - assert!(!client.info.instructions.as_ref().unwrap().is_empty()); - } - - #[test] - fn test_discover_skills_working_dir_overrides_global() { - let temp_dir = TempDir::new().unwrap(); - - // Simulate ~/.claude/skills (global, lowest priority) - let global_claude = temp_dir.path().join("global-claude"); - fs::create_dir(&global_claude).unwrap(); - let skill_global_claude = global_claude.join("my-skill"); - fs::create_dir(&skill_global_claude).unwrap(); - fs::write( - skill_global_claude.join("SKILL.md"), - r#"--- -name: my-skill -description: From global claude ---- -Global claude content -"#, - ) - .unwrap(); - - // Simulate ~/.config/goose/skills (global, medium priority) - let global_goose = temp_dir.path().join("global-goose"); - fs::create_dir(&global_goose).unwrap(); - let skill_global_goose = global_goose.join("my-skill"); - fs::create_dir(&skill_global_goose).unwrap(); - fs::write( - skill_global_goose.join("SKILL.md"), - r#"--- -name: my-skill -description: From global goose config ---- -Global goose config content -"#, - ) - .unwrap(); - - // Simulate $PWD/.claude/skills (working dir, higher priority) - let working_claude = temp_dir.path().join("working-claude"); - fs::create_dir(&working_claude).unwrap(); - let skill_working_claude = working_claude.join("my-skill"); - fs::create_dir(&skill_working_claude).unwrap(); - fs::write( - skill_working_claude.join("SKILL.md"), - r#"--- -name: my-skill -description: From working dir claude ---- -Working dir claude content -"#, - ) - .unwrap(); - - // Simulate $PWD/.goose/skills (working dir, highest priority) - let working_goose = temp_dir.path().join("working-goose"); - fs::create_dir(&working_goose).unwrap(); - let skill_working_goose = working_goose.join("my-skill"); - fs::create_dir(&skill_working_goose).unwrap(); - fs::write( - skill_working_goose.join("SKILL.md"), - r#"--- -name: my-skill -description: From working dir goose ---- -Working dir goose content -"#, - ) - .unwrap(); - - // Test priority order: global_claude < global_goose < working_claude < working_goose - let skills = SkillsClient::discover_skills_in_directories(&[ - global_claude, - global_goose, - working_claude, - working_goose, - ]); - - assert_eq!(skills.len(), 1); - assert!(skills.contains_key("my-skill")); - // The last directory (working_goose) should win - assert_eq!( - skills.get("my-skill").unwrap().metadata.description, - "From working dir goose" - ); - assert!(skills - .get("my-skill") - .unwrap() - .body - .contains("Working dir goose content")); - } - - #[test] - fn test_builtin_skills_loaded() { - let skills = SkillsClient::load_builtin_skills(); - - assert!(!skills.is_empty()); - assert!(skills.contains_key("goose-doc-guide")); - } -} diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index d4ee6fba7a..00066d4e7d 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -144,7 +144,7 @@ fn get_agent_messages( let has_response_schema = recipe.response.is_some(); agent - .apply_recipe_components(recipe.sub_recipes.clone(), recipe.response.clone(), true) + .apply_recipe_components(recipe.response.clone(), true) .await; let tools = agent.list_tools(&session_id, None).await; diff --git a/crates/goose/src/agents/subagent_tool.rs b/crates/goose/src/agents/subagent_tool.rs deleted file mode 100644 index a043736207..0000000000 --- a/crates/goose/src/agents/subagent_tool.rs +++ /dev/null @@ -1,523 +0,0 @@ -use std::borrow::Cow; -use std::collections::HashMap; -use std::path::PathBuf; - -use anyhow::{anyhow, Result}; -use futures::FutureExt; -use rmcp::model::{Content, ErrorCode, ErrorData, Tool}; -use serde::Deserialize; -use serde_json::{json, Value}; -use tokio_util::sync::CancellationToken; - -use crate::agents::subagent_handler::run_complete_subagent_task; -use crate::agents::subagent_task_config::TaskConfig; -use crate::agents::tool_execution::ToolCallResult; -use crate::agents::AgentConfig; -use crate::providers; -use crate::recipe::build_recipe::build_recipe_from_template; -use crate::recipe::local_recipes::load_local_recipe_file; -use crate::recipe::{Recipe, SubRecipe}; - -pub const SUBAGENT_TOOL_NAME: &str = "subagent"; - -const SUMMARY_INSTRUCTIONS: &str = r#" -Important: Your parent agent will only receive your final message as a summary of your work. -Make sure your last message provides a comprehensive summary of: -- What you were asked to do -- What actions you took -- The results or outcomes -- Any important findings or recommendations - -Be concise but complete. -"#; - -#[derive(Debug, Deserialize)] -pub struct SubagentParams { - pub instructions: Option, - pub subrecipe: Option, - pub parameters: Option>, - pub extensions: Option>, - pub settings: Option, - #[serde(default = "default_summary")] - pub summary: bool, -} - -fn default_summary() -> bool { - true -} - -#[derive(Debug, Deserialize)] -pub struct SubagentSettings { - pub provider: Option, - pub model: Option, - pub temperature: Option, - pub max_turns: Option, -} - -pub fn create_subagent_tool(sub_recipes: &[SubRecipe]) -> Tool { - let description = build_tool_description(sub_recipes); - - let schema = json!({ - "type": "object", - "properties": { - "instructions": { - "type": "string", - "description": "Instructions for the subagent. Required for ad-hoc tasks. For predefined tasks, adds additional context." - }, - "subrecipe": { - "type": "string", - "description": "Name of a predefined subrecipe to run." - }, - "parameters": { - "type": "object", - "additionalProperties": true, - "description": "Parameters for the subrecipe. Only valid when 'subrecipe' is specified." - }, - "extensions": { - "type": "array", - "items": {"type": "string"}, - "description": "Extensions to enable. Omit to inherit all, empty array for none." - }, - "settings": { - "type": "object", - "properties": { - "provider": {"type": "string", "description": "Override LLM provider"}, - "model": {"type": "string", "description": "Override model"}, - "temperature": {"type": "number", "description": "Override temperature"}, - "max_turns": {"type": "number", "description": "Override max turns"} - }, - "description": "Override model/provider/settings." - }, - "summary": { - "type": "boolean", - "default": true, - "description": "If true (default), return only the subagent's final summary." - } - } - }); - - Tool::new( - SUBAGENT_TOOL_NAME, - description, - schema.as_object().unwrap().clone(), - ) -} - -fn build_tool_description(sub_recipes: &[SubRecipe]) -> String { - let mut desc = String::from( - "Delegate a task to a subagent that runs independently with its own context.\n\n\ - Modes:\n\ - 1. Ad-hoc: Provide `instructions` for a custom task\n\ - 2. Predefined: Provide `subrecipe` name to run a predefined task\n\ - 3. Augmented: Provide both `subrecipe` and `instructions` to add context\n\n\ - The subagent has access to the same tools as you by default. \ - Use `extensions` to limit which extensions the subagent can use.\n\n\ - For parallel execution, make multiple `subagent` tool calls in the same message.", - ); - - if !sub_recipes.is_empty() { - desc.push_str("\n\nAvailable subrecipes:"); - for sr in sub_recipes { - let params_info = get_subrecipe_params_description(sr); - let sequential_hint = if sr.sequential_when_repeated { - " [run sequentially, not in parallel]" - } else { - "" - }; - desc.push_str(&format!( - "\n• {}{} - {}{}", - sr.name, - sequential_hint, - sr.description.as_deref().unwrap_or("No description"), - if params_info.is_empty() { - String::new() - } else { - format!(" (params: {})", params_info) - } - )); - } - } - - desc -} - -fn get_subrecipe_params_description(sub_recipe: &SubRecipe) -> String { - match load_local_recipe_file(&sub_recipe.path) { - Ok(recipe_file) => match Recipe::from_content(&recipe_file.content) { - Ok(recipe) => { - if let Some(params) = recipe.parameters { - params - .iter() - .filter(|p| { - sub_recipe - .values - .as_ref() - .map(|v| !v.contains_key(&p.key)) - .unwrap_or(true) - }) - .map(|p| { - let req = match p.requirement { - crate::recipe::RecipeParameterRequirement::Required => "[required]", - _ => "[optional]", - }; - format!("{} {}", p.key, req) - }) - .collect::>() - .join(", ") - } else { - String::new() - } - } - Err(_) => String::new(), - }, - Err(_) => String::new(), - } -} - -/// Note: SubRecipe.sequential_when_repeated is surfaced as a hint in the tool description -/// (e.g., "[run sequentially, not in parallel]") but not enforced. The LLM controls -/// sequencing by making sequential vs parallel tool calls. -pub fn handle_subagent_tool( - config: &AgentConfig, - params: Value, - task_config: TaskConfig, - sub_recipes: HashMap, - working_dir: PathBuf, - cancellation_token: Option, -) -> ToolCallResult { - let parsed_params: SubagentParams = match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => { - return ToolCallResult::from(Err(ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from(format!("Invalid parameters: {}", e)), - data: None, - })); - } - }; - - if parsed_params.instructions.is_none() && parsed_params.subrecipe.is_none() { - return ToolCallResult::from(Err(ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from("Must provide 'instructions' or 'subrecipe' (or both)"), - data: None, - })); - } - - if parsed_params.parameters.is_some() && parsed_params.subrecipe.is_none() { - return ToolCallResult::from(Err(ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from("'parameters' can only be used with 'subrecipe'"), - data: None, - })); - } - - let recipe = match build_recipe(&parsed_params, &sub_recipes) { - Ok(r) => r, - Err(e) => { - return ToolCallResult::from(Err(ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from(e.to_string()), - data: None, - })); - } - }; - - let config = config.clone(); - ToolCallResult { - notification_stream: None, - result: Box::new( - execute_subagent( - config, - recipe, - task_config, - parsed_params, - working_dir, - cancellation_token, - ) - .boxed(), - ), - } -} - -async fn execute_subagent( - config: AgentConfig, - recipe: Recipe, - task_config: TaskConfig, - params: SubagentParams, - working_dir: PathBuf, - cancellation_token: Option, -) -> Result { - let session = config - .session_manager - .create_session( - working_dir, - "Subagent task".to_string(), - crate::session::session_manager::SessionType::SubAgent, - ) - .await - .map_err(|e| ErrorData { - code: ErrorCode::INTERNAL_ERROR, - message: Cow::from(format!("Failed to create session: {}", e)), - data: None, - })?; - - let task_config = apply_settings_overrides(task_config, ¶ms) - .await - .map_err(|e| ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from(e.to_string()), - data: None, - })?; - - let result = run_complete_subagent_task( - config, - recipe, - task_config, - params.summary, - session.id, - cancellation_token, - ) - .await; - - match result { - Ok(text) => Ok(rmcp::model::CallToolResult { - content: vec![Content::text(text)], - structured_content: None, - is_error: Some(false), - meta: None, - }), - Err(e) => Err(ErrorData { - code: ErrorCode::INTERNAL_ERROR, - message: Cow::from(e.to_string()), - data: None, - }), - } -} - -fn build_recipe( - params: &SubagentParams, - sub_recipes: &HashMap, -) -> Result { - let mut recipe = if let Some(subrecipe_name) = ¶ms.subrecipe { - build_subrecipe(subrecipe_name, params, sub_recipes)? - } else { - build_adhoc_recipe(params)? - }; - - if params.summary { - let current = recipe.instructions.unwrap_or_default(); - recipe.instructions = Some(format!("{}\n{}", current, SUMMARY_INSTRUCTIONS)); - } - - Ok(recipe) -} - -fn build_subrecipe( - subrecipe_name: &str, - params: &SubagentParams, - sub_recipes: &HashMap, -) -> Result { - let sub_recipe = sub_recipes.get(subrecipe_name).ok_or_else(|| { - let available: Vec<_> = sub_recipes.keys().cloned().collect(); - anyhow!( - "Unknown subrecipe '{}'. Available: {}", - subrecipe_name, - available.join(", ") - ) - })?; - - let recipe_file = load_local_recipe_file(&sub_recipe.path) - .map_err(|e| anyhow!("Failed to load subrecipe '{}': {}", subrecipe_name, e))?; - - let mut param_values: Vec<(String, String)> = Vec::new(); - - if let Some(values) = &sub_recipe.values { - for (k, v) in values { - param_values.push((k.clone(), v.clone())); - } - } - - if let Some(provided_params) = ¶ms.parameters { - for (k, v) in provided_params { - let value_str = match v { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - param_values.push((k.clone(), value_str)); - } - } - - let mut recipe = build_recipe_from_template( - recipe_file.content, - &recipe_file.parent_dir, - param_values, - None:: Result>, - ) - .map_err(|e| anyhow!("Failed to build subrecipe: {}", e))?; - - if let Some(extra) = ¶ms.instructions { - let mut current = recipe.instructions.take().unwrap_or_default(); - if !current.is_empty() { - current.push_str("\n\n"); - } - current.push_str(extra); - recipe.instructions = Some(current); - } - - Ok(recipe) -} - -fn build_adhoc_recipe(params: &SubagentParams) -> Result { - let instructions = params - .instructions - .as_ref() - .ok_or_else(|| anyhow!("Instructions required for ad-hoc task"))?; - - let recipe = Recipe::builder() - .version("1.0.0") - .title("Subagent Task") - .description("Ad-hoc subagent task") - .instructions(instructions) - .build() - .map_err(|e| anyhow!("Failed to build recipe: {}", e))?; - - if recipe.check_for_security_warnings() { - return Err(anyhow!("Recipe contains potentially harmful content")); - } - - Ok(recipe) -} - -async fn apply_settings_overrides( - mut task_config: TaskConfig, - params: &SubagentParams, -) -> Result { - if let Some(settings) = ¶ms.settings { - if let Some(max_turns) = settings.max_turns { - task_config.max_turns = Some(max_turns); - } - - if settings.provider.is_some() || settings.model.is_some() || settings.temperature.is_some() - { - let provider_name = settings - .provider - .clone() - .unwrap_or_else(|| task_config.provider.get_name().to_string()); - - let mut model_config = task_config.provider.get_model_config(); - - if let Some(model) = &settings.model { - model_config.model_name = model.clone(); - } - - if let Some(temp) = settings.temperature { - model_config = model_config.with_temperature(Some(temp)); - } - - task_config.provider = providers::create(&provider_name, model_config) - .await - .map_err(|e| anyhow!("Failed to create provider '{}': {}", provider_name, e))?; - } - } - - if let Some(extension_names) = ¶ms.extensions { - if extension_names.is_empty() { - task_config.extensions = Vec::new(); - } else { - task_config - .extensions - .retain(|ext| extension_names.contains(&ext.name())); - } - } - - Ok(task_config) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tool_name() { - assert_eq!(SUBAGENT_TOOL_NAME, "subagent"); - } - - #[test] - fn test_create_tool_without_subrecipes() { - let tool = create_subagent_tool(&[]); - assert_eq!(tool.name, "subagent"); - assert!(tool.description.as_ref().unwrap().contains("Ad-hoc")); - assert!(!tool - .description - .as_ref() - .unwrap() - .contains("Available subrecipes")); - } - - #[test] - fn test_create_tool_with_subrecipes() { - let sub_recipes = vec![SubRecipe { - name: "test_recipe".to_string(), - path: "test.yaml".to_string(), - values: None, - sequential_when_repeated: false, - description: Some("A test recipe".to_string()), - }]; - - let tool = create_subagent_tool(&sub_recipes); - assert!(tool - .description - .as_ref() - .unwrap() - .contains("Available subrecipes")); - assert!(tool.description.as_ref().unwrap().contains("test_recipe")); - } - - #[test] - fn test_sequential_hint_in_description() { - let sub_recipes = vec![ - SubRecipe { - name: "parallel_ok".to_string(), - path: "test.yaml".to_string(), - values: None, - sequential_when_repeated: false, - description: Some("Can run in parallel".to_string()), - }, - SubRecipe { - name: "sequential_only".to_string(), - path: "test.yaml".to_string(), - values: None, - sequential_when_repeated: true, - description: Some("Must run sequentially".to_string()), - }, - ]; - - let tool = create_subagent_tool(&sub_recipes); - let desc = tool.description.as_ref().unwrap(); - - assert!(desc.contains("parallel_ok")); - assert!(!desc.contains("parallel_ok [run sequentially")); - - assert!(desc.contains("sequential_only [run sequentially, not in parallel]")); - } - - #[test] - fn test_params_deserialization_full() { - let params: SubagentParams = serde_json::from_value(json!({ - "instructions": "Extra context", - "subrecipe": "my_recipe", - "parameters": {"key": "value"}, - "extensions": ["developer"], - "settings": {"model": "gpt-4"}, - "summary": false - })) - .unwrap(); - - assert_eq!(params.instructions, Some("Extra context".to_string())); - assert_eq!(params.subrecipe, Some("my_recipe".to_string())); - assert!(params.parameters.is_some()); - assert_eq!(params.extensions, Some(vec!["developer".to_string()])); - assert!(!params.summary); - } -} diff --git a/crates/goose/src/agents/summon_extension.rs b/crates/goose/src/agents/summon_extension.rs new file mode 100644 index 0000000000..4387ddd905 --- /dev/null +++ b/crates/goose/src/agents/summon_extension.rs @@ -0,0 +1,1867 @@ +//! Summon Extension - Unified tooling for recipes, skills, and subagents +//! +//! Provides two tools: +//! - `load`: Inject knowledge into current context or discover available sources +//! - `delegate`: Run tasks in isolated subagents (sync or async) + +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::mcp_client::{Error, McpClientTrait}; +use crate::agents::subagent_handler::run_complete_subagent_task; +use crate::agents::subagent_task_config::TaskConfig; +use crate::agents::{Agent, AgentConfig, AgentEvent, SessionConfig}; +use crate::config::paths::Paths; +use crate::conversation::message::Message; +use crate::prompt_template::render_template; +use crate::providers; +use crate::recipe::build_recipe::build_recipe_from_template; +use crate::recipe::local_recipes::load_local_recipe_file; +use crate::recipe::{Recipe, Settings, RECIPE_FILE_EXTENSIONS}; +use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; +use crate::session::SessionType; +use anyhow::Result; +use async_trait::async_trait; +use futures::StreamExt; +use rmcp::model::{ + CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, + ProtocolVersion, ServerCapabilities, Tool, ToolsCapability, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::hash::Hash; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +pub static EXTENSION_NAME: &str = "summon"; + +/// Discovered source that can be loaded or delegated +#[derive(Debug, Clone)] +#[allow(dead_code)] // Fields used in TASK-02+ for source discovery +pub struct Source { + pub name: String, + pub kind: SourceKind, + pub description: String, + pub path: PathBuf, + pub content: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[allow(dead_code)] // Variants used in TASK-02+ for source discovery +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 { + /// Format the source content for loading into context + pub fn to_load_text(&self) -> String { + format!( + "## {} ({})\n\n{}\n\n### Content\n\n{}", + self.name, self.kind, self.description, self.content + ) + } +} + +/// Get the plural form of a source kind for display +fn kind_plural(kind: SourceKind) -> &'static str { + match kind { + SourceKind::Subrecipe => "Subrecipes", + SourceKind::Recipe => "Recipes", + SourceKind::Skill => "Skills", + SourceKind::Agent => "Agents", + SourceKind::BuiltinSkill => "Builtin Skills", + } +} + +/// Truncate a string to a maximum length, adding "..." if truncated +/// Handles UTF-8 properly by using char boundaries +fn truncate(s: &str, max_len: usize) -> String { + if s.chars().count() <= max_len { + s.to_string() + } else if max_len <= 3 { + "...".to_string() + } else { + let truncated: String = s.chars().take(max_len - 3).collect(); + format!("{}...", truncated) + } +} + +/// Parameters for the load tool +#[derive(Debug, Deserialize, JsonSchema)] +#[allow(dead_code)] // Fields used via serde deserialization in later tasks +pub struct LoadParams { + /// Name of the source to load. If omitted, lists all available sources. + pub source: Option, +} + +/// Parameters for the delegate tool +#[derive(Debug, Deserialize, JsonSchema)] +#[allow(dead_code)] // Fields used via serde deserialization in later tasks +pub struct DelegateParams { + /// Task instructions. Required for ad-hoc tasks. + pub instructions: Option, + /// Name of a recipe, skill, or agent to run. + pub source: Option, + /// Parameters for the source (only valid with source). + pub parameters: Option>, + /// Extensions to enable. Omit to inherit all, empty array for none. + pub extensions: Option>, + /// Override LLM provider. + pub provider: Option, + /// Override model. + pub model: Option, + /// Override temperature. + pub temperature: Option, + /// Run in background (default: false). + #[serde(default)] + pub r#async: bool, +} + +/// Active background task +pub struct BackgroundTask { + pub id: String, + pub description: String, + pub started_at: Instant, + pub turns: Arc, + pub last_activity: Arc, + pub handle: JoinHandle>, +} + +#[derive(Debug, Deserialize)] +struct SkillMetadata { + name: String, + description: String, +} + +#[derive(Debug, Deserialize)] +struct AgentMetadata { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + model: Option, +} + +fn parse_frontmatter 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)) +} + +fn parse_skill_content(content: &str, path: PathBuf) -> Option { + let (metadata, body): (SkillMetadata, String) = parse_frontmatter(content)?; + + Some(Source { + name: metadata.name, + kind: SourceKind::Skill, + description: metadata.description, + path, + content: body, + }) +} + +fn parse_agent_content(content: &str, path: PathBuf) -> Option { + let (metadata, body): (AgentMetadata, String) = parse_frontmatter(content)?; + + let description = metadata.description.unwrap_or_else(|| { + let model_info = metadata + .model + .as_ref() + .map(|m| format!(" ({})", translate_model_shorthand(m))) + .unwrap_or_default(); + format!("Claude agent{}", model_info) + }); + + Some(Source { + name: metadata.name, + kind: SourceKind::Agent, + description, + path, + content: body, + }) +} + +fn translate_model_shorthand(shorthand: &str) -> &str { + match shorthand.to_lowercase().as_str() { + "sonnet" => "claude-sonnet-4-20250514", + "opus" => "claude-opus-4-20250514", + "haiku" => "claude-haiku-3-20250514", + _ => shorthand, + } +} + +/// Round duration for MOIM display to avoid prompt cache invalidation +fn round_duration(d: Duration) -> String { + let secs = d.as_secs(); + if secs < 60 { + format!("{}s", (secs / 10) * 10) + } else { + format!("{}m", secs / 60) + } +} + +/// Get current epoch milliseconds +fn current_epoch_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +/// Get maximum number of concurrent background tasks +fn max_background_tasks() -> usize { + std::env::var("GOOSE_MAX_BACKGROUND_TASKS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5) +} + +/// Generate a short unique task ID +fn generate_task_id() -> String { + use std::sync::atomic::AtomicU64; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + let timestamp = current_epoch_millis() % 100000; + format!("task_{:05}_{:04}", timestamp, count % 10000) +} + +/// Context for rendering the subagent system prompt +#[derive(Serialize)] +struct SubagentPromptContext { + max_turns: usize, + subagent_id: String, + task_instructions: String, + tool_count: usize, + available_tools: String, +} + +/// Builtin skills content - inlined from the old builtin_skills module +fn get_builtin_skills() -> Vec<&'static str> { + vec![ + r#"--- +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://block.github.io/goose/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://block.github.io/goose/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://block.github.io/goose/docs/guides/sessions/session-management.md`, list it as `https://block.github.io/goose/docs/guides/sessions/session-management` +"#, + ] +} + +pub struct SummonClient { + info: InitializeResult, + #[allow(dead_code)] // Used in TASK-02+ for source discovery + context: PlatformExtensionContext, + #[allow(dead_code)] // Used in TASK-02+ for caching discovered sources + source_cache: Mutex)>>, + background_tasks: Mutex>, +} + +impl SummonClient { + pub fn new(context: PlatformExtensionContext) -> Result { + let info = InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tasks: None, + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Summon".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some( + "Load knowledge and delegate tasks to subagents using the summon extension." + .to_string(), + ), + }; + + Ok(Self { + info, + context, + source_cache: Mutex::new(None), + background_tasks: Mutex::new(HashMap::new()), + }) + } + + fn create_load_tool(&self) -> Tool { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Name of the source to load. If omitted, lists all available sources." + } + } + }); + + Tool::new( + "load", + "Load knowledge into your current context or discover available sources.\n\n\ + Call with no arguments to list all available sources (recipes, skills, agents).\n\ + Call with a source name to load its content into your context.\n\n\ + Examples:\n\ + - load() → Lists available sources\n\ + - load(source: \"rust-patterns\") → Loads the rust-patterns skill\n\n\ + Use this when you want to learn an approach or adopt expertise without delegating." + .to_string(), + schema.as_object().unwrap().clone(), + ) + } + + fn create_delegate_tool(&self) -> Tool { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "instructions": { + "type": "string", + "description": "Task instructions. Required for ad-hoc tasks." + }, + "source": { + "type": "string", + "description": "Name of a recipe, skill, or agent to run." + }, + "parameters": { + "type": "object", + "additionalProperties": true, + "description": "Parameters for the source (only valid with source)." + }, + "extensions": { + "type": "array", + "items": {"type": "string"}, + "description": "Extensions to enable. Omit to inherit all, empty array for none." + }, + "provider": { + "type": "string", + "description": "Override LLM provider." + }, + "model": { + "type": "string", + "description": "Override model." + }, + "temperature": { + "type": "number", + "description": "Override temperature." + }, + "async": { + "type": "boolean", + "default": false, + "description": "Run in background (default: false)." + } + } + }); + + Tool::new( + "delegate", + "Delegate a task to a subagent that runs independently with its own context.\n\n\ + Modes:\n\ + 1. Ad-hoc: Provide `instructions` for a custom task\n\ + 2. Source-based: Provide `source` name to run a recipe, skill, or agent\n\ + 3. Combined: Provide both `source` and `instructions` for additional context\n\n\ + Options:\n\ + - `extensions`: Limit which extensions the subagent can use\n\ + - `provider`, `model`, `temperature`: Override model/provider settings\n\ + - `async`: Run in background (default: false)\n\n\ + For parallel execution, make multiple delegate calls in the same message.\n\ + Background tasks report status automatically in context." + .to_string(), + schema.as_object().unwrap().clone(), + ) + } + + async fn get_working_dir(&self, session_id: &str) -> PathBuf { + self.context + .session_manager + .get_session(session_id, false) + .await + .ok() + .map(|s| s.working_dir) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) + } + + async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec { + 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) { + return sources.clone(); + } + } + let sources = self.discover_sources(session_id, working_dir).await; + *cache = Some((Instant::now(), working_dir.to_path_buf(), sources.clone())); + sources + } + + async fn resolve_source( + &self, + session_id: &str, + name: &str, + working_dir: &Path, + ) -> Option { + let sources = self.get_sources(session_id, working_dir).await; + sources.into_iter().find(|s| s.name == name) + } + + async fn discover_sources(&self, session_id: &str, working_dir: &Path) -> Vec { + let mut sources: Vec = Vec::new(); + let mut seen_names: std::collections::HashSet = std::collections::HashSet::new(); + + self.add_subrecipes(session_id, &mut sources, &mut seen_names) + .await; + self.add_local_recipes(working_dir, &mut sources, &mut seen_names); + self.add_local_skills(working_dir, &mut sources, &mut seen_names); + self.add_local_agents(working_dir, &mut sources, &mut seen_names); + self.add_recipe_path_recipes(&mut sources, &mut seen_names); + self.add_global_recipes(&mut sources, &mut seen_names); + self.add_global_skills(&mut sources, &mut seen_names); + self.add_global_agents(&mut sources, &mut seen_names); + self.add_builtin_skills(&mut sources, &mut seen_names); + + sources.sort_by(|a, b| (&a.kind, &a.name).cmp(&(&b.kind, &b.name))); + sources + } + + async fn add_subrecipes( + &self, + session_id: &str, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let session = match self + .context + .session_manager + .get_session(session_id, false) + .await + { + Ok(s) => s, + Err(_) => return, + }; + + let sub_recipes = match session.recipe.as_ref().and_then(|r| r.sub_recipes.as_ref()) { + Some(sr) => sr, + None => return, + }; + + for sr in sub_recipes { + if seen.contains(&sr.name) { + continue; + } + seen.insert(sr.name.clone()); + sources.push(Source { + name: sr.name.clone(), + kind: SourceKind::Subrecipe, + description: sr + .description + .clone() + .unwrap_or_else(|| format!("Subrecipe from {}", sr.path)), + path: PathBuf::from(&sr.path), + content: String::new(), + }); + } + } + + fn add_local_recipes( + &self, + working_dir: &Path, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let dirs = [ + working_dir.to_path_buf(), + working_dir.join(".goose/recipes"), + ]; + for dir in dirs { + self.scan_recipes_dir(&dir, SourceKind::Recipe, sources, seen); + } + } + + fn add_local_skills( + &self, + working_dir: &Path, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let dirs = [ + working_dir.join(".goose/skills"), + working_dir.join(".claude/skills"), + working_dir.join(".agents/skills"), + ]; + for dir in dirs { + self.scan_skills_dir(&dir, sources, seen); + } + } + + fn add_local_agents( + &self, + working_dir: &Path, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let dirs = [ + working_dir.join(".goose/agents"), + working_dir.join(".claude/agents"), + ]; + for dir in dirs { + self.scan_agents_dir(&dir, sources, seen); + } + } + + fn add_recipe_path_recipes( + &self, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let recipe_path = match std::env::var("GOOSE_RECIPE_PATH") { + Ok(p) => p, + Err(_) => return, + }; + + let separator = if cfg!(windows) { ';' } else { ':' }; + for dir in recipe_path.split(separator) { + self.scan_recipes_dir(Path::new(dir), SourceKind::Recipe, sources, seen); + } + } + + fn add_global_recipes( + &self, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let dir = Paths::config_dir().join("recipes"); + self.scan_recipes_dir(&dir, SourceKind::Recipe, sources, seen); + } + + fn add_global_skills( + &self, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let mut dirs = vec![Paths::config_dir().join("skills")]; + + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".claude/skills")); + dirs.push(home.join(".config/agents/skills")); + } + + for dir in dirs { + self.scan_skills_dir(&dir, sources, seen); + } + } + + fn add_global_agents( + &self, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let mut dirs = vec![Paths::config_dir().join("agents")]; + + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".claude/agents")); + } + + for dir in dirs { + self.scan_agents_dir(&dir, sources, seen); + } + } + + fn add_builtin_skills( + &self, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + for content in get_builtin_skills() { + 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 + }); + } + } + } + } + + fn scan_recipes_dir( + &self, + dir: &Path, + kind: SourceKind, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + 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_file() { + continue; + } + + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if !RECIPE_FILE_EXTENSIONS.contains(&ext) { + continue; + } + + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + if name.is_empty() || seen.contains(&name) { + continue; + } + + match Recipe::from_file_path(&path) { + Ok(recipe) => { + seen.insert(name.clone()); + sources.push(Source { + name, + kind, + description: recipe.description.clone(), + path: path.clone(), + content: recipe.instructions.clone().unwrap_or_default(), + }); + } + Err(e) => { + warn!("Failed to parse recipe {}: {}", path.display(), e); + } + } + } + } + + fn scan_skills_dir( + &self, + dir: &Path, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let skill_dir = entry.path(); + if !skill_dir.is_dir() { + continue; + } + + let skill_file = skill_dir.join("SKILL.md"); + if !skill_file.exists() { + 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(source) = parse_skill_content(&content, skill_file) { + if !seen.contains(&source.name) { + seen.insert(source.name.clone()); + sources.push(source); + } + } + } + } + + fn scan_agents_dir( + &self, + dir: &Path, + sources: &mut Vec, + seen: &mut std::collections::HashSet, + ) { + 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_file() { + continue; + } + + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext != "md" { + continue; + } + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + warn!("Failed to read agent file {}: {}", path.display(), e); + continue; + } + }; + + if let Some(source) = parse_agent_content(&content, path) { + if !seen.contains(&source.name) { + seen.insert(source.name.clone()); + sources.push(source); + } + } + } + } + + async fn handle_load( + &self, + session_id: &str, + arguments: Option, + ) -> Result, String> { + let source_name = arguments + .as_ref() + .and_then(|args| args.get("source")) + .and_then(|v| v.as_str()); + + let working_dir = self.get_working_dir(session_id).await; + + if source_name.is_none() { + return self.handle_load_discovery(session_id, &working_dir).await; + } + + self.handle_load_source(session_id, source_name.unwrap(), &working_dir) + .await + } + + /// Handle discovery mode - list all available sources + async fn handle_load_discovery( + &self, + session_id: &str, + working_dir: &Path, + ) -> Result, String> { + // Invalidate cache to ensure fresh results + { + let mut cache = self.source_cache.lock().await; + *cache = None; + } + + let sources = self.get_sources(session_id, working_dir).await; + + if sources.is_empty() { + return Ok(vec![Content::text( + "No sources available for load/delegate.\n\n\ + Sources will be discovered from:\n\ + • .goose/recipes/*.yaml (subrecipes)\n\ + • ~/.config/goose/recipes/*.yaml (recipes)\n\ + • ~/.config/goose/skills/*.md (skills)\n\ + • ~/.config/goose/agents/*.yaml (agents)\n\n\ + Source discovery will be implemented in TASK-02.", + )]); + } + + // Group by kind + let mut by_kind: HashMap> = HashMap::new(); + for source in &sources { + by_kind.entry(source.kind).or_default().push(source); + } + + // Format output + let mut output = String::from("Available sources for load/delegate:\n"); + + for kind in [ + SourceKind::Subrecipe, + SourceKind::Recipe, + SourceKind::Skill, + SourceKind::Agent, + SourceKind::BuiltinSkill, + ] { + if let Some(sources) = by_kind.get(&kind) { + output.push_str(&format!("\n{}:\n", kind_plural(kind))); + for source in sources { + output.push_str(&format!( + "• {} - {}\n", + source.name, + truncate(&source.description, 60) + )); + } + } + } + + output.push_str("\nUse load(source: \"name\") to load into context.\n"); + output.push_str("Use delegate(source: \"name\") to run as subagent."); + + Ok(vec![Content::text(output)]) + } + + /// Handle load mode - load a specific source by name + async fn handle_load_source( + &self, + session_id: &str, + name: &str, + working_dir: &Path, + ) -> Result, String> { + let source = self.resolve_source(session_id, name, working_dir).await; + + match source { + Some(source) => { + let content = source.to_load_text(); + + let output = format!( + "# Loaded: {} ({})\n\n{}\n\n---\nThis knowledge is now available in your context.", + source.name, source.kind, content + ); + + Ok(vec![Content::text(output)]) + } + None => { + // Get sources for suggestions + let sources = self.get_sources(session_id, working_dir).await; + let suggestions: Vec<&str> = sources + .iter() + .filter(|s| { + s.name.to_lowercase().contains(&name.to_lowercase()) + || name.to_lowercase().contains(&s.name.to_lowercase()) + }) + .take(3) + .map(|s| s.name.as_str()) + .collect(); + + let error_msg = if suggestions.is_empty() { + format!( + "Source '{}' not found. Use load() to see available sources.", + name + ) + } else { + format!( + "Source '{}' not found. Did you mean: {}?", + name, + suggestions.join(", ") + ) + }; + + Err(error_msg) + } + } + } + + async fn handle_delegate( + &self, + session_id: &str, + arguments: Option, + cancellation_token: CancellationToken, + ) -> Result, String> { + // Clean up completed tasks on every delegate call + self.cleanup_completed_tasks().await; + + let params: DelegateParams = arguments + .map(|args| serde_json::from_value(serde_json::Value::Object(args))) + .transpose() + .map_err(|e| format!("Invalid parameters: {}", e))? + .unwrap_or(DelegateParams { + instructions: None, + source: None, + parameters: None, + extensions: None, + provider: None, + model: None, + temperature: None, + r#async: false, + }); + + self.validate_delegate_params(¶ms)?; + + let session = self + .context + .session_manager + .get_session(session_id, false) + .await + .map_err(|e| format!("Failed to get session: {}", e))?; + + if session.session_type == SessionType::SubAgent { + return Err("Delegated tasks cannot spawn further delegations".to_string()); + } + + // Handle async delegation + if params.r#async { + return self.handle_async_delegate(session_id, params).await; + } + + let working_dir = session.working_dir.clone(); + let recipe = self + .build_delegate_recipe(¶ms, session_id, &working_dir) + .await?; + + let task_config = self + .build_task_config(¶ms, &session) + .await + .map_err(|e| format!("Failed to build task config: {}", e))?; + + let agent_config = AgentConfig::new( + self.context.session_manager.clone(), + crate::config::permission::PermissionManager::instance(), + None, + crate::config::GooseMode::Auto, + ); + + let subagent_session = self + .context + .session_manager + .create_session( + working_dir, + "Delegated task".to_string(), + SessionType::SubAgent, + ) + .await + .map_err(|e| format!("Failed to create subagent session: {}", e))?; + + let result = run_complete_subagent_task( + agent_config, + recipe, + task_config, + true, + subagent_session.id, + Some(cancellation_token), + ) + .await + .map_err(|e| format!("Delegation failed: {}", e))?; + + Ok(vec![Content::text(result)]) + } + + fn validate_delegate_params(&self, params: &DelegateParams) -> Result<(), String> { + if params.instructions.is_none() && params.source.is_none() { + return Err("Must provide 'instructions' or 'source' (or both)".to_string()); + } + + if params.parameters.is_some() && params.source.is_none() { + return Err("'parameters' can only be used with 'source'".to_string()); + } + + Ok(()) + } + + async fn build_delegate_recipe( + &self, + params: &DelegateParams, + session_id: &str, + working_dir: &Path, + ) -> Result { + let mut recipe = if let Some(source_name) = ¶ms.source { + self.build_source_recipe(source_name, params, session_id, working_dir) + .await? + } else { + self.build_adhoc_recipe(params)? + }; + + let summary_instructions = r#" +Important: Your parent agent will only receive your final message as a summary of your work. +Make sure your last message provides a comprehensive summary of: +- What you were asked to do +- What actions you took +- The results or outcomes +- Any important findings or recommendations + +Be concise but complete. +"#; + + let current = recipe.instructions.unwrap_or_default(); + recipe.instructions = Some(format!("{}\n{}", current, summary_instructions)); + + Ok(recipe) + } + + fn build_adhoc_recipe(&self, params: &DelegateParams) -> Result { + let instructions = params + .instructions + .as_ref() + .ok_or("Instructions required for ad-hoc task")?; + + Recipe::builder() + .version("1.0.0") + .title("Delegated Task") + .description("Ad-hoc delegated task") + .instructions(instructions) + .build() + .map_err(|e| format!("Failed to build recipe: {}", e)) + } + + async fn build_source_recipe( + &self, + source_name: &str, + params: &DelegateParams, + session_id: &str, + working_dir: &Path, + ) -> Result { + let source = self + .resolve_source(session_id, source_name, working_dir) + .await + .ok_or_else(|| format!("Source '{}' not found", source_name))?; + + let mut recipe = match source.kind { + SourceKind::Recipe | SourceKind::Subrecipe => { + self.build_recipe_from_source(&source, params, session_id) + .await? + } + SourceKind::Skill | SourceKind::BuiltinSkill => { + self.build_recipe_from_skill(&source, params)? + } + SourceKind::Agent => self.build_recipe_from_agent(&source, params)?, + }; + + if let Some(extra_instructions) = ¶ms.instructions { + if recipe.prompt.is_some() { + let current_prompt = recipe.prompt.take().unwrap(); + recipe.prompt = Some(format!("{}\n\n{}", current_prompt, extra_instructions)); + } else { + recipe.prompt = Some(extra_instructions.clone()); + } + } + + Ok(recipe) + } + + async fn build_recipe_from_source( + &self, + source: &Source, + params: &DelegateParams, + session_id: &str, + ) -> Result { + let session = self + .context + .session_manager + .get_session(session_id, false) + .await + .map_err(|e| format!("Failed to get session: {}", e))?; + + if source.kind == SourceKind::Subrecipe { + let sub_recipes = session.recipe.as_ref().and_then(|r| r.sub_recipes.as_ref()); + + if let Some(sub_recipes) = sub_recipes { + if let Some(sr) = sub_recipes.iter().find(|sr| sr.name == source.name) { + let recipe_file = load_local_recipe_file(&sr.path).map_err(|e| { + format!("Failed to load subrecipe '{}': {}", source.name, e) + })?; + + let mut param_values: Vec<(String, String)> = Vec::new(); + + if let Some(values) = &sr.values { + for (k, v) in values { + param_values.push((k.clone(), v.clone())); + } + } + + if let Some(provided_params) = ¶ms.parameters { + for (k, v) in provided_params { + let value_str = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + param_values.push((k.clone(), value_str)); + } + } + + return build_recipe_from_template( + recipe_file.content, + &recipe_file.parent_dir, + param_values, + None:: Result>, + ) + .map_err(|e| format!("Failed to build subrecipe: {}", e)); + } + } + } + + let recipe_file = load_local_recipe_file(source.path.to_str().unwrap_or("")) + .map_err(|e| format!("Failed to load recipe '{}': {}", source.name, e))?; + + let param_values: Vec<(String, String)> = params + .parameters + .as_ref() + .map(|p| { + p.iter() + .map(|(k, v)| { + let value_str = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), value_str) + }) + .collect() + }) + .unwrap_or_default(); + + build_recipe_from_template( + recipe_file.content, + &recipe_file.parent_dir, + param_values, + None:: Result>, + ) + .map_err(|e| format!("Failed to build recipe: {}", e)) + } + + fn build_recipe_from_skill( + &self, + source: &Source, + params: &DelegateParams, + ) -> Result { + let instructions = if params.instructions.is_some() { + source.content.clone() + } else { + format!( + "{}\n\nApply this knowledge to complete the task.", + source.content + ) + }; + + Recipe::builder() + .version("1.0.0") + .title(format!("Skill: {}", source.name)) + .description(source.description.clone()) + .instructions(&instructions) + .build() + .map_err(|e| format!("Failed to build recipe from skill: {}", e)) + } + + fn build_recipe_from_agent( + &self, + source: &Source, + params: &DelegateParams, + ) -> Result { + let agent_content = std::fs::read_to_string(&source.path) + .map_err(|e| format!("Failed to read agent file: {}", e))?; + + let (metadata, body): (AgentMetadata, String) = + parse_frontmatter(&agent_content).ok_or("Failed to parse agent frontmatter")?; + + let model = metadata + .model + .map(|m| translate_model_shorthand(&m).to_string()); + + let settings = model.map(|m| Settings { + goose_model: Some(m), + goose_provider: params.provider.clone(), + temperature: params.temperature, + max_turns: None, + }); + + let instructions = if params.instructions.is_some() { + body + } else { + format!("{}\n\nProceed with your expertise.", body) + }; + + let mut builder = Recipe::builder() + .version("1.0.0") + .title(format!("Agent: {}", source.name)) + .description(source.description.clone()) + .instructions(&instructions); + + if let Some(settings) = settings { + builder = builder.settings(settings); + } + + builder + .build() + .map_err(|e| format!("Failed to build recipe from agent: {}", e)) + } + + async fn build_task_config( + &self, + params: &DelegateParams, + session: &crate::session::Session, + ) -> Result { + let provider = self.resolve_provider(params, session).await?; + + let mut extensions = self.resolve_extensions(params, session)?; + + if let Some(filter) = ¶ms.extensions { + if filter.is_empty() { + extensions = Vec::new(); + } else { + extensions.retain(|ext| filter.contains(&ext.name())); + } + } + + let max_turns = self.resolve_max_turns(session); + + let mut task_config = + TaskConfig::new(provider, &session.id, &session.working_dir, extensions); + task_config.max_turns = Some(max_turns); + + Ok(task_config) + } + + async fn resolve_provider( + &self, + params: &DelegateParams, + session: &crate::session::Session, + ) -> Result, anyhow::Error> { + let provider_name = params + .provider + .clone() + .or_else(|| session.provider_name.clone()) + .ok_or_else(|| anyhow::anyhow!("No provider configured"))?; + + let mut model_config = session + .model_config + .clone() + .unwrap_or_else(|| crate::model::ModelConfig::new("default").unwrap()); + + if let Some(model) = ¶ms.model { + model_config.model_name = translate_model_shorthand(model).to_string(); + } + + if let Some(temp) = params.temperature { + model_config = model_config.with_temperature(Some(temp)); + } + + providers::create(&provider_name, model_config).await + } + + fn resolve_extensions( + &self, + _params: &DelegateParams, + session: &crate::session::Session, + ) -> Result, anyhow::Error> { + let extensions = EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|s| s.extensions) + .unwrap_or_else(crate::config::get_enabled_extensions); + + Ok(extensions) + } + + fn resolve_max_turns(&self, session: &crate::session::Session) -> usize { + const DEFAULT_MAX_TURNS: usize = 50; + + std::env::var("GOOSE_SUBAGENT_MAX_TURNS") + .ok() + .and_then(|v| v.parse().ok()) + .or_else(|| { + session + .recipe + .as_ref() + .and_then(|r| r.settings.as_ref()) + .and_then(|s| s.max_turns) + }) + .unwrap_or(DEFAULT_MAX_TURNS) + } + + /// Clean up completed background tasks and log their results + async fn cleanup_completed_tasks(&self) { + let mut tasks = self.background_tasks.lock().await; + let completed: Vec = tasks + .iter() + .filter(|(_, t)| t.handle.is_finished()) + .map(|(id, _)| id.clone()) + .collect(); + + for id in completed { + if let Some(task) = tasks.remove(&id) { + match task.handle.await { + Ok(Ok(result)) => { + info!( + "Background task {} completed: {}", + id, + truncate(&result, 100) + ); + } + Ok(Err(e)) => { + warn!("Background task {} failed: {}", id, e); + } + Err(e) => { + warn!("Background task {} panicked: {}", id, e); + } + } + } + } + } + + /// Get task description from params for MOIM display + fn get_task_description(params: &DelegateParams) -> String { + if let Some(source) = ¶ms.source { + if let Some(instructions) = ¶ms.instructions { + format!("{}: {}", source, truncate(instructions, 30)) + } else { + source.clone() + } + } else if let Some(instructions) = ¶ms.instructions { + truncate(instructions, 40) + } else { + "Unknown task".to_string() + } + } + + /// Handle async delegate - spawn background task and return immediately + async fn handle_async_delegate( + &self, + session_id: &str, + params: DelegateParams, + ) -> Result, String> { + // Clean up any completed tasks first + self.cleanup_completed_tasks().await; + + // Check task limit + let task_count = self.background_tasks.lock().await.len(); + let max_tasks = max_background_tasks(); + if task_count >= max_tasks { + return Err(format!( + "Maximum {} background tasks already running. Wait for completion or use sync mode.", + max_tasks + )); + } + + let session = self + .context + .session_manager + .get_session(session_id, false) + .await + .map_err(|e| format!("Failed to get session: {}", e))?; + + let working_dir = session.working_dir.clone(); + let recipe = self + .build_delegate_recipe(¶ms, session_id, &working_dir) + .await?; + + let task_config = self + .build_task_config(¶ms, &session) + .await + .map_err(|e| format!("Failed to build task config: {}", e))?; + + let task_id = generate_task_id(); + let description = truncate(&Self::get_task_description(¶ms), 40); + + let agent_config = AgentConfig::new( + self.context.session_manager.clone(), + crate::config::permission::PermissionManager::instance(), + None, + crate::config::GooseMode::Auto, + ); + + let subagent_session = self + .context + .session_manager + .create_session( + working_dir.clone(), + format!("Background task: {}", task_id), + SessionType::SubAgent, + ) + .await + .map_err(|e| format!("Failed to create subagent session: {}", e))?; + + // Create shared atomic counters for turn tracking + let turns = Arc::new(AtomicU32::new(0)); + let last_activity = Arc::new(AtomicU64::new(current_epoch_millis())); + + let turns_clone = Arc::clone(&turns); + let last_activity_clone = Arc::clone(&last_activity); + let subagent_session_id = subagent_session.id.clone(); + let max_turns = task_config.max_turns; + + // Spawn the background task with a fresh cancellation token (NOT the parent's) + let handle = tokio::spawn(async move { + run_background_subagent( + agent_config, + recipe, + task_config, + subagent_session_id, + turns_clone, + last_activity_clone, + max_turns, + ) + .await + }); + + // Create the background task entry with the shared counters + let task = BackgroundTask { + id: task_id.clone(), + description: description.clone(), + started_at: Instant::now(), + turns, + last_activity, + handle, + }; + + self.background_tasks + .lock() + .await + .insert(task_id.clone(), task); + + Ok(vec![Content::text(format!( + "Task {} started in background: \"{}\"\nStatus will appear in context.", + task_id, description + ))]) + } +} + +/// Run a subagent task in the background with turn tracking +async fn run_background_subagent( + config: AgentConfig, + recipe: Recipe, + task_config: TaskConfig, + session_id: String, + turns: Arc, + last_activity: Arc, + max_turns: Option, +) -> Result { + let system_instructions = recipe.instructions.clone().unwrap_or_default(); + let user_task = recipe + .prompt + .clone() + .unwrap_or_else(|| "Begin.".to_string()); + + let agent = Arc::new(Agent::with_config(config)); + + agent + .update_provider(task_config.provider, &session_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to set provider on sub agent: {}", e))?; + + for extension in task_config.extensions { + if let Err(e) = agent.add_extension(extension.clone(), &session_id).await { + debug!( + "Failed to add extension '{}' to subagent: {}", + extension.name(), + e + ); + } + } + + let has_response_schema = recipe.response.is_some(); + agent + .apply_recipe_components(recipe.response.clone(), true) + .await; + + let tools = agent.list_tools(&session_id, None).await; + let subagent_prompt = render_template( + "subagent_system.md", + &SubagentPromptContext { + max_turns: max_turns.unwrap_or(50), + subagent_id: session_id.clone(), + task_instructions: system_instructions, + tool_count: tools.len(), + available_tools: tools + .iter() + .map(|t| t.name.to_string()) + .collect::>() + .join(", "), + }, + ) + .map_err(|e| anyhow::anyhow!("Failed to render subagent system prompt: {}", e))?; + agent.override_system_prompt(subagent_prompt).await; + + let user_message = Message::user().with_text(user_task); + + if let Some(activities) = recipe.activities { + for activity in activities { + info!("Recipe activity: {}", activity); + } + } + + let session_config = SessionConfig { + id: session_id.clone(), + schedule_id: None, + max_turns: max_turns.map(|v| v as u32), + retry_config: recipe.retry, + }; + + // Use a fresh cancellation token - background task should NOT be cancelled + // when the parent tool call is cancelled + let cancellation_token = CancellationToken::new(); + + let mut stream = agent + .reply(user_message, session_config, Some(cancellation_token)) + .await + .map_err(|e| anyhow::anyhow!("Failed to get reply from agent: {}", e))?; + + let mut last_message_text = String::new(); + + while let Some(message_result) = stream.next().await { + match message_result { + Ok(AgentEvent::Message(msg)) => { + // Update turn count and activity timestamp + turns.fetch_add(1, Ordering::Relaxed); + last_activity.store(current_epoch_millis(), Ordering::Relaxed); + + // Extract text from the message for the final result + for content in &msg.content { + if let crate::conversation::message::MessageContent::Text(text_content) = + content + { + last_message_text = text_content.text.clone(); + } + } + } + Ok(AgentEvent::McpNotification(_)) | Ok(AgentEvent::ModelChange { .. }) => {} + Ok(AgentEvent::HistoryReplaced(_)) => {} + Err(e) => { + return Err(anyhow::anyhow!( + "Error receiving message from subagent: {}", + e + )); + } + } + } + + // Check for final output if response schema was provided + if has_response_schema { + if let Some(output) = agent + .final_output_tool + .lock() + .await + .as_ref() + .and_then(|tool| tool.final_output.clone()) + { + return Ok(output); + } + } + + if last_message_text.is_empty() { + Ok("Task completed (no text output)".to_string()) + } else { + Ok(last_message_text) + } +} + +#[async_trait] +impl McpClientTrait for SummonClient { + async fn list_tools( + &self, + session_id: &str, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + // Clean up completed tasks on list_tools calls + self.cleanup_completed_tasks().await; + + // Check if this is a subagent session - hide delegate tool if so + let is_subagent = self + .context + .session_manager + .get_session(session_id, false) + .await + .map(|s| s.session_type == SessionType::SubAgent) + .unwrap_or(false); + + let mut tools = vec![self.create_load_tool()]; + + // Only include delegate for non-subagent sessions + if !is_subagent { + tools.push(self.create_delegate_tool()); + } + + Ok(ListToolsResult { + tools, + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + session_id: &str, + name: &str, + arguments: Option, + cancellation_token: CancellationToken, + ) -> Result { + let content = match name { + "load" => self.handle_load(session_id, arguments).await, + "delegate" => { + self.handle_delegate(session_id, arguments, cancellation_token) + .await + } + _ => Err(format!("Unknown tool: {}", name)), + }; + + match content { + Ok(content) => Ok(CallToolResult::success(content)), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {}", + error + ))])), + } + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } + + async fn get_moim(&self, _session_id: &str) -> Option { + // First cleanup completed tasks (non-blocking check) + self.cleanup_completed_tasks().await; + + let tasks = self.background_tasks.lock().await; + if tasks.is_empty() { + return None; + } + + let mut lines = vec!["Background tasks:".to_string()]; + let now = current_epoch_millis(); + + for task in tasks.values() { + let elapsed = task.started_at.elapsed(); + let last_ms = task.last_activity.load(Ordering::Relaxed); + let idle_ms = now.saturating_sub(last_ms); + let idle = Duration::from_millis(idle_ms); + + lines.push(format!( + "• {}: \"{}\" - running {}, {} turns, idle {}", + task.id, + task.description, + round_duration(elapsed), + task.turns.load(Ordering::Relaxed), + round_duration(idle), + )); + } + + Some(lines.join("\n")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Arc; + use tempfile::TempDir; + + fn create_test_context() -> PlatformExtensionContext { + PlatformExtensionContext { + extension_manager: None, + session_manager: Arc::new(crate::session::SessionManager::instance()), + } + } + + #[test] + fn test_frontmatter_parsing() { + // Skill parsing + let skill = r#"--- +name: test-skill +description: A test skill +--- +Skill body here."#; + let source = parse_skill_content(skill, PathBuf::new()).unwrap(); + assert_eq!(source.name, "test-skill"); + assert_eq!(source.kind, SourceKind::Skill); + assert!(source.content.contains("Skill body")); + + // Agent parsing with model shorthand + let agent = r#"--- +name: reviewer +model: sonnet +--- +You review code."#; + let source = parse_agent_content(agent, PathBuf::new()).unwrap(); + assert_eq!(source.name, "reviewer"); + assert!(source.description.contains("claude-sonnet-4-20250514")); + + // Invalid frontmatter + assert!(parse_skill_content("no frontmatter", PathBuf::new()).is_none()); + assert!(parse_skill_content("---\nunclosed", PathBuf::new()).is_none()); + } + + #[tokio::test] + async fn test_source_discovery_and_priority() { + let temp_dir = TempDir::new().unwrap(); + + // Create skill in .goose/skills (higher priority) + let goose_skill = temp_dir.path().join(".goose/skills/my-skill"); + fs::create_dir_all(&goose_skill).unwrap(); + fs::write( + goose_skill.join("SKILL.md"), + "---\nname: my-skill\ndescription: goose version\n---\nContent", + ) + .unwrap(); + + // Create same skill in .claude/skills (lower priority) + let claude_skill = temp_dir.path().join(".claude/skills/my-skill"); + fs::create_dir_all(&claude_skill).unwrap(); + fs::write( + claude_skill.join("SKILL.md"), + "---\nname: my-skill\ndescription: claude version\n---\nContent", + ) + .unwrap(); + + // Create recipe + let recipes = temp_dir.path().join(".goose/recipes"); + fs::create_dir_all(&recipes).unwrap(); + fs::write( + recipes.join("test.yaml"), + "title: Test\ndescription: A recipe\ninstructions: Do it", + ) + .unwrap(); + + let client = SummonClient::new(create_test_context()).unwrap(); + let sources = client.discover_sources("test", temp_dir.path()).await; + + // First match wins - goose version should be used + let skill = sources.iter().find(|s| s.name == "my-skill").unwrap(); + assert_eq!(skill.description, "goose version"); + + // Recipe should be found + assert!(sources + .iter() + .any(|s| s.name == "test" && s.kind == SourceKind::Recipe)); + + // Builtin skills included + assert!(sources.iter().any(|s| s.kind == SourceKind::BuiltinSkill)); + + // Sources sorted by kind then name + let kinds: Vec<_> = sources.iter().map(|s| s.kind).collect(); + assert!(kinds.windows(2).all(|w| w[0] <= w[1])); + } + + #[tokio::test] + async fn test_client_tools_and_unknown_tool() { + let client = SummonClient::new(create_test_context()).unwrap(); + + // Both tools available + let result = client + .list_tools("test", None, CancellationToken::new()) + .await + .unwrap(); + let names: Vec<_> = result.tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"load") && names.contains(&"delegate")); + + // Unknown tool returns error + let result = client + .call_tool("test", "unknown", None, CancellationToken::new()) + .await + .unwrap(); + assert!(result.is_error.unwrap_or(false)); + } + + #[test] + fn test_duration_rounding_for_moim() { + // Under 60s rounds to 10s + assert_eq!(round_duration(Duration::from_secs(5)), "0s"); + assert_eq!(round_duration(Duration::from_secs(15)), "10s"); + assert_eq!(round_duration(Duration::from_secs(59)), "50s"); + + // 60s+ rounds to minutes + assert_eq!(round_duration(Duration::from_secs(60)), "1m"); + assert_eq!(round_duration(Duration::from_secs(90)), "1m"); + assert_eq!(round_duration(Duration::from_secs(120)), "2m"); + } + + #[test] + fn test_task_description_formatting() { + let make_params = |source: Option<&str>, instructions: Option<&str>| DelegateParams { + source: source.map(String::from), + instructions: instructions.map(String::from), + parameters: None, + extensions: None, + provider: None, + model: None, + temperature: None, + r#async: false, + }; + + assert_eq!( + SummonClient::get_task_description(&make_params(Some("recipe"), None)), + "recipe" + ); + assert_eq!( + SummonClient::get_task_description(&make_params(None, Some("do stuff"))), + "do stuff" + ); + assert_eq!( + SummonClient::get_task_description(&make_params(Some("r"), Some("task"))), + "r: task" + ); + + // Long instructions truncated + let long = "x".repeat(100); + let desc = SummonClient::get_task_description(&make_params(None, Some(&long))); + assert!(desc.len() <= 43 && desc.ends_with("...")); + } +} diff --git a/crates/goose/tests/subagent_tool_tests.rs b/crates/goose/tests/subagent_tool_tests.rs deleted file mode 100644 index 2350d31598..0000000000 --- a/crates/goose/tests/subagent_tool_tests.rs +++ /dev/null @@ -1,104 +0,0 @@ -use goose::agents::subagent_tool::{create_subagent_tool, SUBAGENT_TOOL_NAME}; -use goose::recipe::{Recipe, SubRecipe}; -use std::collections::HashMap; -use tempfile::TempDir; - -const RECIPE_TWO_PARAMS: &str = r#" -version: "1.0.0" -title: "Test Task" -description: "A test task" -instructions: "Process {{ first }} and {{ second }}" -parameters: - - key: first - input_type: string - requirement: required - description: "First param" - - key: second - input_type: string - requirement: required - description: "Second param" -"#; - -fn write_recipe(temp_dir: &TempDir, name: &str, content: &str) -> String { - let path = temp_dir.path().join(format!("{}.yaml", name)); - std::fs::write(&path, content).unwrap(); - path.to_string_lossy().to_string() -} - -fn make_subrecipe(path: String, name: &str, values: Option>) -> SubRecipe { - SubRecipe { - name: name.to_string(), - path, - values, - sequential_when_repeated: false, - description: Some(format!("{} description", name)), - } -} - -#[test] -fn test_tool_description_includes_subrecipe_params_and_filters_presets() { - let temp_dir = TempDir::new().unwrap(); - let path = write_recipe(&temp_dir, "mytask", RECIPE_TWO_PARAMS); - - let no_presets = make_subrecipe(path.clone(), "mytask", None); - let tool = create_subagent_tool(&[no_presets]); - let desc = tool.description.as_ref().unwrap(); - assert!(desc.contains("mytask")); - assert!(desc.contains("first [required]")); - assert!(desc.contains("second [required]")); - - let mut preset = HashMap::new(); - preset.insert("second".to_string(), "preset_value".to_string()); - let with_presets = make_subrecipe(path, "deploy", Some(preset)); - let tool = create_subagent_tool(&[with_presets]); - let params_section = tool - .description - .as_ref() - .unwrap() - .split("(params:") - .nth(1) - .unwrap_or(""); - assert!(params_section.contains("first")); - assert!(!params_section.contains("second")); -} - -#[test] -fn test_adhoc_recipe_builder_and_security_check() { - let recipe = Recipe::builder() - .version("1.0.0") - .title("Adhoc Task") - .description("An ad-hoc task") - .instructions("Do the thing") - .build() - .unwrap(); - - assert_eq!(recipe.title, "Adhoc Task"); - assert_eq!(recipe.instructions.as_ref().unwrap(), "Do the thing"); - assert!(!recipe.check_for_security_warnings()); -} - -#[test] -fn test_adhoc_tool_schema_properties() { - let tool = create_subagent_tool(&[]); - - assert_eq!(tool.name, SUBAGENT_TOOL_NAME); - assert!(tool.description.as_ref().unwrap().contains("Ad-hoc")); - assert!(!tool - .description - .as_ref() - .unwrap() - .contains("Available subrecipes")); - - let props = tool - .input_schema - .get("properties") - .unwrap() - .as_object() - .unwrap(); - assert!(props.contains_key("instructions")); - assert!(props.contains_key("subrecipe")); - assert!(props.contains_key("parameters")); - assert!(props.contains_key("extensions")); - assert!(props.contains_key("settings")); - assert!(props.contains_key("summary")); -}