mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
* refactor(skills): extract evaluation and audit orchestration * chore(security): scope OpenAPI digest Gitleaks exception * refactor(skills): establish domain-oriented service boundaries * refactor(skills): address architecture review feedback * fix(skills): restore prompt seams and harden signature contracts * test(skills): restore ReDoS coverage and simplify API baseline
871 lines
36 KiB
Python
871 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.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}
|
|
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
|