feat(skills): capacités agent découvrables via idea_list_agents

Ajoute SkillKind (Workflow/Reference) sur Skill, extrait le use case
ResolveAgentCapabilities à partir des SkillRef assignés, et enrichit
idea_list_agents d'un champ additif capabilities (name, description,
kind) — remplace l'exposition de SkillRef opaques par un inventaire de
capacités interrogeable, source commune avec le bloc « Skills
disponibles » injecté au lancement (#119).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:26:56 +02:00
parent 5efb026a80
commit 92b17e9a69
18 changed files with 467 additions and 83 deletions

View File

@ -2628,7 +2628,7 @@ pub async fn create_agent_from_template(
.create_agent_from_template
.execute(input)
.await
.map(|out| AgentDto(out.agent))
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)
}

View File

@ -9,14 +9,15 @@ use app_tauri_lib::dto::{
};
use application::AppError;
use application::{
AgentTicketState, AgentWorkState, CreateAgentOutput, InspectConversationOutput,
LaunchAgentOutput, ListAgentsOutput, LiveSessionKind, LiveSessionSnapshot, LiveWorkSession,
ProjectWorkState, TicketWorkSource, TicketWorkStatus,
AgentCapability, AgentTicketState, AgentWorkState, CreateAgentOutput,
InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, ListedAgentCapabilities,
LiveSessionKind, LiveSessionSnapshot, LiveWorkSession, ProjectWorkState, TicketWorkSource,
TicketWorkStatus,
};
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::ports::ConversationDetails;
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath};
use domain::{Agent, AgentOrigin, ProjectPath, SkillKind};
use serde_json::json;
use uuid::Uuid;
@ -40,7 +41,7 @@ fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
#[test]
fn agent_dto_serialises_camelcase() {
let agent = make_agent(1, 2);
let dto = AgentDto(agent.clone());
let dto = AgentDto::from_agent(agent.clone());
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["id"], agent.id.to_string());
@ -53,6 +54,7 @@ fn agent_dto_serialises_camelcase() {
assert_eq!(v["synchronized"], false);
// origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch");
assert_eq!(v["capabilities"], json!([]));
// no snake_case leak
assert!(v.get("context_path").is_none());
assert!(v.get("profile_id").is_none());
@ -60,14 +62,27 @@ fn agent_dto_serialises_camelcase() {
#[test]
fn agent_list_dto_is_transparent_array() {
let first = make_agent(1, 2);
let out = ListAgentsOutput {
agents: vec![make_agent(1, 2), make_agent(3, 4)],
agents: vec![first.clone(), make_agent(3, 4)],
capabilities: vec![ListedAgentCapabilities {
agent_id: first.id,
capabilities: vec![AgentCapability {
name: "review".to_owned(),
description: "Reviews changes".to_owned(),
kind: SkillKind::Reference,
}],
}],
};
let dto = AgentListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "My Agent");
assert_eq!(arr[0]["capabilities"][0]["name"], "review");
assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes");
assert_eq!(arr[0]["capabilities"][0]["kind"], "reference");
assert_eq!(arr[1]["capabilities"], json!([]));
}
#[test]
@ -77,7 +92,8 @@ fn create_agent_output_maps_to_agent_dto() {
agent: agent.clone(),
};
let dto = AgentDto::from(out);
assert_eq!(dto.0.id, agent.id);
assert_eq!(dto.agent.id, agent.id);
assert!(dto.capabilities.is_empty());
}
// ---------------------------------------------------------------------------

View File

@ -94,7 +94,7 @@ fn output_maps_agent_and_omits_session_when_no_relaunch() {
relaunched: None,
};
let dto = ChangeAgentProfileDto::from(out);
assert_eq!(dto.agent.0.id, agent.id);
assert_eq!(dto.agent.agent.id, agent.id);
assert!(dto.relaunched_session.is_none());
let v = serde_json::to_value(&dto).unwrap();

View File

