diff --git a/.ideai/tickets/119/carnet.md b/.ideai/tickets/119/carnet.md index a583af2..36f97ec 100644 --- a/.ideai/tickets/119/carnet.md +++ b/.ideai/tickets/119/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#119" -version: 1 +version: 2 updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1785534242629 +updatedAt: 1785536553069 --- diff --git a/.ideai/tickets/119/issue.md b/.ideai/tickets/119/issue.md index 589b7ac..fc0ca43 100644 --- a/.ideai/tickets/119/issue.md +++ b/.ideai/tickets/119/issue.md @@ -2,7 +2,7 @@ id: "b76431d7-f3a3-438f-ae3f-5648f5eda8ce" number: 119 title: "Refondre le système de skills IdeA en capacités agent découvrables" -status: "open" +status: "inProgress" priority: "high" sprint: null links: [] @@ -11,8 +11,8 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1785534242629 -updatedAt: 1785534242629 -version: 1 +updatedAt: 1785536553069 +version: 2 --- ## Constat diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index f6318f2..c9bc00b 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1542,7 +1542,7 @@ "issueRef": "#119", "path": "119", "title": "Refondre le système de skills IdeA en capacités agent découvrables", - "status": "open", + "status": "inProgress", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -1550,7 +1550,7 @@ "kind": "agent", "agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641" }, - "updatedAt": 1785534242629 + "updatedAt": 1785536553069 }, { "issueRef": "#120", diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 6fccdab..ac2b2bb 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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) } diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 98948ba..f71dc41 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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()); } // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/tests/dto_change_agent_profile.rs b/crates/app-tauri/tests/dto_change_agent_profile.rs index ad8b182..07587b8 100644 --- a/crates/app-tauri/tests/dto_change_agent_profile.rs +++ b/crates/app-tauri/tests/dto_change_agent_profile.rs @@ -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(); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index eaf1aea..f33888d 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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, /// Resolved assigned skill bodies for fallback non-MCP injection. pub assigned_skills: Vec, /// 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, + /// Resolved discoverable capabilities per agent. + pub capabilities: Vec, +} + +/// 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, +} + +/// 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, +} + +impl ListAgentsOutput { + /// Returns the additive discovery shape used by inter-agent surfaces. + #[must_use] + pub fn discovery_entries(&self) -> Vec { + 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, + capabilities: Option>, } impl ListAgents { /// Builds the use case from the [`AgentContextStore`] port. #[must_use] pub fn new(contexts: Arc) -> Self { - Self { contexts } + Self { + contexts, + capabilities: None, + } + } + + /// Builds the use case with resolved agent capabilities enabled. + #[must_use] + pub fn with_capabilities( + contexts: Arc, + capabilities: Arc, + ) -> 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::, _>>()?; - 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, pty: Arc, assigned_skills: Arc, + agent_capabilities: Arc, sessions: Arc, events: Arc, ids: Arc, @@ -1268,13 +1353,18 @@ impl LaunchAgent { recall: Arc, embedder_suggestion: Option>, ) -> 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 -/// `**** — ` (affordances only, *no body*), with +/// `**** — ()` (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** — `. - assert!(doc.contains("**refactor** — Refactors code")); - assert!(doc.contains("**review** — Review skill")); + // Affordance lines: `**name** — ()`. + 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"); diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 5dbb49f..b470ca7 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -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, }; diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index db81457..e1cfb67 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 57f5752..3ef787d 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -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), }) } diff --git a/crates/application/src/skill/mod.rs b/crates/application/src/skill/mod.rs index 051e25e..b495bcd 100644 --- a/crates/application/src/skill/mod.rs +++ b/crates/application/src/skill/mod.rs @@ -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, }; diff --git a/crates/application/src/skill/usecases.rs b/crates/application/src/skill/usecases.rs index c388511..c5315f2 100644 --- a/crates/application/src/skill/usecases.rs +++ b/crates/application/src/skill/usecases.rs @@ -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, +} + +impl ResolveAgentCapabilities { + /// Builds the use case from a skill store. + #[must_use] + pub fn new(skills: Arc) -> Self { + Self { + resolver: Arc::new(AssignedSkillResolver::new(skills)), + } + } + + /// Builds the use case from a shared resolver. + #[must_use] + pub fn with_resolver(resolver: Arc) -> 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, 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, manifest: AgentManifest, diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 2e4c08b..205568a 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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)); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index d2a97a5..2fb6009 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1967,20 +1967,58 @@ pub fn parse_profile_id(raw: &str) -> Result { // --------------------------------------------------------------------------- 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 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, +} + +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); impl From 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 for AgentDto { fn from(out: CreateAgentOutput) -> Self { - Self(out.agent) + Self::from_agent(out.agent) } } @@ -2326,7 +2376,7 @@ pub struct ChangeAgentProfileDto { impl From 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), } } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 8160a49..9bd1d3b 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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, 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( diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index d9b42e8..5ddfa40 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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}; diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 39fd121..74f6172 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -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, } diff --git a/crates/domain/src/skill.rs b/crates/domain/src/skill.rs index e86d57e..7d59f33 100644 --- a/crates/domain/src/skill.rs +++ b/crates/domain/src/skill.rs @@ -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, + /// 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 { 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); } } diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 17e770e..e0a5435 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -165,7 +165,8 @@ pub fn catalogue() -> Vec { 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": {}, diff --git a/crates/infrastructure/src/store/skill.rs b/crates/infrastructure/src/store/skill.rs index d3d9bd5..c6f1c12 100644 --- a/crates/infrastructure/src/store/skill.rs +++ b/crates/infrastructure/src/store/skill.rs @@ -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, + /// 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] diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 1827ef8..00901b9 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -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) }