feat: REPL integration with skills
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use super::rag_cache::{RagCache, RagKey};
|
use super::rag_cache::{RagCache, RagKey};
|
||||||
use super::session::Session;
|
use super::session::Session;
|
||||||
|
use super::skill::{SKILL_SCAFFOLD, Skill};
|
||||||
use super::skill_policy::SkillPolicy;
|
use super::skill_policy::SkillPolicy;
|
||||||
use super::skill_registry::SkillRegistry;
|
use super::skill_registry::SkillRegistry;
|
||||||
use super::todo::TodoList;
|
use super::todo::TodoList;
|
||||||
@@ -1544,6 +1545,7 @@ impl RequestContext {
|
|||||||
"session" => (self.sessions_dir(), Some(".yaml")),
|
"session" => (self.sessions_dir(), Some(".yaml")),
|
||||||
"rag" => (paths::rags_dir(), Some(".yaml")),
|
"rag" => (paths::rags_dir(), Some(".yaml")),
|
||||||
"macro" => (paths::macros_dir(), Some(".yaml")),
|
"macro" => (paths::macros_dir(), Some(".yaml")),
|
||||||
|
"skill" => (paths::skills_dir(), None),
|
||||||
"agent-data" => (paths::agents_data_dir(), None),
|
"agent-data" => (paths::agents_data_dir(), None),
|
||||||
_ => bail!("Unknown kind '{kind}'"),
|
_ => bail!("Unknown kind '{kind}'"),
|
||||||
};
|
};
|
||||||
@@ -1869,6 +1871,16 @@ impl RequestContext {
|
|||||||
super::map_completion_values(values)
|
super::map_completion_values(values)
|
||||||
}
|
}
|
||||||
".macro" => super::map_completion_values(paths::list_macros()),
|
".macro" => super::map_completion_values(paths::list_macros()),
|
||||||
|
".skill" => {
|
||||||
|
let mut values: Vec<String> = 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 {
|
".starter" => match &self.agent {
|
||||||
Some(agent) => agent
|
Some(agent) => agent
|
||||||
.conversation_starters()
|
.conversation_starters()
|
||||||
@@ -1911,6 +1923,7 @@ impl RequestContext {
|
|||||||
"session",
|
"session",
|
||||||
"rag",
|
"rag",
|
||||||
"macro",
|
"macro",
|
||||||
|
"skill",
|
||||||
"agent-data",
|
"agent-data",
|
||||||
]),
|
]),
|
||||||
".vault" => {
|
".vault" => {
|
||||||
@@ -2473,6 +2486,117 @@ impl RequestContext {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn upsert_skill(&self, app: &AppConfig, name: &str) -> Result<()> {
|
||||||
|
let path = paths::skill_file(name);
|
||||||
|
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())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let editor = app.editor()?;
|
||||||
|
edit_file(&editor, &path)?;
|
||||||
|
|
||||||
|
if self.working_mode.is_repl() {
|
||||||
|
if is_new {
|
||||||
|
println!("✓ Created skill at '{}'.", path.display());
|
||||||
|
} else {
|
||||||
|
println!("✓ Saved skill at '{}'.", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {})",
|
||||||
|
paths::skill_file(name).display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let policy = SkillPolicy::effective(
|
||||||
|
&self.app.config,
|
||||||
|
self.role.as_ref(),
|
||||||
|
self.agent.as_ref(),
|
||||||
|
self.session.as_ref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !policy.skills_enabled {
|
||||||
|
bail!("Skills are disabled in this context");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !policy.allows(name) {
|
||||||
|
bail!("Skill '{name}' is not enabled in this context");
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill = Skill::load(name)?;
|
||||||
|
let fn_on = self.app.config.function_calling_support;
|
||||||
|
let mcp_on = self.app.config.mcp_server_support;
|
||||||
|
let needs_tools = skill
|
||||||
|
.enabled_tools()
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let needs_mcps = skill
|
||||||
|
.enabled_mcp_servers()
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if needs_tools && !fn_on {
|
||||||
|
bail!("Skill '{name}' requires function calling, which is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_mcps && !mcp_on {
|
||||||
|
bail!("Skill '{name}' requires MCP servers, which are disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.skill_registry.insert(skill)?;
|
||||||
|
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||||
|
let _ = self.skill_registry.unload(name);
|
||||||
|
bail!("Loaded skill '{name}' but failed to refresh tool scope: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("✓ Loaded skill '{name}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("✓ Unloaded skill '{name}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_loaded_skills(&self) {
|
||||||
|
let names = self.skill_registry.loaded_names();
|
||||||
|
|
||||||
|
if names.is_empty() {
|
||||||
|
println!("No skills loaded.");
|
||||||
|
} else {
|
||||||
|
println!("Loaded skills:");
|
||||||
|
for name in names {
|
||||||
|
println!(" • {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn apply_prelude(
|
pub async fn apply_prelude(
|
||||||
&mut self,
|
&mut self,
|
||||||
app: &AppConfig,
|
app: &AppConfig,
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ struct SkillsAsset;
|
|||||||
static RE_METADATA: LazyLock<Regex> =
|
static RE_METADATA: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"(?s)-{3,}\s*(.*?)\s*-{3,}\s*(.*)").unwrap());
|
LazyLock::new(|| Regex::new(r"(?s)-{3,}\s*(.*?)\s*-{3,}\s*(.*)").unwrap());
|
||||||
|
|
||||||
|
pub const SKILL_SCAFFOLD: &str = "\
|
||||||
|
---
|
||||||
|
description: One-line description shown to the model when listing skills.
|
||||||
|
enabled_tools:
|
||||||
|
enabled_mcp_servers:
|
||||||
|
auto_unload: false
|
||||||
|
---
|
||||||
|
Replace this body with the knowledge or methodology this skill teaches.
|
||||||
|
";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
pub struct Skill {
|
pub struct Skill {
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
+57
-4
@@ -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; 42]> = LazyLock::new(|| {
|
static REPL_COMMANDS: LazyLock<[ReplCommand; 43]> = 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()),
|
||||||
@@ -191,6 +191,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 42]> = LazyLock::new(|| {
|
|||||||
AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT),
|
AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT),
|
||||||
),
|
),
|
||||||
ReplCommand::new(".macro", "Execute a macro", AssertState::pass()),
|
ReplCommand::new(".macro", "Execute a macro", AssertState::pass()),
|
||||||
|
ReplCommand::new(
|
||||||
|
".skill",
|
||||||
|
"List, load, unload, edit, or create skills",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".file",
|
".file",
|
||||||
"Include files, directories, URLs or commands",
|
"Include files, directories, URLs or commands",
|
||||||
@@ -513,6 +518,54 @@ pub async fn run_repl_command(
|
|||||||
.role <name> [text]... # Temporarily switch to the role, send the text, and switch back"#
|
.role <name> [text]... # Temporarily switch to the role, send the text, and switch back"#
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
".skill" => {
|
||||||
|
let trimmed = args.map(str::trim).unwrap_or("");
|
||||||
|
let mut parts = trimmed.splitn(2, char::is_whitespace);
|
||||||
|
let first = parts.next().unwrap_or("");
|
||||||
|
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||||
|
match first {
|
||||||
|
"" => println!(
|
||||||
|
r#"Usage:
|
||||||
|
.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"#
|
||||||
|
),
|
||||||
|
"loaded" => ctx.list_loaded_skills(),
|
||||||
|
"load" => {
|
||||||
|
if rest.is_empty() {
|
||||||
|
println!("Usage: .skill load <name>");
|
||||||
|
} else {
|
||||||
|
ctx.load_skill_repl(rest, abort_signal.clone()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unload" => {
|
||||||
|
if rest.is_empty() {
|
||||||
|
println!("Usage: .skill unload <name>");
|
||||||
|
} else {
|
||||||
|
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)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
".session" => {
|
".session" => {
|
||||||
if let Some(name) = graph::active_agent_graph_name(ctx) {
|
if let Some(name) = graph::active_agent_graph_name(ctx) {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -779,7 +832,7 @@ pub async fn run_repl_command(
|
|||||||
ctx.delete(args)?;
|
ctx.delete(args)?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!("Usage: .delete <role|session|rag|macro|agent-data>")
|
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
".copy" => {
|
".copy" => {
|
||||||
@@ -1265,8 +1318,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_42_entries() {
|
fn repl_commands_has_43_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 42);
|
assert_eq!(REPL_COMMANDS.len(), 43);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user