feat(skill-awareness): T1→T5 — manifeste de skills + outil MCP idea_skill_read
Surface les skills assignés à un agent « à la MCP » : description sur l'entité Skill (+ effective_description fallback), section « # Skills disponibles » haute altitude dans le convention-file (mode MCP), outil read-only idea_skill_read (résolution projet→global), use case ReadSkill (port SkillStore existant), et câblage au composition root. Dump legacy du corps complet conservé en mode sans-MCP (zéro régression). Rétro-compat index.json (serde default). Tests : domain+application+infrastructure 1212 passed / 0 failed (23 ajoutés). Reste : T6 (champ description front) + T7 (e2e après rebuild AppImage). NB topologie : commit réalisé par l'orchestrateur car l'agent Git était injoignable (bug de livraison cold-start) ; à faire relire/rebaser par Git. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -51,7 +51,11 @@ pub enum ToolMapError {
|
||||
pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool,
|
||||
"idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read"
|
||||
"idea_ask_agent"
|
||||
| "idea_list_agents"
|
||||
| "idea_context_read"
|
||||
| "idea_memory_read"
|
||||
| "idea_skill_read"
|
||||
)
|
||||
}
|
||||
|
||||
@ -201,6 +205,21 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_skill_read",
|
||||
description: "Read the full body of an IdeA skill assigned to you, by name — call this \
|
||||
to actually execute/follow a skill listed in your « Skills disponibles » \
|
||||
section. Resolves project scope first, then global. Returns the skill's \
|
||||
Markdown inline.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Skill name (as listed in your context)." }
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_create_skill",
|
||||
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
|
||||
@ -320,6 +339,14 @@ pub fn map_tool_call(
|
||||
content: s("content"),
|
||||
..base()
|
||||
},
|
||||
"idea_skill_read" => OrchestratorRequest {
|
||||
request_type: Some("skill.read".to_owned()),
|
||||
// The requester (handshake identity) is carried for symmetry with the
|
||||
// other read tools; `validate` derives the requester party from it.
|
||||
requested_by: Some(requester.to_owned()),
|
||||
name: s("name"),
|
||||
..base()
|
||||
},
|
||||
"idea_create_skill" => OrchestratorRequest {
|
||||
request_type: Some("skill.create".to_owned()),
|
||||
name: s("name"),
|
||||
|
||||
@ -58,6 +58,11 @@ const INDEX_VERSION: u32 = 1;
|
||||
struct IndexEntry {
|
||||
id: SkillId,
|
||||
name: String,
|
||||
/// One-line affordance description (feature « skills à la MCP »). `#[serde(default)]`
|
||||
/// is **mandatory** for backward-compat: legacy `index.json` written before this
|
||||
/// field existed deserialise with `None` instead of failing.
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
content_hash: String,
|
||||
}
|
||||
|
||||
@ -189,6 +194,7 @@ impl FsSkillStore {
|
||||
MarkdownDoc::new(content),
|
||||
scope,
|
||||
)
|
||||
.map(|skill| skill.with_description(entry.description.clone()))
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
@ -239,6 +245,7 @@ impl SkillStore for FsSkillStore {
|
||||
let row = IndexEntry {
|
||||
id: skill.id,
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
content_hash: content_hash(&skill.content_md),
|
||||
};
|
||||
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
|
||||
@ -266,3 +273,129 @@ impl SkillStore for FsSkillStore {
|
||||
self.write_index(scope, root, &index).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ports::{DirEntry, FsError};
|
||||
|
||||
/// In-memory [`FileSystem`] mock (same minimal shape as the memory-store tests).
|
||||
#[derive(Default)]
|
||||
struct MemFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemFs {
|
||||
fn arc() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for MemFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_string()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.as_str().to_string(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/proj").unwrap()
|
||||
}
|
||||
|
||||
fn skill(id: u128, name: &str, body: &str) -> Skill {
|
||||
Skill::new(
|
||||
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
name,
|
||||
MarkdownDoc::new(body),
|
||||
SkillScope::Global,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[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`.
|
||||
let fs = MemFs::arc();
|
||||
let id = uuid::Uuid::from_u128(42);
|
||||
let legacy_index = format!(
|
||||
r#"{{"version":1,"skills":[{{"id":"{id}","name":"legacy","contentHash":"abc"}}]}}"#
|
||||
);
|
||||
fs.write(
|
||||
&RemotePath::new("/app/skills/index.json".to_owned()),
|
||||
legacy_index.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fs.write(
|
||||
&RemotePath::new(format!("/app/skills/md/{id}.md")),
|
||||
b"# legacy body",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let store = FsSkillStore::new(fs, "/app");
|
||||
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
|
||||
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].name, "legacy");
|
||||
assert_eq!(listed[0].description, None);
|
||||
assert_eq!(listed[0].content_md.as_str(), "# legacy body");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_load_round_trips_description() {
|
||||
let store = FsSkillStore::new(MemFs::arc(), "/app");
|
||||
let s = skill(1, "refactor", "# Refactor\n\nbody")
|
||||
.with_description(Some("Refactors code".to_owned()));
|
||||
|
||||
store.save(&s, &root()).await.unwrap();
|
||||
|
||||
let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
|
||||
assert_eq!(got.description.as_deref(), Some("Refactors code"));
|
||||
assert_eq!(got, s);
|
||||
|
||||
// Also surfaces through `list`.
|
||||
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].description.as_deref(), Some("Refactors code"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_without_description_loads_as_none() {
|
||||
let store = FsSkillStore::new(MemFs::arc(), "/app");
|
||||
let s = skill(2, "plain", "# Plain\n\nbody");
|
||||
|
||||
store.save(&s, &root()).await.unwrap();
|
||||
|
||||
let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
|
||||
assert_eq!(got.description, None);
|
||||
}
|
||||
}
|
||||
|
||||
@ -506,6 +506,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
"idea_stop_agent",
|
||||
"idea_update_context",
|
||||
"idea_create_skill",
|
||||
// Read a skill's body by name (feature « skills à la MCP », T6).
|
||||
"idea_skill_read",
|
||||
// FileGuard-mediated context/memory tools (cadrage C7).
|
||||
"idea_context_read",
|
||||
"idea_context_propose",
|
||||
@ -519,8 +521,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
}
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
11,
|
||||
"exactly the eleven idea_* tools (7 base + 4 FileGuard C7); got {names:?}"
|
||||
12,
|
||||
"exactly the twelve idea_* tools (7 base + idea_skill_read + 4 FileGuard C7); got {names:?}"
|
||||
);
|
||||
|
||||
// Every tool advertises an object input schema.
|
||||
|
||||
Reference in New Issue
Block a user