feat: added skill hint prompt injection and configuration
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-06-05 14:48:54 -06:00
parent b6be2229cf
commit ac3eb40195
17 changed files with 618 additions and 48 deletions
+194 -1
View File
@@ -39,6 +39,7 @@ use indoc::formatdoc;
use inquire::{Confirm, MultiSelect, Text, list_option::ListOption, validator::Validation};
use log::warn;
use parking_lot::RwLock;
use prompts::DEFAULT_SKILL_INSTRUCTIONS;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs::{File, OpenOptions, read_dir, read_to_string, remove_dir_all, remove_file};
use std::io::Write;
@@ -53,6 +54,20 @@ pub struct AutoContinueConfig {
pub continuation_prompt: Option<String>,
}
pub struct SkillInstructionsConfig {
pub inject: bool,
pub instructions: Option<String>,
}
/// Must stay in sync with the predicate that registers `skill__*` tools in `rebuild_tool_scope`
/// (and in `graph::llm::run_llm_node`). Telling the model to call tools that are not exposed
/// is a footgun. `compatible_enabled` is the post-filter universe that `skill__list` would
/// actually return (cascade-allowed AND surviving `Skill::is_compatible` for current
/// `mcp_server_support`), so an empty set means the hint has nothing to point at.
pub fn should_inject_skill_instructions(app: &AppConfig, policy: &SkillPolicy) -> bool {
app.function_calling_support && policy.skills_enabled && !policy.compatible_enabled.is_empty()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RenderMode {
#[default]
@@ -634,9 +649,62 @@ impl RequestContext {
self.agent.as_ref(),
self.session.as_ref(),
)?;
if should_inject_skill_instructions(app, &policy) {
let config = self.skill_instructions_config();
if config.inject {
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
role.append_to_prompt(separator);
role.append_to_prompt(
config
.instructions
.as_deref()
.unwrap_or(DEFAULT_SKILL_INSTRUCTIONS),
);
}
}
Ok(self.skill_registry.effective_role(&role, &policy))
}
pub fn skill_instructions_config(&self) -> SkillInstructionsConfig {
if let Some(agent) = &self.agent {
return SkillInstructionsConfig {
inject: agent.inject_skill_instructions(),
instructions: agent.skill_instructions_value(),
};
}
let app = &self.app.config;
let inject = self
.session
.as_ref()
.and_then(|s| s.inject_skill_instructions())
.or_else(|| {
self.role
.as_ref()
.and_then(|r| r.inject_skill_instructions())
})
.unwrap_or(app.inject_skill_instructions);
let instructions = self
.session
.as_ref()
.and_then(|s| s.skill_instructions().map(|v| v.to_string()))
.or_else(|| {
self.role
.as_ref()
.and_then(|r| r.skill_instructions().map(|v| v.to_string()))
})
.or_else(|| app.skill_instructions.clone());
SkillInstructionsConfig {
inject,
instructions,
}
}
pub fn auto_continue_config(&self) -> AutoContinueConfig {
if let Some(agent) = &self.agent {
return AutoContinueConfig {
@@ -1707,7 +1775,7 @@ impl RequestContext {
}
let value = match key {
"continuation_prompt" => raw_value,
"continuation_prompt" | "skill_instructions" => raw_value,
_ => {
if raw_value.contains(char::is_whitespace) {
bail!("Usage: .set <key> <value>. If value is null, unset key.");
@@ -1907,6 +1975,22 @@ impl RequestContext {
self.update_app_config(|app| app.continuation_prompt = value);
}
}
"inject_skill_instructions" => {
let value: bool = value.parse().with_context(|| "Invalid value")?;
if let Some(session) = self.session.as_mut() {
session.set_inject_skill_instructions(Some(value));
} else {
self.update_app_config(|app| app.inject_skill_instructions = value);
}
}
"skill_instructions" => {
let value: Option<String> = super::parse_value(value)?;
if let Some(session) = self.session.as_mut() {
session.set_skill_instructions(value);
} else {
self.update_app_config(|app| app.skill_instructions = value);
}
}
_ => bail!("Unknown key '{key}'"),
}
Ok(())
@@ -2006,6 +2090,8 @@ impl RequestContext {
"enabled_tools",
"enabled_mcp_servers",
"inject_todo_instructions",
"inject_skill_instructions",
"skill_instructions",
"max_auto_continues",
"save_session",
"compression_threshold",
@@ -2172,6 +2258,11 @@ impl RequestContext {
super::complete_bool(config.inject_instructions)
}
"continuation_prompt" => vec!["null".to_string()],
"inject_skill_instructions" => {
let config = self.skill_instructions_config();
super::complete_bool(config.inject)
}
"skill_instructions" => vec!["null".to_string()],
_ => vec![],
};
values = candidates.into_iter().map(|v| (v, None)).collect();
@@ -3123,6 +3214,108 @@ mod tests {
assert_eq!(extracted.name(), "");
}
#[test]
fn should_inject_skill_instructions_requires_function_calling() {
let app = AppConfig {
function_calling_support: false,
..AppConfig::default()
};
let policy = SkillPolicy {
skills_enabled: true,
enabled: ["a".to_string()].into_iter().collect(),
compatible_enabled: ["a".to_string()].into_iter().collect(),
};
assert!(!should_inject_skill_instructions(&app, &policy));
}
#[test]
fn should_inject_skill_instructions_requires_skills_enabled() {
let app = AppConfig {
function_calling_support: true,
..AppConfig::default()
};
let policy = SkillPolicy {
skills_enabled: false,
enabled: ["a".to_string()].into_iter().collect(),
compatible_enabled: ["a".to_string()].into_iter().collect(),
};
assert!(!should_inject_skill_instructions(&app, &policy));
}
#[test]
fn should_inject_skill_instructions_suppresses_when_no_compatible_skills() {
let app = AppConfig {
function_calling_support: true,
..AppConfig::default()
};
// `enabled` has names, but none survive the compatibility filter — hint must suppress.
let policy = SkillPolicy {
skills_enabled: true,
enabled: ["a".to_string()].into_iter().collect(),
compatible_enabled: Default::default(),
};
assert!(!should_inject_skill_instructions(&app, &policy));
}
#[test]
fn should_inject_skill_instructions_when_all_conditions_met() {
let app = AppConfig {
function_calling_support: true,
..AppConfig::default()
};
let policy = SkillPolicy {
skills_enabled: true,
enabled: ["a".to_string()].into_iter().collect(),
compatible_enabled: ["a".to_string()].into_iter().collect(),
};
assert!(should_inject_skill_instructions(&app, &policy));
}
#[test]
fn skill_instructions_config_falls_back_to_app_default() {
let ctx = create_test_ctx();
let cfg = ctx.skill_instructions_config();
assert!(cfg.inject);
assert!(cfg.instructions.is_none());
}
#[test]
fn skill_instructions_config_respects_role_disable() {
let mut ctx = create_test_ctx();
let role = Role::new("r", "---\ninject_skill_instructions: false\n---\nhello");
ctx.use_role_obj(role).unwrap();
let cfg = ctx.skill_instructions_config();
assert!(!cfg.inject);
}
#[test]
fn skill_instructions_config_session_overrides_role() {
let mut ctx = create_test_ctx();
let role = Role::new("r", "---\ninject_skill_instructions: false\n---\nhello");
ctx.use_role_obj(role).unwrap();
let mut session = Session::default();
session.set_inject_skill_instructions(Some(true));
session.set_skill_instructions(Some("custom hint".into()));
ctx.session = Some(session);
let cfg = ctx.skill_instructions_config();
assert!(cfg.inject);
assert_eq!(cfg.instructions.as_deref(), Some("custom hint"));
}
#[test]
fn exit_session_clears_session() {
let mut ctx = create_test_ctx();