Merge feature/agent-skills-support: Agent Skills specification support
This commit is contained in:
37
README.md
37
README.md
@@ -109,6 +109,43 @@ These commands give you fine-grained control over context management, allowing y
|
||||
- **Code Search**: Embedded tree-sitter for syntax-aware code search (Rust, Python, JavaScript, TypeScript, Go, Java, C, C++) - see [Code Search Guide](docs/CODE_SEARCH.md)
|
||||
- **Final Output**: Formatted result presentation
|
||||
|
||||
### Agent Skills
|
||||
|
||||
g3 supports the [Agent Skills](https://agentskills.io) specification - an open format for portable skill packages that give the agent new capabilities.
|
||||
|
||||
**Skill Locations** (in priority order, later overrides earlier):
|
||||
1. Global: `~/.g3/skills/`
|
||||
2. Extra paths from config
|
||||
3. Workspace: `.g3/skills/` (highest priority)
|
||||
|
||||
**SKILL.md Format**:
|
||||
```yaml
|
||||
---
|
||||
name: pdf-processing # Required: 1-64 chars, lowercase + hyphens
|
||||
description: Extract text... # Required: 1-1024 chars, when to use
|
||||
license: Apache-2.0 # Optional
|
||||
compatibility: Requires git # Optional: environment requirements
|
||||
---
|
||||
|
||||
# PDF Processing
|
||||
|
||||
Detailed instructions for the agent...
|
||||
```
|
||||
|
||||
**Configuration** (in `g3.toml`):
|
||||
```toml
|
||||
[skills]
|
||||
enabled = true # Default: true
|
||||
extra_paths = ["/path/to/skills"] # Additional skill directories
|
||||
```
|
||||
|
||||
At startup, g3 scans skill directories and injects a summary into the system prompt. When the agent needs a skill, it reads the full `SKILL.md` using the `read_file` tool.
|
||||
|
||||
Each skill adds ~50-100 tokens to context (name + description + path). Skills can include:
|
||||
- `scripts/` - Executable code (Python, Bash, etc.)
|
||||
- `references/` - Additional documentation
|
||||
- `assets/` - Templates, data files
|
||||
|
||||
### Provider Flexibility
|
||||
- Support for multiple LLM providers through a unified interface
|
||||
- Hot-swappable providers without code changes
|
||||
|
||||
@@ -367,3 +367,48 @@ Orchestrates 7 g3 agents in sequence for codebase maintenance.
|
||||
**Pipeline Order**: euler → breaker → hopper → fowler → carmack → lamport → huffman
|
||||
**State Storage**: `analysis/sdlc/pipeline.json` (git-tracked)
|
||||
**CLI**: `studio sdlc run [-c N]`, `studio sdlc status`, `studio sdlc reset`
|
||||
|
||||
### Agent Skills Support
|
||||
Implements the Agent Skills specification (https://agentskills.io) for portable skill packages.
|
||||
|
||||
- `crates/g3-core/src/skills/mod.rs` [0..42] - module exports: `Skill`, `discover_skills`, `generate_skills_prompt`
|
||||
- `crates/g3-core/src/skills/parser.rs` [0..363]
|
||||
- `Skill` [11..30] - parsed skill struct with name, description, metadata, body, path
|
||||
- `Skill::parse()` [45..100] - parses SKILL.md content with YAML frontmatter
|
||||
- `Skill::from_file()` [95..105] - loads and parses from disk
|
||||
- `split_frontmatter()` [107..130] - extracts YAML between `---` delimiters
|
||||
- `validate_name()` [133..175] - validates 1-64 chars, lowercase+hyphens
|
||||
- `crates/g3-core/src/skills/discovery.rs` [0..268]
|
||||
- `discover_skills()` [28..65] - scans global, extra, workspace dirs in priority order
|
||||
- `load_skills_from_dir()` [68..100] - loads SKILL.md from subdirectories
|
||||
- `expand_tilde()` [120..125] - uses shellexpand for path expansion
|
||||
- `crates/g3-core/src/skills/prompt.rs` [0..140]
|
||||
- `generate_skills_prompt()` [12..40] - generates `<available_skills>` XML block
|
||||
- `escape_xml()` [42..48] - escapes special XML characters
|
||||
- `crates/g3-config/src/lib.rs`
|
||||
- `SkillsConfig` [180..200] - enabled flag, extra_paths vector
|
||||
- Config.skills field [13..14]
|
||||
- `crates/g3-cli/src/project_files.rs`
|
||||
- `discover_and_format_skills()` [180..210] - discovers skills and generates prompt section
|
||||
- `combine_project_content()` [87..110] - now includes skills_content parameter
|
||||
|
||||
**Skill Locations** (priority order):
|
||||
1. `~/.g3/skills/` (global)
|
||||
2. Config extra_paths
|
||||
3. `.g3/skills/` (workspace, highest priority)
|
||||
|
||||
**SKILL.md Format**:
|
||||
```yaml
|
||||
---
|
||||
name: skill-name # Required: 1-64 chars, lowercase + hyphens
|
||||
description: What it does # Required: 1-1024 chars
|
||||
license: Apache-2.0 # Optional
|
||||
compatibility: Requires X # Optional: max 500 chars
|
||||
metadata: # Optional: arbitrary key-value
|
||||
author: org
|
||||
allowed-tools: Bash Read # Optional/experimental
|
||||
---
|
||||
|
||||
# Skill Title
|
||||
Detailed instructions...
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ use tracing::debug;
|
||||
use g3_core::ui_writer::UiWriter;
|
||||
use g3_core::Agent;
|
||||
|
||||
use crate::project_files::{combine_project_content, read_agents_config, read_include_prompt, read_workspace_memory};
|
||||
use crate::project_files::{combine_project_content, discover_and_format_skills, read_agents_config, read_include_prompt, read_workspace_memory};
|
||||
use crate::display::{LoadedContent, print_loaded_status, print_workspace_path};
|
||||
use crate::language_prompts::{get_language_prompts_for_workspace, get_agent_language_prompts_for_workspace_with_langs};
|
||||
use crate::simple_output::SimpleOutput;
|
||||
@@ -173,12 +173,16 @@ pub async fn run_agent_mode(
|
||||
system_prompt
|
||||
};
|
||||
|
||||
// Discover skills from configured paths
|
||||
let (_skills, skills_content) = discover_and_format_skills(&workspace_dir, &config.skills);
|
||||
|
||||
// Combine all content for the agent's context
|
||||
let combined_content = combine_project_content(
|
||||
agents_content_opt,
|
||||
memory_content_opt,
|
||||
language_content,
|
||||
include_prompt,
|
||||
skills_content,
|
||||
&workspace_dir,
|
||||
);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ use accumulative::run_accumulative_mode;
|
||||
use agent_mode::run_agent_mode;
|
||||
use autonomous::run_autonomous;
|
||||
use interactive::run_interactive;
|
||||
use project_files::{combine_project_content, read_agents_config, read_include_prompt, read_workspace_memory};
|
||||
use project_files::{combine_project_content, discover_and_format_skills, read_agents_config, read_include_prompt, read_workspace_memory};
|
||||
use simple_output::SimpleOutput;
|
||||
use ui_writer_impl::ConsoleUiWriter;
|
||||
use g3_core::ui_writer::UiWriter;
|
||||
@@ -117,8 +117,13 @@ pub async fn run() -> Result<()> {
|
||||
// Load configuration with CLI overrides
|
||||
let config = load_config_with_cli_overrides(&cli)?;
|
||||
|
||||
// Discover skills from configured paths
|
||||
let (_skills, skills_content) = discover_and_format_skills(&workspace_dir, &config.skills);
|
||||
|
||||
// Combine AGENTS.md and memory content
|
||||
let combined_content = combine_project_content(agents_content, memory_content, language_content, include_prompt, &workspace_dir);
|
||||
let combined_content = combine_project_content(
|
||||
agents_content, memory_content, language_content, include_prompt, skills_content, &workspace_dir
|
||||
);
|
||||
|
||||
run_console_mode(cli, config, project, combined_content, workspace_dir).await
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
//! Reads AGENTS.md and workspace memory files from the workspace.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
|
||||
use crate::template::process_template;
|
||||
use g3_core::{discover_skills, generate_skills_prompt, Skill};
|
||||
use g3_config::SkillsConfig;
|
||||
|
||||
/// Read AGENTS.md configuration from the workspace directory.
|
||||
/// Returns formatted content with emoji prefix, or None if not found.
|
||||
@@ -86,15 +89,16 @@ pub fn combine_project_content(
|
||||
memory_content: Option<String>,
|
||||
language_content: Option<String>,
|
||||
include_prompt: Option<String>,
|
||||
skills_content: Option<String>,
|
||||
workspace_dir: &Path,
|
||||
) -> Option<String> {
|
||||
// Always include working directory to prevent LLM from hallucinating paths
|
||||
let cwd_info = format!("📂 Working Directory: {}", workspace_dir.display());
|
||||
|
||||
// Order: cwd → agents → language → include_prompt → memory
|
||||
// Order: cwd → agents → language → include_prompt → skills → memory
|
||||
// Include prompt comes BEFORE memory so memory is always last (most recent context)
|
||||
let parts: Vec<String> = [
|
||||
Some(cwd_info), agents_content, language_content, include_prompt, memory_content
|
||||
Some(cwd_info), agents_content, language_content, include_prompt, skills_content, memory_content
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -171,6 +175,38 @@ fn truncate_for_display(s: &str, max_len: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover skills from configured paths and generate the skills prompt.
|
||||
///
|
||||
/// Returns the skills prompt section if any skills are found, None otherwise.
|
||||
/// Skills are discovered from:
|
||||
/// 1. Global: ~/.g3/skills/
|
||||
/// 2. Extra paths from config
|
||||
/// 3. Workspace: .g3/skills/ (highest priority)
|
||||
pub fn discover_and_format_skills(
|
||||
workspace_dir: &Path,
|
||||
skills_config: &SkillsConfig,
|
||||
) -> (Vec<Skill>, Option<String>) {
|
||||
if !skills_config.enabled {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
|
||||
// Convert extra_paths from config to PathBuf
|
||||
let extra_paths: Vec<PathBuf> = skills_config
|
||||
.extra_paths
|
||||
.iter()
|
||||
.map(|p| PathBuf::from(p))
|
||||
.collect();
|
||||
|
||||
let skills = discover_skills(Some(workspace_dir), &extra_paths);
|
||||
|
||||
if skills.is_empty() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
|
||||
let prompt = generate_skills_prompt(&skills);
|
||||
(skills, Some(prompt))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -219,6 +255,7 @@ mod tests {
|
||||
Some("memory".to_string()),
|
||||
Some("language".to_string()),
|
||||
None, // include_prompt
|
||||
None, // skills_content
|
||||
&workspace,
|
||||
);
|
||||
assert!(result.is_some());
|
||||
@@ -232,7 +269,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_combine_project_content_partial() {
|
||||
let workspace = std::path::PathBuf::from("/test/workspace");
|
||||
let result = combine_project_content(None, Some("memory".to_string()), None, None, &workspace);
|
||||
let result = combine_project_content(None, Some("memory".to_string()), None, None, None, &workspace);
|
||||
assert!(result.is_some());
|
||||
let content = result.unwrap();
|
||||
assert!(content.contains("📂 Working Directory: /test/workspace"));
|
||||
@@ -242,7 +279,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_combine_project_content_all_none() {
|
||||
let workspace = std::path::PathBuf::from("/test/workspace");
|
||||
let result = combine_project_content(None, None, None, None, &workspace);
|
||||
let result = combine_project_content(None, None, None, None, None, &workspace);
|
||||
// Now always returns Some because we always include the working directory
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().contains("📂 Working Directory: /test/workspace"));
|
||||
@@ -256,6 +293,7 @@ mod tests {
|
||||
Some("memory".to_string()),
|
||||
Some("language".to_string()),
|
||||
Some("include_prompt".to_string()),
|
||||
None, // skills_content
|
||||
&workspace,
|
||||
);
|
||||
assert!(result.is_some());
|
||||
@@ -272,6 +310,7 @@ mod tests {
|
||||
Some("MEMORY_CONTENT".to_string()),
|
||||
Some("LANGUAGE_CONTENT".to_string()),
|
||||
Some("INCLUDE_PROMPT_CONTENT".to_string()),
|
||||
None, // skills_content
|
||||
&workspace,
|
||||
);
|
||||
let content = result.unwrap();
|
||||
@@ -297,6 +336,7 @@ mod tests {
|
||||
Some("MEMORY".to_string()),
|
||||
Some("LANGUAGE".to_string()),
|
||||
None, // no include_prompt
|
||||
None, // skills_content
|
||||
&workspace,
|
||||
);
|
||||
let content = result.unwrap();
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct Config {
|
||||
pub computer_control: ComputerControlConfig,
|
||||
#[serde(default)]
|
||||
pub webdriver: WebDriverConfig,
|
||||
#[serde(default)]
|
||||
pub skills: SkillsConfig,
|
||||
}
|
||||
|
||||
/// Provider configuration with named configs per provider type
|
||||
@@ -193,6 +195,26 @@ pub struct WebDriverConfig {
|
||||
pub browser: WebDriverBrowser,
|
||||
}
|
||||
|
||||
/// Skills configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillsConfig {
|
||||
/// Whether skills are enabled (default: true)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Additional paths to search for skills (beyond ~/.g3/skills and .g3/skills)
|
||||
#[serde(default)]
|
||||
pub extra_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for SkillsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
extra_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -257,6 +279,7 @@ impl Default for Config {
|
||||
},
|
||||
computer_control: ComputerControlConfig::default(),
|
||||
webdriver: WebDriverConfig::default(),
|
||||
skills: SkillsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod tools;
|
||||
pub mod ui_writer;
|
||||
pub mod utils;
|
||||
pub mod webdriver_session;
|
||||
pub mod skills;
|
||||
|
||||
pub use feedback_extraction::{
|
||||
extract_coach_feedback, ExtractedFeedback, FeedbackExtractionConfig, FeedbackSource,
|
||||
@@ -42,7 +43,13 @@ pub use context_window::{ContextWindow, ThinResult, ThinScope};
|
||||
pub use pending_research::{PendingResearchManager, ResearchCompletionNotification, ResearchStatus};
|
||||
|
||||
// Export agent prompt generation for CLI use
|
||||
pub use prompts::get_agent_system_prompt;
|
||||
pub use prompts::{
|
||||
get_agent_system_prompt, get_agent_system_prompt_with_skills,
|
||||
get_system_prompt_for_native_with_skills, get_system_prompt_for_non_native_with_skills,
|
||||
};
|
||||
|
||||
// Export skills module
|
||||
pub use skills::{Skill, discover_skills, generate_skills_prompt};
|
||||
|
||||
#[cfg(test)]
|
||||
mod task_result_comprehensive_tests;
|
||||
|
||||
@@ -118,17 +118,34 @@ write_file(\"helper.rs\", \"...\")
|
||||
// COMPOSED PROMPTS
|
||||
// ============================================================================
|
||||
|
||||
use crate::skills::{Skill, generate_skills_prompt};
|
||||
|
||||
/// System prompt for providers with native tool calling (Anthropic, OpenAI, etc.)
|
||||
/// Uses include_str! to embed the prompt at compile time.
|
||||
pub fn get_system_prompt_for_native() -> String {
|
||||
EMBEDDED_NATIVE_PROMPT.to_string()
|
||||
get_system_prompt_for_native_with_skills(&[])
|
||||
}
|
||||
|
||||
/// System prompt for providers with native tool calling, with skills support.
|
||||
pub fn get_system_prompt_for_native_with_skills(skills: &[Skill]) -> String {
|
||||
let skills_section = generate_skills_prompt(skills);
|
||||
if skills_section.is_empty() {
|
||||
EMBEDDED_NATIVE_PROMPT.to_string()
|
||||
} else {
|
||||
format!("{}\n\n{}", EMBEDDED_NATIVE_PROMPT, skills_section)
|
||||
}
|
||||
}
|
||||
|
||||
/// System prompt for providers without native tool calling (embedded models)
|
||||
pub fn get_system_prompt_for_non_native() -> String {
|
||||
get_system_prompt_for_non_native_with_skills(&[])
|
||||
}
|
||||
|
||||
/// System prompt for providers without native tool calling, with skills support.
|
||||
pub fn get_system_prompt_for_non_native_with_skills(skills: &[Skill]) -> String {
|
||||
// For non-native, we still need to inject the tool format instructions
|
||||
// We take the native prompt and insert the non-native sections after the intro
|
||||
let native = EMBEDDED_NATIVE_PROMPT;
|
||||
let native = get_system_prompt_for_native_with_skills(skills);
|
||||
|
||||
// Find the end of the intro section (after the first major heading)
|
||||
// The intro ends before "# Task Management with Plan Mode"
|
||||
@@ -163,9 +180,17 @@ const G3_IDENTITY_LINE: &str = "You are G3, an AI programming agent of the same
|
||||
/// The agent_prompt replaces only the G3 identity line at the start of the prompt.
|
||||
/// Everything else (tool instructions, coding guidelines, etc.) is preserved.
|
||||
pub fn get_agent_system_prompt(agent_prompt: &str, allow_multiple_tool_calls: bool) -> String {
|
||||
get_agent_system_prompt_with_skills(agent_prompt, allow_multiple_tool_calls, &[])
|
||||
}
|
||||
|
||||
/// Generate a system prompt for agent mode with skills support.
|
||||
///
|
||||
/// The agent_prompt replaces only the G3 identity line at the start of the prompt.
|
||||
/// Everything else (tool instructions, coding guidelines, skills, etc.) is preserved.
|
||||
pub fn get_agent_system_prompt_with_skills(agent_prompt: &str, allow_multiple_tool_calls: bool, skills: &[Skill]) -> String {
|
||||
// Get the full system prompt (always allows multiple tool calls now)
|
||||
let _ = allow_multiple_tool_calls; // Parameter kept for API compatibility but ignored
|
||||
let full_prompt = get_system_prompt_for_native();
|
||||
let full_prompt = get_system_prompt_for_native_with_skills(skills);
|
||||
|
||||
// Replace only the G3 identity line with the custom agent prompt
|
||||
full_prompt.replace(G3_IDENTITY_LINE, agent_prompt.trim())
|
||||
@@ -254,4 +279,50 @@ mod tests {
|
||||
assert!(!prompt.is_empty(), "Embedded prompt should not be empty");
|
||||
assert!(prompt.starts_with("# G3 System Prompt"), "Prompt should start with header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_prompt_without_skills() {
|
||||
let prompt = get_system_prompt_for_native_with_skills(&[]);
|
||||
assert!(!prompt.contains("<available_skills>"));
|
||||
assert!(!prompt.contains("# Available Skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_prompt_with_skills() {
|
||||
let skills = vec![Skill {
|
||||
name: "test-skill".to_string(),
|
||||
description: "A test skill for unit testing".to_string(),
|
||||
license: None,
|
||||
compatibility: None,
|
||||
metadata: None,
|
||||
allowed_tools: None,
|
||||
body: String::new(),
|
||||
path: "/path/to/test-skill/SKILL.md".to_string(),
|
||||
}];
|
||||
|
||||
let prompt = get_system_prompt_for_native_with_skills(&skills);
|
||||
assert!(prompt.contains("# Available Skills"));
|
||||
assert!(prompt.contains("<available_skills>"));
|
||||
assert!(prompt.contains("<name>test-skill</name>"));
|
||||
assert!(prompt.contains("<description>A test skill for unit testing</description>"));
|
||||
assert!(prompt.contains("<location>/path/to/test-skill/SKILL.md</location>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_prompt_with_skills() {
|
||||
let skills = vec![Skill {
|
||||
name: "agent-skill".to_string(),
|
||||
description: "Skill for agent mode".to_string(),
|
||||
license: None,
|
||||
compatibility: None,
|
||||
metadata: None,
|
||||
allowed_tools: None,
|
||||
body: String::new(),
|
||||
path: "/path/to/SKILL.md".to_string(),
|
||||
}];
|
||||
|
||||
let prompt = get_agent_system_prompt_with_skills("Custom agent", true, &skills);
|
||||
assert!(prompt.contains("Custom agent"));
|
||||
assert!(prompt.contains("<name>agent-skill</name>"));
|
||||
}
|
||||
}
|
||||
|
||||
269
crates/g3-core/src/skills/discovery.rs
Normal file
269
crates/g3-core/src/skills/discovery.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Skill discovery - scans directories for SKILL.md files.
|
||||
//!
|
||||
//! Discovers skills from:
|
||||
//! - Global: ~/.g3/skills/
|
||||
//! - Workspace: .g3/skills/
|
||||
//!
|
||||
//! Workspace skills override global skills with the same name.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::parser::Skill;
|
||||
|
||||
/// Default global skills directory
|
||||
const GLOBAL_SKILLS_DIR: &str = "~/.g3/skills";
|
||||
|
||||
/// Default workspace skills directory (relative to workspace root)
|
||||
const WORKSPACE_SKILLS_DIR: &str = ".g3/skills";
|
||||
|
||||
/// Discover all available skills from configured paths.
|
||||
///
|
||||
/// Skills are loaded from:
|
||||
/// 1. Global directory (~/.g3/skills/)
|
||||
/// 2. Workspace directory (.g3/skills/)
|
||||
///
|
||||
/// Workspace skills override global skills with the same name.
|
||||
/// Additional paths can be provided via `extra_paths`.
|
||||
pub fn discover_skills(
|
||||
workspace_dir: Option<&Path>,
|
||||
extra_paths: &[PathBuf],
|
||||
) -> Vec<Skill> {
|
||||
let mut skills_by_name: HashMap<String, Skill> = HashMap::new();
|
||||
|
||||
// 1. Load global skills first (lowest priority)
|
||||
let global_dir = expand_tilde(GLOBAL_SKILLS_DIR);
|
||||
if global_dir.exists() {
|
||||
debug!("Scanning global skills directory: {}", global_dir.display());
|
||||
load_skills_from_dir(&global_dir, &mut skills_by_name);
|
||||
}
|
||||
|
||||
// 2. Load from extra paths (medium priority)
|
||||
for path in extra_paths {
|
||||
let expanded = if path.starts_with("~") {
|
||||
expand_tilde(&path.to_string_lossy())
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
if expanded.exists() {
|
||||
debug!("Scanning extra skills directory: {}", expanded.display());
|
||||
load_skills_from_dir(&expanded, &mut skills_by_name);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load workspace skills last (highest priority - overrides others)
|
||||
if let Some(workspace) = workspace_dir {
|
||||
let workspace_skills = workspace.join(WORKSPACE_SKILLS_DIR);
|
||||
if workspace_skills.exists() {
|
||||
debug!("Scanning workspace skills directory: {}", workspace_skills.display());
|
||||
load_skills_from_dir(&workspace_skills, &mut skills_by_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted vector for deterministic ordering
|
||||
let mut skills: Vec<Skill> = skills_by_name.into_values().collect();
|
||||
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
debug!("Discovered {} skills", skills.len());
|
||||
skills
|
||||
}
|
||||
|
||||
/// Load skills from a directory into the map.
|
||||
/// Each subdirectory should contain a SKILL.md file.
|
||||
fn load_skills_from_dir(dir: &Path, skills: &mut HashMap<String, Skill>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
warn!("Failed to read skills directory {}: {}", dir.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip non-directories
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for SKILL.md in this directory
|
||||
let skill_file = path.join("SKILL.md");
|
||||
if !skill_file.exists() {
|
||||
// Also check for lowercase variant
|
||||
let skill_file_lower = path.join("skill.md");
|
||||
if skill_file_lower.exists() {
|
||||
load_skill_file(&skill_file_lower, skills);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
load_skill_file(&skill_file, skills);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a single skill file and add to the map.
|
||||
fn load_skill_file(path: &Path, skills: &mut HashMap<String, Skill>) {
|
||||
match Skill::from_file(path) {
|
||||
Ok(skill) => {
|
||||
let name = skill.name.clone();
|
||||
if skills.contains_key(&name) {
|
||||
debug!("Skill '{}' overridden by {}", name, path.display());
|
||||
}
|
||||
skills.insert(name, skill);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse skill {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand tilde in path to home directory.
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
let expanded = shellexpand::tilde(path);
|
||||
PathBuf::from(expanded.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_skill_dir(parent: &Path, name: &str, description: &str) -> PathBuf {
|
||||
let skill_dir = parent.join(name);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
|
||||
let content = format!(
|
||||
"---\nname: {}\ndescription: {}\n---\n\n# {}\n\nSkill body.",
|
||||
name, description, name
|
||||
);
|
||||
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
|
||||
|
||||
skill_dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_from_workspace() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let workspace = temp.path();
|
||||
|
||||
// Create workspace skills directory
|
||||
let skills_dir = workspace.join(".g3/skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
create_skill_dir(&skills_dir, "test-skill", "A test skill");
|
||||
create_skill_dir(&skills_dir, "another-skill", "Another skill");
|
||||
|
||||
let skills = discover_skills(Some(workspace), &[]);
|
||||
|
||||
assert_eq!(skills.len(), 2);
|
||||
assert_eq!(skills[0].name, "another-skill"); // Sorted alphabetically
|
||||
assert_eq!(skills[1].name, "test-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_from_extra_paths() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let extra_dir = temp.path().join("extra-skills");
|
||||
fs::create_dir_all(&extra_dir).unwrap();
|
||||
|
||||
create_skill_dir(&extra_dir, "extra-skill", "An extra skill");
|
||||
|
||||
let skills = discover_skills(None, &[extra_dir]);
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "extra-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_overrides_extra() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let workspace = temp.path();
|
||||
|
||||
// Create extra skills directory
|
||||
let extra_dir = temp.path().join("extra");
|
||||
fs::create_dir_all(&extra_dir).unwrap();
|
||||
create_skill_dir(&extra_dir, "shared-skill", "Extra version");
|
||||
|
||||
// Create workspace skills directory with same skill name
|
||||
let workspace_skills = workspace.join(".g3/skills");
|
||||
fs::create_dir_all(&workspace_skills).unwrap();
|
||||
create_skill_dir(&workspace_skills, "shared-skill", "Workspace version");
|
||||
|
||||
let skills = discover_skills(Some(workspace), &[extra_dir]);
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "shared-skill");
|
||||
assert_eq!(skills[0].description, "Workspace version");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonexistent_directory() {
|
||||
let skills = discover_skills(Some(Path::new("/nonexistent/path")), &[]);
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_directory() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let skills_dir = temp.path().join(".g3/skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
let skills = discover_skills(Some(temp.path()), &[]);
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_skill_skipped() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let skills_dir = temp.path().join(".g3/skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
// Create valid skill
|
||||
create_skill_dir(&skills_dir, "valid-skill", "Valid");
|
||||
|
||||
// Create invalid skill (missing description)
|
||||
let invalid_dir = skills_dir.join("invalid-skill");
|
||||
fs::create_dir_all(&invalid_dir).unwrap();
|
||||
fs::write(
|
||||
invalid_dir.join("SKILL.md"),
|
||||
"---\nname: invalid-skill\n---\n\nNo description."
|
||||
).unwrap();
|
||||
|
||||
let skills = discover_skills(Some(temp.path()), &[]);
|
||||
|
||||
// Only valid skill should be loaded
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "valid-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lowercase_skill_md() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let skills_dir = temp.path().join(".g3/skills");
|
||||
let skill_dir = skills_dir.join("lowercase-skill");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
|
||||
// Use lowercase skill.md
|
||||
fs::write(
|
||||
skill_dir.join("skill.md"),
|
||||
"---\nname: lowercase-skill\ndescription: Uses lowercase filename\n---\n\nBody."
|
||||
).unwrap();
|
||||
|
||||
let skills = discover_skills(Some(temp.path()), &[]);
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "lowercase-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tilde() {
|
||||
let expanded = expand_tilde("~/test/path");
|
||||
assert!(!expanded.to_string_lossy().starts_with('~'));
|
||||
|
||||
let no_tilde = expand_tilde("/absolute/path");
|
||||
assert_eq!(no_tilde, PathBuf::from("/absolute/path"));
|
||||
}
|
||||
}
|
||||
42
crates/g3-core/src/skills/mod.rs
Normal file
42
crates/g3-core/src/skills/mod.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Agent Skills support for G3.
|
||||
//!
|
||||
//! Implements the Agent Skills specification (https://agentskills.io)
|
||||
//! for discovering and using portable skill packages.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Skills are packages of instructions that give the agent new capabilities.
|
||||
//! Each skill is a directory containing a `SKILL.md` file with:
|
||||
//! - YAML frontmatter (name, description, metadata)
|
||||
//! - Markdown body with detailed instructions
|
||||
//!
|
||||
//! # Directory Structure
|
||||
//!
|
||||
//! ```text
|
||||
//! skill-name/
|
||||
//! ├── SKILL.md # Required: instructions + metadata
|
||||
//! ├── scripts/ # Optional: executable code
|
||||
//! ├── references/ # Optional: additional documentation
|
||||
//! └── assets/ # Optional: templates, data files
|
||||
//! ```
|
||||
//!
|
||||
//! # Discovery
|
||||
//!
|
||||
//! Skills are discovered from:
|
||||
//! 1. Global: `~/.g3/skills/` (lowest priority)
|
||||
//! 2. Extra paths from config (medium priority)
|
||||
//! 3. Workspace: `.g3/skills/` (highest priority, overrides others)
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! At startup, g3 scans skill directories and injects a summary into the
|
||||
//! system prompt. When the agent needs a skill, it reads the full SKILL.md
|
||||
//! using the `read_file` tool.
|
||||
|
||||
mod parser;
|
||||
mod discovery;
|
||||
mod prompt;
|
||||
|
||||
pub use parser::Skill;
|
||||
pub use discovery::discover_skills;
|
||||
pub use prompt::generate_skills_prompt;
|
||||
363
crates/g3-core/src/skills/parser.rs
Normal file
363
crates/g3-core/src/skills/parser.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
//! SKILL.md parser for Agent Skills specification.
|
||||
//!
|
||||
//! Parses YAML frontmatter and markdown body from SKILL.md files.
|
||||
//! See: https://agentskills.io/specification
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// A parsed Agent Skill from a SKILL.md file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Skill {
|
||||
/// Skill name (1-64 chars, lowercase alphanumeric + hyphens)
|
||||
pub name: String,
|
||||
/// Description of what the skill does and when to use it (1-1024 chars)
|
||||
pub description: String,
|
||||
/// Optional license
|
||||
pub license: Option<String>,
|
||||
/// Optional compatibility requirements (max 500 chars)
|
||||
pub compatibility: Option<String>,
|
||||
/// Optional arbitrary metadata
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
/// Optional allowed tools (experimental)
|
||||
pub allowed_tools: Option<String>,
|
||||
/// The full markdown body (after frontmatter)
|
||||
pub body: String,
|
||||
/// Absolute path to the SKILL.md file
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Raw frontmatter structure for deserialization
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SkillFrontmatter {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
license: Option<String>,
|
||||
compatibility: Option<String>,
|
||||
metadata: Option<HashMap<String, String>>,
|
||||
#[serde(rename = "allowed-tools")]
|
||||
allowed_tools: Option<String>,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
/// Parse a SKILL.md file from its content and path.
|
||||
///
|
||||
/// The file must have YAML frontmatter delimited by `---` lines,
|
||||
/// with at least `name` and `description` fields.
|
||||
pub fn parse(content: &str, path: &Path) -> Result<Self> {
|
||||
let (frontmatter, body) = split_frontmatter(content)?;
|
||||
|
||||
let fm: SkillFrontmatter = serde_yaml::from_str(&frontmatter)
|
||||
.map_err(|e| anyhow!("Invalid YAML frontmatter in {}: {}", path.display(), e))?;
|
||||
|
||||
// Validate required fields
|
||||
let name = fm.name.ok_or_else(|| {
|
||||
anyhow!("Missing required 'name' field in {}", path.display())
|
||||
})?;
|
||||
|
||||
let description = fm.description.ok_or_else(|| {
|
||||
anyhow!("Missing required 'description' field in {}", path.display())
|
||||
})?;
|
||||
|
||||
// Validate name format: 1-64 chars, lowercase alphanumeric + hyphens
|
||||
validate_name(&name, path)?;
|
||||
|
||||
// Validate description length: 1-1024 chars
|
||||
if description.is_empty() {
|
||||
return Err(anyhow!("Description cannot be empty in {}", path.display()));
|
||||
}
|
||||
if description.len() > 1024 {
|
||||
return Err(anyhow!(
|
||||
"Description exceeds 1024 characters ({} chars) in {}",
|
||||
description.len(),
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Validate compatibility length if present
|
||||
if let Some(ref compat) = fm.compatibility {
|
||||
if compat.len() > 500 {
|
||||
return Err(anyhow!(
|
||||
"Compatibility exceeds 500 characters ({} chars) in {}",
|
||||
compat.len(),
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
let abs_path = path.canonicalize()
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(Skill {
|
||||
name,
|
||||
description,
|
||||
license: fm.license,
|
||||
compatibility: fm.compatibility,
|
||||
metadata: fm.metadata,
|
||||
allowed_tools: fm.allowed_tools,
|
||||
body: body.to_string(),
|
||||
path: abs_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a SKILL.md file from disk.
|
||||
pub fn from_file(path: &Path) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow!("Failed to read {}: {}", path.display(), e))?;
|
||||
Self::parse(&content, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Split content into frontmatter and body.
|
||||
/// Frontmatter must be delimited by `---` lines at the start.
|
||||
fn split_frontmatter(content: &str) -> Result<(String, String)> {
|
||||
let content = content.trim_start();
|
||||
|
||||
// Must start with ---
|
||||
if !content.starts_with("---") {
|
||||
return Err(anyhow!("SKILL.md must start with YAML frontmatter (---)"));
|
||||
}
|
||||
|
||||
// Find the closing ---
|
||||
let after_first = &content[3..];
|
||||
let closing_pos = after_first.find("\n---")
|
||||
.ok_or_else(|| anyhow!("SKILL.md frontmatter not closed (missing ---)"))?;
|
||||
|
||||
let frontmatter = after_first[..closing_pos].trim();
|
||||
let body = after_first[closing_pos + 4..].trim_start(); // Skip "\n---"
|
||||
|
||||
if frontmatter.is_empty() {
|
||||
return Err(anyhow!("SKILL.md frontmatter is empty"));
|
||||
}
|
||||
|
||||
Ok((frontmatter.to_string(), body.to_string()))
|
||||
}
|
||||
|
||||
/// Validate skill name format.
|
||||
/// Must be 1-64 chars, lowercase alphanumeric + hyphens,
|
||||
/// no leading/trailing/consecutive hyphens.
|
||||
fn validate_name(name: &str, path: &Path) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
return Err(anyhow!("Skill name cannot be empty in {}", path.display()));
|
||||
}
|
||||
|
||||
if name.len() > 64 {
|
||||
return Err(anyhow!(
|
||||
"Skill name exceeds 64 characters ({} chars) in {}",
|
||||
name.len(),
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
for c in name.chars() {
|
||||
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
|
||||
return Err(anyhow!(
|
||||
"Skill name contains invalid character '{}' in {} (must be lowercase alphanumeric or hyphen)",
|
||||
c,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// No leading/trailing hyphens
|
||||
if name.starts_with('-') || name.ends_with('-') {
|
||||
return Err(anyhow!(
|
||||
"Skill name cannot start or end with hyphen in {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// No consecutive hyphens
|
||||
if name.contains("--") {
|
||||
return Err(anyhow!(
|
||||
"Skill name cannot contain consecutive hyphens in {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_path() -> PathBuf {
|
||||
PathBuf::from("/test/skill/SKILL.md")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_skill() {
|
||||
let content = r#"---
|
||||
name: pdf-processing
|
||||
description: Extract text and tables from PDF files using pdfplumber
|
||||
license: Apache-2.0
|
||||
compatibility: Requires Python 3.8+
|
||||
metadata:
|
||||
author: example-org
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# PDF Processing
|
||||
|
||||
Use this skill when working with PDF files.
|
||||
"#;
|
||||
|
||||
let skill = Skill::parse(content, &test_path()).unwrap();
|
||||
assert_eq!(skill.name, "pdf-processing");
|
||||
assert_eq!(skill.description, "Extract text and tables from PDF files using pdfplumber");
|
||||
assert_eq!(skill.license, Some("Apache-2.0".to_string()));
|
||||
assert_eq!(skill.compatibility, Some("Requires Python 3.8+".to_string()));
|
||||
assert!(skill.body.contains("# PDF Processing"));
|
||||
|
||||
let metadata = skill.metadata.unwrap();
|
||||
assert_eq!(metadata.get("author"), Some(&"example-org".to_string()));
|
||||
assert_eq!(metadata.get("version"), Some(&"1.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_skill() {
|
||||
let content = r#"---
|
||||
name: simple-skill
|
||||
description: A simple skill
|
||||
---
|
||||
|
||||
Body content here.
|
||||
"#;
|
||||
|
||||
let skill = Skill::parse(content, &test_path()).unwrap();
|
||||
assert_eq!(skill.name, "simple-skill");
|
||||
assert_eq!(skill.description, "A simple skill");
|
||||
assert!(skill.license.is_none());
|
||||
assert!(skill.compatibility.is_none());
|
||||
assert!(skill.metadata.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_name() {
|
||||
let content = r#"---
|
||||
description: A skill without a name
|
||||
---
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("Missing required 'name'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_description() {
|
||||
let content = r#"---
|
||||
name: no-description
|
||||
---
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("Missing required 'description'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_name_uppercase() {
|
||||
let content = r#"---
|
||||
name: Invalid-Name
|
||||
description: Has uppercase
|
||||
---
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("invalid character"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_name_leading_hyphen() {
|
||||
let content = r#"---
|
||||
name: -leading-hyphen
|
||||
description: Bad name
|
||||
---
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot start or end with hyphen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_name_consecutive_hyphens() {
|
||||
let content = r#"---
|
||||
name: double--hyphen
|
||||
description: Bad name
|
||||
---
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("consecutive hyphens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_frontmatter() {
|
||||
let content = "# Just markdown\n\nNo frontmatter here.";
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("must start with YAML frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unclosed_frontmatter() {
|
||||
let content = r#"---
|
||||
name: unclosed
|
||||
description: Missing closing delimiter
|
||||
|
||||
Body without closing ---
|
||||
"#;
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("not closed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_frontmatter() {
|
||||
let content = "---\n---\n\nBody.";
|
||||
|
||||
let err = Skill::parse(content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("frontmatter is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description_too_long() {
|
||||
let long_desc = "x".repeat(1025);
|
||||
let content = format!("---\nname: long-desc\ndescription: {}\n---\n\nBody.", long_desc);
|
||||
|
||||
let err = Skill::parse(&content, &test_path()).unwrap_err();
|
||||
assert!(err.to_string().contains("exceeds 1024 characters"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_tools_field() {
|
||||
let content = r#"---
|
||||
name: git-skill
|
||||
description: Git operations
|
||||
allowed-tools: Bash(git:*) Read
|
||||
---
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let skill = Skill::parse(content, &test_path()).unwrap();
|
||||
assert_eq!(skill.allowed_tools, Some("Bash(git:*) Read".to_string()));
|
||||
}
|
||||
}
|
||||
140
crates/g3-core/src/skills/prompt.rs
Normal file
140
crates/g3-core/src/skills/prompt.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Generate XML prompt section for available skills.
|
||||
//!
|
||||
//! Creates the `<available_skills>` XML block that gets injected into
|
||||
//! the system prompt to inform the agent about available skills.
|
||||
|
||||
use super::parser::Skill;
|
||||
|
||||
/// Generate the XML section for available skills.
|
||||
///
|
||||
/// Returns an empty string if no skills are available.
|
||||
/// The XML format follows the Agent Skills specification.
|
||||
pub fn generate_skills_prompt(skills: &[Skill]) -> String {
|
||||
if skills.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut xml = String::new();
|
||||
xml.push_str("# Available Skills\n\n");
|
||||
xml.push_str("You have access to the following skills. When a task matches a skill's description, \
|
||||
read the full skill file using `read_file` to get detailed instructions.\n\n");
|
||||
xml.push_str("<available_skills>\n");
|
||||
|
||||
for skill in skills {
|
||||
xml.push_str(" <skill>\n");
|
||||
xml.push_str(&format!(" <name>{}</name>\n", escape_xml(&skill.name)));
|
||||
xml.push_str(&format!(" <description>{}</description>\n", escape_xml(&skill.description)));
|
||||
xml.push_str(&format!(" <location>{}</location>\n", escape_xml(&skill.path)));
|
||||
|
||||
// Include compatibility info if present
|
||||
if let Some(ref compat) = skill.compatibility {
|
||||
xml.push_str(&format!(" <compatibility>{}</compatibility>\n", escape_xml(compat)));
|
||||
}
|
||||
|
||||
xml.push_str(" </skill>\n");
|
||||
}
|
||||
|
||||
xml.push_str("</available_skills>\n");
|
||||
xml
|
||||
}
|
||||
|
||||
/// Escape special XML characters.
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_skill(name: &str, description: &str, path: &str) -> Skill {
|
||||
Skill {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
license: None,
|
||||
compatibility: None,
|
||||
metadata: None,
|
||||
allowed_tools: None,
|
||||
body: String::new(),
|
||||
path: path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_skills() {
|
||||
let result = generate_skills_prompt(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_skill() {
|
||||
let skills = vec![
|
||||
make_skill("pdf-processing", "Extract text from PDFs", "/home/user/.g3/skills/pdf-processing/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("<available_skills>"));
|
||||
assert!(result.contains("</available_skills>"));
|
||||
assert!(result.contains("<name>pdf-processing</name>"));
|
||||
assert!(result.contains("<description>Extract text from PDFs</description>"));
|
||||
assert!(result.contains("<location>/home/user/.g3/skills/pdf-processing/SKILL.md</location>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_skills() {
|
||||
let skills = vec![
|
||||
make_skill("skill-a", "First skill", "/path/a/SKILL.md"),
|
||||
make_skill("skill-b", "Second skill", "/path/b/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("<name>skill-a</name>"));
|
||||
assert!(result.contains("<name>skill-b</name>"));
|
||||
// Should have exactly 2 skill blocks
|
||||
assert_eq!(result.matches("<skill>").count(), 2);
|
||||
assert_eq!(result.matches("</skill>").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xml_escaping() {
|
||||
let skills = vec![
|
||||
make_skill("test-skill", "Handle <special> & \"characters\"", "/path/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("<special>"));
|
||||
assert!(result.contains("&"));
|
||||
assert!(result.contains(""characters""));
|
||||
// Should not contain unescaped special chars in description
|
||||
assert!(!result.contains("<special>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_compatibility() {
|
||||
let mut skill = make_skill("docker-skill", "Docker operations", "/path/SKILL.md");
|
||||
skill.compatibility = Some("Requires Docker 20.0+".to_string());
|
||||
|
||||
let result = generate_skills_prompt(&[skill]);
|
||||
|
||||
assert!(result.contains("<compatibility>Requires Docker 20.0+</compatibility>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_text() {
|
||||
let skills = vec![
|
||||
make_skill("test", "Test skill", "/path/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("# Available Skills"));
|
||||
assert!(result.contains("read the full skill file using `read_file`"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user