feat: implement Agent Skills specification support
Implements the Agent Skills specification (https://agentskills.io) for portable skill packages that give the agent new capabilities. Changes: - Add skills module with SKILL.md parser (YAML frontmatter + markdown body) - Implement skill discovery from ~/.g3/skills/, config extra_paths, and .g3/skills/ - Generate <available_skills> XML for system prompt injection - Add SkillsConfig to g3-config with enabled flag and extra_paths - Wire skills discovery into CLI startup - Add 29 unit tests for parser, discovery, and prompt generation - Update README with Agent Skills documentation Skill locations (priority order): 1. ~/.g3/skills/ (global) 2. Config extra_paths 3. .g3/skills/ (workspace, highest priority) 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.
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)
|
- **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
|
- **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
|
### Provider Flexibility
|
||||||
- Support for multiple LLM providers through a unified interface
|
- Support for multiple LLM providers through a unified interface
|
||||||
- Hot-swappable providers without code changes
|
- Hot-swappable providers without code changes
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Workspace Memory
|
# Workspace Memory
|
||||||
> Updated: 2026-02-02T03:53:06Z | Size: 16.9k chars
|
> Updated: 2026-02-04T01:34:05Z | Size: 18.9k chars
|
||||||
|
|
||||||
### Remember Tool Wiring
|
### Remember Tool Wiring
|
||||||
- `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()`
|
- `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()`
|
||||||
@@ -315,3 +315,48 @@ Verifies evidence in completed plan items deterministically.
|
|||||||
- Test reference: `tests/foo.rs::test_bar`
|
- Test reference: `tests/foo.rs::test_bar`
|
||||||
|
|
||||||
**Integration:** Called from `execute_plan_write()` when plan is complete and approved (line 828-833)
|
**Integration:** Called from `execute_plan_write()` when plan is complete and approved (line 828-833)
|
||||||
|
|
||||||
|
### 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::ui_writer::UiWriter;
|
||||||
use g3_core::Agent;
|
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::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::language_prompts::{get_language_prompts_for_workspace, get_agent_language_prompts_for_workspace_with_langs};
|
||||||
use crate::simple_output::SimpleOutput;
|
use crate::simple_output::SimpleOutput;
|
||||||
@@ -154,12 +154,16 @@ pub async fn run_agent_mode(
|
|||||||
system_prompt
|
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
|
// Combine all content for the agent's context
|
||||||
let combined_content = combine_project_content(
|
let combined_content = combine_project_content(
|
||||||
agents_content_opt,
|
agents_content_opt,
|
||||||
memory_content_opt,
|
memory_content_opt,
|
||||||
language_content,
|
language_content,
|
||||||
include_prompt,
|
include_prompt,
|
||||||
|
skills_content,
|
||||||
&workspace_dir,
|
&workspace_dir,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ use accumulative::run_accumulative_mode;
|
|||||||
use agent_mode::run_agent_mode;
|
use agent_mode::run_agent_mode;
|
||||||
use autonomous::run_autonomous;
|
use autonomous::run_autonomous;
|
||||||
use interactive::run_interactive;
|
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 simple_output::SimpleOutput;
|
||||||
use ui_writer_impl::ConsoleUiWriter;
|
use ui_writer_impl::ConsoleUiWriter;
|
||||||
use g3_core::ui_writer::UiWriter;
|
use g3_core::ui_writer::UiWriter;
|
||||||
@@ -117,8 +117,13 @@ pub async fn run() -> Result<()> {
|
|||||||
// Load configuration with CLI overrides
|
// Load configuration with CLI overrides
|
||||||
let config = load_config_with_cli_overrides(&cli)?;
|
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
|
// 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
|
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.
|
//! Reads AGENTS.md and workspace memory files from the workspace.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::template::process_template;
|
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.
|
/// Read AGENTS.md configuration from the workspace directory.
|
||||||
/// Returns formatted content with emoji prefix, or None if not found.
|
/// Returns formatted content with emoji prefix, or None if not found.
|
||||||
@@ -86,15 +89,16 @@ pub fn combine_project_content(
|
|||||||
memory_content: Option<String>,
|
memory_content: Option<String>,
|
||||||
language_content: Option<String>,
|
language_content: Option<String>,
|
||||||
include_prompt: Option<String>,
|
include_prompt: Option<String>,
|
||||||
|
skills_content: Option<String>,
|
||||||
workspace_dir: &Path,
|
workspace_dir: &Path,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
// Always include working directory to prevent LLM from hallucinating paths
|
// Always include working directory to prevent LLM from hallucinating paths
|
||||||
let cwd_info = format!("📂 Working Directory: {}", workspace_dir.display());
|
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)
|
// Include prompt comes BEFORE memory so memory is always last (most recent context)
|
||||||
let parts: Vec<String> = [
|
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()
|
.into_iter()
|
||||||
.flatten()
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -219,6 +255,7 @@ mod tests {
|
|||||||
Some("memory".to_string()),
|
Some("memory".to_string()),
|
||||||
Some("language".to_string()),
|
Some("language".to_string()),
|
||||||
None, // include_prompt
|
None, // include_prompt
|
||||||
|
None, // skills_content
|
||||||
&workspace,
|
&workspace,
|
||||||
);
|
);
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
@@ -232,7 +269,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_combine_project_content_partial() {
|
fn test_combine_project_content_partial() {
|
||||||
let workspace = std::path::PathBuf::from("/test/workspace");
|
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());
|
assert!(result.is_some());
|
||||||
let content = result.unwrap();
|
let content = result.unwrap();
|
||||||
assert!(content.contains("📂 Working Directory: /test/workspace"));
|
assert!(content.contains("📂 Working Directory: /test/workspace"));
|
||||||
@@ -242,7 +279,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_combine_project_content_all_none() {
|
fn test_combine_project_content_all_none() {
|
||||||
let workspace = std::path::PathBuf::from("/test/workspace");
|
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
|
// Now always returns Some because we always include the working directory
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
assert!(result.unwrap().contains("📂 Working Directory: /test/workspace"));
|
assert!(result.unwrap().contains("📂 Working Directory: /test/workspace"));
|
||||||
@@ -256,6 +293,7 @@ mod tests {
|
|||||||
Some("memory".to_string()),
|
Some("memory".to_string()),
|
||||||
Some("language".to_string()),
|
Some("language".to_string()),
|
||||||
Some("include_prompt".to_string()),
|
Some("include_prompt".to_string()),
|
||||||
|
None, // skills_content
|
||||||
&workspace,
|
&workspace,
|
||||||
);
|
);
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
@@ -272,6 +310,7 @@ mod tests {
|
|||||||
Some("MEMORY_CONTENT".to_string()),
|
Some("MEMORY_CONTENT".to_string()),
|
||||||
Some("LANGUAGE_CONTENT".to_string()),
|
Some("LANGUAGE_CONTENT".to_string()),
|
||||||
Some("INCLUDE_PROMPT_CONTENT".to_string()),
|
Some("INCLUDE_PROMPT_CONTENT".to_string()),
|
||||||
|
None, // skills_content
|
||||||
&workspace,
|
&workspace,
|
||||||
);
|
);
|
||||||
let content = result.unwrap();
|
let content = result.unwrap();
|
||||||
@@ -297,6 +336,7 @@ mod tests {
|
|||||||
Some("MEMORY".to_string()),
|
Some("MEMORY".to_string()),
|
||||||
Some("LANGUAGE".to_string()),
|
Some("LANGUAGE".to_string()),
|
||||||
None, // no include_prompt
|
None, // no include_prompt
|
||||||
|
None, // skills_content
|
||||||
&workspace,
|
&workspace,
|
||||||
);
|
);
|
||||||
let content = result.unwrap();
|
let content = result.unwrap();
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub struct Config {
|
|||||||
pub computer_control: ComputerControlConfig,
|
pub computer_control: ComputerControlConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub webdriver: WebDriverConfig,
|
pub webdriver: WebDriverConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: SkillsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider configuration with named configs per provider type
|
/// Provider configuration with named configs per provider type
|
||||||
@@ -193,6 +195,26 @@ pub struct WebDriverConfig {
|
|||||||
pub browser: WebDriverBrowser,
|
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 {
|
impl Default for AgentConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -257,6 +279,7 @@ impl Default for Config {
|
|||||||
},
|
},
|
||||||
computer_control: ComputerControlConfig::default(),
|
computer_control: ComputerControlConfig::default(),
|
||||||
webdriver: WebDriverConfig::default(),
|
webdriver: WebDriverConfig::default(),
|
||||||
|
skills: SkillsConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub mod tools;
|
|||||||
pub mod ui_writer;
|
pub mod ui_writer;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod webdriver_session;
|
pub mod webdriver_session;
|
||||||
|
pub mod skills;
|
||||||
|
|
||||||
pub use feedback_extraction::{
|
pub use feedback_extraction::{
|
||||||
extract_coach_feedback, ExtractedFeedback, FeedbackExtractionConfig, FeedbackSource,
|
extract_coach_feedback, ExtractedFeedback, FeedbackExtractionConfig, FeedbackSource,
|
||||||
@@ -42,7 +43,13 @@ pub use context_window::{ContextWindow, ThinResult, ThinScope};
|
|||||||
pub use pending_research::{PendingResearchManager, ResearchCompletionNotification, ResearchStatus};
|
pub use pending_research::{PendingResearchManager, ResearchCompletionNotification, ResearchStatus};
|
||||||
|
|
||||||
// Export agent prompt generation for CLI use
|
// 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)]
|
#[cfg(test)]
|
||||||
mod task_result_comprehensive_tests;
|
mod task_result_comprehensive_tests;
|
||||||
|
|||||||
@@ -300,30 +300,58 @@ write_file(\"helper.rs\", \"...\")
|
|||||||
// COMPOSED PROMPTS
|
// COMPOSED PROMPTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
use crate::skills::{Skill, generate_skills_prompt};
|
||||||
|
|
||||||
/// System prompt for providers with native tool calling (Anthropic, OpenAI, etc.)
|
/// System prompt for providers with native tool calling (Anthropic, OpenAI, etc.)
|
||||||
pub fn get_system_prompt_for_native() -> String {
|
pub fn get_system_prompt_for_native() -> 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);
|
||||||
|
let skills_part = if skills_section.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("\n\n{}", skills_section)
|
||||||
|
};
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}",
|
"{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}{}",
|
||||||
SHARED_INTRO,
|
SHARED_INTRO,
|
||||||
SHARED_PLAN_SECTION,
|
SHARED_PLAN_SECTION,
|
||||||
SHARED_TEMPORARY_FILES,
|
SHARED_TEMPORARY_FILES,
|
||||||
SHARED_WEB_RESEARCH,
|
SHARED_WEB_RESEARCH,
|
||||||
SHARED_WORKSPACE_MEMORY,
|
SHARED_WORKSPACE_MEMORY,
|
||||||
SHARED_RESPONSE_GUIDELINES
|
SHARED_RESPONSE_GUIDELINES,
|
||||||
|
skills_part
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System prompt for providers without native tool calling (embedded models)
|
/// System prompt for providers without native tool calling (embedded models)
|
||||||
pub fn get_system_prompt_for_non_native() -> String {
|
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 {
|
||||||
|
let skills_section = generate_skills_prompt(skills);
|
||||||
|
let skills_part = if skills_section.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("\n\n{}", skills_section)
|
||||||
|
};
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}",
|
"{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}{}",
|
||||||
SHARED_INTRO,
|
SHARED_INTRO,
|
||||||
NON_NATIVE_TOOL_FORMAT,
|
NON_NATIVE_TOOL_FORMAT,
|
||||||
NON_NATIVE_INSTRUCTIONS,
|
NON_NATIVE_INSTRUCTIONS,
|
||||||
SHARED_PLAN_SECTION,
|
SHARED_PLAN_SECTION,
|
||||||
SHARED_WEB_RESEARCH,
|
SHARED_WEB_RESEARCH,
|
||||||
SHARED_WORKSPACE_MEMORY,
|
SHARED_WORKSPACE_MEMORY,
|
||||||
SHARED_RESPONSE_GUIDELINES
|
SHARED_RESPONSE_GUIDELINES,
|
||||||
|
skills_part
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,9 +364,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.
|
/// The agent_prompt replaces only the G3 identity line at the start of the prompt.
|
||||||
/// Everything else (tool instructions, coding guidelines, etc.) is preserved.
|
/// Everything else (tool instructions, coding guidelines, etc.) is preserved.
|
||||||
pub fn get_agent_system_prompt(agent_prompt: &str, allow_multiple_tool_calls: bool) -> String {
|
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)
|
// Get the full system prompt (always allows multiple tool calls now)
|
||||||
let _ = allow_multiple_tool_calls; // Parameter kept for API compatibility but ignored
|
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
|
// Replace only the G3 identity line with the custom agent prompt
|
||||||
full_prompt.replace(G3_IDENTITY_LINE, agent_prompt.trim())
|
full_prompt.replace(G3_IDENTITY_LINE, agent_prompt.trim())
|
||||||
@@ -419,4 +455,50 @@ mod tests {
|
|||||||
assert!(native.contains("# Web Research"));
|
assert!(native.contains("# Web Research"));
|
||||||
assert!(non_native.contains("# Web Research"));
|
assert!(non_native.contains("# Web Research"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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