feat(orchestrator): add skills field to agent YAML template and CreateAgentRequest API#1228
feat(orchestrator): add skills field to agent YAML template and CreateAgentRequest API#1228geoffjay wants to merge 2 commits into
Conversation
…eAgentRequest API - Add skills Vec<String> 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<String>) - 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
geoffjay
left a comment
There was a problem hiding this comment.
Review: feat(orchestrator): add skills field to agent YAML template and CreateAgentRequest API
Stack Position
Branch issue-1210 is stacked on epic-1209, not main. The parent branch epic-1209 contains the issue-1209 (Skill model) work plus a fix(skills): address review feedback commit.
Parent status: PR #1226 has been re-submitted for review (review-agent label is set, needs-rework has been removed). I verified the two blocking issues from that review are both fixed on epic-1209:
discover_all_skills()now has a global sort ✓- Per-entry
read_direrrors now uselet Ok(entry) = entry else { continue }✓
This PR is approvable in isolation but cannot merge until #1226 is re-reviewed and cleared. The conductor should treat it as blocked until the parent chain is clean.
SkillsField Enum: Correct
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum SkillsField {
All(String), // matches any string — validated at apply time
Named(Vec<String>), // matches any JSON array
}#[serde(untagged)] is the right choice here — serde tries All(String) first for string values and Named(Vec<String>) for array values, which correctly handles skills: all vs skills: [a, b] vs skills: []. The Default impl returning Named(vec![]) matches the schema's default: []. ✓
Skills Resolution in apply_agent: Correct
All four cases are handled cleanly:
| Input | Outcome |
|---|---|
skills: all |
Expands to all discovered skill names |
skills: "typo" |
Fails with actionable error: use 'all' or a list |
skills: [] / omitted |
Assigns no skills (fast path, no discovery call) |
skills: [a, b] |
Validates each name against discovered skills; fails with missing names and a hint to run agent skill list |
The guard SkillsField::Named(names) if names.is_empty() => vec![] correctly avoids calling discover_all_skills() when no skills are needed. ✓
Migration: Idempotent and Consistent
let stmt = "ALTER TABLE agents ADD COLUMN skills TEXT NOT NULL DEFAULT '[]'";
if let Err(e) = db.execute_unprepared(stmt).await {
if !e.to_string().contains("duplicate column name") {
return Err(e);
}
}Follows the same idempotency pattern as other migrations in this codebase (m20260417_000016 etc.). The SQLite "duplicate column name" string check is the established convention here. ✓
The empty down() with an explanatory comment about SQLite < 3.35.0 is the right approach — consistent with other migrations that can't safely DROP COLUMN on older SQLite.
Storage / Entity Integration: Consistent
skills is stored as JSON text ("[]" default) in both the entity model and the AgentConfig, following the exact same pattern as rooms. Serialization uses unwrap_or_else(|_| "[]".to_string()) and deserialization uses unwrap_or_default() — matching every other JSON-in-text column in storage.rs. ✓
Schema: Correct oneOf
oneOf:
- type: array
items: {type: string}
- type: string
enum: [all]
default: []"all" matches only the string variant. ["a", "b"] and [] match only the array variant. No overlap, no ambiguity. ✓
Non-Blocking: No Tests for SkillsField
There are no unit tests for SkillsField YAML/JSON deserialization or for the apply_agent skills resolution logic. The #[serde(untagged)] behavior (particularly the fallthrough for unexpected strings reaching SkillsField::All and being caught at apply time) would be valuable to cover. These are tracked in #1214 (the capstone test issue for the epic), so the omission is intentional — just noting it for when #1214 is dispatched.
Non-Blocking: discover_all_skills() Called Per-Agent at Apply Time
When skills: all or skills: [named] is used, discover_all_skills() runs once per agent in apply_agent. If an agent YAML defines 20 agents, this means up to 20 filesystem scans. For the typical small .agentd/skills/ directory this is negligible, but it's worth noting as a future optimization candidate (scan once, pass results to all agents).
Summary
The code is correct and well-integrated throughout the call chain (apply.rs → types.rs → api.rs → storage.rs → migration). The parent epic-1209 branch has confirmed fixes for both blocking issues from the #1226 review. Two non-blocking notes: tests deferred to #1214 (expected), and per-agent skill discovery at apply time is a minor inefficiency.
Holding merge-queue pending re-review and approval of #1226. Once that clears, this can follow immediately.
Adds the
skillsfield throughout the agent lifecycle so operators can declare which skills each agent receives.Changes:
AgentConfigandCreateAgentRequestgain askills: Vec<String>field (serde default empty)m20260513_000017addsskills TEXT NOT NULL DEFAULT '[]'columnAgentTemplateinapply.rsgains aSkillsFieldenum supportingskills: all(expand to all discovered skills) orskills: [name1, name2](validated against discovered skills)schemas/agent.ymlupdated withoneOfskills propertymain.rsbinary gainsmod skillssocrate::skills::discover_all_skillsresolves in API handlerCloses #1210