merge: agent skills support

This commit is contained in:
Keisuke Hirata 2026-07-16 09:11:07 +09:00
commit 1611e04d17
No known key found for this signature in database
13 changed files with 1534 additions and 12 deletions

View File

@ -139,6 +139,10 @@ pub enum SystemItem {
/// byte-identical to what was sent. /// byte-identical to what was sent.
FileAttachment { path: String, body: String }, 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 /// Historical persisted Knowledge reference resolution. Knowledge is no
/// longer active, so restored sessions ignore this item instead of replaying /// longer active, so restored sessions ignore this item instead of replaying
/// archived record text into model context. /// archived record text into model context.
@ -174,6 +178,7 @@ impl SystemItem {
SystemItem::Notification { body, .. } => body.clone(), SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(), SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(), SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::SkillActivation { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(), SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
SystemItem::LegacyIgnored { slug } => { SystemItem::LegacyIgnored { slug } => {
format!("Ignored legacy procedure item: /{slug}") format!("Ignored legacy procedure item: /{slug}")
@ -196,6 +201,7 @@ impl SystemItem {
SystemItem::Notification { .. } => "notification", SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event", SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FileAttachment { .. } => "file_attachment", SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::SkillActivation { .. } => "skill_activation",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored", SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder", SystemItem::TaskReminder { .. } => "task_reminder",

View File

@ -2163,6 +2163,7 @@ impl App {
self.blocks.push(Block::WorkerEvent { event }); self.blocks.push(Block::WorkerEvent { event });
} }
session_store::SystemItem::FileAttachment { body, .. } session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::TaskReminder { body, .. } | session_store::SystemItem::TaskReminder { body, .. }
| session_store::SystemItem::Interrupt { body } => { | session_store::SystemItem::Interrupt { body } => {
self.task_store.apply_system_message_text(&body); self.task_store.apply_system_message_text(&body);

View File

@ -13,6 +13,7 @@ pub mod runtime;
pub mod segment_log_sink; pub mod segment_log_sink;
pub mod shared_state; pub mod shared_state;
mod shutdown_after_idle; mod shutdown_after_idle;
pub mod skill;
pub mod spawn; pub mod spawn;
mod interrupt_prep; mod interrupt_prep;

238
crates/worker/src/skill.rs Normal file
View 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();
}
}

View File

@ -49,6 +49,7 @@ use crate::prompt::loader::PromptLoader;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir; use crate::runtime::dir;
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError}; use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
use crate::skill::{SkillActivationResponse, SkillClientError};
#[cfg(test)] #[cfg(test)]
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{ use protocol::{
@ -896,6 +897,31 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.workspace_context.client() 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 pub(crate) fn worker_metadata_store(&self) -> St
where where
St: Clone, St: Clone,
@ -4789,6 +4815,9 @@ pub enum WorkerError {
#[error(transparent)] #[error(transparent)]
PromptCatalog(#[from] CatalogError), PromptCatalog(#[from] CatalogError),
#[error(transparent)]
Skill(#[from] SkillClientError),
#[error("memory extract staging write failed: {0}")] #[error("memory extract staging write failed: {0}")]
ExtractStaging(#[source] memory::extract::StagingError), ExtractStaging(#[source] memory::extract::StagingError),
@ -5939,6 +5968,102 @@ mod build_summary_prompt_tests {
assert!(!prompt.contains("resident summary marker")); assert!(!prompt.contains("resident summary marker"));
} }
#[test]
fn activate_skill_commits_and_appends_history_before_future_context_use() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::thread;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = 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-skill/skills/triage-errors/activate 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!({
"name": "triage-errors",
"provenance": { "kind": "workspace", "id": "workspace:triage-errors" },
"diagnostics": [],
"body": "---\nname: triage-errors\ndescription: Use when testing activation history.\n---\n\n# Triage Errors\n\nCommitted Skill body."
})
.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 dir = tempfile::tempdir().unwrap();
let manifest = minimal_manifest();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
let cwd = dir.path().join("workspace");
std::fs::create_dir_all(&cwd).unwrap();
let scope = Scope::writable(&cwd).unwrap();
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
let mut worker = tokio::runtime::Runtime::new()
.unwrap()
.block_on(Worker::new(
manifest,
Engine::new(NoopClient),
store,
WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("ws-skill").unwrap()),
WorkspaceClient::http("ws-skill", format!("http://{addr}")),
),
authority,
scope,
))
.unwrap();
let activation = worker.activate_skill("triage-errors").unwrap();
assert_eq!(activation.name, "triage-errors");
server.join().unwrap();
let history = worker.history();
assert_eq!(history.len(), 1);
let history_text = history[0].as_text().unwrap();
assert!(
history_text
.contains("Agent Skill `triage-errors` activated from workspace:triage-errors")
);
assert!(history_text.contains("# Triage Errors"));
assert!(history_text.contains("Committed Skill body."));
let entries = worker
.store
.read_all(
worker.segment_state.session_id(),
worker.segment_state.segment_id(),
)
.unwrap();
assert!(entries.iter().any(|entry| {
matches!(
entry,
LogEntry::SystemItem {
item: SystemItem::SkillActivation { name, body },
..
} if name == "triage-errors"
&& body.contains("# Triage Errors")
&& body == history_text
)
}));
}
fn minimal_manifest() -> WorkerManifest { fn minimal_manifest() -> WorkerManifest {
let toml_str = r#" let toml_str = r#"
[worker] [worker]

View File

@ -14,6 +14,7 @@ pub mod records;
pub mod repositories; pub mod repositories;
pub mod resource_broker; pub mod resource_broker;
pub mod server; pub mod server;
pub mod skills;
pub mod store; pub mod store;
pub use config::{ pub use config::{

View File

@ -15,6 +15,7 @@ enum Command {
Init(InitOptions), Init(InitOptions),
ConfigDefault, ConfigDefault,
ConfigDiff(WorkspacePathOptions), ConfigDiff(WorkspacePathOptions),
Skills(SkillsCommand),
Help, Help,
} }
@ -36,6 +37,13 @@ struct WorkspacePathOptions {
workspace: PathBuf, workspace: PathBuf,
} }
#[derive(Debug)]
enum SkillsCommand {
List(WorkspacePathOptions),
Lint(WorkspacePathOptions),
Show { workspace: PathBuf, name: String },
}
#[derive(Debug)] #[derive(Debug)]
struct CliError(String); struct CliError(String);
@ -65,6 +73,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Init(options) => run_init(options), Command::Init(options) => run_init(options),
Command::ConfigDefault => run_config_default(), Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options), Command::ConfigDiff(options) => run_config_diff(options),
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()), Command::Help => Ok(()),
} }
} }
@ -84,6 +93,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Init(parse_init_options(rest)?)) Ok(Command::Init(parse_init_options(rest)?))
} }
"config" => parse_config_command(rest), "config" => parse_config_command(rest),
"skills" => parse_skills_command(rest),
"serve" => { "serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") { if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_serve_help(); print_serve_help();
@ -96,7 +106,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( 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(()) 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>> { async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_required(&options.workspace)?; let identity = WorkspaceIdentity::load_required(&options.workspace)?;
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&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> { fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
let mut workspace = std::env::current_dir() let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?; .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() { fn print_help() {
println!( 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() { fn print_serve_help() {
println!( 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" "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"

View File

@ -56,6 +56,7 @@ use crate::repositories::{
RepositoryRegistryReader, RepositorySummary, RepositoryRegistryReader, RepositorySummary,
}; };
use crate::resource_broker::BackendResourceBroker; use crate::resource_broker::BackendResourceBroker;
use crate::skills;
use crate::store::{ use crate::store::{
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
WorkspaceRecord, WorkspaceRecord,
@ -330,6 +331,13 @@ pub fn build_router(api: WorkspaceApi) -> Router {
post(scoped_ticket_backend_operation), post(scoped_ticket_backend_operation),
) )
.route("/api/tickets/{id}", get(get_ticket)) .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/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
.route("/api/objectives", get(list_objectives)) .route("/api/objectives", get(list_objectives))
.route( .route(
@ -995,6 +1003,12 @@ struct ScopedRecordPath {
id: String, id: String,
} }
#[derive(Debug, Deserialize)]
struct ScopedSkillPath {
workspace_id: String,
name: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ScopedRepositoryPath { struct ScopedRepositoryPath {
workspace_id: String, workspace_id: String,
@ -1261,6 +1275,60 @@ async fn scoped_ticket_backend_operation(
Ok(Json(response)) 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( async fn scoped_list_objectives(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -5096,7 +5164,9 @@ impl IntoResponse for ApiError {
| Error::UnknownWorker { .. } | Error::UnknownWorker { .. }
| Error::UnknownRepository(_) | Error::UnknownRepository(_)
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, | 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::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
StatusCode::NOT_FOUND 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 { async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
WorkspaceApi::new_with_execution_backend( WorkspaceApi::new_with_execution_backend(

View File

@ -0,0 +1,789 @@
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_value = match serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
Ok(value) => value,
Err(error) => {
diagnostics.push(SkillDiagnostic::error(
"invalid_frontmatter_yaml",
format!("SKILL.md frontmatter is invalid YAML: {error}"),
Some(provenance.id.clone()),
));
return Err(diagnostics);
}
};
diagnose_unsupported_frontmatter_keys(&frontmatter_value, &provenance, &mut diagnostics);
let frontmatter = match serde_yaml::from_value::<SkillFrontmatter>(frontmatter_value) {
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 diagnose_unsupported_frontmatter_keys(
value: &serde_yaml::Value,
provenance: &SkillProvenance,
diagnostics: &mut Vec<SkillDiagnostic>,
) {
let Some(mapping) = value.as_mapping() else {
diagnostics.push(SkillDiagnostic::error(
"invalid_frontmatter_shape",
"SKILL.md frontmatter must be a YAML mapping",
Some(provenance.id.clone()),
));
return;
};
for key in mapping.keys() {
let Some(key) = key.as_str() else {
diagnostics.push(SkillDiagnostic::error(
"invalid_frontmatter_key",
"Skill frontmatter keys must be strings",
Some(provenance.id.clone()),
));
continue;
};
if !is_supported_frontmatter_key(key) {
let (code, message) = if is_workflow_projection_key(key) {
(
"unsupported_workflow_frontmatter_field",
format!(
"Skill frontmatter field `{key}` is a removed Workflow projection/invocation field and is not accepted as Skill semantics"
),
)
} else {
(
"unsupported_frontmatter_field",
format!(
"Skill frontmatter field `{key}` is not supported; supported fields are name, description, license, compatibility, metadata, and allowed-tools"
),
)
};
diagnostics.push(SkillDiagnostic::error(
code,
message,
Some(provenance.id.clone()),
));
}
}
}
fn is_supported_frontmatter_key(key: &str) -> bool {
matches!(
key,
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
)
}
fn is_workflow_projection_key(key: &str) -> bool {
matches!(
key,
"model_invokation"
| "model_invocation"
| "user_invocable"
| "workflow"
| "workflow_record"
| "workflow_invoke"
| "invocation"
| "invocations"
| "graph"
| "nodes"
| "edges"
| "triggers"
)
}
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 workflow_projection_frontmatter_fields_are_rejected() {
let tmp = tempfile::tempdir().unwrap();
write_skill(
tmp.path(),
"workflow-shaped",
"---\nname: workflow-shaped\ndescription: Use when proving workflow projection fields are rejected as Skills.\nmodel_invokation: old-typo\nuser_invocable: true\ngraph: {}\ninvocation:\n run: now\n---\n\n# Workflow Shaped",
);
let catalog = catalog(tmp.path());
assert!(
catalog
.entries
.iter()
.all(|entry| entry.name != "workflow-shaped")
);
let codes = catalog
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>();
assert!(codes.contains(&"unsupported_workflow_frontmatter_field"));
for unsupported in ["model_invokation", "user_invocable", "graph", "invocation"] {
assert!(
catalog.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "unsupported_workflow_frontmatter_field"
&& diagnostic.message.contains(unsupported)
}),
"missing unsupported-field diagnostic for {unsupported}"
);
}
assert!(matches!(
detail(tmp.path(), "workflow-shaped"),
Err(SkillError::NotFound(_))
));
}
#[test]
fn unknown_frontmatter_fields_are_rejected() {
let tmp = tempfile::tempdir().unwrap();
write_skill(
tmp.path(),
"unknown-field",
"---\nname: unknown-field\ndescription: Use when proving unsupported Skill fields are rejected.\ncustom-authority: no\n---\n\n# Unknown",
);
let catalog = catalog(tmp.path());
assert!(
catalog
.entries
.iter()
.all(|entry| entry.name != "unknown-field")
);
assert!(catalog.diagnostics.iter().any(|diagnostic| diagnostic.code
== "unsupported_frontmatter_field"
&& diagnostic.message.contains("custom-authority")));
}
#[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())
);
}
}

View File

@ -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. 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. 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 %} {% if tool_capabilities.memory_any %}
### Memory ### Memory

View 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.

View File

@ -1,4 +1,11 @@
import { workspaceApiPath, workspaceRoute } from "./http.ts"; import {
loadWorkspaceSkillCatalog,
workspaceApiPath,
workspaceRoute,
workspaceSkillActivationPath,
workspaceSkillCatalogPath,
workspaceSkillDetailPath,
} from "./http.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void; 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", () => { 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"), "/w/workspace%201");
assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives"); assertEquals(
workspaceRoute("workspace 1", "/objectives"),
"/w/workspace%201/objectives",
);
assertEquals( assertEquals(
workspaceApiPath("workspace 1", "/repositories/repo-a"), workspaceApiPath("workspace 1", "/repositories/repo-a"),
"/api/w/workspace%201/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 () => { 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( assert(
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'), layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
"unscoped layout may use only the workspace-id bootstrap endpoint", "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", "unscoped layout should redirect to the scoped workspace route",
); );
assert( 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", "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", "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);
});

View File

@ -3,19 +3,108 @@ export type ApiResult<T> = {
error: string | null; 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 { function normalizePath(path: string): string {
if (!path || path === '/') return ''; if (!path || path === "/") return "";
return path.startsWith('/') ? path : `/${path}`; 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)}`; 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)}`; 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>( export async function loadJson<T>(
fetchFn: typeof fetch, fetchFn: typeof fetch,
path: string, path: string,
@ -50,7 +139,10 @@ export async function workspaceApiJson<T>(path: string): Promise<T> {
return requireJson<T>(await fetch(path), path); 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>( return requireJson<T>(
await fetch(path, { await fetch(path, {
headers: { headers: {