1064 lines
35 KiB
Rust
1064 lines
35 KiB
Rust
//! Skill use cases (ARCHITECTURE §14.2; L12).
|
|
//!
|
|
//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`],
|
|
//! [`ListSkills`].
|
|
//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate
|
|
//! the project manifest entry's `skills` and announce
|
|
//! [`DomainEvent::SkillAssigned`]. Both are idempotent.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore, StoreError};
|
|
use domain::{
|
|
Agent, AgentId, AgentManifest, AssignedSkillSnapshot, ConversationParty, DomainEvent,
|
|
MarkdownDoc, OrchestrationCapabilitySnapshot, Project, ProjectPath, Skill, SkillId, SkillKind,
|
|
SkillRef, SkillScope,
|
|
};
|
|
|
|
use crate::error::AppError;
|
|
use crate::ProjectPluginResolver;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateSkill
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`CreateSkill::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateSkillInput {
|
|
/// Display name (also the `.md` stem on disk).
|
|
pub name: String,
|
|
/// Optional one-line affordance description (feature « skills à la MCP »).
|
|
/// `None`/empty ⇒ the skill falls back to the first line of its body when
|
|
/// surfaced (see [`domain::Skill::effective_description`]).
|
|
pub description: Option<String>,
|
|
/// Capability nature to expose for this skill. Defaults to workflow when the
|
|
/// caller does not specify it.
|
|
pub kind: SkillKind,
|
|
/// Initial Markdown body.
|
|
pub content: String,
|
|
/// Scope the skill is created in (selects its backing store).
|
|
pub scope: SkillScope,
|
|
/// Active project root (used only for [`SkillScope::Project`]).
|
|
pub project_root: ProjectPath,
|
|
}
|
|
|
|
/// Output of [`CreateSkill::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateSkillOutput {
|
|
/// The created skill.
|
|
pub skill: Skill,
|
|
}
|
|
|
|
/// Creates a skill in the store of its [`SkillScope`].
|
|
pub struct CreateSkill {
|
|
skills: Arc<dyn SkillStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
}
|
|
|
|
impl CreateSkill {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
|
Self { skills, ids }
|
|
}
|
|
|
|
/// Executes creation.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::Invalid`] if `name`/`content` is empty,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
|
|
let id = SkillId::from_uuid(self.ids.new_uuid());
|
|
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?
|
|
.with_description(input.description)
|
|
.with_kind(input.kind);
|
|
self.skills.save(&skill, &input.project_root).await?;
|
|
Ok(CreateSkillOutput { skill })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UpdateSkill
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`UpdateSkill::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UpdateSkillInput {
|
|
/// Scope the skill lives in.
|
|
pub scope: SkillScope,
|
|
/// Skill to update.
|
|
pub skill_id: SkillId,
|
|
/// New Markdown body.
|
|
pub content: String,
|
|
/// Active project root (used only for [`SkillScope::Project`]).
|
|
pub project_root: ProjectPath,
|
|
}
|
|
|
|
/// Output of [`UpdateSkill::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UpdateSkillOutput {
|
|
/// The updated skill.
|
|
pub skill: Skill,
|
|
}
|
|
|
|
/// Replaces a skill's content (re-validating the non-empty invariant).
|
|
pub struct UpdateSkill {
|
|
skills: Arc<dyn SkillStore>,
|
|
}
|
|
|
|
impl UpdateSkill {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
|
Self { skills }
|
|
}
|
|
|
|
/// Executes the update.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
|
/// - [`AppError::Invalid`] if the new content is empty,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: UpdateSkillInput) -> Result<UpdateSkillOutput, AppError> {
|
|
let current = self
|
|
.skills
|
|
.get(input.scope, &input.project_root, input.skill_id)
|
|
.await?;
|
|
let updated = current
|
|
.with_content(MarkdownDoc::new(input.content))
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.skills.save(&updated, &input.project_root).await?;
|
|
Ok(UpdateSkillOutput { skill: updated })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ListSkills / DeleteSkill
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`ListSkills::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListSkillsInput {
|
|
/// Scope to list.
|
|
pub scope: SkillScope,
|
|
/// Active project root (used only for [`SkillScope::Project`]).
|
|
pub project_root: ProjectPath,
|
|
}
|
|
|
|
/// Output of [`ListSkills::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListSkillsOutput {
|
|
/// All skills in the requested scope.
|
|
pub skills: Vec<Skill>,
|
|
}
|
|
|
|
/// Lists the skills in one scope.
|
|
pub struct ListSkills {
|
|
skills: Arc<dyn SkillStore>,
|
|
}
|
|
|
|
impl ListSkills {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
|
Self { skills }
|
|
}
|
|
|
|
/// Lists skills in `input.scope`.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
|
|
Ok(ListSkillsOutput {
|
|
skills: self.skills.list(input.scope, &input.project_root).await?,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Input for [`DeleteSkill::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeleteSkillInput {
|
|
/// Scope the skill lives in.
|
|
pub scope: SkillScope,
|
|
/// Skill to delete.
|
|
pub skill_id: SkillId,
|
|
/// Active project root (used only for [`SkillScope::Project`]).
|
|
pub project_root: ProjectPath,
|
|
}
|
|
|
|
/// Deletes a skill from its scope's store.
|
|
///
|
|
/// Agents that referenced it keep their [`SkillRef`]; the injection step simply
|
|
/// finds nothing to resolve for the now-absent skill and skips it.
|
|
pub struct DeleteSkill {
|
|
skills: Arc<dyn SkillStore>,
|
|
}
|
|
|
|
impl DeleteSkill {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
|
Self { skills }
|
|
}
|
|
|
|
/// Deletes the skill.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> {
|
|
self.skills
|
|
.delete(input.scope, &input.project_root, input.skill_id)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ResolveAgentCapabilities / ReadSkill
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One discoverable capability carried by an agent.
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentCapability {
|
|
/// Capability display name.
|
|
pub name: String,
|
|
/// One-line affordance description.
|
|
pub description: String,
|
|
/// Capability nature.
|
|
pub kind: SkillKind,
|
|
}
|
|
|
|
impl From<&AssignedSkillSnapshot> for AgentCapability {
|
|
fn from(skill: &AssignedSkillSnapshot) -> Self {
|
|
Self {
|
|
name: skill.name.clone(),
|
|
description: skill.description.clone(),
|
|
kind: skill.kind,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Input for [`ResolveAgentCapabilities::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ResolveAgentCapabilitiesInput {
|
|
/// Agent whose assigned skills must be resolved.
|
|
pub agent: Agent,
|
|
/// Active project root used to resolve project-scoped skills.
|
|
pub project_root: ProjectPath,
|
|
}
|
|
|
|
/// Resolves assigned skills into discoverable agent capabilities.
|
|
pub struct ResolveAgentCapabilities {
|
|
resolver: Arc<AssignedSkillResolver>,
|
|
}
|
|
|
|
impl ResolveAgentCapabilities {
|
|
/// Builds the use case from a skill store.
|
|
#[must_use]
|
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
|
Self {
|
|
resolver: Arc::new(AssignedSkillResolver::new(skills)),
|
|
}
|
|
}
|
|
|
|
/// Builds the use case from a shared resolver.
|
|
#[must_use]
|
|
pub fn with_resolver(resolver: Arc<AssignedSkillResolver>) -> Self {
|
|
Self { resolver }
|
|
}
|
|
|
|
/// Resolves the compact capability list, skipping dangling skill refs.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on store failures other than a missing skill.
|
|
pub async fn execute(
|
|
&self,
|
|
input: ResolveAgentCapabilitiesInput,
|
|
) -> Result<Vec<AgentCapability>, AppError> {
|
|
let snapshot = self
|
|
.resolver
|
|
.resolve_for_agent(&input.agent, &input.project_root)
|
|
.await?;
|
|
Ok(snapshot
|
|
.assigned_skills
|
|
.iter()
|
|
.map(AgentCapability::from)
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
/// Resolves assigned skills into the runtime capability snapshot used by both
|
|
/// context rendering and `idea_skill_read` authorization.
|
|
pub struct AssignedSkillResolver {
|
|
skills: Arc<dyn SkillStore>,
|
|
plugin_skills: Option<Arc<ProjectPluginResolver>>,
|
|
}
|
|
|
|
impl AssignedSkillResolver {
|
|
/// Builds the resolver from the existing skill store port.
|
|
#[must_use]
|
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
|
Self {
|
|
skills,
|
|
plugin_skills: None,
|
|
}
|
|
}
|
|
|
|
/// Adds the provider-agnostic project-plugin resolver.
|
|
#[must_use]
|
|
pub fn with_plugin_skills(mut self, plugin_skills: Arc<ProjectPluginResolver>) -> Self {
|
|
self.plugin_skills = Some(plugin_skills);
|
|
self
|
|
}
|
|
|
|
/// Resolves the assigned skills for `agent` in manifest order.
|
|
///
|
|
/// Dangling assignments are skipped: a deleted skill must not block a launch,
|
|
/// and a skipped skill is therefore not readable via `idea_skill_read`.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on store failures other than a missing skill.
|
|
pub async fn resolve_for_agent(
|
|
&self,
|
|
agent: &Agent,
|
|
root: &ProjectPath,
|
|
) -> Result<OrchestrationCapabilitySnapshot, AppError> {
|
|
let resolved = self.resolve_for_agent_with_content(agent, root).await?;
|
|
Ok(OrchestrationCapabilitySnapshot::new(
|
|
resolved.into_iter().map(|(snapshot, _)| snapshot).collect(),
|
|
))
|
|
}
|
|
|
|
/// Resolves assigned skills and keeps their Markdown bodies for non-MCP
|
|
/// fallback context injection.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on store failures other than a missing skill.
|
|
pub async fn resolve_for_agent_with_content(
|
|
&self,
|
|
agent: &Agent,
|
|
root: &ProjectPath,
|
|
) -> Result<Vec<(AssignedSkillSnapshot, MarkdownDoc)>, AppError> {
|
|
let mut assigned = Vec::with_capacity(agent.skills.len());
|
|
for (index, skill_ref) in agent.skills.iter().enumerate() {
|
|
match self
|
|
.skills
|
|
.get(skill_ref.scope, root, skill_ref.skill_id)
|
|
.await
|
|
{
|
|
Ok(skill) => {
|
|
let description = skill.effective_description();
|
|
let snapshot = AssignedSkillSnapshot {
|
|
skill_id: skill.id,
|
|
scope: skill.scope,
|
|
plugin: None,
|
|
name: skill.name,
|
|
description,
|
|
kind: skill.kind,
|
|
assignment_index: index as u32,
|
|
};
|
|
assigned.push((snapshot, skill.content_md));
|
|
}
|
|
Err(StoreError::NotFound) => {}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
if let Some(resolver) = &self.plugin_skills {
|
|
let settings = resolver.settings(root).await?;
|
|
if let Some(assignments) = settings.assignments_for(agent.id) {
|
|
for (offset, reference) in assignments.skills.iter().enumerate() {
|
|
let Some(manifest) = resolver
|
|
.enabled_manifest(root, &reference.plugin_id)
|
|
.await?
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(skill) = manifest
|
|
.contributes
|
|
.skills
|
|
.iter()
|
|
.find(|s| s.id == reference.skill_id)
|
|
else {
|
|
continue;
|
|
};
|
|
let content = match resolver
|
|
.read_skill_body(&reference.plugin_id, &skill.path)
|
|
.await
|
|
{
|
|
Ok(content) => content,
|
|
Err(AppError::NotFound(_)) => continue,
|
|
Err(error) => return Err(error),
|
|
};
|
|
assigned.push((
|
|
AssignedSkillSnapshot {
|
|
// Native fields remain populated for backwards wire compatibility;
|
|
// plugin identity is authoritative when `plugin` is present.
|
|
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
|
|
scope: SkillScope::Project,
|
|
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
|
|
plugin_id: reference.plugin_id.clone(),
|
|
skill_id: reference.skill_id.clone(),
|
|
}),
|
|
name: skill.name.clone(),
|
|
description: skill.description.clone(),
|
|
kind: skill.kind,
|
|
assignment_index: (agent.skills.len() + offset) as u32,
|
|
},
|
|
content,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(assigned)
|
|
}
|
|
|
|
/// Reads the Markdown body for one skill by name, but only inside the supplied
|
|
/// assigned-skill snapshot.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if `name` is not assigned in `snapshot`,
|
|
/// - [`AppError::Invalid`] if the assigned snapshot has ambiguous names,
|
|
/// - [`AppError::Store`] on a store failure.
|
|
pub async fn read_assigned_by_name(
|
|
&self,
|
|
snapshot: &OrchestrationCapabilitySnapshot,
|
|
root: &ProjectPath,
|
|
name: &str,
|
|
) -> Result<MarkdownDoc, AppError> {
|
|
let mut matches = snapshot
|
|
.assigned_skills
|
|
.iter()
|
|
.filter(|s| s.name.eq_ignore_ascii_case(name));
|
|
let selected = match (matches.next(), matches.next()) {
|
|
(None, _) => return Err(AppError::NotFound(format!("assigned skill `{name}`"))),
|
|
(Some(skill), None) => skill,
|
|
(Some(_), Some(_)) => {
|
|
return Err(AppError::Invalid(format!(
|
|
"assigned skill name `{name}` is ambiguous for this agent"
|
|
)))
|
|
}
|
|
};
|
|
if let Some(plugin) = &selected.plugin {
|
|
let resolver = self
|
|
.plugin_skills
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
|
let manifest = resolver
|
|
.enabled_manifest(root, &plugin.plugin_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
|
let contribution = manifest
|
|
.contributes
|
|
.skills
|
|
.iter()
|
|
.find(|s| s.id == plugin.skill_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
|
return resolver
|
|
.read_skill_body(&plugin.plugin_id, &contribution.path)
|
|
.await;
|
|
}
|
|
let skill = self
|
|
.skills
|
|
.get(selected.scope, root, selected.skill_id)
|
|
.await?;
|
|
Ok(skill.content_md)
|
|
}
|
|
}
|
|
|
|
/// Input for [`ReadSkill::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReadSkillInput {
|
|
/// Skill display name to resolve (case-insensitive).
|
|
pub name: String,
|
|
/// Active project.
|
|
pub project: Project,
|
|
/// Current requester identity from the orchestration handshake.
|
|
pub requester: ConversationParty,
|
|
}
|
|
|
|
/// Reads an assigned skill's Markdown body **by name** — the application side of
|
|
/// the MCP `idea_skill_read` tool.
|
|
///
|
|
/// The same [`AssignedSkillResolver`] that builds the runtime capability
|
|
/// snapshot authorizes the read. A model can read only skills assigned to the
|
|
/// requesting agent in the current manifest.
|
|
pub struct ReadSkill {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
resolver: Arc<AssignedSkillResolver>,
|
|
}
|
|
|
|
impl ReadSkill {
|
|
/// Builds the use case from existing ports.
|
|
#[must_use]
|
|
pub fn new(contexts: Arc<dyn AgentContextStore>, skills: Arc<dyn SkillStore>) -> Self {
|
|
Self {
|
|
contexts,
|
|
resolver: Arc::new(AssignedSkillResolver::new(skills)),
|
|
}
|
|
}
|
|
|
|
/// Builds the use case from a shared resolver.
|
|
#[must_use]
|
|
pub fn with_resolver(
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
resolver: Arc<AssignedSkillResolver>,
|
|
) -> Self {
|
|
Self { contexts, resolver }
|
|
}
|
|
|
|
/// Resolves the requester's assigned snapshot and returns the skill body.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::Invalid`] if requester is not an agent or the assigned name is ambiguous,
|
|
/// - [`AppError::NotFound`] if requester/skill is absent,
|
|
/// - [`AppError::Store`] on store failures.
|
|
pub async fn execute(&self, input: ReadSkillInput) -> Result<MarkdownDoc, AppError> {
|
|
let requester = input.requester.as_agent().ok_or_else(|| {
|
|
AppError::Invalid("idea_skill_read requires an agent requester".to_owned())
|
|
})?;
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let entry = manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| e.agent_id == requester)
|
|
.ok_or_else(|| AppError::NotFound(format!("agent {requester}")))?;
|
|
let agent = entry
|
|
.to_agent()
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
let snapshot = self
|
|
.resolver
|
|
.resolve_for_agent(&agent, &input.project.root)
|
|
.await?;
|
|
self.resolver
|
|
.read_assigned_by_name(&snapshot, &input.project.root, &input.name)
|
|
.await
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AssignSkillToAgent / UnassignSkillFromAgent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`AssignSkillToAgent::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AssignSkillToAgentInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent receiving the skill.
|
|
pub agent_id: AgentId,
|
|
/// The skill to assign.
|
|
pub skill: SkillRef,
|
|
}
|
|
|
|
/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry.
|
|
/// Idempotent: re-assigning the same skill is a no-op (no duplicate).
|
|
pub struct AssignSkillToAgent {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl AssignSkillToAgent {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
|
Self { contexts, events }
|
|
}
|
|
|
|
/// Executes the assignment.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
|
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> {
|
|
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let entry = manifest
|
|
.entries
|
|
.iter_mut()
|
|
.find(|e| e.agent_id == input.agent_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
|
|
|
// Mutate through the domain entity so the dedup invariant is enforced in
|
|
// one place, then fold the result back into the manifest entry.
|
|
let mut agent = entry
|
|
.to_agent()
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
let changed = agent.assign_skill(input.skill);
|
|
*entry = domain::ManifestEntry::from_agent(&agent);
|
|
|
|
if changed {
|
|
self.persist_and_announce(
|
|
&input.project,
|
|
manifest,
|
|
input.agent_id,
|
|
input.skill.skill_id,
|
|
true,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Saves the manifest and announces the assignment change.
|
|
async fn persist_and_announce(
|
|
&self,
|
|
project: &Project,
|
|
manifest: AgentManifest,
|
|
agent_id: AgentId,
|
|
skill_id: SkillId,
|
|
assigned: bool,
|
|
) -> Result<(), AppError> {
|
|
let manifest = AgentManifest::with_orchestrator(
|
|
manifest.version,
|
|
manifest.entries,
|
|
manifest.orchestrator,
|
|
)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.contexts.save_manifest(project, &manifest).await?;
|
|
self.events.publish(DomainEvent::SkillAssigned {
|
|
agent_id,
|
|
skill_id,
|
|
assigned,
|
|
});
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Input for [`UnassignSkillFromAgent::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UnassignSkillFromAgentInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent losing the skill.
|
|
pub agent_id: AgentId,
|
|
/// The skill to unassign.
|
|
pub skill_id: SkillId,
|
|
}
|
|
|
|
/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the
|
|
/// agent does not carry is a no-op.
|
|
pub struct UnassignSkillFromAgent {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl UnassignSkillFromAgent {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
|
Self { contexts, events }
|
|
}
|
|
|
|
/// Executes the un-assignment.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
|
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> {
|
|
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let entry = manifest
|
|
.entries
|
|
.iter_mut()
|
|
.find(|e| e.agent_id == input.agent_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
|
|
|
let mut agent = entry
|
|
.to_agent()
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
let changed = agent.unassign_skill(input.skill_id);
|
|
*entry = domain::ManifestEntry::from_agent(&agent);
|
|
|
|
if changed {
|
|
let manifest = AgentManifest::with_orchestrator(
|
|
manifest.version,
|
|
manifest.entries,
|
|
manifest.orchestrator,
|
|
)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.contexts
|
|
.save_manifest(&input.project, &manifest)
|
|
.await?;
|
|
self.events.publish(DomainEvent::SkillAssigned {
|
|
agent_id: input.agent_id,
|
|
skill_id: input.skill_id,
|
|
assigned: false,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::Mutex;
|
|
|
|
use async_trait::async_trait;
|
|
use domain::ports::{AgentContextStore, SkillStore, StoreError};
|
|
use domain::remote::RemoteRef;
|
|
|
|
/// In-memory [`SkillStore`] fake: skills are bucketed by scope, ignoring the
|
|
/// project root (the [`ReadSkill`] resolution logic is root-agnostic — it only
|
|
/// switches scope). Enough to exercise project-first resolution, shadowing,
|
|
/// ambiguity and absence without touching disk.
|
|
#[derive(Default)]
|
|
struct FakeSkillStore {
|
|
global: Mutex<Vec<Skill>>,
|
|
project: Mutex<Vec<Skill>>,
|
|
}
|
|
|
|
impl FakeSkillStore {
|
|
fn bucket(&self, scope: SkillScope) -> &Mutex<Vec<Skill>> {
|
|
match scope {
|
|
SkillScope::Global => &self.global,
|
|
SkillScope::Project => &self.project,
|
|
}
|
|
}
|
|
|
|
fn push(&self, skill: Skill) {
|
|
self.bucket(skill.scope).lock().unwrap().push(skill);
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SkillStore for FakeSkillStore {
|
|
async fn list(
|
|
&self,
|
|
scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
) -> Result<Vec<Skill>, StoreError> {
|
|
Ok(self.bucket(scope).lock().unwrap().clone())
|
|
}
|
|
async fn get(
|
|
&self,
|
|
scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
self.bucket(scope)
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|s| s.id == id)
|
|
.cloned()
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
|
self.push(skill.clone());
|
|
Ok(())
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct FakeAgentContextStore {
|
|
manifest: Mutex<AgentManifest>,
|
|
}
|
|
|
|
impl FakeAgentContextStore {
|
|
fn new(manifest: AgentManifest) -> Self {
|
|
Self {
|
|
manifest: Mutex::new(manifest),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeAgentContextStore {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
_agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
Ok(MarkdownDoc::new("# Persona"))
|
|
}
|
|
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
_agent: &AgentId,
|
|
_md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.manifest.lock().unwrap().clone())
|
|
}
|
|
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
*self.manifest.lock().unwrap() = manifest.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn root() -> ProjectPath {
|
|
ProjectPath::new("/proj").unwrap()
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(100)),
|
|
"Project",
|
|
root(),
|
|
RemoteRef::Local,
|
|
0,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn agent_id(n: u128) -> AgentId {
|
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
fn profile_id(n: u128) -> domain::ProfileId {
|
|
domain::ProfileId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
fn manifest_for(agent_id: AgentId, skills: Vec<SkillRef>) -> AgentManifest {
|
|
let agent = Agent::new(
|
|
agent_id,
|
|
"Dev",
|
|
"agents/dev.md",
|
|
profile_id(900),
|
|
domain::AgentOrigin::Scratch,
|
|
false,
|
|
)
|
|
.unwrap()
|
|
.with_skills(skills);
|
|
AgentManifest::new(1, vec![domain::ManifestEntry::from_agent(&agent)]).unwrap()
|
|
}
|
|
|
|
fn skill(id: u128, name: &str, body: &str, scope: SkillScope) -> Skill {
|
|
Skill::new(
|
|
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
|
|
name,
|
|
MarkdownDoc::new(body),
|
|
scope,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_agent_capabilities_returns_compact_kind_aware_affordances() {
|
|
let store = Arc::new(FakeSkillStore::default());
|
|
store.push(
|
|
skill(1, "deploy", "# Deploy\n\nbody", SkillScope::Project)
|
|
.with_description(Some("Deploys safely".to_owned())),
|
|
);
|
|
store.push(
|
|
skill(2, "runbook", "# Runbook", SkillScope::Global).with_kind(SkillKind::Reference),
|
|
);
|
|
let requester = agent_id(10);
|
|
let agent = manifest_for(
|
|
requester,
|
|
vec![
|
|
SkillRef::new(
|
|
SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
|
SkillScope::Project,
|
|
),
|
|
SkillRef::new(
|
|
SkillId::from_uuid(uuid::Uuid::from_u128(99)),
|
|
SkillScope::Project,
|
|
),
|
|
SkillRef::new(
|
|
SkillId::from_uuid(uuid::Uuid::from_u128(2)),
|
|
SkillScope::Global,
|
|
),
|
|
],
|
|
)
|
|
.entries[0]
|
|
.to_agent()
|
|
.unwrap();
|
|
let uc = ResolveAgentCapabilities::new(store);
|
|
|
|
let capabilities = uc
|
|
.execute(ResolveAgentCapabilitiesInput {
|
|
agent,
|
|
project_root: root(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(capabilities.len(), 2);
|
|
assert_eq!(capabilities[0].name, "deploy");
|
|
assert_eq!(capabilities[0].description, "Deploys safely");
|
|
assert_eq!(capabilities[0].kind, SkillKind::Workflow);
|
|
assert_eq!(capabilities[1].name, "runbook");
|
|
assert_eq!(capabilities[1].description, "Runbook");
|
|
assert_eq!(capabilities[1].kind, SkillKind::Reference);
|
|
}
|
|
|
|
fn read_skill_uc(
|
|
store: Arc<FakeSkillStore>,
|
|
manifest: AgentManifest,
|
|
) -> (ReadSkill, Project, AgentId) {
|
|
let requester = manifest.entries[0].agent_id;
|
|
let contexts = Arc::new(FakeAgentContextStore::new(manifest));
|
|
(ReadSkill::new(contexts, store), project(), requester)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_skill_returns_assigned_project_skill() {
|
|
let store = Arc::new(FakeSkillStore::default());
|
|
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
|
|
|
|
let requester = agent_id(10);
|
|
let manifest = manifest_for(
|
|
requester,
|
|
vec![SkillRef {
|
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
|
scope: SkillScope::Project,
|
|
}],
|
|
);
|
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
|
|
|
let body = uc
|
|
.execute(ReadSkillInput {
|
|
name: "deploy".to_owned(),
|
|
project,
|
|
requester: ConversationParty::agent(requester),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body.as_str(), "PROJECT_BODY");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_skill_returns_assigned_global_skill() {
|
|
let store = Arc::new(FakeSkillStore::default());
|
|
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
|
|
|
|
let requester = agent_id(10);
|
|
let manifest = manifest_for(
|
|
requester,
|
|
vec![SkillRef {
|
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
|
scope: SkillScope::Global,
|
|
}],
|
|
);
|
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
|
|
|
let body = uc
|
|
.execute(ReadSkillInput {
|
|
name: "deploy".to_owned(),
|
|
project,
|
|
requester: ConversationParty::agent(requester),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body.as_str(), "GLOBAL_BODY");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_skill_refuses_unassigned_name_even_if_skill_exists() {
|
|
let store = Arc::new(FakeSkillStore::default());
|
|
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
|
|
|
|
let requester = agent_id(10);
|
|
let manifest = manifest_for(requester, Vec::new());
|
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
|
|
|
let err = uc
|
|
.execute(ReadSkillInput {
|
|
name: "deploy".to_owned(),
|
|
project,
|
|
requester: ConversationParty::agent(requester),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_skill_is_case_insensitive_within_assigned_snapshot() {
|
|
let store = Arc::new(FakeSkillStore::default());
|
|
store.push(skill(1, "Deploy", "BODY", SkillScope::Project));
|
|
|
|
let requester = agent_id(10);
|
|
let manifest = manifest_for(
|
|
requester,
|
|
vec![SkillRef {
|
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
|
scope: SkillScope::Project,
|
|
}],
|
|
);
|
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
|
|
|
let body = uc
|
|
.execute(ReadSkillInput {
|
|
name: "deploy".to_owned(),
|
|
project,
|
|
requester: ConversationParty::agent(requester),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body.as_str(), "BODY");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_skill_requires_agent_requester() {
|
|
let store = Arc::new(FakeSkillStore::default());
|
|
let requester = agent_id(10);
|
|
let manifest = manifest_for(requester, Vec::new());
|
|
let (uc, project, _) = read_skill_uc(store, manifest);
|
|
|
|
let err = uc
|
|
.execute(ReadSkillInput {
|
|
name: "ghost".to_owned(),
|
|
project,
|
|
requester: ConversationParty::User,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_skill_ambiguous_assigned_name_is_invalid() {
|
|
let store = Arc::new(FakeSkillStore::default());
|
|
store.push(skill(1, "deploy", "ONE", SkillScope::Project));
|
|
store.push(skill(2, "Deploy", "TWO", SkillScope::Global));
|
|
|
|
let requester = agent_id(10);
|
|
let manifest = manifest_for(
|
|
requester,
|
|
vec![
|
|
SkillRef {
|
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
|
scope: SkillScope::Project,
|
|
},
|
|
SkillRef {
|
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(2)),
|
|
scope: SkillScope::Global,
|
|
},
|
|
],
|
|
);
|
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
|
|
|
let err = uc
|
|
.execute(ReadSkillInput {
|
|
name: "deploy".to_owned(),
|
|
project,
|
|
requester: ConversationParty::agent(requester),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
|
|
}
|
|
}
|