mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: Add summon extension replacing subagent tool and skills extension
Implements unified 'summon' platform extension with two intuitive tools:
- load: Inject knowledge into current context ("teach me this")
- delegate: Run tasks in isolated subagents ("do this for me")
Key features:
- Source discovery from recipes, skills, agents with priority ordering
- Skill/agent frontmatter parsing with Claude model shorthand translation
- Sync and async delegation with background task tracking
- MOIM status reporting for background tasks with rounded durations
- Nested delegation prevention (subagents cannot spawn subagents)
- 60s TTL caching for source discovery
Removes:
- subagent_tool.rs (523 LOC)
- skills_extension.rs (865 LOC)
- builtin_skills/ directory
Adds:
- summon_extension.rs (1834 LOC) - consolidated implementation
The summon extension provides a cleaner mental model while maintaining
all existing functionality. Builtin skills are now inlined in the
extension.
Ref: GitHub Discussion #6202
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -163,11 +163,7 @@ pub async fn apply_recipe_to_agent(
|
||||
include_final_output_tool: bool,
|
||||
) -> Option<String> {
|
||||
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| {
|
||||
|
||||
@@ -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<ExtensionManager>,
|
||||
pub(super) sub_recipes: Mutex<HashMap<String, SubRecipe>>,
|
||||
pub(super) final_output_tool: Arc<Mutex<Option<FinalOutputTool>>>,
|
||||
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
|
||||
pub(super) frontend_instructions: Mutex<Option<String>>,
|
||||
@@ -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<SubRecipe>) {
|
||||
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<Vec<SubRecipe>>,
|
||||
response: Option<Response>,
|
||||
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<CancellationToken>,
|
||||
session: &Session,
|
||||
) -> (String, Result<ToolCallResult, ErrorData>) {
|
||||
// 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<String>) -> Vec<Tool> {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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()]
|
||||
|
||||
@@ -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<HashMap<&'static str, PlatformExtensionDef>
|
||||
);
|
||||
|
||||
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()),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct SkillsClient {
|
||||
info: InitializeResult,
|
||||
skills: HashMap<String, Skill>,
|
||||
}
|
||||
|
||||
impl SkillsClient {
|
||||
pub fn new(_context: PlatformExtensionContext) -> Result<Self> {
|
||||
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::<Vec<_>>();
|
||||
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<String, Skill> {
|
||||
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<PathBuf> {
|
||||
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<Skill> {
|
||||
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<Vec<PathBuf>> {
|
||||
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<String, Skill> {
|
||||
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<JsonObject>,
|
||||
) -> Result<Vec<Content>, 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<Tool> {
|
||||
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<String>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ListToolsResult, Error> {
|
||||
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<JsonObject>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<CallToolResult, Error> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub subrecipe: Option<String>,
|
||||
pub parameters: Option<HashMap<String, Value>>,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
pub settings: Option<SubagentSettings>,
|
||||
#[serde(default = "default_summary")]
|
||||
pub summary: bool,
|
||||
}
|
||||
|
||||
fn default_summary() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubagentSettings {
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_turns: Option<usize>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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<String, SubRecipe>,
|
||||
working_dir: PathBuf,
|
||||
cancellation_token: Option<CancellationToken>,
|
||||
) -> 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<CancellationToken>,
|
||||
) -> Result<rmcp::model::CallToolResult, ErrorData> {
|
||||
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<String, SubRecipe>,
|
||||
) -> Result<Recipe> {
|
||||
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<String, SubRecipe>,
|
||||
) -> Result<Recipe> {
|
||||
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::<fn(&str, &str) -> Result<String, anyhow::Error>>,
|
||||
)
|
||||
.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<Recipe> {
|
||||
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<TaskConfig> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<HashMap<String, String>>) -> 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user