feat(skills): add Agent Skills support with orchestrator-driven activation

This commit is contained in:
Spherrrical 2026-05-13 15:44:16 -07:00
parent 5a4487fc6e
commit 7f5bf641bb
24 changed files with 2777 additions and 97 deletions

View file

@ -163,6 +163,12 @@ pub struct TopLevelRoutingPreference {
pub name: String,
pub description: String,
pub models: Vec<String>,
/// Agent Skills associated with this route. When Plano-Orchestrator
/// selects this route, every skill listed here is also offered to the
/// orchestrator in the `<skills>` block; selected skills have their
/// SKILL.md body prepended to the upstream system prompt.
#[serde(default)]
pub skills: Option<Vec<String>>,
#[serde(default)]
pub selection_policy: SelectionPolicy,
}
@ -224,6 +230,17 @@ pub struct Configuration {
pub state_storage: Option<StateStorageConfig>,
pub routing_preferences: Option<Vec<TopLevelRoutingPreference>>,
pub model_metrics_sources: Option<Vec<MetricsSource>>,
/// Agent Skills (https://agentskills.io) installed for this project.
///
/// The Plano CLI discovers `.plano/skills/<name>/SKILL.md` files at render
/// time and materializes them into this list with `body` already loaded so
/// downstream consumers do not need filesystem access. Skills are scoped
/// to specific routes via `routing_preferences[].skills`; Plano-Orchestrator
/// receives a `<skills>` block alongside `<routes>` for any skills attached
/// to candidate routes, and selected skills have their SKILL.md body
/// injected into the upstream system prompt.
#[serde(default)]
pub skills: Option<Vec<SkillRef>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@ -611,6 +628,45 @@ pub struct PromptTarget {
pub auto_llm_dispatch_on_response: Option<bool>,
}
/// An Agent Skill (https://agentskills.io) as materialized by the Plano CLI.
///
/// At runtime brightstaff and the WASM filters reason over the catalog
/// (`name` + `description`) and, when a skill is selected, inject the
/// pre-loaded `body` into the downstream system prompt.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillRef {
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_dir: Option<String>,
/// Full SKILL.md markdown body (post-frontmatter). Inlined here at render
/// time so the WASM sandbox does not need filesystem access.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<String>,
}
impl SkillRef {
/// Best-effort short summary suitable for the `<skills>` block sent to
/// Plano-Orchestrator: only the public-facing description, never the
/// full SKILL.md body. The body is injected separately, after a skill
/// has been selected.
pub fn catalog_description(&self) -> &str {
&self.description
}
}
// convert PromptTarget to ChatCompletionTool
impl From<&PromptTarget> for ChatCompletionTool {
fn from(val: &PromptTarget) -> Self {
@ -807,4 +863,34 @@ disable_signals: false
let overrides: super::Overrides = serde_yaml::from_str(yaml_missing).unwrap();
assert_eq!(overrides.disable_signals, None);
}
#[test]
fn test_top_level_routing_preference_skills_deserialize() {
let yaml = r#"
name: code review
description: reviewing, analyzing, and suggesting improvements to existing code
models:
- openai/gpt-4o
skills:
- code-review-skill
"#;
let pref: super::TopLevelRoutingPreference = serde_yaml::from_str(yaml).unwrap();
assert_eq!(pref.name, "code review");
assert_eq!(
pref.skills.as_deref(),
Some(&["code-review-skill".to_string()][..])
);
}
#[test]
fn test_top_level_routing_preference_skills_optional() {
let yaml = r#"
name: code generation
description: generating new code
models:
- openai/gpt-4o
"#;
let pref: super::TopLevelRoutingPreference = serde_yaml::from_str(yaml).unwrap();
assert!(pref.skills.is_none());
}
}

View file

@ -8,6 +8,7 @@ pub mod path;
pub mod pii;
pub mod ratelimit;
pub mod routing;
pub mod skills_runtime;
pub mod stats;
pub mod tokenizer;
pub mod traces;

View file

