Merge branch 'xuhd' into 'master'

feat(skills): 自我进化后端 — curator 配置可编辑 + 运行日志可读

See merge request knowledge-base-bj/deerflow-server!15
This commit is contained in:
xu hd committed 2026-05-15 01:36:33 +00:00
commit 43bdbcf89c
67 files changed
+9493 -2796

No files matched your search

+10
View File
@@ -65,6 +65,16 @@ offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
skills/*
offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
@@ -0,0 +1,79 @@
"use client";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
export function UserManagementPage() {
const { t } = useI18n();
const { data: users, isLoading, error } = useAllUsers(true);
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminUsers.title}
description={t.adminUsers.description}
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive text-sm py-4">
{String(error)}
</div>
)}
{!isLoading && !error && users && users.length === 0 && (
<div className="text-muted-foreground text-sm py-8 text-center">
{t.adminUsers.empty}
</div>
)}
{!isLoading && !error && users && users.length > 0 && (
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<tr
key={user.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">{user.email}</td>
<td className="px-4 py-3">
{user.system_role === "admin" ? (
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
) : (
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
{user.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SettingsSection>
</div>
);
}
@@ -0,0 +1,212 @@
"use client";
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
SidebarMenuButton,
useSidebar,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
export function NotificationBell() {
const { t } = useI18n();
const { open: isSidebarOpen } = useSidebar();
const [open, setOpen] = useState(false);
// Render badge from the lightweight count endpoint so the bell stays cheap
// when nobody opens it. Only when the user actually opens the dropdown do
// we pay for the full list query.
const { data: unreadCount = 0 } = useUnreadCount();
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label={t.notifications.bellAria}
>
{isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<div className="relative">
<BellIcon className="size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
<span>{t.notifications.title}</span>
{hasUnread && (
<span className="text-muted-foreground ml-auto text-xs">
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
</span>
)}
</div>
) : (
<div className="flex size-full items-center justify-center">
<div className="relative">
<BellIcon className="text-sidebar-foreground/80 size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
)}
</div>
</div>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
sideOffset={8}
className="w-96 p-0"
>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<span className="text-sm font-semibold">
{t.notifications.title}
</span>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
</div>
<ScrollArea className="max-h-[60vh]">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.notifications.empty}
</div>
) : (
<ul className="divide-y">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
!notification.read && "bg-blue-500/5",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-2">
{!notification.read && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"truncate text-sm",
!notification.read && "font-medium",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground line-clamp-2 text-xs">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -2,7 +2,6 @@
import {
BellIcon,
InfoIcon,
BrainIcon,
PaletteIcon,
SparklesIcon,
@@ -127,12 +126,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "skills" && (
<SkillSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "skills" && <SkillSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -1,6 +1,21 @@
"use client";
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
ClockIcon,
EyeIcon,
EyeOffIcon,
FileArchive,
Loader2,
PinIcon,
PinOffIcon,
SparklesIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -10,6 +25,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -29,13 +45,15 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useEnableSkill,
useDeleteCustomSkill,
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -44,6 +62,7 @@ import { env } from "@/env";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
@@ -73,29 +92,110 @@ function SkillSettingsList({
}) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("public");
const [filter, setFilter] = useState<string>("all");
const [installOpen, setInstallOpen] = useState(false);
const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo(
() => skills.filter((skill) => skill.category === filter),
[skills, filter],
);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// Visible-to-me skills, classified into the three buckets the UI cares about.
// "All" = built-in + anyone-published-custom + mine (active state only).
// "Mine" = strictly my own custom skills (active state only).
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
const all: Skill[] = [];
const mine: Skill[] = [];
const stale: Skill[] = [];
const archived: Skill[] = [];
for (const skill of skills) {
const state = skill.state ?? "active";
const isCustom = skill.category === "custom";
const isOwnedByMe =
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isPublishedByOthers =
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
if (state === "stale") {
stale.push(skill);
continue;
}
if (state === "archived") {
archived.push(skill);
continue;
}
// state === active
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
all.push(skill);
}
if (isOwnedByMe) {
mine.push(skill);
}
}
return {
activeAll: all,
activeMine: mine,
staleVisible: stale,
archivedVisible: archived,
};
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex justify-between">
<div className="flex gap-2">
<Tabs defaultValue="public" onValueChange={setFilter}>
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="outline"
disabled={staleCount === 0}
onClick={() => setStaleSheetOpen(true)}
title={t.settings.skills.badgeStaleTitle}
>
<ClockIcon className="size-4" />
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
</Button>
<Button
size="sm"
variant="outline"
disabled={archivedCount === 0}
onClick={() => setArchiveSheetOpen(true)}
title={t.settings.skills.badgeArchivedTitle}
>
<ArchiveIcon className="size-4" />
{t.settings.skills.entryArchived.replace(
"{count}",
String(archivedCount),
)}
</Button>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
onClose?.();
router.push("/page/workspace/skills/evolution");
}}
>
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
@@ -116,34 +216,267 @@ function SkillSettingsList({
)}
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item className="w-full" variant="outline" key={skill.name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">{skill.name}</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={skill.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onCheckedChange={(checked) =>
enableSkill({ skillName: skill.name, enabled: checked })
}
/>
</ItemActions>
</Item>
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
isAdmin={isAdmin}
/>
<SkillStateSheet
kind="stale"
open={staleSheetOpen}
onOpenChange={setStaleSheetOpen}
skills={staleVisible}
/>
<SkillStateSheet
kind="archived"
open={archiveSheetOpen}
onOpenChange={setArchiveSheetOpen}
skills={archivedVisible}
/>
</div>
);
}
function SkillRow({
skill,
currentUserId,
isAdmin,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
// the multitenancy fields still classify public skills correctly.
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) {
try {
await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success(
next
? t.settings.skills.publishSuccess
: t.settings.skills.unpublishSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePinned() {
const next = !skill.pinned;
try {
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
toast.success(
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<Item className="w-full" variant="outline">
<ItemContent>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
</ItemContent>
<ItemActions>
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="size-3.5" />
) : (
<PinIcon className="size-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<EyeOffIcon className="size-3.5" />
) : (
<EyeIcon className="size-3.5" />
)}
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</ItemActions>
</Item>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
<DialogDescription>
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -167,9 +500,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
function InstallSkillDialog({
open,
onClose,
isAdmin,
}: {
open: boolean;
onClose: () => void;
isAdmin: boolean;
}) {
const { t } = useI18n();
const dialog = t.settings.skills.installDialog;
@@ -178,6 +513,7 @@ function InstallSkillDialog({
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
@@ -186,6 +522,7 @@ function InstallSkillDialog({
setValidation(null);
setValidationError(null);
setIsDragging(false);
setTargetCategory("custom");
if (inputRef.current) inputRef.current.value = "";
}, []);
@@ -242,7 +579,7 @@ function InstallSkillDialog({
async (overwrite: boolean) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite });
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
@@ -255,13 +592,15 @@ function InstallSkillDialog({
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
const canOverwrite = !!validation && !validationError && isCustomConflict;
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -309,6 +648,27 @@ function InstallSkillDialog({
/>
</div>
{isAdmin && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
<div className="flex gap-4">
{(["custom", "public"] as const).map((cat) => (
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
</label>
))}
</div>
</div>
)}
{validateMutation.isPending && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
@@ -366,10 +726,10 @@ function InstallSkillDialog({
</div>
)}
{isPublicConflict && (
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
</span>
</div>
)}
@@ -0,0 +1,360 @@
"use client";
import { ArchiveIcon, ClockIcon, MoreHorizontalIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { useI18n } from "@/core/i18n/hooks";
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { cn } from "@/lib/utils";
type Kind = "stale" | "archived";
export function SkillStateSheet({
kind,
open,
onOpenChange,
skills,
}: {
kind: Kind;
open: boolean;
onOpenChange: (open: boolean) => void;
skills: Skill[];
}) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const isArchived = kind === "archived";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
{isArchived ? (
<ArchiveIcon className="size-4" />
) : (
<ClockIcon className="size-4" />
)}
{isArchived ? ts.archivedTitle : ts.staleTitle}
<span className="text-muted-foreground text-sm font-normal">
({skills.length})
</span>
</SheetTitle>
<SheetDescription>
{isArchived ? ts.archivedDescription : ts.staleDescription}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto p-4">
{skills.length === 0 ? (
<div className="text-muted-foreground py-12 text-center text-sm">
{ts.empty}
</div>
) : (
<div className="flex flex-col gap-3">
{skills.map((skill) => (
<SkillStateRow key={skill.name} skill={skill} kind={kind} />
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const setState = useSetSkillState();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const pending = setState.isPending || deleteSkill.isPending;
const isArchived = kind === "archived";
const timeLabel = useMemo(() => {
if (isArchived) {
const when = skill.last_activity_at;
return when
? ts.archivedAt.replace("{when}", formatRelative(when))
: ts.archivedAt.replace("{when}", "—");
}
const when = skill.last_activity_at;
return when
? ts.lastUsedAt.replace("{when}", formatRelative(when))
: ts.neverUsed;
}, [isArchived, skill.last_activity_at, ts.archivedAt, ts.lastUsedAt, ts.neverUsed]);
async function handleRestore() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "active" });
toast.success(ts.restoreSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleArchive() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "archived" });
toast.success(ts.archiveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(tt.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<div className="bg-card flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{skill.name}</span>
{isArchived ? (
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
{tt.badgeArchived}
</Badge>
) : (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title={tt.badgeStaleTitle}
>
{tt.badgeStale}
</Badge>
)}
{skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={tt.badgeSourceAgentTitle}
>
{tt.badgeSourceAgent}
</Badge>
)}
{skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={tt.badgeSourceUploadTitle}
>
{tt.badgeSourceUpload}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{skill.description}
</p>
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>{timeLabel}</span>
<span>
{skill.use_count && skill.use_count > 0
? tt.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: tt.useCountInlineZero}
</span>
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
onClick={() => void handleRestore()}
disabled={pending}
>
<RotateCcwIcon className="size-3.5" />
{ts.actionRestore}
</Button>
{!isArchived && (
<Button
size="sm"
variant="outline"
onClick={() => void handleArchive()}
disabled={pending}
>
<ArchiveIcon className="size-3.5" />
{ts.actionArchiveNow}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={pending}
title={ts.moreMenu}
>
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setViewOpen(true)}>
{ts.actionView}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setConfirmDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="size-3.5" />
{ts.actionDelete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<SkillViewDialog
skill={skill}
open={viewOpen}
onOpenChange={setViewOpen}
/>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{tt.deleteAction}</DialogTitle>
<DialogDescription>
{tt.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function SkillViewDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tv = t.settings.skills.viewDialog;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{tv.title}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 text-sm">
<Row label={tv.descriptionLabel}>
<span className="whitespace-pre-wrap">{skill.description}</span>
</Row>
<Row label={tv.stateLabel}>
<span className={cn("font-medium", stateColor(skill.state))}>
{skill.state ?? "active"}
</span>
</Row>
{skill.use_count !== undefined && (
<Row label={tv.useCountLabel}>{skill.use_count}</Row>
)}
{skill.last_activity_at && (
<Row label={tv.lastUsedLabel}>
{formatRelative(skill.last_activity_at)}
</Row>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{tv.close}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0">{children}</div>
</div>
);
}
function stateColor(state: string | undefined): string {
switch (state) {
case "archived":
return "text-muted-foreground";
case "stale":
return "text-amber-600 dark:text-amber-300";
default:
return "text-emerald-600 dark:text-emerald-300";
}
}
function formatRelative(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return iso;
const sec = Math.max(0, Math.floor(ms / 1000));
const min = Math.floor(sec / 60);
const hour = Math.floor(min / 60);
const day = Math.floor(hour / 24);
if (day >= 1) return `${day}d ago`;
if (hour >= 1) return `${hour}h ago`;
if (min >= 1) return `${min}m ago`;
return "just now";
}
@@ -0,0 +1,456 @@
"use client";
import {
ArrowLeftIcon,
Loader2Icon,
PlayIcon,
RefreshCwIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useCuratorConfig,
useCuratorLogs,
useCuratorStatus,
useRunCurator,
useUpdateCuratorConfig,
} from "@/core/curator/hooks";
import type { CuratorConfig } from "@/core/curator/types";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
export function SkillEvolutionPanel() {
const { t } = useI18n();
const router = useRouter();
const tt = t.settings.skillEvolution;
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{tt.title}</h1>
<p className="text-muted-foreground mt-1 text-sm">
{tt.description}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/page/workspace/skills")}
>
<ArrowLeftIcon className="size-4" />
{tt.backToSkills}
</Button>
</header>
<StatusCard />
<ConfigCard />
<LogsCard />
</div>
);
}
function StatusCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.status;
const { status, isLoading } = useCuratorStatus();
const runCurator = useRunCurator();
const lastRunDisplay = useMemo(() => {
if (!status?.last_run_at) return tt.lastRunNever;
try {
return new Date(status.last_run_at).toLocaleString();
} catch {
return status.last_run_at;
}
}, [status?.last_run_at, tt.lastRunNever]);
const handleRun = async () => {
try {
await runCurator.mutateAsync();
toast.success(tt.runStarted);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
{status && (
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant={status.enabled ? "default" : "secondary"}>
{status.enabled ? tt.enabled : tt.disabled}
</Badge>
{status.paused && (
<Badge variant="outline">{tt.paused}</Badge>
)}
</div>
)}
</div>
<Button
size="sm"
onClick={handleRun}
disabled={runCurator.isPending}
>
{runCurator.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
{tt.runNow}
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : status ? (
<div className="grid gap-3 text-sm sm:grid-cols-2">
<StatRow label={tt.runCount}>
{status.run_count}
{tt.runCountUnit && (
<span className="text-muted-foreground ml-1">
{tt.runCountUnit}
</span>
)}
</StatRow>
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
<span className="text-muted-foreground">
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
</span>
</StatRow>
<StatRow label={tt.skillCounts} className="sm:col-span-2">
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
<span>
{tt.total}: {status.agent_created_skills.total}
</span>
<span>
{tt.active}: {status.agent_created_skills.active}
</span>
<span>
{tt.stale}: {status.agent_created_skills.stale}
</span>
<span>
{tt.archived}: {status.agent_created_skills.archived}
</span>
</span>
</StatRow>
</div>
) : null}
</CardContent>
</Card>
);
}
function StatRow({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
<span className="font-medium">{children}</span>
</div>
);
}
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
const { config, isLoading } = useCuratorConfig();
const updateConfig = useUpdateCuratorConfig();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
useEffect(() => {
if (config && !draft) {
setDraft(config);
}
}, [config, draft]);
if (isLoading || !draft) {
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
</CardContent>
</Card>
);
}
const isDirty =
!!config &&
JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => {
try {
const updated = await updateConfig.mutateAsync(draft);
setDraft(updated);
toast.success(tt.saveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<FieldRow
label={tt.enabledLabel}
helper={tt.enabledHelper}
>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</FieldRow>
<FieldRow
label={tt.curatorEnabledLabel}
helper={tt.curatorEnabledHelper}
>
<Switch
checked={draft.curator.enabled}
onCheckedChange={(v) =>
setDraft({
...draft,
curator: { ...draft.curator, enabled: v },
})
}
/>
</FieldRow>
<FieldRow
label={tt.intervalHoursLabel}
helper={tt.intervalHoursHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.interval_hours}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
interval_hours: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.staleAfterDaysLabel}
helper={tt.staleAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.stale_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
stale_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.archiveAfterDaysLabel}
helper={tt.archiveAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.archive_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
archive_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.moderationModelLabel}
helper={tt.moderationModelHelper}
>
<Input
type="text"
className="max-w-[20rem]"
value={draft.moderation_model_name ?? ""}
placeholder=""
onChange={(e) =>
setDraft({
...draft,
moderation_model_name:
e.target.value.trim() === "" ? null : e.target.value,
})
}
/>
</FieldRow>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={() => config && setDraft(config)}
>
{tt.reset}
</Button>
<Button
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={handleSave}
>
{updateConfig.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
{tt.save}
</Button>
</div>
</CardContent>
</Card>
);
}
function FieldRow({
label,
helper,
children,
}: {
label: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
<div className="flex-1">
<div className="text-sm font-medium">{label}</div>
{helper && (
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
function LogsCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.logs;
const [autoRefresh, setAutoRefresh] = useState(true);
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
const records = logs?.records ?? [];
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<label className="text-muted-foreground flex items-center gap-2 text-xs">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
{tt.autoRefresh}
</label>
<Button size="sm" variant="outline" onClick={() => void refetch()}>
<RefreshCwIcon className="size-3.5" />
{tt.refresh}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading && records.length === 0 ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : records.length === 0 ? (
<div className="text-muted-foreground text-sm">{tt.empty}</div>
) : (
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
{records.map((record, idx) => (
<div
key={`${record.ts}-${idx}`}
className={cn(
"flex gap-3",
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
record.level === "error" && "text-red-600 dark:text-red-300",
)}
>
<span className="text-muted-foreground shrink-0">
{formatTime(record.ts)}
</span>
<span className="text-muted-foreground w-14 shrink-0 uppercase">
{record.level}
</span>
<span className="whitespace-pre-wrap break-all">
{record.message === "run_started"
? "——— 本次策展开始 ———"
: record.message}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function formatTime(ts: string): string {
try {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return ts;
}
}
@@ -1,6 +1,7 @@
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -10,11 +11,14 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
const isAdmin = getStoredAuth()?.system_role === "admin";
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
@@ -48,6 +52,17 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -59,6 +74,19 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
<UsersIcon />
<span>{t.sidebar.users}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -20,6 +20,7 @@ import {
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { NotificationBell } from "./notification-bell";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -49,7 +50,7 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
"appearance" | "memory" | "tools" | "notification" | "about"
>("appearance");
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
@@ -67,6 +68,9 @@ export function WorkspaceNavMenu() {
defaultSection={settingsDefaultSection}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
<NotificationBell />
</SidebarMenuItem>
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
+14
View File
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
export interface UserListItem {
id: string;
email: string;
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
export async function loginByUsername(
username: string,
): Promise<LoginByUsernameResponse> {
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { listAllUsers } from "./api";
export function useAllUsers(enabled: boolean) {
return useQuery({
queryKey: ["admin", "users"],
queryFn: listAllUsers,
enabled,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
CuratorConfig,
CuratorLogs,
CuratorStatus,
} from "./types";
export async function getCuratorStatus(): Promise<CuratorStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
return response.json() as Promise<CuratorStatus>;
}
export async function getCuratorConfig(): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
return response.json() as Promise<CuratorConfig>;
}
export async function updateCuratorConfig(
payload: CuratorConfig,
): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return response.json() as Promise<CuratorConfig>;
}
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
method: "POST",
});
return response.json();
}
export async function getCuratorLogs(): Promise<CuratorLogs> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
return response.json() as Promise<CuratorLogs>;
}
+90
View File
@@ -0,0 +1,90 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
getCuratorConfig,
getCuratorLogs,
getCuratorStatus,
runCurator,
updateCuratorConfig,
} from "./api";
import type { CuratorConfig } from "./types";
// All curator-related queries are per-user data (status / config / logs all
// live under .deer-flow/users/{user_id}/), so the cache key MUST be scoped by
// user_id. Otherwise switching accounts in the same tab would let user B
// briefly read user A's cached values — and clicking "save" on that stale
// frame would silently overwrite user B's real config with user A's values.
function useCuratorKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(
() => ({
status: ["curator", userId, "status"] as const,
config: ["curator", userId, "config"] as const,
logs: ["curator", userId, "logs"] as const,
}),
[userId],
);
}
export function useCuratorStatus() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.status,
queryFn: getCuratorStatus,
});
return { status: query.data, isLoading: query.isLoading, error: query.error };
}
export function useCuratorConfig() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.config,
queryFn: getCuratorConfig,
});
return { config: query.data, isLoading: query.isLoading, error: query.error };
}
export function useUpdateCuratorConfig() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: keys.config });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useRunCurator() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: () => runCurator(),
onSuccess: () => {
// Logs start from scratch on a new run — refresh so the UI clears
// immediately and then picks up the new run_started record.
void queryClient.invalidateQueries({ queryKey: keys.logs });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
const autoRefresh = options?.autoRefresh ?? true;
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.logs,
queryFn: getCuratorLogs,
refetchInterval: autoRefresh ? 2000 : false,
});
return {
logs: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+38
View File
@@ -0,0 +1,38 @@
export interface CuratorSubConfig {
enabled: boolean;
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
}
export interface CuratorConfig {
enabled: boolean;
moderation_model_name: string | null;
curator: CuratorSubConfig;
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
last_run_at: string | null;
last_run_summary: string | null;
paused: boolean;
agent_created_skills: {
total: number;
active: number;
stale: number;
archived: number;
};
}
export interface CuratorLogRecord {
ts: string;
level: string;
message: string;
}
export interface CuratorLogs {
records: CuratorLogRecord[];
run_count: number;
last_run_at: string | null;
}
+154
View File
@@ -172,6 +172,7 @@ export const enUS: Translations = {
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
users: "Users",
},
// Agents
@@ -434,10 +435,77 @@ export const enUS: Translations = {
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
installSkill: "Install skill",
goToEvolution: "Skill evolution",
emptyTitle: "No agent skill yet",
emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
emptyButton: "Create Your First Skill",
badgeBuiltin: "Built-in",
badgeMine: "Mine",
badgeFromOthers: "From others",
badgeFromOthersTitle: "Owned by user {user_id}",
badgePublished: "Published",
badgePublishedTitle: "Visible to all users",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived",
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
badgePinned: "Pinned",
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
badgeSourceAgent: "Agent-created",
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded",
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
tabAll: "All",
tabMine: "Mine",
entryStale: "Stale {count}",
entryArchived: "Archived {count}",
useCountInline: "Used {count} times",
useCountInlineZero: "Not used yet",
publishAction: "Publish",
unpublishAction: "Unpublish",
publishSuccess: "Skill published",
unpublishSuccess: "Skill unpublished",
pinAction: "Pin (prevent auto-archive)",
unpinAction: "Unpin",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
deleteAction: "Delete skill",
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
stateSheet: {
archivedTitle: "Archived skills",
archivedDescription:
"These skills have been archived by you or the curator and can be restored at any time.",
staleTitle: "Stale skills",
staleDescription:
"These skills have been idle for a while and were flagged by the curator. Using one again will auto-revive it; you can also act on them here.",
empty: "Nothing here",
actionRestore: "Restore",
actionArchiveNow: "Archive now",
actionView: "View",
actionPin: "Pin",
actionUnpin: "Unpin",
actionDelete: "Delete permanently",
moreMenu: "More",
archivedAt: "Archived {when}",
lastUsedAt: "Last used {when}",
neverUsed: "Never used",
restoreSuccess: "Restored",
archiveSuccess: "Archived",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
},
viewDialog: {
title: "Skill details",
descriptionLabel: "Description",
stateLabel: "State",
sourceLabel: "Source",
useCountLabel: "Use count",
lastUsedLabel: "Last used",
close: "Close",
},
installDialog: {
title: "Install skill",
dropHint: "Drop a .zip or .skill archive here, or",
@@ -456,9 +524,73 @@ export const enUS: Translations = {
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
targetCategoryLabel: "Install to",
categoryCustom: "Custom skill",
categoryPublic: "Built-in (admin)",
},
},
skillEvolution: {
title: "Skill Evolution",
description:
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
backToSkills: "Back to skills",
status: {
title: "Status",
enabled: "Enabled",
disabled: "Disabled",
paused: "Paused",
runCount: "Total runs",
runCountUnit: "",
lastRunAt: "Last run",
lastRunNever: "Never",
lastRunSummary: "Last summary",
lastRunSummaryEmpty: "—",
skillCounts: "Skills",
active: "Active",
stale: "Stale",
archived: "Archived",
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
},
config: {
title: "Configuration",
description:
"Saves to skill_evolution.json and hot-reloads — no restart required.",
enabledLabel: "Enable skill evolution",
enabledHelper:
"When off, the agent cannot create or modify skills under custom/.",
curatorEnabledLabel: "Curator runs automatically",
curatorEnabledHelper:
"When off, the curator only runs when triggered from this page.",
intervalHoursLabel: "Run interval (hours)",
intervalHoursHelper:
"Minimum hours between automatic curator runs.",
staleAfterDaysLabel: "Stale threshold (days)",
staleAfterDaysHelper:
"Skills inactive for this many days are flagged stale.",
archiveAfterDaysLabel: "Archive threshold (days)",
archiveAfterDaysHelper:
"Stale skills inactive for this many additional days are archived.",
moderationModelLabel: "Moderation model (optional)",
moderationModelHelper:
"Leave blank to use the default chat model.",
save: "Save changes",
saveSuccess: "Configuration updated",
reset: "Reset",
},
logs: {
title: "Recent run logs",
description:
"Latest curator run only; cleared on backend restart.",
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
autoRefresh: "Auto-refresh (2s)",
refresh: "Refresh now",
},
},
notification: {
@@ -479,4 +611,26 @@ export const enUS: Translations = {
emptyDescription: "Credits and acknowledgements will show here.",
},
},
adminUsers: {
title: "User Management",
description: "View all registered users and their associated skill and agent counts.",
email: "Email",
role: "Role",
userId: "User ID",
skillCount: "Skills",
agentCount: "Agents",
roleAdmin: "Admin",
roleUser: "User",
empty: "No users found",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
unreadShort: "{count} unread",
markAllRead: "Mark all read",
empty: "No notifications",
deleteAria: "Delete notification",
},
};
+145
View File
@@ -1,5 +1,14 @@
import type { LucideIcon } from "lucide-react";
export interface NotificationsTranslations {
title: string;
bellAria: string;
unreadShort: string;
markAllRead: string;
empty: string;
deleteAria: string;
}
export interface Translations {
// Locale meta
locale: {
@@ -116,6 +125,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
users: string;
};
// Agents
@@ -355,9 +365,73 @@ export interface Translations {
description: string;
createSkill: string;
installSkill: string;
goToEvolution: string;
emptyTitle: string;
emptyDescription: string;
emptyButton: string;
badgeBuiltin: string;
badgeMine: string;
badgeFromOthers: string;
badgeFromOthersTitle: string;
badgePublished: string;
badgePublishedTitle: string;
badgeStale: string;
badgeStaleTitle: string;
badgeArchived: string;
badgeArchivedTitle: string;
badgePinned: string;
badgePinnedTitle: string;
badgeSourceAgent: string;
badgeSourceAgentTitle: string;
badgeSourceUpload: string;
badgeSourceUploadTitle: string;
tabAll: string;
tabMine: string;
entryStale: string;
entryArchived: string;
useCountInline: string;
useCountInlineZero: string;
publishAction: string;
unpublishAction: string;
publishSuccess: string;
unpublishSuccess: string;
pinAction: string;
unpinAction: string;
pinSuccess: string;
unpinSuccess: string;
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
stateSheet: {
archivedTitle: string;
archivedDescription: string;
staleTitle: string;
staleDescription: string;
empty: string;
actionRestore: string;
actionArchiveNow: string;
actionView: string;
actionPin: string;
actionUnpin: string;
actionDelete: string;
moreMenu: string;
archivedAt: string;
lastUsedAt: string;
neverUsed: string;
restoreSuccess: string;
archiveSuccess: string;
pinSuccess: string;
unpinSuccess: string;
};
viewDialog: {
title: string;
descriptionLabel: string;
stateLabel: string;
sourceLabel: string;
useCountLabel: string;
lastUsedLabel: string;
close: string;
};
installDialog: {
title: string;
dropHint: string;
@@ -374,9 +448,63 @@ export interface Translations {
warningsLabel: string;
conflictCustom: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
installSuccessReplaced: string;
rejectInvalidExt: string;
targetCategoryLabel: string;
categoryCustom: string;
categoryPublic: string;
};
};
skillEvolution: {
title: string;
description: string;
backToSkills: string;
status: {
title: string;
enabled: string;
disabled: string;
paused: string;
runCount: string;
runCountUnit: string;
lastRunAt: string;
lastRunNever: string;
lastRunSummary: string;
lastRunSummaryEmpty: string;
skillCounts: string;
active: string;
stale: string;
archived: string;
total: string;
runNow: string;
runStarted: string;
};
config: {
title: string;
description: string;
enabledLabel: string;
enabledHelper: string;
curatorEnabledLabel: string;
curatorEnabledHelper: string;
intervalHoursLabel: string;
intervalHoursHelper: string;
staleAfterDaysLabel: string;
staleAfterDaysHelper: string;
archiveAfterDaysLabel: string;
archiveAfterDaysHelper: string;
moderationModelLabel: string;
moderationModelHelper: string;
save: string;
saveSuccess: string;
reset: string;
};
logs: {
title: string;
description: string;
empty: string;
autoRefresh: string;
refresh: string;
};
};
notification: {
@@ -395,4 +523,21 @@ export interface Translations {
emptyDescription: string;
};
};
// Admin user management page
adminUsers: {
title: string;
description: string;
email: string;
role: string;
userId: string;
skillCount: string;
agentCount: string;
roleAdmin: string;
roleUser: string;
empty: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
+147 -4
View File
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
users: "用户管理",
},
// Agents
@@ -415,10 +416,74 @@ export const zhCN: Translations = {
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
installSkill: "安装技能",
goToEvolution: "自我进化",
emptyTitle: "还没有技能",
emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
emptyButton: "创建你的第一个技能",
badgeBuiltin: "内置",
badgeMine: "我的",
badgeFromOthers: "来自他人",
badgeFromOthersTitle: "来自用户 {user_id}",
badgePublished: "已发布",
badgePublishedTitle: "该技能已对所有用户可见",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
badgePinned: "钉选",
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
badgeSourceAgent: "Agent 创建",
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传",
badgeSourceUploadTitle: "由用户手动上传或编辑",
tabAll: "全部",
tabMine: "我的",
entryStale: "过时 {count}",
entryArchived: "归档 {count}",
useCountInline: "调用 {count} 次",
useCountInlineZero: "尚未调用",
publishAction: "发布",
unpublishAction: "取消发布",
publishSuccess: "已发布",
unpublishSuccess: "已取消发布",
pinAction: "钉选(防止被自动归档)",
unpinAction: "取消钉选",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
deleteSuccess: "技能已删除",
stateSheet: {
archivedTitle: "归档的技能",
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
staleTitle: "过时的技能",
staleDescription: "这些技能长期未被使用,已被策展程序标记。重新使用一次会自动恢复活跃,你也可以主动处理。",
empty: "暂无内容",
actionRestore: "恢复",
actionArchiveNow: "立即归档",
actionView: "查看详情",
actionPin: "钉选",
actionUnpin: "取消钉选",
actionDelete: "永久删除",
moreMenu: "更多操作",
archivedAt: "归档于 {when}",
lastUsedAt: "最近使用 {when}",
neverUsed: "尚未使用",
restoreSuccess: "已恢复",
archiveSuccess: "已归档",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
},
viewDialog: {
title: "技能详情",
descriptionLabel: "描述",
stateLabel: "状态",
sourceLabel: "来源",
useCountLabel: "调用次数",
lastUsedLabel: "最近使用",
close: "关闭",
},
installDialog: {
title: "安装技能",
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
@@ -435,10 +500,66 @@ export const zhCN: Translations = {
warningsLabel: "警告",
conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
installSuccess: "技能 “{name}” 已安装。",
installSuccessReplaced: "技能 “{name}” 已替换。",
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
targetCategoryLabel: `安装到`,
categoryCustom: `自定义技能`,
categoryPublic: `内置技能(管理员)`,
},
},
skillEvolution: {
title: "自我进化",
description:
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
backToSkills: "返回技能",
status: {
title: "当前状态",
enabled: "已启用",
disabled: "已停用",
paused: "已暂停",
runCount: "累计运行",
runCountUnit: "次",
lastRunAt: "上次运行",
lastRunNever: "尚未运行",
lastRunSummary: "上次摘要",
lastRunSummaryEmpty: "—",
skillCounts: "技能",
active: "活跃",
stale: "陈旧",
archived: "已归档",
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
},
config: {
title: "配置",
description:
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
enabledLabel: "启用自我进化",
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
curatorEnabledLabel: "curator 自动运行",
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
intervalHoursLabel: "运行间隔(小时)",
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
staleAfterDaysLabel: "陈旧阈值(天)",
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
archiveAfterDaysLabel: "归档阈值(天)",
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
moderationModelLabel: "安全审核模型(可选)",
moderationModelHelper: "为空时使用默认主模型。",
save: "保存修改",
saveSuccess: "配置已更新",
reset: "重置",
},
logs: {
title: "最近运行日志",
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
autoRefresh: "自动刷新 (2s)",
refresh: "立即刷新",
},
},
notification: {
@@ -459,4 +580,26 @@ export const zhCN: Translations = {
emptyDescription: "相关的致谢信息会展示在这里。",
},
},
adminUsers: {
title: "用户管理",
description: "查看所有注册用户及其关联的技能和智能体数量。",
email: "邮箱",
role: "角色",
userId: "用户 ID",
skillCount: "技能数",
agentCount: "智能体数",
roleAdmin: "管理员",
roleUser: "普通用户",
empty: "暂无用户",
},
notifications: {
title: "通知",
bellAria: "打开通知",
unreadShort: "{count} 条未读",
markAllRead: "全部已读",
empty: "暂无通知",
deleteAria: "删除通知",
},
};
@@ -0,0 +1,60 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
NotificationItem,
NotificationListResponse,
NotificationUnreadCountResponse,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
export async function listNotifications(
options: { unreadOnly?: boolean; limit?: number } = {},
): Promise<NotificationItem[]> {
const params = new URLSearchParams();
if (options.unreadOnly) params.set("unread_only", "true");
if (options.limit) params.set("limit", String(options.limit));
const query = params.toString();
const url = `${getBackendBaseURL()}/api/notifications${query ? `?${query}` : ""}`;
const res = await apiFetch(url);
if (!res.ok) throw await parseDetail(res, "Failed to list notifications");
const data = (await res.json()) as NotificationListResponse;
return data.items;
}
export async function getUnreadCount(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/unread_count`);
if (!res.ok) throw await parseDetail(res, "Failed to load unread count");
const data = (await res.json()) as NotificationUnreadCountResponse;
return data.unread;
}
export async function markNotificationRead(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}/read`,
{ method: "POST" },
);
if (!res.ok) throw await parseDetail(res, "Failed to mark notification read");
}
export async function markAllNotificationsRead(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/read_all`, {
method: "POST",
});
if (!res.ok) throw await parseDetail(res, "Failed to mark all read");
const data = (await res.json()) as { updated: number };
return data.updated;
}
export async function deleteNotification(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw await parseDetail(res, "Failed to delete notification");
}
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
deleteNotification,
getUnreadCount,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "./api";
// Notifications are per-user data, so the cache key MUST be scoped by user_id.
// Otherwise switching accounts in the same tab would show the previous user's
// inbox until the next fetch lands.
function useNotificationKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(() => {
const root = ["notifications", userId] as const;
return {
root,
list: (limit: number, unreadOnly: boolean) =>
[...root, "list", { limit, unreadOnly }] as const,
count: [...root, "unread-count"] as const,
};
}, [userId]);
}
export function useNotifications(options: { limit?: number; unreadOnly?: boolean } = {}) {
const { limit = 20, unreadOnly = false } = options;
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.list(limit, unreadOnly),
queryFn: () => listNotifications({ limit, unreadOnly }),
});
}
export function useUnreadCount(refetchInterval = 30000) {
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.count,
queryFn: () => getUnreadCount(),
refetchInterval,
// Keep the badge fresh when the user comes back to the tab without
// waiting for the next interval tick.
refetchOnWindowFocus: true,
});
}
function useInvalidateNotifications() {
const queryClient = useQueryClient();
const keys = useNotificationKeys();
return () => {
void queryClient.invalidateQueries({ queryKey: keys.root });
};
}
export function useMarkNotificationRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => markNotificationRead(id),
onSuccess: invalidate,
});
}
export function useMarkAllNotificationsRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: () => markAllNotificationsRead(),
onSuccess: invalidate,
});
}
export function useDeleteNotification() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => deleteNotification(id),
onSuccess: invalidate,
});
}
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
@@ -0,0 +1,18 @@
export interface NotificationItem {
id: string;
user_id: string;
type: string;
title: string;
body: string;
payload: Record<string, unknown>;
read: boolean;
created_at: string | null;
}
export interface NotificationListResponse {
items: NotificationItem[];
}
export interface NotificationUnreadCountResponse {
unread: number;
}
+76 -3
View File
@@ -1,7 +1,7 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Skill } from "./type";
import type { Skill, SkillState } from "./type";
export async function loadSkills() {
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean } = {},
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const query = options.overwrite ? "?overwrite=true" : "";
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form },
@@ -110,3 +113,73 @@ export async function installSkillUpload(
}
return response.json() as Promise<InstallSkillResponse>;
}
export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
published: boolean;
}
export async function setSkillPublished(
skillName: string,
published: boolean,
): Promise<SetSkillPublishedResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ published }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update published state");
}
return response.json() as Promise<SetSkillPublishedResponse>;
}
export async function setSkillState(
skillName: string,
state: SkillState,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill state");
}
return response.json() as Promise<Skill>;
}
export async function setSkillPinned(
skillName: string,
pinned: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill pinned state");
}
return response.json() as Promise<Skill>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
+60 -3
View File
@@ -1,6 +1,15 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
import {
deleteCustomSkill,
enableSkill,
installSkillUpload,
setSkillPinned,
setSkillPublished,
setSkillState,
validateSkillUpload,
} from "./api";
import type { SkillState } from "./type";
import { loadSkills } from ".";
@@ -39,8 +48,56 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
installSkillUpload(file, { overwrite }),
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPublished() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
published,
}: {
skillName: string;
published: boolean;
}) => setSkillPublished(skillName, published),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillState() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
setSkillState(skillName, state),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPinned() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
setSkillPinned(skillName, pinned),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDeleteCustomSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
+26 -7
View File
@@ -1,7 +1,26 @@
export interface Skill {
name: string;
description: string;
category: string;
license: string;
enabled: boolean;
}
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
export interface Skill {
name: string;
description: string;
category: string;
license: string;
enabled: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
published?: boolean;
builtin?: boolean;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
pinned?: boolean;
// Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource;
last_activity_at?: string | null;
use_count?: number;
view_count?: number;
patch_count?: number;
}
@@ -0,0 +1,9 @@
import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evolution-panel";
export default function SkillEvolutionPage() {
return (
<div className="flex h-full flex-col overflow-auto p-6">
<SkillEvolutionPanel />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
export default function SkillsPage() {
return (
<div className="flex flex-col h-full overflow-auto p-6">
<SkillSettingsPage />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
export default function UsersPage() {
return <UserManagementPage />;
}
@@ -20,6 +20,9 @@ import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -134,6 +137,22 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="skills"
element={
<WorkspaceLayout {...layoutProps}>
<SkillsPage />
</WorkspaceLayout>
}
/>
<Route
path="skills/evolution"
element={
<WorkspaceLayout {...layoutProps}>
<SkillEvolutionPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -154,6 +173,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/users"
element={
<WorkspaceLayout {...layoutProps}>
<UsersPage />
</WorkspaceLayout>
}
/>
<Route
path="settings/recommended-questions"
element={
@@ -30,6 +30,7 @@ from app.gateway.routers import (
mcp,
memory,
models,
notifications,
recommended_questions,
runs,
scheduled_tasks,
@@ -153,6 +154,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.
@@ -229,6 +292,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
@@ -507,6 +584,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)
@@ -99,6 +99,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)
@@ -88,6 +88,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.
@@ -119,6 +119,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
@@ -94,6 +96,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)
@@ -108,6 +123,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()
@@ -166,6 +184,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.
@@ -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__)
@@ -559,6 +562,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)}")
@@ -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.
"""
@@ -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
@@ -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.",
)
@@ -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
@@ -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)
@@ -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
@@ -18,6 +18,7 @@ there is no matching entity directory.
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
@@ -26,12 +27,14 @@ 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
__all__ = [
"FeedbackRow",
"AgentRow",
"NotificationRow",
"RunEventRow",
"RunRow",
"ScheduledTaskDeliveryProfileRow",
@@ -39,6 +42,7 @@ __all__ = [
"ScheduledTaskRunRow",
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SkillRow",
"ThreadMetaRow",
"UserRow",
]
@@ -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)
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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:
@@ -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")
@@ -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}"
@@ -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"}
+2692 -2692
View File
File diff suppressed because it is too large. Load diff