@ -31,7 +31,7 @@ use domain::{
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, Posture, ProfileId, Project,
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
SessionId, SessionKind, SessionStatus, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
SessionId, SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
};
use domain::live_state::WorkStatus;
@ -42,7 +42,7 @@ use crate::model_server::{
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
};
use crate::project::project_context_path;
use crate::skill::AssignedSkillResolver;
use crate::skill::{AgentCapability, AssignedSkillResolver, ResolveAgentCapabilities};
use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean;
@ -138,6 +138,8 @@ pub struct EffectiveAgentContext {
pub project_context: String,
/// Provider-agnostic orchestration capabilities exposed to the agent.
pub capabilities: OrchestrationCapabilitySnapshot,
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
pub agent_capabilities: Vec<AgentCapability>,
/// Resolved assigned skill bodies for fallback non-MCP injection.
pub assigned_skills: Vec<ResolvedAssignedSkill>,
/// Project-memory recall selected for this launch.
@ -277,18 +279,79 @@ pub struct ListAgentsInput {
pub struct ListAgentsOutput {
/// The project's agents (reconstructed from the manifest).
pub agents: Vec<Agent>,
/// Resolved discoverable capabilities per agent.
pub capabilities: Vec<ListedAgentCapabilities>,
}
/// Resolved capabilities for one listed agent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedAgentCapabilities {
/// Agent id.
pub agent_id: AgentId,
/// Compact capability list.
pub capabilities: Vec<AgentCapability>,
}
/// Agent list item for discovery surfaces.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentDiscoveryEntry {
/// Raw agent manifest shape, including `skills` for compatibility.
#[serde(flatten)]
pub agent: Agent,
/// Resolved capability affordances.
pub capabilities: Vec<AgentCapability>,
}
impl ListAgentsOutput {
/// Returns the additive discovery shape used by inter-agent surfaces.
#[must_use]
pub fn discovery_entries(&self) -> Vec<AgentDiscoveryEntry> {
self.agents
.iter()
.cloned()
.map(|agent| {
let capabilities = self
.capabilities
.iter()
.find(|entry| entry.agent_id == agent.id)
.map(|entry| entry.capabilities.clone())
.unwrap_or_default();
AgentDiscoveryEntry {
agent,
capabilities,
}
})
.collect()
}
}
/// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents {
contexts: Arc<dyn AgentContextStore>,
capabilities: Option<Arc<ResolveAgentCapabilities>>,
}
impl ListAgents {
/// Builds the use case from the [`AgentContextStore`] port.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
Self {
contexts,
capabilities: None,
}
}
/// Builds the use case with resolved agent capabilities enabled.
#[must_use]
pub fn with_capabilities(
contexts: Arc<dyn AgentContextStore>,
capabilities: Arc<ResolveAgentCapabilities>,
) -> Self {
Self {
contexts,
capabilities: Some(capabilities),
}
}
/// Loads the manifest and folds each entry back into an [`Agent`].
@ -306,7 +369,28 @@ impl ListAgents {
.map_err(|err| AppError::Invalid(err.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(ListAgentsOutput { agents })
let capabilities = match &self.capabilities {
Some(resolver) => {
let mut out = Vec::with_capacity(agents.len());
for agent in &agents {
out.push(ListedAgentCapabilities {
agent_id: agent.id,
capabilities: resolver
.execute(crate::ResolveAgentCapabilitiesInput {
agent: agent.clone(),
project_root: input.project.root.clone(),
})
.await?,
});
}
out
}
None => Vec::new(),
};
Ok(ListAgentsOutput {
agents,
capabilities,
})
}
}
@ -1185,6 +1269,7 @@ pub struct LaunchAgent {
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
assigned_skills: Arc<AssignedSkillResolver>,
agent_capabilities: Arc<ResolveAgentCapabilities>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
ids: Arc<dyn IdGenerator>,
@ -1268,13 +1353,18 @@ impl LaunchAgent {
recall: Arc<dyn MemoryRecall>,
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
) -> Self {
let assigned_skills = Arc::new(AssignedSkillResolver::new(skills));
let agent_capabilities = Arc::new(ResolveAgentCapabilities::with_resolver(Arc::clone(
&assigned_skills,
)));
Self {
contexts,
profiles,
runtime,
fs,
pty,
assigned_skills: Arc::new(AssignedSkillResolver::new(skills)),
assigned_skills,
agent_capabilities,
sessions,
events,
ids,
@ -1433,6 +1523,13 @@ impl LaunchAgent {
let capabilities = OrchestrationCapabilitySnapshot::new(
assigned_skills.iter().map(|s| s.snapshot.clone()).collect(),
);
let agent_capabilities = self
.agent_capabilities
.execute(crate::ResolveAgentCapabilitiesInput {
agent: agent.clone(),
project_root: project.root.clone(),
})
.await?;
let project_context = self.resolve_project_context(project).await?;
let memory = self.resolve_memory(&project.root, persona.as_str()).await;
let handoff = self
@ -1445,6 +1542,7 @@ impl LaunchAgent {
persona,
project_context,
capabilities,
agent_capabilities,
assigned_skills,
memory,
handoff,
@ -3320,7 +3418,7 @@ fn append_block(input: &str, block: &str) -> String {
/// the given (manifest) order — making the output deterministic:
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
/// right after the orchestration block, listing each as
/// `**<name>** — <effective description>` (affordances only, *no body*), with
/// `**<name>** — <effective description> (<kind>)` (affordances only, *no body*), with
/// prose pointing to `idea_skill_read` to load a body on demand. Respects the
/// altitude: the capability is exposed, never the skill content.
/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full
@ -3446,7 +3544,7 @@ pub(crate) fn compose_convention_file(
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
if mcp_enabled && !effective.capabilities.assigned_skills.is_empty() {
if mcp_enabled && !effective.agent_capabilities.is_empty() {
out.push_str("# Skills disponibles\n\n");
out.push_str("Snapshot version: ");
out.push_str(&effective.capabilities.version.to_string());
@ -3456,11 +3554,17 @@ pub(crate) fn compose_convention_file(
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
workflow déjà couvert par un skill, charge-le.\n\n",
);
for skill in &effective.capabilities.assigned_skills {
for skill in &effective.agent_capabilities {
out.push_str("**");
out.push_str(&skill.name);
out.push_str("** — ");
out.push_str(&skill.description);
out.push_str(" (");
out.push_str(match skill.kind {
SkillKind::Workflow => "workflow",
SkillKind::Reference => "reference",
});
out.push(')');
out.push('\n');
}
out.push_str("\n---\n\n");
@ -3654,17 +3758,23 @@ mod tests {
scope: skill.scope,
name: skill.name.clone(),
description: skill.effective_description(),
kind: skill.kind,
assignment_index: index as u32,
},
content: skill.content_md.clone(),
})
.collect();
let agent_capabilities = assigned_skills
.iter()
.map(|skill| AgentCapability::from(&skill.snapshot))
.collect();
let effective = EffectiveAgentContext {
persona: MarkdownDoc::new(agent_md),
project_context: project_context.to_owned(),
capabilities: OrchestrationCapabilitySnapshot::new(
assigned_skills.iter().map(|s| s.snapshot.clone()).collect(),
),
agent_capabilities,
assigned_skills,
memory: memory.to_vec(),
handoff: handoff.cloned(),
@ -3936,9 +4046,9 @@ mod tests {
"MCP skills section present"
);
assert!(doc.contains("idea_skill_read"), "points to the read tool");
// Affordance lines: `**name** — <effective description>`.
assert!(doc.contains("**refactor** — Refactors code"));
assert!(doc.contains("**review** — Review skill"));
// Affordance lines: `**name** — <effective description> (<kind>)`.
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
assert!(doc.contains("**review** — Review skill (workflow)"));
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode");
assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode");

View File

@ -34,9 +34,9 @@ pub use lifecycle::{
resolve_opencode_mcp_timeout_ms, ChangeAgentProfile, ChangeAgentProfileInput,
ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, HandoffProvider, InjectedLiveRow, LaunchAgent, LaunchAgentInput,
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, LiveStateLeanProvider,
McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};

View File

@ -54,11 +54,11 @@ pub use agent::{
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListClaudeModels,
ListClaudeModelsOutput, ListCodexModels, ListCodexModelsOutput, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability,
ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
ListResumableAgentsInput, ListResumableAgentsOutput, ListedAgentCapabilities,
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
ProfileAvailability, ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
@ -169,10 +169,11 @@ pub use project::{
};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use skill::{
AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver, CreateSkill,
CreateSkillInput, CreateSkillOutput, DeleteSkill, DeleteSkillInput, ListSkills,
ListSkillsInput, ListSkillsOutput, ReadSkill, ReadSkillInput, UnassignSkillFromAgent,
UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
AgentCapability, AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver,
CreateSkill, CreateSkillInput, CreateSkillOutput, DeleteSkill, DeleteSkillInput, ListSkills,
ListSkillsInput, ListSkillsOutput, ReadSkill, ReadSkillInput, ResolveAgentCapabilities,
ResolveAgentCapabilitiesInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput,
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
};
pub use sprints::{
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,

View File

@ -2687,8 +2687,8 @@ impl OrchestratorService {
/// The list is serialised as a JSON array into [`OrchestratorOutcome::reply`]
/// (the existing inline-payload channel, also used by `ask`), with a one-line
/// count in [`OrchestratorOutcome::detail`]. Each element carries the agent's
/// `id`, `name`, `contextPath`, `profileId`, `origin`, `synchronized` and
/// `skills` (camelCase, the [`domain::Agent`] serde shape).
/// `id`, `name`, `contextPath`, `profileId`, `origin`, `synchronized`,
/// `skills` and the additive resolved `capabilities` list.
///
/// # Errors
/// Propagates [`AppError`] from the use case (manifest load / invariant) or a
@ -2701,11 +2701,12 @@ impl OrchestratorService {
})
.await?;
let reply = serde_json::to_string(&listed.agents)
let entries = listed.discovery_entries();
let reply = serde_json::to_string(&entries)
.map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("listed {} agent(s)", listed.agents.len()),
detail: format!("listed {} agent(s)", entries.len()),
reply: Some(reply),
})
}

View File

@ -14,8 +14,9 @@
mod usecases;
pub use usecases::{
AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver, CreateSkill,
CreateSkillInput, CreateSkillOutput, DeleteSkill, DeleteSkillInput, ListSkills,
ListSkillsInput, ListSkillsOutput, ReadSkill, ReadSkillInput, UnassignSkillFromAgent,
UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
AgentCapability, AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver,
CreateSkill, CreateSkillInput, CreateSkillOutput, DeleteSkill, DeleteSkillInput, ListSkills,
ListSkillsInput, ListSkillsOutput, ReadSkill, ReadSkillInput, ResolveAgentCapabilities,
ResolveAgentCapabilitiesInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput,
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
};

View File

@ -11,8 +11,8 @@ 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, SkillRef,
SkillScope,
MarkdownDoc, OrchestrationCapabilitySnapshot, Project, ProjectPath, Skill, SkillId, SkillKind,
SkillRef, SkillScope,
};
use crate::error::AppError;
@ -211,9 +211,80 @@ impl DeleteSkill {
}
// ---------------------------------------------------------------------------
// ReadSkill
// 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 {
@ -269,6 +340,7 @@ impl AssignedSkillResolver {
scope: skill.scope,
name: skill.name,
description,
kind: skill.kind,
assignment_index: index as u32,
};
assigned.push((snapshot, skill.content_md));
@ -691,6 +763,56 @@ mod tests {
.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,

View File

@ -827,6 +827,43 @@ async fn list_reconstructs_agents_from_manifest() {
assert_eq!(out.agents, vec![a]);
}
#[tokio::test]
async fn list_resolves_agent_capabilities_additively() {
let mut a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let skill_id = SkillId::from_uuid(uuid::Uuid::from_u128(77));
a.assign_skill(domain::SkillRef::new(skill_id, domain::SkillScope::Global));
let contexts = FakeContexts::with_agent(&a, "ctx");
let skills = FakeSkills::with(vec![Skill::new(
skill_id,
"review",
MarkdownDoc::new("# Review workflow\n\nbody"),
domain::SkillScope::Global,
)
.unwrap()
.with_description(Some("Reviews changes".to_owned()))
.with_kind(domain::SkillKind::Reference)]);
let resolver = Arc::new(application::ResolveAgentCapabilities::new(Arc::new(skills)));
let list = ListAgents::with_capabilities(Arc::new(contexts), resolver);
let out = list
.execute(ListAgentsInput { project: project() })
.await
.unwrap();
assert_eq!(out.agents, vec![a.clone()]);
let entries = out.discovery_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].agent.id, a.id);
assert_eq!(entries[0].agent.skills.len(), 1);
assert_eq!(entries[0].capabilities.len(), 1);
assert_eq!(entries[0].capabilities[0].name, "review");
assert_eq!(entries[0].capabilities[0].description, "Reviews changes");
assert_eq!(
entries[0].capabilities[0].kind,
domain::SkillKind::Reference
);
}
#[tokio::test]
async fn read_then_update_context_roundtrips() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));

View File

@ -1967,20 +1967,58 @@ pub fn parse_profile_id(raw: &str) -> Result<ProfileId, ErrorDto> {
// ---------------------------------------------------------------------------
use application::{
ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput,
ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
AgentCapability, ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput,
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
};
use domain::{
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet,
ProjectPermissions, TerminalSession,
ProjectPermissions, SkillKind, TerminalSession,
};
/// An agent crossing the wire. [`Agent`] already serialises camelCase
/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`),
/// so we embed it directly — the TS mirror matches this shape.
/// One discoverable capability carried by an agent.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct AgentDto(pub Agent);
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilityDto {
/// Capability display name.
pub name: String,
/// One-line affordance description.
pub description: String,
/// Capability nature.
pub kind: SkillKind,
}
impl From<AgentCapability> for AgentCapabilityDto {
fn from(value: AgentCapability) -> Self {
Self {
name: value.name,
description: value.description,
kind: value.kind,
}
}
}
/// An agent crossing the wire. The raw [`Agent`] shape is flattened so existing
/// fields, including `skills`, remain compatible; `capabilities` is additive.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentDto {
/// Raw agent manifest shape.
#[serde(flatten)]
pub agent: Agent,
/// Resolved discoverable capabilities.
pub capabilities: Vec<AgentCapabilityDto>,
}
impl AgentDto {
/// Builds a DTO without resolved capabilities.
#[must_use]
pub fn from_agent(agent: Agent) -> Self {
Self {
agent,
capabilities: Vec::new(),
}
}
}
/// A list of agents (camelCase array on the wire).
#[derive(Debug, Clone, Serialize)]
@ -1989,13 +2027,25 @@ pub struct AgentListDto(pub Vec<AgentDto>);
impl From<ListAgentsOutput> for AgentListDto {
fn from(out: ListAgentsOutput) -> Self {
Self(out.agents.into_iter().map(AgentDto).collect())
Self(
out.discovery_entries()
.into_iter()
.map(|entry| AgentDto {
agent: entry.agent,
capabilities: entry
.capabilities
.into_iter()
.map(AgentCapabilityDto::from)
.collect(),
})
.collect(),
)
}
}
impl From<CreateAgentOutput> for AgentDto {
fn from(out: CreateAgentOutput) -> Self {
Self(out.agent)
Self::from_agent(out.agent)
}
}
@ -2326,7 +2376,7 @@ pub struct ChangeAgentProfileDto {
impl From<ChangeAgentProfileOutput> for ChangeAgentProfileDto {
fn from(out: ChangeAgentProfileOutput) -> Self {
Self {
agent: AgentDto(out.agent),
agent: AgentDto::from_agent(out.agent),
relaunched_session: out.relaunched.map(TerminalSessionDto::from),
}
}

View File

@ -40,19 +40,19 @@ use application::{
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
@ -1977,12 +1977,18 @@ impl BackendCore {
&prompt_store_port,
)));
let agent_capabilities =
Arc::new(ResolveAgentCapabilities::new(Arc::clone(&skill_store_port)));
let create_agent = Arc::new(CreateAgentFromScratch::new(
Arc::clone(&contexts_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&events_port),
));
let list_agents = Arc::new(ListAgents::new(Arc::clone(&contexts_port)));
let list_agents = Arc::new(ListAgents::with_capabilities(
Arc::clone(&contexts_port),
Arc::clone(&agent_capabilities),
));
let read_agent_context = Arc::new(ReadAgentContext::new(Arc::clone(&contexts_port)));
let update_agent_context = Arc::new(UpdateAgentContext::new(Arc::clone(&contexts_port)));
let delete_agent = Arc::new(DeleteAgent::new(

View File

@ -101,7 +101,7 @@ pub use background_task::{
BACKGROUND_TASK_TEXT_MAX_BYTES,
};
pub use skill::{Skill, SkillRef, SkillScope};
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
pub use template::{AgentTemplate, TemplateVersion};

View File

@ -17,7 +17,7 @@ use crate::conversation::ConversationParty;
use crate::ids::{AgentId, NodeId, SkillId};
use crate::live_state::WorkStatus;
use crate::mailbox::TicketId;
use crate::skill::SkillScope;
use crate::skill::{SkillKind, SkillScope};
/// Current schema version for orchestration capabilities injected into an
/// agent's effective runtime context.
@ -39,6 +39,9 @@ pub struct AssignedSkillSnapshot {
pub name: String,
/// One-line affordance description shown in the model context.
pub description: String,
/// Explicit nature of the assigned skill capability.
#[serde(default)]
pub kind: SkillKind,
/// Position in the agent assignment list, preserving manifest order.
pub assignment_index: u32,
}

View File

@ -27,6 +27,17 @@ pub enum SkillScope {
Project,
}
/// Nature of a skill when surfaced as an agent capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum SkillKind {
/// Procedural workflow the agent may execute on demand.
#[default]
Workflow,
/// Reference material the agent may consult selectively.
Reference,
}
/// A reusable workflow assignable to one or more agents.
///
/// Invariants enforced here:
@ -45,6 +56,10 @@ pub struct Skill {
/// [`Skill::effective_description`] falls back to the first line of the body.
#[serde(default)]
pub description: Option<String>,
/// Explicit skill nature. Defaults to [`SkillKind::Workflow`] for legacy
/// skill JSON/index rows that predate this field.
#[serde(default)]
pub kind: SkillKind,
/// Markdown body — the workflow injected into an agent's convention file.
pub content_md: MarkdownDoc,
/// Scope (selects the backing store).
@ -74,6 +89,7 @@ impl Skill {
id,
name,
description: None,
kind: SkillKind::Workflow,
content_md,
scope,
})
@ -90,6 +106,13 @@ impl Skill {
self
}
/// Returns this skill with an explicit [`SkillKind`] set (builder).
#[must_use]
pub const fn with_kind(mut self, kind: SkillKind) -> Self {
self.kind = kind;
self
}
/// The description to surface for this skill, with a deterministic fallback.
///
/// Returns [`Skill::description`] when present and non-blank (trimmed);
@ -122,7 +145,8 @@ impl Skill {
pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
Ok(
Skill::new(self.id, self.name.clone(), content_md, self.scope)?
.with_description(self.description.clone()),
.with_description(self.description.clone())
.with_kind(self.kind),
)
}
}
@ -230,22 +254,26 @@ mod tests {
#[test]
fn deserialize_legacy_skill_without_description_defaults_to_none() {
// A skill JSON written before `description` existed must deserialise with
// `description: None` thanks to `#[serde(default)]`.
// A skill JSON written before `description`/`kind` existed must
// deserialise with additive defaults.
let id = uuid::Uuid::from_u128(7);
let json =
format!(r##"{{"id":"{id}","name":"legacy","contentMd":"# body","scope":"global"}}"##);
let parsed: Skill = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.description, None);
assert_eq!(parsed.kind, SkillKind::Workflow);
assert_eq!(parsed.name, "legacy");
}
#[test]
fn serde_round_trip_with_description() {
let original = skill("# body").with_description(Some("affordance".to_owned()));
fn serde_round_trip_with_description_and_kind() {
let original = skill("# body")
.with_description(Some("affordance".to_owned()))
.with_kind(SkillKind::Reference);
let json = serde_json::to_string(&original).unwrap();
let back: Skill = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
assert_eq!(back.description.as_deref(), Some("affordance"));
assert_eq!(back.kind, SkillKind::Reference);
}
}

View File

@ -165,7 +165,8 @@ pub fn catalogue() -> Vec<ToolDef> {
ToolDef {
name: "idea_list_agents",
description: "List the IdeA agents declared in the project's manifest. Returns the \
agents inline as a JSON array (id, name, profile, origin, …).",
agents inline as a JSON array (id, name, profile, origin, raw skills, \
and resolved capabilities).",
input_schema: json!({
"type": "object",
"properties": {},

View File

@ -35,7 +35,7 @@ use domain::ids::SkillId;
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
use domain::project::ProjectPath;
use domain::skill::{Skill, SkillScope};
use domain::skill::{Skill, SkillKind, SkillScope};
/// Directory (under app-data) holding the global skills store.
const GLOBAL_SKILLS_DIR: &str = "skills";
@ -63,6 +63,9 @@ struct IndexEntry {
/// field existed deserialise with `None` instead of failing.
#[serde(default)]
description: Option<String>,
/// Skill nature. Defaults to workflow for legacy index rows.
#[serde(default)]
kind: SkillKind,
content_hash: String,
}
@ -195,6 +198,7 @@ impl FsSkillStore {
scope,
)
.map(|skill| skill.with_description(entry.description.clone()))
.map(|skill| skill.with_kind(entry.kind))
.map_err(|e| StoreError::Serialization(e.to_string()))
}
}
@ -246,6 +250,7 @@ impl SkillStore for FsSkillStore {
id: skill.id,
name: skill.name.clone(),
description: skill.description.clone(),
kind: skill.kind,
content_hash: content_hash(&skill.content_md),
};
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
@ -340,9 +345,9 @@ mod tests {
}
#[tokio::test]
async fn legacy_index_without_description_loads_with_none() {
// A legacy `index.json` written before the `description` field existed must
// deserialise (thanks to `#[serde(default)]`) and load with `description: None`.
async fn legacy_index_without_description_loads_with_defaults() {
// A legacy `index.json` written before the `description`/`kind` fields
// existed must deserialise through additive defaults.
let fs = MemFs::arc();
let id = uuid::Uuid::from_u128(42);
let legacy_index = format!(
@ -367,14 +372,16 @@ mod tests {
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "legacy");
assert_eq!(listed[0].description, None);
assert_eq!(listed[0].kind, SkillKind::Workflow);
assert_eq!(listed[0].content_md.as_str(), "# legacy body");
}
#[tokio::test]
async fn save_then_load_round_trips_description() {
async fn save_then_load_round_trips_description_and_kind() {
let store = FsSkillStore::new(MemFs::arc(), "/app");
let s = skill(1, "refactor", "# Refactor\n\nbody")
.with_description(Some("Refactors code".to_owned()));
.with_description(Some("Refactors code".to_owned()))
.with_kind(SkillKind::Reference);
store.save(&s, &root()).await.unwrap();
@ -386,6 +393,7 @@ mod tests {
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].description.as_deref(), Some("Refactors code"));
assert_eq!(listed[0].kind, SkillKind::Reference);
}
#[tokio::test]

View File

@ -3422,7 +3422,7 @@ async fn invoke_create_agent_from_template(
.create_agent_from_template
.execute(request.into_input(project)?)
.await
.map(|out| AgentDto(out.agent))
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}