mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
update_skill only checked whether the target directory existed. A skill
with that name in another category lives elsewhere on disk, so the
rename went through and left two skills with one name. Update and
delete look skills up by name, so the next delete removed whichever
file os.walk reached first.
Check the owner's skills in every category before renaming, and answer
409 from PUT /api/skills/{id} instead of the misleading 404.
876 lines
36 KiB
Python
876 lines
36 KiB
Python
# routes/skills_routes.py
|
|
"""REST API for the Skills system.
|
|
|
|
The on-disk format is SKILL.md (frontmatter + structured body) under
|
|
`data/skills/<category>/<name>/`. Old shape (`title`, `problem`, `solution`,
|
|
`steps`) still accepted on input — they're translated to the new fields
|
|
(`description`, `when_to_use`, `body_extra`, `procedure`).
|
|
"""
|
|
|
|
import logging
|
|
from typing import List, Optional
|
|
|
|
import httpx
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from services.memory.skill_format import slugify
|
|
from services.memory.skills import SkillsManager
|
|
from src.auth_helpers import get_current_user
|
|
from services.skills import evaluation as _evaluation
|
|
from services.skills import agent_runner as _agent_runner
|
|
from services.skills import audit_workflow as _audit_workflow
|
|
from services.skills import model_selection as _model_selection
|
|
from services.skills import prompts as _prompts
|
|
from services.skills import publication as _publication
|
|
from services.skills import job_coordinator as _job_coordinator
|
|
from services.skills.dependencies import AuditDependencies
|
|
from src.utility_effort import effort_for_call
|
|
from core.middleware import require_admin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_VERDICT_PROSE_RE = _evaluation._VERDICT_PROSE_RE
|
|
|
|
_verdict_efficiency = _evaluation.verdict_efficiency
|
|
|
|
# Compatibility bindings keep existing imports and offline patch targets.
|
|
# Implementations receive current collaborators through _audit_dependencies.
|
|
|
|
|
|
class SkillAddRequest(BaseModel):
|
|
# New schema (preferred)
|
|
name: Optional[str] = Field(None, max_length=80)
|
|
description: Optional[str] = Field(None, max_length=200)
|
|
category: str = Field("general", max_length=40)
|
|
tags: List[str] = Field(default_factory=list)
|
|
platforms: List[str] = Field(default_factory=list)
|
|
requires_toolsets: List[str] = Field(default_factory=list)
|
|
fallback_for_toolsets: List[str] = Field(default_factory=list)
|
|
when_to_use: Optional[str] = Field(None, max_length=2000)
|
|
procedure: List[str] = Field(default_factory=list)
|
|
pitfalls: List[str] = Field(default_factory=list)
|
|
verification: List[str] = Field(default_factory=list)
|
|
status: str = "draft"
|
|
version: str = "1.0.0"
|
|
confidence: float = 0.8
|
|
# Manual adds via this endpoint are human-authored → "user", which exempts
|
|
# them from auto-dedup and cap-eviction in add_skill. (The agent's own
|
|
# skill writes go through do_manage_skills with source="learned".)
|
|
source: str = "user"
|
|
teacher_model: Optional[str] = None
|
|
session_id: Optional[str] = None
|
|
|
|
# Old schema (back-compat)
|
|
title: Optional[str] = Field(None, max_length=200)
|
|
problem: Optional[str] = Field(None, max_length=2000)
|
|
solution: Optional[str] = Field(None, max_length=5000)
|
|
steps: List[str] = Field(default_factory=list)
|
|
|
|
|
|
class SkillImportUrlRequest(BaseModel):
|
|
url: str = Field(..., min_length=8, max_length=2000)
|
|
|
|
|
|
class SkillUpdateRequest(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
platforms: Optional[List[str]] = None
|
|
requires_toolsets: Optional[List[str]] = None
|
|
fallback_for_toolsets: Optional[List[str]] = None
|
|
when_to_use: Optional[str] = None
|
|
procedure: Optional[List[str]] = None
|
|
pitfalls: Optional[List[str]] = None
|
|
verification: Optional[List[str]] = None
|
|
status: Optional[str] = None
|
|
version: Optional[str] = None
|
|
confidence: Optional[float] = None
|
|
body_extra: Optional[str] = None
|
|
# Old shape
|
|
title: Optional[str] = None
|
|
problem: Optional[str] = None
|
|
solution: Optional[str] = None
|
|
steps: Optional[List[str]] = None
|
|
|
|
|
|
_skill_test_task = _prompts._skill_test_task
|
|
|
|
|
|
def _skill_baseline_task(skill: dict) -> str:
|
|
"""Compatibility alias using the currently bound shared task builder."""
|
|
return _skill_test_task(skill)
|
|
|
|
|
|
_skill_test_messages = _prompts._skill_test_messages
|
|
|
|
|
|
_skill_baseline_messages = _prompts._skill_baseline_messages
|
|
|
|
|
|
async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
|
url: str, model: str, headers: Optional[dict],
|
|
baseline_transcript: str = "",
|
|
skill_stats: Optional[dict] = None,
|
|
baseline_stats: Optional[dict] = None,
|
|
workload: str = "foreground", owner: Optional[str] = None) -> dict:
|
|
return await _evaluation.evaluate_run(
|
|
skill_md, task, transcript, url, model, headers, baseline_transcript,
|
|
skill_stats, baseline_stats, workload, owner, _effort_for_call=effort_for_call,
|
|
)
|
|
|
|
|
|
async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: str,
|
|
headers: Optional[dict],
|
|
workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]:
|
|
return await _evaluation.evaluate_necessity(
|
|
skill_md, others, url, model, headers, workload, owner,
|
|
_effort_for_call=effort_for_call,
|
|
)
|
|
|
|
|
|
_should_check_retrieval_precision = _evaluation.should_check_retrieval_precision
|
|
|
|
|
|
async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
|
url: str, model: str,
|
|
headers: Optional[dict],
|
|
workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]:
|
|
return await _evaluation.evaluate_retrieval_precision(
|
|
skill_md, others, url, model, headers, workload, owner,
|
|
_effort_for_call=effort_for_call,
|
|
)
|
|
|
|
|
|
# In-memory skill-test jobs, keyed by (owner, skill_name). Runs server-side so
|
|
# the test survives the modal being closed; the UI polls /test-status. (Not
|
|
# persisted across restart — it's a "come back in a bit" convenience.)
|
|
_skill_test_jobs = _agent_runner._skill_test_jobs
|
|
|
|
|
|
async def _run_skill_test_job(
|
|
key,
|
|
name,
|
|
md,
|
|
task,
|
|
url,
|
|
model,
|
|
headers,
|
|
owner,
|
|
skills_manager=None,
|
|
*,
|
|
messages=None,
|
|
transcript=None,
|
|
exact_approval=None,
|
|
request_authority=None,
|
|
):
|
|
return await _agent_runner.run_test_job(
|
|
key, name, md, task, url, model, headers, owner, skills_manager,
|
|
messages=messages, transcript=transcript, exact_approval=exact_approval,
|
|
request_authority=request_authority, deps=_audit_dependencies(),
|
|
)
|
|
|
|
|
|
# ── Autonomous skill audit: test → judge → self-edit → retry → teacher → flag ──
|
|
_skill_audit_jobs = _audit_workflow._skill_audit_jobs
|
|
|
|
|
|
_audit_auto_publish_policy = _publication.auto_publish_policy
|
|
|
|
|
|
_skill_duplicate_blocker = _publication.duplicate_blocker
|
|
|
|
|
|
def _finalize_audit_batch(skills_manager, results: list[dict], owner, log) -> None:
|
|
return _publication.finalize_batch(
|
|
skills_manager, results, owner, log, deps=_audit_dependencies(),
|
|
)
|
|
|
|
|
|
_audit_flag_text = _publication.audit_flag_text
|
|
|
|
|
|
_audit_utility_blocker = _publication.utility_blocker
|
|
|
|
|
|
def _audit_finalize_status(skills_manager, name: str, owner, verdict: str,
|
|
confidence: Optional[float], necessity: Optional[dict] = None,
|
|
verdict_data: Optional[dict] = None) -> str:
|
|
return _publication.finalize_status(
|
|
skills_manager, name, owner, verdict, confidence, necessity, verdict_data,
|
|
deps=_audit_dependencies(),
|
|
)
|
|
|
|
|
|
_apply_skill_md = _publication.apply_markdown
|
|
|
|
|
|
SkillAuditUnavailable = _agent_runner.SkillAuditUnavailable
|
|
|
|
|
|
async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
|
|
workload: str = "foreground") -> tuple[str, dict, Optional[dict]]:
|
|
return await _agent_runner.run_audit_arm(messages, url, model, headers, owner, workload)
|
|
|
|
|
|
async def _run_skill_test_once(md: str, task: str, url, model, headers, owner,
|
|
workload: str = "foreground") -> tuple:
|
|
return await _agent_runner.run_test_once(
|
|
md, task, url, model, headers, owner, workload, deps=_audit_dependencies(),
|
|
)
|
|
|
|
|
|
async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url, model, headers,
|
|
workload: str = "foreground", owner: Optional[str] = None):
|
|
return await _evaluation.improve_markdown(
|
|
skill_md, verdict, transcript, url, model, headers, workload, owner,
|
|
_effort_for_call=effort_for_call,
|
|
)
|
|
|
|
|
|
async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|
teacher, owner, log, workload: str = "foreground") -> dict:
|
|
return await _audit_workflow.audit_one_skill(
|
|
skills_manager, skill, url, model, headers, teacher, owner, log, workload,
|
|
deps=_audit_dependencies(),
|
|
)
|
|
|
|
|
|
async def _run_audit_all_job(key, skills_manager, names, url, model, headers, teacher, owner,
|
|
workload: str = "foreground"):
|
|
return await _audit_workflow.run_audit_batch(
|
|
key, skills_manager, names, url, model, headers, teacher, owner, workload,
|
|
deps=_audit_dependencies(),
|
|
)
|
|
|
|
|
|
def _resolve_audit_models(owner=None, model_spec=None, endpoint_url=None):
|
|
return _model_selection.resolve_audit_models(owner, model_spec, endpoint_url)
|
|
|
|
|
|
async def run_scheduled_skill_audit(skills_manager: SkillsManager,
|
|
owner: Optional[str] = None,
|
|
max_skills: int = 8) -> dict:
|
|
return await _audit_workflow.run_scheduled_skill_audit(
|
|
skills_manager, owner, max_skills, deps=_audit_dependencies(),
|
|
)
|
|
|
|
|
|
def _audit_dependencies() -> AuditDependencies:
|
|
"""Bind current compatibility symbols without shared mutable rebinding."""
|
|
return AuditDependencies(
|
|
test_jobs=_skill_test_jobs,
|
|
audit_jobs=_skill_audit_jobs,
|
|
test_task=_skill_test_task,
|
|
test_messages=_skill_test_messages,
|
|
baseline_messages=_skill_baseline_messages,
|
|
evaluate_run=_eval_skill_run,
|
|
evaluate_necessity=_eval_skill_necessity,
|
|
evaluate_retrieval_precision=_eval_skill_retrieval_precision,
|
|
should_check_retrieval_precision=_should_check_retrieval_precision,
|
|
improve_markdown=_improve_skill_md,
|
|
apply_markdown=_apply_skill_md,
|
|
duplicate_blocker=_skill_duplicate_blocker,
|
|
finalize_status=_audit_finalize_status,
|
|
finalize_batch=_finalize_audit_batch,
|
|
auto_publish_policy=_audit_auto_publish_policy,
|
|
utility_blocker=_audit_utility_blocker,
|
|
verdict_efficiency=_verdict_efficiency,
|
|
run_test_job=_run_skill_test_job,
|
|
run_arm=_run_skill_audit_arm,
|
|
run_test_once=_run_skill_test_once,
|
|
audit_one=_audit_one_skill,
|
|
run_batch=_run_audit_all_job,
|
|
resolve_models=_resolve_audit_models,
|
|
)
|
|
|
|
|
|
def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|
router = APIRouter(prefix="/api/skills", tags=["skills"])
|
|
|
|
def _owner(request: Request) -> Optional[str]:
|
|
return get_current_user(request)
|
|
|
|
def _verify_owner(skill: dict, user: Optional[str]):
|
|
if user is None:
|
|
return
|
|
# SECURITY: strict check — previously `sk_owner and sk_owner != user`
|
|
# let any user mutate/read a skill that happened to have no owner
|
|
# field (legacy or un-stamped writes), since the truthiness guard
|
|
# short-circuited the comparison. Treat missing owner as not-owned.
|
|
if skill.get("owner") != user and not (
|
|
skill.get("source") == "builtin" and not skill.get("owner")
|
|
):
|
|
raise HTTPException(404, "Skill not found")
|
|
|
|
def _fire_skill_added(user: Optional[str]):
|
|
try:
|
|
from src.event_bus import fire_event
|
|
fire_event("skill_added", user)
|
|
except Exception:
|
|
logger.debug("skill_added event dispatch failed", exc_info=True)
|
|
|
|
@router.get("")
|
|
async def list_skills(request: Request):
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
return {"skills": skills, "count": len(skills)}
|
|
|
|
@router.get("/index")
|
|
async def get_index(request: Request):
|
|
"""The lightweight `[{name, description, category}]` list that the
|
|
agent's system prompt sees. Useful for the UI's "what does the model
|
|
actually have access to?" view."""
|
|
user = _owner(request)
|
|
idx = skills_manager.index_for(owner=user)
|
|
return {"index": idx, "count": len(idx)}
|
|
|
|
@router.get("/slash-catalog")
|
|
async def get_slash_catalog(request: Request):
|
|
"""Return skills that are available as slash commands.
|
|
|
|
Mirrors the agent prompt's published-skill index so the UI never offers
|
|
a slash command the model would not normally be allowed to discover.
|
|
"""
|
|
user = _owner(request)
|
|
all_skills = {s.get("name"): s for s in skills_manager.load(owner=user)}
|
|
entries = []
|
|
for s in skills_manager.index_for(owner=user):
|
|
name = (s.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
full = all_skills.get(name) or {}
|
|
category = (s.get("category") or full.get("category") or "general").strip() or "general"
|
|
entries.append({
|
|
"type": "skill",
|
|
"token": f"/{name}",
|
|
"name": name,
|
|
"category": f"Skills / {category}",
|
|
"help": s.get("description") or full.get("description") or "",
|
|
"usage": f"/{name} <request>",
|
|
"uses": int(full.get("uses") or 0),
|
|
"last_used": full.get("last_used"),
|
|
})
|
|
entries.sort(key=lambda row: row["name"])
|
|
return {"skills": entries, "count": len(entries)}
|
|
|
|
@router.get("/builtin")
|
|
async def list_builtin_skills(request: Request):
|
|
"""Read-only list of the agent's built-in tool capabilities (research,
|
|
sessions, tasks, email, etc.) — the things it natively knows how to do.
|
|
Surfaced so the Skills tab can show them in a separate "Built-in"
|
|
section alongside the user's learned SKILL.md skills. Sourced from
|
|
agent_loop.TOOL_SECTIONS (the same descriptions the model is given)."""
|
|
import re
|
|
|
|
def _clean(raw: str) -> str:
|
|
s = raw or ""
|
|
s = re.sub(r"```.*?```", "", s, flags=re.S) # drop code fences (incl. inline ```name```)
|
|
s = re.sub(r"\s+", " ", s).strip()
|
|
s = re.sub(r"^[-–—:\s]+", "", s) # drop leftover "- — " / ": " bullet prefix
|
|
return s[:240]
|
|
|
|
try:
|
|
from src.agent_loop import TOOL_SECTIONS, get_builtin_overrides
|
|
except Exception as e:
|
|
return {"builtin": [], "count": 0, "error": str(e)}
|
|
|
|
overrides = get_builtin_overrides()
|
|
out = []
|
|
for key, raw in TOOL_SECTIONS.items():
|
|
names = key if isinstance(key, tuple) else (key,)
|
|
for nm in names:
|
|
if isinstance(nm, str):
|
|
overridden = nm in overrides
|
|
eff = overrides.get(nm, raw)
|
|
out.append({
|
|
"name": nm,
|
|
"description": _clean(eff),
|
|
"is_overridden": overridden,
|
|
})
|
|
out.sort(key=lambda x: x["name"])
|
|
return {"builtin": out, "count": len(out)}
|
|
|
|
@router.get("/builtin/{name}")
|
|
async def get_builtin_skill(name: str, request: Request):
|
|
"""Full text of a built-in tool's instruction block — the override
|
|
if one is set, plus the shipped default (for the revert button)."""
|
|
try:
|
|
from src.agent_loop import TOOL_SECTIONS, get_builtin_overrides
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
default = None
|
|
for key, raw in TOOL_SECTIONS.items():
|
|
names = key if isinstance(key, tuple) else (key,)
|
|
if name in names:
|
|
default = raw
|
|
break
|
|
if default is None:
|
|
raise HTTPException(404, f"No built-in tool named {name!r}")
|
|
overrides = get_builtin_overrides()
|
|
return {
|
|
"name": name,
|
|
"text": overrides.get(name, default),
|
|
"default": default,
|
|
"is_overridden": name in overrides,
|
|
}
|
|
|
|
@router.put("/builtin/{name}")
|
|
async def set_builtin_override(name: str, request: Request):
|
|
"""Save a user override for a built-in tool's instruction block.
|
|
WARNING surfaced in the UI — this changes how the assistant is
|
|
told to use a native tool."""
|
|
require_admin(request)
|
|
from src.agent_loop import TOOL_SECTIONS
|
|
valid = set()
|
|
for key in TOOL_SECTIONS:
|
|
valid.update(key if isinstance(key, tuple) else (key,))
|
|
if name not in valid:
|
|
raise HTTPException(404, f"No built-in tool named {name!r}")
|
|
body = await request.json()
|
|
text = (body or {}).get("text", "")
|
|
if not isinstance(text, str) or not text.strip():
|
|
raise HTTPException(400, "text is required")
|
|
from src.settings import get_setting, save_settings, load_settings
|
|
settings = load_settings()
|
|
ov = settings.get("builtin_tool_overrides")
|
|
if not isinstance(ov, dict):
|
|
ov = {}
|
|
ov[name] = text
|
|
settings["builtin_tool_overrides"] = ov
|
|
save_settings(settings)
|
|
return {"ok": True, "name": name, "is_overridden": True}
|
|
|
|
@router.delete("/builtin/{name}")
|
|
async def reset_builtin_override(name: str, request: Request):
|
|
"""Revert a built-in tool to its shipped instruction block."""
|
|
require_admin(request)
|
|
from src.settings import load_settings, save_settings
|
|
settings = load_settings()
|
|
ov = settings.get("builtin_tool_overrides")
|
|
if isinstance(ov, dict) and name in ov:
|
|
del ov[name]
|
|
settings["builtin_tool_overrides"] = ov
|
|
save_settings(settings)
|
|
return {"ok": True, "name": name, "is_overridden": False}
|
|
|
|
@router.post("/import-from-url")
|
|
async def import_skill_from_url(request: Request, body: SkillImportUrlRequest):
|
|
"""Install a SKILL.md bundle from a public GitHub URL (skills.sh links supported)."""
|
|
require_admin(request)
|
|
user = _owner(request)
|
|
from services.memory.skill_importer import (
|
|
SkillImportError,
|
|
fetch_skill_bundle,
|
|
)
|
|
|
|
try:
|
|
files, _src = fetch_skill_bundle(body.url.strip())
|
|
entry = skills_manager.import_bundle_from_files(
|
|
files,
|
|
owner=user,
|
|
source_url=body.url.strip(),
|
|
)
|
|
except SkillImportError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
except httpx.HTTPError as e:
|
|
logger.warning("skill import fetch failed: %s", e)
|
|
detail = str(e).strip() or "Could not download skill from URL"
|
|
raise HTTPException(502, detail) from e
|
|
except Exception as e:
|
|
logger.error("skill import failed: %s", e)
|
|
raise HTTPException(500, "Skill import failed") from e
|
|
|
|
_fire_skill_added(user)
|
|
return {"ok": True, "skill": entry, "files": len(files)}
|
|
|
|
@router.post("/add")
|
|
async def add_skill(request: Request, body: SkillAddRequest):
|
|
user = _owner(request)
|
|
entry = skills_manager.add_skill(
|
|
# New shape
|
|
name=body.name,
|
|
description=body.description,
|
|
category=body.category,
|
|
tags=body.tags,
|
|
platforms=body.platforms,
|
|
requires_toolsets=body.requires_toolsets,
|
|
fallback_for_toolsets=body.fallback_for_toolsets,
|
|
when_to_use=body.when_to_use,
|
|
procedure=body.procedure,
|
|
pitfalls=body.pitfalls,
|
|
verification=body.verification,
|
|
status=body.status,
|
|
version=body.version,
|
|
confidence=body.confidence,
|
|
source=body.source,
|
|
teacher_model=body.teacher_model,
|
|
session_id=body.session_id,
|
|
owner=user,
|
|
# Old shape (manager translates)
|
|
title=body.title or "",
|
|
problem=body.problem or "",
|
|
solution=body.solution or "",
|
|
steps=body.steps,
|
|
)
|
|
if not entry.get("_deduped"):
|
|
_fire_skill_added(user)
|
|
return {"ok": True, "deduped": bool(entry.get("_deduped")), "skill": entry}
|
|
|
|
@router.post("/{skill_id}/invoke")
|
|
async def invoke_skill(request: Request, skill_id: str):
|
|
"""Build a skill-pinned prompt for slash-command invocation.
|
|
|
|
This is intentionally server-side so availability, ownership, and usage
|
|
accounting use the same rules as the SkillsManager.
|
|
"""
|
|
user = _owner(request)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
request_text = (body.get("request") or "").strip() if isinstance(body, dict) else ""
|
|
|
|
invokable = {
|
|
s.get("name"): s for s in skills_manager.index_for(owner=user)
|
|
if (s.get("name") or "").strip()
|
|
}
|
|
match = invokable.get(skill_id)
|
|
if not match:
|
|
raise HTTPException(404, "Skill is not available for slash invocation")
|
|
|
|
name = match.get("name")
|
|
md = skills_manager.read_skill_md(name, owner=user)
|
|
if md is None:
|
|
raise HTTPException(404, "Skill source unavailable")
|
|
|
|
skills_manager.record_use(name, owner=user)
|
|
message = (
|
|
"Apply the skill below to my request, following its Procedure / Pitfalls / Verification.\n\n"
|
|
f"--- BEGIN SKILL ---\n{md}\n--- END SKILL ---\n\n"
|
|
+ (f"Request: {request_text}" if request_text else "Request: (use the skill as appropriate)")
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"type": "skill",
|
|
"name": name,
|
|
"command": f"/{name}",
|
|
"message": message,
|
|
}
|
|
|
|
@router.get("/{skill_id}")
|
|
async def get_skill(request: Request, skill_id: str):
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
for sk in skills:
|
|
if sk.get("name") == skill_id or sk.get("id") == skill_id:
|
|
return sk
|
|
raise HTTPException(404, "Skill not found")
|
|
|
|
@router.get("/{skill_id}/markdown")
|
|
async def get_skill_markdown(request: Request, skill_id: str):
|
|
"""Return the raw SKILL.md text — used by the slash-invocation flow
|
|
and the editor's 'view source' affordance."""
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
|
if not match:
|
|
raise HTTPException(404, "Skill not found")
|
|
_verify_owner(match, user)
|
|
# Some legacy records are identified by ``id`` but do not carry a
|
|
# separate name. Use the same resolved identifier that the list route
|
|
# exposes so those records remain previewable.
|
|
skill_name = match.get("name") or match.get("id")
|
|
md = skills_manager.read_skill_md(skill_name, owner=user)
|
|
if md is None:
|
|
raise HTTPException(404, "Skill source unavailable (legacy entry?)")
|
|
return {"name": skill_name, "markdown": md}
|
|
|
|
@router.post("/{skill_id}/test")
|
|
async def test_skill(request: Request, skill_id: str):
|
|
"""Kick off a background skill test (agent run + LLM judge). Returns
|
|
immediately; the run executes server-side so it survives the modal being
|
|
closed. Poll GET /{skill_id}/test-status for progress + verdict.
|
|
On completion it records the verdict and nudges the skill's confidence
|
|
to match (pass→0.95, needs_work→0.6, fail→0.4; inconclusive/unknown leave
|
|
it untouched). It never changes the skill's published/draft STATUS."""
|
|
from core.database import SessionLocal
|
|
from src.endpoint_resolver import (
|
|
build_chat_url, build_headers, resolve_endpoint,
|
|
resolve_endpoint_runtime, resolve_owner_registered_endpoint,
|
|
)
|
|
|
|
user = _owner(request)
|
|
body = await request.json()
|
|
task = (body.get("task") or "").strip()
|
|
from src.agent_runtime.authority import RequestAuthority, request_authority_for_http
|
|
request_authority = (request_authority_for_http(request, task, owner=user)
|
|
if task else RequestAuthority.empty(owner=user))
|
|
|
|
skills = skills_manager.load(owner=user)
|
|
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
|
if not match:
|
|
raise HTTPException(404, "Skill not found")
|
|
_verify_owner(match, user)
|
|
name = match.get("name")
|
|
md = skills_manager.read_skill_md(name, owner=user) or ""
|
|
|
|
if not task:
|
|
task = _skill_test_task(match)
|
|
|
|
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
|
# session's model. Fall back to the caller's session model only if unset.
|
|
url, model, headers = resolve_endpoint("utility", owner=user)
|
|
if not url or not model:
|
|
if not url and body.get("endpoint_url") is not None:
|
|
db = SessionLocal()
|
|
try:
|
|
endpoint = resolve_owner_registered_endpoint(db, body["endpoint_url"], user)
|
|
base, api_key = resolve_endpoint_runtime(endpoint, owner=user)
|
|
url = build_chat_url(base)
|
|
headers = build_headers(api_key, base)
|
|
except ValueError as exc:
|
|
raise HTTPException(400, str(exc)) from exc
|
|
finally:
|
|
db.close()
|
|
model = model or ((body.get("model") or "").strip() or None)
|
|
if not url or not model:
|
|
raise HTTPException(400, "No model configured — set a Default or Utility model in Settings.")
|
|
|
|
# Normalize against the endpoint's served models (avoids 404 model drift).
|
|
try:
|
|
from src.llm_core import list_model_ids
|
|
_avail = list_model_ids(url, headers=headers)
|
|
if _avail and model not in _avail:
|
|
import os as _os
|
|
_base = _os.path.basename((model or "").rstrip("/"))
|
|
_match = next((a for a in _avail if _os.path.basename(a.rstrip("/")) == _base), None)
|
|
model = _match or _avail[0]
|
|
except Exception as _e:
|
|
logger.warning(f"Skill-test model resolve failed: {_e}")
|
|
|
|
return _job_coordinator.start_test_job(
|
|
name, md, task, url, model, headers, user, skills_manager,
|
|
request_authority, deps=_audit_dependencies(),
|
|
)
|
|
|
|
@router.post("/{skill_id}/test-approval")
|
|
async def approve_skill_test_action(request: Request, skill_id: str):
|
|
"""Resume a manual skill test with one exact server-sealed action."""
|
|
from src.tool_approvals import tool_approval_store
|
|
from src.agent_runtime.authority import require_user_approval_request
|
|
require_user_approval_request(request)
|
|
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
match = next(
|
|
(s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id),
|
|
None,
|
|
)
|
|
if not match:
|
|
raise HTTPException(404, "Skill not found")
|
|
_verify_owner(match, user)
|
|
name = match.get("name")
|
|
key = (user or "", name)
|
|
job = _skill_test_jobs.get(key)
|
|
if not job or job.get("status") != "awaiting_approval":
|
|
raise HTTPException(409, "This skill test is not awaiting an approval.")
|
|
|
|
body = await request.json()
|
|
if not isinstance(body, dict):
|
|
raise HTTPException(400, "Tool approval body must be a JSON object.")
|
|
approval_id = str(body.get("approval_id") or "")
|
|
decision = str(body.get("decision") or "").strip().lower()
|
|
expected = job.get("approval") or {}
|
|
if approval_id != str(expected.get("approval_id") or ""):
|
|
raise HTTPException(409, "This approval does not match the pending skill test action.")
|
|
if decision not in {"approve", "deny"}:
|
|
raise HTTPException(400, "Invalid tool approval decision.")
|
|
|
|
pending = tool_approval_store.peek(approval_id)
|
|
normalized_owner = str(user or "").strip().casefold()
|
|
if (
|
|
pending is None
|
|
or pending.owner != normalized_owner
|
|
or pending.session_id != ""
|
|
):
|
|
raise HTTPException(409, "This tool approval is invalid or expired.")
|
|
exact_approval = tool_approval_store.consume(
|
|
approval_id,
|
|
decision=decision,
|
|
owner=user,
|
|
session_id=None,
|
|
# The button here says "Allow once" and there is no chat to carry a
|
|
# scope into, so the gate must re-arm behind the sealed action.
|
|
allow_continuation=False,
|
|
)
|
|
|
|
if decision == "approve" and exact_approval is None:
|
|
raise HTTPException(409, "This tool approval could not be consumed.")
|
|
return _job_coordinator.continue_test_job(
|
|
job, key, name, decision, expected, exact_approval,
|
|
skills_manager, deps=_audit_dependencies(),
|
|
)
|
|
|
|
@router.get("/{skill_id}/test-status")
|
|
async def test_skill_status(request: Request, skill_id: str):
|
|
"""Current background-test state for a skill (status / log / verdict)."""
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
|
name = (match or {}).get("name", skill_id)
|
|
return _job_coordinator.test_status(user, name, deps=_audit_dependencies())
|
|
|
|
@router.post("/audit-all")
|
|
async def audit_all_skills(request: Request):
|
|
"""Kick off a background audit of skills: each is tested + judged; if it
|
|
needs work the model self-edits and retries; if a teacher model is
|
|
configured it escalates; a skill that still fails is demoted to draft
|
|
(never deleted). Poll GET /audit-status. Body:
|
|
{scope: 'drafts'|'unchecked'|'all', names?: [...], skip_audited?: bool}. Default 'all'
|
|
means every visible skill, including already-published skills, so audit
|
|
can publish or demote according to the confidence threshold."""
|
|
|
|
user = _owner(request)
|
|
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
|
|
scope = (body.get("scope") or "all").lower()
|
|
requested_names = body.get("names")
|
|
skip_audited = bool(body.get("skip_audited"))
|
|
requested_model = str(body.get("model") or "").strip() or None
|
|
|
|
key = (user or "",)
|
|
existing = _skill_audit_jobs.get(key)
|
|
if existing and existing.get("status") == "running":
|
|
return {
|
|
"ok": True, "status": "running", "total": existing.get("total", 0),
|
|
"done": existing.get("done", 0), "model": existing.get("model"),
|
|
}
|
|
|
|
# Worker model (Default, normalized) + optional teacher — shared resolver.
|
|
try:
|
|
url, model, headers, teacher = _resolve_audit_models(owner=user, model_spec=requested_model)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
names, scope = _job_coordinator.select_audit_names(
|
|
skills_manager, user, scope, requested_names, skip_audited,
|
|
)
|
|
return _job_coordinator.start_audit_job(
|
|
key, skills_manager, names, url, model, headers, teacher, user,
|
|
scope, deps=_audit_dependencies(),
|
|
)
|
|
|
|
@router.get("/audit-all/status")
|
|
async def audit_status(request: Request):
|
|
user = _owner(request)
|
|
return _job_coordinator.audit_status(user, deps=_audit_dependencies())
|
|
|
|
@router.post("/audit-all/cancel")
|
|
async def audit_cancel(request: Request):
|
|
user = _owner(request)
|
|
return _job_coordinator.cancel_audit(user, deps=_audit_dependencies())
|
|
|
|
@router.post("/{skill_id}/markdown")
|
|
async def save_skill_markdown(request: Request, skill_id: str):
|
|
"""Replace SKILL.md with new raw content. Parses + validates first."""
|
|
from services.memory.skill_format import Skill
|
|
user = _owner(request)
|
|
body = await request.json()
|
|
new_content = body.get("markdown")
|
|
if not isinstance(new_content, str) or not new_content.strip():
|
|
raise HTTPException(400, "markdown is required")
|
|
skills = skills_manager.load(owner=user)
|
|
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
|
if not match:
|
|
raise HTTPException(404, "Skill not found")
|
|
_verify_owner(match, user)
|
|
try:
|
|
sk = Skill.from_markdown(new_content)
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Could not parse SKILL.md: {e}")
|
|
# Never rename on save: a changed `name` in the markdown would move
|
|
# the skill dir (update_skill) and orphan the original id, so a later
|
|
# delete 404s (#1333). Pin to the stored name, like _apply_skill_md.
|
|
sk.name = match.get("name")
|
|
if not sk.owner:
|
|
sk.owner = match.get("owner") or user
|
|
ok = skills_manager.update_skill(match.get("name"), {
|
|
"name": sk.name,
|
|
"description": sk.description,
|
|
"version": sk.version,
|
|
"category": sk.category,
|
|
"tags": sk.tags,
|
|
"platforms": sk.platforms,
|
|
"requires_toolsets": sk.requires_toolsets,
|
|
"fallback_for_toolsets": sk.fallback_for_toolsets,
|
|
"status": sk.status,
|
|
"confidence": sk.confidence,
|
|
"source": sk.source,
|
|
"teacher_model": sk.teacher_model,
|
|
"owner": sk.owner,
|
|
"when_to_use": sk.when_to_use,
|
|
"procedure": sk.procedure,
|
|
"pitfalls": sk.pitfalls,
|
|
"verification": sk.verification,
|
|
"body_extra": sk.body_extra,
|
|
}, owner=user)
|
|
if not ok:
|
|
raise HTTPException(500, "Update failed")
|
|
# Manual markdown edits can create or substantially rewrite a draft
|
|
# skill without going through /add. Treat unaudited saves as new audit
|
|
# candidates so the event-driven Skills Audit pipeline still runs.
|
|
if not match.get("audit_verdict"):
|
|
_fire_skill_added(user)
|
|
return {"ok": True, "name": sk.name}
|
|
|
|
@router.put("/{skill_id}")
|
|
async def update_skill(request: Request, skill_id: str, body: SkillUpdateRequest):
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
|
if not match:
|
|
raise HTTPException(404, "Skill not found")
|
|
_verify_owner(match, user)
|
|
|
|
updates = body.dict(exclude_none=True)
|
|
if not updates:
|
|
return {"ok": True}
|
|
if "name" in updates:
|
|
new_name = slugify(updates["name"] or match.get("name"))
|
|
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
|
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
|
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
|
if not ok:
|
|
raise HTTPException(404, "Skill not found")
|
|
if not match.get("audit_verdict"):
|
|
_fire_skill_added(user)
|
|
return {"ok": True}
|
|
|
|
@router.delete("/{skill_id}")
|
|
async def delete_skill(request: Request, skill_id: str):
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
|
if not match:
|
|
raise HTTPException(404, "Skill not found")
|
|
_verify_owner(match, user)
|
|
ok = skills_manager.delete_skill(match.get("name"), owner=user)
|
|
if not ok:
|
|
raise HTTPException(404, "Skill not found")
|
|
return {"ok": True}
|
|
|
|
@router.post("/search")
|
|
async def search_skills(request: Request):
|
|
body = await request.json()
|
|
query = body.get("query", "")
|
|
if not query.strip():
|
|
raise HTTPException(400, "query is required")
|
|
user = _owner(request)
|
|
skills = skills_manager.load(owner=user)
|
|
results = skills_manager.get_relevant_skills(query, skills, max_items=10)
|
|
return {"skills": results, "query": query, "count": len(results)}
|
|
|
|
return router
|