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

@ -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,
};