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;