feat: add workspace-backed agent skills
This commit is contained in:
parent
05c50e32cf
commit
62ef89a163
|
|
@ -139,6 +139,10 @@ pub enum SystemItem {
|
|||
/// byte-identical to what was sent.
|
||||
FileAttachment { path: String, body: String },
|
||||
|
||||
/// Explicit Agent Skill activation. `body` is the exact LLM-facing
|
||||
/// Skill text committed before any subsequent LLM request can use it.
|
||||
SkillActivation { name: String, body: String },
|
||||
|
||||
/// Historical persisted Knowledge reference resolution. Knowledge is no
|
||||
/// longer active, so restored sessions ignore this item instead of replaying
|
||||
/// archived record text into model context.
|
||||
|
|
@ -174,6 +178,7 @@ impl SystemItem {
|
|||
SystemItem::Notification { body, .. } => body.clone(),
|
||||
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||
SystemItem::SkillActivation { body, .. } => body.clone(),
|
||||
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
|
||||
SystemItem::LegacyIgnored { slug } => {
|
||||
format!("Ignored legacy procedure item: /{slug}")
|
||||
|
|
@ -196,6 +201,7 @@ impl SystemItem {
|
|||
SystemItem::Notification { .. } => "notification",
|
||||
SystemItem::WorkerEvent { .. } => "worker_event",
|
||||
SystemItem::FileAttachment { .. } => "file_attachment",
|
||||
SystemItem::SkillActivation { .. } => "skill_activation",
|
||||
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||
|
|
|
|||
|
|
@ -2163,6 +2163,7 @@ impl App {
|
|||
self.blocks.push(Block::WorkerEvent { event });
|
||||
}
|
||||
session_store::SystemItem::FileAttachment { body, .. }
|
||||
| session_store::SystemItem::SkillActivation { body, .. }
|
||||
| session_store::SystemItem::TaskReminder { body, .. }
|
||||
| session_store::SystemItem::Interrupt { body } => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod runtime;
|
|||
pub mod segment_log_sink;
|
||||
pub mod shared_state;
|
||||
mod shutdown_after_idle;
|
||||
pub mod skill;
|
||||
pub mod spawn;
|
||||
|
||||
mod interrupt_prep;
|
||||
|
|
|
|||
238
crates/worker/src/skill.rs
Normal file
238
crates/worker/src/skill.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::worker::WorkspaceClient;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillDiagnosticSeverity {
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillDiagnostic {
|
||||
pub severity: SkillDiagnosticSeverity,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
/// Path-free authority/provenance label such as `builtin:foo` or `workspace:foo`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
impl SkillDiagnostic {
|
||||
pub fn error(
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
source: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
severity: SkillDiagnosticSeverity::Error,
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warning(
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
source: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
severity: SkillDiagnosticSeverity::Warning,
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillSourceKind {
|
||||
Builtin,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillProvenance {
|
||||
pub kind: SkillSourceKind,
|
||||
/// Stable path-free id: `builtin:<name>` or `workspace:<name>`.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillResourceRef {
|
||||
pub kind: String,
|
||||
/// Skill-relative resource name/path. Never an absolute filesystem path.
|
||||
pub name: String,
|
||||
pub supported: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillCatalogEntry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub provenance: SkillProvenance,
|
||||
#[serde(default)]
|
||||
pub overrides: Vec<SkillProvenance>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<SkillDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillCatalogResponse {
|
||||
/// Authority label for diagnostics; callers must not interpret it as a path.
|
||||
pub authority: String,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<SkillCatalogEntry>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<SkillDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillDetailResponse {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub provenance: SkillProvenance,
|
||||
#[serde(default)]
|
||||
pub overrides: Vec<SkillProvenance>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<SkillDiagnostic>,
|
||||
/// Full SKILL.md contents. This is intentionally omitted from catalog responses.
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
/// Explicitly documents that allowed-tools is parsed only as an experimental hint.
|
||||
pub allowed_tools_status: String,
|
||||
#[serde(default)]
|
||||
pub resources: Vec<SkillResourceRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillActivationResponse {
|
||||
pub name: String,
|
||||
pub provenance: SkillProvenance,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<SkillDiagnostic>,
|
||||
/// Full SKILL.md contents to append to Worker history on explicit activation.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SkillClientError {
|
||||
#[error("workspace client is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
#[error("workspace client kind `{0}` does not expose direct Skill HTTP operations")]
|
||||
UnsupportedClient(String),
|
||||
#[error("Skill request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
#[error("Skill API response JSON is invalid: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Skill API returned HTTP {status}: {body}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
#[error("invalid Skill API base URL: {0}")]
|
||||
InvalidBaseUrl(String),
|
||||
}
|
||||
|
||||
impl WorkspaceClient {
|
||||
pub fn list_skills(&self) -> Result<SkillCatalogResponse, SkillClientError> {
|
||||
self.get_skill_json("skills")
|
||||
}
|
||||
|
||||
pub fn read_skill(&self, name: &str) -> Result<SkillDetailResponse, SkillClientError> {
|
||||
self.get_skill_json(&format!("skills/{name}"))
|
||||
}
|
||||
|
||||
pub fn activate_skill(&self, name: &str) -> Result<SkillActivationResponse, SkillClientError> {
|
||||
self.get_skill_json(&format!("skills/{name}/activate"))
|
||||
}
|
||||
|
||||
fn get_skill_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<T, SkillClientError> {
|
||||
let Self::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} = self
|
||||
else {
|
||||
return match self {
|
||||
Self::Available { kind } => Err(SkillClientError::UnsupportedClient(kind.clone())),
|
||||
Self::Unavailable { reason } => Err(SkillClientError::Unavailable(reason.clone())),
|
||||
Self::Http { .. } => unreachable!(),
|
||||
};
|
||||
};
|
||||
if base_url.trim().is_empty() {
|
||||
return Err(SkillClientError::InvalidBaseUrl(base_url.clone()));
|
||||
}
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/api/w/{workspace_id}/{path}");
|
||||
let response = reqwest::blocking::Client::new().get(url).send()?;
|
||||
let status = response.status();
|
||||
let body = response.text()?;
|
||||
if !status.is_success() {
|
||||
return Err(SkillClientError::Http { status, body });
|
||||
}
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn http_workspace_client_fetches_skill_catalog_from_backend_endpoint() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line).unwrap();
|
||||
assert!(request_line.starts_with("GET /api/w/ws-1/skills HTTP/1.1"));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line == "\r\n" || line.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"authority": "workspace-backend-skills-v0",
|
||||
"entries": [{
|
||||
"name": "triage-errors",
|
||||
"description": "Use when triaging errors.",
|
||||
"provenance": { "kind": "workspace", "id": "workspace:triage-errors" },
|
||||
"overrides": [],
|
||||
"diagnostics": []
|
||||
}],
|
||||
"diagnostics": []
|
||||
})
|
||||
.to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let client = WorkspaceClient::http("ws-1", format!("http://{addr}"));
|
||||
let catalog = client.list_skills().unwrap();
|
||||
assert_eq!(catalog.entries[0].name, "triage-errors");
|
||||
assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ use crate::prompt::loader::PromptLoader;
|
|||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
use crate::runtime::dir;
|
||||
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
|
||||
use crate::skill::{SkillActivationResponse, SkillClientError};
|
||||
#[cfg(test)]
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
|
|
@ -896,6 +897,31 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||
self.workspace_context.client()
|
||||
}
|
||||
|
||||
/// Activate an Agent Skill through the Workspace backend/client and commit
|
||||
/// the returned SKILL.md body to history before it can influence an LLM run.
|
||||
///
|
||||
/// This deliberately does not scan `.yoi/skills` locally: when a Workspace
|
||||
/// HTTP client is available, catalog/detail/activation authority belongs to
|
||||
/// the Workspace backend API.
|
||||
pub fn activate_skill(&mut self, name: &str) -> Result<SkillActivationResponse, WorkerError> {
|
||||
let activation = self.workspace_client().activate_skill(name)?;
|
||||
self.ensure_segment_head()?;
|
||||
let body = format!(
|
||||
"Agent Skill `{}` activated from {}.\n\n{}",
|
||||
activation.name, activation.provenance.id, activation.body
|
||||
);
|
||||
self.commit_entry(LogEntry::SystemItem {
|
||||
ts: segment_log::now_millis(),
|
||||
item: SystemItem::SkillActivation {
|
||||
name: activation.name.clone(),
|
||||
body: body.clone(),
|
||||
},
|
||||
})?;
|
||||
self.engine_mut()
|
||||
.append_history(std::iter::once(llm_engine::Item::system_message(body)));
|
||||
Ok(activation)
|
||||
}
|
||||
|
||||
pub(crate) fn worker_metadata_store(&self) -> St
|
||||
where
|
||||
St: Clone,
|
||||
|
|
@ -4789,6 +4815,9 @@ pub enum WorkerError {
|
|||
#[error(transparent)]
|
||||
PromptCatalog(#[from] CatalogError),
|
||||
|
||||
#[error(transparent)]
|
||||
Skill(#[from] SkillClientError),
|
||||
|
||||
#[error("memory extract staging write failed: {0}")]
|
||||
ExtractStaging(#[source] memory::extract::StagingError),
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub mod records;
|
|||
pub mod repositories;
|
||||
pub mod resource_broker;
|
||||
pub mod server;
|
||||
pub mod skills;
|
||||
pub mod store;
|
||||
|
||||
pub use config::{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ enum Command {
|
|||
Init(InitOptions),
|
||||
ConfigDefault,
|
||||
ConfigDiff(WorkspacePathOptions),
|
||||
Skills(SkillsCommand),
|
||||
Help,
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +37,13 @@ struct WorkspacePathOptions {
|
|||
workspace: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SkillsCommand {
|
||||
List(WorkspacePathOptions),
|
||||
Lint(WorkspacePathOptions),
|
||||
Show { workspace: PathBuf, name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CliError(String);
|
||||
|
||||
|
|
@ -65,6 +73,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
Command::Init(options) => run_init(options),
|
||||
Command::ConfigDefault => run_config_default(),
|
||||
Command::ConfigDiff(options) => run_config_diff(options),
|
||||
Command::Skills(command) => run_skills(command),
|
||||
Command::Help => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +93,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||
Ok(Command::Init(parse_init_options(rest)?))
|
||||
}
|
||||
"config" => parse_config_command(rest),
|
||||
"skills" => parse_skills_command(rest),
|
||||
"serve" => {
|
||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
print_serve_help();
|
||||
|
|
@ -96,7 +106,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||
Ok(Command::Help)
|
||||
}
|
||||
other => Err(CliError(format!(
|
||||
"unknown command `{other}`; expected `init`, `config`, or `serve`"
|
||||
"unknown command `{other}`; expected `init`, `config`, `skills`, or `serve`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
|
@ -123,6 +133,39 @@ fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::err
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
SkillsCommand::List(options) => {
|
||||
let catalog = yoi_workspace_server::skills::catalog(&options.workspace);
|
||||
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||
}
|
||||
SkillsCommand::Lint(options) => {
|
||||
let catalog = yoi_workspace_server::skills::lint(&options.workspace);
|
||||
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||
if catalog
|
||||
.diagnostics
|
||||
.iter()
|
||||
.chain(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| entry.diagnostics.iter()),
|
||||
)
|
||||
.any(|diagnostic| {
|
||||
diagnostic.severity == worker::skill::SkillDiagnosticSeverity::Error
|
||||
})
|
||||
{
|
||||
return Err(Box::new(CliError("Skill lint found errors".to_string())));
|
||||
}
|
||||
}
|
||||
SkillsCommand::Show { workspace, name } => {
|
||||
let detail = yoi_workspace_server::skills::detail(&workspace, &name)?;
|
||||
println!("{}", serde_json::to_string_pretty(&detail)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
|
||||
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
|
||||
|
|
@ -187,6 +230,37 @@ fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
print_skills_help();
|
||||
return Ok(Command::Help);
|
||||
};
|
||||
match subcommand.as_str() {
|
||||
"list" => Ok(Command::Skills(SkillsCommand::List(
|
||||
parse_workspace_path_options(rest)?,
|
||||
))),
|
||||
"lint" => Ok(Command::Skills(SkillsCommand::Lint(
|
||||
parse_workspace_path_options(rest)?,
|
||||
))),
|
||||
"show" => {
|
||||
let Some((name, rest)) = rest.split_first() else {
|
||||
return Err(CliError("skills show requires a Skill name".to_string()));
|
||||
};
|
||||
Ok(Command::Skills(SkillsCommand::Show {
|
||||
workspace: parse_workspace_path_options(rest)?.workspace,
|
||||
name: name.to_string(),
|
||||
}))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_skills_help();
|
||||
Ok(Command::Help)
|
||||
}
|
||||
other => Err(CliError(format!(
|
||||
"unknown skills subcommand `{other}`; expected `list`, `lint`, or `show`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
||||
let mut workspace = std::env::current_dir()
|
||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
||||
|
|
@ -333,7 +407,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server skills <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +423,12 @@ fn print_config_help() {
|
|||
);
|
||||
}
|
||||
|
||||
fn print_skills_help() {
|
||||
println!(
|
||||
"yoi-workspace-server skills\n\nUsage:\n yoi-workspace-server skills list [OPTIONS]\n yoi-workspace-server skills lint [OPTIONS]\n yoi-workspace-server skills show <NAME> [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_serve_help() {
|
||||
println!(
|
||||
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ use crate::repositories::{
|
|||
RepositoryRegistryReader, RepositorySummary,
|
||||
};
|
||||
use crate::resource_broker::BackendResourceBroker;
|
||||
use crate::skills;
|
||||
use crate::store::{
|
||||
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
||||
WorkspaceRecord,
|
||||
|
|
@ -330,6 +331,13 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||
post(scoped_ticket_backend_operation),
|
||||
)
|
||||
.route("/api/tickets/{id}", get(get_ticket))
|
||||
.route("/api/w/{workspace_id}/skills", get(scoped_list_skills))
|
||||
.route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills))
|
||||
.route("/api/w/{workspace_id}/skills/{name}", get(scoped_get_skill))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/skills/{name}/activate",
|
||||
get(scoped_activate_skill),
|
||||
)
|
||||
.route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
|
||||
.route("/api/objectives", get(list_objectives))
|
||||
.route(
|
||||
|
|
@ -995,6 +1003,12 @@ struct ScopedRecordPath {
|
|||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedSkillPath {
|
||||
workspace_id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedRepositoryPath {
|
||||
workspace_id: String,
|
||||
|
|
@ -1261,6 +1275,60 @@ async fn scoped_ticket_backend_operation(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn scoped_list_skills(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(skills::catalog(&api.config.workspace_root)))
|
||||
}
|
||||
|
||||
async fn scoped_lint_skills(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(skills::lint(&api.config.workspace_root)))
|
||||
}
|
||||
|
||||
async fn scoped_get_skill(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
skills::detail(&api.config.workspace_root, &path.name)
|
||||
.map(Json)
|
||||
.map_err(skill_api_error)
|
||||
}
|
||||
|
||||
async fn scoped_activate_skill(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
skills::activation(&api.config.workspace_root, &path.name)
|
||||
.map(Json)
|
||||
.map_err(skill_api_error)
|
||||
}
|
||||
|
||||
fn skill_api_error(error: skills::SkillError) -> ApiError {
|
||||
match error {
|
||||
skills::SkillError::NotFound(name) => ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace".to_string(),
|
||||
code: "skill_not_found".to_string(),
|
||||
message: format!("unknown Skill `{name}`"),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
code: "skill_not_found".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message: format!("unknown Skill `{name}`"),
|
||||
}],
|
||||
),
|
||||
skills::SkillError::Io(error) => ApiError::from(Error::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn scoped_list_objectives(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
|
|
@ -5096,7 +5164,9 @@ impl IntoResponse for ApiError {
|
|||
| Error::UnknownWorker { .. }
|
||||
| Error::UnknownRepository(_)
|
||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||
StatusCode::NOT_FOUND
|
||||
|
|
@ -5846,6 +5916,51 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_endpoints_use_workspace_backend_catalog_and_progressive_detail() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join(".yoi/skills/triage-errors");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: triage-errors\ndescription: Use when triaging backend errors and choosing safe diagnostics.\n---\n\n# Triage Errors\n\nInspect logs before changing code.",
|
||||
)
|
||||
.unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
|
||||
let Json(catalog) = scoped_list_skills(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("skill catalog failed: {}", error.error));
|
||||
let entry = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == "triage-errors")
|
||||
.expect("workspace Skill catalog entry");
|
||||
assert_eq!(entry.provenance.id, "workspace:triage-errors");
|
||||
assert!(
|
||||
!serde_json::to_string(&catalog)
|
||||
.unwrap()
|
||||
.contains("# Triage Errors")
|
||||
);
|
||||
|
||||
let Json(detail) = scoped_get_skill(
|
||||
State(api),
|
||||
AxumPath(ScopedSkillPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
name: "triage-errors".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("skill detail failed: {}", error.error));
|
||||
assert!(detail.body.contains("# Triage Errors"));
|
||||
assert_eq!(detail.provenance.id, "workspace:triage-errors");
|
||||
}
|
||||
|
||||
async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
WorkspaceApi::new_with_execution_backend(
|
||||
|
|
|
|||
646
crates/workspace-server/src/skills.rs
Normal file
646
crates/workspace-server/src/skills.rs
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use worker::skill::{
|
||||
SkillActivationResponse, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
|
||||
SkillDiagnostic, SkillDiagnosticSeverity, SkillProvenance, SkillResourceRef, SkillSourceKind,
|
||||
};
|
||||
|
||||
const BUILTIN_AGENT_SKILLS: &str = include_str!("../../../resources/skills/agent-skills/SKILL.md");
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SkillError {
|
||||
#[error("unknown Skill `{0}`")]
|
||||
NotFound(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SkillSource {
|
||||
source_kind: SkillSourceKind,
|
||||
parent_name: String,
|
||||
content: String,
|
||||
resource_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ParsedSkill {
|
||||
name: String,
|
||||
description: String,
|
||||
content: String,
|
||||
allowed_tools: Vec<String>,
|
||||
diagnostics: Vec<SkillDiagnostic>,
|
||||
provenance: SkillProvenance,
|
||||
resource_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
struct SkillFrontmatter {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
license: Option<String>,
|
||||
compatibility: Option<String>,
|
||||
metadata: Option<BTreeMap<String, serde_yaml::Value>>,
|
||||
#[serde(default)]
|
||||
allowed_tools: Option<serde_yaml::Value>,
|
||||
}
|
||||
|
||||
pub fn catalog(workspace_root: &Path) -> SkillCatalogResponse {
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut active = BTreeMap::<String, ParsedSkill>::new();
|
||||
let mut builtin_by_name = BTreeMap::<String, ParsedSkill>::new();
|
||||
|
||||
for source in builtin_skill_sources() {
|
||||
match parse_skill_source(source) {
|
||||
Ok(skill) => {
|
||||
builtin_by_name.insert(skill.name.clone(), skill.clone());
|
||||
active.insert(skill.name.clone(), skill);
|
||||
}
|
||||
Err(errs) => diagnostics.extend(errs),
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_sources = match workspace_skill_sources(workspace_root) {
|
||||
Ok(sources) => sources,
|
||||
Err(error) => {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"workspace_skill_read_failed",
|
||||
format!("failed to read workspace Skills: {error}"),
|
||||
Some("workspace:.yoi/skills".to_string()),
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
for source in workspace_sources {
|
||||
match parse_skill_source(source) {
|
||||
Ok(skill) => {
|
||||
let overrides = builtin_by_name
|
||||
.get(&skill.name)
|
||||
.map(|builtin| vec![builtin.provenance.clone()])
|
||||
.unwrap_or_default();
|
||||
if let Some(overridden) = overrides.first() {
|
||||
diagnostics.push(SkillDiagnostic::warning(
|
||||
"workspace_skill_overrides_builtin",
|
||||
format!(
|
||||
"workspace Skill `{}` overrides builtin Skill `{}`",
|
||||
skill.name, overridden.id
|
||||
),
|
||||
Some(skill.provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
let mut skill = skill;
|
||||
if !overrides.is_empty() {
|
||||
skill.diagnostics.push(SkillDiagnostic::warning(
|
||||
"workspace_skill_overrides_builtin",
|
||||
"workspace Skill has priority over the builtin Skill with the same name",
|
||||
Some(skill.provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
active.insert(skill.name.clone(), skill);
|
||||
}
|
||||
Err(errs) => diagnostics.extend(errs),
|
||||
}
|
||||
}
|
||||
|
||||
let mut entries = active
|
||||
.into_values()
|
||||
.map(|skill| {
|
||||
let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) {
|
||||
builtin_by_name
|
||||
.get(&skill.name)
|
||||
.map(|builtin| vec![builtin.provenance.clone()])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
SkillCatalogEntry {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
provenance: skill.provenance,
|
||||
overrides,
|
||||
diagnostics: skill.diagnostics,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
SkillCatalogResponse {
|
||||
authority: "workspace-backend-skills-v0".to_string(),
|
||||
entries,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lint(workspace_root: &Path) -> SkillCatalogResponse {
|
||||
catalog(workspace_root)
|
||||
}
|
||||
|
||||
pub fn detail(workspace_root: &Path, name: &str) -> Result<SkillDetailResponse, SkillError> {
|
||||
let skill = active_skill(workspace_root, name)?;
|
||||
let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) {
|
||||
builtin_skill_sources()
|
||||
.into_iter()
|
||||
.filter_map(|source| parse_skill_source(source).ok())
|
||||
.find(|builtin| builtin.name == skill.name)
|
||||
.map(|builtin| vec![builtin.provenance])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let resources = resource_refs(&skill);
|
||||
Ok(SkillDetailResponse {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
provenance: skill.provenance,
|
||||
overrides,
|
||||
diagnostics: skill.diagnostics,
|
||||
body: skill.content,
|
||||
allowed_tools: skill.allowed_tools,
|
||||
allowed_tools_status:
|
||||
"experimental_ignored_by_workspace_backend; does not grant or deny tool authority"
|
||||
.to_string(),
|
||||
resources,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn activation(
|
||||
workspace_root: &Path,
|
||||
name: &str,
|
||||
) -> Result<SkillActivationResponse, SkillError> {
|
||||
let detail = detail(workspace_root, name)?;
|
||||
Ok(SkillActivationResponse {
|
||||
name: detail.name,
|
||||
provenance: detail.provenance,
|
||||
diagnostics: detail.diagnostics,
|
||||
body: detail.body,
|
||||
})
|
||||
}
|
||||
|
||||
fn active_skill(workspace_root: &Path, name: &str) -> Result<ParsedSkill, SkillError> {
|
||||
let mut parsed = BTreeMap::<String, ParsedSkill>::new();
|
||||
for source in builtin_skill_sources() {
|
||||
if let Ok(skill) = parse_skill_source(source) {
|
||||
parsed.insert(skill.name.clone(), skill);
|
||||
}
|
||||
}
|
||||
for source in workspace_skill_sources(workspace_root)? {
|
||||
if let Ok(skill) = parse_skill_source(source) {
|
||||
parsed.insert(skill.name.clone(), skill);
|
||||
}
|
||||
}
|
||||
parsed
|
||||
.remove(name)
|
||||
.ok_or_else(|| SkillError::NotFound(name.to_string()))
|
||||
}
|
||||
|
||||
fn builtin_skill_sources() -> Vec<SkillSource> {
|
||||
vec![SkillSource {
|
||||
source_kind: SkillSourceKind::Builtin,
|
||||
parent_name: "agent-skills".to_string(),
|
||||
content: BUILTIN_AGENT_SKILLS.to_string(),
|
||||
resource_root: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn workspace_skill_sources(workspace_root: &Path) -> Result<Vec<SkillSource>, std::io::Error> {
|
||||
let skills_dir = workspace_root.join(".yoi").join("skills");
|
||||
if !skills_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut sources = Vec::new();
|
||||
for entry in fs::read_dir(&skills_dir)? {
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let parent_name = entry.file_name().to_string_lossy().to_string();
|
||||
let skill_path = entry.path().join("SKILL.md");
|
||||
if !skill_path.exists() {
|
||||
sources.push(SkillSource {
|
||||
source_kind: SkillSourceKind::Workspace,
|
||||
parent_name,
|
||||
content: String::new(),
|
||||
resource_root: Some(entry.path()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
match fs::read_to_string(&skill_path) {
|
||||
Ok(content) => sources.push(SkillSource {
|
||||
source_kind: SkillSourceKind::Workspace,
|
||||
parent_name,
|
||||
content,
|
||||
resource_root: Some(entry.path()),
|
||||
}),
|
||||
Err(error) => sources.push(SkillSource {
|
||||
source_kind: SkillSourceKind::Workspace,
|
||||
parent_name,
|
||||
content: format!("__read_error__:{error}"),
|
||||
resource_root: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
fn parse_skill_source(source: SkillSource) -> Result<ParsedSkill, Vec<SkillDiagnostic>> {
|
||||
let provenance = provenance(source.source_kind.clone(), &source.parent_name);
|
||||
let mut diagnostics = Vec::new();
|
||||
|
||||
if !valid_skill_name(&source.parent_name) {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"invalid_skill_directory_name",
|
||||
"Skill directory name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
if source.content.is_empty() {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"missing_skill_markdown",
|
||||
"Skill directory must contain SKILL.md",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
return Err(diagnostics);
|
||||
}
|
||||
if source.content.starts_with("__read_error__:") {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"skill_markdown_read_failed",
|
||||
source
|
||||
.content
|
||||
.trim_start_matches("__read_error__:")
|
||||
.to_string(),
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
return Err(diagnostics);
|
||||
}
|
||||
|
||||
let Some((frontmatter, _markdown)) = split_frontmatter(&source.content) else {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"missing_frontmatter",
|
||||
"SKILL.md must start with YAML frontmatter delimited by ---",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
return Err(diagnostics);
|
||||
};
|
||||
let frontmatter = match serde_yaml::from_str::<SkillFrontmatter>(frontmatter) {
|
||||
Ok(frontmatter) => frontmatter,
|
||||
Err(error) => {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"invalid_frontmatter_yaml",
|
||||
format!("SKILL.md frontmatter is invalid YAML: {error}"),
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
return Err(diagnostics);
|
||||
}
|
||||
};
|
||||
|
||||
let Some(name) = frontmatter.name else {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"missing_name",
|
||||
"Skill frontmatter requires `name`",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
return Err(diagnostics);
|
||||
};
|
||||
if name != source.parent_name {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"name_parent_mismatch",
|
||||
"Skill frontmatter `name` must match its parent directory name",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
if !valid_skill_name(&name) {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"invalid_name",
|
||||
"Skill name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(description) = frontmatter.description else {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"missing_description",
|
||||
"Skill frontmatter requires `description`",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
return Err(diagnostics);
|
||||
};
|
||||
let description = description.trim().to_string();
|
||||
if description.is_empty() || description.chars().count() > 1024 {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"invalid_description",
|
||||
"Skill description must be 1-1024 characters",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
} else if description.chars().count() < 16 {
|
||||
diagnostics.push(SkillDiagnostic::warning(
|
||||
"description_too_generic",
|
||||
"Skill description should state concrete when/what guidance",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(license) = frontmatter.license.as_deref() {
|
||||
validate_optional_string("license", license, &provenance, &mut diagnostics);
|
||||
}
|
||||
if let Some(compatibility) = frontmatter.compatibility.as_deref() {
|
||||
validate_optional_string(
|
||||
"compatibility",
|
||||
compatibility,
|
||||
&provenance,
|
||||
&mut diagnostics,
|
||||
);
|
||||
}
|
||||
if let Some(metadata) = frontmatter.metadata {
|
||||
for (key, value) in metadata {
|
||||
if !matches!(value, serde_yaml::Value::String(_)) {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
"invalid_metadata_value",
|
||||
format!("metadata `{key}` must be a string value"),
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut allowed_tools = Vec::new();
|
||||
if let Some(value) = frontmatter.allowed_tools {
|
||||
allowed_tools = parse_allowed_tools(value, &provenance, &mut diagnostics);
|
||||
diagnostics.push(SkillDiagnostic::warning(
|
||||
"allowed_tools_ignored",
|
||||
"allowed-tools is experimental metadata only; Workspace Skill activation does not grant or deny tools",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
|
||||
if diagnostics
|
||||
.iter()
|
||||
.any(|d| d.severity == SkillDiagnosticSeverity::Error)
|
||||
{
|
||||
return Err(diagnostics);
|
||||
}
|
||||
|
||||
Ok(ParsedSkill {
|
||||
name,
|
||||
description,
|
||||
content: source.content,
|
||||
allowed_tools,
|
||||
diagnostics,
|
||||
provenance,
|
||||
resource_root: source.resource_root,
|
||||
})
|
||||
}
|
||||
|
||||
fn provenance(kind: SkillSourceKind, parent_name: &str) -> SkillProvenance {
|
||||
let prefix = match kind {
|
||||
SkillSourceKind::Builtin => "builtin",
|
||||
SkillSourceKind::Workspace => "workspace",
|
||||
};
|
||||
SkillProvenance {
|
||||
kind,
|
||||
id: format!("{prefix}:{parent_name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
|
||||
let rest = content.strip_prefix("---\n")?;
|
||||
let (frontmatter, body) = rest.split_once("\n---")?;
|
||||
let body = body.strip_prefix('\n').unwrap_or(body);
|
||||
Some((frontmatter, body))
|
||||
}
|
||||
|
||||
fn valid_skill_name(name: &str) -> bool {
|
||||
let len = name.chars().count();
|
||||
if !(1..=64).contains(&len)
|
||||
|| name.starts_with('-')
|
||||
|| name.ends_with('-')
|
||||
|| name.contains("--")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
name.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||||
}
|
||||
|
||||
fn validate_optional_string(
|
||||
field: &str,
|
||||
value: &str,
|
||||
provenance: &SkillProvenance,
|
||||
diagnostics: &mut Vec<SkillDiagnostic>,
|
||||
) {
|
||||
if value.trim().is_empty() {
|
||||
diagnostics.push(SkillDiagnostic::error(
|
||||
format!("invalid_{field}"),
|
||||
format!("optional `{field}` must be a non-empty string when present"),
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_allowed_tools(
|
||||
value: serde_yaml::Value,
|
||||
provenance: &SkillProvenance,
|
||||
diagnostics: &mut Vec<SkillDiagnostic>,
|
||||
) -> Vec<String> {
|
||||
match value {
|
||||
serde_yaml::Value::String(text) => text
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect(),
|
||||
serde_yaml::Value::Sequence(items) => items
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
serde_yaml::Value::String(text) if !text.trim().is_empty() => Some(text),
|
||||
_ => {
|
||||
diagnostics.push(SkillDiagnostic::warning(
|
||||
"invalid_allowed_tools_entry_ignored",
|
||||
"allowed-tools entries must be strings; invalid entries are ignored",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => {
|
||||
diagnostics.push(SkillDiagnostic::warning(
|
||||
"invalid_allowed_tools_ignored",
|
||||
"allowed-tools must be a string or string list; value ignored",
|
||||
Some(provenance.id.clone()),
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_refs(skill: &ParsedSkill) -> Vec<SkillResourceRef> {
|
||||
let Some(root) = &skill.resource_root else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut refs = Vec::new();
|
||||
for (dir, kind, supported, diagnostic) in [
|
||||
(
|
||||
"references",
|
||||
"reference",
|
||||
false,
|
||||
Some(
|
||||
"Skill references are listed only; resource read endpoints are not implemented yet",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assets",
|
||||
"asset",
|
||||
false,
|
||||
Some("Skill assets are listed only; resource read endpoints are not implemented yet"),
|
||||
),
|
||||
(
|
||||
"scripts",
|
||||
"script",
|
||||
false,
|
||||
Some(
|
||||
"Skill scripts are discovered but not executable; use normal typed tools and permissions",
|
||||
),
|
||||
),
|
||||
] {
|
||||
let path = root.join(dir);
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = fs::read_dir(&path) {
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(file_type) = entry.file_type() {
|
||||
if !(file_type.is_file() || file_type.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let name = format!("{dir}/{}", entry.file_name().to_string_lossy());
|
||||
refs.push(SkillResourceRef {
|
||||
kind: kind.to_string(),
|
||||
name,
|
||||
supported,
|
||||
diagnostic: diagnostic.map(ToOwned::to_owned),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
refs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
refs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_skill(root: &Path, name: &str, content: &str) {
|
||||
let dir = root.join(".yoi").join("skills").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_skill_is_cataloged_without_body_and_detail_contains_body() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_skill(
|
||||
tmp.path(),
|
||||
"debug-rust",
|
||||
"---\nname: debug-rust\ndescription: Use when debugging Rust failures and deciding what tests to run.\nallowed-tools:\n - Bash\nmetadata:\n owner: dev\n---\n\n# Debug Rust\n\nRun focused checks.",
|
||||
);
|
||||
|
||||
let catalog = catalog(tmp.path());
|
||||
let entry = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == "debug-rust")
|
||||
.expect("workspace skill listed");
|
||||
assert_eq!(
|
||||
entry.description,
|
||||
"Use when debugging Rust failures and deciding what tests to run."
|
||||
);
|
||||
assert_eq!(entry.provenance.id, "workspace:debug-rust");
|
||||
let catalog_json = serde_json::to_string(&catalog).unwrap();
|
||||
assert!(!catalog_json.contains("# Debug Rust"));
|
||||
|
||||
let detail = detail(tmp.path(), "debug-rust").unwrap();
|
||||
assert!(detail.body.contains("# Debug Rust"));
|
||||
assert_eq!(detail.allowed_tools, vec!["Bash"]);
|
||||
assert!(
|
||||
detail
|
||||
.allowed_tools_status
|
||||
.contains("does not grant or deny")
|
||||
);
|
||||
assert!(
|
||||
detail
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == "allowed_tools_ignored")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_name_and_parent_mismatch_are_lint_errors() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_skill(
|
||||
tmp.path(),
|
||||
"Bad--Name",
|
||||
"---\nname: other\ndescription: Use when checking invalid examples.\n---\n\n# Invalid",
|
||||
);
|
||||
let diagnostics = catalog(tmp.path()).diagnostics;
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == "invalid_skill_directory_name")
|
||||
);
|
||||
assert!(diagnostics.iter().any(|d| d.code == "name_parent_mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_skill_overrides_builtin() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_skill(
|
||||
tmp.path(),
|
||||
"agent-skills",
|
||||
"---\nname: agent-skills\ndescription: Use when testing deterministic workspace override of builtin Skills.\n---\n\n# Workspace Override",
|
||||
);
|
||||
let catalog = catalog(tmp.path());
|
||||
let entry = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == "agent-skills")
|
||||
.unwrap();
|
||||
assert_eq!(entry.provenance.id, "workspace:agent-skills");
|
||||
assert_eq!(entry.overrides[0].id, "builtin:agent-skills");
|
||||
let detail = detail(tmp.path(), "agent-skills").unwrap();
|
||||
assert!(detail.body.contains("Workspace Override"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_are_reported_not_executable_without_raw_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_skill(
|
||||
tmp.path(),
|
||||
"scripted-help",
|
||||
"---\nname: scripted-help\ndescription: Use when checking Skill resource diagnostics safely.\n---\n\n# Scripted",
|
||||
);
|
||||
let script_dir = tmp.path().join(".yoi/skills/scripted-help/scripts");
|
||||
std::fs::create_dir_all(&script_dir).unwrap();
|
||||
std::fs::write(script_dir.join("run.sh"), "echo no").unwrap();
|
||||
let detail = detail(tmp.path(), "scripted-help").unwrap();
|
||||
let script = detail
|
||||
.resources
|
||||
.iter()
|
||||
.find(|r| r.name == "scripts/run.sh")
|
||||
.unwrap();
|
||||
assert!(!script.supported);
|
||||
assert!(
|
||||
!serde_json::to_string(&detail)
|
||||
.unwrap()
|
||||
.contains(tmp.path().to_str().unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@ When searching, use grep/glob primitives rather than shell pipelines.
|
|||
|
||||
You can run multiple tools simultaneously by calling them within a single response.
|
||||
It is recommended to run tools that handle asynchronous processing, such as queries and readings, in batches.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
When an Agent Skill is explicitly activated, follow its committed `SKILL.md` body as LLM-facing procedural guidance only. A Skill does not grant authority to mutate Tickets, repositories, networks, worktrees, queues, schedules, scripts, or other external state; use the normal typed tools, features, and permissions for those actions. Skill catalog metadata is lightweight, and full Skill bodies should enter context only through explicit activation/read.
|
||||
{% if tool_capabilities.memory_any %}
|
||||
|
||||
### Memory
|
||||
|
|
|
|||
16
resources/skills/agent-skills/SKILL.md
Normal file
16
resources/skills/agent-skills/SKILL.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
name: agent-skills
|
||||
description: Use when a user or host explicitly activates an Agent Skill so the model follows the selected procedural guidance without treating it as external authority.
|
||||
license: MIT
|
||||
compatibility: yoi-agent-skills-v0
|
||||
metadata:
|
||||
origin: builtin
|
||||
---
|
||||
|
||||
# Agent Skills
|
||||
|
||||
When this Skill is activated, treat the loaded `SKILL.md` as procedural guidance for the current conversation. A Skill can explain how to approach a task, which references to inspect through normal tools, or what style to use.
|
||||
|
||||
A Skill is not authority to mutate Tickets, repositories, networks, worktrees, processes, queues, schedules, or other external state. Use the normal typed tools and feature permissions for any external action.
|
||||
|
||||
`allowed-tools` is experimental metadata only in this implementation. It does not grant or deny tools unless the host feature/permission layer implements that separately.
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import { workspaceApiPath, workspaceRoute } from "./http.ts";
|
||||
import {
|
||||
loadWorkspaceSkillCatalog,
|
||||
workspaceApiPath,
|
||||
workspaceRoute,
|
||||
workspaceSkillActivationPath,
|
||||
workspaceSkillCatalogPath,
|
||||
workspaceSkillDetailPath,
|
||||
} from "./http.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
|
|
@ -21,7 +28,10 @@ function assert(condition: unknown, message: string): asserts condition {
|
|||
|
||||
Deno.test("workspace route helpers scope browser routes and API by immutable workspace id", () => {
|
||||
assertEquals(workspaceRoute("workspace 1"), "/w/workspace%201");
|
||||
assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives");
|
||||
assertEquals(
|
||||
workspaceRoute("workspace 1", "/objectives"),
|
||||
"/w/workspace%201/objectives",
|
||||
);
|
||||
assertEquals(
|
||||
workspaceApiPath("workspace 1", "/repositories/repo-a"),
|
||||
"/api/w/workspace%201/repositories/repo-a",
|
||||
|
|
@ -29,7 +39,9 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
|||
});
|
||||
|
||||
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
|
||||
const layout = await Deno.readTextFile(new URL("./../../../routes/+layout.ts", import.meta.url));
|
||||
const layout = await Deno.readTextFile(
|
||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
||||
|
|
@ -39,7 +51,8 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
|
|||
"unscoped layout should redirect to the scoped workspace route",
|
||||
);
|
||||
assert(
|
||||
!layout.includes('`/api${path}`') && !layout.includes('"/api/repositories"'),
|
||||
!layout.includes("`/api${path}`") &&
|
||||
!layout.includes('"/api/repositories"'),
|
||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
||||
);
|
||||
|
||||
|
|
@ -53,3 +66,44 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
|
|||
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
|
||||
assertEquals(workspaceSkillCatalogPath("ws 1"), "/api/w/ws%201/skills");
|
||||
assertEquals(
|
||||
workspaceSkillDetailPath("ws 1", "triage-errors"),
|
||||
"/api/w/ws%201/skills/triage-errors",
|
||||
);
|
||||
assertEquals(
|
||||
workspaceSkillActivationPath("ws 1", "triage-errors"),
|
||||
"/api/w/ws%201/skills/triage-errors/activate",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
|
||||
const result = await loadWorkspaceSkillCatalog(
|
||||
((input: RequestInfo | URL) => {
|
||||
assertEquals(String(input), "/api/w/ws-1/skills");
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
authority: "workspace-backend-skills-v0",
|
||||
entries: [{
|
||||
name: "triage-errors",
|
||||
description: "Use when triaging errors.",
|
||||
provenance: { kind: "workspace", id: "workspace:triage-errors" },
|
||||
overrides: [],
|
||||
diagnostics: [],
|
||||
}],
|
||||
diagnostics: [],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch,
|
||||
"ws-1",
|
||||
);
|
||||
|
||||
assertEquals(result.error, null);
|
||||
assertEquals(result.data?.entries[0].name, "triage-errors");
|
||||
assertEquals(JSON.stringify(result.data).includes("SKILL.md body"), false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,19 +3,108 @@ export type ApiResult<T> = {
|
|||
error: string | null;
|
||||
};
|
||||
|
||||
export type SkillDiagnosticSeverity = "error" | "warning";
|
||||
|
||||
export type SkillDiagnostic = {
|
||||
severity: SkillDiagnosticSeverity;
|
||||
code: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type SkillProvenance = {
|
||||
kind: "builtin" | "workspace";
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type SkillCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
provenance: SkillProvenance;
|
||||
overrides: SkillProvenance[];
|
||||
diagnostics: SkillDiagnostic[];
|
||||
};
|
||||
|
||||
export type SkillCatalogResponse = {
|
||||
authority: string;
|
||||
entries: SkillCatalogEntry[];
|
||||
diagnostics: SkillDiagnostic[];
|
||||
};
|
||||
|
||||
export type SkillResourceRef = {
|
||||
kind: string;
|
||||
name: string;
|
||||
supported: boolean;
|
||||
diagnostic?: string;
|
||||
};
|
||||
|
||||
export type SkillDetailResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
provenance: SkillProvenance;
|
||||
overrides: SkillProvenance[];
|
||||
diagnostics: SkillDiagnostic[];
|
||||
body: string;
|
||||
allowed_tools: string[];
|
||||
allowed_tools_status: string;
|
||||
resources: SkillResourceRef[];
|
||||
};
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path || path === '/') return '';
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
if (!path || path === "/") return "";
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
export function workspaceRoute(workspaceId: string, path = ''): string {
|
||||
export function workspaceRoute(workspaceId: string, path = ""): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
|
||||
}
|
||||
|
||||
export function workspaceApiPath(workspaceId: string, path = ''): string {
|
||||
export function workspaceApiPath(workspaceId: string, path = ""): string {
|
||||
return `/api/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
|
||||
}
|
||||
|
||||
export function workspaceSkillCatalogPath(workspaceId: string): string {
|
||||
return workspaceApiPath(workspaceId, "/skills");
|
||||
}
|
||||
|
||||
export function workspaceSkillDetailPath(
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
): string {
|
||||
return workspaceApiPath(workspaceId, `/skills/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export function workspaceSkillActivationPath(
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
): string {
|
||||
return workspaceApiPath(
|
||||
workspaceId,
|
||||
`/skills/${encodeURIComponent(name)}/activate`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceSkillCatalog(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
): Promise<ApiResult<SkillCatalogResponse>> {
|
||||
return loadJson<SkillCatalogResponse>(
|
||||
fetchFn,
|
||||
workspaceSkillCatalogPath(workspaceId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceSkillDetail(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
): Promise<ApiResult<SkillDetailResponse>> {
|
||||
return loadJson<SkillDetailResponse>(
|
||||
fetchFn,
|
||||
workspaceSkillDetailPath(workspaceId, name),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadJson<T>(
|
||||
fetchFn: typeof fetch,
|
||||
path: string,
|
||||
|
|
@ -50,7 +139,10 @@ export async function workspaceApiJson<T>(path: string): Promise<T> {
|
|||
return requireJson<T>(await fetch(path), path);
|
||||
}
|
||||
|
||||
export async function workspaceApiJsonWithBody<T>(path: string, init: RequestInit): Promise<T> {
|
||||
export async function workspaceApiJsonWithBody<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
return requireJson<T>(
|
||||
await fetch(path, {
|
||||
headers: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user