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:
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#119"
|
||||
version: 1
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1785534242629
|
||||
updatedAt: 1785536553069
|
||||
---
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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};
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {},
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user