`read_file` exempted SKILL.md from every limit, and both injection paths (`/skill` expansion and the `skill` tool) pasted the whole body. A 220 KB skill put 55k tokens into the context on each call. `cap_skill_body` cuts an over-cap body at the last line boundary that fits (a char boundary when the body is one long line) and appends a note that points at offset and limit. It runs before `apply_substitutions`, so the arguments land after the cut. Both injection paths call it and log the cut. `read_file` returns a SKILL.md whole only while it fits the cap. Over the cap it reads like any other file: offset and limit apply, and a window that is still too large answers FileTooLarge. Without that the note would send the model to a read that ignores its range. `skill_file_skips_token_limit` pinned the exemption; it gives way to `skill_file_over_cap_returns_file_too_large` and `skill_file_over_cap_honors_offset_and_limit`, as upstream did. Ported from upstream (grok-build tools skills/skill.rs cap_skill_body and read_file skill_full, 1.0.33). Upstream's `max_output_bytes` read budget in the same hunk is a feature and stays out.
910 lines
29 KiB
Rust
910 lines
29 KiB
Rust
//! `skill` tool — OpenCode variant of the skill tool.
|
|
//!
|
|
//! Loads a user-defined skill by name, reads its `SKILL.md` content,
|
|
//! lists up to 10 bundled files from the skill directory, and returns
|
|
//! the result wrapped in `<skill_content>` XML.
|
|
|
|
use std::path::Path;
|
|
|
|
use crate::implementations::skills::body_cap::cap_skill_body;
|
|
use crate::implementations::skills::skill::{SkillOutput, extract_skill_body, format_skill_name};
|
|
use crate::implementations::skills::types::SkillInfo;
|
|
use crate::types::requirements::{Expr, ToolRequirement};
|
|
#[allow(unused_imports)]
|
|
use crate::types::resources::{AvailableSkills, SharedResources};
|
|
use crate::types::tool::{ToolKind, ToolNamespace};
|
|
|
|
const DESCRIPTION: &str = r#"Load a specialized skill that provides domain-specific instructions and workflows.
|
|
|
|
When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.
|
|
|
|
The skill will inject detailed instructions, workflows, and access to bundled resources into the conversation via a `<skill_content name="...">` block with the loaded content.
|
|
|
|
The following skills provide specialized sets of instructions for particular tasks.
|
|
Invoke this tool to load a skill when a task matches one of the available skills listed below:
|
|
|
|
<available_skills>
|
|
${%- if skills %}
|
|
${%- for skill in skills %}
|
|
<skill>
|
|
<name>${{ skill.name }}</name>
|
|
<description>${{ skill.description|e }}</description>
|
|
<location>${{ skill.location }}</location>
|
|
</skill>
|
|
${%- endfor %}
|
|
${%- else %}
|
|
(No skills available. Skills can be added in ~/.kigi/skills/ or .kigi/skills/)
|
|
${%- endif %}
|
|
</available_skills>"#;
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct SkillInput {
|
|
#[schemars(description = "The name of the skill to invoke")]
|
|
pub name: String,
|
|
}
|
|
|
|
impl TryFrom<crate::types::tool_io::ToolInput> for SkillInput {
|
|
type Error = String;
|
|
fn try_from(value: crate::types::tool_io::ToolInput) -> Result<Self, Self::Error> {
|
|
match value {
|
|
crate::types::tool_io::ToolInput::Dynamic(v) => {
|
|
serde_json::from_value(v).map_err(|e| format!("SkillInput: {e}"))
|
|
}
|
|
_ => Err("expected Dynamic variant for SkillInput".into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SkillInput> for crate::types::tool_io::ToolInput {
|
|
fn from(value: SkillInput) -> Self {
|
|
crate::types::tool_io::ToolInput::Dynamic(
|
|
serde_json::to_value(value).expect("SkillInput serializes to JSON"),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// OpenCode skill tool — loads specialized skill instructions into the conversation.
|
|
#[derive(Debug, Default)]
|
|
pub struct SkillTool;
|
|
|
|
/// Result of looking up a skill by name.
|
|
#[derive(Debug)]
|
|
enum FindSkillResult<'a> {
|
|
Found(&'a SkillInfo),
|
|
/// Multiple skills share the same short name -- caller must use a qualified name.
|
|
Ambiguous(Vec<String>),
|
|
NotFound,
|
|
}
|
|
|
|
/// Find a skill by name from the available skills list.
|
|
///
|
|
/// Supports both fully-qualified names (`"local:commit"`) and
|
|
/// short names (`"commit"`).
|
|
///
|
|
/// When a short name matches multiple skills across scopes, returns
|
|
/// `Ambiguous` with the qualified names so the caller can ask for
|
|
/// disambiguation instead of silently picking first-match.
|
|
fn find_skill<'a>(name: &str, skills: &'a [SkillInfo]) -> FindSkillResult<'a> {
|
|
// A fully-qualified name matches at most one skill.
|
|
if let Some(skill) = skills
|
|
.iter()
|
|
.find(|s| s.enabled && format_skill_name(s) == name)
|
|
{
|
|
return FindSkillResult::Found(skill);
|
|
}
|
|
|
|
let matches: Vec<&SkillInfo> = skills
|
|
.iter()
|
|
.filter(|s| s.enabled && s.name == name)
|
|
.collect();
|
|
match matches.len() {
|
|
0 => FindSkillResult::NotFound,
|
|
1 => FindSkillResult::Found(matches[0]),
|
|
_ => {
|
|
let qualified: Vec<String> = matches.iter().map(|s| format_skill_name(s)).collect();
|
|
FindSkillResult::Ambiguous(qualified)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load skill content from its SKILL.md file, stripping YAML frontmatter.
|
|
async fn load_skill_content(skill: &SkillInfo) -> Result<String, String> {
|
|
let path = Path::new(&skill.path);
|
|
match tokio::fs::read_to_string(path).await {
|
|
Ok(content) => Ok(extract_skill_body(&content)),
|
|
Err(e) => Err(format!("Failed to read skill file '{}': {}", skill.path, e)),
|
|
}
|
|
}
|
|
|
|
/// List up to `limit` bundled files in the skill directory, excluding SKILL.md.
|
|
async fn list_skill_files(skill: &SkillInfo, limit: usize) -> Vec<String> {
|
|
let skill_path = Path::new(&skill.path);
|
|
let dir = match skill_path.parent() {
|
|
Some(d) => d,
|
|
None => return vec![],
|
|
};
|
|
|
|
let mut entries = match tokio::fs::read_dir(dir).await {
|
|
Ok(entries) => entries,
|
|
Err(_) => return vec![],
|
|
};
|
|
|
|
let mut files = Vec::new();
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
if files.len() >= limit {
|
|
break;
|
|
}
|
|
let path = entry.path();
|
|
if path
|
|
.file_name()
|
|
.is_some_and(|n| n.eq_ignore_ascii_case("SKILL.md"))
|
|
{
|
|
continue;
|
|
}
|
|
files.push(path.display().to_string());
|
|
}
|
|
files
|
|
}
|
|
|
|
/// Format the `<skill_content>` XML output block.
|
|
fn format_output(skill: &SkillInfo, content: &str, files: &[String]) -> String {
|
|
let base_dir = Path::new(&skill.path)
|
|
.parent()
|
|
.map(|p| format!("file://{}", p.display()))
|
|
.unwrap_or_default();
|
|
|
|
let files_xml: String = files
|
|
.iter()
|
|
.map(|f| format!("<file>{f}</file>"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
let mut out = String::new();
|
|
out.push_str(&format!("<skill_content name=\"{}\">\n", skill.name));
|
|
out.push_str(&format!("# Skill: {}\n\n", skill.name));
|
|
out.push_str(content.trim());
|
|
out.push_str("\n\n");
|
|
out.push_str(&format!("Base directory for this skill: {base_dir}\n"));
|
|
out.push_str(
|
|
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\n",
|
|
);
|
|
out.push_str("Note: file list is sampled.\n\n");
|
|
out.push_str("<skill_files>\n");
|
|
out.push_str(&files_xml);
|
|
out.push('\n');
|
|
out.push_str("</skill_files>\n");
|
|
out.push_str("</skill_content>");
|
|
out
|
|
}
|
|
|
|
impl crate::types::tool_metadata::ToolMetadata for SkillTool {
|
|
fn kind(&self) -> ToolKind {
|
|
ToolKind::Skill
|
|
}
|
|
|
|
fn tool_namespace(&self) -> ToolNamespace {
|
|
ToolNamespace::OpenCode
|
|
}
|
|
|
|
fn description_template(&self) -> &str {
|
|
DESCRIPTION
|
|
}
|
|
|
|
fn requires_expr(&self) -> Expr<ToolRequirement> {
|
|
Expr::True
|
|
}
|
|
}
|
|
|
|
impl kigi_tool_runtime::Tool for SkillTool {
|
|
type Args = SkillInput;
|
|
type Output = SkillOutput;
|
|
|
|
fn id(&self) -> kigi_tool_protocol::ToolId {
|
|
kigi_tool_protocol::ToolId::new("skill").expect("valid tool id")
|
|
}
|
|
|
|
fn description(
|
|
&self,
|
|
_ctx: &::kigi_tool_runtime::ListToolsContext,
|
|
) -> kigi_tool_types::ToolDescription {
|
|
kigi_tool_types::ToolDescription::new(
|
|
"skill",
|
|
crate::types::tool_metadata::ToolMetadata::description_template(self),
|
|
)
|
|
}
|
|
|
|
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
|
|
kigi_tool_protocol::ToolCapabilities {
|
|
is_read_only: false,
|
|
tool_scope: Some(kigi_tool_protocol::ToolScope::Write),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(name = "tool.opencode_skill", skip_all, fields(skill_name = %input.name))]
|
|
async fn run(
|
|
&self,
|
|
ctx: kigi_tool_runtime::ToolCallContext,
|
|
input: SkillInput,
|
|
) -> Result<SkillOutput, kigi_tool_runtime::ToolError> {
|
|
use crate::types::tool_metadata::shared_resources;
|
|
let resources = shared_resources(&ctx)?;
|
|
|
|
let available_skills;
|
|
{
|
|
available_skills = resources
|
|
.lock()
|
|
.await
|
|
.get::<AvailableSkills>()
|
|
.map(|a| a.0.clone())
|
|
.unwrap_or_default();
|
|
}
|
|
|
|
let skill = match find_skill(&input.name, &available_skills) {
|
|
FindSkillResult::Found(s) => s.clone(),
|
|
FindSkillResult::Ambiguous(qualified) => {
|
|
return Ok(SkillOutput {
|
|
success: false,
|
|
tool_result: format!(
|
|
"Skill '{}' is ambiguous -- multiple skills share this name. \
|
|
Use a qualified name: {}",
|
|
input.name,
|
|
qualified.join(", ")
|
|
),
|
|
skill_name: input.name.clone(),
|
|
skill_message: None,
|
|
error: Some(format!(
|
|
"Ambiguous skill '{}': use one of {}",
|
|
input.name,
|
|
qualified.join(", ")
|
|
)),
|
|
});
|
|
}
|
|
FindSkillResult::NotFound => {
|
|
let names: Vec<&str> = available_skills.iter().map(|s| s.name.as_str()).collect();
|
|
let available = if names.is_empty() {
|
|
"none".to_string()
|
|
} else {
|
|
names.join(", ")
|
|
};
|
|
return Ok(SkillOutput {
|
|
success: false,
|
|
tool_result: format!(
|
|
"Skill \"{}\" not found. Available skills: {}",
|
|
input.name, available
|
|
),
|
|
skill_name: input.name.clone(),
|
|
skill_message: None,
|
|
error: Some(format!("Skill \"{}\" not found", input.name)),
|
|
});
|
|
}
|
|
};
|
|
|
|
let mut content = match load_skill_content(&skill).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return Ok(SkillOutput {
|
|
success: false,
|
|
tool_result: format!("Failed to load skill '{}': {}", skill.name, e),
|
|
skill_name: skill.name.clone(),
|
|
skill_message: None,
|
|
error: Some(e),
|
|
});
|
|
}
|
|
};
|
|
|
|
if cap_skill_body(&mut content) {
|
|
tracing::info!(
|
|
skill = %skill.name,
|
|
path = %skill.path,
|
|
"skill body truncated at read cap"
|
|
);
|
|
}
|
|
|
|
let files = list_skill_files(&skill, 10).await;
|
|
|
|
let output = format_output(&skill, &content, &files);
|
|
|
|
Ok(SkillOutput {
|
|
success: true,
|
|
tool_result: format!("Loaded skill: {}", skill.name),
|
|
skill_name: skill.name.clone(),
|
|
skill_message: Some(output),
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::tool_metadata::test_ctx;
|
|
|
|
use crate::implementations::skills::types::SkillScope;
|
|
use crate::types::resources::Resources;
|
|
use tempfile::TempDir;
|
|
|
|
fn make_test_skill(name: &str, scope: SkillScope, path: &str) -> SkillInfo {
|
|
SkillInfo {
|
|
name: name.to_string(),
|
|
display_name: None,
|
|
description: format!("Test skill: {}", name),
|
|
short_description: None,
|
|
author: None,
|
|
argument_hint: None,
|
|
path: path.to_string(),
|
|
scope,
|
|
config_source: None,
|
|
plugin_name: None,
|
|
plugin_version: None,
|
|
plugin_root: None,
|
|
plugin_data: None,
|
|
allowed_tools: None,
|
|
license: None,
|
|
compatibility: None,
|
|
metadata: None,
|
|
model: None,
|
|
effort: None,
|
|
user_invocable: true,
|
|
disable_model_invocation: false,
|
|
when_to_use: None,
|
|
has_user_specified_description: false,
|
|
paths: None,
|
|
enabled: true,
|
|
body: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tool_metadata() {
|
|
use crate::types::tool_metadata::ToolMetadata;
|
|
let tool = SkillTool;
|
|
assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "skill");
|
|
assert!(matches!(tool.kind(), ToolKind::Skill));
|
|
assert!(matches!(tool.tool_namespace(), ToolNamespace::OpenCode));
|
|
let desc = tool.description_template();
|
|
assert!(desc.contains("available_skills"));
|
|
assert!(desc.contains("${%- for skill in skills %}"));
|
|
assert!(desc.contains("${{ skill.name }}"));
|
|
}
|
|
|
|
#[test]
|
|
fn find_skill_by_short_name() {
|
|
let skills = vec![
|
|
make_test_skill("commit", SkillScope::User, "/path/a"),
|
|
make_test_skill("build", SkillScope::Local, "/path/b"),
|
|
];
|
|
|
|
assert!(
|
|
matches!(find_skill("commit", &skills), FindSkillResult::Found(s) if s.name == "commit")
|
|
);
|
|
assert!(
|
|
matches!(find_skill("build", &skills), FindSkillResult::Found(s) if s.name == "build")
|
|
);
|
|
assert!(matches!(
|
|
find_skill("nonexistent", &skills),
|
|
FindSkillResult::NotFound
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn find_skill_by_qualified_name() {
|
|
let skills = vec![
|
|
make_test_skill("commit", SkillScope::User, "/path/a"),
|
|
make_test_skill("build", SkillScope::Local, "/path/b"),
|
|
];
|
|
|
|
assert!(
|
|
matches!(find_skill("user:commit", &skills), FindSkillResult::Found(s) if s.name == "commit")
|
|
);
|
|
assert!(
|
|
matches!(find_skill("local:build", &skills), FindSkillResult::Found(s) if s.name == "build")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn find_skill_ambiguous_short_name_rejected() {
|
|
let skills = vec![
|
|
make_test_skill("commit", SkillScope::Local, "/path/local"),
|
|
make_test_skill("commit", SkillScope::User, "/path/user"),
|
|
];
|
|
|
|
let result = find_skill("commit", &skills);
|
|
match result {
|
|
FindSkillResult::Ambiguous(qualified) => {
|
|
assert!(qualified.contains(&"local:commit".to_string()));
|
|
assert!(qualified.contains(&"user:commit".to_string()));
|
|
}
|
|
other => panic!("Expected Ambiguous, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn find_skill_qualified_resolves_despite_ambiguous_short_name() {
|
|
let skills = vec![
|
|
make_test_skill("commit", SkillScope::Local, "/path/local"),
|
|
make_test_skill("commit", SkillScope::User, "/path/user"),
|
|
];
|
|
|
|
assert!(matches!(
|
|
find_skill("local:commit", &skills),
|
|
FindSkillResult::Found(s) if s.path == "/path/local"
|
|
));
|
|
assert!(matches!(
|
|
find_skill("user:commit", &skills),
|
|
FindSkillResult::Found(s) if s.path == "/path/user"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_ambiguous_returns_error_output() {
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![
|
|
make_test_skill("commit", SkillScope::Local, "/path/local"),
|
|
make_test_skill("commit", SkillScope::User, "/path/user"),
|
|
]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "commit".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!output.success);
|
|
assert!(output.tool_result.contains("ambiguous"));
|
|
assert!(output.tool_result.contains("local:commit"));
|
|
assert!(output.tool_result.contains("user:commit"));
|
|
assert!(output.error.is_some());
|
|
assert!(output.skill_message.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_not_found_returns_error_output() {
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"commit",
|
|
SkillScope::User,
|
|
"/path/a",
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "nonexistent".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!output.success);
|
|
assert!(output.tool_result.contains("not found"));
|
|
assert!(output.tool_result.contains("commit"));
|
|
assert!(output.error.is_some());
|
|
assert!(output.skill_message.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_not_found_with_no_skills_resource() {
|
|
let resources = Resources::new();
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "commit".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!output.success);
|
|
assert!(output.tool_result.contains("not found"));
|
|
assert!(output.tool_result.contains("none"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_loads_content_from_file() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(
|
|
&skill_path,
|
|
"---\nname: test\ndescription: A test\n---\n# Test Skill\n\nDo the thing.",
|
|
)
|
|
.unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"test",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "test".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
assert_eq!(output.skill_name, "test");
|
|
assert_eq!(output.tool_result, "Loaded skill: test");
|
|
|
|
let msg = output.skill_message.unwrap();
|
|
assert!(msg.contains("<skill_content name=\"test\">"));
|
|
assert!(msg.contains("# Skill: test"));
|
|
assert!(msg.contains("# Test Skill"));
|
|
assert!(msg.contains("Do the thing."));
|
|
assert!(msg.contains("<skill_files>"));
|
|
assert!(msg.contains("</skill_content>"));
|
|
}
|
|
|
|
/// Regression: the OpenCode skill tool's body must reach the model via the
|
|
/// normal tool result (`to_prompt_format()`). OpenCode registers the skill
|
|
/// tool without a follow-up handler, so a body delivered only through such a
|
|
/// handler would never reach the model.
|
|
#[tokio::test]
|
|
async fn skill_body_reaches_prompt_format() {
|
|
use crate::types::output::ToolOutput;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(
|
|
&skill_path,
|
|
"---\nname: test\ndescription: A test\n---\n# Test Skill\n\nDo the thing.",
|
|
)
|
|
.unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"test",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "test".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let prompt = ToolOutput::Skill(output).to_prompt_format();
|
|
assert!(
|
|
prompt.contains("Do the thing."),
|
|
"skill body must reach to_prompt_format(), got: {prompt}"
|
|
);
|
|
assert!(prompt.contains("<skill_content name=\"test\">"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_lists_bundled_files() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(&skill_path, "# Skill\n\nContent.").unwrap();
|
|
std::fs::write(tmp.path().join("helper.sh"), "#!/bin/bash").unwrap();
|
|
std::fs::write(tmp.path().join("reference.md"), "# Ref").unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"test",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "test".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let msg = output.skill_message.unwrap();
|
|
assert!(msg.contains("<file>"));
|
|
// SKILL.md itself should NOT appear in the file list.
|
|
assert!(!msg.contains("<file>") || !msg.contains("SKILL.md"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_file_not_found_returns_error() {
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"missing",
|
|
SkillScope::Local,
|
|
"/nonexistent/path/SKILL.md",
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "missing".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!output.success);
|
|
assert!(output.tool_result.contains("Failed to load"));
|
|
assert!(output.error.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn works_through_erased_interface() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(&skill_path, "# Hello\n\nContent.").unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"hello",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let result = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "hello".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.success);
|
|
assert_eq!(result.skill_name, "hello");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn frontmatter_stripping() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(
|
|
&skill_path,
|
|
"---\nname: deploy\ndescription: Deploy to prod\ntags: [ops]\n---\nRun the deploy pipeline.",
|
|
)
|
|
.unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"deploy",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "deploy".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let msg = output.skill_message.unwrap();
|
|
assert!(msg.contains("Run the deploy pipeline."));
|
|
assert!(!msg.contains("---"));
|
|
assert!(!msg.contains("tags: [ops]"));
|
|
assert!(!msg.contains("description: Deploy to prod"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_message_xml_structure() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(&skill_path, "---\nname: fmt\n---\nFormat the code.").unwrap();
|
|
std::fs::write(tmp.path().join("fmt.sh"), "#!/bin/bash").unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"fmt",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput { name: "fmt".into() },
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let msg = output.skill_message.unwrap();
|
|
|
|
assert!(msg.starts_with("<skill_content name=\"fmt\">\n"));
|
|
assert!(msg.contains("# Skill: fmt\n"));
|
|
assert!(msg.contains("Format the code."));
|
|
assert!(msg.contains(&format!(
|
|
"Base directory for this skill: file://{}",
|
|
tmp.path().display()
|
|
)));
|
|
assert!(msg.contains("<skill_files>\n"));
|
|
assert!(msg.contains(&format!(
|
|
"<file>{}</file>",
|
|
tmp.path().join("fmt.sh").display()
|
|
)));
|
|
assert!(msg.ends_with("</skill_content>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn base_directory_in_output() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_dir = tmp.path().join("my-skills").join("linter");
|
|
std::fs::create_dir_all(&skill_dir).unwrap();
|
|
let skill_path = skill_dir.join("SKILL.md");
|
|
std::fs::write(&skill_path, "Lint all files.").unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"linter",
|
|
SkillScope::User,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "linter".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let msg = output.skill_message.unwrap();
|
|
let expected_base = format!(
|
|
"Base directory for this skill: file://{}",
|
|
skill_dir.display()
|
|
);
|
|
assert!(
|
|
msg.contains(&expected_base),
|
|
"Expected base dir line:\n {expected_base}\nFull message:\n{msg}"
|
|
);
|
|
assert!(msg.contains("Relative paths in this skill"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skill_with_no_bundled_files() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(&skill_path, "Solo skill, no extras.").unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"solo",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "solo".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let msg = output.skill_message.unwrap();
|
|
assert!(msg.contains("<skill_files>"));
|
|
assert!(msg.contains("</skill_files>"));
|
|
assert!(!msg.contains("<file>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_skill_content() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(&skill_path, "---\nname: empty\ndescription: nothing\n---\n").unwrap();
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"empty",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "empty".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
assert_eq!(output.tool_result, "Loaded skill: empty");
|
|
assert!(output.error.is_none());
|
|
|
|
let msg = output.skill_message.unwrap();
|
|
assert!(msg.contains("<skill_content name=\"empty\">"));
|
|
assert!(msg.contains("# Skill: empty"));
|
|
assert!(msg.contains("</skill_content>"));
|
|
assert!(!msg.contains("description: nothing"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ten_file_cap() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let skill_path = tmp.path().join("SKILL.md");
|
|
std::fs::write(
|
|
&skill_path,
|
|
"---\nname: bigskill\ndescription: lots of files\n---\nDo stuff.",
|
|
)
|
|
.unwrap();
|
|
|
|
for i in 1..=15 {
|
|
std::fs::write(
|
|
tmp.path().join(format!("file{i:02}.txt")),
|
|
format!("content {i}"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let mut resources = Resources::new();
|
|
resources.insert(AvailableSkills(vec![make_test_skill(
|
|
"bigskill",
|
|
SkillScope::Local,
|
|
skill_path.to_str().unwrap(),
|
|
)]));
|
|
|
|
let tool = SkillTool;
|
|
let output = kigi_tool_runtime::Tool::run(
|
|
&tool,
|
|
test_ctx(resources.into_shared()),
|
|
SkillInput {
|
|
name: "bigskill".into(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let msg = output.skill_message.unwrap();
|
|
let file_tag_count = msg.matches("<file>").count();
|
|
assert!(
|
|
file_tag_count <= 10,
|
|
"Expected at most 10 <file> entries, got {file_tag_count}"
|
|
);
|
|
}
|
|
}
|