Skip to main content

defect_agent/tool/
skill.rs

1//! `skill`: loads the full specification of a skill into the current conversation.
2//!
3//! A skill is a user-configurable reusable prompt fragment — a markdown body plus
4//! optional
5//! `scripts/` / `refs/` resources in the same directory. The model sees skill contents in
6//! three
7//! layers via progressive disclosure:
8//!
9//! - **L1 manifest**: all skills' `name + description`. This tool embeds them into its
10//!   own
11//!   `description` (same pattern as `spawn_agent` embedding the profile catalog), so the
12//!   model
13//!   knows which skills are available from startup. Optionally also injected into the
14//!   system
15//!   prompt by `crate::hooks::builtin::SkillManifestHook`.
16//! - **L2 body**: the full `SKILL.md` fetched by name when the model calls this tool,
17//!   arriving
18//!   as a tool result — the model then works according to the instructions **within the
19//!   current
20//!   conversation** (unlike `spawn_agent` which spawns an isolated sub-session).
21//! - **L3 attachments**: `scripts/*.sh` / `refs/*.md` referenced in the body, read on
22//!   demand by
23//!   the model via ordinary `bash` / `read_file` tools — the tool result includes the
24//!   absolute
25//!   skill directory path for constructing paths.
26//!
27//! This tool is a pure [`Tool`] implementation with `safety_hint = ReadOnly` (only
28//! queries the
29//! in-memory loaded skill index, no disk writes, no network access), treated identically
30//! to other
31//! built-in tools.
32
33use std::collections::BTreeMap;
34use std::path::PathBuf;
35use std::pin::Pin;
36use std::sync::Arc;
37
38use agent_client_protocol_schema::{
39    Content, ContentBlock, TextContent, ToolCallContent, ToolCallUpdateFields, ToolKind,
40};
41use futures::future::BoxFuture;
42use serde::Deserialize;
43use serde_json::json;
44
45use crate::error::BoxError;
46use crate::tool::{
47    SafetyClass, Tool, ToolCallDescription, ToolContext, ToolError, ToolEvent, ToolSchema,
48    ToolStream,
49};
50
51/// The name of the `skill` tool.
52pub(crate) const SKILL_TOOL_NAME: &str = "skill";
53
54/// Auto-activation triggers for a skill (the `triggers` sub-table of the Agent Skills
55/// open-standard).
56///
57/// Defined on the agent side, populated and reused by `defect-config` during parsing
58/// (dependency direction: config → agent, not reversible). `globs` is compiled into a
59/// [`globset::GlobSet`] at config parse time—invalid globs fail fast immediately, no
60/// runtime parsing; `None` when no globs are configured. See
61/// `crate::hooks::builtin::SkillTriggersHook` for matching logic.
62#[derive(Debug, Clone, Default)]
63pub struct SkillTriggers {
64    /// Compiled file-path glob set; `None` means no globs were configured.
65    pub globs: Option<globset::GlobSet>,
66    /// Prompt keywords (case-insensitive substring matching).
67    pub keywords: Vec<String>,
68}
69
70/// A skill that can be loaded by the `skill` tool (agent-side representation).
71///
72/// `SkillSpec` in `defect-config` is the configuration-side source of truth; during CLI
73/// assembly it is projected into this struct before being handed to the tool. The two are
74/// kept separate because `defect-config` depends on `defect-agent` — a reverse dependency
75/// would create a cycle (same boundary as [`crate::tool::SubagentProfile`] /
76/// `ProfileSpec`).
77#[derive(Debug, Clone)]
78pub struct SkillEntry {
79    /// Description shown in the selection phase, included in the L1 manifest (the catalog
80    /// of tool descriptions).
81    pub description: String,
82    /// The full body of `SKILL.md` after stripping frontmatter — returned to the model
83    /// during L2 loading.
84    pub body: String,
85    /// Absolute path to the skill directory, backfilled in L2 tool results so the model
86    /// can construct absolute paths to resources like `scripts/` / `refs/` for `bash` /
87    /// `read_file`.
88    pub dir: PathBuf,
89    /// `always: true` ⇒ body is directly appended to the system prompt at session start
90    /// (always-on; see `crate::hooks::builtin::SkillManifestHook`).
91    pub always: bool,
92    /// Automatic activation triggers (by file glob or prompt keyword); see
93    /// [`SkillTriggers`].
94    pub triggers: SkillTriggers,
95}
96
97/// The `skill` tool. It is registered on `StaticToolRegistry` and shared across sessions
98/// of the owning `AgentCore` via `process_tools` (it is **not** a process-global
99/// singleton—a single process may host multiple `AgentCore` instances, each with its own
100/// skill index).
101pub struct SkillTool {
102    schema: ToolSchema,
103    skills: Arc<BTreeMap<String, SkillEntry>>,
104}
105
106impl SkillTool {
107    /// Constructs a `skill` tool. When `skills` is empty, callers **should not** register
108    /// this tool
109    /// (the schema's `name` enum will be empty, so it will always fail) — see
110    /// [`Self::has_skills`].
111    pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
112        let schema = build_schema(&skills);
113        Self { schema, skills }
114    }
115
116    /// Whether any skills were discovered. The assembler uses this to decide whether to
117    /// register this tool.
118    pub fn has_skills(skills: &BTreeMap<String, SkillEntry>) -> bool {
119        !skills.is_empty()
120    }
121}
122
123/// Dynamically builds the schema: `name` is an enum of discovered skill names (hard
124/// constraint), and the tool description embeds a catalog of `- <name>: <description>`
125/// entries (soft guidance, i.e. an L1 manifest). Both are required: the enum alone gives
126/// the model no context for usage, while the catalog alone risks the model misspelling
127/// names (same rationale as [`crate::tool::SpawnAgentTool`]'s `build_schema`).
128fn build_schema(skills: &BTreeMap<String, SkillEntry>) -> ToolSchema {
129    let names: Vec<&str> = skills.keys().map(String::as_str).collect();
130    let catalog = skills
131        .iter()
132        .map(|(name, s)| format!("- {name}: {}", s.description))
133        .collect::<Vec<_>>()
134        .join("\n");
135    let description = format!(
136        "Load the full instructions for a specialized skill into the current conversation. \
137         Use this when the task at hand matches one of the skills below; the loaded content may \
138         contain detailed workflow guidance plus references to scripts / files in the skill's \
139         directory that you can then read with `bash` / `read_file`. After loading, carry out the \
140         task in this same conversation.\n\n\
141         Available skills:\n{catalog}"
142    );
143    ToolSchema {
144        name: SKILL_TOOL_NAME.to_string(),
145        description,
146        input_schema: json!({
147            "type": "object",
148            "properties": {
149                "name": {
150                    "type": "string",
151                    "enum": names,
152                    "description": "Which skill to load. See the tool description for what each skill does."
153                }
154            },
155            "required": ["name"]
156        }),
157    }
158}
159
160#[derive(Debug, Deserialize)]
161struct SkillArgs {
162    name: String,
163}
164
165impl Tool for SkillTool {
166    fn schema(&self) -> &ToolSchema {
167        &self.schema
168    }
169
170    fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
171        // Only queries the in-memory skill index and feeds the body text back to the
172        // model — no disk writes, no network access.
173        SafetyClass::ReadOnly
174    }
175
176    fn describe<'a>(
177        &'a self,
178        args: &'a serde_json::Value,
179        _ctx: ToolContext<'a>,
180    ) -> BoxFuture<'a, ToolCallDescription> {
181        Box::pin(async move {
182            let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("?");
183            let mut fields = ToolCallUpdateFields::default();
184            fields.title = Some(format!("Load skill `{name}`"));
185            fields.kind = Some(ToolKind::Think);
186            ToolCallDescription { fields }
187        })
188    }
189
190    fn execute(&self, args: serde_json::Value, _ctx: ToolContext<'_>) -> ToolStream {
191        let skills = self.skills.clone();
192        let fut = async move {
193            let parsed: SkillArgs = match serde_json::from_value(args) {
194                Ok(v) => v,
195                Err(err) => return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(err))),
196            };
197
198            let Some(skill) = skills.get(&parsed.name) else {
199                return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(io_err(format!(
200                    "unknown skill `{}`; available: {}",
201                    parsed.name,
202                    skills.keys().cloned().collect::<Vec<_>>().join(", ")
203                )))));
204            };
205
206            let output = render_skill(&parsed.name, skill);
207            let mut fields = ToolCallUpdateFields::default();
208            fields.content = Some(vec![ToolCallContent::Content(Content::new(
209                ContentBlock::Text(TextContent::new(output.clone())),
210            ))]);
211            // raw_output is for telemetry (the langfuse projector reads only raw_output
212            // as the observation output).
213            fields.raw_output = Some(serde_json::Value::String(output));
214            ToolEvent::Completed(fields)
215        };
216        let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> =
217            Box::pin(futures::stream::once(fut));
218        s
219    }
220}
221
222/// Compose the tool result text for an L2-loaded skill: title + directory hint + body.
223/// The directory hint tells the model the absolute root for resources like `scripts/` /
224/// `refs/` (analogous to opencode's "Base directory" line).
225fn render_skill(name: &str, skill: &SkillEntry) -> String {
226    format!(
227        "# Skill: {name}\n\n{body}\n\n\
228         Skill directory: {dir}\n\
229         Relative paths in this skill (e.g. scripts/, refs/) are relative to that directory; \
230         read them with `read_file` / `bash` as needed.",
231        body = skill.body,
232        dir = skill.dir.display(),
233    )
234}
235
236fn io_err(msg: String) -> std::io::Error {
237    std::io::Error::other(msg)
238}
239
240#[cfg(test)]
241mod tests;