Files
yoi/crates/workspace-server/src/skills.rs
T

601 lines
20 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use config_source::{
ConfigSchemaContribution, MarkdownDocumentProjection, VirtualPath, project_markdown_document,
};
use serde::Deserialize;
use worker::skill::{
SkillActivationResponse, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
SkillDiagnostic, SkillDiagnosticSeverity, SkillProvenance, SkillResourceRef, SkillSourceKind,
};
use crate::config_source::{
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
};
const BUILTIN_SKILL_ID: &str = "agent-skills";
const BUILTIN_SKILL_SOURCE: &str = include_str!("../../../resources/skills/agent-skills/SKILL.md");
const BUILTIN_SKILL_VIRTUAL_PATH: &str = "builtin/skills/agent-skills/SKILL.md";
const SKILL_SCHEMA_PROVIDER_ID: &str = "builtin:skills";
const SKILL_SCHEMA_NAMESPACE: &str = "skills";
const SKILL_SCHEMA_VERSION: &str = "1";
const SKILL_CATALOG_AUTHORITY: &str = "workspace-config-skills-v1";
/// Skill documents are values imported from `SKILL.md`. Known Agent Skills
/// frontmatter is typed while extension keys remain concrete values.
pub const SKILL_DOCUMENT_SCHEMA_SOURCE: &str = r#"{
frontmatter = {
name = String;
description = String;
license = String default "";
compatibility = String default "";
metadata = {...String} default {};
...Unknown
};
content = String;
}"#;
const SKILL_CONFIG_SCHEMA_SOURCE: &str = r#"{
skills = {...{
frontmatter = {
name = String;
description = String;
license = String default "";
compatibility = String default "";
metadata = {...String} default {};
...Unknown
};
content = String;
}} default {};
}"#;
#[derive(Debug, Clone, Copy)]
pub struct SkillConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for SkillConfigSchemaProvider {
fn contribution(&self) -> crate::Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new(
SKILL_SCHEMA_PROVIDER_ID,
SKILL_SCHEMA_NAMESPACE,
SKILL_SCHEMA_VERSION,
SKILL_CONFIG_SCHEMA_SOURCE,
)
.map_err(|error| crate::Error::Config(error.to_string()))
}
}
#[derive(Debug, thiserror::Error)]
pub enum SkillError {
#[error("unknown Skill `{0}`")]
NotFound(String),
#[error("Skill `{0}` has blocking diagnostics")]
InvalidSkill(String),
#[error("failed to evaluate the active Workspace config revision: {0}")]
Evaluation(String),
#[error("the active Workspace config projection is missing its root value")]
MissingProjection,
#[error("the active Workspace config Skill projection is invalid: {0}")]
InvalidProjection(String),
}
#[derive(Debug, Clone)]
struct ParsedSkill {
name: String,
description: String,
allowed_tools: Vec<String>,
body: String,
provenance: SkillProvenance,
overrides: Vec<SkillProvenance>,
resources: Vec<SkillResourceRef>,
diagnostics: Vec<SkillDiagnostic>,
}
#[derive(Debug, Deserialize)]
struct WorkspaceSkillProjection {
#[serde(default)]
skills: BTreeMap<String, MarkdownDocumentProjection>,
}
pub fn catalog(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, SkillError> {
let entries = merged_skills(state)?
.into_values()
.map(|skill| skill.catalog_entry())
.collect();
Ok(SkillCatalogResponse {
authority: SKILL_CATALOG_AUTHORITY.to_string(),
entries,
diagnostics: Vec::new(),
})
}
pub fn lint(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, SkillError> {
catalog(state)
}
pub fn detail(state: &WorkspaceConfigState, name: &str) -> Result<SkillDetailResponse, SkillError> {
let skill = merged_skills(state)?
.remove(name)
.ok_or_else(|| SkillError::NotFound(name.to_string()))?;
Ok(SkillDetailResponse {
name: skill.name,
description: skill.description,
provenance: skill.provenance,
overrides: skill.overrides,
diagnostics: skill.diagnostics,
body: skill.body,
allowed_tools: skill.allowed_tools,
allowed_tools_status: "experimental_hint_only".to_string(),
resources: skill.resources,
})
}
pub fn activation(
state: &WorkspaceConfigState,
name: &str,
) -> Result<SkillActivationResponse, SkillError> {
let skill = merged_skills(state)?
.remove(name)
.ok_or_else(|| SkillError::NotFound(name.to_string()))?;
if skill.has_errors() {
return Err(SkillError::InvalidSkill(name.to_string()));
}
Ok(SkillActivationResponse {
name: skill.name,
provenance: skill.provenance,
diagnostics: skill.diagnostics,
body: skill.body,
})
}
fn merged_skills(
state: &WorkspaceConfigState,
) -> Result<BTreeMap<String, ParsedSkill>, SkillError> {
let mut merged = BTreeMap::new();
let builtin_projection = project_markdown_document(BUILTIN_SKILL_SOURCE)
.expect("embedded built-in Skill Markdown is valid");
let builtin = parse_skill(
BUILTIN_SKILL_ID,
builtin_projection,
SkillProvenance {
kind: SkillSourceKind::Builtin,
id: format!("builtin:{BUILTIN_SKILL_ID}"),
virtual_path: Some(BUILTIN_SKILL_VIRTUAL_PATH.to_string()),
revision: None,
source_digest: Some(config_source::digest_bytes(BUILTIN_SKILL_SOURCE.as_bytes())),
tree_digest: None,
},
Vec::new(),
None,
);
merged.insert(builtin.name.clone(), builtin);
let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())
.map_err(|error| SkillError::Evaluation(error.to_string()))?;
let projection = evaluation
.projections
.first()
.ok_or(SkillError::MissingProjection)?;
let workspace =
serde_json::from_value::<WorkspaceSkillProjection>(projection.data_json.clone())
.map_err(|error| SkillError::InvalidProjection(error.to_string()))?;
for (config_key, document) in workspace.skills {
let name = string_field(&document.frontmatter, "name")
.unwrap_or(&config_key)
.to_string();
let canonical_path = format!("skills/{name}/SKILL.md");
let mut source_diagnostic = None;
let source_entry = VirtualPath::parse(&canonical_path)
.ok()
.and_then(|path| state.snapshot.entries.get(&path));
if let Some(entry) = source_entry {
match project_markdown_document(&entry.content) {
Ok(expected) if normalize_document(expected.clone()) == document => {}
Ok(_) => {
source_diagnostic = Some(SkillDiagnostic::error(
"skill_source_mismatch",
format!(
"Skill `{name}` must be the imported value of `{canonical_path}` in the active config revision"
),
Some(format!("workspace:{name}")),
));
}
Err(message) => {
source_diagnostic = Some(SkillDiagnostic::error(
"invalid_skill_markdown",
format!("{canonical_path}: {message}"),
Some(format!("workspace:{name}")),
));
}
}
} else {
source_diagnostic = Some(SkillDiagnostic::error(
"missing_skill_source",
format!("Skill `{name}` requires `{canonical_path}` in the active config revision"),
Some(format!("workspace:{name}")),
));
}
let source_digest = source_entry
.map(|entry| entry.content_digest.clone())
.unwrap_or_else(|| config_source::digest_bytes(canonical_path.as_bytes()));
let resources = workspace_resources(state, &name);
let mut skill = parse_skill(
&name,
document,
SkillProvenance {
kind: SkillSourceKind::Workspace,
id: format!("workspace:{name}"),
virtual_path: Some(canonical_path),
revision: Some(state.snapshot.revision),
source_digest: Some(source_digest),
tree_digest: Some(state.snapshot.digest.clone()),
},
resources,
source_diagnostic,
);
if let Some(overridden) = merged.insert(name, skill.clone()) {
skill.overrides.push(overridden.provenance);
merged.insert(skill.name.clone(), skill);
}
}
Ok(merged)
}
fn normalize_document(mut document: MarkdownDocumentProjection) -> MarkdownDocumentProjection {
document
.frontmatter
.entry("license")
.or_insert_with(|| serde_json::Value::String(String::new()));
document
.frontmatter
.entry("compatibility")
.or_insert_with(|| serde_json::Value::String(String::new()));
document
.frontmatter
.entry("metadata")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
document
}
fn parse_skill(
fallback_name: &str,
document: MarkdownDocumentProjection,
provenance: SkillProvenance,
resources: Vec<SkillResourceRef>,
source_diagnostic: Option<SkillDiagnostic>,
) -> ParsedSkill {
let mut diagnostics = Vec::new();
if let Some(diagnostic) = source_diagnostic {
diagnostics.push(diagnostic);
}
let frontmatter = document.frontmatter;
let name = string_field(&frontmatter, "name").unwrap_or(fallback_name);
if !valid_skill_name(name) {
diagnostics.push(SkillDiagnostic::error(
"invalid_skill_name",
"frontmatter `name` must be a lowercase kebab-case Skill id",
Some(provenance.id.clone()),
));
}
if name != fallback_name {
diagnostics.push(SkillDiagnostic::error(
"skill_name_mismatch",
format!("frontmatter name `{name}` must match Skill id `{fallback_name}`"),
Some(provenance.id.clone()),
));
}
let description = string_field(&frontmatter, "description")
.unwrap_or_default()
.trim()
.to_string();
if description.is_empty() {
diagnostics.push(SkillDiagnostic::error(
"missing_description",
"frontmatter `description` must be a non-empty string",
Some(provenance.id.clone()),
));
}
for field in [
"profile",
"system_prompt",
"prompt",
"plugins",
"plugin",
"model_invokation",
"model_invocation",
"user_invocable",
"graph",
"invocation",
] {
if frontmatter.contains_key(field) {
diagnostics.push(SkillDiagnostic::error(
"workflow_authority",
format!("frontmatter `{field}` is workflow/profile authority and is not allowed"),
Some(provenance.id.clone()),
));
}
}
let allowed_tools = frontmatter
.get("allowed-tools")
.map(parse_allowed_tools)
.unwrap_or_default();
if !allowed_tools.is_empty() {
diagnostics.push(SkillDiagnostic::warning(
"allowed_tools_hint",
"frontmatter `allowed-tools` is an instruction hint only and does not grant tools",
Some(provenance.id.clone()),
));
}
ParsedSkill {
name: fallback_name.to_string(),
description,
allowed_tools,
body: document.content,
provenance,
overrides: Vec::new(),
resources,
diagnostics,
}
}
fn valid_skill_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& !name.ends_with('-')
&& !name.contains("--")
&& name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
fn string_field<'a>(
frontmatter: &'a serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Option<&'a str> {
frontmatter.get(field).and_then(serde_json::Value::as_str)
}
fn parse_allowed_tools(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::String(value) => value
.split(|character: char| character == ',' || character.is_whitespace())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
serde_json::Value::Array(values) => values
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect(),
_ => Vec::new(),
}
}
fn workspace_resources(state: &WorkspaceConfigState, name: &str) -> Vec<SkillResourceRef> {
let mut resources = Vec::new();
for (kind, directory) in [
("reference", "references"),
("asset", "assets"),
("script", "scripts"),
] {
let prefix = format!("skills/{name}/{directory}/");
for child in resource_children(state, &prefix) {
resources.push(SkillResourceRef {
kind: kind.to_string(),
name: child,
supported: kind == "reference",
diagnostic: (kind != "reference").then(|| {
format!("{kind} resources are catalogued but are not loaded automatically")
}),
});
}
}
resources
}
fn resource_children(state: &WorkspaceConfigState, prefix: &str) -> Vec<String> {
let mut children = BTreeSet::new();
for path in state.snapshot.entries.keys() {
let Some(relative) = path.as_str().strip_prefix(prefix) else {
continue;
};
if relative.is_empty() {
continue;
}
let child = relative.split('/').next().unwrap_or(relative);
children.insert(format!("{prefix}{child}"));
}
children.into_iter().collect()
}
impl ParsedSkill {
fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == SkillDiagnosticSeverity::Error)
}
fn catalog_entry(&self) -> SkillCatalogEntry {
SkillCatalogEntry {
name: self.name.clone(),
description: self.description.clone(),
provenance: self.provenance.clone(),
overrides: self.overrides.clone(),
diagnostics: self.diagnostics.clone(),
}
}
}
#[cfg(test)]
mod tests {
use config_source::{
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, ToolchainContract,
WorkspaceConfigSchemaBundle,
};
use super::*;
fn state(main: &str, markdown: &str, name: &str) -> WorkspaceConfigState {
let skill_path = format!("skills/{name}/SKILL.md");
let reference_path = format!("skills/{name}/references/checklist.md");
let tree = ConfigTreeSnapshot::from_entries(
9,
[
ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
main,
)
.unwrap(),
ConfigEntry::new(
VirtualPath::parse(&skill_path).unwrap(),
ConfigContentType::Text,
markdown,
)
.unwrap(),
ConfigEntry::new(
VirtualPath::parse(&reference_path).unwrap(),
ConfigContentType::Text,
"checklist",
)
.unwrap(),
],
)
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([SkillConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
WorkspaceConfigState {
snapshot: tree,
contract: ToolchainContract::with_schema_bundle(
1,
vec![VirtualPath::parse("main.dcdl").unwrap()],
1,
bundle,
),
projection_digest: "projection".to_string(),
}
}
fn main_source(name: &str) -> String {
let config_key = name.replace('-', "_");
format!(
r#"{{ skills = {{ {config_key} = import "./skills/{name}/SKILL.md" as {}; }}; }}"#,
SKILL_DOCUMENT_SCHEMA_SOURCE
)
}
#[test]
fn workspace_skill_projection_keeps_extensions_and_uses_virtual_resources() {
let markdown = concat!(
"---\n",
"name: debug-rust\n",
"description: Debug Rust failures\n",
"custom-authority: no\n",
"allowed-tools: Read Grep\n",
"metadata:\n owner: platform\n",
"---\n",
"# Debug Rust\n",
);
let state = state(&main_source("debug-rust"), markdown, "debug-rust");
let evaluation =
evaluate_workspace_config_state(&state, state.contract.schema_bundle.clone()).unwrap();
assert_eq!(
evaluation.projections[0].data_json["skills"]["debug_rust"]["frontmatter"]["custom-authority"],
"no"
);
let catalog = catalog(&state).unwrap();
let item = catalog
.entries
.iter()
.find(|item| item.name == "debug-rust")
.unwrap();
assert_eq!(item.provenance.kind, SkillSourceKind::Workspace);
assert_eq!(item.provenance.revision, Some(9));
assert!(
item.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity != SkillDiagnosticSeverity::Error)
);
let detail = detail(&state, "debug-rust").unwrap();
assert_eq!(detail.body, "# Debug Rust\n");
assert_eq!(detail.allowed_tools, vec!["Read", "Grep"]);
assert_eq!(
detail.resources[0].name,
"skills/debug-rust/references/checklist.md"
);
assert_eq!(
activation(&state, "debug-rust").unwrap().body,
"# Debug Rust\n"
);
}
#[test]
fn workspace_override_replaces_builtin_deterministically() {
let markdown = concat!(
"---\nname: agent-skills\n",
"description: Workspace override\n",
"---\n# Workspace agent skills\n",
);
let state = state(&main_source("agent-skills"), markdown, "agent-skills");
let catalog = catalog(&state).unwrap();
assert_eq!(
catalog
.entries
.iter()
.filter(|item| item.name == "agent-skills")
.count(),
1
);
let item = catalog
.entries
.iter()
.find(|item| item.name == "agent-skills")
.unwrap();
assert_eq!(item.description, "Workspace override");
assert_eq!(item.provenance.kind, SkillSourceKind::Workspace);
assert_eq!(item.overrides[0].kind, SkillSourceKind::Builtin);
}
#[test]
fn inline_skill_value_cannot_claim_canonical_source_identity() {
let main = r#"{
skills = {
debug_rust = {
frontmatter = {
name = "debug-rust";
description = "Inline authority";
};
content = "inline";
};
};
}"#;
let markdown = "---\nname: debug-rust\ndescription: File authority\n---\nfile\n";
let state = state(main, markdown, "debug-rust");
let item = catalog(&state)
.unwrap()
.entries
.into_iter()
.find(|item| item.name == "debug-rust")
.unwrap();
assert!(
item.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "skill_source_mismatch")
);
assert!(matches!(
activation(&state, "debug-rust"),
Err(SkillError::InvalidSkill(_))
));
}
#[test]
fn schema_accepts_unknown_extensions_but_keeps_skill_documents_typed() {
let contribution = SkillConfigSchemaProvider.contribution().unwrap();
assert_eq!(contribution.namespace, "skills");
assert!(contribution.source.contains("...Unknown"));
assert!(contribution.source.contains("metadata = {...String}"));
}
}