agent-Specialization/modules/skills_manager.py
JOJO 398ee6e760 feat(memory): refactor proactive memory and conversation recall
Refactor conversation persistence into workspace-scoped storage with host workspace legacy migration, current workspace filtering, and active host workspace state protection.

Add proactive recent-conversation prompt injection with personal-space controls, configurable item count, empty-conversation filtering, and frozen per-conversation prompt metadata to keep request caches stable.

Introduce conversation_search and conversation_review tools for current-workspace history: multi-keyword search, list mode, current conversation exclusion, message/tool counts, formatted model output, read/save review modes, long-review fallback, and .agents/review storage.

Move internal workspace artifacts under .agents, including uploads, skills, compact results, and reviews; update prompt file-tree visibility, compression output, upload paths, and hidden versioning exclusions.

Add create_skill workflow and a Skills tool category, move read_skill into Skills, validate/归档 skill folders, support host global skills and Docker/API private skills, and sync private skills into .agents/skills and skill prompts.

Update frontend tool icons, statuses, and expanded renderers for conversation recall and skills workflows; add workspace conversation and skills-manager tests.
2026-05-27 03:09:42 +08:00

364 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Agent skills manager / 智能体技能管理器。
负责扫描全局 skills 库、生成可用清单,并同步到工作区 skills/。
"""
from __future__ import annotations
import re
import shutil
import threading
from pathlib import Path
from typing import Dict, List, Optional, Sequence
from config import AGENT_SKILLS_DIR, WORKSPACE_SKILLS_DIRNAME
from utils.logger import setup_logger
logger = setup_logger(__name__)
SKILL_FILE_NAME = "SKILL.md"
SKILL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
_SYNC_LOCK_GUARD = threading.Lock()
_SYNC_LOCKS: Dict[str, threading.Lock] = {}
def _get_sync_lock(skills_dir: Path) -> threading.Lock:
key = str(skills_dir.resolve())
with _SYNC_LOCK_GUARD:
lock = _SYNC_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_SYNC_LOCKS[key] = lock
return lock
def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path:
"""Ensure the global skills directory exists / 确保全局技能目录存在。"""
root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def ensure_workspace_skills_dir(project_path: str | Path) -> Path:
"""Ensure workspace skills directory exists / 确保工作区 skills 目录存在。"""
root = Path(project_path).expanduser().resolve()
skills_dir = (root / WORKSPACE_SKILLS_DIRNAME).resolve()
skills_dir.mkdir(parents=True, exist_ok=True)
return skills_dir
def infer_private_skills_dir(data_dir: str | Path | None) -> Optional[Path]:
"""Infer per-user private skills dir from workspace data_dir."""
if not data_dir:
return None
try:
data_path = Path(data_dir).expanduser().resolve()
if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
return (data_path.parent.parent.parent / "agentskills").resolve()
return (data_path.parent / "agentskills").resolve()
except Exception:
return None
def validate_skill_directory(source_dir: str | Path) -> Dict[str, object]:
"""Validate a skill folder with minimal required SKILL.md frontmatter."""
source = Path(source_dir).expanduser().resolve()
if not source.exists() or not source.is_dir():
return {"success": False, "error": "source_dir 不是目录", "source_dir": str(source)}
skill_id = source.name
if not _is_valid_skill_id(skill_id):
return {"success": False, "error": "skill 文件夹名称不合法", "skill_name": skill_id}
skill_file = source / SKILL_FILE_NAME
if not skill_file.exists() or not skill_file.is_file():
return {"success": False, "error": "缺少 SKILL.md", "skill_name": skill_id}
try:
content = skill_file.read_text(encoding="utf-8")
except Exception as exc:
return {"success": False, "error": f"读取 SKILL.md 失败: {exc}", "skill_name": skill_id}
if not re.search(r"(?m)^\s*name\s*:", content):
return {"success": False, "error": "缺少 name:", "skill_name": skill_id}
if not re.search(r"(?m)^\s*description\s*:", content):
return {"success": False, "error": "缺少 description:", "skill_name": skill_id}
return {
"success": True,
"skill_name": skill_id,
"source_dir": str(source),
"skill_file": str(skill_file),
}
def archive_skill_directory(source_dir: str | Path, target_root: str | Path) -> Dict[str, object]:
"""Move a validated skill folder into a skills library root."""
validation = validate_skill_directory(source_dir)
if not validation.get("success"):
return validation
source = Path(str(validation["source_dir"])).expanduser().resolve()
target_base = Path(target_root).expanduser().resolve()
target_base.mkdir(parents=True, exist_ok=True)
target = (target_base / source.name).resolve()
try:
target.relative_to(target_base)
except ValueError:
return {"success": False, "error": "目标路径不合法", "skill_name": source.name}
if target.exists():
return {
"success": False,
"error": "目标 skill 已存在",
"skill_name": source.name,
"target_dir": str(target),
}
try:
shutil.move(str(source), str(target))
except Exception as exc:
return {
"success": False,
"error": f"移动 skill 失败: {exc}",
"skill_name": source.name,
"source_dir": str(source),
"target_dir": str(target),
}
return {
"success": True,
"skill_name": source.name,
"source_dir": str(source),
"target_dir": str(target),
}
def _parse_frontmatter(text: str) -> Dict[str, str]:
"""Parse simple YAML frontmatter / 解析简单 YAML 头信息。"""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}
end_idx = None
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
end_idx = idx
break
if end_idx is None:
return {}
meta: Dict[str, str] = {}
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and value:
meta[key] = value
return meta
def _is_valid_skill_id(name: str) -> bool:
"""Validate skill id / 校验技能目录名。"""
return bool(name and SKILL_ID_PATTERN.match(name))
def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]:
"""Scan one skills root."""
if not root.exists() or not root.is_dir():
return []
catalog: List[Dict[str, str]] = []
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
if not _is_valid_skill_id(child.name):
continue
skill_file = child / SKILL_FILE_NAME
if not skill_file.exists():
continue
meta: Dict[str, str] = {}
try:
meta = _parse_frontmatter(skill_file.read_text(encoding="utf-8"))
except Exception:
meta = {}
label = meta.get("name") or child.name
description = meta.get("description") or ""
catalog.append({
"id": child.name,
"label": label,
"description": description,
})
return catalog
def get_skills_catalog(
base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
) -> List[Dict[str, str]]:
"""List available skills from global library plus optional private library."""
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
if private_dir:
private_root = Path(private_dir).expanduser().resolve()
if private_root not in roots:
roots.append(private_root)
merged: Dict[str, Dict[str, str]] = {}
order: List[str] = []
for root in roots:
for item in _scan_skills_catalog(root):
skill_id = item.get("id")
if not skill_id:
continue
if skill_id not in merged:
order.append(skill_id)
# Later roots (private) override metadata for same id.
merged[skill_id] = item
return [merged[skill_id] for skill_id in order if skill_id in merged]
def _get_skill_source_map(
base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
) -> Dict[str, Path]:
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
if private_dir:
private_root = Path(private_dir).expanduser().resolve()
if private_root not in roots:
roots.append(private_root)
sources: Dict[str, Path] = {}
for root in roots:
if not root.exists() or not root.is_dir():
continue
for item in _scan_skills_catalog(root):
skill_id = item.get("id")
if skill_id:
sources[skill_id] = root / skill_id
return sources
def resolve_enabled_skills(
enabled_skills: Optional[Sequence[str]],
catalog: Sequence[Dict[str, str]],
) -> List[str]:
"""Resolve enabled skills list / 解析启用技能列表。"""
catalog_ids = [item.get("id", "") for item in catalog if item.get("id")]
if enabled_skills is None:
return catalog_ids
if not isinstance(enabled_skills, (list, tuple)):
return catalog_ids
allowed = set(catalog_ids)
seen = set()
resolved: List[str] = []
for item in enabled_skills:
if not isinstance(item, str):
continue
skill_id = item.strip()
if not skill_id or skill_id in seen or skill_id not in allowed:
continue
resolved.append(skill_id)
seen.add(skill_id)
return resolved
def build_skills_list(
catalog: Sequence[Dict[str, str]],
enabled_skill_ids: Sequence[str],
) -> List[str]:
"""Build skills list lines / 生成 skills 列表行。"""
if not enabled_skill_ids:
return []
lookup = {item.get("id"): item for item in catalog if item.get("id")}
lines: List[str] = []
for skill_id in enabled_skill_ids:
meta = lookup.get(skill_id)
if not meta:
continue
description = (meta.get("description") or "").strip()
if description:
lines.append(f"skills/{skill_id}{description}")
else:
lines.append(f"skills/{skill_id}")
return lines
def merge_enabled_skills(
enabled_skill_ids: Optional[Sequence[str]],
catalog: Sequence[Dict[str, str]],
catalog_snapshot: Optional[Sequence[str]] = None,
) -> List[str]:
"""Merge enabled skills with new catalog items / 合并启用列表并默认开启新增技能。"""
catalog_ids = [item.get("id") for item in catalog if item.get("id")]
if enabled_skill_ids is None:
base = list(catalog_ids)
else:
base_set = {item for item in enabled_skill_ids if isinstance(item, str)}
base = [item for item in catalog_ids if item in base_set]
if catalog_snapshot:
snapshot_set = {item for item in catalog_snapshot if isinstance(item, str)}
new_items = [item for item in catalog_ids if item not in snapshot_set]
for item in new_items:
if item not in base:
base.append(item)
return base
def build_skills_prompt(template: str, skills_list: Sequence[str]) -> str:
"""Build skills prompt from template / 根据模板生成 skills 提示。"""
if not template:
return ""
list_block = "\n".join(skills_list) if skills_list else ""
content = template
if list_block:
content = content.replace("{skills_list}", list_block)
content = re.sub(r"\[skills_empty\].*?\[/skills_empty\]\n?", "", content, flags=re.S)
else:
content = content.replace("{skills_list}", "")
content = content.replace("[skills_empty]", "").replace("[/skills_empty]", "")
return content.strip()
def sync_workspace_skills(
project_path: str | Path,
enabled_skills: Optional[Sequence[str]] = None,
base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
) -> Dict[str, object]:
"""Sync global skills into workspace / 将全局 skills 同步到工作区。"""
root = Path(project_path).expanduser().resolve()
skills_dir = (root / WORKSPACE_SKILLS_DIRNAME).resolve()
try:
skills_dir.relative_to(root)
except Exception:
return {"success": False, "error": "skills 目录不在项目路径内"}
ensure_agent_skills_dir(base_dir)
catalog = get_skills_catalog(base_dir, private_dir=private_dir)
resolved = resolve_enabled_skills(enabled_skills, catalog)
sources = _get_skill_source_map(base_dir, private_dir=private_dir)
lock = _get_sync_lock(skills_dir)
try:
with lock:
if skills_dir.exists():
shutil.rmtree(skills_dir, ignore_errors=True)
skills_dir.mkdir(parents=True, exist_ok=True)
for skill_id in resolved:
src = sources.get(skill_id)
if src is None or not src.exists() or not src.is_dir():
continue
dst = skills_dir / skill_id
if dst.exists() and not dst.is_dir():
dst.unlink(missing_ok=True)
shutil.copytree(src, dst, dirs_exist_ok=True)
return {
"success": True,
"copied": list(resolved),
"available": [item.get("id") for item in catalog],
"target": str(skills_dir),
}
except Exception as exc:
logger.error("同步 skills 失败: %s", exc, exc_info=True)
return {"success": False, "error": str(exc), "target": str(skills_dir)}