10 Commits

14 changed files with 161 additions and 186 deletions
+1
View File
@@ -37,6 +37,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation. * [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions. * [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains. * [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools. * [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
* [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns). * [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
* [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models. * [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models.
+5
View File
@@ -42,6 +42,11 @@ global_tools: # Optional list of additional global tools to e
- web_search - web_search
- fs - fs
- python - python
skills_enabled: true # Master switch for skills in this agent (default: inherit from global)
enabled_skills: # Optional list of skills available when this agent runs.
# Must be a subset of global `visible_skills`. Omit to inherit the global default.
- git-master
- ai-slop-remover
dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used
instructions: | # Static instructions for the agent; ignored if dynamic instructions are used instructions: | # Static instructions for the agent; ignored if dynamic instructions are used
You are a AI agent designed to demonstrate agent capabilities. You are a AI agent designed to demonstrate agent capabilities.
+15
View File
@@ -80,6 +80,21 @@ mapping_mcp_servers: # Alias for an MCP server or set of servers
git: github,gitmcp git: github,gitmcp
enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search') enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search')
# ---- Skills ----
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
# See the [Skills documentation](https://github.com/Dark-Alex-17/coyote/wiki/Skills) for more details.
skills_enabled: true # Master switch. Set to false to hide all skill management tools from the model.
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
- ai-slop-remover
- code-review
- frontend-ui-ux
- git-master
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible.
# Example: only expose two skills in the bare REPL.
# enabled_skills:
# - git-master
# - ai-slop-remover
# ---- Auto-Continue (Todo System) ---- # ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability. # The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically # When enabled, the model can create todo lists and the system will automatically
+3
View File
@@ -10,6 +10,9 @@ temperature: 0.2 # The temperature to use for this role whe
top_p: 0 # The top_p to use for this role when querying the model top_p: 0 # The top_p to use for this role when querying the model
enabled_tools: fs_ls,fs_cat # A comma-separated list of tools to enable for this role enabled_tools: fs_ls,fs_cat # A comma-separated list of tools to enable for this role
enabled_mcp_servers: github,gitmcp # A comma-separated list of MCP servers to enable for this role enabled_mcp_servers: github,gitmcp # A comma-separated list of MCP servers to enable for this role
skills_enabled: true # Master switch for skills in this role (default: inherit from global)
enabled_skills: git-master,ai-slop-remover # Comma-separated list of skills available when this role is active.
# Must be a subset of global `visible_skills`. Omit to inherit the global default.
prompt: null # A custom prompt to use for this role that will immediately query prompt: null # A custom prompt to use for this role that will immediately query
# the model for output instead of using the instructions below # the model for output instead of using the instructions below
# Auto-Continue (Todo System) # Auto-Continue (Todo System)
+7
View File
@@ -207,6 +207,13 @@ impl Agent {
functions.append_teammate_functions(); functions.append_teammate_functions();
functions.append_user_interaction_functions(); functions.append_user_interaction_functions();
if app.function_calling_support
&& app.skills_enabled
&& !matches!(agent_config.skills_enabled, Some(false))
{
functions.append_skill_functions();
}
agent_config.replace_tools_placeholder(&functions); agent_config.replace_tools_placeholder(&functions);
Ok(Self { Ok(Self {
+18 -1
View File
@@ -375,7 +375,12 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
} }
if let Some(src_dir) = &layout.skills { if let Some(src_dir) = &layout.skills {
plan_dir_into(src_dir, &paths::skills_dir(), TopCategory::Skills, &mut files)?; plan_dir_into(
src_dir,
&paths::skills_dir(),
TopCategory::Skills,
&mut files,
)?;
} }
if let Some(src_dir) = &layout.macros { if let Some(src_dir) = &layout.macros {
@@ -1289,6 +1294,12 @@ mod tests {
#[test] #[test]
fn merge_non_tty_conflict_aborts_without_force() { fn merge_non_tty_conflict_aborts_without_force() {
if *IS_STDOUT_TERMINAL {
eprintln!(
"Skipping merge_non_tty_conflict_aborts_without_force: requires non-TTY stdout"
);
return;
}
let dir = fresh_temp_dir("merge-non-tty-"); let dir = fresh_temp_dir("merge-non-tty-");
let remote = dir.join("remote.json"); let remote = dir.join("remote.json");
let target = dir.join("target.json"); let target = dir.join("target.json");
@@ -1365,6 +1376,12 @@ mod tests {
#[test] #[test]
fn handle_missing_secrets_defers_all_in_non_tty() { fn handle_missing_secrets_defers_all_in_non_tty() {
if *IS_STDOUT_TERMINAL {
eprintln!(
"Skipping handle_missing_secrets_defers_all_in_non_tty: requires non-TTY stdout"
);
return;
}
let missing = vec![ let missing = vec![
"COYOTE_TEST_STEP4_A".to_string(), "COYOTE_TEST_STEP4_A".to_string(),
"COYOTE_TEST_STEP4_B".to_string(), "COYOTE_TEST_STEP4_B".to_string(),
+31 -29
View File
@@ -15,7 +15,7 @@ use super::{MessageContentToolCalls, prompts};
use crate::client::{Model, ModelType, list_models}; use crate::client::{Model, ModelType, list_models};
use crate::function::{ use crate::function::{
FunctionDeclaration, Functions, ToolCallTracker, ToolResult, FunctionDeclaration, Functions, ToolCallTracker, ToolResult,
user_interaction::USER_FUNCTION_PREFIX, skill::SKILL_FUNCTION_PREFIX, user_interaction::USER_FUNCTION_PREFIX,
}; };
use crate::mcp::{ use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
@@ -132,6 +132,13 @@ impl RequestContext {
functions.append_user_interaction_functions(); functions.append_user_interaction_functions();
} }
if app.config.function_calling_support {
let policy = SkillPolicy::effective(&app.config, None, None, None)?;
if policy.skills_enabled {
functions.append_skill_functions();
}
}
let mut mcp_runtime = McpRuntime::default(); let mut mcp_runtime = McpRuntime::default();
if let Some(registry) = &app.mcp_registry { if let Some(registry) = &app.mcp_registry {
mcp_runtime.sync_from_registry(registry); mcp_runtime.sync_from_registry(registry);
@@ -891,6 +898,7 @@ impl RequestContext {
("env_file", display_path(&paths::env_file())), ("env_file", display_path(&paths::env_file())),
("agents_dir", display_path(&paths::agents_data_dir())), ("agents_dir", display_path(&paths::agents_data_dir())),
("roles_dir", display_path(&paths::roles_dir())), ("roles_dir", display_path(&paths::roles_dir())),
("skills_dir", display_path(&paths::skills_dir())),
("sessions_dir", display_path(&self.sessions_dir())), ("sessions_dir", display_path(&self.sessions_dir())),
("rags_dir", display_path(&paths::rags_dir())), ("rags_dir", display_path(&paths::rags_dir())),
("macros_dir", display_path(&paths::macros_dir())), ("macros_dir", display_path(&paths::macros_dir())),
@@ -1137,7 +1145,9 @@ impl RequestContext {
.declarations() .declarations()
.iter() .iter()
.filter(|v| { .filter(|v| {
v.name.starts_with(USER_FUNCTION_PREFIX) && !existing.contains(&v.name) (v.name.starts_with(USER_FUNCTION_PREFIX)
|| v.name.starts_with(SKILL_FUNCTION_PREFIX))
&& !existing.contains(&v.name)
}) })
.cloned() .cloned()
.collect(); .collect();
@@ -1873,14 +1883,11 @@ impl RequestContext {
} }
".macro" => super::map_completion_values(paths::list_macros()), ".macro" => super::map_completion_values(paths::list_macros()),
".skill" => { ".skill" => {
let mut values: Vec<String> = vec![ super::map_completion_values(vec![
"loaded".to_string(), "loaded".to_string(),
"load".to_string(), "load".to_string(),
"unload".to_string(), "unload".to_string(),
"edit".to_string(), ])
];
values.extend(paths::list_skills());
super::map_completion_values(values)
} }
".starter" => match &self.agent { ".starter" => match &self.agent {
Some(agent) => agent Some(agent) => agent
@@ -1937,6 +1944,12 @@ impl RequestContext {
} }
_ => vec![], _ => vec![],
}; };
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
{
values = super::map_completion_values(paths::list_skills());
} else if cmd == ".skill" && args.first() == Some(&"unload") && args.len() == 2 {
values = super::map_completion_values(self.skill_registry.loaded_names());
} else if cmd == ".install" && args.first() == Some(&"remote") && args.len() >= 2 { } else if cmd == ".install" && args.first() == Some(&"remote") && args.len() >= 2 {
let prev = args.get(args.len() - 2).copied().unwrap_or(""); let prev = args.get(args.len() - 2).copied().unwrap_or("");
if prev == "--filter" { if prev == "--filter" {
@@ -2492,9 +2505,8 @@ impl RequestContext {
ensure_parent_exists(&path)?; ensure_parent_exists(&path)?;
let is_new = !path.exists(); let is_new = !path.exists();
if is_new { if is_new {
fs::write(&path, SKILL_SCAFFOLD).with_context(|| { fs::write(&path, SKILL_SCAFFOLD)
format!("Failed to scaffold skill at {}", path.display()) .with_context(|| format!("Failed to scaffold skill at {}", path.display()))?;
})?;
} }
let editor = app.editor()?; let editor = app.editor()?;
edit_file(&editor, &path)?; edit_file(&editor, &path)?;
@@ -2506,11 +2518,7 @@ impl RequestContext {
Ok(()) Ok(())
} }
pub async fn load_skill_repl( pub async fn load_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
&mut self,
name: &str,
abort_signal: AbortSignal,
) -> Result<()> {
if !paths::has_skill(name) { if !paths::has_skill(name) {
bail!( bail!(
"Skill '{name}' is not installed (expected at {})", "Skill '{name}' is not installed (expected at {})",
@@ -2563,17 +2571,11 @@ impl RequestContext {
Ok(()) Ok(())
} }
pub async fn unload_skill_repl( pub async fn unload_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
&mut self,
name: &str,
abort_signal: AbortSignal,
) -> Result<()> {
self.skill_registry.unload(name)?; self.skill_registry.unload(name)?;
if let Err(e) = self.refresh_tool_scope(abort_signal).await { if let Err(e) = self.refresh_tool_scope(abort_signal).await {
eprintln!( eprintln!("Warning: unloaded skill '{name}' but tool scope refresh failed: {e}");
"Warning: unloaded skill '{name}' but tool scope refresh failed: {e}"
);
} }
println!("✓ Unloaded skill '{name}'."); println!("✓ Unloaded skill '{name}'.");
@@ -3534,7 +3536,8 @@ mod tests {
let input = Input::from_str(&ctx, "hello", None); let input = Input::from_str(&ctx, "hello", None);
let app = Arc::clone(&ctx.app.config); let app = Arc::clone(&ctx.app.config);
ctx.after_chat_completion(app.as_ref(), &input, "response", &[]).unwrap(); ctx.after_chat_completion(app.as_ref(), &input, "response", &[])
.unwrap();
assert!(!ctx.skill_registry.is_loaded("ephemeral")); assert!(!ctx.skill_registry.is_loaded("ephemeral"));
assert!(ctx.skill_registry.is_loaded("persistent")); assert!(ctx.skill_registry.is_loaded("persistent"));
@@ -3556,11 +3559,10 @@ mod tests {
let input = Input::from_str(&ctx, "hello", None); let input = Input::from_str(&ctx, "hello", None);
let app = Arc::clone(&ctx.app.config); let app = Arc::clone(&ctx.app.config);
let tool_result = ToolResult::new( let tool_result =
crate::function::ToolCall::default(), ToolResult::new(crate::function::ToolCall::default(), serde_json::json!({}));
serde_json::json!({}), ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result])
); .unwrap();
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result]).unwrap();
assert!( assert!(
ctx.skill_registry.is_loaded("ephemeral"), ctx.skill_registry.is_loaded("ephemeral"),
+1 -3
View File
@@ -103,9 +103,7 @@ impl Role {
role.enabled_mcp_servers = value.as_str().map(|v| v.to_string()) role.enabled_mcp_servers = value.as_str().map(|v| v.to_string())
} }
"skills_enabled" => role.skills_enabled = value.as_bool(), "skills_enabled" => role.skills_enabled = value.as_bool(),
"enabled_skills" => { "enabled_skills" => role.enabled_skills = value.as_str().map(|v| v.to_string()),
role.enabled_skills = value.as_str().map(|v| v.to_string())
}
"auto_continue" => role.auto_continue = value.as_bool(), "auto_continue" => role.auto_continue = value.as_bool(),
"max_auto_continues" => { "max_auto_continues" => {
role.max_auto_continues = value.as_u64().map(|v| v as usize) role.max_auto_continues = value.as_u64().map(|v| v as usize)
+6 -9
View File
@@ -2,11 +2,11 @@ use super::*;
use anyhow::Result; use anyhow::Result;
use fancy_regex::Regex; use fancy_regex::Regex;
use log::{debug, info};
use rust_embed::Embed; use rust_embed::Embed;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::sync::LazyLock; use std::sync::LazyLock;
use log::{debug, info};
#[derive(Embed)] #[derive(Embed)]
#[folder = "assets/skills/"] #[folder = "assets/skills/"]
@@ -93,9 +93,8 @@ impl Skill {
for file in SkillsAsset::iter() { for file in SkillsAsset::iter() {
debug!("Processing skill file: {}", file.as_ref()); debug!("Processing skill file: {}", file.as_ref());
let embedded_file = SkillsAsset::get(&file).ok_or_else(|| { let embedded_file = SkillsAsset::get(&file)
anyhow!("Failed to load embedded skill file: {}", file.as_ref()) .ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?;
})?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
let file_path = paths::skills_dir().join(file.as_ref()); let file_path = paths::skills_dir().join(file.as_ref());
@@ -118,9 +117,8 @@ impl Skill {
pub fn load(name: &str) -> Result<Self> { pub fn load(name: &str) -> Result<Self> {
let path = paths::skill_file(name); let path = paths::skill_file(name);
let content = read_to_string(&path).with_context(|| { let content = read_to_string(&path)
format!("Failed to read skill '{name}' at {}", path.display()) .with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
})?;
Ok(Skill::new(name, &content)) Ok(Skill::new(name, &content))
} }
@@ -307,8 +305,7 @@ mod tests {
#[test] #[test]
fn is_compatible_requires_both_when_both_declared() { fn is_compatible_requires_both_when_both_declared() {
let content = let content = "---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody";
"---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody";
let skill = Skill::new("test", content); let skill = Skill::new("test", content);
+25 -75
View File
@@ -145,15 +145,9 @@ mod tests {
fn defaults_yield_skills_enabled_with_empty_universe() { fn defaults_yield_skills_enabled_with_empty_universe() {
let global = AppConfig::default(); let global = AppConfig::default();
let policy = SkillPolicy::effective_with( let policy =
&global, SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
None, .unwrap();
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
assert!(policy.skills_enabled); assert!(policy.skills_enabled);
assert!(policy.enabled.is_empty()); assert!(policy.enabled.is_empty());
@@ -177,15 +171,9 @@ mod tests {
fn falls_back_to_visible_when_visible_set_but_no_enabled() { fn falls_back_to_visible_when_visible_set_but_no_enabled() {
let global = make_app_config(true, None, Some(&["alpha", "beta"])); let global = make_app_config(true, None, Some(&["alpha", "beta"]));
let policy = SkillPolicy::effective_with( let policy =
&global, SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
None, .unwrap();
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
assert_eq!(policy.enabled.len(), 2); assert_eq!(policy.enabled.len(), 2);
assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("alpha"));
@@ -196,15 +184,9 @@ mod tests {
fn global_enabled_skills_is_effective_when_no_other_levels() { fn global_enabled_skills_is_effective_when_no_other_levels() {
let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"])); let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"]));
let policy = SkillPolicy::effective_with( let policy =
&global, SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
None, .unwrap();
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("alpha"));
assert!(policy.enabled.contains("beta")); assert!(policy.enabled.contains("beta"));
@@ -214,10 +196,7 @@ mod tests {
#[test] #[test]
fn role_overrides_global_enabled_skills() { fn role_overrides_global_enabled_skills() {
let global = make_app_config(true, Some("alpha"), Some(&["alpha", "beta"])); let global = make_app_config(true, Some("alpha"), Some(&["alpha", "beta"]));
let role = Role::new( let role = Role::new("test", "---\nenabled_skills: beta\n---\nbody");
"test",
"---\nenabled_skills: beta\n---\nbody",
);
let policy = SkillPolicy::effective_with( let policy = SkillPolicy::effective_with(
&global, &global,
@@ -258,14 +237,9 @@ mod tests {
..AppConfig::default() ..AppConfig::default()
}; };
let policy = SkillPolicy::effective_with( let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &|| {
&global, vec!["alpha".to_string()]
None, })
None,
None,
&always_true,
&|| vec!["alpha".to_string()],
)
.unwrap(); .unwrap();
assert!(!policy.allows("alpha")); assert!(!policy.allows("alpha"));
@@ -275,15 +249,9 @@ mod tests {
fn allows_returns_true_when_skill_in_enabled_set() { fn allows_returns_true_when_skill_in_enabled_set() {
let global = make_app_config(true, Some("alpha"), None); let global = make_app_config(true, Some("alpha"), None);
let policy = SkillPolicy::effective_with( let policy =
&global, SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
None, .unwrap();
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
assert!(policy.allows("alpha")); assert!(policy.allows("alpha"));
assert!(!policy.allows("beta")); assert!(!policy.allows("beta"));
@@ -293,15 +261,9 @@ mod tests {
fn validation_rejects_uninstalled_skill_reference() { fn validation_rejects_uninstalled_skill_reference() {
let global = make_app_config(true, Some("ghost"), None); let global = make_app_config(true, Some("ghost"), None);
let err = SkillPolicy::effective_with( let err =
&global, SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
None, .unwrap_err();
None,
None,
&|_| false,
&empty_installed,
)
.unwrap_err();
assert!(err.to_string().contains("not installed")); assert!(err.to_string().contains("not installed"));
assert!(err.to_string().contains("ghost")); assert!(err.to_string().contains("ghost"));
@@ -311,15 +273,9 @@ mod tests {
fn validation_rejects_skill_not_in_visible_set() { fn validation_rejects_skill_not_in_visible_set() {
let global = make_app_config(true, Some("beta"), Some(&["alpha"])); let global = make_app_config(true, Some("beta"), Some(&["alpha"]));
let err = SkillPolicy::effective_with( let err =
&global, SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
None, .unwrap_err();
None,
None,
&always_true,
&empty_installed,
)
.unwrap_err();
assert!(err.to_string().contains("not in visible_skills")); assert!(err.to_string().contains("not in visible_skills"));
assert!(err.to_string().contains("beta")); assert!(err.to_string().contains("beta"));
@@ -329,15 +285,9 @@ mod tests {
fn validation_skipped_when_no_explicit_enabled_skills() { fn validation_skipped_when_no_explicit_enabled_skills() {
let global = make_app_config(true, None, None); let global = make_app_config(true, None, None);
let policy = SkillPolicy::effective_with( let policy =
&global, SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
None, .unwrap();
None,
None,
&|_| false,
&empty_installed,
)
.unwrap();
assert!(policy.enabled.is_empty()); assert!(policy.enabled.is_empty());
} }
+20 -46
View File
@@ -10,24 +10,7 @@ pub struct SkillRegistry {
loaded: IndexMap<String, Skill>, loaded: IndexMap<String, Skill>,
} }
#[allow(dead_code)]
impl SkillRegistry { impl SkillRegistry {
pub fn new() -> Self {
Self {
loaded: IndexMap::new(),
}
}
pub fn load(&mut self, name: &str) -> Result<()> {
if self.loaded.contains_key(name) {
bail!("Skill '{name}' is already loaded");
}
let skill = Skill::load(name)?;
self.loaded.insert(name.to_string(), skill);
Ok(())
}
pub fn insert(&mut self, skill: Skill) -> Result<()> { pub fn insert(&mut self, skill: Skill) -> Result<()> {
let name = skill.name().to_string(); let name = skill.name().to_string();
@@ -93,7 +76,11 @@ impl SkillRegistry {
tools.extend(parse_csv(skill.enabled_tools())); tools.extend(parse_csv(skill.enabled_tools()));
mcps.extend(parse_csv(skill.enabled_mcp_servers())); mcps.extend(parse_csv(skill.enabled_mcp_servers()));
if !skip_body && !skill.body().is_empty() { if !skip_body && !skill.body().is_empty() {
let separator = if effective.is_empty_prompt() { "" } else { "\n\n" }; let separator = if effective.is_empty_prompt() {
""
} else {
"\n\n"
};
effective.append_to_prompt(separator); effective.append_to_prompt(separator);
effective.append_to_prompt(skill.body()); effective.append_to_prompt(skill.body());
} }
@@ -151,7 +138,7 @@ mod tests {
#[test] #[test]
fn empty_registry_returns_base_clone() { fn empty_registry_returns_base_clone() {
let base = Role::new("test", "You are a helper"); let base = Role::new("test", "You are a helper");
let registry = SkillRegistry::new(); let registry = SkillRegistry::default();
let effective = registry.effective_role(&base); let effective = registry.effective_role(&base);
@@ -160,7 +147,7 @@ mod tests {
#[test] #[test]
fn one_skill_appends_body_after_base_with_separator() { fn one_skill_appends_body_after_base_with_separator() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("git-master", "description: D", "Git knowledge")); registry.insert_for_test(make_skill("git-master", "description: D", "Git knowledge"));
let base = Role::new("test", "You are a helper"); let base = Role::new("test", "You are a helper");
@@ -171,7 +158,7 @@ mod tests {
#[test] #[test]
fn two_skills_compose_bodies_in_insertion_order() { fn two_skills_compose_bodies_in_insertion_order() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("a", "", "Alpha body")); registry.insert_for_test(make_skill("a", "", "Alpha body"));
registry.insert_for_test(make_skill("b", "", "Beta body")); registry.insert_for_test(make_skill("b", "", "Beta body"));
@@ -183,7 +170,7 @@ mod tests {
#[test] #[test]
fn empty_base_prompt_omits_leading_separator() { fn empty_base_prompt_omits_leading_separator() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("a", "", "Alpha")); registry.insert_for_test(make_skill("a", "", "Alpha"));
registry.insert_for_test(make_skill("b", "", "Beta")); registry.insert_for_test(make_skill("b", "", "Beta"));
@@ -195,7 +182,7 @@ mod tests {
#[test] #[test]
fn embedded_prompt_base_skips_body_composition() { fn embedded_prompt_base_skips_body_composition() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill( registry.insert_for_test(make_skill(
"git-master", "git-master",
"enabled_tools: shell", "enabled_tools: shell",
@@ -212,7 +199,7 @@ mod tests {
#[test] #[test]
fn skills_with_empty_body_do_not_inject_separator() { fn skills_with_empty_body_do_not_inject_separator() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("knowledge", "enabled_tools: fs", "")); registry.insert_for_test(make_skill("knowledge", "enabled_tools: fs", ""));
let base = Role::new("test", "Base"); let base = Role::new("test", "Base");
@@ -223,7 +210,7 @@ mod tests {
#[test] #[test]
fn tools_and_mcps_are_unioned_and_deduplicated() { fn tools_and_mcps_are_unioned_and_deduplicated() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill( registry.insert_for_test(make_skill(
"a", "a",
"enabled_tools: shell,fs\nenabled_mcp_servers: github", "enabled_tools: shell,fs\nenabled_mcp_servers: github",
@@ -242,10 +229,7 @@ mod tests {
let tools_str = effective.enabled_tools().unwrap(); let tools_str = effective.enabled_tools().unwrap();
let tools: BTreeSet<&str> = tools_str.split(',').collect(); let tools: BTreeSet<&str> = tools_str.split(',').collect();
assert_eq!( assert_eq!(tools, BTreeSet::from(["fs", "git", "shell", "web_search"]));
tools,
BTreeSet::from(["fs", "git", "shell", "web_search"])
);
let mcps_str = effective.enabled_mcp_servers().unwrap(); let mcps_str = effective.enabled_mcp_servers().unwrap();
let mcps: BTreeSet<&str> = mcps_str.split(',').collect(); let mcps: BTreeSet<&str> = mcps_str.split(',').collect();
@@ -254,7 +238,7 @@ mod tests {
#[test] #[test]
fn no_skill_tool_contributions_preserves_base_none() { fn no_skill_tool_contributions_preserves_base_none() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge")); registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge"));
let base = Role::new("test", "Base"); let base = Role::new("test", "Base");
@@ -266,7 +250,7 @@ mod tests {
#[test] #[test]
fn base_some_empty_tools_is_preserved() { fn base_some_empty_tools_is_preserved() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge")); registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge"));
let mut base = Role::new("test", "Base"); let mut base = Role::new("test", "Base");
@@ -276,19 +260,9 @@ mod tests {
assert_eq!(effective.enabled_tools().as_deref(), Some("")); assert_eq!(effective.enabled_tools().as_deref(), Some(""));
} }
#[test]
fn load_already_loaded_returns_error() {
let mut registry = SkillRegistry::new();
registry.insert_for_test(make_skill("git-master", "", "body"));
let err = registry.load("git-master").unwrap_err();
assert!(err.to_string().contains("already loaded"));
}
#[test] #[test]
fn unload_not_loaded_returns_error() { fn unload_not_loaded_returns_error() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
let err = registry.unload("missing").unwrap_err(); let err = registry.unload("missing").unwrap_err();
@@ -297,7 +271,7 @@ mod tests {
#[test] #[test]
fn unload_existing_succeeds_and_removes() { fn unload_existing_succeeds_and_removes() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("git-master", "", "body")); registry.insert_for_test(make_skill("git-master", "", "body"));
assert!(registry.is_loaded("git-master")); assert!(registry.is_loaded("git-master"));
@@ -307,7 +281,7 @@ mod tests {
#[test] #[test]
fn loaded_names_returns_insertion_order() { fn loaded_names_returns_insertion_order() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("zulu", "", "body")); registry.insert_for_test(make_skill("zulu", "", "body"));
registry.insert_for_test(make_skill("alpha", "", "body")); registry.insert_for_test(make_skill("alpha", "", "body"));
@@ -321,7 +295,7 @@ mod tests {
#[test] #[test]
fn sweep_removes_only_auto_unload_skills() { fn sweep_removes_only_auto_unload_skills() {
let mut registry = SkillRegistry::new(); let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("ephemeral", "auto_unload: true", "body")); registry.insert_for_test(make_skill("ephemeral", "auto_unload: true", "body"));
registry.insert_for_test(make_skill("persistent", "", "body")); registry.insert_for_test(make_skill("persistent", "", "body"));
@@ -333,7 +307,7 @@ mod tests {
#[test] #[test]
fn is_loaded_returns_false_for_unknown() { fn is_loaded_returns_false_for_unknown() {
let registry = SkillRegistry::new(); let registry = SkillRegistry::default();
assert!(!registry.is_loaded("nothing")); assert!(!registry.is_loaded("nothing"));
} }
+1 -1
View File
@@ -22,6 +22,7 @@ use indoc::formatdoc;
use rust_embed::Embed; use rust_embed::Embed;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use skill::SKILL_FUNCTION_PREFIX;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs::File; use std::fs::File;
@@ -33,7 +34,6 @@ use std::{
process::{Command, Stdio}, process::{Command, Stdio},
}; };
use strum_macros::AsRefStr; use strum_macros::AsRefStr;
use skill::SKILL_FUNCTION_PREFIX;
use supervisor::SUPERVISOR_FUNCTION_PREFIX; use supervisor::SUPERVISOR_FUNCTION_PREFIX;
use todo::TODO_FUNCTION_PREFIX; use todo::TODO_FUNCTION_PREFIX;
use user_interaction::USER_FUNCTION_PREFIX; use user_interaction::USER_FUNCTION_PREFIX;
+26 -20
View File
@@ -46,7 +46,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
4. Continue with the next pending item now. Call tools immediately." 4. Continue with the next pending item now. Call tools immediately."
}; };
static REPL_COMMANDS: LazyLock<[ReplCommand; 43]> = LazyLock::new(|| { static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
[ [
ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -193,7 +193,12 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 43]> = LazyLock::new(|| {
ReplCommand::new(".macro", "Execute a macro", AssertState::pass()), ReplCommand::new(".macro", "Execute a macro", AssertState::pass()),
ReplCommand::new( ReplCommand::new(
".skill", ".skill",
"List, load, unload, edit, or create skills", "List, load, unload, or create skills",
AssertState::pass(),
),
ReplCommand::new(
".edit skill",
"Modify an existing skill by name",
AssertState::pass(), AssertState::pass(),
), ),
ReplCommand::new( ReplCommand::new(
@@ -529,8 +534,8 @@ pub async fn run_repl_command(
.skill loaded # List currently-loaded skills .skill loaded # List currently-loaded skills
.skill load <name> # Load a skill into the current context .skill load <name> # Load a skill into the current context
.skill unload <name> # Unload a loaded skill .skill unload <name> # Unload a loaded skill
.skill edit <name> # Open an existing skill in $EDITOR .skill <name> # Open the skill in $EDITOR; create with a scaffold if missing
.skill <name> # Open the skill in $EDITOR; create with a scaffold if missing"# # (Use `.edit skill <name>` to edit an existing skill without the create-if-missing behavior.)"#
), ),
"loaded" => ctx.list_loaded_skills(), "loaded" => ctx.list_loaded_skills(),
"load" => { "load" => {
@@ -547,19 +552,6 @@ pub async fn run_repl_command(
ctx.unload_skill_repl(rest, abort_signal.clone()).await?; ctx.unload_skill_repl(rest, abort_signal.clone()).await?;
} }
} }
"edit" => {
if rest.is_empty() {
println!("Usage: .skill edit <name>");
} else if !paths::has_skill(rest) {
bail!(
"Skill '{rest}' is not installed (expected at {})",
paths::skill_file(rest).display()
);
} else {
let app = Arc::clone(&ctx.app.config);
ctx.upsert_skill(app.as_ref(), rest)?;
}
}
name => { name => {
let app = Arc::clone(&ctx.app.config); let app = Arc::clone(&ctx.app.config);
ctx.upsert_skill(app.as_ref(), name)?; ctx.upsert_skill(app.as_ref(), name)?;
@@ -712,9 +704,23 @@ pub async fn run_repl_command(
Some("mcp-config") => { Some("mcp-config") => {
ctx.edit_mcp_config()?; ctx.edit_mcp_config()?;
} }
Some(s) if s == "skill" || s.starts_with("skill ") => {
let name = s.strip_prefix("skill").unwrap_or("").trim();
if name.is_empty() {
println!("Usage: .edit skill <name>");
} else if !paths::has_skill(name) {
bail!(
"Skill '{name}' is not installed (expected at {})",
paths::skill_file(name).display()
);
} else {
let app = Arc::clone(&ctx.app.config);
ctx.upsert_skill(app.as_ref(), name)?;
}
}
_ => { _ => {
println!( println!(
r#"Usage: .edit <config|mcp-config|role|session|rag-docs|agent-config>"# r#"Usage: .edit <config|mcp-config|role|session|rag-docs|agent-config|skill <name>>"#
) )
} }
} }
@@ -1318,8 +1324,8 @@ mod tests {
} }
#[test] #[test]
fn repl_commands_has_43_entries() { fn repl_commands_has_44_entries() {
assert_eq!(REPL_COMMANDS.len(), 43); assert_eq!(REPL_COMMANDS.len(), 44);
} }
#[test] #[test]