merge feature/ticket119-skills-agent-capabilities dans develop (#119: capacités agent découvrables via idea_list_agents)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:27:13 +02:00
21 changed files with 474 additions and 90 deletions

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,