From 778fdd10472658013cfce8ee6c135ba3b13d6ac0 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Wed, 13 May 2026 09:36:34 -0700 Subject: [PATCH 1/2] feat: add skills field to agent YAML template and CreateAgentRequest API From 6cdf671b521ba086fa282ec1504f70a296a07b93 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Wed, 13 May 2026 09:54:52 -0700 Subject: [PATCH 2/2] feat(orchestrator): add skills field to agent YAML template and CreateAgentRequest API - Add skills Vec to AgentConfig and CreateAgentRequest in types.rs - Add skills TEXT column to agents entity and migration m20260513_000017 - Store/retrieve skills in storage.rs (JSON-serialized Vec) - Add SkillsField enum to apply.rs with 'all' expansion and name validation - Add skills to AgentTemplate with serde(default) - Update schemas/agent.yml with oneOf skills property - Wire skills from CreateAgentRequest into AgentConfig in api.rs - Add mod skills to main.rs binary so crate::skills resolves Closes #1210 --- crates/cli/src/commands/apply.rs | 64 +++++++++++++++++++ crates/cli/src/commands/orchestrator.rs | 3 + crates/orchestrator/src/api.rs | 1 + crates/orchestrator/src/entity/agent.rs | 2 + crates/orchestrator/src/main.rs | 1 + crates/orchestrator/src/manager.rs | 1 + .../m20260513_000017_add_skills_to_agents.rs | 36 +++++++++++ crates/orchestrator/src/migration/mod.rs | 2 + crates/orchestrator/src/storage.rs | 5 ++ crates/orchestrator/src/system_agents.rs | 1 + crates/orchestrator/src/types.rs | 20 ++++++ .../tests/conversation_persistence.rs | 1 + .../orchestrator/tests/system_agent_http.rs | 2 + schemas/agent.yml | 30 +++++++++ 14 files changed, 169 insertions(+) create mode 100644 crates/orchestrator/src/migration/m20260513_000017_add_skills_to_agents.rs diff --git a/crates/cli/src/commands/apply.rs b/crates/cli/src/commands/apply.rs index 60ecad4c..d0629c60 100644 --- a/crates/cli/src/commands/apply.rs +++ b/crates/cli/src/commands/apply.rs @@ -36,6 +36,28 @@ use uuid::Uuid; // ── YAML template types ────────────────────────────────────────────── +/// The `skills` field in an agent YAML template. +/// +/// Supports two forms: +/// - `skills: all` — include every skill discovered from `.agentd/skills/` +/// - `skills: [name1, name2]` — include specific skills by name +/// +/// Omitting the field or setting `skills: []` assigns no skills (current behavior). +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum SkillsField { + /// A string value — must be `"all"` to include all discovered skills. + All(String), + /// An explicit list of skill names to assign. + Named(Vec), +} + +impl Default for SkillsField { + fn default() -> Self { + SkillsField::Named(vec![]) + } +} + /// YAML agent template (`.agentd/agents/.yml`). #[derive(Debug, Deserialize)] pub struct AgentTemplate { @@ -87,6 +109,13 @@ pub struct AgentTemplate { /// Rooms are created (if missing) during `agent apply` before agents start. #[serde(default)] pub rooms: Vec, + /// Skills to make available to this agent. + /// + /// Use `skills: all` to include every discovered skill, or + /// `skills: [name1, name2]` for a specific set. Skill names are validated + /// against `.agentd/skills/` at apply time. + #[serde(default)] + pub skills: SkillsField, } fn default_working_dir() -> String { @@ -1202,6 +1231,40 @@ async fn apply_agent( } }); + // Resolve skills: validate named skills against discovered skills; expand "all". + let skills: Vec = match &tmpl.skills { + SkillsField::All(s) if s == "all" => { + // Expand to all discovered skill names from .agentd/skills/ (and user-level dirs). + orchestrator::skills::discover_all_skills().into_iter().map(|sk| sk.name).collect() + } + SkillsField::All(s) => { + bail!( + "Agent '{}': invalid skills value '{}' — use 'all' to include all skills, or a list: [name1, name2]", + tmpl.name, + s + ); + } + SkillsField::Named(names) if names.is_empty() => vec![], + SkillsField::Named(names) => { + let available = orchestrator::skills::discover_all_skills(); + let available_names: std::collections::HashSet<&str> = + available.iter().map(|sk| sk.name.as_str()).collect(); + let missing: Vec<&str> = names + .iter() + .filter(|n| !available_names.contains(n.as_str())) + .map(String::as_str) + .collect(); + if !missing.is_empty() { + bail!( + "Agent '{}' references unknown skill(s): {}. Run 'agent skill list' to see available skills.", + tmpl.name, + missing.join(", ") + ); + } + names.clone() + } + }; + let request = CreateAgentRequest { name: tmpl.name.clone(), working_dir, @@ -1227,6 +1290,7 @@ async fn apply_agent( resource_limits: tmpl.resource_limits.clone(), additional_dirs, rooms: tmpl.rooms.iter().map(|r| r.name().to_string()).collect(), + skills, }; let agent = client.create_agent(&request).await?; diff --git a/crates/cli/src/commands/orchestrator.rs b/crates/cli/src/commands/orchestrator.rs index 7e18eef9..1895c7ae 100644 --- a/crates/cli/src/commands/orchestrator.rs +++ b/crates/cli/src/commands/orchestrator.rs @@ -1249,6 +1249,7 @@ async fn create_agent( resource_limits, additional_dirs: add_dirs.to_vec(), rooms: vec![], + skills: vec![], }; let agent = client.create_agent(&request).await.context("Failed to create agent")?; @@ -3069,6 +3070,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }, session_id: Some("agentd-orch-abc123".to_string()), backend_type: Some("tmux".to_string()), @@ -3930,6 +3932,7 @@ mod tests { }), additional_dirs: vec![], rooms: vec![], + skills: vec![], }, session_id: Some("abc123container".to_string()), backend_type: Some("docker".to_string()), diff --git a/crates/orchestrator/src/api.rs b/crates/orchestrator/src/api.rs index 25a7f4b8..df73636d 100644 --- a/crates/orchestrator/src/api.rs +++ b/crates/orchestrator/src/api.rs @@ -252,6 +252,7 @@ async fn create_agent( resource_limits: req.resource_limits, additional_dirs: req.additional_dirs, rooms: req.rooms, + skills: req.skills, }; let agent = state.manager.spawn_agent(req.name, config, false).await?; diff --git a/crates/orchestrator/src/entity/agent.rs b/crates/orchestrator/src/entity/agent.rs index 0b85aa57..0eecb9bc 100644 --- a/crates/orchestrator/src/entity/agent.rs +++ b/crates/orchestrator/src/entity/agent.rs @@ -68,6 +68,8 @@ pub struct Model { /// System agents are created programmatically and managed by the orchestrator; /// user-created agents always have this set to 0. pub built_in: i32, + /// JSON-serialized `Vec` of skill names assigned to this agent. + pub skills: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/orchestrator/src/main.rs b/crates/orchestrator/src/main.rs index 177e9bd5..9390bc90 100644 --- a/crates/orchestrator/src/main.rs +++ b/crates/orchestrator/src/main.rs @@ -6,6 +6,7 @@ mod manager; mod message_bridge; mod migration; mod scheduler; +mod skills; mod storage; mod system_agents; mod types; diff --git a/crates/orchestrator/src/manager.rs b/crates/orchestrator/src/manager.rs index c8a3b566..1b95ec93 100644 --- a/crates/orchestrator/src/manager.rs +++ b/crates/orchestrator/src/manager.rs @@ -1174,6 +1174,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], } } diff --git a/crates/orchestrator/src/migration/m20260513_000017_add_skills_to_agents.rs b/crates/orchestrator/src/migration/m20260513_000017_add_skills_to_agents.rs new file mode 100644 index 00000000..d677292f --- /dev/null +++ b/crates/orchestrator/src/migration/m20260513_000017_add_skills_to_agents.rs @@ -0,0 +1,36 @@ +//! Migration: add `skills` column to the `agents` table. +//! +//! Adds one column: +//! - `skills` (TEXT NOT NULL DEFAULT '[]'): JSON-serialized `Vec` +//! of skill names assigned to this agent. +//! +//! Existing rows default to an empty JSON array (`[]`), preserving +//! backwards compatibility. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + let stmt = "ALTER TABLE agents ADD COLUMN skills TEXT NOT NULL DEFAULT '[]'"; + if let Err(e) = db.execute_unprepared(stmt).await { + // Idempotent: ignore if column already exists (e.g., re-run). + if !e.to_string().contains("duplicate column name") { + return Err(e); + } + } + + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // SQLite < 3.35.0 does not support DROP COLUMN, so we leave + // the column in place on rollback for simplicity. + Ok(()) + } +} diff --git a/crates/orchestrator/src/migration/mod.rs b/crates/orchestrator/src/migration/mod.rs index e48179cf..cf88d826 100644 --- a/crates/orchestrator/src/migration/mod.rs +++ b/crates/orchestrator/src/migration/mod.rs @@ -27,6 +27,7 @@ mod m20260415_000013_add_builtin_to_agents; mod m20260417_000014_create_projects_table; mod m20260417_000015_add_project_id_to_agents_workflows; mod m20260417_000016_add_conversation_events; +mod m20260513_000017_add_skills_to_agents; /// The migration runner — applies all known migrations in order. pub struct Migrator; @@ -51,6 +52,7 @@ impl MigratorTrait for Migrator { Box::new(m20260417_000014_create_projects_table::Migration), Box::new(m20260417_000015_add_project_id_to_agents_workflows::Migration), Box::new(m20260417_000016_add_conversation_events::Migration), + Box::new(m20260513_000017_add_skills_to_agents::Migration), ] } } diff --git a/crates/orchestrator/src/storage.rs b/crates/orchestrator/src/storage.rs index 8c767819..04f7aa3d 100644 --- a/crates/orchestrator/src/storage.rs +++ b/crates/orchestrator/src/storage.rs @@ -107,6 +107,9 @@ impl AgentStorage { rooms: Set( serde_json::to_string(&agent.config.rooms).unwrap_or_else(|_| "[]".to_string()) ), + skills: Set( + serde_json::to_string(&agent.config.skills).unwrap_or_else(|_| "[]".to_string()) + ), launch_command: Set(agent.launch_command.clone()), pid: Set(agent.pid.map(|p| p as i64)), project_id: Set(agent.project_id.map(|id| id.to_string())), @@ -997,6 +1000,7 @@ fn model_to_agent(model: agent_entity::Model) -> Result { .and_then(|s| serde_json::from_str(s).ok()), additional_dirs: serde_json::from_str(&model.additional_dirs).unwrap_or_default(), rooms: serde_json::from_str(&model.rooms).unwrap_or_default(), + skills: serde_json::from_str(&model.skills).unwrap_or_default(), }, session_id: model.session_id, backend_type: model.backend_type, @@ -1103,6 +1107,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }, ) } diff --git a/crates/orchestrator/src/system_agents.rs b/crates/orchestrator/src/system_agents.rs index bbfedbde..68f3b31b 100644 --- a/crates/orchestrator/src/system_agents.rs +++ b/crates/orchestrator/src/system_agents.rs @@ -81,6 +81,7 @@ pub fn build_system_agent_config() -> AgentConfig { resource_limits: None, additional_dirs: vec![], rooms: vec!["system".to_string()], + skills: vec![], } } diff --git a/crates/orchestrator/src/types.rs b/crates/orchestrator/src/types.rs index e34c1476..a727308e 100644 --- a/crates/orchestrator/src/types.rs +++ b/crates/orchestrator/src/types.rs @@ -467,6 +467,10 @@ pub struct AgentConfig { /// Each entry is a room name — rooms will be created if they don't exist. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rooms: Vec, + /// Skills to make available to this agent. + /// Names are resolved against discovered skills from .agentd/skills/. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, } fn default_shell() -> String { @@ -657,6 +661,10 @@ pub struct CreateAgentRequest { /// Rooms the agent should automatically join when it connects. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rooms: Vec, + /// Skills to make available to this agent. + /// Names are resolved against discovered skills from .agentd/skills/. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, } /// Response body for agent endpoints. @@ -1067,6 +1075,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("\"model\":\"opus\"")); @@ -1097,6 +1106,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); assert!(!json.contains("model")); @@ -1125,6 +1135,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&request).unwrap(); assert!(json.contains("\"model\":\"sonnet\"")); @@ -1159,6 +1170,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); @@ -1191,6 +1203,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); @@ -1235,6 +1248,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&request).unwrap(); @@ -1270,6 +1284,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let agent = Agent::new("test".to_string(), config); let response = AgentResponse::from(agent); @@ -1389,6 +1404,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); assert!(!json.contains("docker_image")); @@ -1425,6 +1441,7 @@ mod tests { }), additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("custom-image:v1")); @@ -1471,6 +1488,7 @@ mod tests { resource_limits: None, additional_dirs: vec!["/opt/configs".to_string(), "/shared/libs".to_string()], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("additional_dirs")); @@ -1503,6 +1521,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let json = serde_json::to_string(&config).unwrap(); // Empty vec should be omitted from JSON output @@ -1863,6 +1882,7 @@ mod tests { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }; let agent = Agent::new("test".to_string(), config); let response = AgentResponse::from(agent); diff --git a/crates/orchestrator/tests/conversation_persistence.rs b/crates/orchestrator/tests/conversation_persistence.rs index 8ecd5afa..79ec56cb 100644 --- a/crates/orchestrator/tests/conversation_persistence.rs +++ b/crates/orchestrator/tests/conversation_persistence.rs @@ -146,6 +146,7 @@ async fn create_agent(storage: &AgentStorage) -> Uuid { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }, ); storage.add(&agent).await.unwrap(); diff --git a/crates/orchestrator/tests/system_agent_http.rs b/crates/orchestrator/tests/system_agent_http.rs index 7676dd74..a0e7d249 100644 --- a/crates/orchestrator/tests/system_agent_http.rs +++ b/crates/orchestrator/tests/system_agent_http.rs @@ -150,6 +150,7 @@ async fn insert_builtin_agent(storage: &AgentStorage, name: &str) -> Agent { resource_limits: None, additional_dirs: vec![], rooms: vec!["system".to_string()], + skills: vec![], }, ); agent.built_in = true; @@ -182,6 +183,7 @@ async fn insert_user_agent(storage: &AgentStorage, name: &str) -> Agent { resource_limits: None, additional_dirs: vec![], rooms: vec![], + skills: vec![], }, ); storage.add(&agent).await.unwrap(); diff --git a/schemas/agent.yml b/schemas/agent.yml index 88f5a7d0..09b0a6dd 100644 --- a/schemas/agent.yml +++ b/schemas/agent.yml @@ -27,6 +27,7 @@ propertyOrder: - auto_clear_threshold - additional_dirs - rooms + - skills - network_policy - docker_image - extra_mounts @@ -203,6 +204,35 @@ properties: - name: operations role: observer + skills: + description: | + Skills to make available to this agent. Each entry is a skill name + that must exist in .agentd/skills/. Use "all" to include every + discovered skill, or provide a list of specific skill names. + + Skills are written to .claude/skills/ in the agent's working directory + before the agent process is launched. The agent's own local skills + take precedence over materialized skills. + oneOf: + - type: array + items: + type: string + description: "Explicit list of skill names." + - type: string + enum: [all] + description: "Include all skills discovered from .agentd/skills/." + default: [] + examples: + - + - "Specific skills" + - | + skills: + - git-spice + - agent-memory + - + - "All discovered skills" + - "skills: all" + required: - name