diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts
index 83eabbe8..eb0caeae 100644
--- a/frontend-web/src/core/agents/api.ts
+++ b/frontend-web/src/core/agents/api.ts
@@ -174,6 +174,31 @@ export async function setAgentFeatured(
return res.json() as Promise;
}
+export interface AgentSkillsSyncRemoval {
+ agent_id: string;
+ agent_name: string | null;
+ user_id: string | null;
+ removed_skills: string[];
+}
+
+export interface AgentSkillsSyncResult {
+ checked: number;
+ modified: number;
+ removals: AgentSkillsSyncRemoval[];
+}
+
+export async function adminSyncAgentSkills(): Promise {
+ const res = await apiFetch(
+ `${getBackendBaseURL()}/api/agents/admin/sync-skills`,
+ { method: "POST" },
+ );
+ if (!res.ok) {
+ const err = (await res.json().catch(() => ({}))) as { detail?: string };
+ throw new Error(err.detail ?? `Failed to sync agent skills: ${res.statusText}`);
+ }
+ return res.json() as Promise;
+}
+
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts
index 9099d63c..4066ab48 100644
--- a/frontend-web/src/core/agents/hooks.ts
+++ b/frontend-web/src/core/agents/hooks.ts
@@ -6,6 +6,7 @@ import {
} from "@tanstack/react-query";
import {
+ adminSyncAgentSkills,
createAgent,
deleteAgent,
getAgent,
@@ -180,3 +181,15 @@ export function useSetAgentPrivate() {
},
});
}
+
+export function useAdminSyncAgentSkills() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: adminSyncAgentSkills,
+ onSuccess: (result) => {
+ if (result.modified > 0) {
+ invalidateAgentQueries(queryClient);
+ }
+ },
+ });
+}
diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts
index c869f40f..658124bd 100644
--- a/frontend-web/src/core/skills/hooks.ts
+++ b/frontend-web/src/core/skills/hooks.ts
@@ -162,6 +162,10 @@ export function useSetSkillPublished() {
}) => setSkillPublished(skillName, published),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
+ // Unpublishing triggers the backend cascade that strips this skill
+ // from every referencing agent's config.yaml, so the cached agent
+ // list (agent.skills) is now stale.
+ void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
@@ -194,6 +198,10 @@ export function useDeleteCustomSkill() {
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
+ // Deletion triggers the backend cascade that strips this skill from
+ // every referencing agent's config.yaml, so the cached agent list
+ // (agent.skills) is now stale.
+ void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
diff --git a/frontend-web/src/pages/AdminAgentsPage.tsx b/frontend-web/src/pages/AdminAgentsPage.tsx
index 9a94f848..d33d1680 100644
--- a/frontend-web/src/pages/AdminAgentsPage.tsx
+++ b/frontend-web/src/pages/AdminAgentsPage.tsx
@@ -1,8 +1,18 @@
"use client";
-import { BotIcon, SearchIcon } from "lucide-react";
+import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
WorkspaceBody,
@@ -10,12 +20,35 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { AgentCard } from "@/components/workspace/agents/agent-card";
-import { useAllAgentsAdmin } from "@/core/agents";
+import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents";
+import type { AgentSkillsSyncResult } from "@/core/agents/api";
import { desensitize } from "@/lib/desensitize";
export default function AdminAgentsPage() {
const [search, setSearch] = useState("");
const { agents, isLoading } = useAllAgentsAdmin(true);
+ const syncSkills = useAdminSyncAgentSkills();
+ const [syncResult, setSyncResult] = useState(
+ null,
+ );
+ const [syncDialogOpen, setSyncDialogOpen] = useState(false);
+
+ async function handleSyncSkills() {
+ try {
+ const result = await syncSkills.mutateAsync();
+ setSyncResult(result);
+ setSyncDialogOpen(true);
+ if (result.modified === 0) {
+ toast.success("所有智能体的技能均有效,无需清理");
+ } else {
+ toast.success(
+ `已清理 ${result.modified} 个智能体中的失效技能引用`,
+ );
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : String(err));
+ }
+ }
const filteredAgents = useMemo(() => {
const needle = search.trim().toLowerCase();
@@ -37,14 +70,28 @@ export default function AdminAgentsPage() {
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
-
-
-
setSearch(e.target.value)}
- placeholder="按名称搜索"
- className="pl-8"
- />
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="按名称搜索"
+ className="pl-8"
+ />
+
@@ -77,6 +124,60 @@ export default function AdminAgentsPage() {
+
+
);
}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py
index 1fb66c5e..9f40d9ec 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/agents.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
import logging
import shutil
from datetime import datetime
@@ -9,18 +10,22 @@ from typing import Any
from uuid import uuid4
import yaml
-from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.auth.password import verify_password_async
-from app.gateway.deps import get_agent_store, get_tag_store
+from app.gateway.deps import get_agent_store, get_config, get_tag_store
+from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
+from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
+from deerflow.skills.cascade import sync_agent_skills_to_available
+from deerflow.skills.storage import get_or_new_skill_storage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["agents"])
@@ -842,3 +847,78 @@ async def reorder_featured_agents(
except Exception as e:
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e
+
+
+class AgentSkillRemovalEntry(BaseModel):
+ agent_id: str = Field(..., description="Agent storage id whose config.yaml was rewritten")
+ agent_name: str | None = Field(default=None, description="Display name from the DB, if any")
+ user_id: str | None = Field(default=None, description="Owner user id (null = built-in)")
+ removed_skills: list[str] = Field(..., description="Skill names that no longer exist on disk and were stripped")
+
+
+class AgentSkillsSyncResponse(BaseModel):
+ checked: int = Field(..., description="Number of agent config.yaml files scanned")
+ modified: int = Field(..., description="Number of agents whose skills list was rewritten")
+ removals: list[AgentSkillRemovalEntry] = Field(default_factory=list, description="Per-agent removal details")
+
+
+@router.post(
+ "/agents/admin/sync-skills",
+ response_model=AgentSkillsSyncResponse,
+ summary="Sync Agent Skills (admin)",
+ description=(
+ "Admin-only. Scan every agent's ``config.yaml`` and strip skill names that "
+ "no longer exist on disk. Repairs historical drift where a skill was "
+ "removed without the delete/unpublish cascade firing (e.g. deleted from "
+ "the filesystem manually, or removed in an older version of the code)."
+ ),
+)
+async def admin_sync_agent_skills(
+ request: Request,
+ config: AppConfig = Depends(get_config),
+) -> AgentSkillsSyncResponse:
+ _require_agents_api_enabled()
+ if not _is_admin_user(request):
+ raise HTTPException(status_code=403, detail="Only system admins can run agent-skills sync")
+
+ try:
+ storage = get_or_new_skill_storage(app_config=config)
+ all_skills = storage.load_skills(enabled_only=False)
+ available = {skill.name for skill in all_skills}
+
+ result = await asyncio.to_thread(sync_agent_skills_to_available, available)
+
+ # Enrich removals with display name + owner from the DB so the admin
+ # UI can show "我的智能体 X 移除了技能 Y" instead of just the id.
+ store = get_agent_store(request)
+ enriched: list[AgentSkillRemovalEntry] = []
+ for entry in result.get("removals", []):
+ agent_id = entry["agent_id"]
+ record = await store.get_any(agent_id)
+ enriched.append(
+ AgentSkillRemovalEntry(
+ agent_id=agent_id,
+ agent_name=(record.get("name") if record else None),
+ user_id=(record.get("user_id") if record else None),
+ removed_skills=entry["removed_skills"],
+ )
+ )
+
+ if result.get("modified"):
+ await refresh_skills_system_prompt_cache_async()
+
+ logger.info(
+ "Admin agent-skills sync: checked=%d modified=%d",
+ result["checked"],
+ result["modified"],
+ )
+ return AgentSkillsSyncResponse(
+ checked=result["checked"],
+ modified=result["modified"],
+ removals=enriched,
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to sync agent skills: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to sync agent skills: {str(e)}") from e
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py
index 17d3ae0f..1e753ce0 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py
@@ -149,3 +149,58 @@ async def cascade_skill_unpublished_or_deleted(
counters["notified"] += 1
return counters
+
+
+def sync_agent_skills_to_available(available_skill_names: set[str]) -> dict[str, Any]:
+ """Strip skill references that no longer exist on disk from every agent.
+
+ Walks every agent dir and rewrites its ``config.yaml`` ``skills`` list to
+ contain only names that appear in ``available_skill_names``. Used by the
+ admin "agent-skills sync" endpoint to repair historical drift — skills
+ deleted before the cascade existed, deleted via a path that skipped the
+ cascade, or removed from the filesystem manually.
+
+ Synchronous because it only touches local files and the admin endpoint
+ runs it in a worker thread.
+
+ Returns ``{checked, modified, removals: [{agent_id, removed_skills}]}``.
+ """
+ counters: dict[str, Any] = {"checked": 0, "modified": 0, "removals": []}
+
+ for agent_dir in _list_agent_dirs():
+ config_file = agent_dir / "config.yaml"
+ if not config_file.exists():
+ continue
+ counters["checked"] += 1
+
+ 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("Sync: failed to parse %s, skipping", config_file)
+ continue
+
+ skills = data.get("skills")
+ if not isinstance(skills, list) or not skills:
+ continue
+
+ kept: list[str] = []
+ removed: list[str] = []
+ for name in skills:
+ if isinstance(name, str) and name in available_skill_names:
+ kept.append(name)
+ else:
+ removed.append(name if isinstance(name, str) else str(name))
+
+ if not removed:
+ continue
+
+ data["skills"] = kept
+ with config_file.open("w", encoding="utf-8") as f:
+ yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
+
+ counters["modified"] += 1
+ counters["removals"].append({"agent_id": agent_dir.name, "removed_skills": removed})
+ logger.info("Sync: removed orphan skills %s from agent '%s'", removed, agent_dir.name)
+
+ return counters