@ -0,0 +1,215 @@
//! Runtime helpers for handling Agent Skills selected by Plano-Orchestrator.
//!
//! These functions live in `common` (rather than `brightstaff` or a WASM
//! crate) so they can be unit-tested on the native target without dragging
//! in proxy-wasm host-call symbols or tokio runtime dependencies.
use crate::configuration::{SkillRef, TopLevelRoutingPreference};
/// Filter `skills` down to the subset attached to `route_name` via
/// `routing_preferences[].skills`. When the selected route has no `skills:`
/// list, returns an empty vector — skills are scoped to routes, not global.
///
/// `routing_preferences` is the source of truth for which skills are even
/// eligible for the orchestrator to activate on a given route.
pub fn skills_for_route<'a>(
skills: &'a [SkillRef],
routing_preferences: &[TopLevelRoutingPreference],
route_name: &str,
) -> Vec<&'a SkillRef> {
let Some(route) = routing_preferences.iter().find(|p| p.name == route_name) else {
return Vec::new();
};
let Some(allow) = route.skills.as_ref() else {
return Vec::new();
};
let mut out: Vec<&SkillRef> = Vec::with_capacity(allow.len());
for name in allow {
if let Some(skill) = skills.iter().find(|s| &s.name == name) {
out.push(skill);
}
}
out
}
/// Resolve a list of orchestrator-selected skill names to their `SkillRef`s.
/// Unknown names are dropped silently — the orchestrator can hallucinate.
/// Results are deduplicated by name, preserving the order Plano-Orchestrator
/// returned.
pub fn resolve_selected_skills<'a>(
skills: &'a [SkillRef],
selected_names: &[String],
) -> Vec<&'a SkillRef> {
let mut out: Vec<&SkillRef> = Vec::with_capacity(selected_names.len());
for name in selected_names {
if out.iter().any(|s| &s.name == name) {
continue;
}
if let Some(skill) = skills.iter().find(|s| &s.name == name) {
out.push(skill);
}
}
out
}
/// Append the bodies of activated skills to a system prompt, wrapped in
/// `<skill_content name="...">` tags so a future context-management pass can
/// recognize and recompact them.
///
/// Returns `None` only if no base system prompt was supplied and no skills
/// were activated. When skills are present the wrapper text always appears so
/// the downstream model receives a clear, well-structured instruction block.
pub fn augment_system_prompt_with_skills(
base_system_prompt: Option<String>,
activated_skills: &[&SkillRef],
) -> Option<String> {
if activated_skills.is_empty() {
return base_system_prompt;
}
let mut buf = String::new();
if let Some(base) = base_system_prompt {
if !base.is_empty() {
buf.push_str(&base);
buf.push('\n');
buf.push('\n');
}
}
buf.push_str(
"The following Agent Skills have been activated for this request. \
Follow their instructions when relevant; resolve relative paths \
against each skill's base directory.\n\n",
);
for skill in activated_skills {
buf.push_str(&format!("<skill_content name=\"{}\"", skill.name));
if let Some(base_dir) = skill.base_dir.as_deref() {
buf.push_str(&format!(" base_dir=\"{}\"", base_dir));
}
buf.push_str(">\n");
if let Some(body) = skill.body.as_deref() {
buf.push_str(body.trim_end());
buf.push('\n');
} else {
buf.push_str(&format!("(skill description) {}\n", skill.description));
}
buf.push_str("</skill_content>\n\n");
}
Some(buf.trim_end().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::configuration::SelectionPolicy;
fn skill(name: &str, body: &str) -> SkillRef {
SkillRef {
name: name.to_string(),
description: format!("desc for {}", name),
path: Some(format!("/skills/{}/SKILL.md", name)),
base_dir: Some(format!("/skills/{}", name)),
body: Some(body.to_string()),
scope: Some("project".to_string()),
compatibility: None,
license: None,
metadata: None,
allowed_tools: None,
}
}
fn route(name: &str, skill_names: Option<Vec<&str>>) -> TopLevelRoutingPreference {
TopLevelRoutingPreference {
name: name.to_string(),
description: format!("desc for {}", name),
models: vec!["openai/gpt-4o".to_string()],
skills: skill_names.map(|v| v.into_iter().map(String::from).collect()),
selection_policy: SelectionPolicy::default(),
}
}
#[test]
fn skills_for_route_returns_attached_skills() {
let catalog = vec![
skill("pdf-processing", "extract"),
skill("code-review", "review"),
];
let routes = vec![
route("code review", Some(vec!["code-review"])),
route("doc work", Some(vec!["pdf-processing"])),
];
let resolved = skills_for_route(&catalog, &routes, "code review");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "code-review");
}
#[test]
fn skills_for_route_empty_when_route_has_no_skills_list() {
let catalog = vec![skill("pdf-processing", "extract")];
let routes = vec![route("code review", None)];
let resolved = skills_for_route(&catalog, &routes, "code review");
assert!(resolved.is_empty());
}
#[test]
fn skills_for_route_empty_when_route_missing() {
let catalog = vec![skill("pdf-processing", "extract")];
let routes = vec![route("code review", Some(vec!["pdf-processing"]))];
let resolved = skills_for_route(&catalog, &routes, "no-such-route");
assert!(resolved.is_empty());
}
#[test]
fn skills_for_route_drops_unknown_skill_names() {
let catalog = vec![skill("pdf-processing", "extract")];
let routes = vec![route(
"code review",
Some(vec!["pdf-processing", "ghost-skill"]),
)];
let resolved = skills_for_route(&catalog, &routes, "code review");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "pdf-processing");
}
#[test]
fn resolve_selected_skills_drops_unknown_and_dedupes() {
let catalog = vec![
skill("pdf-processing", "extract"),
skill("code-review", "review"),
];
let names = vec![
"code-review".to_string(),
"ghost".to_string(),
"code-review".to_string(),
"pdf-processing".to_string(),
];
let resolved = resolve_selected_skills(&catalog, &names);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name, "code-review");
assert_eq!(resolved[1].name, "pdf-processing");
}
#[test]
fn augment_passthrough_with_no_skills() {
let augmented = augment_system_prompt_with_skills(Some("you are helpful".to_string()), &[]);
assert_eq!(augmented.as_deref(), Some("you are helpful"));
}
#[test]
fn augment_includes_skill_bodies() {
let s = skill("pdf-processing", "extract text and tables");
let augmented =
augment_system_prompt_with_skills(Some("you are helpful".to_string()), &[&s])
.expect("augmented");
assert!(augmented.starts_with("you are helpful"));
assert!(augmented.contains("<skill_content name=\"pdf-processing\""));
assert!(augmented.contains("extract text and tables"));
assert!(augmented.contains("base_dir=\"/skills/pdf-processing\""));
}
#[test]
fn augment_without_base_prompt_still_works() {
let s = skill("code-review", "look at diffs");
let augmented = augment_system_prompt_with_skills(None, &[&s]).expect("augmented");
assert!(augmented.contains("<skill_content name=\"code-review\""));
assert!(augmented.contains("look at diffs"));
}
}