mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] skills: per-skill enable toggle gates prompt list, Skill tool, and cloud runs; files endpoint feeds the detail picker
This commit is contained in:
@@ -397,7 +397,7 @@ def build_installed_skills_catalog() -> Optional[str]:
|
||||
return None
|
||||
try:
|
||||
from backend.apps.skills.skills import sync_skills
|
||||
skills = [s for s in sync_skills() if not s.built_in]
|
||||
skills = [s for s in sync_skills() if not s.built_in and s.enabled]
|
||||
except Exception:
|
||||
return None
|
||||
if not skills:
|
||||
|
||||
@@ -118,7 +118,7 @@ def register_builtin_mcp_servers(
|
||||
if not skill_denied:
|
||||
try:
|
||||
from backend.apps.skills.skills import sync_skills
|
||||
has_loadable_skill = any(not s.built_in for s in sync_skills())
|
||||
has_loadable_skill = any(not s.built_in and s.enabled for s in sync_skills())
|
||||
except Exception:
|
||||
has_loadable_skill = False
|
||||
if has_loadable_skill:
|
||||
|
||||
@@ -19,6 +19,8 @@ class Skill(BaseModel):
|
||||
source: str = ""
|
||||
folder: str = ""
|
||||
version: str = ""
|
||||
# The detail-page toggle: a disabled skill stays installed but leaves the agent's skill list, the Skill tool refuses to load it, and cloud runs skip it.
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
@@ -33,6 +35,7 @@ class SkillUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
command: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class SkillLoadRequest(BaseModel):
|
||||
|
||||
@@ -247,6 +247,7 @@ def p_build_skill(skill_id: str, content: str, md_path: str, kind: str, index: d
|
||||
source=meta.get("source", ""),
|
||||
folder=meta.get("folder", ""),
|
||||
version=meta.get("version", ""),
|
||||
enabled=bool(meta.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
@@ -321,7 +322,9 @@ async def load_skill(body: SkillLoadRequest):
|
||||
skills_list = sync_skills()
|
||||
target = p_resolve_skill(body.id, skills_list)
|
||||
if target is None:
|
||||
return {"ok": False, "error": "unknown_skill", "available": [s.id for s in skills_list]}
|
||||
return {"ok": False, "error": "unknown_skill", "available": [s.id for s in skills_list if s.enabled]}
|
||||
if not target.enabled:
|
||||
return {"ok": False, "error": "skill_disabled", "available": [s.id for s in skills_list if s.enabled]}
|
||||
folder = target.dir_path if (target.dir_path and target.has_supporting_files) else None
|
||||
return {"ok": True, "text": format_skill_for_prompt(target.name, target.content, folder)}
|
||||
|
||||
@@ -477,6 +480,33 @@ def write_folder_skill(skill_id: str, files: dict[str, str], meta: dict) -> Skil
|
||||
return p_build_skill(slug, content, md_path, kind, index)
|
||||
|
||||
|
||||
@skills.router.get("/{skill_id}/files")
|
||||
async def list_skill_files(skill_id: str):
|
||||
"""The detail page's file picker: every text file in a folder skill, SKILL.md first."""
|
||||
md_path, kind = skill_md_path(skill_id)
|
||||
if not md_path:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
if kind != "folder":
|
||||
with open(md_path, encoding="utf-8") as f:
|
||||
return {"files": [{"path": "SKILL.md", "content": f.read()}]}
|
||||
base_abs = os.path.abspath(os.path.join(SKILLS_DIR, skill_id))
|
||||
out: list[dict] = []
|
||||
for root, dirs, names in os.walk(base_abs):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for n in sorted(names):
|
||||
path = os.path.join(root, n)
|
||||
rel = os.path.relpath(path, base_abs)
|
||||
if n.startswith(".") or os.path.getsize(path) > 512_000:
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
out.append({"path": rel, "content": f.read()})
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
out.sort(key=lambda e: (e["path"] != "SKILL.md", e["path"]))
|
||||
return {"files": out}
|
||||
|
||||
|
||||
@skills.router.post("/upload")
|
||||
async def upload_skill(body: SkillUpload):
|
||||
"""The Directory's Upload skill drop zone: a bare SKILL .md, or a .zip/.skill archive
|
||||
@@ -555,6 +585,8 @@ async def update_skill(skill_id: str, body: SkillUpdate):
|
||||
meta["description"] = body.description
|
||||
if body.command is not None:
|
||||
meta["command"] = body.command
|
||||
if body.enabled is not None:
|
||||
meta["enabled"] = body.enabled
|
||||
index[skill_id] = meta
|
||||
save_index(index)
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ def portable_skills() -> List[PortableSkill]:
|
||||
out: List[PortableSkill] = []
|
||||
budget = MAX_TOTAL_SKILL_CHARS
|
||||
for skill in sync_skills():
|
||||
if skill.built_in or len(out) >= MAX_SKILLS:
|
||||
if skill.built_in or not skill.enabled or len(out) >= MAX_SKILLS:
|
||||
continue
|
||||
# The id becomes a directory name in the container, so it has to survive being one.
|
||||
slug = safe_slug(skill.id)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""The detail-page chrome's backend: the enable toggle must actually gate the agent-facing
|
||||
surfaces (Skill tool load + sync list), and the file picker endpoint lists a folder skill's
|
||||
text files with SKILL.md first."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import backend.apps.skills.skills as skills_mod
|
||||
from backend.apps.skills.models import SkillUpdate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_skills(tmp_path, monkeypatch):
|
||||
d = tmp_path / "skills"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(skills_mod, "SKILLS_DIR", str(d))
|
||||
monkeypatch.setattr(skills_mod, "INDEX_PATH", str(d / ".skills_index.json"))
|
||||
return d
|
||||
|
||||
|
||||
def seed(name: str, extra: dict[str, str] | None = None) -> str:
|
||||
files = {"SKILL.md": f"---\nname: {name}\ndescription: d\n---\nbody"}
|
||||
files.update(extra or {})
|
||||
skill = skills_mod.write_folder_skill(skills_mod.safe_slug(name), files, {"name": name, "description": "d"})
|
||||
return skill.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_gates_load_and_listing(isolated_skills):
|
||||
sid = seed("Togglable")
|
||||
assert all(s.enabled for s in skills_mod.sync_skills())
|
||||
|
||||
await skills_mod.update_skill(sid, SkillUpdate(enabled=False))
|
||||
target = next(s for s in skills_mod.sync_skills() if s.id == sid)
|
||||
assert target.enabled is False
|
||||
|
||||
res = await skills_mod.load_skill(skills_mod.SkillLoadRequest(id=sid))
|
||||
assert res["ok"] is False
|
||||
assert res["error"] == "skill_disabled"
|
||||
assert sid not in res["available"]
|
||||
|
||||
await skills_mod.update_skill(sid, SkillUpdate(enabled=True))
|
||||
res = await skills_mod.load_skill(skills_mod.SkillLoadRequest(id=sid))
|
||||
assert res["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_endpoint_lists_skill_md_first(isolated_skills):
|
||||
sid = seed("Multi", {"scripts/run.py": "print('hi')", "notes.txt": "n"})
|
||||
res = await skills_mod.list_skill_files(sid)
|
||||
paths = [f["path"] for f in res["files"]]
|
||||
assert paths[0] == "SKILL.md"
|
||||
assert "scripts/run.py" in paths
|
||||
assert "notes.txt" in paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_endpoint_404_for_unknown(isolated_skills):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await skills_mod.list_skill_files("nope")
|
||||
assert e.value.status_code == 404
|
||||
Reference in New Issue
Block a user