调度健壮性:
- 技能进化总开关 skill_evolution.enabled 真正生效;CuratorConfig
与 CuratorSubConfig 默认 enabled 改为 false(显式 opt-in)
- 新增 per-user 运行锁(.curator_state 的 running/started_at),
防定时器与手动触发并发重复运行;6h stale 检测防进程残留死锁
- maybe_run_curator 改用 ThreadPoolExecutor 限并发(上限 5),
防首次部署所有用户同时到期的惊群
- 首次运行持续失败时按 6h 退避重试,不再每个 tick 烧 LLM 配额
- should_run_now 时间解析容忍 naive 时间串;损坏的 last_run_at
触发重跑而非永久死锁
- SkillStore 新增 list_published_names 批量接口,curator 一轮
只查一次 published,取代逐技能查库
LLM 评审超时:
- _run_llm_review 给 agent.invoke 套整体超时
(curator.review_timeout_seconds,默认 600s),超时中止本次评审
并在 curator 日志输出,curator 正常收尾、释放运行锁
- copy_context 把 user 上下文传入工作线程
模型可配置:
- 新增 curator.model_name;curator 评审与技能安全审核共用该模型,
未配置则回退主模型
- 移除独立的 moderation_model_name 配置
- 前端进化面板新增模型下拉选择器
手动触发体验:
- /api/curator/run 派发后台线程前先预检,返回 {triggered, reason},
不再无论开关状态都提示"已开始"
- 前端:总开关/curator 开关未开时禁用"立即执行"按钮,
被预检拦下时按原因(开关未开 / 已有任务在跑)给出准确提示
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1121 lines
50 KiB
Python
1121 lines
50 KiB
Python
import json
|
|
import logging
|
|
import tempfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
|
from deerflow.persistence.skills import SkillStore
|
|
from deerflow.persistence.tags import TagStore
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
from deerflow.skills import Skill
|
|
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
|
from deerflow.skills.installer import SkillAlreadyExistsError
|
|
from deerflow.skills.security_scanner import scan_skill_content
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["skills"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-user authz helpers (mirrors routers/agents.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _current_user_id(request: Request) -> str:
|
|
user = getattr(request.state, "user", None)
|
|
if user is not None:
|
|
return str(user.id)
|
|
return get_effective_user_id()
|
|
|
|
|
|
def _is_admin_user(request: Request) -> bool:
|
|
user = getattr(request.state, "user", None)
|
|
return getattr(user, "system_role", None) == "admin"
|
|
|
|
|
|
async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
|
|
owned = await store.get_owned(name, user_id)
|
|
if owned is not None:
|
|
return owned
|
|
visible = await store.get_visible(name, user_id)
|
|
if visible is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
|
|
raise HTTPException(status_code=403, detail="You can only modify skills that you own")
|
|
|
|
|
|
async def _get_editable_skill_or_raise(
|
|
store: SkillStore,
|
|
name: str,
|
|
user_id: str,
|
|
*,
|
|
is_admin: bool,
|
|
) -> tuple[dict[str, Any], str]:
|
|
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
|
|
|
Admins can edit/delete any skill (including others' published ones, since
|
|
the spec lets them force-takedown). Non-admins are limited to their own.
|
|
"""
|
|
owned = await store.get_owned(name, user_id)
|
|
if owned is not None:
|
|
return owned, "owner"
|
|
record = await store.get_any(name)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
|
|
if is_admin:
|
|
return record, "admin"
|
|
raise HTTPException(status_code=403, detail="You can only modify skills that you own")
|
|
|
|
|
|
class SkillResponse(BaseModel):
|
|
"""Response model for skill information."""
|
|
|
|
name: str = Field(..., description="Name of the skill")
|
|
description: str = Field(..., description="Description of what the skill does")
|
|
license: str | None = Field(None, description="License information")
|
|
category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
|
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
|
use_count: int = Field(default=0, description="Number of times the skill has been used")
|
|
view_count: int = Field(default=0, description="Number of times the skill has been viewed")
|
|
patch_count: int = Field(default=0, description="Number of times the skill has been patched")
|
|
last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp")
|
|
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
|
|
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
|
|
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
|
|
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
|
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
|
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
|
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
|
|
|
|
|
|
class SkillsListResponse(BaseModel):
|
|
"""Response model for listing all skills."""
|
|
|
|
skills: list[SkillResponse]
|
|
|
|
|
|
class SkillUpdateRequest(BaseModel):
|
|
"""Request model for updating a skill."""
|
|
|
|
enabled: bool = Field(..., description="Whether to enable or disable the skill")
|
|
|
|
|
|
class SkillInstallRequest(BaseModel):
|
|
"""Request model for installing a skill from a .skill file."""
|
|
|
|
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
|
|
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
|
|
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
|
|
|
|
|
|
class SkillInstallResponse(BaseModel):
|
|
"""Response model for skill installation."""
|
|
|
|
success: bool = Field(..., description="Whether the installation was successful")
|
|
skill_name: str = Field(..., description="Name of the installed skill")
|
|
message: str = Field(..., description="Installation result message")
|
|
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
|
|
category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
|
|
|
|
|
|
class SkillUploadValidateResponse(BaseModel):
|
|
"""Response model for the pre-install validation endpoint."""
|
|
|
|
ok: bool = Field(..., description="Whether the archive passed cheap validation")
|
|
skill_name: str = Field(..., description="Skill name parsed from frontmatter")
|
|
description: str = Field(default="", description="Skill description parsed from frontmatter")
|
|
license: str | None = Field(default=None, description="Skill license, if declared")
|
|
allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
|
|
conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
|
|
conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
|
|
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
|
|
|
|
|
|
class CustomSkillContentResponse(SkillResponse):
|
|
content: str = Field(..., description="Raw SKILL.md content")
|
|
|
|
|
|
class CustomSkillUpdateRequest(BaseModel):
|
|
content: str = Field(..., description="Replacement SKILL.md content")
|
|
|
|
|
|
class CustomSkillHistoryResponse(BaseModel):
|
|
history: list[dict]
|
|
|
|
|
|
class SkillRollbackRequest(BaseModel):
|
|
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
|
|
|
|
|
|
def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse:
|
|
"""Convert a Skill object to a SkillResponse.
|
|
|
|
``ownership`` is the skill's record from ``SkillStore`` (or None for public
|
|
built-in skills which never have a DB row). When present, contributes the
|
|
``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user
|
|
visibility and the frontend's owner badges.
|
|
"""
|
|
from deerflow.skills.usage import get_entry, latest_activity_at
|
|
|
|
entry = get_entry(skill.name)
|
|
last_at_str = latest_activity_at(skill.name)
|
|
last_at: datetime | None = None
|
|
if last_at_str:
|
|
try:
|
|
last_at = datetime.fromisoformat(last_at_str)
|
|
except Exception:
|
|
pass
|
|
|
|
owner_user_id: str | None = None
|
|
published = False
|
|
builtin = skill.category != SkillCategory.CUSTOM
|
|
if ownership is not None:
|
|
owner_user_id = ownership.get("owner_user_id")
|
|
published = bool(ownership.get("published", False))
|
|
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
|
|
|
return SkillResponse(
|
|
name=skill.name,
|
|
description=skill.description,
|
|
license=skill.license,
|
|
category=skill.category,
|
|
enabled=skill.enabled,
|
|
use_count=entry.get("use_count", 0),
|
|
view_count=entry.get("view_count", 0),
|
|
patch_count=entry.get("patch_count", 0),
|
|
last_activity_at=last_at,
|
|
state=entry.get("state", "active"),
|
|
pinned=entry.get("pinned", False),
|
|
source=entry.get("source", "upload"),
|
|
owner_user_id=owner_user_id,
|
|
published=published,
|
|
builtin=builtin,
|
|
)
|
|
|
|
|
|
async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]:
|
|
"""Index visible-to-user skill records by name. Internal call cache for list endpoints."""
|
|
records = await skill_store.list_visible(user_id)
|
|
return {record["name"]: record for record in records}
|
|
|
|
|
|
async def _attach_and_filter_tags(
|
|
tag_store: TagStore,
|
|
responses: list[SkillResponse],
|
|
search: str | None,
|
|
tag_id: str | None,
|
|
) -> list[SkillResponse]:
|
|
"""Bulk-attach tags to skill responses, then apply name/tag filters."""
|
|
assignments = await tag_store.list_assignments("skill", [r.name for r in responses])
|
|
for resp in responses:
|
|
resp.tags = assignments.get(resp.name, [])
|
|
if search:
|
|
needle = search.strip().lower()
|
|
responses = [r for r in responses if needle in r.name.lower()]
|
|
if tag_id:
|
|
responses = [r for r in responses if any(t.get("id") == tag_id for t in r.tags)]
|
|
return responses
|
|
|
|
|
|
@router.get(
|
|
"/skills",
|
|
response_model=SkillsListResponse,
|
|
summary="List All Skills",
|
|
description="Retrieve a list of all available skills from both public and custom directories.",
|
|
)
|
|
async def list_skills(
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
tag_store: TagStore = Depends(get_tag_store),
|
|
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
|
|
tag_id: str | None = Query(default=None, description="Keep only skills carrying this tag"),
|
|
) -> SkillsListResponse:
|
|
try:
|
|
user_id = _current_user_id(request)
|
|
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
ownership = await _build_ownership_index(skill_store, user_id)
|
|
responses: list[SkillResponse] = []
|
|
for skill in all_skills:
|
|
# Public/built-in skills (no DB row) are visible to everyone.
|
|
# Custom skills must have an ownership record in the visible set.
|
|
if skill.category != SkillCategory.CUSTOM:
|
|
responses.append(_skill_to_response(skill))
|
|
continue
|
|
record = ownership.get(skill.name)
|
|
if record is None:
|
|
continue
|
|
responses.append(_skill_to_response(skill, record))
|
|
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_id)
|
|
return SkillsListResponse(skills=responses)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
|
|
|
|
|
_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
|
|
_ZIP_MAGIC = b"PK\x03\x04"
|
|
|
|
|
|
async def _persist_uploaded_skill(file: UploadFile) -> Path:
|
|
"""Stream an uploaded skill archive to a temp file. Caller must unlink it.
|
|
|
|
Validates the leading bytes look like a ZIP archive and enforces the same
|
|
512 MB cap that the extractor uses, so we reject obvious garbage early.
|
|
"""
|
|
if file.filename is None:
|
|
raise HTTPException(status_code=400, detail="No file provided")
|
|
suffix = Path(file.filename).suffix.lower()
|
|
if suffix not in {".zip", ".skill"}:
|
|
raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file")
|
|
|
|
fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-")
|
|
tmp_path = Path(tmp_name)
|
|
total = 0
|
|
head: bytes = b""
|
|
try:
|
|
with open(fd, "wb") as out:
|
|
while True:
|
|
chunk = await file.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
if not head:
|
|
head = chunk[: len(_ZIP_MAGIC)]
|
|
total += len(chunk)
|
|
if total > _MAX_SKILL_UPLOAD_BYTES:
|
|
raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit")
|
|
out.write(chunk)
|
|
except HTTPException:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
except Exception:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
if not head.startswith(_ZIP_MAGIC):
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
|
return tmp_path
|
|
|
|
|
|
@router.post(
|
|
"/skills/validate",
|
|
response_model=SkillUploadValidateResponse,
|
|
summary="Validate Skill Archive (No Install)",
|
|
description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
|
|
)
|
|
async def validate_skill_upload(
|
|
file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
|
|
config: AppConfig = Depends(get_config),
|
|
) -> SkillUploadValidateResponse:
|
|
tmp_path = await _persist_uploaded_skill(file)
|
|
try:
|
|
result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
|
|
return SkillUploadValidateResponse(**result)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to validate uploaded skill: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}")
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
@router.post(
|
|
"/skills/install-upload",
|
|
response_model=SkillInstallResponse,
|
|
summary="Install Skill from Upload",
|
|
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
|
|
)
|
|
async def install_skill_upload(
|
|
request: Request,
|
|
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
|
overwrite: bool = False,
|
|
target_category: SkillCategory = SkillCategory.CUSTOM,
|
|
config: AppConfig = Depends(get_config),
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
) -> SkillInstallResponse:
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
# Only admins may install to the public (built-in) directory.
|
|
if target_category == SkillCategory.PUBLIC and not is_admin:
|
|
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
|
|
tmp_path = await _persist_uploaded_skill(file)
|
|
try:
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
# Pre-parse the archive to learn the skill name without writing anything,
|
|
# so we can reject the upload before touching the filesystem if another
|
|
# user already owns this name. Admins bypass the ownership check since
|
|
# they install to the shared public directory.
|
|
try:
|
|
validation = storage.validate_skill_archive(tmp_path)
|
|
preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
|
|
except Exception:
|
|
preview_name = None
|
|
if preview_name and not is_admin:
|
|
existing = await skill_store.get_any(preview_name)
|
|
if existing and existing.get("owner_user_id") not in (None, user_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Skill '{preview_name}' already exists and is owned by another user",
|
|
)
|
|
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
|
|
try:
|
|
from deerflow.skills.usage import mark_uploaded
|
|
mark_uploaded(result["skill_name"])
|
|
except Exception:
|
|
pass
|
|
# For public installs (admin), register as built-in with no owner.
|
|
# For custom installs, register ownership under the uploading user.
|
|
if target_category == SkillCategory.PUBLIC:
|
|
await skill_store.ensure_legacy(
|
|
{"name": result["skill_name"], "owner_user_id": None, "published": True}
|
|
)
|
|
else:
|
|
# ``ensure_legacy`` is idempotent and only patches a NULL owner; it
|
|
# never overwrites an existing owner, so the pre-parse check above
|
|
# is what really guards cross-user steals.
|
|
await skill_store.ensure_legacy(
|
|
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
|
|
)
|
|
await refresh_skills_system_prompt_cache_async()
|
|
return SkillInstallResponse(**result)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except SkillAlreadyExistsError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to install uploaded skill: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}")
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
@router.post(
|
|
"/skills/install",
|
|
response_model=SkillInstallResponse,
|
|
summary="Install Skill",
|
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
|
)
|
|
async def install_skill(
|
|
body: SkillInstallRequest,
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
) -> SkillInstallResponse:
|
|
try:
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
target_category = body.target_category
|
|
if target_category == SkillCategory.PUBLIC and not is_admin:
|
|
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
|
|
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
|
|
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
|
|
try:
|
|
from deerflow.skills.usage import mark_uploaded
|
|
mark_uploaded(result["skill_name"])
|
|
except Exception:
|
|
pass
|
|
if target_category == SkillCategory.PUBLIC:
|
|
await skill_store.ensure_legacy(
|
|
{"name": result["skill_name"], "owner_user_id": None, "published": True}
|
|
)
|
|
else:
|
|
await skill_store.ensure_legacy(
|
|
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
|
|
)
|
|
await refresh_skills_system_prompt_cache_async()
|
|
return SkillInstallResponse(**result)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except SkillAlreadyExistsError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to install skill: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|
|
|
|
|
|
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
|
async def list_custom_skills(
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
) -> SkillsListResponse:
|
|
try:
|
|
user_id = _current_user_id(request)
|
|
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
|
|
ownership = await _build_ownership_index(skill_store, user_id)
|
|
responses = [
|
|
_skill_to_response(skill, ownership[skill.name])
|
|
for skill in custom
|
|
if skill.name in ownership
|
|
]
|
|
return SkillsListResponse(skills=responses)
|
|
except Exception as e:
|
|
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
|
|
|
|
|
|
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
|
async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name and s.category == SkillCategory.CUSTOM), None)
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
|
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to get custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get custom skill: {str(e)}")
|
|
|
|
|
|
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
|
async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
storage.ensure_custom_skill_is_editable(skill_name)
|
|
storage.validate_skill_markdown_content(skill_name, request.content)
|
|
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
|
if scan.decision == "block":
|
|
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
|
prev_content = storage.read_custom_skill(skill_name)
|
|
storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
|
|
storage.append_history(
|
|
skill_name,
|
|
{
|
|
"action": "human_edit",
|
|
"author": "human",
|
|
"thread_id": None,
|
|
"file_path": SKILL_MD_FILE,
|
|
"prev_content": prev_content,
|
|
"new_content": request.content,
|
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
|
},
|
|
)
|
|
try:
|
|
from deerflow.skills.usage import bump_patch
|
|
bump_patch(skill_name)
|
|
except Exception:
|
|
pass
|
|
await refresh_skills_system_prompt_cache_async()
|
|
return await get_custom_skill(skill_name, config)
|
|
except HTTPException:
|
|
raise
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error("Failed to update custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update custom skill: {str(e)}")
|
|
|
|
|
|
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
|
async def delete_custom_skill(
|
|
skill_name: str,
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
agent_store=Depends(get_agent_store),
|
|
) -> dict[str, bool]:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
|
operation = "admin_takedown" if mode == "admin" and record.get("owner_user_id") not in (None, user_id) else "delete"
|
|
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
storage.delete_custom_skill(
|
|
skill_name,
|
|
history_meta={
|
|
"action": "human_delete",
|
|
"author": "human",
|
|
"thread_id": None,
|
|
"file_path": SKILL_MD_FILE,
|
|
"prev_content": None,
|
|
"new_content": None,
|
|
"scanner": {"decision": "allow", "reason": "Deletion requested."},
|
|
},
|
|
)
|
|
try:
|
|
from deerflow.skills.usage import forget
|
|
forget(skill_name)
|
|
except Exception:
|
|
pass
|
|
|
|
# Persist ownership record removal so the DB stays consistent.
|
|
if mode == "admin":
|
|
await skill_store.delete_admin(skill_name)
|
|
else:
|
|
await skill_store.delete(skill_name, user_id)
|
|
|
|
try:
|
|
await get_tag_store(request).unassign_all("skill", skill_name)
|
|
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
|
|
pass
|
|
|
|
# Strip references from every agent that pulled this skill in,
|
|
# and notify their owners (no-op for self).
|
|
await cascade_skill_unpublished_or_deleted(
|
|
skill_name,
|
|
actor_user_id=user_id,
|
|
operation=operation,
|
|
skill_owner_user_id=record.get("owner_user_id"),
|
|
agent_store=agent_store,
|
|
)
|
|
|
|
await refresh_skills_system_prompt_cache_async()
|
|
return {"success": True}
|
|
except HTTPException:
|
|
raise
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error("Failed to delete custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
|
|
|
|
|
|
class SkillPublishRequest(BaseModel):
|
|
"""Request body for toggling a custom skill's published flag."""
|
|
|
|
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
|
|
|
|
|
|
class SkillPublishResponse(BaseModel):
|
|
name: str
|
|
owner_user_id: str | None
|
|
published: bool
|
|
|
|
|
|
@router.put(
|
|
"/skills/custom/{skill_name}/published",
|
|
response_model=SkillPublishResponse,
|
|
summary="Toggle Custom Skill Published State",
|
|
)
|
|
async def update_custom_skill_published(
|
|
skill_name: str,
|
|
body: SkillPublishRequest,
|
|
request: Request,
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
agent_store=Depends(get_agent_store),
|
|
) -> SkillPublishResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
|
|
|
was_published = bool(record.get("published", False))
|
|
if mode == "admin":
|
|
updated = await skill_store.update_admin(skill_name, {"published": body.published})
|
|
else:
|
|
updated = await skill_store.update(skill_name, user_id, {"published": body.published})
|
|
if updated is None:
|
|
raise HTTPException(status_code=500, detail="Failed to persist published state")
|
|
|
|
# If we are *removing* visibility (was published, now not), strip from
|
|
# other users' agents and notify them. admin takedown is signaled by
|
|
# the actor not being the owner.
|
|
if was_published and not body.published:
|
|
skill_owner = record.get("owner_user_id")
|
|
operation = "admin_takedown" if mode == "admin" and skill_owner not in (None, user_id) else "unpublish"
|
|
await cascade_skill_unpublished_or_deleted(
|
|
skill_name,
|
|
actor_user_id=user_id,
|
|
operation=operation,
|
|
skill_owner_user_id=skill_owner,
|
|
agent_store=agent_store,
|
|
)
|
|
await refresh_skills_system_prompt_cache_async()
|
|
|
|
return SkillPublishResponse(
|
|
name=updated["name"],
|
|
owner_user_id=updated.get("owner_user_id"),
|
|
published=bool(updated.get("published", False)),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update published state for %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}")
|
|
|
|
|
|
_ALLOWED_USER_STATES = {"active", "archived"}
|
|
|
|
|
|
class SkillStateRequest(BaseModel):
|
|
"""Request body for switching a custom skill's lifecycle state."""
|
|
|
|
state: str = Field(..., description="New state: 'active' (restore) or 'archived' (soft archive)")
|
|
|
|
|
|
class SkillPinRequest(BaseModel):
|
|
"""Request body for pinning / unpinning a custom skill."""
|
|
|
|
pinned: bool = Field(..., description="Whether to pin (true) so the curator never auto-archives this skill")
|
|
|
|
|
|
def _reload_skill_response(skill_name: str, ownership: dict[str, Any], config: AppConfig) -> SkillResponse:
|
|
"""Reload a single skill from disk and return its API response form."""
|
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found on disk")
|
|
return _skill_to_response(skill, ownership)
|
|
|
|
|
|
@router.put(
|
|
"/skills/custom/{skill_name}/state",
|
|
response_model=SkillResponse,
|
|
summary="Update Custom Skill Lifecycle State",
|
|
description="Restore (active) or soft-archive a custom skill. Only the owner or admins may invoke.",
|
|
)
|
|
async def update_custom_skill_state(
|
|
skill_name: str,
|
|
body: SkillStateRequest,
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
) -> SkillResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
if body.state not in _ALLOWED_USER_STATES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"state must be one of {sorted(_ALLOWED_USER_STATES)}",
|
|
)
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
|
|
|
from deerflow.skills.usage import set_state as _usage_set_state
|
|
|
|
_usage_set_state(skill_name, body.state)
|
|
await refresh_skills_system_prompt_cache_async()
|
|
return _reload_skill_response(skill_name, record, config)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update state for %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update state: {e}")
|
|
|
|
|
|
@router.put(
|
|
"/skills/custom/{skill_name}/pinned",
|
|
response_model=SkillResponse,
|
|
summary="Pin or Unpin a Custom Skill",
|
|
description="Pin a skill so the curator never auto-archives it. Only the owner or admins may invoke.",
|
|
)
|
|
async def update_custom_skill_pinned(
|
|
skill_name: str,
|
|
body: SkillPinRequest,
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
skill_store: SkillStore = Depends(get_skill_store),
|
|
) -> SkillResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
|
|
|
from deerflow.skills.usage import set_pinned as _usage_set_pinned
|
|
|
|
_usage_set_pinned(skill_name, body.pinned)
|
|
return _reload_skill_response(skill_name, record, config)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update pinned for %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
|
|
|
|
|
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
|
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
|
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
|
return CustomSkillHistoryResponse(history=storage.read_history(skill_name))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to read history for %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}")
|
|
|
|
|
|
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
|
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
|
try:
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
|
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
|
history = storage.read_history(skill_name)
|
|
if not history:
|
|
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
|
record = history[request.history_index]
|
|
target_content = record.get("prev_content")
|
|
if target_content is None:
|
|
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
|
storage.validate_skill_markdown_content(skill_name, target_content)
|
|
scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
|
skill_file = storage.get_custom_skill_file(skill_name)
|
|
current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
|
|
history_entry = {
|
|
"action": "rollback",
|
|
"author": "human",
|
|
"thread_id": None,
|
|
"file_path": SKILL_MD_FILE,
|
|
"prev_content": current_content,
|
|
"new_content": target_content,
|
|
"rollback_from_ts": record.get("ts"),
|
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
|
}
|
|
if scan.decision == "block":
|
|
storage.append_history(skill_name, history_entry)
|
|
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
|
|
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
|
|
storage.append_history(skill_name, history_entry)
|
|
await refresh_skills_system_prompt_cache_async()
|
|
return await get_custom_skill(skill_name, config)
|
|
except HTTPException:
|
|
raise
|
|
except IndexError:
|
|
raise HTTPException(status_code=400, detail="history_index is out of range")
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error("Failed to roll back custom skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to roll back custom skill: {str(e)}")
|
|
|
|
|
|
@router.get(
|
|
"/skills/{skill_name}",
|
|
response_model=SkillResponse,
|
|
summary="Get Skill Details",
|
|
description="Retrieve detailed information about a specific skill by its name.",
|
|
)
|
|
async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
return _skill_to_response(skill)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
|
|
|
|
|
class SkillContentResponse(BaseModel):
|
|
"""SKILL.md 原文内容,适用于公共和自定义技能。"""
|
|
|
|
name: str
|
|
category: SkillCategory
|
|
content: str
|
|
|
|
|
|
@router.get(
|
|
"/skills/{skill_name}/content",
|
|
response_model=SkillContentResponse,
|
|
summary="Get Skill Markdown Content",
|
|
description="Retrieve the raw SKILL.md content of a specific skill (public or custom).",
|
|
)
|
|
async def get_skill_content(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillContentResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
content = skill.skill_file.read_text(encoding="utf-8")
|
|
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
|
|
|
|
|
|
@router.put(
|
|
"/skills/{skill_name}",
|
|
response_model=SkillResponse,
|
|
summary="Update Skill",
|
|
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
|
)
|
|
async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
if config_path is None:
|
|
config_path = Path.cwd().parent / "extensions_config.json"
|
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
|
|
|
extensions_config = get_extensions_config()
|
|
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
|
|
|
config_data = {
|
|
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
|
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
|
|
}
|
|
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
json.dump(config_data, f, indent=2)
|
|
|
|
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
|
reload_extensions_config()
|
|
await refresh_skills_system_prompt_cache_async()
|
|
|
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if updated_skill is None:
|
|
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
|
|
|
logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
|
|
return _skill_to_response(updated_skill)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|
|
|
|
|
|
class SkillPinRequest(BaseModel):
|
|
pinned: bool = Field(..., description="Whether to pin or unpin the skill")
|
|
|
|
|
|
@router.post("/skills/custom/{skill_name}/pin", response_model=SkillResponse, summary="Pin or Unpin Skill")
|
|
async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
|
"""Pin a skill to prevent it from being automatically archived by the curator."""
|
|
try:
|
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
if not storage.custom_skill_exists(skill_name):
|
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
|
from deerflow.skills.usage import set_pinned
|
|
set_pinned(skill_name, request.pinned)
|
|
skills = storage.load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
return _skill_to_response(skill)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to pin skill %s: %s", skill_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to pin skill: {str(e)}")
|
|
|
|
|
|
@router.get("/curator/status", summary="Curator Status")
|
|
async def curator_status(request: Request) -> dict:
|
|
"""Return the current curator state and per-lifecycle skill counts for the
|
|
authenticated user."""
|
|
try:
|
|
from deerflow.skills.curator import get_curator_status
|
|
return get_curator_status(_current_user_id(request))
|
|
except Exception as e:
|
|
logger.error("Failed to get curator status: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}")
|
|
|
|
|
|
@router.post("/curator/run", summary="Trigger Curator Run")
|
|
async def run_curator(request: Request) -> dict:
|
|
"""Manually trigger a curator review pass for the authenticated user
|
|
(bypasses the interval check, runs in a background thread).
|
|
|
|
Pre-checks the master switch / curator switch / run lock so the response
|
|
accurately reflects whether a run actually started — instead of always
|
|
reporting success. Returns ``{"triggered": bool, "reason": str | None}``.
|
|
"""
|
|
try:
|
|
import threading
|
|
|
|
from deerflow.skills.curator import _curator_thread_target, manual_trigger_blocked_reason
|
|
|
|
user_id = _current_user_id(request)
|
|
reason = manual_trigger_blocked_reason(user_id)
|
|
if reason is not None:
|
|
return {"triggered": False, "reason": reason}
|
|
thread = threading.Thread(
|
|
target=_curator_thread_target,
|
|
args=(user_id,),
|
|
daemon=True,
|
|
name=f"skill-curator-manual-{user_id}",
|
|
)
|
|
thread.start()
|
|
return {"triggered": True, "reason": None}
|
|
except Exception as e:
|
|
logger.error("Failed to trigger curator: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}")
|
|
|
|
|
|
class CuratorSubConfig(BaseModel):
|
|
"""Nested curator configuration mirroring CuratorConfig."""
|
|
|
|
# 与 CuratorConfig.enabled 保持一致:默认关闭,需显式 opt-in。
|
|
enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
|
|
interval_hours: int = Field(default=168, ge=1, description="Hours between curator runs.")
|
|
stale_after_days: int = Field(default=30, ge=1, description="Days of inactivity before a skill is marked stale.")
|
|
archive_after_days: int = Field(default=90, ge=1, description="Days of inactivity before a stale skill is archived.")
|
|
review_timeout_seconds: int = Field(default=600, ge=1, description="Overall timeout (seconds) for the LLM review pass.")
|
|
model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass; null = primary model.")
|
|
|
|
|
|
class CuratorConfigResponse(BaseModel):
|
|
"""Response model exposing the effective skill_evolution config."""
|
|
|
|
enabled: bool
|
|
curator: CuratorSubConfig
|
|
|
|
|
|
class CuratorConfigRequest(BaseModel):
|
|
"""Payload for updating skill_evolution overrides.
|
|
|
|
All fields are required so the frontend always sends a complete config;
|
|
backend writes the entire payload to skill_evolution.json atomically.
|
|
"""
|
|
|
|
enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
|
|
curator: CuratorSubConfig
|
|
|
|
|
|
class CuratorLogRecord(BaseModel):
|
|
ts: str
|
|
level: str
|
|
message: str
|
|
|
|
|
|
class CuratorLogsResponse(BaseModel):
|
|
records: list[CuratorLogRecord]
|
|
run_count: int
|
|
last_run_at: str | None
|
|
|
|
|
|
def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
|
"""Pure helper so both the GET route and the post-PUT refresh share one shape.
|
|
|
|
``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
|
|
overrides if any).
|
|
"""
|
|
return CuratorConfigResponse(
|
|
enabled=evo.enabled,
|
|
curator=CuratorSubConfig(
|
|
enabled=evo.curator.enabled,
|
|
interval_hours=evo.curator.interval_hours,
|
|
stale_after_days=evo.curator.stale_after_days,
|
|
archive_after_days=evo.curator.archive_after_days,
|
|
review_timeout_seconds=evo.curator.review_timeout_seconds,
|
|
model_name=evo.curator.model_name,
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
|
|
async def get_curator_config(request: Request) -> CuratorConfigResponse:
|
|
"""Return the current curator config for the authenticated user (yaml
|
|
defaults + that user's overrides)."""
|
|
try:
|
|
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
|
|
|
evo = load_skill_evolution_config_for_user(_current_user_id(request))
|
|
return _build_curator_config_response(evo)
|
|
except Exception as e:
|
|
logger.error("Failed to read curator config: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}")
|
|
|
|
|
|
@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
|
|
async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
|
|
"""Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
|
|
and return the effective merged config.
|
|
|
|
The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
|
|
fields are rejected.
|
|
"""
|
|
try:
|
|
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
|
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
|
|
|
|
user_id = _current_user_id(request)
|
|
payload = body.model_dump()
|
|
# Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
|
|
SkillEvolutionConfig.model_validate(payload)
|
|
save_user_overrides(user_id, payload)
|
|
return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update curator config: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update curator config: {str(e)}")
|
|
|
|
|
|
@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs")
|
|
async def get_curator_logs(request: Request) -> CuratorLogsResponse:
|
|
"""Return in-memory log records from the most recent curator run for the
|
|
authenticated user.
|
|
|
|
The buffer is process-local and cleared when curator starts a new run or
|
|
when the backend restarts. Records are ordered oldest → newest.
|
|
"""
|
|
try:
|
|
from deerflow.skills import curator_log
|
|
from deerflow.skills.curator import get_curator_status
|
|
|
|
user_id = _current_user_id(request)
|
|
records = curator_log.get_recent(user_id)
|
|
status = get_curator_status(user_id)
|
|
return CuratorLogsResponse(
|
|
records=[CuratorLogRecord(**r) for r in records],
|
|
run_count=int(status.get("run_count", 0)),
|
|
last_run_at=status.get("last_run_at"),
|
|
)
|
|
except Exception as e:
|
|
logger.error("Failed to read curator logs: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read curator logs: {str(e)}")
|