Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions crates/cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>),
}

impl Default for SkillsField {
fn default() -> Self {
SkillsField::Named(vec![])
}
}

/// YAML agent template (`.agentd/agents/<name>.yml`).
#[derive(Debug, Deserialize)]
pub struct AgentTemplate {
Expand Down Expand Up @@ -87,6 +109,13 @@ pub struct AgentTemplate {
/// Rooms are created (if missing) during `agent apply` before agents start.
#[serde(default)]
pub rooms: Vec<AgentRoomConfig>,
/// 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 {
Expand Down Expand Up @@ -1202,6 +1231,40 @@ async fn apply_agent(
}
});

// Resolve skills: validate named skills against discovered skills; expand "all".
let skills: Vec<String> = 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,
Expand All @@ -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?;
Expand Down
3 changes: 3 additions & 0 deletions crates/cli/src/commands/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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()),
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
2 changes: 2 additions & 0 deletions crates/orchestrator/src/entity/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>` of skill names assigned to this agent.
pub skills: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod manager;
mod message_bridge;
mod migration;
mod scheduler;
mod skills;
mod storage;
mod system_agents;
mod types;
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,7 @@ mod tests {
resource_limits: None,
additional_dirs: vec![],
rooms: vec![],
skills: vec![],
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//! Migration: add `skills` column to the `agents` table.
//!
//! Adds one column:
//! - `skills` (TEXT NOT NULL DEFAULT '[]'): JSON-serialized `Vec<String>`
//! 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(())
}
}
2 changes: 2 additions & 0 deletions crates/orchestrator/src/migration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
]
}
}
5 changes: 5 additions & 0 deletions crates/orchestrator/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down Expand Up @@ -997,6 +1000,7 @@ fn model_to_agent(model: agent_entity::Model) -> Result<Agent> {
.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,
Expand Down Expand Up @@ -1103,6 +1107,7 @@ mod tests {
resource_limits: None,
additional_dirs: vec![],
rooms: vec![],
skills: vec![],
},
)
}
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator/src/system_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ pub fn build_system_agent_config() -> AgentConfig {
resource_limits: None,
additional_dirs: vec![],
rooms: vec!["system".to_string()],
skills: vec![],
}
}

Expand Down
20 changes: 20 additions & 0 deletions crates/orchestrator/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

fn default_shell() -> String {
Expand Down Expand Up @@ -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<String>,
/// 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<String>,
}

/// Response body for agent endpoints.
Expand Down Expand Up @@ -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\""));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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\""));
Expand Down Expand Up @@ -1159,6 +1170,7 @@ mod tests {
resource_limits: None,
additional_dirs: vec![],
rooms: vec![],
skills: vec![],
};

let json = serde_json::to_string(&config).unwrap();
Expand Down Expand Up @@ -1191,6 +1203,7 @@ mod tests {
resource_limits: None,
additional_dirs: vec![],
rooms: vec![],
skills: vec![],
};

let json = serde_json::to_string(&config).unwrap();
Expand Down Expand Up @@ -1235,6 +1248,7 @@ mod tests {
resource_limits: None,
additional_dirs: vec![],
rooms: vec![],
skills: vec![],
};

let json = serde_json::to_string(&request).unwrap();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator/tests/conversation_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions crates/orchestrator/tests/system_agent_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading