aps-agent/server/agent_core/skills.py

264 lines
9.6 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 外部算法排产 Skill 注册表(moduleId: core-skills, 可重生 ✅)
# 桌面端:~/.aps/skills/<skill_id>/manifest.json(对标 Codex skills 目录)
# Web:server/data/skills/…;APS_SKILLS_PATH 仍可指向单文件 JSON(测试兼容)
# ============================================================
from __future__ import annotations
import json
import os
import shutil
import tempfile
import threading
from pathlib import Path
from typing import Any
import httpx
from pydantic import BaseModel, Field
_PATH_ENV = "APS_SKILLS_PATH"
class SkillManifest(BaseModel):
skill_id: str
name: str
endpoint: str = "local://stub"
auth: str = ""
max_power: str = "P1"
track: str = "flex"
enabled: bool = True
timeout_sec: float = 60.0
description: str = ""
golden_tests: list[str] = Field(default_factory=list)
version: str = "1.0"
capabilities: list[str] = Field(
default_factory=lambda: ["flex_schedule"])
ragScopes: list[str] = Field(default_factory=list)
def _default_skills() -> list[dict[str, Any]]:
return [
SkillManifest(
skill_id="algo.stub",
name="内置算法桩(演示)",
endpoint="local://stub",
track="flex",
enabled=True,
description="本地桩:把工序顺序串到能力池首台设备,供联调与黄金测试",
golden_tests=["test_external_engine"],
capabilities=["flex_schedule"],
ragScopes=["process", "sop"],
).model_dump()
]
def _skills_root() -> Path:
"""Skill 根目录(目录化布局)。"""
from server.aps_home import skills_dir
return skills_dir()
class SkillRegistry:
"""外部算法 skill 清单:目录扫描 + 启停/健康检查。"""
def __init__(self, path: str | None = None) -> None:
# path:单文件 JSON(测试/旧配置);None → ~/.aps/skills 目录布局
self.legacy_file = path or os.environ.get(_PATH_ENV)
self._lock = threading.Lock()
self.health_history: dict[str, list[dict[str, Any]]] = {}
self.skills: list[dict[str, Any]] = self._load()
if not self.skills:
self.skills = _default_skills()
self._write_all()
else:
upgraded = False
for s in self.skills:
for key, default in (("version", "1.0"),
("capabilities", ["flex_schedule"]),
("ragScopes", [])):
if key not in s:
s[key] = default
upgraded = True
if upgraded:
self._write_all()
def _load(self) -> list[dict[str, Any]]:
if self.legacy_file:
return self._load_legacy_file(Path(self.legacy_file))
root = _skills_root()
root.mkdir(parents=True, exist_ok=True)
# 迁移:若仍有旧 skills.json,导入后可保留文件
legacy = root.parent / "skills.json"
if not any(root.iterdir()) and legacy.exists():
items = self._load_legacy_file(legacy)
for s in items:
self._write_one(root, s)
return items
# 同层 server/data/skills.json(web 默认 data 根)
flat = root.parent / "skills.json" if root.name == "skills" else None
out: list[dict[str, Any]] = []
for child in sorted(root.iterdir()):
if not child.is_dir() or child.name.startswith("."):
continue
manifest = child / "manifest.json"
if not manifest.exists():
continue
try:
data = json.loads(manifest.read_text(encoding="utf-8"))
if isinstance(data, dict) and data.get("skill_id"):
out.append(data)
except (OSError, json.JSONDecodeError):
continue
if not out and flat and flat.exists():
return self._load_legacy_file(flat)
return out
@staticmethod
def _load_legacy_file(path: Path) -> list[dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f).get("skills", [])
except (FileNotFoundError, json.JSONDecodeError, OSError):
return []
@staticmethod
def _write_one(root: Path, skill: dict[str, Any]) -> None:
sid = skill.get("skill_id") or "unknown"
folder = root / sid
folder.mkdir(parents=True, exist_ok=True)
path = folder / "manifest.json"
fd, tmp = tempfile.mkstemp(dir=str(folder), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(skill, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
# 可选说明文件,便于人在资源管理器里阅读
readme = folder / "README.md"
if not readme.exists():
readme.write_text(
f"# {skill.get('name') or sid}\n\n"
f"- id: `{sid}`\n"
f"- endpoint: `{skill.get('endpoint')}`\n"
f"- {skill.get('description') or ''}\n",
encoding="utf-8",
)
def _write_all(self) -> None:
if self.legacy_file:
path = Path(self.legacy_file)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"skills": self.skills}, f, ensure_ascii=False, indent=1)
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
return
root = _skills_root()
root.mkdir(parents=True, exist_ok=True)
keep = {s["skill_id"] for s in self.skills}
for s in self.skills:
self._write_one(root, s)
# 清理已删除的 skill 目录
for child in root.iterdir():
if child.is_dir() and child.name not in keep and not child.name.startswith("."):
shutil.rmtree(child, ignore_errors=True)
def list(self) -> list[dict[str, Any]]:
return list(self.skills)
def get(self, skill_id: str) -> dict[str, Any] | None:
return next((s for s in self.skills if s["skill_id"] == skill_id), None)
def get_enabled(self, skill_id: str | None = None, track: str | None = None) -> dict[str, Any] | None:
if skill_id:
s = self.get(skill_id)
return s if s and s.get("enabled") else None
for s in self.skills:
if not s.get("enabled"):
continue
if track and s.get("track") != track:
continue
return s
return None
def upsert(self, manifest: dict[str, Any]) -> dict[str, Any]:
m = SkillManifest(**manifest).model_dump()
with self._lock:
for i, s in enumerate(self.skills):
if s["skill_id"] == m["skill_id"]:
self.skills[i] = m
self._write_all()
return m
self.skills.append(m)
self._write_all()
return m
def set_enabled(self, skill_id: str, enabled: bool) -> dict[str, Any]:
with self._lock:
s = self.get(skill_id)
if not s:
raise ValueError(f"未找到 skill:{skill_id}")
s["enabled"] = bool(enabled)
self._write_all()
return dict(s)
def health(self, skill_id: str | None = None) -> list[dict[str, Any]]:
from datetime import datetime
targets = [self.get(skill_id)] if skill_id else self.skills
out = []
for s in targets:
if not s:
continue
status = self._probe(s)
record = {"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), **status}
hist = self.health_history.setdefault(s["skill_id"], [])
hist.append(record)
del hist[:-20]
out.append({"skill_id": s["skill_id"], "name": s["name"],
"enabled": s.get("enabled"), "endpoint": s.get("endpoint"),
**status})
return out
def history(self, skill_id: str) -> list[dict[str, Any]]:
return list(self.health_history.get(skill_id) or [])
def _probe(self, s: dict[str, Any]) -> dict[str, Any]:
ep = s.get("endpoint") or ""
if ep.startswith("local://"):
return {"ok": True, "latencyMs": 0, "detail": "local stub"}
health_url = ep.rstrip("/") + "/health"
try:
headers = {}
if s.get("auth"):
headers["Authorization"] = f"Bearer {s['auth']}"
with httpx.Client(timeout=5.0) as client:
r = client.get(health_url, headers=headers)
return {"ok": r.status_code < 400, "latencyMs": int(r.elapsed.total_seconds() * 1000),
"detail": f"HTTP {r.status_code}"}
except Exception as exc:
return {"ok": False, "latencyMs": None, "detail": str(exc)}
_registry: SkillRegistry | None = None
def get_skills() -> SkillRegistry:
global _registry
if _registry is None:
_registry = SkillRegistry()
return _registry
def reset_skills_registry() -> None:
"""测试用:丢弃单例,下次按当前环境变量重建。"""
global _registry
_registry = None