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:
@ -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");
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user