feat(skills): capacités agent découvrables via idea_list_agents

Ajoute SkillKind (Workflow/Reference) sur Skill, extrait le use case
ResolveAgentCapabilities à partir des SkillRef assignés, et enrichit
idea_list_agents d'un champ additif capabilities (name, description,
kind) — remplace l'exposition de SkillRef opaques par un inventaire de
capacités interrogeable, source commune avec le bloc « Skills
disponibles » injecté au lancement (#119).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:26:56 +02:00
parent 5efb026a80
commit 92b17e9a69
18 changed files with 467 additions and 83 deletions

View File

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

View File

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

View File

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