aps-agent/server/agent_core/skills.py

264 lines
9.6 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.

# ============================================================
# 外部算法排产 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