Compare commits
10 Commits
6a5561edba
...
ba665528ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
ba665528ed
|
|||
|
1440e23748
|
|||
|
8ff9d84a85
|
|||
|
dc8e831f27
|
|||
|
985ae11fcf
|
|||
|
b758b17dbb
|
|||
|
aef26013cb
|
|||
|
b1fc199a5f
|
|||
|
7e801b80d0
|
|||
|
7cd7abe469
|
@@ -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.
|
||||
* [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.
|
||||
* [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.
|
||||
* [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.
|
||||
|
||||
@@ -42,6 +42,11 @@ global_tools: # Optional list of additional global tools to e
|
||||
- web_search
|
||||
- fs
|
||||
- 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
|
||||
instructions: | # Static instructions for the agent; ignored if dynamic instructions are used
|
||||
You are a AI agent designed to demonstrate agent capabilities.
|
||||
|
||||
@@ -80,6 +80,21 @@ mapping_mcp_servers: # Alias for an MCP server or set of servers
|
||||
git: github,gitmcp
|
||||
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) ----
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
# the model for output instead of using the instructions below
|
||||
# Auto-Continue (Todo System)
|
||||
|
||||
@@ -207,6 +207,13 @@ impl Agent {
|
||||
functions.append_teammate_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);
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -394,7 +394,7 @@ impl AppConfig {
|
||||
if let Some(Some(v)) = super::read_env_bool(&get_env_name("skills_enabled")) {
|
||||
self.skills_enabled = v;
|
||||
}
|
||||
|
||||
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("enabled_skills")) {
|
||||
self.enabled_skills = v;
|
||||
}
|
||||
|
||||
@@ -375,7 +375,12 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1289,6 +1294,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 remote = dir.join("remote.json");
|
||||
let target = dir.join("target.json");
|
||||
@@ -1365,6 +1376,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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![
|
||||
"COYOTE_TEST_STEP4_A".to_string(),
|
||||
"COYOTE_TEST_STEP4_B".to_string(),
|
||||
|
||||
@@ -15,7 +15,7 @@ use super::{MessageContentToolCalls, prompts};
|
||||
use crate::client::{Model, ModelType, list_models};
|
||||
use crate::function::{
|
||||
FunctionDeclaration, Functions, ToolCallTracker, ToolResult,
|
||||
user_interaction::USER_FUNCTION_PREFIX,
|
||||
skill::SKILL_FUNCTION_PREFIX, user_interaction::USER_FUNCTION_PREFIX,
|
||||
};
|
||||
use crate::mcp::{
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
@@ -132,6 +132,13 @@ impl RequestContext {
|
||||
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();
|
||||
if let Some(registry) = &app.mcp_registry {
|
||||
mcp_runtime.sync_from_registry(registry);
|
||||
@@ -891,6 +898,7 @@ impl RequestContext {
|
||||
("env_file", display_path(&paths::env_file())),
|
||||
("agents_dir", display_path(&paths::agents_data_dir())),
|
||||
("roles_dir", display_path(&paths::roles_dir())),
|
||||
("skills_dir", display_path(&paths::skills_dir())),
|
||||
("sessions_dir", display_path(&self.sessions_dir())),
|
||||
("rags_dir", display_path(&paths::rags_dir())),
|
||||
("macros_dir", display_path(&paths::macros_dir())),
|
||||
@@ -1137,7 +1145,9 @@ impl RequestContext {
|
||||
.declarations()
|
||||
.iter()
|
||||
.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()
|
||||
.collect();
|
||||
@@ -1873,14 +1883,11 @@ impl RequestContext {
|
||||
}
|
||||
".macro" => super::map_completion_values(paths::list_macros()),
|
||||
".skill" => {
|
||||
let mut values: Vec<String> = vec![
|
||||
super::map_completion_values(vec![
|
||||
"loaded".to_string(),
|
||||
"load".to_string(),
|
||||
"unload".to_string(),
|
||||
"edit".to_string(),
|
||||
];
|
||||
values.extend(paths::list_skills());
|
||||
super::map_completion_values(values)
|
||||
])
|
||||
}
|
||||
".starter" => match &self.agent {
|
||||
Some(agent) => agent
|
||||
@@ -1937,6 +1944,12 @@ impl RequestContext {
|
||||
}
|
||||
_ => 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 {
|
||||
let prev = args.get(args.len() - 2).copied().unwrap_or("");
|
||||
if prev == "--filter" {
|
||||
@@ -2492,9 +2505,8 @@ impl RequestContext {
|
||||
ensure_parent_exists(&path)?;
|
||||
let is_new = !path.exists();
|
||||
if is_new {
|
||||
fs::write(&path, SKILL_SCAFFOLD).with_context(|| {
|
||||
format!("Failed to scaffold skill at {}", path.display())
|
||||
})?;
|
||||
fs::write(&path, SKILL_SCAFFOLD)
|
||||
.with_context(|| format!("Failed to scaffold skill at {}", path.display()))?;
|
||||
}
|
||||
let editor = app.editor()?;
|
||||
edit_file(&editor, &path)?;
|
||||
@@ -2506,11 +2518,7 @@ impl RequestContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_skill_repl(
|
||||
&mut self,
|
||||
name: &str,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<()> {
|
||||
pub async fn load_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
|
||||
if !paths::has_skill(name) {
|
||||
bail!(
|
||||
"Skill '{name}' is not installed (expected at {})",
|
||||
@@ -2563,17 +2571,11 @@ impl RequestContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unload_skill_repl(
|
||||
&mut self,
|
||||
name: &str,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<()> {
|
||||
pub async fn unload_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
|
||||
self.skill_registry.unload(name)?;
|
||||
|
||||
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||
eprintln!(
|
||||
"Warning: unloaded skill '{name}' but tool scope refresh failed: {e}"
|
||||
);
|
||||
eprintln!("Warning: unloaded skill '{name}' but tool scope refresh failed: {e}");
|
||||
}
|
||||
|
||||
println!("✓ Unloaded skill '{name}'.");
|
||||
@@ -3534,7 +3536,8 @@ mod tests {
|
||||
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
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("persistent"));
|
||||
@@ -3556,11 +3559,10 @@ mod tests {
|
||||
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
let tool_result = ToolResult::new(
|
||||
crate::function::ToolCall::default(),
|
||||
serde_json::json!({}),
|
||||
);
|
||||
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result]).unwrap();
|
||||
let tool_result =
|
||||
ToolResult::new(crate::function::ToolCall::default(), serde_json::json!({}));
|
||||
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result])
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
ctx.skill_registry.is_loaded("ephemeral"),
|
||||
|
||||
+1
-3
@@ -103,9 +103,7 @@ impl Role {
|
||||
role.enabled_mcp_servers = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
"skills_enabled" => role.skills_enabled = value.as_bool(),
|
||||
"enabled_skills" => {
|
||||
role.enabled_skills = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
"enabled_skills" => role.enabled_skills = value.as_str().map(|v| v.to_string()),
|
||||
"auto_continue" => role.auto_continue = value.as_bool(),
|
||||
"max_auto_continues" => {
|
||||
role.max_auto_continues = value.as_u64().map(|v| v as usize)
|
||||
|
||||
+7
-10
@@ -2,11 +2,11 @@ use super::*;
|
||||
|
||||
use anyhow::Result;
|
||||
use fancy_regex::Regex;
|
||||
use log::{debug, info};
|
||||
use rust_embed::Embed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::LazyLock;
|
||||
use log::{debug, info};
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "assets/skills/"]
|
||||
@@ -93,9 +93,8 @@ impl Skill {
|
||||
for file in SkillsAsset::iter() {
|
||||
debug!("Processing skill file: {}", file.as_ref());
|
||||
|
||||
let embedded_file = SkillsAsset::get(&file).ok_or_else(|| {
|
||||
anyhow!("Failed to load embedded skill file: {}", file.as_ref())
|
||||
})?;
|
||||
let embedded_file = SkillsAsset::get(&file)
|
||||
.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 file_path = paths::skills_dir().join(file.as_ref());
|
||||
|
||||
@@ -118,9 +117,8 @@ impl Skill {
|
||||
|
||||
pub fn load(name: &str) -> Result<Self> {
|
||||
let path = paths::skill_file(name);
|
||||
let content = read_to_string(&path).with_context(|| {
|
||||
format!("Failed to read skill '{name}' at {}", path.display())
|
||||
})?;
|
||||
let content = read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
|
||||
Ok(Skill::new(name, &content))
|
||||
}
|
||||
|
||||
@@ -152,7 +150,7 @@ impl Skill {
|
||||
if self.declares_tools() && !function_calling_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if self.declares_mcp_servers() && !mcp_enabled {
|
||||
return false;
|
||||
}
|
||||
@@ -307,8 +305,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn is_compatible_requires_both_when_both_declared() {
|
||||
let content =
|
||||
"---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody";
|
||||
let content = "---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody";
|
||||
|
||||
let skill = Skill::new("test", content);
|
||||
|
||||
|
||||
+25
-75
@@ -145,15 +145,9 @@ mod tests {
|
||||
fn defaults_yield_skills_enabled_with_empty_universe() {
|
||||
let global = AppConfig::default();
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
)
|
||||
.unwrap();
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.skills_enabled);
|
||||
assert!(policy.enabled.is_empty());
|
||||
@@ -177,15 +171,9 @@ mod tests {
|
||||
fn falls_back_to_visible_when_visible_set_but_no_enabled() {
|
||||
let global = make_app_config(true, None, Some(&["alpha", "beta"]));
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
)
|
||||
.unwrap();
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.enabled.len(), 2);
|
||||
assert!(policy.enabled.contains("alpha"));
|
||||
@@ -196,15 +184,9 @@ mod tests {
|
||||
fn global_enabled_skills_is_effective_when_no_other_levels() {
|
||||
let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"]));
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
)
|
||||
.unwrap();
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.enabled.contains("alpha"));
|
||||
assert!(policy.enabled.contains("beta"));
|
||||
@@ -214,10 +196,7 @@ mod tests {
|
||||
#[test]
|
||||
fn role_overrides_global_enabled_skills() {
|
||||
let global = make_app_config(true, Some("alpha"), Some(&["alpha", "beta"]));
|
||||
let role = Role::new(
|
||||
"test",
|
||||
"---\nenabled_skills: beta\n---\nbody",
|
||||
);
|
||||
let role = Role::new("test", "---\nenabled_skills: beta\n---\nbody");
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
@@ -258,14 +237,9 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&|| vec!["alpha".to_string()],
|
||||
)
|
||||
let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &|| {
|
||||
vec!["alpha".to_string()]
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!policy.allows("alpha"));
|
||||
@@ -275,15 +249,9 @@ mod tests {
|
||||
fn allows_returns_true_when_skill_in_enabled_set() {
|
||||
let global = make_app_config(true, Some("alpha"), None);
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
)
|
||||
.unwrap();
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.allows("alpha"));
|
||||
assert!(!policy.allows("beta"));
|
||||
@@ -293,15 +261,9 @@ mod tests {
|
||||
fn validation_rejects_uninstalled_skill_reference() {
|
||||
let global = make_app_config(true, Some("ghost"), None);
|
||||
|
||||
let err = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&|_| false,
|
||||
&empty_installed,
|
||||
)
|
||||
.unwrap_err();
|
||||
let err =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("not installed"));
|
||||
assert!(err.to_string().contains("ghost"));
|
||||
@@ -311,15 +273,9 @@ mod tests {
|
||||
fn validation_rejects_skill_not_in_visible_set() {
|
||||
let global = make_app_config(true, Some("beta"), Some(&["alpha"]));
|
||||
|
||||
let err = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
)
|
||||
.unwrap_err();
|
||||
let err =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("not in visible_skills"));
|
||||
assert!(err.to_string().contains("beta"));
|
||||
@@ -329,15 +285,9 @@ mod tests {
|
||||
fn validation_skipped_when_no_explicit_enabled_skills() {
|
||||
let global = make_app_config(true, None, None);
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&|_| false,
|
||||
&empty_installed,
|
||||
)
|
||||
.unwrap();
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.enabled.is_empty());
|
||||
}
|
||||
|
||||
@@ -10,24 +10,7 @@ pub struct SkillRegistry {
|
||||
loaded: IndexMap<String, Skill>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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<()> {
|
||||
let name = skill.name().to_string();
|
||||
|
||||
@@ -93,7 +76,11 @@ impl SkillRegistry {
|
||||
tools.extend(parse_csv(skill.enabled_tools()));
|
||||
mcps.extend(parse_csv(skill.enabled_mcp_servers()));
|
||||
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(skill.body());
|
||||
}
|
||||
@@ -151,7 +138,7 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_registry_returns_base_clone() {
|
||||
let base = Role::new("test", "You are a helper");
|
||||
let registry = SkillRegistry::new();
|
||||
let registry = SkillRegistry::default();
|
||||
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
@@ -160,7 +147,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
|
||||
let base = Role::new("test", "You are a helper");
|
||||
@@ -171,7 +158,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("b", "", "Beta body"));
|
||||
|
||||
@@ -183,7 +170,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("b", "", "Beta"));
|
||||
|
||||
@@ -195,7 +182,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn embedded_prompt_base_skips_body_composition() {
|
||||
let mut registry = SkillRegistry::new();
|
||||
let mut registry = SkillRegistry::default();
|
||||
registry.insert_for_test(make_skill(
|
||||
"git-master",
|
||||
"enabled_tools: shell",
|
||||
@@ -212,7 +199,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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", ""));
|
||||
|
||||
let base = Role::new("test", "Base");
|
||||
@@ -223,7 +210,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tools_and_mcps_are_unioned_and_deduplicated() {
|
||||
let mut registry = SkillRegistry::new();
|
||||
let mut registry = SkillRegistry::default();
|
||||
registry.insert_for_test(make_skill(
|
||||
"a",
|
||||
"enabled_tools: shell,fs\nenabled_mcp_servers: github",
|
||||
@@ -242,10 +229,7 @@ mod tests {
|
||||
|
||||
let tools_str = effective.enabled_tools().unwrap();
|
||||
let tools: BTreeSet<&str> = tools_str.split(',').collect();
|
||||
assert_eq!(
|
||||
tools,
|
||||
BTreeSet::from(["fs", "git", "shell", "web_search"])
|
||||
);
|
||||
assert_eq!(tools, BTreeSet::from(["fs", "git", "shell", "web_search"]));
|
||||
|
||||
let mcps_str = effective.enabled_mcp_servers().unwrap();
|
||||
let mcps: BTreeSet<&str> = mcps_str.split(',').collect();
|
||||
@@ -254,7 +238,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
|
||||
let base = Role::new("test", "Base");
|
||||
@@ -266,7 +250,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
|
||||
let mut base = Role::new("test", "Base");
|
||||
@@ -276,19 +260,9 @@ mod tests {
|
||||
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]
|
||||
fn unload_not_loaded_returns_error() {
|
||||
let mut registry = SkillRegistry::new();
|
||||
let mut registry = SkillRegistry::default();
|
||||
|
||||
let err = registry.unload("missing").unwrap_err();
|
||||
|
||||
@@ -297,7 +271,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
assert!(registry.is_loaded("git-master"));
|
||||
|
||||
@@ -307,7 +281,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("alpha", "", "body"));
|
||||
@@ -321,7 +295,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("persistent", "", "body"));
|
||||
|
||||
@@ -333,7 +307,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn is_loaded_returns_false_for_unknown() {
|
||||
let registry = SkillRegistry::new();
|
||||
let registry = SkillRegistry::default();
|
||||
|
||||
assert!(!registry.is_loaded("nothing"));
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,6 +22,7 @@ use indoc::formatdoc;
|
||||
use rust_embed::Embed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use skill::SKILL_FUNCTION_PREFIX;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
@@ -33,7 +34,6 @@ use std::{
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
use strum_macros::AsRefStr;
|
||||
use skill::SKILL_FUNCTION_PREFIX;
|
||||
use supervisor::SUPERVISOR_FUNCTION_PREFIX;
|
||||
use todo::TODO_FUNCTION_PREFIX;
|
||||
use user_interaction::USER_FUNCTION_PREFIX;
|
||||
|
||||
+26
-20
@@ -46,7 +46,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
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(".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(
|
||||
".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(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
@@ -529,8 +534,8 @@ pub async fn run_repl_command(
|
||||
.skill loaded # List currently-loaded skills
|
||||
.skill load <name> # Load a skill into the current context
|
||||
.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(),
|
||||
"load" => {
|
||||
@@ -547,19 +552,6 @@ pub async fn run_repl_command(
|
||||
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 => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.upsert_skill(app.as_ref(), name)?;
|
||||
@@ -712,9 +704,23 @@ pub async fn run_repl_command(
|
||||
Some("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!(
|
||||
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]
|
||||
fn repl_commands_has_43_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 43);
|
||||
fn repl_commands_has_44_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user