wip: checkpoint 2026-03-29 runtime work

This commit is contained in:
zyl
2026-03-29 22:44:30 +08:00
parent 7d9036b2d4
commit e294fbb9b1
30 changed files with 6759 additions and 161 deletions

View File

@@ -1,11 +1,14 @@
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::path::PathBuf;
use std::collections::{BTreeSet, VecDeque};
use std::path::{Path, PathBuf};
/// Compact-mode helper for loading a skill's source file on demand.
pub struct ReadSkillTool {
workspace_dir: PathBuf,
runtime_skills_dir: Option<PathBuf>,
allow_scripts: bool,
open_skills_enabled: bool,
open_skills_dir: Option<String>,
}
@@ -18,6 +21,24 @@ impl ReadSkillTool {
) -> Self {
Self {
workspace_dir,
runtime_skills_dir: None,
allow_scripts: false,
open_skills_enabled,
open_skills_dir,
}
}
pub fn with_runtime_skills_dir(
workspace_dir: PathBuf,
runtime_skills_dir: Option<PathBuf>,
allow_scripts: bool,
open_skills_enabled: bool,
open_skills_dir: Option<String>,
) -> Self {
Self {
workspace_dir,
runtime_skills_dir,
allow_scripts,
open_skills_enabled,
open_skills_dir,
}
@@ -55,11 +76,27 @@ impl Tool for ReadSkillTool {
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
let skills = crate::skills::load_skills_with_open_skills_settings(
let mut skills = crate::skills::load_skills_with_open_skills_settings(
&self.workspace_dir,
self.open_skills_enabled,
self.open_skills_dir.as_deref(),
);
let default_skills_dir = self.workspace_dir.join("skills");
if let Some(runtime_skills_dir) = &self.runtime_skills_dir {
if runtime_skills_dir != &default_skills_dir {
skills.retain(|skill| {
skill
.location
.as_ref()
.map(|location| !location.starts_with(&default_skills_dir))
.unwrap_or(true)
});
skills.extend(crate::skills::load_skills_from_directory(
runtime_skills_dir,
self.allow_scripts,
));
}
}
let Some(skill) = skills
.iter()
@@ -93,7 +130,7 @@ impl Tool for ReadSkillTool {
});
};
match tokio::fs::read_to_string(location).await {
match read_skill_bundle(location).await {
Ok(output) => Ok(ToolResult {
success: true,
output,
@@ -112,6 +149,152 @@ impl Tool for ReadSkillTool {
}
}
pub async fn read_skill_bundle(location: &Path) -> std::io::Result<String> {
let primary = tokio::fs::read_to_string(location).await?;
let Some(skill_root) = location.parent() else {
return Ok(primary);
};
let skill_root = skill_root.canonicalize().unwrap_or_else(|_| skill_root.to_path_buf());
let mut output = primary.clone();
let mut appended = BTreeSet::new();
let mut queued = BTreeSet::new();
let mut pending = VecDeque::new();
enqueue_reference_paths(
&primary,
location.parent().unwrap_or(skill_root.as_path()),
&skill_root,
&mut queued,
&mut pending,
);
while let Some(path) = pending.pop_front() {
let canonical = path.canonicalize().unwrap_or(path.clone());
if !canonical.starts_with(&skill_root) || !appended.insert(canonical.clone()) {
continue;
}
let Ok(content) = tokio::fs::read_to_string(&canonical).await else {
continue;
};
let relative = canonical
.strip_prefix(&skill_root)
.unwrap_or(canonical.as_path())
.display()
.to_string();
output.push_str("\n\n## Referenced File: ");
output.push_str(&relative);
output.push_str("\n\n");
output.push_str(&content);
enqueue_reference_paths(
&content,
canonical.parent().unwrap_or(skill_root.as_path()),
&skill_root,
&mut queued,
&mut pending,
);
}
Ok(output)
}
fn enqueue_reference_paths(
content: &str,
base_dir: &Path,
skill_root: &Path,
queued: &mut BTreeSet<PathBuf>,
pending: &mut VecDeque<PathBuf>,
) {
for candidate in extract_reference_paths(content) {
for resolved in resolve_reference_candidates(&candidate, base_dir, skill_root) {
let canonical = resolved.canonicalize().unwrap_or(resolved);
if !canonical.starts_with(skill_root) || !is_supported_reference_file(&canonical) {
continue;
}
if queued.insert(canonical.clone()) {
pending.push_back(canonical);
}
}
}
}
fn extract_reference_paths(content: &str) -> Vec<String> {
let mut paths = Vec::new();
let mut cursor = content;
while let Some(start) = cursor.find("](") {
cursor = &cursor[start + 2..];
let Some(end) = cursor.find(')') else {
break;
};
let raw = cursor[..end].trim();
if looks_like_relative_reference_path(raw) {
paths.push(raw.to_string());
}
cursor = &cursor[end + 1..];
}
let mut in_backticks = false;
let mut token = String::new();
for ch in content.chars() {
if ch == '`' {
if in_backticks {
let raw = token.trim();
if looks_like_relative_reference_path(raw) {
paths.push(raw.to_string());
}
token.clear();
}
in_backticks = !in_backticks;
continue;
}
if in_backticks {
token.push(ch);
}
}
paths
}
fn looks_like_relative_reference_path(raw: &str) -> bool {
if raw.is_empty() ||
raw.starts_with('/') ||
raw.starts_with("http://") ||
raw.starts_with("https://") ||
raw.starts_with('#')
{
return false;
}
let candidate = raw.split('#').next().unwrap_or(raw).split('?').next().unwrap_or(raw);
let path = Path::new(candidate);
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return false;
}
candidate.contains('/') && is_supported_reference_file(path)
}
fn is_supported_reference_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|value| value.to_str()),
Some("md" | "txt" | "json" | "html" | "toml" | "yaml" | "yml" | "csv")
)
}
fn resolve_reference_candidates(raw: &str, base_dir: &Path, skill_root: &Path) -> Vec<PathBuf> {
let mut candidates = vec![base_dir.join(raw)];
let skill_root_candidate = skill_root.join(raw);
if skill_root_candidate != candidates[0] {
candidates.push(skill_root_candidate);
}
candidates
}
#[cfg(test)]
mod tests {
use super::*;
@@ -184,4 +367,43 @@ description = "Ship safely"
Some("Unknown skill 'calendar'. Available skills: weather")
);
}
#[tokio::test]
async fn inlines_markdown_reference_files_for_skill_context() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("workspace/skills/zhihu-hotlist");
let refs_dir = skill_dir.join("references");
std::fs::create_dir_all(&refs_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
concat!(
"# Zhihu Hotlist\n\n",
"Follow [collection-flow.md](references/collection-flow.md).\n",
"Apply [data-quality.md](references/data-quality.md).\n",
),
)
.unwrap();
std::fs::write(
refs_dir.join("collection-flow.md"),
"# Collection Flow\n\nCollect rows from the hotlist first.\n",
)
.unwrap();
std::fs::write(
refs_dir.join("data-quality.md"),
"# Data Quality\n\nMark partial metrics explicitly.\n",
)
.unwrap();
let result = make_tool(&tmp)
.execute(json!({ "name": "zhihu-hotlist" }))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("# Zhihu Hotlist"));
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
assert!(result.output.contains("Collect rows from the hotlist first."));
assert!(result.output.contains("## Referenced File: references/data-quality.md"));
assert!(result.output.contains("Mark partial metrics explicitly."));
}
}