Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts: # frontend-web/src/pages/WorkspaceRoutes.tsx # offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
This commit is contained in:
commit
aeba15eb16
88 files changed
+13824
-3572
No files matched your search
@@ -31,6 +31,7 @@ from app.gateway.routers import (
|
||||
mcp,
|
||||
memory,
|
||||
models,
|
||||
notifications,
|
||||
recommended_questions,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
@@ -155,6 +156,68 @@ async def _iter_store_items(store, namespace, *, page_size: int = 500):
|
||||
offset += page_size
|
||||
|
||||
|
||||
async def _sync_legacy_skills(app: FastAPI) -> None:
|
||||
"""Idempotently register existing on-disk ``skills/custom/*`` directories
|
||||
in the ``skills`` DB table, owned by the first admin and published=True.
|
||||
|
||||
Without this step, after upgrading to per-user skill ownership the existing
|
||||
custom skill folders would not be visible to anyone (the SkillStore
|
||||
visibility filter relies on a DB row). Running this once on every boot is
|
||||
cheap (folder scan + one ``ensure_legacy`` per name) and idempotent.
|
||||
|
||||
Non-fatal: a failure here MUST NOT block server startup.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
return
|
||||
|
||||
skill_store = getattr(app.state, "skill_store", None)
|
||||
if skill_store is None:
|
||||
return
|
||||
|
||||
async with sf() as session:
|
||||
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
# No admin yet — leave ownership null; first admin is responsible for
|
||||
# claiming via the UI once they exist.
|
||||
admin_id: str | None = None
|
||||
else:
|
||||
admin_id = str(row.id)
|
||||
|
||||
config = getattr(app.state, "config", None)
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
try:
|
||||
skills = storage.load_skills(enabled_only=False)
|
||||
except Exception:
|
||||
logger.exception("_sync_legacy_skills: could not list on-disk skills")
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
for skill in skills:
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
continue
|
||||
try:
|
||||
existing = await skill_store.get_any(skill.name)
|
||||
if existing is not None:
|
||||
continue
|
||||
await skill_store.ensure_legacy(
|
||||
{"name": skill.name, "owner_user_id": admin_id, "published": True}
|
||||
)
|
||||
inserted += 1
|
||||
except Exception:
|
||||
logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True)
|
||||
if inserted:
|
||||
logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted)
|
||||
|
||||
|
||||
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||||
"""Migrate LangGraph store threads with no user_id to the given admin.
|
||||
|
||||
@@ -231,6 +294,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
||||
await _ensure_admin_user(app)
|
||||
|
||||
# Migrate "ownerless" on-disk skills into the new SkillStore table —
|
||||
# makes the multitenancy switch transparent for existing deployments.
|
||||
try:
|
||||
await _sync_legacy_skills(app)
|
||||
except Exception:
|
||||
logger.exception("Skill ownership migration failed (non-fatal)")
|
||||
|
||||
# Kick off skill curator in background if the run interval has elapsed
|
||||
try:
|
||||
from deerflow.skills.curator import maybe_run_curator
|
||||
asyncio.create_task(maybe_run_curator())
|
||||
except Exception:
|
||||
logger.exception("Skill curator startup task failed (non-fatal)")
|
||||
|
||||
# Start IM channel service if any channels are configured
|
||||
try:
|
||||
from app.channels.service import start_channel_service
|
||||
@@ -515,6 +592,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
||||
app.include_router(feedback.router)
|
||||
|
||||
# Notifications API is mounted at /api/notifications
|
||||
app.include_router(notifications.router)
|
||||
|
||||
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||||
app.include_router(thread_runs.router)
|
||||
|
||||
|
||||
@@ -108,6 +108,10 @@ class LocalAuthProvider(AuthProvider):
|
||||
"""Update an existing user."""
|
||||
return await self._repo.update_user(user)
|
||||
|
||||
async def list_users(self) -> list[User]:
|
||||
"""Return all registered users ordered by creation date."""
|
||||
return await self._repo.list_users()
|
||||
|
||||
async def get_user_by_email(self, email: str) -> User | None:
|
||||
"""Get user by email."""
|
||||
return await self._repo.get_user_by_email(email)
|
||||
@@ -108,6 +108,11 @@ class UserRepository(ABC):
|
||||
"""Return number of users with system_role == 'admin'."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_users(self) -> list[User]:
|
||||
"""Return all users ordered by creation date."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
||||
"""Get user by OAuth provider and ID.
|
||||
|
||||
@@ -140,6 +140,12 @@ class SQLiteUserRepository(UserRepository):
|
||||
async with self._sf() as session:
|
||||
return await session.scalar(stmt) or 0
|
||||
|
||||
async def list_users(self) -> list[User]:
|
||||
stmt = select(UserRow).order_by(UserRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._row_to_user(row) for row in result.scalars()]
|
||||
|
||||
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
||||
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
|
||||
async with self._sf() as session:
|
||||
|
||||
@@ -25,7 +25,9 @@ if TYPE_CHECKING:
|
||||
from app.gateway.auth.local_provider import LocalAuthProvider
|
||||
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||
from deerflow.persistence.agents import AgentStore
|
||||
from deerflow.persistence.notifications import NotificationStore
|
||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||
from deerflow.persistence.skills import SkillStore
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
|
||||
|
||||
@@ -98,6 +100,19 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.agent_store = make_agent_store(sf)
|
||||
|
||||
from deerflow.persistence.skills import make_skill_store
|
||||
|
||||
app.state.skill_store = make_skill_store(sf)
|
||||
|
||||
from deerflow.persistence.notifications import (
|
||||
make_notification_store,
|
||||
set_notification_store,
|
||||
)
|
||||
|
||||
app.state.notification_store = make_notification_store(sf)
|
||||
# Make the sender helper (harness layer) able to write without going
|
||||
# through FastAPI request context — used by cascade.py.
|
||||
set_notification_store(app.state.notification_store)
|
||||
from deerflow.persistence.recommended_questions import make_recommended_question_store
|
||||
|
||||
app.state.recommended_question_store = make_recommended_question_store(sf)
|
||||
@@ -112,6 +127,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
from deerflow.persistence.notifications import set_notification_store
|
||||
|
||||
set_notification_store(None)
|
||||
await close_engine()
|
||||
|
||||
|
||||
@@ -175,6 +193,22 @@ def get_agent_store(request: Request) -> AgentStore:
|
||||
return val
|
||||
|
||||
|
||||
def get_skill_store(request: Request) -> SkillStore:
|
||||
"""Return the custom-skill store."""
|
||||
val = getattr(request.app.state, "skill_store", None)
|
||||
if val is None:
|
||||
raise HTTPException(status_code=503, detail="Skill store not available")
|
||||
return val
|
||||
|
||||
|
||||
def get_notification_store(request: Request) -> NotificationStore:
|
||||
"""Return the in-app notification store."""
|
||||
val = getattr(request.app.state, "notification_store", None)
|
||||
if val is None:
|
||||
raise HTTPException(status_code=503, detail="Notification store not available")
|
||||
return val
|
||||
|
||||
|
||||
def get_run_context(request: Request) -> RunContext:
|
||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||
@@ -181,3 +182,46 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
|
||||
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
|
||||
|
||||
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
|
||||
|
||||
|
||||
class ArtifactUpdateRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
@router.put(
|
||||
"/threads/{thread_id}/artifacts/{path:path}",
|
||||
summary="Update Artifact File",
|
||||
description="Update the content of an artifact file.",
|
||||
)
|
||||
@require_permission("threads", "write", owner_check=True)
|
||||
async def update_artifact(thread_id: str, path: str, request: Request, body: ArtifactUpdateRequest) -> dict:
|
||||
"""Update an artifact file's content.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
path: The artifact path (e.g. mnt/user-data/outputs/index.html).
|
||||
body: Request body containing the new file content.
|
||||
|
||||
Returns:
|
||||
{"ok": true} on success.
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- 400 if path is invalid, not a file, or is a .skill archive
|
||||
- 403 if access denied
|
||||
- 404 if file not found
|
||||
"""
|
||||
if ".skill/" in path or path.endswith(".skill"):
|
||||
raise HTTPException(status_code=400, detail="Cannot edit .skill archives")
|
||||
|
||||
actual_path = resolve_thread_virtual_path(thread_id, path)
|
||||
|
||||
if not actual_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
|
||||
|
||||
if not actual_path.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
|
||||
|
||||
actual_path.write_text(body.content, encoding="utf-8")
|
||||
logger.info(f"Updated artifact: thread_id={thread_id}, path={path}")
|
||||
return {"ok": True}
|
||||
@@ -10,6 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from deerflow.persistence.agents.base import AgentStore
|
||||
from deerflow.persistence.skills.base import SkillStore
|
||||
|
||||
from app.gateway.auth import (
|
||||
UserResponse,
|
||||
create_access_token,
|
||||
@@ -17,7 +20,7 @@ from app.gateway.auth import (
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
from app.gateway.deps import get_current_user_from_request, get_local_provider
|
||||
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -588,6 +591,43 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
||||
|
||||
|
||||
# ── Admin Endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
system_role: Literal["admin", "user"]
|
||||
skill_count: int
|
||||
agent_count: int
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserListItem])
|
||||
async def list_all_users(
|
||||
request: Request,
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
agent_store: AgentStore = Depends(get_agent_store),
|
||||
):
|
||||
"""List all users with their skill and agent counts. Admin only."""
|
||||
user = await get_current_user_from_request(request)
|
||||
if user.system_role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
users = await get_local_provider().list_users()
|
||||
result = []
|
||||
for u in users:
|
||||
uid = str(u.id)
|
||||
result.append(
|
||||
UserListItem(
|
||||
id=uid,
|
||||
email=u.email,
|
||||
system_role=u.system_role,
|
||||
skill_count=await skill_store.count_owned(uid),
|
||||
agent_count=await agent_store.count_owned(uid),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""In-app notification inbox API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_notification_store
|
||||
from deerflow.persistence.notifications import NotificationStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["notifications"])
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
type: str
|
||||
title: str
|
||||
body: str
|
||||
payload: dict[str, Any]
|
||||
read: bool
|
||||
created_at: str | None
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationItem]
|
||||
|
||||
|
||||
class NotificationUnreadCountResponse(BaseModel):
|
||||
unread: int = Field(..., description="Number of unread notifications for the current user")
|
||||
|
||||
|
||||
class MarkReadResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
class MarkAllReadResponse(BaseModel):
|
||||
updated: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notifications",
|
||||
response_model=NotificationListResponse,
|
||||
summary="List Notifications",
|
||||
)
|
||||
async def list_notifications(
|
||||
request: Request,
|
||||
unread_only: bool = Query(default=False),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
store: NotificationStore = Depends(get_notification_store),
|
||||
) -> NotificationListResponse:
|
||||
try:
|
||||
items = await store.list_for_user(
|
||||
_current_user_id(request),
|
||||
unread_only=unread_only,
|
||||
limit=limit,
|
||||
)
|
||||
return NotificationListResponse(items=[NotificationItem(**item) for item in items])
|
||||
except Exception as e:
|
||||
logger.error("Failed to list notifications: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list notifications: {e}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notifications/unread_count",
|
||||
response_model=NotificationUnreadCountResponse,
|
||||
summary="Unread Notification Count",
|
||||
)
|
||||
async def unread_count(
|
||||
request: Request,
|
||||
store: NotificationStore = Depends(get_notification_store),
|
||||
) -> NotificationUnreadCountResponse:
|
||||
try:
|
||||
count = await store.unread_count(_current_user_id(request))
|
||||
return NotificationUnreadCountResponse(unread=count)
|
||||
except Exception as e:
|
||||
logger.error("Failed to read unread notification count: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read unread count: {e}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/{notification_id}/read",
|
||||
response_model=MarkReadResponse,
|
||||
summary="Mark Notification Read",
|
||||
)
|
||||
async def mark_read(
|
||||
notification_id: str,
|
||||
request: Request,
|
||||
store: NotificationStore = Depends(get_notification_store),
|
||||
) -> MarkReadResponse:
|
||||
try:
|
||||
ok = await store.mark_read(notification_id, _current_user_id(request))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
return MarkReadResponse(success=True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to mark notification read: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to mark read: {e}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/read_all",
|
||||
response_model=MarkAllReadResponse,
|
||||
summary="Mark All Notifications Read",
|
||||
)
|
||||
async def mark_all_read(
|
||||
request: Request,
|
||||
store: NotificationStore = Depends(get_notification_store),
|
||||
) -> MarkAllReadResponse:
|
||||
try:
|
||||
count = await store.mark_all_read(_current_user_id(request))
|
||||
return MarkAllReadResponse(updated=count)
|
||||
except Exception as e:
|
||||
logger.error("Failed to mark all notifications read: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to mark all read: {e}")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/notifications/{notification_id}",
|
||||
response_model=MarkReadResponse,
|
||||
summary="Delete Notification",
|
||||
)
|
||||
async def delete_notification(
|
||||
notification_id: str,
|
||||
request: Request,
|
||||
store: NotificationStore = Depends(get_notification_store),
|
||||
) -> MarkReadResponse:
|
||||
try:
|
||||
ok = await store.delete(notification_id, _current_user_id(request))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
return MarkReadResponse(success=True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete notification: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}")
|
||||
@@ -1,17 +1,22 @@
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Request, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_config
|
||||
from app.gateway.deps import get_agent_store, get_config, get_skill_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.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
|
||||
@@ -22,6 +27,56 @@ 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."""
|
||||
|
||||
@@ -30,6 +85,16 @@ class SkillResponse(BaseModel):
|
||||
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)")
|
||||
|
||||
|
||||
class SkillsListResponse(BaseModel):
|
||||
@@ -49,6 +114,7 @@ class SkillInstallRequest(BaseModel):
|
||||
|
||||
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):
|
||||
@@ -58,6 +124,7 @@ class SkillInstallResponse(BaseModel):
|
||||
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):
|
||||
@@ -89,27 +156,85 @@ 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) -> SkillResponse:
|
||||
"""Convert a Skill object to a SkillResponse."""
|
||||
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}
|
||||
|
||||
|
||||
@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(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
||||
async def list_skills(
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillsListResponse:
|
||||
try:
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||
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))
|
||||
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)}")
|
||||
@@ -194,14 +319,56 @@ async def validate_skill_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)
|
||||
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
|
||||
# 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:
|
||||
@@ -225,10 +392,33 @@ async def install_skill_upload(
|
||||
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(request: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
||||
async def install_skill(
|
||||
body: SkillInstallRequest,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillInstallResponse:
|
||||
try:
|
||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
||||
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
||||
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:
|
||||
@@ -245,10 +435,21 @@ async def install_skill(request: SkillInstallRequest, config: AppConfig = Depend
|
||||
|
||||
|
||||
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
||||
async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
||||
async def list_custom_skills(
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillsListResponse:
|
||||
try:
|
||||
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
|
||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||
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)}")
|
||||
@@ -294,6 +495,11 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
||||
"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:
|
||||
@@ -308,9 +514,20 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
||||
|
||||
|
||||
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
||||
async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
||||
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,
|
||||
@@ -324,8 +541,32 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
|
||||
"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)
|
||||
|
||||
# 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:
|
||||
@@ -335,6 +576,161 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
|
||||
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:
|
||||
@@ -469,3 +865,186 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
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 interval check, runs in a background thread)."""
|
||||
try:
|
||||
import threading
|
||||
from deerflow.skills.curator import _curator_thread_target
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
thread = threading.Thread(
|
||||
target=_curator_thread_target,
|
||||
args=(user_id,),
|
||||
daemon=True,
|
||||
name=f"skill-curator-manual-{user_id}",
|
||||
)
|
||||
thread.start()
|
||||
return {"triggered": True, "message": "Curator run started in background"}
|
||||
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."""
|
||||
|
||||
enabled: bool = Field(default=True, 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.")
|
||||
|
||||
|
||||
class CuratorConfigResponse(BaseModel):
|
||||
"""Response model exposing the effective skill_evolution config."""
|
||||
|
||||
enabled: bool
|
||||
moderation_model_name: str | None
|
||||
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.")
|
||||
moderation_model_name: str | None = Field(default=None, description="Optional model name for skill security moderation.")
|
||||
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,
|
||||
moderation_model_name=evo.moderation_model_name,
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@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)}")
|
||||
+17
-8
@@ -191,14 +191,23 @@ def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
|
||||
return ""
|
||||
return """
|
||||
## Skill Self-Evolution
|
||||
After completing a task, consider creating or updating a skill when:
|
||||
- The task required 5+ tool calls to resolve
|
||||
- You overcame non-obvious errors or pitfalls
|
||||
- The user corrected your approach and the corrected version worked
|
||||
- You discovered a non-trivial, recurring workflow
|
||||
If you used a skill and encountered issues not covered by it, patch it immediately.
|
||||
Prefer patch over edit. Before creating a new skill, confirm with the user first.
|
||||
Skip simple one-off tasks.
|
||||
|
||||
You have access to `skill_manage` (plus `skill_list` / `skill_view`).
|
||||
|
||||
After completing a non-trivial task — 5+ tool calls to resolve, recovery from
|
||||
a non-obvious error, a corrected approach that worked, or a recurring
|
||||
workflow — **offer to save the approach as a reusable skill** so you can
|
||||
replay it next time. Briefly propose a hyphen-case name and a one-line
|
||||
description, then ask the user to confirm before calling
|
||||
`skill_manage(action="create", ...)`. Capture the generalised steps, not the
|
||||
user's specific data, paths, or credentials.
|
||||
|
||||
When using an existing skill and finding it outdated, incomplete, or wrong,
|
||||
**patch it immediately** with `skill_manage(action="patch")` — don't wait to
|
||||
be asked. Skills that aren't maintained become liabilities.
|
||||
|
||||
Skip the offer for one-off queries, casual chat, or short single-tool
|
||||
answers — and never create a skill silently without user confirmation.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
messages[-1] = HumanMessage(
|
||||
content=last_message.content,
|
||||
id=last_message.id,
|
||||
name=last_message.name or "user-input",
|
||||
name=last_message.name,
|
||||
additional_kwargs={**last_message.additional_kwargs, "run_id": runtime.context.get("run_id"), "timestamp": datetime.now(UTC).isoformat()},
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ from deerflow.config.run_events_config import RunEventsConfig
|
||||
from deerflow.config.runtime_paths import existing_project_file
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
|
||||
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
|
||||
from deerflow.config.skills_config import SkillsConfig
|
||||
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
|
||||
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
|
||||
@@ -54,6 +56,22 @@ def _legacy_config_candidates() -> tuple[Path, ...]:
|
||||
return (backend_dir / "config.yaml", repo_root / "config.yaml")
|
||||
|
||||
|
||||
def _deep_merge_dict(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Deep-merge ``overrides`` onto a copy of ``base`` and return the result.
|
||||
|
||||
Nested dicts are merged recursively; any other value type in ``overrides``
|
||||
replaces the corresponding value in ``base`` outright.
|
||||
"""
|
||||
merged: dict[str, Any] = dict(base)
|
||||
for key, value in overrides.items():
|
||||
existing = merged.get(key)
|
||||
if isinstance(existing, dict) and isinstance(value, dict):
|
||||
merged[key] = _deep_merge_dict(existing, value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def logging_level_from_config(name: str | None) -> int:
|
||||
"""Map ``config.yaml`` ``log_level`` string to a :mod:`logging` level constant."""
|
||||
mapping = logging.getLevelNamesMapping()
|
||||
@@ -204,6 +222,15 @@ class AppConfig(BaseModel):
|
||||
extensions_config = ExtensionsConfig.from_file()
|
||||
config_data["extensions"] = extensions_config.model_dump()
|
||||
|
||||
# Apply skill_evolution runtime overrides from skill_evolution.json
|
||||
# (file is optional; load_overrides returns None if missing/invalid)
|
||||
overrides = _load_skill_evolution_overrides()
|
||||
if overrides:
|
||||
yaml_section = config_data.get("skill_evolution") or {}
|
||||
if not isinstance(yaml_section, dict):
|
||||
yaml_section = {}
|
||||
config_data["skill_evolution"] = _deep_merge_dict(yaml_section, overrides)
|
||||
|
||||
result = cls.model_validate(config_data)
|
||||
return result
|
||||
|
||||
@@ -329,6 +356,7 @@ class AppConfig(BaseModel):
|
||||
_app_config: AppConfig | None = None
|
||||
_app_config_path: Path | None = None
|
||||
_app_config_mtime: float | None = None
|
||||
_app_config_skill_evolution_overrides_mtime: float | None = None
|
||||
_app_config_is_custom = False
|
||||
_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None)
|
||||
_current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=())
|
||||
@@ -344,12 +372,13 @@ def _get_config_mtime(config_path: Path) -> float | None:
|
||||
|
||||
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
|
||||
"""Load config from disk and refresh cache metadata."""
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
|
||||
|
||||
resolved_path = AppConfig.resolve_config_path(config_path)
|
||||
_app_config = AppConfig.from_file(str(resolved_path))
|
||||
_app_config_path = resolved_path
|
||||
_app_config_mtime = _get_config_mtime(resolved_path)
|
||||
_app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime()
|
||||
_app_config_is_custom = False
|
||||
return _app_config
|
||||
|
||||
@@ -373,8 +402,14 @@ def get_app_config() -> AppConfig:
|
||||
|
||||
resolved_path = AppConfig.resolve_config_path()
|
||||
current_mtime = _get_config_mtime(resolved_path)
|
||||
current_overrides_mtime = _get_skill_evolution_overrides_mtime()
|
||||
|
||||
should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_mtime != current_mtime
|
||||
should_reload = (
|
||||
_app_config is None
|
||||
or _app_config_path != resolved_path
|
||||
or _app_config_mtime != current_mtime
|
||||
or _app_config_skill_evolution_overrides_mtime != current_overrides_mtime
|
||||
)
|
||||
if should_reload:
|
||||
if _app_config_path == resolved_path and _app_config_mtime is not None and current_mtime is not None and _app_config_mtime != current_mtime:
|
||||
logger.info(
|
||||
@@ -382,6 +417,12 @@ def get_app_config() -> AppConfig:
|
||||
_app_config_mtime,
|
||||
current_mtime,
|
||||
)
|
||||
elif _app_config_skill_evolution_overrides_mtime != current_overrides_mtime:
|
||||
logger.info(
|
||||
"skill_evolution.json overrides changed (mtime: %s -> %s), reloading AppConfig",
|
||||
_app_config_skill_evolution_overrides_mtime,
|
||||
current_overrides_mtime,
|
||||
)
|
||||
_load_and_cache_app_config(str(resolved_path))
|
||||
return _app_config
|
||||
|
||||
@@ -409,10 +450,11 @@ def reset_app_config() -> None:
|
||||
`get_app_config()` to reload from file. Useful for testing
|
||||
or when switching between different configurations.
|
||||
"""
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
|
||||
_app_config = None
|
||||
_app_config_path = None
|
||||
_app_config_mtime = None
|
||||
_app_config_skill_evolution_overrides_mtime = None
|
||||
_app_config_is_custom = False
|
||||
|
||||
|
||||
@@ -424,10 +466,11 @@ def set_app_config(config: AppConfig) -> None:
|
||||
Args:
|
||||
config: The AppConfig instance to use.
|
||||
"""
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
|
||||
_app_config = config
|
||||
_app_config_path = None
|
||||
_app_config_mtime = None
|
||||
_app_config_skill_evolution_overrides_mtime = None
|
||||
_app_config_is_custom = True
|
||||
|
||||
|
||||
|
||||
+13
@@ -1,6 +1,15 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CuratorConfig(BaseModel):
|
||||
"""Configuration for the background skill curator."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether the curator runs automatically.")
|
||||
interval_hours: int = Field(default=168, description="Hours between curator runs (default: 7 days).")
|
||||
stale_after_days: int = Field(default=30, description="Days of inactivity before a skill is marked stale.")
|
||||
archive_after_days: int = Field(default=90, description="Days of inactivity before a stale skill is archived.")
|
||||
|
||||
|
||||
class SkillEvolutionConfig(BaseModel):
|
||||
"""Configuration for agent-managed skill evolution."""
|
||||
|
||||
@@ -12,3 +21,7 @@ class SkillEvolutionConfig(BaseModel):
|
||||
default=None,
|
||||
description="Optional model name for skill security moderation. Defaults to the primary chat model.",
|
||||
)
|
||||
curator: CuratorConfig = Field(
|
||||
default_factory=CuratorConfig,
|
||||
description="Configuration for the background skill curator.",
|
||||
)
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
"""Runtime overrides for `skill_evolution` config, stored in skill_evolution.json.
|
||||
|
||||
Two scopes exist:
|
||||
|
||||
* **Global** (back-compat): ``backend/skill_evolution.json`` deep-merged onto
|
||||
``config.yaml.skill_evolution`` at AppConfig load time. Used as a fallback
|
||||
for users who never set a personal override.
|
||||
* **Per-user**: ``.deer-flow/users/{user_id}/skill_evolution.json``. Read by
|
||||
the curator on each run and by the ``GET/PUT /api/curator/config`` route
|
||||
with the active user id. Curator merges this on top of the global config
|
||||
via :func:`load_curator_config_for_user`.
|
||||
|
||||
The file is optional in both scopes; absence (or a malformed payload) is
|
||||
treated as "no overrides" so the system keeps the YAML-only behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deerflow.config.runtime_paths import existing_project_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FILENAME = "skill_evolution.json"
|
||||
|
||||
|
||||
def resolve_path() -> Path:
|
||||
"""Resolve the on-disk location of ``skill_evolution.json``.
|
||||
|
||||
Priority:
|
||||
1. ``DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH`` environment variable
|
||||
2. ``skill_evolution.json`` in the project root (current and parents)
|
||||
3. Legacy ``backend/`` or repo-root sibling locations
|
||||
4. Falls back to ``backend/skill_evolution.json`` (does not need to exist)
|
||||
"""
|
||||
override = os.getenv("DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH")
|
||||
if override:
|
||||
return Path(override)
|
||||
|
||||
project_file = existing_project_file((_FILENAME,))
|
||||
if project_file is not None:
|
||||
return project_file
|
||||
|
||||
backend_dir = Path(__file__).resolve().parents[4]
|
||||
repo_root = backend_dir.parent
|
||||
for candidate in (backend_dir / _FILENAME, repo_root / _FILENAME):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
return backend_dir / _FILENAME
|
||||
|
||||
|
||||
def load_overrides() -> dict[str, Any] | None:
|
||||
"""Read overrides from disk.
|
||||
|
||||
Returns ``None`` when the file does not exist, is empty, contains
|
||||
non-dict JSON, or fails to parse. Errors are logged at WARNING level
|
||||
so production stays silent in the common "no overrides" case.
|
||||
"""
|
||||
path = resolve_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
if not raw:
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read %s: %s", path, exc)
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Skill evolution overrides at %s are not valid JSON: %s", path, exc)
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Skill evolution overrides at %s must be a JSON object, got %s", path, type(data).__name__)
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def save_overrides(payload: dict[str, Any]) -> Path:
|
||||
"""Atomically persist ``payload`` to ``skill_evolution.json``.
|
||||
|
||||
The caller is responsible for shape validation (e.g. via the
|
||||
``SkillEvolutionConfig`` Pydantic model). Returns the path that was
|
||||
written so callers can log it.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise TypeError("skill_evolution overrides payload must be a dict")
|
||||
|
||||
path = resolve_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path_str = tempfile.mkstemp(prefix=".skill-evolution-", suffix=".tmp", dir=str(path.parent))
|
||||
tmp_path = Path(tmp_path_str)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info("Wrote skill_evolution overrides to %s", path)
|
||||
return path
|
||||
|
||||
|
||||
def get_overrides_mtime() -> float | None:
|
||||
"""Return the mtime of ``skill_evolution.json`` if it exists, else None.
|
||||
|
||||
Used by the AppConfig cache to decide whether to reload — when the
|
||||
file appears, disappears, or is modified, the mtime tuple changes
|
||||
and ``get_app_config()`` rebuilds.
|
||||
"""
|
||||
path = resolve_path()
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-user overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_user_path(user_id: str) -> Path:
|
||||
"""Return ``{base_dir}/users/{user_id}/skill_evolution.json``."""
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
return get_paths().user_dir(user_id) / _FILENAME
|
||||
|
||||
|
||||
def load_user_overrides(user_id: str) -> dict[str, Any] | None:
|
||||
"""Read the per-user overrides file for ``user_id`` (None if missing/invalid)."""
|
||||
path = resolve_user_path(user_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
if not raw:
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read %s: %s", path, exc)
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("User skill_evolution overrides at %s are not valid JSON: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("User skill_evolution overrides at %s must be a JSON object", path)
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def save_user_overrides(user_id: str, payload: dict[str, Any]) -> Path:
|
||||
"""Atomically persist ``payload`` to the per-user overrides file."""
|
||||
if not isinstance(payload, dict):
|
||||
raise TypeError("skill_evolution overrides payload must be a dict")
|
||||
path = resolve_user_path(user_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path_str = tempfile.mkstemp(prefix=".skill-evolution-", suffix=".tmp", dir=str(path.parent))
|
||||
tmp_path = Path(tmp_path_str)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info("Wrote per-user skill_evolution overrides to %s", path)
|
||||
return path
|
||||
|
||||
|
||||
def get_user_overrides_mtime(user_id: str) -> float | None:
|
||||
path = resolve_user_path(user_id)
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def load_skill_evolution_config_for_user(user_id: str | None):
|
||||
"""Return a ``SkillEvolutionConfig`` instance with per-user overrides merged
|
||||
on top of the global AppConfig defaults.
|
||||
|
||||
Used by the curator + the per-user ``GET/PUT /api/curator/config`` route.
|
||||
When ``user_id`` is None or the per-user file is absent, returns the global
|
||||
config unchanged.
|
||||
"""
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
|
||||
base = get_app_config().skill_evolution
|
||||
if not user_id:
|
||||
return base
|
||||
overrides = load_user_overrides(user_id)
|
||||
if not overrides:
|
||||
return base
|
||||
|
||||
merged = base.model_dump()
|
||||
for key, value in overrides.items():
|
||||
existing = merged.get(key)
|
||||
if isinstance(existing, dict) and isinstance(value, dict):
|
||||
merged[key] = {**existing, **value}
|
||||
else:
|
||||
merged[key] = value
|
||||
try:
|
||||
return SkillEvolutionConfig.model_validate(merged)
|
||||
except Exception:
|
||||
logger.warning("Per-user skill_evolution overrides for %s failed validation, ignoring", user_id, exc_info=True)
|
||||
return base
|
||||
@@ -56,3 +56,8 @@ class AgentStore(ABC):
|
||||
any user identity and returns ``False`` if the target is not a built-in.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
"""Return the number of agents owned by ``user_id`` (user_id IS NOT NULL)."""
|
||||
raise NotImplementedError
|
||||
+3
@@ -114,3 +114,6 @@ class MemoryAgentStore(AgentStore):
|
||||
return False
|
||||
self._agents.pop(agent_id, None)
|
||||
return True
|
||||
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
return sum(1 for a in self._agents.values() if a.get("user_id") == user_id)
|
||||
+6
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.agents.base import AgentStore
|
||||
@@ -137,3 +137,8 @@ class AgentRepository(AgentStore):
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
stmt = select(func.count()).select_from(AgentRow).where(AgentRow.user_id == user_id)
|
||||
async with self._sf() as session:
|
||||
return await session.scalar(stmt) or 0
|
||||
+4
@@ -19,6 +19,7 @@ from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.scheduled_tasks.model import (
|
||||
@@ -28,6 +29,7 @@ from deerflow.persistence.scheduled_tasks.model import (
|
||||
ScheduledTaskSubscriptionRow,
|
||||
SchedulerThreadRow,
|
||||
)
|
||||
from deerflow.persistence.skills.model import SkillRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
|
||||
@@ -35,6 +37,7 @@ __all__ = [
|
||||
"FeedbackRow",
|
||||
"AgentRow",
|
||||
"LlmCallMetricRow",
|
||||
"NotificationRow",
|
||||
"RecommendedQuestionRow",
|
||||
"RunEventRow",
|
||||
"RunRow",
|
||||
@@ -43,6 +46,7 @@ __all__ = [
|
||||
"ScheduledTaskRunRow",
|
||||
"ScheduledTaskSubscriptionRow",
|
||||
"SchedulerThreadRow",
|
||||
"SkillRow",
|
||||
"ThreadMetaRow",
|
||||
"UserRow",
|
||||
]
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"""Persistence layer for per-user in-app notifications."""
|
||||
|
||||
from deerflow.persistence.notifications.base import NotificationStore
|
||||
from deerflow.persistence.notifications.memory import MemoryNotificationStore
|
||||
from deerflow.persistence.notifications.sender import (
|
||||
get_notification_store,
|
||||
notify,
|
||||
set_notification_store,
|
||||
)
|
||||
from deerflow.persistence.notifications.sql import NotificationRepository
|
||||
|
||||
__all__ = [
|
||||
"NotificationStore",
|
||||
"MemoryNotificationStore",
|
||||
"NotificationRepository",
|
||||
"notify",
|
||||
"set_notification_store",
|
||||
"get_notification_store",
|
||||
"make_notification_store",
|
||||
]
|
||||
|
||||
|
||||
def make_notification_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryNotificationStore()
|
||||
return NotificationRepository(session_factory)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""Abstract persistence API for in-app notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NotificationStore(ABC):
|
||||
@abstractmethod
|
||||
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Insert a notification record. ``data`` must include ``id``, ``user_id``, ``type``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return newest-first records for ``user_id``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def unread_count(self, user_id: str) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def mark_read(self, notification_id: str, user_id: str) -> bool:
|
||||
"""Mark a single notification as read; returns False if not owned by user."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def mark_all_read(self, user_id: str) -> int:
|
||||
"""Mark every unread notification of ``user_id`` as read; returns count updated."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, notification_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
"""In-memory ``NotificationStore`` implementation for tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.notifications.base import NotificationStore
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class MemoryNotificationStore(NotificationStore):
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
record = {
|
||||
"id": data["id"],
|
||||
"user_id": data["user_id"],
|
||||
"type": data["type"],
|
||||
"title": data.get("title") or "",
|
||||
"body": data.get("body") or "",
|
||||
"payload": data.get("payload") or {},
|
||||
"read": False,
|
||||
"created_at": _now_iso(),
|
||||
}
|
||||
self._items[record["id"]] = record
|
||||
return copy.deepcopy(record)
|
||||
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
filtered = [r for r in self._items.values() if r["user_id"] == user_id]
|
||||
if unread_only:
|
||||
filtered = [r for r in filtered if not r["read"]]
|
||||
filtered.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return [copy.deepcopy(r) for r in filtered[:limit]]
|
||||
|
||||
async def unread_count(self, user_id: str) -> int:
|
||||
return sum(1 for r in self._items.values() if r["user_id"] == user_id and not r["read"])
|
||||
|
||||
async def mark_read(self, notification_id: str, user_id: str) -> bool:
|
||||
record = self._items.get(notification_id)
|
||||
if record is None or record["user_id"] != user_id:
|
||||
return False
|
||||
record["read"] = True
|
||||
return True
|
||||
|
||||
async def mark_all_read(self, user_id: str) -> int:
|
||||
updated = 0
|
||||
for record in self._items.values():
|
||||
if record["user_id"] == user_id and not record["read"]:
|
||||
record["read"] = True
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
async def delete(self, notification_id: str, user_id: str) -> bool:
|
||||
record = self._items.get(notification_id)
|
||||
if record is None or record["user_id"] != user_id:
|
||||
return False
|
||||
del self._items[notification_id]
|
||||
return True
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
"""ORM model for in-app notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class NotificationRow(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False, default="")
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), index=True)
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
"""Helper for writing notifications without callers having to thread a store.
|
||||
|
||||
The harness can NOT import ``app.*`` (firewall), so the Gateway app injects
|
||||
its ``NotificationStore`` instance via ``set_notification_store(...)`` at
|
||||
startup. Until that happens, every call is a no-op (silently logged) so
|
||||
non-Gateway entry points (tests, embedded client) don't crash on missing
|
||||
infrastructure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.notifications.base import NotificationStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_store: NotificationStore | None = None
|
||||
|
||||
|
||||
def set_notification_store(store: NotificationStore | None) -> None:
|
||||
"""Inject the active ``NotificationStore`` instance for the process.
|
||||
|
||||
Pass ``None`` to clear (e.g. on shutdown or in tests).
|
||||
"""
|
||||
global _store
|
||||
_store = store
|
||||
|
||||
|
||||
def get_notification_store() -> NotificationStore | None:
|
||||
return _store
|
||||
|
||||
|
||||
async def notify(
|
||||
user_id: str,
|
||||
*,
|
||||
type: str,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Write a notification for ``user_id``. Never raises — failures are logged.
|
||||
|
||||
Args:
|
||||
user_id: Recipient.
|
||||
type: Stable identifier (e.g. ``agent_skill_removed``, ``skill_taken_down``).
|
||||
title: One-line title shown in the inbox.
|
||||
body: Longer description shown when the user expands the entry.
|
||||
payload: Structured context (skill_name, agent_id, actor_user_id, ...).
|
||||
"""
|
||||
try:
|
||||
if _store is None:
|
||||
logger.warning(
|
||||
"notify(user_id=%s, type=%s) dropped — NotificationStore not configured",
|
||||
user_id,
|
||||
type,
|
||||
)
|
||||
return
|
||||
await _store.create(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"type": type,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"payload": payload or {},
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to write notification (user_id=%s, type=%s)", user_id, type)
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
"""SQLAlchemy-backed notification repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.notifications.base import NotificationStore
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
|
||||
|
||||
def _row_to_dict(row: NotificationRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
value = data.get("created_at")
|
||||
if isinstance(value, datetime):
|
||||
data["created_at"] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class NotificationRepository(NotificationStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = NotificationRow(
|
||||
id=data["id"],
|
||||
user_id=data["user_id"],
|
||||
type=data["type"],
|
||||
title=data.get("title") or "",
|
||||
body=data.get("body") or "",
|
||||
payload=data.get("payload") or {},
|
||||
read=False,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(NotificationRow).where(NotificationRow.user_id == user_id)
|
||||
if unread_only:
|
||||
stmt = stmt.where(NotificationRow.read.is_(False))
|
||||
stmt = stmt.order_by(NotificationRow.created_at.desc()).limit(limit)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def unread_count(self, user_id: str) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(NotificationRow)
|
||||
.where(NotificationRow.user_id == user_id, NotificationRow.read.is_(False))
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
async def mark_read(self, notification_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(NotificationRow, notification_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
if not row.read:
|
||||
row.read = True
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def mark_all_read(self, user_id: str) -> int:
|
||||
stmt = (
|
||||
update(NotificationRow)
|
||||
.where(NotificationRow.user_id == user_id, NotificationRow.read.is_(False))
|
||||
.values(read=True)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
async def delete(self, notification_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(NotificationRow, notification_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
"""Persistence layer for custom skills (per-user ownership + publish state)."""
|
||||
|
||||
from deerflow.persistence.skills.base import SkillStore
|
||||
from deerflow.persistence.skills.memory import MemorySkillStore
|
||||
from deerflow.persistence.skills.sql import SkillRepository
|
||||
|
||||
__all__ = ["SkillStore", "MemorySkillStore", "SkillRepository", "make_skill_store"]
|
||||
|
||||
|
||||
def make_skill_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemorySkillStore()
|
||||
return SkillRepository(session_factory)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Abstract persistence API for custom skill ownership + publish state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SkillStore(ABC):
|
||||
@abstractmethod
|
||||
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""List all skill records visible to ``user_id``.
|
||||
|
||||
Visible = owner, OR published by anyone, OR built-in (owner_user_id IS NULL).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_any(self, name: str) -> dict[str, Any] | None:
|
||||
"""Lookup by name regardless of ownership/visibility (admin / cascade use)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a skill record. Caller is responsible for global-name uniqueness check."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Idempotently insert a legacy on-disk skill into the DB. Updates the row if it
|
||||
already exists with ``owner_user_id IS NULL`` (so a re-run can migrate ownership).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Owner-only update. Returns None if the caller doesn't own the skill."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Admin override — caller is responsible for auth check.
|
||||
|
||||
Unlike ``update_builtin`` for agents, this accepts ANY skill including
|
||||
user-owned ones, since the spec lets admin take down others' skills.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, name: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_admin(self, name: str) -> bool:
|
||||
"""Admin-side delete; bypasses ownership check (caller authorizes)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||
raise NotImplementedError
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
"""In-memory ``SkillStore`` implementation for tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.skills.base import SkillStore
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _copy(record: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
return copy.deepcopy(record) if record is not None else None
|
||||
|
||||
|
||||
def _with_derived(record: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(record)
|
||||
out["builtin"] = out.get("owner_user_id") is None
|
||||
return out
|
||||
|
||||
|
||||
class MemorySkillStore(SkillStore):
|
||||
def __init__(self) -> None:
|
||||
self._skills: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _is_visible(record: dict[str, Any], user_id: str) -> bool:
|
||||
owner = record.get("owner_user_id")
|
||||
if owner is None:
|
||||
return True
|
||||
if owner == user_id:
|
||||
return True
|
||||
return bool(record.get("published"))
|
||||
|
||||
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
_with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
for record in self._skills.values()
|
||||
if self._is_visible(record, user_id)
|
||||
]
|
||||
|
||||
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||
record = self._skills.get(name)
|
||||
if record is None or not self._is_visible(record, user_id):
|
||||
return None
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
async def get_any(self, name: str) -> dict[str, Any] | None:
|
||||
record = self._skills.get(name)
|
||||
if record is None:
|
||||
return None
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||
record = self._skills.get(name)
|
||||
if record is None or record.get("owner_user_id") != user_id:
|
||||
return None
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
name = data["name"]
|
||||
if name in self._skills:
|
||||
raise ValueError(f"Skill '{name}' already exists")
|
||||
record = {
|
||||
"name": name,
|
||||
"owner_user_id": data.get("owner_user_id"),
|
||||
"published": bool(data.get("published", False)),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self._skills[name] = record
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
name = data["name"]
|
||||
record = self._skills.get(name)
|
||||
if record is None:
|
||||
record = {
|
||||
"name": name,
|
||||
"owner_user_id": data.get("owner_user_id"),
|
||||
"published": bool(data.get("published", True)),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self._skills[name] = record
|
||||
elif record.get("owner_user_id") is None and data.get("owner_user_id") is not None:
|
||||
record["owner_user_id"] = data["owner_user_id"]
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
record = self._skills.get(name)
|
||||
if record is None or record.get("owner_user_id") != user_id:
|
||||
return None
|
||||
if "published" in data:
|
||||
record["published"] = bool(data["published"])
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
record = self._skills.get(name)
|
||||
if record is None:
|
||||
return None
|
||||
if "published" in data:
|
||||
record["published"] = bool(data["published"])
|
||||
if "owner_user_id" in data:
|
||||
record["owner_user_id"] = data["owner_user_id"]
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
async def delete(self, name: str, user_id: str) -> bool:
|
||||
record = self._skills.get(name)
|
||||
if record is None or record.get("owner_user_id") != user_id:
|
||||
return False
|
||||
del self._skills[name]
|
||||
return True
|
||||
|
||||
async def delete_admin(self, name: str) -> bool:
|
||||
if name not in self._skills:
|
||||
return False
|
||||
del self._skills[name]
|
||||
return True
|
||||
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""ORM model for custom skill ownership + publish state.
|
||||
|
||||
Note: skill content lives on disk under ``skills/custom/{name}/``; this table
|
||||
only stores the metadata (owner, published flag, timestamps). The skill
|
||||
``name`` doubles as the primary key because the filesystem path is already
|
||||
keyed by name, and we require global name uniqueness across all owners.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class SkillRow(Base):
|
||||
__tablename__ = "skills"
|
||||
|
||||
# Global unique name — must match the on-disk folder name under skills/custom/.
|
||||
# Hyphen-case or snake_case allowed; see deerflow.skills.validation.
|
||||
name: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
@@ -0,0 +1,144 @@
|
||||
"""SQLAlchemy-backed custom skill repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.skills.base import SkillStore
|
||||
from deerflow.persistence.skills.model import SkillRow
|
||||
|
||||
|
||||
def _row_to_dict(row: SkillRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
data["builtin"] = data.get("owner_user_id") is None
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class SkillRepository(SkillStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _visible_filter(user_id: str):
|
||||
return or_(
|
||||
SkillRow.owner_user_id.is_(None),
|
||||
SkillRow.owner_user_id == user_id,
|
||||
SkillRow.published.is_(True),
|
||||
)
|
||||
|
||||
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||
stmt = select(SkillRow).where(self._visible_filter(user_id)).order_by(SkillRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||
stmt = select(SkillRow).where(SkillRow.name == name, self._visible_filter(user_id))
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return _row_to_dict(row) if row is not None else None
|
||||
|
||||
async def get_any(self, name: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SkillRow, name)
|
||||
return _row_to_dict(row) if row is not None else None
|
||||
|
||||
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
|
||||
stmt = select(SkillRow).where(SkillRow.name == name, SkillRow.owner_user_id == user_id)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return _row_to_dict(row) if row is not None else None
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = SkillRow(
|
||||
name=data["name"],
|
||||
owner_user_id=data.get("owner_user_id"),
|
||||
published=bool(data.get("published", False)),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SkillRow, data["name"])
|
||||
if row is None:
|
||||
now = datetime.now(UTC)
|
||||
row = SkillRow(
|
||||
name=data["name"],
|
||||
owner_user_id=data.get("owner_user_id"),
|
||||
published=bool(data.get("published", True)),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
elif row.owner_user_id is None and data.get("owner_user_id") is not None:
|
||||
# Only patch ownership if it was previously null — never overwrite
|
||||
# an existing owner record on a re-migration.
|
||||
row.owner_user_id = data["owner_user_id"]
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SkillRow, name)
|
||||
if row is None or row.owner_user_id != user_id:
|
||||
return None
|
||||
if "published" in data:
|
||||
row.published = bool(data["published"])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SkillRow, name)
|
||||
if row is None:
|
||||
return None
|
||||
if "published" in data:
|
||||
row.published = bool(data["published"])
|
||||
if "owner_user_id" in data:
|
||||
row.owner_user_id = data["owner_user_id"]
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def delete(self, name: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SkillRow, name)
|
||||
if row is None or row.owner_user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def delete_admin(self, name: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SkillRow, name)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
|
||||
async with self._sf() as session:
|
||||
return await session.scalar(stmt) or 0
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Cascade handling when a published skill is removed from circulation.
|
||||
|
||||
Called from the Gateway skills router whenever a published skill is
|
||||
unpublished, deleted, or admin-taken-down. Walks every agent on disk that
|
||||
referenced the skill, strips the skill from its ``config.yaml`` ``skills``
|
||||
list, and emits an in-app notification to that agent's owner.
|
||||
|
||||
The harness/app firewall means we cannot import the FastAPI app side; the
|
||||
``AgentStore`` instance must be passed in by the caller (the router gets
|
||||
it from ``app.gateway.deps.get_agent_store``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.persistence.notifications import notify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CascadeOperation = Literal["unpublish", "delete", "admin_takedown"]
|
||||
|
||||
|
||||
def _list_agent_dirs() -> list[Path]:
|
||||
agents_dir = get_paths().agents_dir
|
||||
if not agents_dir.exists():
|
||||
return []
|
||||
return [entry for entry in sorted(agents_dir.iterdir()) if entry.is_dir()]
|
||||
|
||||
|
||||
def _remove_skill_from_yaml(config_file: Path, skill_name: str) -> bool:
|
||||
"""Rewrite ``config.yaml`` removing ``skill_name`` from its ``skills`` list.
|
||||
|
||||
Returns True if the file was modified, False otherwise.
|
||||
"""
|
||||
if not config_file.exists():
|
||||
return False
|
||||
try:
|
||||
with config_file.open("r", encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError:
|
||||
logger.warning("Cascade: failed to parse %s, skipping", config_file)
|
||||
return False
|
||||
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or skill_name not in skills:
|
||||
return False
|
||||
|
||||
data["skills"] = [s for s in skills if s != skill_name]
|
||||
with config_file.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||
return True
|
||||
|
||||
|
||||
_OP_LABELS: dict[CascadeOperation, str] = {
|
||||
"unpublish": "取消发布",
|
||||
"delete": "删除",
|
||||
"admin_takedown": "管理员强制下架",
|
||||
}
|
||||
|
||||
|
||||
async def cascade_skill_unpublished_or_deleted(
|
||||
skill_name: str,
|
||||
*,
|
||||
actor_user_id: str,
|
||||
operation: CascadeOperation,
|
||||
skill_owner_user_id: str | None,
|
||||
agent_store: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Detach ``skill_name`` from every referencing agent and notify owners.
|
||||
|
||||
Self-references (agent.user_id == actor_user_id) are still detached but
|
||||
the owner gets no in-app notification — the user just did the action,
|
||||
they know.
|
||||
|
||||
On ``admin_takedown`` we additionally notify ``skill_owner_user_id``.
|
||||
|
||||
Returns counters for testability: ``{checked, modified, notified}``.
|
||||
"""
|
||||
counters = {"checked": 0, "modified": 0, "notified": 0}
|
||||
label = _OP_LABELS.get(operation, operation)
|
||||
|
||||
for agent_dir in _list_agent_dirs():
|
||||
config_file = agent_dir / "config.yaml"
|
||||
if not config_file.exists():
|
||||
continue
|
||||
counters["checked"] += 1
|
||||
|
||||
# Quick read to decide whether we need to mutate at all.
|
||||
try:
|
||||
agent_cfg = load_agent_config(agent_dir.name)
|
||||
except Exception:
|
||||
logger.debug("Cascade: failed to load %s, skipping", agent_dir.name, exc_info=True)
|
||||
continue
|
||||
if agent_cfg is None or not agent_cfg.skills or skill_name not in agent_cfg.skills:
|
||||
continue
|
||||
|
||||
modified = _remove_skill_from_yaml(config_file, skill_name)
|
||||
if not modified:
|
||||
continue
|
||||
counters["modified"] += 1
|
||||
logger.info(
|
||||
"Cascade: removed skill '%s' from agent '%s/config.yaml' (op=%s)",
|
||||
skill_name,
|
||||
agent_dir.name,
|
||||
operation,
|
||||
)
|
||||
|
||||
# Look up agent owner from DB to decide where to send the notification.
|
||||
record = await agent_store.get_any(agent_dir.name)
|
||||
agent_owner = record.get("user_id") if record else None
|
||||
if agent_owner and agent_owner != actor_user_id:
|
||||
agent_display = (record.get("name") if record else None) or agent_dir.name
|
||||
await notify(
|
||||
agent_owner,
|
||||
type="agent_skill_removed",
|
||||
title=f"专家 “{agent_display}” 的一个技能已失效",
|
||||
body=(
|
||||
f"技能 “{skill_name}” 被 {label},已从该专家的技能列表中自动移除。"
|
||||
),
|
||||
payload={
|
||||
"skill_name": skill_name,
|
||||
"agent_id": agent_dir.name,
|
||||
"agent_name": agent_display,
|
||||
"operation": operation,
|
||||
"actor_user_id": actor_user_id,
|
||||
},
|
||||
)
|
||||
counters["notified"] += 1
|
||||
|
||||
if operation == "admin_takedown" and skill_owner_user_id and skill_owner_user_id != actor_user_id:
|
||||
await notify(
|
||||
skill_owner_user_id,
|
||||
type="skill_taken_down",
|
||||
title=f"你的技能 “{skill_name}” 已被管理员下架",
|
||||
body="管理员对该技能执行了强制下架。如有疑问请联系管理员。",
|
||||
payload={
|
||||
"skill_name": skill_name,
|
||||
"operation": operation,
|
||||
"actor_user_id": actor_user_id,
|
||||
},
|
||||
)
|
||||
counters["notified"] += 1
|
||||
|
||||
return counters
|
||||
@@ -0,0 +1,835 @@
|
||||
"""Skill curator — automatic lifecycle transitions and LLM consolidation pass.
|
||||
|
||||
Per-user since the multitenancy upgrade: every public function takes a
|
||||
``user_id`` argument and the on-disk state lives under
|
||||
``{base_dir}/users/{user_id}/.curator_state`` (alongside the per-user
|
||||
memory.json and threads/ dirs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Coroutine
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CURATOR_STATE_FILENAME = ".curator_state"
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def _run_async_blocking(coro: Coroutine[Any, Any, _T]) -> _T:
|
||||
"""Run an async coroutine to completion from sync curator code.
|
||||
|
||||
Curator is invoked from a background thread, but ``agent.invoke()`` inside
|
||||
``_run_llm_review`` (LangChain 1.x ``create_agent``) spins up its own
|
||||
asyncio loop which can leave the calling thread in an "already has a
|
||||
running loop" state. In that case raw ``asyncio.run(coro)`` raises
|
||||
``RuntimeError`` *after* the coroutine object has been materialised but
|
||||
before anyone awaits it — producing the ``coroutine was never awaited``
|
||||
RuntimeWarning that pollutes our logs.
|
||||
|
||||
This helper detects a running loop in the caller's thread and offloads
|
||||
to a dedicated worker thread; otherwise it falls back to ``asyncio.run``.
|
||||
Either way it guarantees the coroutine is closed on the failure path,
|
||||
so no RuntimeWarning ever escapes.
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
in_loop = True
|
||||
except RuntimeError:
|
||||
in_loop = False
|
||||
|
||||
if in_loop:
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
except BaseException:
|
||||
# ``asyncio.run`` ran in the worker (it consumes the coroutine on
|
||||
# success or surfaces the inner exception); but if the executor
|
||||
# itself failed to submit, close to be safe.
|
||||
try:
|
||||
coro.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
try:
|
||||
return asyncio.run(coro)
|
||||
except BaseException:
|
||||
try:
|
||||
coro.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _get_skills_root() -> Path:
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
return Path(get_or_new_skill_storage().get_skills_root_path())
|
||||
|
||||
|
||||
def _curator_state_path(user_id: str | None) -> Path:
|
||||
"""Return the state file path. Per-user when user_id is known; falls back
|
||||
to the global ``skills/.curator_state`` for legacy callers."""
|
||||
if not user_id:
|
||||
return _get_skills_root() / _CURATOR_STATE_FILENAME
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
return get_paths().user_dir(user_id) / _CURATOR_STATE_FILENAME
|
||||
|
||||
|
||||
def _read_curator_state(user_id: str | None) -> dict:
|
||||
path = _curator_state_path(user_id)
|
||||
if not path.exists():
|
||||
return {"run_count": 0, "paused": False}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"run_count": 0, "paused": False}
|
||||
|
||||
|
||||
def _write_curator_state(state: dict, user_id: str | None) -> None:
|
||||
path = _curator_state_path(user_id)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2, default=str)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug("Failed to write curator state", exc_info=True)
|
||||
|
||||
|
||||
def _get_curator_config(user_id: str | None = None):
|
||||
"""Return the curator config object for ``user_id``.
|
||||
|
||||
Reads ``config.yaml.skill_evolution.curator`` as the baseline and merges
|
||||
the per-user override file (``users/{user_id}/skill_evolution.json``) on
|
||||
top. With ``user_id=None`` falls back to the global default.
|
||||
"""
|
||||
try:
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
||||
|
||||
skill_evo = load_skill_evolution_config_for_user(user_id)
|
||||
return getattr(skill_evo, "curator", None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _list_user_owned_skill_names(user_id: str) -> set[str] | None:
|
||||
"""Return skill names owned by ``user_id`` (used to filter transitions).
|
||||
|
||||
Returns None if the SkillStore is unreachable — curator then degrades to
|
||||
"process every on-disk agent-created skill" (legacy behaviour).
|
||||
"""
|
||||
try:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.skills import make_skill_store
|
||||
|
||||
factory = get_session_factory()
|
||||
if factory is None:
|
||||
return None
|
||||
store = make_skill_store(factory)
|
||||
|
||||
async def _run() -> list[dict]:
|
||||
return await store.list_visible(user_id)
|
||||
|
||||
records = _run_async_blocking(_run())
|
||||
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
|
||||
except Exception:
|
||||
logger.debug("_list_user_owned_skill_names failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _collect_referenced_skill_names() -> set[str]:
|
||||
"""Scan every agent's config.yaml and return the union of their ``skills``
|
||||
fields.
|
||||
|
||||
Used as the "do not touch" set during curator transitions and LLM
|
||||
candidate selection — any skill currently bound to *any* agent
|
||||
(including the owner's own) is exempt from automatic stale/archive
|
||||
and from being suggested for merging.
|
||||
|
||||
Returns an empty set on filesystem errors (best-effort, non-fatal).
|
||||
"""
|
||||
import yaml
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
referenced: set[str] = set()
|
||||
try:
|
||||
agents_dir = get_paths().agents_dir
|
||||
except Exception:
|
||||
return referenced
|
||||
if not agents_dir.exists():
|
||||
return referenced
|
||||
|
||||
for entry in agents_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
config_file = entry / "config.yaml"
|
||||
if not config_file.exists():
|
||||
continue
|
||||
try:
|
||||
with config_file.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
logger.debug("_collect_referenced_skill_names: failed to parse %s", config_file, exc_info=True)
|
||||
continue
|
||||
skills = data.get("skills")
|
||||
if isinstance(skills, list):
|
||||
for s in skills:
|
||||
if isinstance(s, str):
|
||||
referenced.add(s)
|
||||
return referenced
|
||||
|
||||
|
||||
def _is_skill_published(skill_name: str) -> bool:
|
||||
"""Return True if ``skill_name`` is marked ``published=True`` in SkillStore.
|
||||
|
||||
Best-effort — returns False on any error so a transient DB hiccup never
|
||||
causes a published skill to be wrongly archived (the caller treats
|
||||
"published=True" as exempt, so False = "fail open to processing", which
|
||||
only puts a skill *back* into curator scope when DB is unavailable; in
|
||||
that degraded mode curator legacy behaviour applies).
|
||||
"""
|
||||
try:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.skills import make_skill_store
|
||||
|
||||
factory = get_session_factory()
|
||||
if factory is None:
|
||||
return False
|
||||
store = make_skill_store(factory)
|
||||
|
||||
async def _run() -> dict | None:
|
||||
return await store.get_any(skill_name)
|
||||
|
||||
record = _run_async_blocking(_run())
|
||||
return bool(record and record.get("published", False))
|
||||
except Exception:
|
||||
logger.debug("_is_skill_published(%s) failed", skill_name, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _eligible_for_curation(
|
||||
name: str,
|
||||
entry: dict,
|
||||
*,
|
||||
owned_set: set[str] | None,
|
||||
referenced_set: set[str],
|
||||
) -> bool:
|
||||
"""Three-condition gate for whether curator may touch a skill.
|
||||
|
||||
Curator only acts on skills that ALL of:
|
||||
1. source == "agent" (caller is expected to have already filtered, but
|
||||
we double-check here for safety)
|
||||
2. NOT referenced by any agent's skills config
|
||||
3. NOT published (queried via SkillStore)
|
||||
|
||||
``owned_set`` is the set of skill names owned by the running user. When
|
||||
None, the legacy "process every agent-created skill" fallback applies
|
||||
for ownership only (the other two conditions still hold).
|
||||
"""
|
||||
if entry.get("source") != "agent":
|
||||
return False
|
||||
if owned_set is not None and name not in owned_set:
|
||||
return False
|
||||
if name in referenced_set:
|
||||
return False
|
||||
if _is_skill_published(name):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def should_run_now(user_id: str) -> bool:
|
||||
"""Return True if the curator interval has elapsed for ``user_id`` and it
|
||||
is not paused."""
|
||||
curator_cfg = _get_curator_config()
|
||||
if curator_cfg is None or not getattr(curator_cfg, "enabled", True):
|
||||
return False
|
||||
state = _read_curator_state(user_id)
|
||||
if state.get("paused"):
|
||||
return False
|
||||
last_run = state.get("last_run_at")
|
||||
if not last_run:
|
||||
return True
|
||||
interval_hours = getattr(curator_cfg, "interval_hours", 168)
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_run)
|
||||
return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> dict:
|
||||
"""Pure age-based state transitions — no LLM involved.
|
||||
|
||||
Only processes agent-created skills that are ALL of:
|
||||
- owned by ``user_id``
|
||||
- not referenced by any agent's ``skills`` config
|
||||
- not currently published
|
||||
|
||||
Skips pinned skills. Returns counters: {marked_stale, archived, checked,
|
||||
skipped_referenced, skipped_published}.
|
||||
"""
|
||||
from deerflow.skills.usage import STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, agent_created_entries, latest_activity_at, set_state
|
||||
|
||||
if now is None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
curator_cfg = _get_curator_config()
|
||||
stale_days = getattr(curator_cfg, "stale_after_days", 30) if curator_cfg else 30
|
||||
archive_days = getattr(curator_cfg, "archive_after_days", 90) if curator_cfg else 90
|
||||
|
||||
owned = _list_user_owned_skill_names(user_id)
|
||||
referenced = _collect_referenced_skill_names()
|
||||
raw_entries = agent_created_entries()
|
||||
|
||||
skipped_referenced = 0
|
||||
skipped_published = 0
|
||||
eligible: dict[str, dict] = {}
|
||||
for name, entry in raw_entries.items():
|
||||
if owned is not None and name not in owned:
|
||||
continue
|
||||
if name in referenced:
|
||||
skipped_referenced += 1
|
||||
continue
|
||||
if _is_skill_published(name):
|
||||
skipped_published += 1
|
||||
continue
|
||||
eligible[name] = entry
|
||||
|
||||
counters = {
|
||||
"marked_stale": 0,
|
||||
"archived": 0,
|
||||
"checked": len(eligible),
|
||||
"skipped_referenced": skipped_referenced,
|
||||
"skipped_published": skipped_published,
|
||||
}
|
||||
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
for name, entry in eligible.items():
|
||||
if entry.get("pinned"):
|
||||
continue
|
||||
last_activity_str = latest_activity_at(name)
|
||||
if not last_activity_str:
|
||||
continue
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_activity_str)
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=UTC)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
days_idle = (now - last_dt).days
|
||||
current_state = entry.get("state", STATE_ACTIVE)
|
||||
|
||||
if days_idle >= archive_days and current_state != STATE_ARCHIVED:
|
||||
set_state(name, STATE_ARCHIVED)
|
||||
counters["archived"] += 1
|
||||
curator_log.record(
|
||||
"info",
|
||||
f" 自动归档 [{name}]:已闲置 {days_idle} 天(阈值 {archive_days} 天)",
|
||||
user_id=user_id,
|
||||
)
|
||||
elif days_idle >= stale_days and current_state == STATE_ACTIVE:
|
||||
set_state(name, STATE_STALE)
|
||||
counters["marked_stale"] += 1
|
||||
curator_log.record(
|
||||
"info",
|
||||
f" 标记过时 [{name}]:已闲置 {days_idle} 天(阈值 {stale_days} 天)",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
def _render_candidate_list(user_id: str) -> str:
|
||||
"""List of skill candidates handed to the LLM for merge/archive review.
|
||||
|
||||
Same three-condition gate as ``apply_automatic_transitions``: only
|
||||
agent-created, not-referenced-by-any-agent, not-published skills owned
|
||||
by ``user_id`` show up here. The LLM literally cannot see (and thus
|
||||
cannot suggest action on) anything outside that set.
|
||||
"""
|
||||
from deerflow.skills.usage import STATE_ARCHIVED, all_entries
|
||||
|
||||
owned = _list_user_owned_skill_names(user_id)
|
||||
referenced = _collect_referenced_skill_names()
|
||||
entries: dict[str, dict] = {}
|
||||
for k, v in all_entries().items():
|
||||
if not _eligible_for_curation(k, v, owned_set=owned, referenced_set=referenced):
|
||||
continue
|
||||
if v.get("state") == STATE_ARCHIVED:
|
||||
continue
|
||||
entries[k] = v
|
||||
if not entries:
|
||||
return "No agent-created active skills found."
|
||||
lines = []
|
||||
for name, entry in sorted(entries.items()):
|
||||
state = entry.get("state", "active")
|
||||
use = entry.get("use_count", 0)
|
||||
view = entry.get("view_count", 0)
|
||||
patch = entry.get("patch_count", 0)
|
||||
last = entry.get("last_used_at") or entry.get("created_at") or "unknown"
|
||||
lines.append(f"- {name} [state={state}, use={use}, view={view}, patches={patch}, last_active={last}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_CURATOR_REVIEW_PROMPT = (
|
||||
"You are running as DeerFlow's background skill CURATOR. This is an "
|
||||
"UMBRELLA-BUILDING consolidation pass, not a passive audit and not a "
|
||||
"duplicate-finder.\n\n"
|
||||
"The goal of the skill collection is a LIBRARY OF CLASS-LEVEL "
|
||||
"INSTRUCTIONS AND EXPERIENTIAL KNOWLEDGE. A collection of hundreds of "
|
||||
"narrow skills where each one captures one session's specific bug is "
|
||||
"a FAILURE of the library — not a feature. An agent searching skills "
|
||||
"matches on descriptions, not on exact names; one broad umbrella "
|
||||
"skill with labeled subsections beats five narrow siblings for "
|
||||
"discoverability, not the other way around.\n\n"
|
||||
"The right target shape is CLASS-LEVEL skills with rich SKILL.md "
|
||||
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
|
||||
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
|
||||
"Hard rules — do not violate:\n"
|
||||
"1. DO NOT touch public (built-in) skills. The candidate list below "
|
||||
"is already filtered to agent-created custom skills only.\n"
|
||||
"2. DO NOT delete any skill. Archiving (a soft state flip via the "
|
||||
"skill_archive tool — the file stays in custom/<name>/ and the user "
|
||||
"can restore it from the Web UI) is the maximum action you may take. "
|
||||
"Archives are recoverable; deletion is not. "
|
||||
"When you call skill_archive(name, reason) you MUST pass a `reason` "
|
||||
"string written **in Chinese**, in plain language a human operator "
|
||||
"would read — NOT English jargon or terse debug notes. Examples of "
|
||||
"acceptable reasons: '已合并到 code-formatter,该技能同时覆盖 Python 与 JS', "
|
||||
"'与 foo-bar 内容重复,保留 foo-bar', '重构后过时,被 api-v2-search 替代', "
|
||||
"'一次性 POC 实验,无复用价值'. Bad reasons (do NOT use): 'merged', "
|
||||
"'duplicate', 'obsolete', 'consolidated'. The reason is shown in the "
|
||||
"Web UI run-history for user audit, so write it as you would explain "
|
||||
"it to the user themselves.\n"
|
||||
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
|
||||
"4. DO NOT use usage counters as a reason to skip consolidation. The "
|
||||
"counters are new and often mostly zero. Judge overlap on CONTENT, "
|
||||
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
|
||||
"absence of evidence either way.\n"
|
||||
"5. DO NOT reject consolidation on the grounds that 'each skill has "
|
||||
"a distinct trigger'. Pairwise distinctness is the wrong bar. The "
|
||||
"right bar is: 'would a human maintainer write this as N separate "
|
||||
"skills, or as one skill with N labeled subsections?' When the "
|
||||
"answer is the latter, merge.\n\n"
|
||||
"How to work — not optional:\n"
|
||||
"1. Scan the full candidate list. Identify PREFIX CLUSTERS (skills "
|
||||
"sharing a first word or domain keyword). Examples you are likely "
|
||||
"to find: search-*, code-*, data-*, api-*, file-*, analysis-*, "
|
||||
"workflow-*, report-*, monitor-*, debug-*, python-*, security-*, etc. "
|
||||
"Expect 10-25 clusters.\n"
|
||||
"2. For each cluster with 2+ members, do NOT ask 'are these pairs "
|
||||
"overlapping?' — ask 'what is the UMBRELLA CLASS these skills all "
|
||||
"serve? Would a maintainer name that class and write one skill for "
|
||||
"it?' If yes, pick (or create) the umbrella and absorb the siblings "
|
||||
"into it.\n"
|
||||
"3. Three ways to consolidate — use the right one per cluster:\n"
|
||||
" a. MERGE INTO EXISTING UMBRELLA — one skill in the cluster is "
|
||||
"already broad enough to be the umbrella. Patch it to add a labeled "
|
||||
"section for each sibling's unique insight, then archive the "
|
||||
"siblings.\n"
|
||||
" b. CREATE A NEW UMBRELLA SKILL.md — no existing member is broad "
|
||||
"enough. Create a new class-level skill whose SKILL.md covers the "
|
||||
"shared workflow and has short labeled subsections. Archive the "
|
||||
"now-absorbed narrow siblings.\n"
|
||||
" c. DEMOTE TO REFERENCES/TEMPLATES/SCRIPTS — a sibling has "
|
||||
"narrow-but-valuable session-specific content. Move it into the "
|
||||
"umbrella's appropriate support directory:\n"
|
||||
" • `references/<topic>.md` for session-specific detail OR "
|
||||
"condensed knowledge banks (quoted research, API docs excerpts, "
|
||||
"domain notes, provider quirks, reproduction recipes)\n"
|
||||
" • `templates/<name>.<ext>` for starter files meant to be "
|
||||
"copied and modified\n"
|
||||
" • `scripts/<name>.<ext>` for statically re-runnable actions "
|
||||
"(verification scripts, fixture generators, probes)\n"
|
||||
" Then archive the old sibling.\n"
|
||||
"4. Also flag skills whose NAME is too narrow (contains a specific "
|
||||
"issue number, a feature codename, a specific error string, an "
|
||||
"'audit' / 'diagnosis' / 'salvage' session artifact). These almost "
|
||||
"always belong as a subsection or support file under a class-level "
|
||||
"umbrella.\n"
|
||||
"5. Iterate. After one consolidation round, scan the remaining set "
|
||||
"and look for the NEXT umbrella opportunity. Don't stop after 3 "
|
||||
"merges.\n\n"
|
||||
"'keep' is a legitimate decision ONLY when the skill is already a "
|
||||
"class-level umbrella and none of the proposed merges would improve "
|
||||
"discoverability. 'This is narrow but distinct from its siblings' "
|
||||
"is NOT a reason to keep — it's a reason to move it under an "
|
||||
"umbrella as a subsection or support file.\n\n"
|
||||
"Expected output: real umbrella-ification. Process every obvious "
|
||||
"cluster. If you end the pass with fewer than 10 archives, you "
|
||||
"stopped too early — go back and look at the clusters you left "
|
||||
"alone.\n\n"
|
||||
"When done, write a human summary AND a structured machine-readable "
|
||||
"block so downstream tooling can distinguish consolidation from "
|
||||
"pruning. Format EXACTLY:\n\n"
|
||||
"## Structured summary (required)\n"
|
||||
"```yaml\n"
|
||||
"consolidations:\n"
|
||||
" - from: <old-skill-name>\n"
|
||||
" into: <umbrella-skill-name>\n"
|
||||
" reason: <one short sentence — why merged, not just 'similar'>\n"
|
||||
"prunings:\n"
|
||||
" - name: <skill-name>\n"
|
||||
" reason: <one short sentence — why archived with no merge target>\n"
|
||||
"```\n\n"
|
||||
"Every skill you archived via skill_archive MUST appear in exactly one "
|
||||
"of the two lists. If you consolidated X into umbrella Y (patched Y, wrote "
|
||||
"a references file to Y, or created Y with X's content absorbed), X "
|
||||
"goes under `consolidations` with `into: Y`. If you archived X with "
|
||||
"no absorption — truly stale, irrelevant, or obsolete — X goes under "
|
||||
"`prunings`. Leave a list empty (`consolidations: []`) if none. Do "
|
||||
"not omit the block. The block comes AFTER your human-readable "
|
||||
"summary of clusters processed, patches made, and decisions left alone."
|
||||
)
|
||||
|
||||
|
||||
def _make_skill_archive_tool(user_id: str):
|
||||
"""Curator-only tool: soft-archive a skill (sets state, files stay put)."""
|
||||
from langchain.tools import tool as lc_tool
|
||||
|
||||
from deerflow.skills import curator_log
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
@lc_tool("skill_archive")
|
||||
def _skill_archive(name: str, reason: str) -> str:
|
||||
"""Soft-archive an agent-created skill (sets state=archived; files stay in custom/).
|
||||
|
||||
The user can recover the skill from the Web UI's archive drawer.
|
||||
This is intentionally non-destructive — the on-disk SKILL.md and any
|
||||
supporting files are preserved so nothing is lost.
|
||||
|
||||
Args:
|
||||
name: The skill name to archive.
|
||||
reason: 必填。用**中文**写一句话,清晰说明为什么归档这个技能 —
|
||||
直接给读它的运维人员看,不要写英文术语 / 调试信息。例如:
|
||||
- "已合并到 code-formatter,该技能同时覆盖 Python 与 JS"
|
||||
- "重构后过时,被 api-v2-search 替代"
|
||||
- "与现有 foo-bar 技能重复,内容完全一致"
|
||||
- "一次性 POC 实验,无复用价值"
|
||||
- "命名过窄(含具体 issue 号),已并入通用伞技能 X"
|
||||
此原因会展示在策展日志和 Web UI 运行历史中,供用户审计。
|
||||
"""
|
||||
from deerflow.skills.usage import STATE_ARCHIVED, get_entry, set_state
|
||||
|
||||
# Defensive: tolerate empty/None reason but log a clear placeholder
|
||||
# rather than crashing the curator run.
|
||||
reason_text = (reason or "").strip() or "未提供原因"
|
||||
|
||||
storage = get_or_new_skill_storage()
|
||||
if not storage.custom_skill_exists(name):
|
||||
curator_log.record("warning", f"归档:未找到技能 [{name}],或它已被归档", user_id=user_id)
|
||||
return f"Skill '{name}' not found or already archived."
|
||||
entry = get_entry(name)
|
||||
if entry.get("pinned"):
|
||||
curator_log.record("info", f"归档:技能 [{name}] 已被钉选,跳过", user_id=user_id)
|
||||
return f"Skill '{name}' is pinned — skipping."
|
||||
if entry.get("state") == STATE_ARCHIVED:
|
||||
curator_log.record("info", f"归档:技能 [{name}] 已是归档状态,跳过", user_id=user_id)
|
||||
return f"Skill '{name}' is already archived."
|
||||
|
||||
# Safety net: even if the LLM hallucinated a name not in the
|
||||
# candidate list, refuse to archive anything that is published or
|
||||
# currently referenced by an agent.
|
||||
if _is_skill_published(name):
|
||||
curator_log.record(
|
||||
"warning",
|
||||
f"归档:已拒绝处理 [{name}],该技能已发布(策展不允许操作已发布的技能)",
|
||||
user_id=user_id,
|
||||
)
|
||||
return f"Refused: skill '{name}' is published; manual unpublish is required first."
|
||||
referenced = _collect_referenced_skill_names()
|
||||
if name in referenced:
|
||||
curator_log.record(
|
||||
"warning",
|
||||
f"归档:已拒绝处理 [{name}],该技能正被某个 Agent 引用",
|
||||
user_id=user_id,
|
||||
)
|
||||
return f"Refused: skill '{name}' is still referenced by one or more agents."
|
||||
|
||||
# Soft archive: flip the lifecycle state. The skill directory stays in
|
||||
# ``custom/<name>/`` untouched so the user can recover it from the
|
||||
# Web UI's archive drawer (which lists skills by state, not by location).
|
||||
try:
|
||||
set_state(name, STATE_ARCHIVED)
|
||||
except Exception as e:
|
||||
curator_log.record("error", f"归档失败:技能 [{name}] — {e}", user_id=user_id)
|
||||
return f"Archive failed: {e}"
|
||||
curator_log.record(
|
||||
"info",
|
||||
f"已归档(软):[{name}] — 原因:{reason_text} — 文件保留在 custom/{name}/",
|
||||
user_id=user_id,
|
||||
)
|
||||
return f"Archived '{name}' (reason: {reason_text}; files preserved in custom/{name}/)."
|
||||
|
||||
return _skill_archive
|
||||
|
||||
|
||||
def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
||||
"""Run the curator consolidation pass as a React agent with skill tools bound."""
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
try:
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
||||
|
||||
model = create_chat_model()
|
||||
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool(user_id)]
|
||||
# Reuse the lead-agent error middleware: when the LLM mis-calls a tool
|
||||
# (wrong action / missing required args), the exception is converted
|
||||
# into a ToolMessage(status="error") so the LLM can read the error and
|
||||
# retry, instead of aborting the entire review pass.
|
||||
# NOTE: ``create_react_agent`` (langgraph.prebuilt) does NOT accept a
|
||||
# middleware kwarg — use LangChain 1.x's ``create_agent`` which does.
|
||||
agent = create_agent(model=model, tools=tools, middleware=[ToolErrorHandlingMiddleware()])
|
||||
|
||||
curator_log.record("info", "LLM 评审:开始调用 React Agent", user_id=user_id)
|
||||
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
|
||||
result = agent.invoke({"messages": [HumanMessage(content=prompt)]})
|
||||
|
||||
messages = result.get("messages", [])
|
||||
for msg in reversed(messages):
|
||||
if not getattr(msg, "tool_calls", None):
|
||||
content = getattr(msg, "content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
text = " ".join(
|
||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||
for block in content
|
||||
).strip()
|
||||
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
||||
return text
|
||||
curator_log.record("warning", "LLM 评审:本次未产出最终回复", user_id=user_id)
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning("Curator LLM review failed: %s", e)
|
||||
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_structured_summary(text: str) -> dict:
|
||||
pattern = re.compile(r"```yaml\s*(.*?)```", re.DOTALL)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return {"consolidations": [], "prunings": []}
|
||||
try:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(match.group(1)) or {}
|
||||
return {"consolidations": data.get("consolidations") or [], "prunings": data.get("prunings") or []}
|
||||
except Exception:
|
||||
return {"consolidations": [], "prunings": []}
|
||||
|
||||
|
||||
def run_curator_review(user_id: str) -> dict:
|
||||
"""Main curator run for ``user_id``: transitions → LLM consolidation → state.
|
||||
|
||||
Only skills owned by ``user_id`` are touched.
|
||||
"""
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
curator_log.start_new_run(user_id=user_id)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
logger.info("Curator[%s]: starting run at %s", user_id, now.isoformat())
|
||||
curator_log.record("info", f"本次策展开始,启动时间 {now.isoformat()}", user_id=user_id)
|
||||
|
||||
transition_counters = apply_automatic_transitions(user_id, now)
|
||||
logger.info("Curator[%s]: transitions=%s", user_id, transition_counters)
|
||||
curator_log.record(
|
||||
"info",
|
||||
(
|
||||
f"按年龄自动流转完成:已检查 {transition_counters['checked']} 个,"
|
||||
f"标记陈旧 {transition_counters['marked_stale']} 个,"
|
||||
f"自动归档 {transition_counters['archived']} 个"
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
candidate_list = _render_candidate_list(user_id)
|
||||
candidate_line_count = 0 if not candidate_list.strip() else len([line for line in candidate_list.splitlines() if line.startswith("- ")])
|
||||
curator_log.record(
|
||||
"info", f"进入 LLM 评审的候选技能:共 {candidate_line_count} 个", user_id=user_id
|
||||
)
|
||||
|
||||
llm_text = _run_llm_review(candidate_list, user_id=user_id)
|
||||
suggestions = _parse_structured_summary(llm_text)
|
||||
curator_log.record(
|
||||
"info",
|
||||
(
|
||||
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
|
||||
f"建议删减 {len(suggestions['prunings'])} 项"
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
# Surface per-item reasoning so operators can audit *why* each action
|
||||
# was taken, not just the count.
|
||||
for item in suggestions.get("consolidations", []) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("from", "?")
|
||||
into = item.get("into", "?")
|
||||
reason = (item.get("reason") or "无说明").strip()
|
||||
curator_log.record(
|
||||
"info",
|
||||
f" 合并建议 [{name}] → [{into}]:{reason}",
|
||||
user_id=user_id,
|
||||
)
|
||||
for item in suggestions.get("prunings", []) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name", "?")
|
||||
reason = (item.get("reason") or "无说明").strip()
|
||||
curator_log.record(
|
||||
"info",
|
||||
f" 删减建议 [{name}]:{reason}",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
state = _read_curator_state(user_id)
|
||||
state["last_run_at"] = now.isoformat()
|
||||
state["run_count"] = state.get("run_count", 0) + 1
|
||||
state["last_run_summary"] = (
|
||||
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
|
||||
f"归档 {transition_counters['archived']} 个;"
|
||||
f"LLM:建议合并 {len(suggestions['consolidations'])} 项"
|
||||
)
|
||||
state["last_consolidation_suggestions"] = suggestions
|
||||
_write_curator_state(state, user_id)
|
||||
|
||||
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
|
||||
curator_log.record("info", f"本次策展已完成 — {state['last_run_summary']}", user_id=user_id)
|
||||
return {"transitions": transition_counters, "suggestions": suggestions}
|
||||
|
||||
|
||||
class _CuratorUser:
|
||||
"""Minimal ``CurrentUser`` protocol implementation for background runs.
|
||||
|
||||
Curator threads are spawned via ``threading.Thread`` (not an asyncio task),
|
||||
so the ``deerflow.runtime.user_context`` ContextVar starts unset. Without
|
||||
a stand-in, any tool the LLM review invokes (e.g. ``skill_manage(create)``)
|
||||
would call ``get_effective_user_id()`` and silently fall back to the
|
||||
``DEFAULT_USER_ID`` sentinel — causing newly-created skills to be owned
|
||||
by ``"default"`` instead of the user the curator is running for.
|
||||
"""
|
||||
|
||||
__slots__ = ("id",)
|
||||
|
||||
def __init__(self, user_id: str) -> None:
|
||||
self.id = user_id
|
||||
|
||||
|
||||
def _curator_thread_target(user_id: str) -> None:
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
# Bind the per-thread user context for the entire curator run so every
|
||||
# downstream tool / repository call resolves the correct user_id.
|
||||
token = set_current_user(_CuratorUser(user_id))
|
||||
try:
|
||||
run_curator_review(user_id)
|
||||
except Exception as exc:
|
||||
logger.exception("Curator[%s] background run failed", user_id)
|
||||
curator_log.record("error", f"策展后台任务失败:{exc}", user_id=user_id)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
|
||||
async def maybe_run_curator() -> None:
|
||||
"""Async startup entry — kicks off a thread per user whose interval elapsed.
|
||||
|
||||
Skips users with no DB-tracked entries (a fresh deployment) and never
|
||||
blocks lifespan startup on failure.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
|
||||
factory = get_session_factory()
|
||||
if factory is None:
|
||||
return
|
||||
async with factory() as session:
|
||||
result = await session.execute(select(UserRow.id))
|
||||
user_ids = [str(row[0]) for row in result.all()]
|
||||
except Exception:
|
||||
logger.exception("Curator: failed to enumerate users at startup")
|
||||
return
|
||||
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
if not should_run_now(user_id):
|
||||
continue
|
||||
logger.info("Curator[%s]: interval elapsed, spawning background thread", user_id)
|
||||
thread = threading.Thread(
|
||||
target=_curator_thread_target,
|
||||
args=(user_id,),
|
||||
daemon=True,
|
||||
name=f"skill-curator-{user_id}",
|
||||
)
|
||||
thread.start()
|
||||
except Exception:
|
||||
logger.exception("Curator: failed to spawn for user %s", user_id)
|
||||
|
||||
|
||||
def get_curator_status(user_id: str) -> dict:
|
||||
"""Return curator state for ``user_id``."""
|
||||
from deerflow.skills.usage import STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, agent_created_entries
|
||||
|
||||
state = _read_curator_state(user_id)
|
||||
owned = _list_user_owned_skill_names(user_id)
|
||||
entries = agent_created_entries()
|
||||
if owned is not None:
|
||||
entries = {name: entry for name, entry in entries.items() if name in owned}
|
||||
counts: dict[str, int] = {STATE_ACTIVE: 0, STATE_STALE: 0, STATE_ARCHIVED: 0}
|
||||
for entry in entries.values():
|
||||
s = entry.get("state", STATE_ACTIVE)
|
||||
counts[s] = counts.get(s, 0) + 1
|
||||
|
||||
cfg = _get_curator_config()
|
||||
return {
|
||||
"enabled": cfg is not None and getattr(cfg, "enabled", True),
|
||||
"run_count": state.get("run_count", 0),
|
||||
"last_run_at": state.get("last_run_at"),
|
||||
"last_run_summary": state.get("last_run_summary"),
|
||||
"paused": state.get("paused", False),
|
||||
"agent_created_skills": {
|
||||
"total": len(entries),
|
||||
"active": counts[STATE_ACTIVE],
|
||||
"stale": counts[STATE_STALE],
|
||||
"archived": counts[STATE_ARCHIVED],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"""In-memory ring buffer holding the most recent curator run's execution log.
|
||||
|
||||
Now sharded per-user — each ``user_id`` gets its own deque so users only see
|
||||
their own curator activity from the Web UI.
|
||||
|
||||
The buffer is intentionally process-local and non-persistent. The Gateway
|
||||
exposes it over ``GET /api/curator/logs`` with the active user id resolved
|
||||
from the request context.
|
||||
|
||||
All public functions swallow internal exceptions so a logging failure can
|
||||
never break the curator run itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_RECORDS = 200
|
||||
# Reserved bucket key for "no user context" callers (legacy code paths or
|
||||
# tests). Real curator runs always supply a concrete user_id.
|
||||
_DEFAULT_BUCKET = "__default__"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_buffers: dict[str, deque[dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _bucket_key(user_id: str | None) -> str:
|
||||
return user_id or _DEFAULT_BUCKET
|
||||
|
||||
|
||||
def _get_or_create(user_id: str | None) -> deque[dict[str, Any]]:
|
||||
key = _bucket_key(user_id)
|
||||
buffer = _buffers.get(key)
|
||||
if buffer is None:
|
||||
buffer = deque(maxlen=_MAX_RECORDS)
|
||||
_buffers[key] = buffer
|
||||
return buffer
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def start_new_run(user_id: str | None = None) -> None:
|
||||
"""Clear the user's buffer and emit a ``run_started`` record."""
|
||||
try:
|
||||
with _lock:
|
||||
buffer = _get_or_create(user_id)
|
||||
buffer.clear()
|
||||
buffer.append({"ts": _now_iso(), "level": "info", "message": "run_started"})
|
||||
except Exception:
|
||||
logger.debug("curator_log.start_new_run failed", exc_info=True)
|
||||
|
||||
|
||||
def record(level: str, message: str, *, user_id: str | None = None, **fields: Any) -> None:
|
||||
"""Append a single log record to the user's buffer. Never raises."""
|
||||
try:
|
||||
entry: dict[str, Any] = {
|
||||
"ts": _now_iso(),
|
||||
"level": str(level or "info"),
|
||||
"message": str(message),
|
||||
}
|
||||
if fields:
|
||||
entry.update({k: v for k, v in fields.items() if k not in entry})
|
||||
with _lock:
|
||||
buffer = _get_or_create(user_id)
|
||||
buffer.append(entry)
|
||||
except Exception:
|
||||
logger.debug("curator_log.record failed", exc_info=True)
|
||||
|
||||
|
||||
def get_recent(user_id: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a snapshot of records for ``user_id``. Never raises."""
|
||||
try:
|
||||
with _lock:
|
||||
buffer = _buffers.get(_bucket_key(user_id))
|
||||
if buffer is None:
|
||||
return []
|
||||
return list(buffer)
|
||||
except Exception:
|
||||
logger.debug("curator_log.get_recent failed", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def clear(user_id: str | None = None) -> None:
|
||||
"""Drop a user's records. With ``user_id=None`` drops the default bucket.
|
||||
|
||||
Use ``clear_all()`` for cross-user cleanup (tests).
|
||||
"""
|
||||
try:
|
||||
with _lock:
|
||||
buffer = _buffers.get(_bucket_key(user_id))
|
||||
if buffer is not None:
|
||||
buffer.clear()
|
||||
except Exception:
|
||||
logger.debug("curator_log.clear failed", exc_info=True)
|
||||
|
||||
|
||||
def clear_all() -> None:
|
||||
"""Drop every user's records. Used by tests."""
|
||||
try:
|
||||
with _lock:
|
||||
_buffers.clear()
|
||||
except Exception:
|
||||
logger.debug("curator_log.clear_all failed", exc_info=True)
|
||||
+10
-6
@@ -189,6 +189,7 @@ class LocalSkillStorage(SkillStorage):
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
require_skill_suffix: bool = True,
|
||||
target_category: SkillCategory = SkillCategory.CUSTOM,
|
||||
) -> dict:
|
||||
from deerflow.skills.installer import (
|
||||
SkillAlreadyExistsError,
|
||||
@@ -199,10 +200,10 @@ class LocalSkillStorage(SkillStorage):
|
||||
)
|
||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||
|
||||
logger.info("Installing skill from %s (overwrite=%s)", archive_path, overwrite)
|
||||
logger.info("Installing skill from %s (overwrite=%s, target_category=%s)", archive_path, overwrite, target_category)
|
||||
|
||||
custom_dir = self._host_root / "custom"
|
||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
||||
category_dir = self._host_root / target_category.value
|
||||
category_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
@@ -218,10 +219,12 @@ class LocalSkillStorage(SkillStorage):
|
||||
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||
raise ValueError(f"Invalid skill name: {skill_name}")
|
||||
|
||||
if self.public_skill_exists(skill_name):
|
||||
# Custom installs cannot overwrite built-in public skills.
|
||||
# Public installs (admin-only) are allowed to replace existing public skills when overwrite=True.
|
||||
if target_category == SkillCategory.CUSTOM and self.public_skill_exists(skill_name):
|
||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' conflicts with a built-in public skill and cannot be overwritten")
|
||||
|
||||
target = custom_dir / skill_name
|
||||
target = category_dir / skill_name
|
||||
replaced = False
|
||||
if target.exists():
|
||||
if not overwrite:
|
||||
@@ -231,7 +234,7 @@ class LocalSkillStorage(SkillStorage):
|
||||
|
||||
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root:
|
||||
with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=category_dir) as staging_root:
|
||||
staging_target = Path(staging_root) / skill_name
|
||||
shutil.copytree(skill_dir, staging_target)
|
||||
_move_staged_skill_into_reserved_target(staging_target, target)
|
||||
@@ -242,6 +245,7 @@ class LocalSkillStorage(SkillStorage):
|
||||
"skill_name": skill_name,
|
||||
"message": f"Skill '{skill_name}' installed successfully",
|
||||
"overwritten": replaced,
|
||||
"category": target_category.value,
|
||||
}
|
||||
|
||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
||||
|
||||
+14
-3
@@ -132,17 +132,28 @@ class SkillStorage(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
|
||||
async def ainstall_skill_from_archive(
|
||||
self,
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
target_category: SkillCategory = SkillCategory.CUSTOM,
|
||||
) -> dict:
|
||||
"""Async install of a skill from a ``.skill`` ZIP archive.
|
||||
|
||||
``target_category`` controls the destination directory (public or custom).
|
||||
Origin: ``deerflow.skills.installer.ainstall_skill_from_archive``.
|
||||
"""
|
||||
|
||||
def install_skill_from_archive(self, archive_path: str | Path) -> dict:
|
||||
def install_skill_from_archive(
|
||||
self,
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
target_category: SkillCategory = SkillCategory.CUSTOM,
|
||||
) -> dict:
|
||||
"""Sync wrapper — delegates to :meth:`ainstall_skill_from_archive`."""
|
||||
from deerflow.skills.installer import _run_async_install
|
||||
|
||||
return _run_async_install(self.ainstall_skill_from_archive(archive_path))
|
||||
return _run_async_install(self.ainstall_skill_from_archive(archive_path, target_category=target_category))
|
||||
|
||||
@abstractmethod
|
||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Skill usage sidecar — per-skill telemetry stored in .usage.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_USAGE_FILENAME = ".usage.json"
|
||||
|
||||
STATE_ACTIVE = "active"
|
||||
STATE_STALE = "stale"
|
||||
STATE_ARCHIVED = "archived"
|
||||
|
||||
SOURCE_AGENT = "agent"
|
||||
SOURCE_UPLOAD = "upload"
|
||||
|
||||
_DEFAULT_ENTRY: dict = {
|
||||
"use_count": 0,
|
||||
"view_count": 0,
|
||||
"patch_count": 0,
|
||||
"last_used_at": None,
|
||||
"last_viewed_at": None,
|
||||
"last_patched_at": None,
|
||||
"created_at": None,
|
||||
"state": STATE_ACTIVE,
|
||||
"pinned": False,
|
||||
"archived_at": None,
|
||||
"source": SOURCE_UPLOAD,
|
||||
}
|
||||
|
||||
|
||||
def _usage_path() -> Path:
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
return Path(get_or_new_skill_storage().get_skills_root_path()) / _USAGE_FILENAME
|
||||
|
||||
|
||||
def _read_all() -> dict:
|
||||
path = _usage_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
logger.debug("Failed to read %s", path, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_all(data: dict) -> None:
|
||||
path = _usage_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug("Failed to write %s", path, exc_info=True)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _update_entry(name: str, updates: dict) -> None:
|
||||
try:
|
||||
data = _read_all()
|
||||
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
|
||||
entry.update(updates)
|
||||
data[name] = entry
|
||||
_write_all(data)
|
||||
except Exception:
|
||||
logger.debug("Failed to update usage entry for %r", name, exc_info=True)
|
||||
|
||||
|
||||
def get_entry(name: str) -> dict:
|
||||
"""Return the usage entry for *name*, filling missing fields with defaults."""
|
||||
data = _read_all()
|
||||
return {**_DEFAULT_ENTRY, **data.get(name, {})}
|
||||
|
||||
|
||||
def all_entries() -> dict:
|
||||
"""Return all usage entries keyed by skill name."""
|
||||
return _read_all()
|
||||
|
||||
|
||||
def agent_created_entries() -> dict:
|
||||
"""Return only entries where source == 'agent' (eligible for curation)."""
|
||||
return {k: v for k, v in _read_all().items() if v.get("source") == SOURCE_AGENT}
|
||||
|
||||
|
||||
def bump_view(name: str) -> None:
|
||||
entry = get_entry(name)
|
||||
_update_entry(name, {"view_count": entry.get("view_count", 0) + 1, "last_viewed_at": _now_iso()})
|
||||
|
||||
|
||||
def bump_use(name: str) -> None:
|
||||
entry = get_entry(name)
|
||||
_update_entry(name, {"use_count": entry.get("use_count", 0) + 1, "last_used_at": _now_iso()})
|
||||
|
||||
|
||||
def bump_patch(name: str) -> None:
|
||||
entry = get_entry(name)
|
||||
_update_entry(name, {"patch_count": entry.get("patch_count", 0) + 1, "last_patched_at": _now_iso()})
|
||||
|
||||
|
||||
def set_state(name: str, state: str) -> None:
|
||||
updates: dict = {"state": state}
|
||||
if state == STATE_ARCHIVED:
|
||||
updates["archived_at"] = _now_iso()
|
||||
elif state == STATE_ACTIVE:
|
||||
updates["archived_at"] = None
|
||||
_update_entry(name, updates)
|
||||
|
||||
|
||||
def set_pinned(name: str, pinned: bool) -> None:
|
||||
_update_entry(name, {"pinned": pinned})
|
||||
|
||||
|
||||
def forget(name: str) -> None:
|
||||
try:
|
||||
data = _read_all()
|
||||
data.pop(name, None)
|
||||
_write_all(data)
|
||||
except Exception:
|
||||
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
|
||||
|
||||
|
||||
def mark_agent_created(name: str) -> None:
|
||||
"""Record a skill as agent-created; sets source='agent' and initialises created_at."""
|
||||
_update_entry(name, {"source": SOURCE_AGENT, "created_at": _now_iso(), "state": STATE_ACTIVE})
|
||||
|
||||
|
||||
def mark_uploaded(name: str) -> None:
|
||||
"""Record a skill as user-uploaded; sets source='upload'."""
|
||||
_update_entry(name, {"source": SOURCE_UPLOAD, "created_at": _now_iso()})
|
||||
|
||||
|
||||
def latest_activity_at(name: str) -> str | None:
|
||||
"""Return the most recent activity timestamp, falling back to created_at."""
|
||||
entry = get_entry(name)
|
||||
candidates = [entry.get("last_used_at"), entry.get("last_viewed_at"), entry.get("last_patched_at")]
|
||||
valid = [ts for ts in candidates if ts]
|
||||
if valid:
|
||||
return max(valid)
|
||||
return entry.get("created_at")
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
"""skill_view and skill_list — read-only tools for agent skill introspection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_active_agent_id(runtime: ToolRuntime | None) -> str | None:
|
||||
"""Extract the agent_id under which the current tool call is running.
|
||||
|
||||
Custom agents pass their id via either the ``context`` dict or the
|
||||
``configurable`` section of the LangGraph runtime config. The default
|
||||
lead agent runs without an explicit agent_id — callers should treat
|
||||
a None return as "no per-agent skills filter applies".
|
||||
"""
|
||||
if runtime is None:
|
||||
return None
|
||||
try:
|
||||
if getattr(runtime, "context", None):
|
||||
ctx_id = runtime.context.get("agent_id")
|
||||
if ctx_id:
|
||||
return str(ctx_id)
|
||||
cfg = getattr(runtime, "config", None)
|
||||
if isinstance(cfg, dict):
|
||||
cid = cfg.get("configurable", {}).get("agent_id")
|
||||
if cid:
|
||||
return str(cid)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
|
||||
"""Return the explicit skill name set declared in an agent's config.yaml.
|
||||
|
||||
Semantics:
|
||||
- ``None`` returned → no agent_id, or config missing — caller should
|
||||
fall back to "all skills visible".
|
||||
- ``set()`` (empty) → agent has ``skills: []`` declared — visibility
|
||||
is intentionally empty.
|
||||
- Non-empty set → only these names are visible to this agent.
|
||||
"""
|
||||
if not agent_id:
|
||||
return None
|
||||
try:
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
|
||||
cfg = load_agent_config(agent_id)
|
||||
if cfg is None or cfg.skills is None:
|
||||
return None
|
||||
return set(cfg.skills)
|
||||
except Exception:
|
||||
logger.debug("Failed to load agent config for skill_list filter", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@tool("skill_view")
|
||||
def skill_view_tool(name: str) -> str:
|
||||
"""Read the full SKILL.md content of a skill by name.
|
||||
|
||||
Args:
|
||||
name: The skill name (hyphen-case, e.g. 'my-skill').
|
||||
"""
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.usage import bump_view
|
||||
|
||||
storage = get_or_new_skill_storage()
|
||||
try:
|
||||
if storage.custom_skill_exists(name):
|
||||
content = storage.read_custom_skill(name)
|
||||
elif storage.public_skill_exists(name):
|
||||
skill_file = storage.get_skills_root_path()
|
||||
from pathlib import Path
|
||||
from deerflow.skills.types import SKILL_MD_FILE
|
||||
|
||||
path = Path(skill_file) / "public" / name / SKILL_MD_FILE
|
||||
if not path.exists():
|
||||
return f"Skill '{name}' not found."
|
||||
content = path.read_text(encoding="utf-8")
|
||||
else:
|
||||
return f"Skill '{name}' not found."
|
||||
except Exception as e:
|
||||
logger.debug("skill_view failed for %r: %s", name, e)
|
||||
return f"Error reading skill '{name}': {e}"
|
||||
|
||||
try:
|
||||
bump_view(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@tool("skill_list")
|
||||
def skill_list_tool(runtime: ToolRuntime) -> str:
|
||||
"""List skills available to this agent, with metadata and usage stats.
|
||||
Does NOT read SKILL.md content.
|
||||
|
||||
For a **custom agent**, the result is filtered to skills explicitly
|
||||
declared in the agent's ``.deer-flow/agents/{agent_id}/config.yaml``
|
||||
under the ``skills:`` field — the agent only sees what it was wired to
|
||||
use.
|
||||
|
||||
For the **default lead agent** (no agent_id in runtime), there is no
|
||||
per-agent whitelist and every visible skill is returned.
|
||||
|
||||
Returns a JSON array with fields: name, description, category, enabled,
|
||||
state, use_count, view_count, patch_count, last_activity_at, pinned,
|
||||
source.
|
||||
"""
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.usage import all_entries, latest_activity_at
|
||||
|
||||
agent_id = _resolve_active_agent_id(runtime)
|
||||
allowed = _load_agent_skill_whitelist(agent_id)
|
||||
|
||||
try:
|
||||
storage = get_or_new_skill_storage()
|
||||
skills = storage.load_skills(enabled_only=False)
|
||||
usage = all_entries()
|
||||
result = []
|
||||
for skill in skills:
|
||||
if allowed is not None and skill.name not in allowed:
|
||||
continue
|
||||
entry = usage.get(skill.name, {})
|
||||
result.append({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"category": skill.category.value if hasattr(skill.category, "value") else str(skill.category),
|
||||
"enabled": skill.enabled,
|
||||
"state": entry.get("state", "active"),
|
||||
"use_count": entry.get("use_count", 0),
|
||||
"view_count": entry.get("view_count", 0),
|
||||
"patch_count": entry.get("patch_count", 0),
|
||||
"last_activity_at": latest_activity_at(skill.name),
|
||||
"pinned": entry.get("pinned", False),
|
||||
"source": entry.get("source", "upload"),
|
||||
})
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.debug("skill_list failed: %s", e)
|
||||
return f"Error listing skills: {e}"
|
||||
+147
@@ -13,6 +13,8 @@ from langgraph.typing import ContextT
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.mcp.tools import _make_sync_tool_wrapper
|
||||
from deerflow.persistence.skills import SkillStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.skills.security_scanner import scan_skill_content
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||
@@ -64,6 +66,85 @@ async def _to_thread(func, /, *args, **kwargs):
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
|
||||
async def _ensure_skill_db_registered(store: SkillStore, name: str, user_id: str) -> None:
|
||||
"""Idempotently record an agent-created skill's ownership in the DB.
|
||||
|
||||
Mirrors the ``/api/skills/install-upload`` path so agent-created skills
|
||||
show up in the Web UI listing (which filters custom skills by DB
|
||||
ownership). ``ensure_legacy`` is idempotent — it never overwrites an
|
||||
existing owner — so re-running the create flow on an already-owned skill
|
||||
is a no-op. Failures are swallowed so DB outages can never break the
|
||||
agent's create flow itself.
|
||||
"""
|
||||
try:
|
||||
await store.ensure_legacy(
|
||||
{"name": name, "owner_user_id": user_id, "published": False}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to register skill ownership for %r", name, exc_info=True)
|
||||
|
||||
|
||||
def _default_skill_store() -> SkillStore | None:
|
||||
"""Build a ``SkillStore`` backed by the global session factory.
|
||||
|
||||
Returns ``None`` if no DB session factory is configured (e.g. ephemeral
|
||||
test environments), letting the caller skip the registration step
|
||||
silently.
|
||||
"""
|
||||
try:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.skills import make_skill_store
|
||||
|
||||
factory = get_session_factory()
|
||||
if factory is None:
|
||||
return None
|
||||
return make_skill_store(factory)
|
||||
except Exception:
|
||||
logger.debug("Failed to build default skill store", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _default_agent_store() -> Any | None:
|
||||
"""Build an ``AgentStore`` backed by the global session factory."""
|
||||
try:
|
||||
from deerflow.persistence.agents import make_agent_store
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
|
||||
factory = get_session_factory()
|
||||
if factory is None:
|
||||
return None
|
||||
return make_agent_store(factory)
|
||||
except Exception:
|
||||
logger.debug("Failed to build default agent store", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _assert_agent_may_delete(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
|
||||
"""Pre-flight ownership check before agent-delete touches the on-disk file.
|
||||
|
||||
LocalSkillStorage doesn't know about users — it deletes anything that
|
||||
matches by name. Without this guard, a malicious or hallucinating agent
|
||||
could destroy another user's skill just by knowing the name. We refuse
|
||||
unless the DB confirms ``user_id`` owns the skill outright.
|
||||
|
||||
Returns the DB record for use in the subsequent cascade step. Raises
|
||||
``ValueError`` (mapped to a tool error visible to the LLM) on refusal.
|
||||
"""
|
||||
record = await store.get_any(name)
|
||||
if record is None:
|
||||
# No DB row — could be a legacy/orphan skill. Refuse rather than
|
||||
# delete blindly; admin can clean up via the Web UI.
|
||||
raise ValueError(
|
||||
f"Refused: skill '{name}' has no ownership record. Ask a human to delete it via the Web UI."
|
||||
)
|
||||
owner = record.get("owner_user_id")
|
||||
if owner is None:
|
||||
raise ValueError(f"Refused: skill '{name}' is built-in and cannot be deleted by an agent.")
|
||||
if owner != user_id:
|
||||
raise ValueError(f"Refused: skill '{name}' is owned by another user.")
|
||||
return record
|
||||
|
||||
|
||||
async def _skill_manage_impl(
|
||||
runtime: ToolRuntime[ContextT, ThreadState],
|
||||
action: str,
|
||||
@@ -78,6 +159,10 @@ async def _skill_manage_impl(
|
||||
|
||||
Args:
|
||||
action: One of create, patch, edit, delete, write_file, remove_file.
|
||||
For listing or viewing skills, prefer the dedicated
|
||||
``skill_list`` / ``skill_view`` tools — but ``action='list'``
|
||||
and ``action='view'`` are accepted here as convenience
|
||||
aliases that delegate to those tools.
|
||||
name: Skill name in hyphen-case.
|
||||
content: New file content for create, edit, or write_file.
|
||||
path: Supporting file path for write_file or remove_file.
|
||||
@@ -85,6 +170,17 @@ async def _skill_manage_impl(
|
||||
replace: Replacement text for patch.
|
||||
expected_count: Optional expected number of replacements for patch.
|
||||
"""
|
||||
# Read-only aliases — delegate to the dedicated tools so a model that
|
||||
# over-uses skill_manage doesn't trip on Unsupported action errors.
|
||||
if action == "list":
|
||||
from deerflow.tools.builtins.skill_tools import skill_list_tool
|
||||
|
||||
return await _to_thread(skill_list_tool.invoke, {})
|
||||
if action == "view":
|
||||
from deerflow.tools.builtins.skill_tools import skill_view_tool
|
||||
|
||||
return await _to_thread(skill_view_tool.invoke, {"name": name})
|
||||
|
||||
name = SkillStorage.validate_skill_name(name)
|
||||
lock = _get_lock(name)
|
||||
thread_id = _get_thread_id(runtime)
|
||||
@@ -104,6 +200,18 @@ async def _skill_manage_impl(
|
||||
name,
|
||||
_history_record(action="create", file_path=SKILL_MD_FILE, prev_content=None, new_content=content, thread_id=thread_id, scanner=scan),
|
||||
)
|
||||
try:
|
||||
from deerflow.skills.usage import mark_agent_created
|
||||
await _to_thread(mark_agent_created, name)
|
||||
except Exception:
|
||||
pass
|
||||
# Register the new skill in the DB so the web UI listing actually
|
||||
# surfaces it (custom skills without a DB row are filtered out by
|
||||
# ``GET /api/skills``). Resolve the user_id *here* on the main
|
||||
# async stack — contextvars don't propagate into ``to_thread``.
|
||||
store = _default_skill_store()
|
||||
if store is not None:
|
||||
await _ensure_skill_db_registered(store, name, get_effective_user_id())
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return f"Created custom skill '{name}'."
|
||||
|
||||
@@ -149,6 +257,15 @@ async def _skill_manage_impl(
|
||||
return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)."
|
||||
|
||||
if action == "delete":
|
||||
# Ownership pre-check: LocalSkillStorage deletes purely by name,
|
||||
# so without this an agent could blow away another user's skill
|
||||
# just by knowing the name. Raise BEFORE touching the filesystem.
|
||||
user_id = get_effective_user_id()
|
||||
store = _default_skill_store()
|
||||
record: dict[str, Any] | None = None
|
||||
if store is not None:
|
||||
record = await _assert_agent_may_delete(store, name, user_id)
|
||||
|
||||
await _to_thread(
|
||||
skill_storage.delete_custom_skill,
|
||||
name,
|
||||
@@ -161,6 +278,36 @@ async def _skill_manage_impl(
|
||||
scanner={"decision": "allow", "reason": "Deletion requested."},
|
||||
),
|
||||
)
|
||||
# Mirror what ``DELETE /api/skills/custom/{name}`` does: drop the
|
||||
# ``.usage.json`` entry and the DB ownership row, then cascade
|
||||
# the removal to every agent that referenced this skill so
|
||||
# ``config.yaml`` doesn't end up with a dangling reference.
|
||||
# Failures are best-effort — the on-disk delete already happened
|
||||
# and is authoritative.
|
||||
try:
|
||||
from deerflow.skills.usage import forget
|
||||
await _to_thread(forget, name)
|
||||
except Exception:
|
||||
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
|
||||
if store is not None:
|
||||
try:
|
||||
await store.delete(name, user_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to delete DB row for %r", name, exc_info=True)
|
||||
agent_store = _default_agent_store()
|
||||
if agent_store is not None:
|
||||
try:
|
||||
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||
|
||||
await cascade_skill_unpublished_or_deleted(
|
||||
name,
|
||||
actor_user_id=user_id,
|
||||
operation="delete",
|
||||
skill_owner_user_id=(record.get("owner_user_id") if record else user_id),
|
||||
agent_store=agent_store,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Cascade failed for deleted skill %r", name, exc_info=True)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return f"Deleted custom skill '{name}'."
|
||||
|
||||
|
||||
@@ -86,9 +86,10 @@ def get_available_tools(
|
||||
builtin_tools.append(scheduled_task_tool)
|
||||
skill_evolution_config = getattr(config, "skill_evolution", None)
|
||||
if getattr(skill_evolution_config, "enabled", False):
|
||||
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
||||
|
||||
builtin_tools.append(skill_manage_tool)
|
||||
builtin_tools.extend([skill_list_tool, skill_view_tool, skill_manage_tool])
|
||||
|
||||
# Add subagent tools only if enabled via runtime parameter
|
||||
if subagent_enabled:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config import skill_evolution_runtime
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def overrides_path(tmp_path, monkeypatch):
|
||||
"""Redirect skill_evolution_runtime.resolve_path at a fresh temp file."""
|
||||
target = tmp_path / "skill_evolution.json"
|
||||
monkeypatch.setenv("DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH", str(target))
|
||||
yield target
|
||||
|
||||
|
||||
def test_load_overrides_returns_none_when_missing(overrides_path):
|
||||
assert not overrides_path.exists()
|
||||
assert skill_evolution_runtime.load_overrides() is None
|
||||
|
||||
|
||||
def test_save_and_load_overrides_roundtrip(overrides_path):
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"moderation_model_name": "moderation-mini",
|
||||
"curator": {
|
||||
"enabled": True,
|
||||
"interval_hours": 12,
|
||||
"stale_after_days": 7,
|
||||
"archive_after_days": 30,
|
||||
},
|
||||
}
|
||||
written = skill_evolution_runtime.save_overrides(payload)
|
||||
assert written == overrides_path
|
||||
assert overrides_path.exists()
|
||||
assert skill_evolution_runtime.load_overrides() == payload
|
||||
|
||||
|
||||
def test_load_overrides_handles_invalid_json(overrides_path):
|
||||
overrides_path.write_text("{not valid json", encoding="utf-8")
|
||||
assert skill_evolution_runtime.load_overrides() is None
|
||||
|
||||
|
||||
def test_load_overrides_rejects_non_object_root(overrides_path):
|
||||
overrides_path.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
|
||||
assert skill_evolution_runtime.load_overrides() is None
|
||||
|
||||
|
||||
def test_app_config_merges_overrides_on_from_file(overrides_path, monkeypatch, tmp_path):
|
||||
yaml_cfg = tmp_path / "config.yaml"
|
||||
yaml_cfg.write_text(
|
||||
"""
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
skill_evolution:
|
||||
enabled: false
|
||||
curator:
|
||||
interval_hours: 168
|
||||
stale_after_days: 30
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Overrides flip enabled and change interval_hours.
|
||||
skill_evolution_runtime.save_overrides(
|
||||
{
|
||||
"enabled": True,
|
||||
"curator": {"interval_hours": 24},
|
||||
}
|
||||
)
|
||||
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
cfg = AppConfig.from_file(str(yaml_cfg))
|
||||
assert cfg.skill_evolution.enabled is True
|
||||
assert cfg.skill_evolution.curator.interval_hours == 24
|
||||
# Untouched yaml values remain.
|
||||
assert cfg.skill_evolution.curator.stale_after_days == 30
|
||||
|
||||
|
||||
def test_curator_log_start_new_run_resets_and_seeds_record():
|
||||
curator_log.clear()
|
||||
curator_log.record("info", "old record")
|
||||
assert len(curator_log.get_recent()) == 1
|
||||
|
||||
curator_log.start_new_run()
|
||||
records = curator_log.get_recent()
|
||||
assert len(records) == 1
|
||||
assert records[0]["message"] == "run_started"
|
||||
assert records[0]["level"] == "info"
|
||||
|
||||
|
||||
def test_curator_log_record_is_thread_safe():
|
||||
curator_log.clear()
|
||||
|
||||
def writer(idx: int) -> None:
|
||||
for i in range(20):
|
||||
curator_log.record("info", f"thread-{idx}-{i}")
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
records = curator_log.get_recent()
|
||||
# 5 threads * 20 records each = 100, well under the 200 ring-buffer cap.
|
||||
assert len(records) == 100
|
||||
assert all("message" in r and "ts" in r for r in records)
|
||||
|
||||
|
||||
def test_curator_log_record_ignores_internal_errors(monkeypatch):
|
||||
# Even if the timestamp helper blows up, record() must not raise.
|
||||
monkeypatch.setattr(curator_log, "_now_iso", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
curator_log.record("info", "should not raise")
|
||||
@@ -0,0 +1,600 @@
|
||||
"""Multitenancy tests for the Stage 1-3 skills/notifications work.
|
||||
|
||||
Covers:
|
||||
- ``SkillStore`` per-user visibility, global-name uniqueness, admin override
|
||||
- ``_get_editable_skill_or_raise`` route helper (owner / admin / 403 / 404 paths)
|
||||
- ``cascade_skill_unpublished_or_deleted`` removes skills from referencing
|
||||
agents' ``config.yaml`` and writes notifications via the sender shim
|
||||
- ``NotificationStore`` CRUD + ``unread_count``
|
||||
- Curator per-user isolation — user A's transitions never touch user B's skills
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers.skills import _get_editable_skill_or_raise, _get_owned_skill_or_raise
|
||||
from deerflow.config import paths as paths_module
|
||||
from deerflow.persistence.agents import MemoryAgentStore
|
||||
from deerflow.persistence.notifications import MemoryNotificationStore, set_notification_store
|
||||
from deerflow.persistence.skills import MemorySkillStore
|
||||
from deerflow.skills import curator_log
|
||||
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_paths(tmp_path, monkeypatch):
|
||||
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
yield tmp_path
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_store():
|
||||
"""Install a fresh ``MemoryNotificationStore`` as the sender backend.
|
||||
|
||||
Cleared on both setup and teardown so a stale store from a previous test
|
||||
can never leak in through the global ``_store`` singleton in
|
||||
``deerflow.persistence.notifications.sender``.
|
||||
"""
|
||||
store = MemoryNotificationStore()
|
||||
set_notification_store(store)
|
||||
yield store
|
||||
set_notification_store(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SkillStore: per-user visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_visibility_owner_published_and_builtin() -> None:
|
||||
store = MemorySkillStore()
|
||||
|
||||
# Builtin (legacy migrated, no human owner) — visible to everyone.
|
||||
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
|
||||
# User A's private skill (not published).
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
|
||||
# User B's published skill — visible across users.
|
||||
await store.create({"name": "beta-tool", "owner_user_id": "user-b", "published": True})
|
||||
# User B's private skill — should be invisible to A and C.
|
||||
await store.create({"name": "gamma-tool", "owner_user_id": "user-b", "published": False})
|
||||
|
||||
names_for_a = {s["name"] for s in await store.list_visible("user-a")}
|
||||
assert names_for_a == {"builtin-skill", "alpha-tool", "beta-tool"}
|
||||
|
||||
names_for_c = {s["name"] for s in await store.list_visible("user-c")}
|
||||
assert names_for_c == {"builtin-skill", "beta-tool"}
|
||||
|
||||
# get_visible respects the same filter.
|
||||
assert (await store.get_visible("alpha-tool", "user-a")) is not None
|
||||
assert (await store.get_visible("alpha-tool", "user-c")) is None
|
||||
assert (await store.get_visible("gamma-tool", "user-a")) is None
|
||||
|
||||
# builtin flag is derived from owner_user_id IS NULL.
|
||||
builtin = await store.get_any("builtin-skill")
|
||||
assert builtin is not None and builtin["builtin"] is True
|
||||
alpha = await store.get_any("alpha-tool")
|
||||
assert alpha is not None and alpha["builtin"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_create_rejects_duplicate_name() -> None:
|
||||
"""Global uniqueness — even an admin can't register a name a normal user owns."""
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "shared", "owner_user_id": "user-a"})
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await store.create({"name": "shared", "owner_user_id": "user-b"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_only_owner_can_update_or_delete() -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
|
||||
|
||||
# Non-owner update returns None and does not mutate.
|
||||
assert await store.update("alpha-tool", "user-b", {"published": True}) is None
|
||||
record = await store.get_any("alpha-tool")
|
||||
assert record is not None and record["published"] is False
|
||||
|
||||
# Owner can update.
|
||||
updated = await store.update("alpha-tool", "user-a", {"published": True})
|
||||
assert updated is not None and updated["published"] is True
|
||||
|
||||
# Non-owner delete fails.
|
||||
assert await store.delete("alpha-tool", "user-b") is False
|
||||
assert await store.get_any("alpha-tool") is not None
|
||||
|
||||
# Owner delete succeeds.
|
||||
assert await store.delete("alpha-tool", "user-a") is True
|
||||
assert await store.get_any("alpha-tool") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_admin_override_can_unpublish_and_delete_any_skill() -> None:
|
||||
"""``update_admin`` / ``delete_admin`` bypass ownership — including others' owned skills."""
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": True})
|
||||
|
||||
# update_admin can flip published on a skill the admin doesn't own.
|
||||
updated = await store.update_admin("alpha-tool", {"published": False})
|
||||
assert updated is not None and updated["published"] is False
|
||||
|
||||
# delete_admin removes it.
|
||||
assert await store.delete_admin("alpha-tool") is True
|
||||
assert await store.get_any("alpha-tool") is None
|
||||
|
||||
# And no-ops on a missing name.
|
||||
assert await store.update_admin("does-not-exist", {"published": False}) is None
|
||||
assert await store.delete_admin("does-not-exist") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_ensure_legacy_patches_null_owner_only() -> None:
|
||||
store = MemorySkillStore()
|
||||
# First migration: no owner → claim by admin.
|
||||
first = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "admin-id", "published": True})
|
||||
assert first["owner_user_id"] == "admin-id"
|
||||
|
||||
# Second migration: someone else tries to take ownership — refused, original wins.
|
||||
second = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "someone-else"})
|
||||
assert second["owner_user_id"] == "admin-id"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route helper authorization (mirrors test_agents_backend.py contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_editable_skill_or_raise_owner_admin_and_403() -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
|
||||
await store.create({"name": "owned", "owner_user_id": "user-a", "published": False})
|
||||
await store.create({"name": "others-published", "owner_user_id": "user-b", "published": True})
|
||||
|
||||
# Owner can edit own skill.
|
||||
record, mode = await _get_editable_skill_or_raise(store, "owned", "user-a", is_admin=False)
|
||||
assert mode == "owner" and record["name"] == "owned"
|
||||
|
||||
# Admin can edit a built-in (NULL owner).
|
||||
record, mode = await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=True)
|
||||
assert mode == "admin" and record["name"] == "builtin-skill"
|
||||
|
||||
# Unlike agents, the spec lets admin take down ANY skill, including
|
||||
# another user's published one.
|
||||
record, mode = await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=True)
|
||||
assert mode == "admin" and record["owner_user_id"] == "user-b"
|
||||
|
||||
# Non-admin trying to edit a built-in → 403.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=False)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Non-admin trying to edit someone else's published skill → 403.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=False)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Missing skill → 404 regardless of admin.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_skill_or_raise(store, "missing", "user-a", is_admin=True)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_owned_skill_or_raise_distinguishes_404_from_403() -> None:
|
||||
"""Visible-but-not-owned must surface as 403 so the user sees a clear reason."""
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "published-from-b", "owner_user_id": "user-b", "published": True})
|
||||
|
||||
# Owner check on a published-but-not-owned skill returns 403, not 404.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_owned_skill_or_raise(store, "published-from-b", "user-a")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Truly invisible / nonexistent → 404.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_owned_skill_or_raise(store, "nope", "user-a")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade: detaches references + writes notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_agent_dir(base_dir, *, agent_id: str, skills: list[str]) -> None:
|
||||
agent_dir = base_dir / "agents" / agent_id
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
(agent_dir / "config.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"id": agent_id, "name": agent_id, "description": "", "skills": skills},
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_unpublish_strips_skill_and_notifies_other_owners(
|
||||
isolated_paths, notification_store
|
||||
) -> None:
|
||||
"""User A unpublishes ``alpha-tool``. User B's agent loses the skill from its
|
||||
config and gets an in-app notification. User A's own agent loses the skill
|
||||
too but no notification (self action)."""
|
||||
agent_store = MemoryAgentStore()
|
||||
# User A is the actor; she also owns one of the referencing agents.
|
||||
await agent_store.create({"id": "agent-of-a", "name": "Agent A", "user_id": "user-a"})
|
||||
await agent_store.create({"id": "agent-of-b", "name": "Agent B", "user_id": "user-b"})
|
||||
# An agent referencing a different skill — should be untouched.
|
||||
await agent_store.create({"id": "agent-untouched", "name": "Untouched", "user_id": "user-b"})
|
||||
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-of-a", skills=["alpha-tool", "other"])
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-untouched", skills=["unrelated"])
|
||||
|
||||
counters = await cascade_skill_unpublished_or_deleted(
|
||||
"alpha-tool",
|
||||
actor_user_id="user-a",
|
||||
operation="unpublish",
|
||||
skill_owner_user_id="user-a",
|
||||
agent_store=agent_store,
|
||||
)
|
||||
|
||||
assert counters["modified"] == 2
|
||||
assert counters["notified"] == 1 # only user-b — not the actor
|
||||
|
||||
# A's agent: skill stripped, no notification for the actor.
|
||||
a_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-a" / "config.yaml").read_text("utf-8"))
|
||||
assert "alpha-tool" not in a_cfg["skills"]
|
||||
assert "other" in a_cfg["skills"]
|
||||
|
||||
# B's agent: skill stripped, notification delivered to B.
|
||||
b_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-b" / "config.yaml").read_text("utf-8"))
|
||||
assert b_cfg["skills"] == []
|
||||
|
||||
user_b_inbox = await notification_store.list_for_user("user-b")
|
||||
assert len(user_b_inbox) == 1
|
||||
note = user_b_inbox[0]
|
||||
assert note["type"] == "agent_skill_removed"
|
||||
assert note["payload"]["skill_name"] == "alpha-tool"
|
||||
assert note["payload"]["agent_id"] == "agent-of-b"
|
||||
assert note["payload"]["operation"] == "unpublish"
|
||||
|
||||
# Untouched agent retains its config + no notification fired for it.
|
||||
untouched_cfg = yaml.safe_load(
|
||||
(isolated_paths / "agents" / "agent-untouched" / "config.yaml").read_text("utf-8")
|
||||
)
|
||||
assert untouched_cfg["skills"] == ["unrelated"]
|
||||
assert await notification_store.unread_count("user-a") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_admin_takedown_also_notifies_skill_owner(
|
||||
isolated_paths, notification_store
|
||||
) -> None:
|
||||
"""Admin force-takedown emits an extra ``skill_taken_down`` notification to
|
||||
the original skill owner, on top of any per-agent notifications."""
|
||||
agent_store = MemoryAgentStore()
|
||||
await agent_store.create({"id": "agent-of-b", "name": "B", "user_id": "user-b"})
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
|
||||
|
||||
await cascade_skill_unpublished_or_deleted(
|
||||
"alpha-tool",
|
||||
actor_user_id="admin-id",
|
||||
operation="admin_takedown",
|
||||
skill_owner_user_id="user-a",
|
||||
agent_store=agent_store,
|
||||
)
|
||||
|
||||
# B (agent owner) gets the agent-skill-removed notification.
|
||||
assert (await notification_store.unread_count("user-b")) == 1
|
||||
# A (skill owner, not the actor) gets the takedown notification.
|
||||
a_inbox = await notification_store.list_for_user("user-a")
|
||||
assert len(a_inbox) == 1
|
||||
assert a_inbox[0]["type"] == "skill_taken_down"
|
||||
assert a_inbox[0]["payload"]["skill_name"] == "alpha-tool"
|
||||
# Admin (the actor) gets nothing.
|
||||
assert (await notification_store.unread_count("admin-id")) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification inbox CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_store_crud_and_unread_count() -> None:
|
||||
store = MemoryNotificationStore()
|
||||
|
||||
await store.create({"id": "n1", "user_id": "user-a", "type": "x", "title": "t1"})
|
||||
await store.create({"id": "n2", "user_id": "user-a", "type": "x", "title": "t2"})
|
||||
await store.create({"id": "n3", "user_id": "user-b", "type": "x", "title": "t3"})
|
||||
|
||||
assert (await store.unread_count("user-a")) == 2
|
||||
assert (await store.unread_count("user-b")) == 1
|
||||
|
||||
# list_for_user filters by recipient and respects unread_only.
|
||||
a_items = await store.list_for_user("user-a")
|
||||
assert {item["id"] for item in a_items} == {"n1", "n2"}
|
||||
|
||||
# Mark one read → unread count drops; second listing reflects state.
|
||||
assert await store.mark_read("n1", "user-a") is True
|
||||
assert (await store.unread_count("user-a")) == 1
|
||||
unread = await store.list_for_user("user-a", unread_only=True)
|
||||
assert [item["id"] for item in unread] == ["n2"]
|
||||
|
||||
# Cross-user mark_read is rejected.
|
||||
assert await store.mark_read("n3", "user-a") is False
|
||||
assert (await store.unread_count("user-b")) == 1
|
||||
|
||||
# mark_all_read returns the count of newly-flipped rows.
|
||||
flipped = await store.mark_all_read("user-a")
|
||||
assert flipped == 1
|
||||
assert (await store.unread_count("user-a")) == 0
|
||||
|
||||
# Delete behaves the same way — owner-only.
|
||||
assert await store.delete("n3", "user-a") is False
|
||||
assert await store.delete("n3", "user-b") is True
|
||||
assert (await store.list_for_user("user-b")) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curator per-user isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_curator_apply_transitions_only_touches_owned_skills(
|
||||
isolated_paths, monkeypatch
|
||||
) -> None:
|
||||
"""User A running curator must not mark / archive user B's skills.
|
||||
|
||||
Reproduces a minimal end-of-life scenario: both users own an idle skill,
|
||||
but only A's should transition to STALE.
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from deerflow.skills import curator, usage
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, reset_skill_storage
|
||||
|
||||
# The curator's filter helper normally queries the SkillStore via
|
||||
# ``get_session_factory``; here we substitute a static mapping so the test
|
||||
# stays SQL-free. ``apply_automatic_transitions`` is synchronous so we
|
||||
# cannot wait on the async store from inside it without nested-loop pain.
|
||||
owned_by = {
|
||||
"user-a": {"a-skill"},
|
||||
"user-b": {"b-skill"},
|
||||
}
|
||||
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: owned_by.get(uid, set()))
|
||||
|
||||
# Point the on-disk skills root at the temp dir so the usage helpers write
|
||||
# to ``{tmp}/skills/.usage.json`` and not into the global singleton state.
|
||||
reset_skill_storage()
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.storage._default_skill_storage",
|
||||
get_or_new_skill_storage(skills_path=str(isolated_paths / "skills")),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
# Stamp both skills as agent-created and old enough to mark stale.
|
||||
old_iso = (datetime.now(UTC) - timedelta(days=90)).isoformat()
|
||||
usage._update_entry(
|
||||
"a-skill",
|
||||
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
|
||||
)
|
||||
usage._update_entry(
|
||||
"b-skill",
|
||||
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
|
||||
)
|
||||
|
||||
# Run curator transitions as user-a.
|
||||
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
|
||||
|
||||
# Only a-skill counted as checked, only it got transitioned.
|
||||
assert counters["checked"] == 1 # owned-filter narrows the entry set
|
||||
assert counters["archived"] == 1 # 90 days idle == archive_after_days
|
||||
|
||||
# The on-disk state reflects the filter.
|
||||
assert usage.get_entry("a-skill")["state"] == usage.STATE_ARCHIVED
|
||||
assert usage.get_entry("b-skill")["state"] == usage.STATE_ACTIVE
|
||||
|
||||
reset_skill_storage()
|
||||
|
||||
|
||||
def test_curator_log_buckets_records_per_user() -> None:
|
||||
"""Curator log records for one user must not appear in another's view —
|
||||
guards the dict-of-deques refactor in curator_log.py."""
|
||||
curator_log.clear_all()
|
||||
|
||||
curator_log.start_new_run(user_id="user-a")
|
||||
curator_log.record("info", "alpha event", user_id="user-a")
|
||||
|
||||
curator_log.start_new_run(user_id="user-b")
|
||||
curator_log.record("info", "beta event", user_id="user-b")
|
||||
|
||||
a_records = curator_log.get_recent(user_id="user-a")
|
||||
b_records = curator_log.get_recent(user_id="user-b")
|
||||
|
||||
assert [r["message"] for r in a_records] == ["run_started", "alpha event"]
|
||||
assert [r["message"] for r in b_records] == ["run_started", "beta event"]
|
||||
|
||||
# An unknown user gets an empty buffer rather than a cross-user view.
|
||||
assert curator_log.get_recent(user_id="user-c") == []
|
||||
|
||||
curator_log.clear_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Increment #1: Curator three-condition eligibility filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_old_agent_skill(name: str) -> None:
|
||||
"""Helper: stamp an agent-created skill as 90 days idle (archive-eligible)."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from deerflow.skills import usage
|
||||
|
||||
old_iso = (datetime.now(UTC) - timedelta(days=90)).isoformat()
|
||||
usage._update_entry(
|
||||
name,
|
||||
{
|
||||
"source": usage.SOURCE_AGENT,
|
||||
"created_at": old_iso,
|
||||
"last_used_at": old_iso,
|
||||
"state": usage.STATE_ACTIVE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _isolated_skills_root(isolated_paths, monkeypatch) -> None:
|
||||
"""Helper: point the on-disk skills root at the temp directory."""
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, reset_skill_storage
|
||||
|
||||
reset_skill_storage()
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.storage._default_skill_storage",
|
||||
get_or_new_skill_storage(skills_path=str(isolated_paths / "skills")),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def test_curator_skips_skill_referenced_by_any_agent(
|
||||
isolated_paths, monkeypatch
|
||||
) -> None:
|
||||
"""A skill listed in any agent's config.yaml ``skills`` field must be
|
||||
exempt from automatic archive, even if it has been idle for ages."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from deerflow.skills import curator, usage
|
||||
|
||||
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: {"linked-skill"})
|
||||
monkeypatch.setattr(curator, "_is_skill_published", lambda name: False)
|
||||
# Pretend one agent's config references linked-skill.
|
||||
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: {"linked-skill"})
|
||||
_isolated_skills_root(isolated_paths, monkeypatch)
|
||||
_seed_old_agent_skill("linked-skill")
|
||||
|
||||
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
|
||||
|
||||
# Should be excluded from the eligible set entirely.
|
||||
assert counters["checked"] == 0
|
||||
assert counters["archived"] == 0
|
||||
assert counters["skipped_referenced"] == 1
|
||||
assert usage.get_entry("linked-skill")["state"] == usage.STATE_ACTIVE
|
||||
|
||||
|
||||
def test_curator_skips_published_skill(isolated_paths, monkeypatch) -> None:
|
||||
"""A skill marked ``published`` in SkillStore must never be auto-archived
|
||||
even when nothing references it."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from deerflow.skills import curator, usage
|
||||
|
||||
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: {"public-tool"})
|
||||
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
|
||||
monkeypatch.setattr(curator, "_is_skill_published", lambda name: name == "public-tool")
|
||||
_isolated_skills_root(isolated_paths, monkeypatch)
|
||||
_seed_old_agent_skill("public-tool")
|
||||
|
||||
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
|
||||
|
||||
assert counters["checked"] == 0
|
||||
assert counters["archived"] == 0
|
||||
assert counters["skipped_published"] == 1
|
||||
assert usage.get_entry("public-tool")["state"] == usage.STATE_ACTIVE
|
||||
|
||||
|
||||
def test_curator_archives_orphan_unpublished_skill(isolated_paths, monkeypatch) -> None:
|
||||
"""The "default happy path": owner's own agent-created skill, unpublished,
|
||||
not referenced anywhere — IS archived when idle past the threshold."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from deerflow.skills import curator, usage
|
||||
|
||||
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: {"orphan-skill"})
|
||||
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
|
||||
monkeypatch.setattr(curator, "_is_skill_published", lambda name: False)
|
||||
_isolated_skills_root(isolated_paths, monkeypatch)
|
||||
_seed_old_agent_skill("orphan-skill")
|
||||
|
||||
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
|
||||
|
||||
assert counters["checked"] == 1
|
||||
assert counters["archived"] == 1
|
||||
assert counters["skipped_referenced"] == 0
|
||||
assert counters["skipped_published"] == 0
|
||||
assert usage.get_entry("orphan-skill")["state"] == usage.STATE_ARCHIVED
|
||||
|
||||
|
||||
def test_skill_archive_tool_refuses_referenced_or_published(
|
||||
isolated_paths, monkeypatch
|
||||
) -> None:
|
||||
"""Safety net: even if the LLM bypasses the candidate filter and invokes
|
||||
skill_archive with a protected name, the tool itself refuses."""
|
||||
from deerflow.skills import curator
|
||||
|
||||
# Make the on-disk lookup believe the skill exists and is not pinned.
|
||||
class _FakeStorage:
|
||||
def custom_skill_exists(self, _name: str) -> bool:
|
||||
return True
|
||||
|
||||
class _FakeEntry:
|
||||
def get(self, key, default=None):
|
||||
return False # pinned=False
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.storage.get_or_new_skill_storage", lambda *_a, **_kw: _FakeStorage())
|
||||
monkeypatch.setattr("deerflow.skills.usage.get_entry", lambda _n: _FakeEntry())
|
||||
|
||||
tool = curator._make_skill_archive_tool("user-a")
|
||||
|
||||
# Case A: target is referenced — must refuse.
|
||||
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: {"linked"})
|
||||
monkeypatch.setattr(curator, "_is_skill_published", lambda name: False)
|
||||
out_a = tool.invoke({"name": "linked", "reason": "test"})
|
||||
assert "Refused" in out_a
|
||||
assert "referenced" in out_a
|
||||
|
||||
# Case B: target is published — must refuse.
|
||||
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
|
||||
monkeypatch.setattr(curator, "_is_skill_published", lambda name: name == "pub")
|
||||
out_b = tool.invoke({"name": "pub", "reason": "test"})
|
||||
assert "Refused" in out_b
|
||||
assert "published" in out_b
|
||||
|
||||
|
||||
def test_collect_referenced_skill_names_aggregates_from_disk(
|
||||
isolated_paths, monkeypatch
|
||||
) -> None:
|
||||
"""End-to-end of the scanner helper: it walks ``.deer-flow/agents/*/config.yaml``
|
||||
and returns the union of every ``skills`` list."""
|
||||
from deerflow.skills import curator
|
||||
|
||||
# Build two agents on disk, each referencing one skill.
|
||||
agents_root = isolated_paths / "agents"
|
||||
(agents_root / "agent-1").mkdir(parents=True, exist_ok=True)
|
||||
(agents_root / "agent-2").mkdir(parents=True, exist_ok=True)
|
||||
(agents_root / "agent-1" / "config.yaml").write_text(
|
||||
yaml.safe_dump({"id": "agent-1", "name": "A", "skills": ["alpha", "beta"]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(agents_root / "agent-2" / "config.yaml").write_text(
|
||||
yaml.safe_dump({"id": "agent-2", "name": "B", "skills": ["beta", "gamma"]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Point get_paths().agents_dir at the temp tree.
|
||||
class _FakePaths:
|
||||
agents_dir = agents_root
|
||||
|
||||
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: _FakePaths())
|
||||
|
||||
assert curator._collect_referenced_skill_names() == {"alpha", "beta", "gamma"}
|
||||
Generated
+2692
-2692
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user