diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 01168597..5b907bf4 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -134,11 +134,11 @@ export function App() { } /> } /> diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 25358b1f..27dda218 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -1,8 +1,15 @@ "use client"; -import { BotIcon, MessageSquareIcon, SquarePenIcon, Trash2Icon } from "lucide-react"; +import { + BotIcon, + EyeIcon, + EyeOffIcon, + MessageSquareIcon, + SquarePenIcon, + Trash2Icon, +} from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -24,9 +31,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { useDeleteAgent, useUpdateAgent } from "@/core/agents"; import type { Agent } from "@/core/agents"; +import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { useSkills } from "@/core/skills/hooks"; @@ -42,28 +51,45 @@ export function AgentCard({ agent }: AgentCardProps) { const { skills, isLoading: isSkillsLoading } = useSkills(); const [deleteOpen, setDeleteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); + const [editName, setEditName] = useState(agent.name); const [editDescription, setEditDescription] = useState(agent.description ?? ""); const [editSoul, setEditSoul] = useState(agent.soul ?? ""); + const [editPublished, setEditPublished] = useState(agent.published); const [selectedSkills, setSelectedSkills] = useState( agent.skills ?? [], ); + const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []); + const isBuiltin = agent.builtin; + const isOwned = + !isBuiltin && agent.user_id !== null && agent.user_id === currentUserId; + const isFromOthers = !isBuiltin && !isOwned; + useEffect(() => { if (!editOpen) { return; } + setEditName(agent.name); setEditDescription(agent.description ?? ""); setEditSoul(agent.soul ?? ""); + setEditPublished(agent.published); setSelectedSkills(agent.skills ?? []); - }, [agent.description, agent.skills, agent.soul, editOpen]); + }, [ + agent.description, + agent.name, + agent.published, + agent.skills, + agent.soul, + editOpen, + ]); function handleChat() { - router.push(`/workspace/agents/${agent.name}/chats/new`); + router.push(`/workspace/agents/${agent.id}/chats/new`); } async function handleDelete() { try { - await deleteAgent.mutateAsync(agent.name); + await deleteAgent.mutateAsync(agent.id); toast.success(t.agents.deleteSuccess); setDeleteOpen(false); } catch (err) { @@ -72,12 +98,19 @@ export function AgentCard({ agent }: AgentCardProps) { } async function handleSaveEdit() { + const trimmedName = editName.trim(); + if (!trimmedName) { + toast.error(t.agents.nameStepInvalidError ?? "名称不能为空"); + return; + } try { await updateAgent.mutateAsync({ - name: agent.name, + id: agent.id, request: { + name: trimmedName, description: editDescription, soul: editSoul, + published: editPublished, skills: selectedSkills, }, }); @@ -88,6 +121,18 @@ export function AgentCard({ agent }: AgentCardProps) { } } + async function handleTogglePublished() { + try { + await updateAgent.mutateAsync({ + id: agent.id, + request: { published: !agent.published }, + }); + toast.success(agent.published ? "已取消发布" : "已发布"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + function toggleSkill(skillName: string) { setSelectedSkills((prev) => { if (prev.includes(skillName)) { @@ -119,6 +164,39 @@ export function AgentCard({ agent }: AgentCardProps) { )} + + {isBuiltin && ( + + 内置 + + )} + {isOwned && ( + + 我的 + + )} + {isFromOthers && ( + + 来自他人 + + )} + {isOwned && agent.published && ( + + 已发布 + + )} + {agent.description && ( @@ -157,41 +235,61 @@ export function AgentCard({ agent }: AgentCardProps) { {t.agents.chat} - - setEditOpen(true)} - title="编辑智能体" - > - - - setDeleteOpen(true)} - title={t.agents.delete} - > - - - + {isOwned && ( + + void handleTogglePublished()} + disabled={updateAgent.isPending} + title={agent.published ? "取消发布" : "发布"} + > + {agent.published ? ( + + ) : ( + + )} + + setEditOpen(true)} + title="编辑智能体" + > + + + setDeleteOpen(true)} + title={t.agents.delete} + > + + + + )} - 编辑智能体:{agent.name} + 编辑智能体:{agent.name} - 可以修改描述、技能白名单和 SOUL 内容。 + 可以修改名称、描述、技能白名单、SOUL 内容与发布状态。 名称 - + setEditName(e.target.value)} + placeholder="输入智能体名称(允许中文)" + /> 描述 @@ -238,7 +336,7 @@ export function AgentCard({ agent }: AgentCardProps) { )} - 不选任何技能会保存为无技能(skills = [])。 + 不选任何技能会保存为无技能(skills = [])。 @@ -250,6 +348,18 @@ export function AgentCard({ agent }: AgentCardProps) { className="min-h-40 font-mono text-xs" /> + + + 公开发布 + + 打开后,该智能体对所有用户可见。 + + + + {agents.map((agent) => ( - + ))} )} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index ced13faf..5ddec201 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -62,10 +62,10 @@ export function RecentChatList() { const { t } = useI18n(); const router = useRouter(); const pathname = usePathname(); - const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } = + const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{ thread_id: string; - agent_name?: string; + agent_id?: string; }>(); const { data: threads = [] } = useThreads(); const { mutate: deleteThread } = useDeleteThread(); @@ -82,7 +82,7 @@ export function RecentChatList() { if (threadId === threadIdFromPath) { const threadIndex = threads.findIndex((t) => t.thread_id === threadId); let nextThreadPath = pathOfThread("new", { - agent_name: agentNameFromPath, + agent_id: agentIdFromPath, }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { @@ -94,7 +94,7 @@ export function RecentChatList() { void router.push(nextThreadPath); } }, - [agentNameFromPath, deleteThread, router, threadIdFromPath, threads], + [agentIdFromPath, deleteThread, router, threadIdFromPath, threads], ); const handleRenameClick = useCallback( diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index 2fd7d1bb..c797da41 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -22,9 +22,9 @@ export async function listAgents(): Promise { return data.agents; } -export async function getAgent(name: string): Promise { - const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${name}`); - if (!res.ok) throw new Error(`Agent '${name}' not found`); +export async function getAgent(id: string): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`); + if (!res.ok) throw new Error(`Agent '${id}' not found`); return res.json() as Promise; } @@ -42,10 +42,10 @@ export async function createAgent(request: CreateAgentRequest): Promise { } export async function updateAgent( - name: string, + id: string, request: UpdateAgentRequest, ): Promise { - const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${name}`, { + const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -57,8 +57,8 @@ export async function updateAgent( return res.json() as Promise; } -export async function deleteAgent(name: string): Promise { - const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${name}`, { +export async function deleteAgent(id: string): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`, { method: "DELETE", }); if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index c40f0dae..63800036 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -17,11 +17,11 @@ export function useAgents() { return { agents: data ?? [], isLoading, error }; } -export function useAgent(name: string | null | undefined) { +export function useAgent(id: string | null | undefined) { const { data, isLoading, error } = useQuery({ - queryKey: ["agents", name], - queryFn: () => getAgent(name!), - enabled: !!name, + queryKey: ["agents", id], + queryFn: () => getAgent(id!), + enabled: !!id, }); return { agent: data ?? null, isLoading, error }; } @@ -40,15 +40,15 @@ export function useUpdateAgent() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ - name, + id, request, }: { - name: string; + id: string; request: UpdateAgentRequest; - }) => updateAgent(name, request), - onSuccess: (_data, { name }) => { + }) => updateAgent(id, request), + onSuccess: (_data, { id }) => { void queryClient.invalidateQueries({ queryKey: ["agents"] }); - void queryClient.invalidateQueries({ queryKey: ["agents", name] }); + void queryClient.invalidateQueries({ queryKey: ["agents", id] }); }, }); } @@ -56,7 +56,7 @@ export function useUpdateAgent() { export function useDeleteAgent() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (name: string) => deleteAgent(name), + mutationFn: (id: string) => deleteAgent(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["agents"] }); }, diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 53e09ba6..a427ecb4 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -1,25 +1,34 @@ export interface Agent { + id: string; name: string; description: string; - model: string | null; - tool_groups: string[] | null; - skills: string[] | null; + user_id: string | null; + published: boolean; + builtin: boolean; + created_at?: string | null; + updated_at?: string | null; soul?: string | null; + model?: string | null; + tool_groups?: string[] | null; + skills?: string[] | null; } export interface CreateAgentRequest { name: string; description?: string; + soul?: string; + published?: boolean; + skills?: string[] | null; model?: string | null; tool_groups?: string[] | null; - skills?: string[] | null; - soul?: string; } export interface UpdateAgentRequest { + name?: string | null; description?: string | null; + soul?: string | null; + published?: boolean | null; + skills?: string[] | null; model?: string | null; tool_groups?: string[] | null; - skills?: string[] | null; - soul?: string | null; } diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 1a507667..33093451 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -28,6 +28,7 @@ export type ThreadStreamOptions = { threadId?: string | null | undefined; context: LocalSettings["context"]; isMock?: boolean; + assistantId?: string; onStart?: (threadId: string) => void; onFinish?: (state: AgentThreadState) => void; onToolEnd?: (event: ToolEndEvent) => void; @@ -139,6 +140,7 @@ export function useThreadStream({ threadId, context, isMock, + assistantId, onStart, onFinish, onToolEnd, @@ -204,7 +206,7 @@ export function useThreadStream({ const thread = useStream({ client: getAPIClient(isMock), - assistantId: "lead_agent", + assistantId: assistantId ?? "lead_agent", threadId: onStreamThreadId, reconnectOnMount: runMetadataStorageRef.current ? () => runMetadataStorageRef.current! @@ -213,10 +215,11 @@ export function useThreadStream({ onCreated(meta) { handleStreamStart(meta.thread_id); setOnStreamThreadId(meta.thread_id); - if (context.agent_name && !isMock) { + const metadataAgentId = context.agent_id ?? context.agent_name; + if (metadataAgentId && !isMock) { void getAPIClient() .threads.update(meta.thread_id, { - metadata: { agent_name: context.agent_name }, + metadata: { agent_id: metadataAgentId }, }) .catch(() => ({})); } diff --git a/frontend-web/src/core/threads/types.ts b/frontend-web/src/core/threads/types.ts index 50f1dab6..24074d64 100644 --- a/frontend-web/src/core/threads/types.ts +++ b/frontend-web/src/core/threads/types.ts @@ -16,6 +16,8 @@ export interface AgentThreadContext extends Record { is_plan_mode: boolean; subagent_enabled: boolean; reasoning_effort?: "minimal" | "low" | "medium" | "high"; + agent_id?: string; + /** @deprecated kept for reading legacy thread metadata; prefer agent_id */ agent_name?: string; } diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 901f1d07..fc31b859 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -2,34 +2,41 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { AgentThread, AgentThreadContext } from "./types"; +type ThreadAgentRef = Pick; + type ThreadRouteTarget = | string | { thread_id: string; - context?: Pick | null; + context?: ThreadAgentRef | null; metadata?: Record | null; }; +function pickAgentRef( + context: ThreadAgentRef | null | undefined, + metadata: Record | null | undefined, +): string | undefined { + if (context?.agent_id) return context.agent_id; + if (context?.agent_name) return context.agent_name; + const metaId = metadata?.agent_id; + if (typeof metaId === "string" && metaId) return metaId; + const metaName = metadata?.agent_name; + if (typeof metaName === "string" && metaName) return metaName; + return undefined; +} + export function pathOfThread( thread: ThreadRouteTarget, - context?: Pick | null, + context?: ThreadAgentRef | null, ) { const threadId = typeof thread === "string" ? thread : thread.thread_id; - let agentName: string | undefined; - if (typeof thread === "string") { - agentName = context?.agent_name; - } else { - agentName = thread.context?.agent_name; - if (!agentName) { - const metaAgent = thread.metadata?.agent_name; - if (typeof metaAgent === "string") { - agentName = metaAgent; - } - } - } + const agentRef = + typeof thread === "string" + ? pickAgentRef(context, null) + : pickAgentRef(thread.context, thread.metadata); - return agentName - ? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${threadId}` + return agentRef + ? `/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` : `/workspace/chats/${threadId}`; } diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index fad004fc..6bf9e897 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -37,11 +37,11 @@ export default function AgentChatPage() { const [hasStartedConversation, setHasStartedConversation] = useState(false); const router = useRouter(); - const { agent_name } = useParams<{ - agent_name: string; + const { agent_id } = useParams<{ + agent_id: string; }>(); - const { agent } = useAgent(agent_name); + const { agent } = useAgent(agent_id); const { threadId, setThreadId, isNewThread, setIsNewThread } = useThreadChat(); @@ -57,7 +57,8 @@ export default function AgentChatPage() { const { showNotification } = useNotification(); const [thread, sendMessage] = useThreadStream({ threadId: isNewThread ? undefined : threadId, - context: { ...settings.context, agent_name: agent_name }, + assistantId: agent_id, + context: { ...settings.context, agent_id }, onStart: (createdThreadId) => { setThreadId(createdThreadId); setIsNewThread(false); @@ -65,7 +66,7 @@ export default function AgentChatPage() { history.replaceState( null, "", - `${window.location.pathname}${window.location.search}#/workspace/agents/${agent_name}/chats/${createdThreadId}`, + `${window.location.pathname}${window.location.search}#/workspace/agents/${agent_id}/chats/${createdThreadId}`, ); }, onFinish: (state) => { @@ -92,9 +93,9 @@ export default function AgentChatPage() { window.dispatchEvent( new CustomEvent("workspace:sandbox-resume-autoscroll"), ); - void sendMessage(threadId, message, { agent_name }); + void sendMessage(threadId, message, { agent_id }); }, - [sendMessage, threadId, agent_name], + [sendMessage, threadId, agent_id], ); const handleStop = useCallback(async () => { @@ -123,7 +124,7 @@ export default function AgentChatPage() { - {agent?.name ?? agent_name} + {agent?.name ?? agent_id} @@ -136,7 +137,7 @@ export default function AgentChatPage() { size="sm" variant="secondary" onClick={() => { - router.push(`/workspace/agents/${agent_name}/chats/new`); + router.push(`/workspace/agents/${agent_id}/chats/new`); }} > {t.agents.newChat} @@ -199,7 +200,7 @@ export default function AgentChatPage() { context={settings.context} extraHeader={ isLandingState && ( - + ) } disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 2b0a6426..a9d4f531 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -49,7 +49,6 @@ import { cn } from "@/lib/utils"; type Step = "name" | "chat"; type SetupAgentStatus = "idle" | "requested" | "completed"; -const NAME_RE = /^[A-Za-z0-9-]+$/; const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen"; const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000]; const DEFAULT_SELECTED_SKILL_COUNT = 3; @@ -58,14 +57,14 @@ function wait(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } -async function getAgentWithRetry(agentName: string) { +async function getAgentWithRetry(agentId: string) { for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) { if (delay > 0) { await wait(delay); } try { - return await getAgent(agentName); + return await getAgent(agentId); } catch { // Retry until the write settles or the attempts are exhausted. } @@ -97,7 +96,7 @@ export default function NewAgentPage() { const [nameError, setNameError] = useState(""); const [isCheckingName, setIsCheckingName] = useState(false); const [isCreatingAgent, setIsCreatingAgent] = useState(false); - const [agentName, setAgentName] = useState(""); + const [agentId, setAgentId] = useState(""); const [agent, setAgent] = useState(null); const [showSaveHint, setShowSaveHint] = useState(false); const [setupAgentStatus, setSetupAgentStatus] = @@ -125,6 +124,7 @@ export default function NewAgentPage() { const [thread, sendMessage] = useThreadStream({ threadId: step === "chat" ? threadId : undefined, + assistantId: agentId || undefined, context: { mode: "flash", is_bootstrap: true, @@ -135,9 +135,9 @@ export default function NewAgentPage() { } }, onToolEnd({ name }) { - if (name !== "setup_agent" || !agentName) return; + if (name !== "setup_agent" || !agentId) return; setSetupAgentStatus("completed"); - void getAgentWithRetry(agentName).then((fetched) => { + void getAgentWithRetry(agentId).then((fetched) => { if (fetched) { setAgent(fetched); return; @@ -171,8 +171,7 @@ export default function NewAgentPage() { const handleConfirmName = useCallback(async () => { const trimmed = nameInput.trim(); - if (!trimmed) return; - if (!NAME_RE.test(trimmed)) { + if (!trimmed) { setNameError(t.agents.nameStepInvalidError); return; } @@ -185,11 +184,7 @@ export default function NewAgentPage() { setNameError(""); setIsCheckingName(true); try { - const result = await checkAgentName(trimmed); - if (!result.available) { - setNameError(t.agents.nameStepAlreadyExistsError); - return; - } + await checkAgentName(trimmed); } catch (err) { if ( err instanceof AgentNameCheckError && @@ -204,14 +199,18 @@ export default function NewAgentPage() { setIsCheckingName(false); } + let createdId: string; + let createdName: string; setIsCreatingAgent(true); try { - await createAgent({ + const created = await createAgent({ name: trimmed, description: "", skills: selectedSkills, soul: "", }); + createdId = created.id; + createdName = created.name; } catch (err) { setNameError( getCreateAgentErrorMessage( @@ -225,17 +224,16 @@ export default function NewAgentPage() { setIsCreatingAgent(false); } - setAgentName(trimmed); + setAgentId(createdId); setStep("chat"); await ensureThreadExists(); await sendMessage(threadId, { - text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed), + text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName), files: [], }); }, [ nameInput, sendMessage, - t.agents.nameStepAlreadyExistsError, t.agents.nameStepNetworkError, t.agents.nameStepBootstrapMessage, t.agents.nameStepCheckError, @@ -272,15 +270,15 @@ export default function NewAgentPage() { await sendMessage( threadId, { text: trimmed, files: [] }, - { agent_name: agentName }, + { agent_id: agentId }, ); }, - [agentName, sendMessage, thread.isLoading, threadId, ensureThreadExists], + [agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists], ); const handleSaveAgent = useCallback(async () => { if ( - !agentName || + !agentId || agent || thread.isLoading || setupAgentStatus !== "idle" @@ -294,7 +292,7 @@ export default function NewAgentPage() { await sendMessage( threadId, { text: t.agents.saveCommandMessage, files: [] }, - { agent_name: agentName }, + { agent_id: agentId }, { additionalKwargs: { hide_from_ui: true } }, ); toast.success(t.agents.saveRequested); @@ -304,7 +302,7 @@ export default function NewAgentPage() { } }, [ agent, - agentName, + agentId, sendMessage, setupAgentStatus, t.agents.saveCommandMessage, @@ -480,7 +478,7 @@ export default function NewAgentPage() { router.push( - `/workspace/agents/${agentName}/chats/new`, + `/workspace/agents/${agentId}/chats/new`, ) } > diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index b185ce4a..ad89c1aa 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -215,6 +215,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) | | **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) | | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | +| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents; `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | @@ -225,6 +226,10 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. +### Custom Agent Persistence + +Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. + ### Sandbox System (`packages/harness/deerflow/sandbox/`) **Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir` diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 15854094..b8f0d62b 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -120,6 +120,8 @@ FastAPI application providing REST endpoints for frontend integration: | `GET/PUT /api/mcp/config` | Manage MCP server configurations | | `GET/PUT /api/skills` | List and manage skills | | `POST /api/skills/install` | Install skill from `.skill` archive | +| `GET/POST /api/agents` | List visible agents or create a user-owned agent | +| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent | | `GET /api/memory` | Retrieve memory data | | `POST /api/memory/reload` | Force memory reload | | `GET /api/memory/config` | Memory configuration | @@ -129,6 +131,10 @@ FastAPI application providing REST endpoints for frontend integration: | `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts | +### Custom Agents + +Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created. + ### IM Channels The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place. diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 9d913787..33e7eafb 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -24,6 +24,7 @@ from deerflow.runtime.runs.store.base import RunStore 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.scheduled_tasks import ScheduledTaskStore from deerflow.persistence.thread_meta.base import ThreadMetaStore @@ -89,6 +90,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.scheduled_task_store = make_scheduled_task_store(sf) + from deerflow.persistence.agents import make_agent_store + + app.state.agent_store = make_agent_store(sf) + # Run event store (has its own factory with config-driven backend selection) run_events_config = getattr(config, "run_events", None) app.state.run_event_store = make_run_event_store(run_events_config) @@ -149,6 +154,14 @@ def get_scheduled_task_store(request: Request) -> ScheduledTaskStore: return val +def get_agent_store(request: Request) -> AgentStore: + """Return the custom-agent store.""" + val = getattr(request.app.state, "agent_store", None) + if val is None: + raise HTTPException(status_code=503, detail="Agent store not available") + return val + + def get_run_context(request: Request) -> RunContext: """Build a :class:`RunContext` from ``app.state`` singletons. diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index b6cc5c43..2eb2b9bb 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -1,36 +1,50 @@ """CRUD API for custom agents.""" +from __future__ import annotations + import logging -import re import shutil +from datetime import datetime +from typing import Any +from uuid import uuid4 import yaml -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field +from app.gateway.deps import get_agent_store from deerflow.config.agents_api_config import get_agents_api_config -from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul +from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id from deerflow.config.paths import get_paths +from deerflow.persistence.agents import AgentStore +from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["agents"]) -AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") - class AgentResponse(BaseModel): - """Response model for a custom agent.""" + """Response model for an agent.""" - name: str = Field(..., description="Agent name (hyphen-case)") + id: str = Field(..., description="Agent id used for routing and filesystem storage") + name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.") description: str = Field(default="", description="Agent description") - model: str | None = Field(default=None, description="Optional model override") - tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") - skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None/[]=none)") + user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.") + published: bool = Field(default=False, description="Whether a user-created agent is public") + builtin: bool = Field(default=False, description="True when user_id is null") + created_at: datetime | str | None = None + updated_at: datetime | str | None = None soul: str | None = Field(default=None, description="SOUL.md content") + # Deprecated compatibility fields. Agent runtime configuration is no longer + # stored in the agents table, but older frontends may still read these keys. + model: str | None = None + tool_groups: list[str] | None = None + skills: list[str] | None = None + class AgentsListResponse(BaseModel): - """Response model for listing all custom agents.""" + """Response model for listing all visible agents.""" agents: list[AgentResponse] @@ -38,43 +52,25 @@ class AgentsListResponse(BaseModel): class AgentCreateRequest(BaseModel): """Request body for creating a custom agent.""" - name: str = Field(..., description="Agent name (must match ^[A-Za-z0-9-]+$, stored as lowercase)") + name: str = Field(..., description="Display name; Chinese and duplicate names are allowed") description: str = Field(default="", description="Agent description") - model: str | None = Field(default=None, description="Optional model override") - tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") - skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None/[]=none)") - soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails") + soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails") + published: bool = Field(default=False, description="Whether the new agent should be public") + skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml") + model: str | None = Field(default=None, description="Optional model override persisted to config.yaml") + tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml") class AgentUpdateRequest(BaseModel): """Request body for updating a custom agent.""" + name: str | None = Field(default=None, description="Updated display name") description: str | None = Field(default=None, description="Updated description") - model: str | None = Field(default=None, description="Updated model override") - tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist") - skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None/[]=none)") soul: str | None = Field(default=None, description="Updated SOUL.md content") - - -def _validate_agent_name(name: str) -> None: - """Validate agent name against allowed pattern. - - Args: - name: The agent name to validate. - - Raises: - HTTPException: 422 if the name is invalid. - """ - if not AGENT_NAME_PATTERN.match(name): - raise HTTPException( - status_code=422, - detail=f"Invalid agent name '{name}'. Must match ^[A-Za-z0-9-]+$ (letters, digits, and hyphens only).", - ) - - -def _normalize_agent_name(name: str) -> str: - """Normalize agent name to lowercase for filesystem storage.""" - return name.lower() + published: bool | None = Field(default=None, description="Updated publication state") + skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)") + model: str | None = Field(default=None, description="Updated model override (config.yaml)") + tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)") def _require_agents_api_enabled() -> None: @@ -86,242 +82,290 @@ def _require_agents_api_enabled() -> None: ) -def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse: - """Convert AgentConfig to AgentResponse.""" +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 _validate_display_name(name: str) -> str: + normalized = name.strip() + if not normalized: + raise HTTPException(status_code=422, detail="Agent name cannot be empty") + return normalized + + +def _validate_storage_id(agent_id: str) -> str: + try: + return validate_agent_id(agent_id) or agent_id + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +def _write_agent_config( + agent_id: str, + *, + name: str, + description: str, + skills: list[str] | None = None, + model: str | None = None, + tool_groups: list[str] | None = None, +) -> None: + agent_dir = get_paths().agent_dir(agent_id) + agent_dir.mkdir(parents=True, exist_ok=True) + config_file = agent_dir / "config.yaml" + config_data: dict[str, Any] = { + "id": agent_id, + "name": name, + "description": description or "", + } + if skills is not None: + config_data["skills"] = skills + if model is not None: + config_data["model"] = model + if tool_groups is not None: + config_data["tool_groups"] = tool_groups + with open(config_file, "w", encoding="utf-8") as f: + yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) + + +def _load_config_extras(agent_id: str) -> dict[str, Any]: + """Read skills/model/tool_groups from the agent's config.yaml, tolerant of missing files.""" + try: + cfg = load_agent_config(agent_id) + except (FileNotFoundError, ValueError): + return {"skills": None, "model": None, "tool_groups": None} + if cfg is None: + return {"skills": None, "model": None, "tool_groups": None} + return {"skills": cfg.skills, "model": cfg.model, "tool_groups": cfg.tool_groups} + + +def _write_agent_soul(agent_id: str, soul: str) -> None: + agent_dir = get_paths().agent_dir(agent_id) + agent_dir.mkdir(parents=True, exist_ok=True) + (agent_dir / "SOUL.md").write_text(soul, encoding="utf-8") + + +async def _sync_legacy_agents(store: AgentStore) -> None: + """Import existing folder-backed agents as built-in DB records. + + This preserves old deployments where agents already existed under + ``.deer-flow/agents/{name}`` before the agents table was introduced. + """ + try: + for agent_cfg in list_custom_agents(): + agent_id = agent_cfg.id or agent_cfg.name + if not agent_id: + continue + await store.ensure_builtin( + { + "id": agent_id, + "name": agent_cfg.name, + "description": agent_cfg.description or "", + "published": True, + } + ) + except Exception: + logger.exception("Failed to sync legacy folder-backed agents") + + +def _record_to_response(record: dict[str, Any], *, include_soul: bool = False) -> AgentResponse: soul: str | None = None if include_soul: - soul = load_agent_soul(agent_cfg.name) or "" + soul = load_agent_soul(record["id"]) or "" + + extras = _load_config_extras(record["id"]) return AgentResponse( - name=agent_cfg.name, - description=agent_cfg.description, - model=agent_cfg.model, - tool_groups=agent_cfg.tool_groups, - skills=agent_cfg.skills, + id=record["id"], + name=record["name"], + description=record.get("description") or "", + user_id=record.get("user_id"), + published=bool(record.get("published", False)), + builtin=record.get("user_id") is None, + created_at=record.get("created_at"), + updated_at=record.get("updated_at"), soul=soul, + model=extras["model"], + tool_groups=extras["tool_groups"], + skills=extras["skills"], ) +async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]: + owned = await store.get_owned(agent_id, user_id) + if owned is not None: + return owned + visible = await store.get_visible(agent_id, user_id) + if visible is None: + raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") + raise HTTPException(status_code=403, detail="You can only modify agents that you created") + + @router.get( "/agents", response_model=AgentsListResponse, - summary="List Custom Agents", - description="List all custom agents available in the agents directory, including their soul content.", + summary="List Agents", + description="List built-in agents, the current user's agents, and published agents.", ) -async def list_agents() -> AgentsListResponse: - """List all custom agents. - - Returns: - List of all custom agents with their metadata and soul content. - """ +async def list_agents(request: Request) -> AgentsListResponse: _require_agents_api_enabled() try: - agents = list_custom_agents() - return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents]) + store = get_agent_store(request) + await _sync_legacy_agents(store) + user_id = _current_user_id(request) + agents = await store.list_visible(user_id) + return AgentsListResponse(agents=[_record_to_response(a, include_soul=True) for a in agents]) + except HTTPException: + raise except Exception as e: - logger.error(f"Failed to list agents: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") + logger.error("Failed to list agents: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") from e @router.get( "/agents/check", summary="Check Agent Name", - description="Validate an agent name and check if it is available (case-insensitive).", + description="Validate an agent display name. Duplicate names are allowed.", ) -async def check_agent_name(name: str) -> dict: - """Check whether an agent name is valid and not yet taken. - - Args: - name: The agent name to check. - - Returns: - ``{"available": true/false, "name": ""}`` - - Raises: - HTTPException: 422 if the name is invalid. - """ +async def check_agent_name(name: str) -> dict[str, Any]: _require_agents_api_enabled() - _validate_agent_name(name) - normalized = _normalize_agent_name(name) - available = not get_paths().agent_dir(normalized).exists() - return {"available": available, "name": normalized} + normalized = _validate_display_name(name) + return {"available": True, "name": normalized} @router.get( - "/agents/{name}", + "/agents/{agent_id}", response_model=AgentResponse, - summary="Get Custom Agent", - description="Retrieve details and SOUL.md content for a specific custom agent.", + summary="Get Agent", + description="Retrieve details and SOUL.md content for a visible agent.", ) -async def get_agent(name: str) -> AgentResponse: - """Get a specific custom agent by name. - - Args: - name: The agent name. - - Returns: - Agent details including SOUL.md content. - - Raises: - HTTPException: 404 if agent not found. - """ +async def get_agent(agent_id: str, request: Request) -> AgentResponse: _require_agents_api_enabled() - _validate_agent_name(name) - name = _normalize_agent_name(name) + agent_id = _validate_storage_id(agent_id) try: - agent_cfg = load_agent_config(name) - return _agent_config_to_response(agent_cfg, include_soul=True) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + store = get_agent_store(request) + await _sync_legacy_agents(store) + record = await store.get_visible(agent_id, _current_user_id(request)) + if record is None: + raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") + return _record_to_response(record, include_soul=True) + except HTTPException: + raise except Exception as e: - logger.error(f"Failed to get agent '{name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") + logger.error("Failed to get agent '%s': %s", agent_id, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") from e @router.post( "/agents", response_model=AgentResponse, status_code=201, - summary="Create Custom Agent", - description="Create a new custom agent with its config and SOUL.md.", + summary="Create Agent", + description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.", ) -async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: - """Create a new custom agent. - - Args: - request: The agent creation request. - - Returns: - The created agent details. - - Raises: - HTTPException: 409 if agent already exists, 422 if name is invalid. - """ +async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse: _require_agents_api_enabled() - _validate_agent_name(request.name) - normalized_name = _normalize_agent_name(request.name) - - agent_dir = get_paths().agent_dir(normalized_name) - - if agent_dir.exists(): - raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists") + name = _validate_display_name(request.name) + agent_id = uuid4().hex + agent_dir = get_paths().agent_dir(agent_id) try: - agent_dir.mkdir(parents=True, exist_ok=True) + store = get_agent_store(http_request) + if agent_dir.exists(): + raise HTTPException(status_code=409, detail=f"Generated agent id '{agent_id}' already exists") - # Write config.yaml - config_data: dict = {"name": normalized_name} - if request.description: - config_data["description"] = request.description - if request.model is not None: - config_data["model"] = request.model - if request.tool_groups is not None: - config_data["tool_groups"] = request.tool_groups - if request.skills is not None: - config_data["skills"] = request.skills + _write_agent_config( + agent_id, + name=name, + description=request.description or "", + skills=request.skills, + model=request.model, + tool_groups=request.tool_groups, + ) + _write_agent_soul(agent_id, request.soul) - config_file = agent_dir / "config.yaml" - with open(config_file, "w", encoding="utf-8") as f: - yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) - - # Write SOUL.md - soul_file = agent_dir / "SOUL.md" - soul_file.write_text(request.soul, encoding="utf-8") - - logger.info(f"Created agent '{normalized_name}' at {agent_dir}") - - agent_cfg = load_agent_config(normalized_name) - return _agent_config_to_response(agent_cfg, include_soul=True) + record = await store.create( + { + "id": agent_id, + "name": name, + "description": request.description or "", + "user_id": _current_user_id(http_request), + "published": request.published, + } + ) + logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir) + return _record_to_response(record, include_soul=True) except HTTPException: raise except Exception as e: - # Clean up on failure if agent_dir.exists(): shutil.rmtree(agent_dir) - logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") + logger.error("Failed to create agent '%s': %s", request.name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") from e @router.put( - "/agents/{name}", + "/agents/{agent_id}", response_model=AgentResponse, - summary="Update Custom Agent", - description="Update an existing custom agent's config and/or SOUL.md.", + summary="Update Agent", + description="Update an existing user-owned agent.", ) -async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: - """Update an existing custom agent. - - Args: - name: The agent name. - request: The update request (all fields optional). - - Returns: - The updated agent details. - - Raises: - HTTPException: 404 if agent not found. - """ +async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: Request) -> AgentResponse: _require_agents_api_enabled() - _validate_agent_name(name) - name = _normalize_agent_name(name) + agent_id = _validate_storage_id(agent_id) try: - agent_cfg = load_agent_config(name) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + store = get_agent_store(http_request) + user_id = _current_user_id(http_request) + current = await _get_owned_or_raise(store, agent_id, user_id) - agent_dir = get_paths().agent_dir(name) - - try: - # Update config if any config fields changed - # Use model_fields_set to distinguish "field omitted" from "explicitly set to null". - # This is critical for skills where None means "inherit all" (not "don't change"). fields_set = request.model_fields_set - config_changed = bool(fields_set & {"description", "model", "tool_groups", "skills"}) + update_data: dict[str, Any] = {} + if "name" in fields_set: + update_data["name"] = _validate_display_name(request.name or "") + if "description" in fields_set: + update_data["description"] = request.description or "" + if "published" in fields_set: + update_data["published"] = bool(request.published) - if config_changed: - updated: dict = { - "name": agent_cfg.name, - "description": request.description if "description" in fields_set else agent_cfg.description, - } - new_model = request.model if "model" in fields_set else agent_cfg.model - if new_model is not None: - updated["model"] = new_model + next_name = update_data.get("name", current["name"]) + next_description = update_data.get("description", current.get("description") or "") - new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups - if new_tool_groups is not None: - updated["tool_groups"] = new_tool_groups + existing_extras = _load_config_extras(agent_id) + next_skills = request.skills if "skills" in fields_set else existing_extras["skills"] + next_model = request.model if "model" in fields_set else existing_extras["model"] + next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"] - # skills: None = inherit all, [] = no skills, ["a","b"] = whitelist - if "skills" in fields_set: - new_skills = request.skills - else: - new_skills = agent_cfg.skills - if new_skills is not None: - updated["skills"] = new_skills - - config_file = agent_dir / "config.yaml" - with open(config_file, "w", encoding="utf-8") as f: - yaml.dump(updated, f, default_flow_style=False, allow_unicode=True) - - # Update SOUL.md if provided + config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups")) + if config_dirty: + _write_agent_config( + agent_id, + name=next_name, + description=next_description, + skills=next_skills, + model=next_model, + tool_groups=next_tool_groups, + ) if request.soul is not None: - soul_path = agent_dir / "SOUL.md" - soul_path.write_text(request.soul, encoding="utf-8") + _write_agent_soul(agent_id, request.soul) - logger.info(f"Updated agent '{name}'") + record = await store.update(agent_id, user_id, update_data) if update_data else current + if record is None: + raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") - refreshed_cfg = load_agent_config(name) - return _agent_config_to_response(refreshed_cfg, include_soul=True) + logger.info("Updated agent '%s' (%s)", record["name"], agent_id) + return _record_to_response(record, include_soul=True) except HTTPException: raise except Exception as e: - logger.error(f"Failed to update agent '{name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") + logger.error("Failed to update agent '%s': %s", agent_id, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e class UserProfileResponse(BaseModel): @@ -333,7 +377,7 @@ class UserProfileResponse(BaseModel): class UserProfileUpdateRequest(BaseModel): """Request body for setting the global user profile.""" - content: str = Field(default="", description="USER.md content — describes the user's background and preferences") + content: str = Field(default="", description="USER.md content - describes the user's background and preferences") @router.get( @@ -343,11 +387,6 @@ class UserProfileUpdateRequest(BaseModel): description="Read the global USER.md file that is injected into all custom agents.", ) async def get_user_profile() -> UserProfileResponse: - """Return the current USER.md content. - - Returns: - UserProfileResponse with content=None if USER.md does not exist yet. - """ _require_agents_api_enabled() try: @@ -357,8 +396,8 @@ async def get_user_profile() -> UserProfileResponse: raw = user_md_path.read_text(encoding="utf-8").strip() return UserProfileResponse(content=raw or None) except Exception as e: - logger.error(f"Failed to read user profile: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}") + logger.error("Failed to read user profile: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}") from e @router.put( @@ -368,54 +407,43 @@ async def get_user_profile() -> UserProfileResponse: description="Write the global USER.md file that is injected into all custom agents.", ) async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse: - """Create or overwrite the global USER.md. - - Args: - request: The update request with the new USER.md content. - - Returns: - UserProfileResponse with the saved content. - """ _require_agents_api_enabled() try: paths = get_paths() paths.base_dir.mkdir(parents=True, exist_ok=True) paths.user_md_file.write_text(request.content, encoding="utf-8") - logger.info(f"Updated USER.md at {paths.user_md_file}") + logger.info("Updated USER.md at %s", paths.user_md_file) return UserProfileResponse(content=request.content or None) except Exception as e: - logger.error(f"Failed to update user profile: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}") + logger.error("Failed to update user profile: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}") from e @router.delete( - "/agents/{name}", + "/agents/{agent_id}", status_code=204, - summary="Delete Custom Agent", - description="Delete a custom agent and all its files (config, SOUL.md, memory).", + summary="Delete Agent", + description="Delete a user-owned agent and all its files.", ) -async def delete_agent(name: str) -> None: - """Delete a custom agent. - - Args: - name: The agent name. - - Raises: - HTTPException: 404 if agent not found. - """ +async def delete_agent(agent_id: str, request: Request) -> None: _require_agents_api_enabled() - _validate_agent_name(name) - name = _normalize_agent_name(name) - - agent_dir = get_paths().agent_dir(name) - - if not agent_dir.exists(): - raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + agent_id = _validate_storage_id(agent_id) try: - shutil.rmtree(agent_dir) - logger.info(f"Deleted agent '{name}' from {agent_dir}") + store = get_agent_store(request) + user_id = _current_user_id(request) + await _get_owned_or_raise(store, agent_id, user_id) + deleted = await store.delete(agent_id, user_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") + + agent_dir = get_paths().agent_dir(agent_id) + if agent_dir.exists(): + shutil.rmtree(agent_dir) + logger.info("Deleted agent '%s' from %s", agent_id, agent_dir) + except HTTPException: + raise except Exception as e: - logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") + logger.error("Failed to delete agent '%s': %s", agent_id, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e diff --git a/offline-backend-20260512/backend/app/gateway/routers/assistants_compat.py b/offline-backend-20260512/backend/app/gateway/routers/assistants_compat.py index 83708747..cbb67b45 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/assistants_compat.py +++ b/offline-backend-20260512/backend/app/gateway/routers/assistants_compat.py @@ -13,7 +13,7 @@ import logging from datetime import UTC, datetime from typing import Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -56,23 +56,37 @@ def _get_default_assistant() -> AssistantResponse: ) -def _list_assistants() -> list[AssistantResponse]: - """List all available assistants from config.""" - assistants = [_get_default_assistant()] +async def _sync_legacy_agents(store) -> None: + from deerflow.config.agents_config import list_custom_agents - # Also include custom agents from config.yaml agents directory + for agent_cfg in list_custom_agents(): + agent_id = agent_cfg.id or agent_cfg.name + if agent_id: + await store.ensure_builtin( + { + "id": agent_id, + "name": agent_cfg.name, + "description": agent_cfg.description or "", + "published": True, + } + ) + + +def _legacy_assistants() -> list[AssistantResponse]: + assistants: list[AssistantResponse] = [] try: from deerflow.config.agents_config import list_custom_agents for agent_cfg in list_custom_agents(): now = datetime.now(UTC).isoformat() + assistant_id = agent_cfg.id or agent_cfg.name assistants.append( AssistantResponse( - assistant_id=agent_cfg.name, - graph_id="lead_agent", # All agents use the same graph + assistant_id=assistant_id, + graph_id="lead_agent", name=agent_cfg.name, config={}, - metadata={"created_by": "user"}, + metadata={"created_by": "system", "agent_id": assistant_id}, description=agent_cfg.description or "", created_at=now, updated_at=now, @@ -80,18 +94,54 @@ def _list_assistants() -> list[AssistantResponse]: ) ) except Exception: - logger.debug("Could not load custom agents for assistants list") + logger.debug("Could not load legacy custom agents for assistants list") + return assistants + + +async def _list_assistants(request: Request) -> list[AssistantResponse]: + """List all available assistants from config.""" + assistants = [_get_default_assistant()] + + try: + from app.gateway.deps import get_agent_store + from deerflow.runtime.user_context import get_effective_user_id + + store = get_agent_store(request) + await _sync_legacy_agents(store) + for record in await store.list_visible(get_effective_user_id()): + created_at = str(record.get("created_at") or datetime.now(UTC).isoformat()) + updated_at = str(record.get("updated_at") or created_at) + assistants.append( + AssistantResponse( + assistant_id=record["id"], + graph_id="lead_agent", # All agents use the same graph + name=record["name"], + config={}, + metadata={ + "created_by": "system" if record.get("user_id") is None else "user", + "agent_id": record["id"], + "published": bool(record.get("published", False)), + }, + description=record.get("description") or "", + created_at=created_at, + updated_at=updated_at, + version=1, + ) + ) + except Exception: + logger.debug("Could not load DB-backed custom agents for assistants list") + assistants.extend(_legacy_assistants()) return assistants @router.post("/search", response_model=list[AssistantResponse]) -async def search_assistants(body: AssistantSearchRequest | None = None) -> list[AssistantResponse]: +async def search_assistants(request: Request, body: AssistantSearchRequest | None = None) -> list[AssistantResponse]: """Search assistants. Returns all registered assistants (lead_agent + custom agents from config). """ - assistants = _list_assistants() + assistants = await _list_assistants(request) if body and body.graph_id: assistants = [a for a in assistants if a.graph_id == body.graph_id] @@ -104,22 +154,22 @@ async def search_assistants(body: AssistantSearchRequest | None = None) -> list[ @router.get("/{assistant_id}", response_model=AssistantResponse) -async def get_assistant_compat(assistant_id: str) -> AssistantResponse: +async def get_assistant_compat(assistant_id: str, request: Request) -> AssistantResponse: """Get an assistant by ID.""" - for a in _list_assistants(): + for a in await _list_assistants(request): if a.assistant_id == assistant_id: return a raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found") @router.get("/{assistant_id}/graph") -async def get_assistant_graph(assistant_id: str) -> dict: +async def get_assistant_graph(assistant_id: str, request: Request) -> dict: """Get the graph structure for an assistant. Returns a minimal graph description. Full graph introspection is not supported in the Gateway — this stub satisfies SDK validation. """ - found = any(a.assistant_id == assistant_id for a in _list_assistants()) + found = any(a.assistant_id == assistant_id for a in await _list_assistants(request)) if not found: raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found") @@ -131,12 +181,12 @@ async def get_assistant_graph(assistant_id: str) -> dict: @router.get("/{assistant_id}/schemas") -async def get_assistant_schemas(assistant_id: str) -> dict: +async def get_assistant_schemas(assistant_id: str, request: Request) -> dict: """Get JSON schemas for an assistant's input/output/state. Returns empty schemas — full introspection not supported in Gateway. """ - found = any(a.assistant_id == assistant_id for a in _list_assistants()) + found = any(a.assistant_id == assistant_id for a in await _list_assistants(request)) if not found: raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found") diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 634b8b9d..2e76aba0 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -106,6 +106,7 @@ _DEFAULT_ASSISTANT_ID = "lead_agent" # ``configurable`` for consumers like ``setup_agent``. _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset( { + "agent_id", "model_name", "mode", "thinking_enabled", @@ -139,11 +140,11 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An def resolve_agent_factory(assistant_id: str | None): """Resolve the agent factory callable from config. - Custom agents are implemented as ``lead_agent`` + an ``agent_name`` + Custom agents are implemented as ``lead_agent`` + an ``agent_id`` injected into ``configurable`` or ``context`` — see :func:`build_run_config`. All ``assistant_id`` values therefore map to the same factory; the routing happens inside ``make_lead_agent`` when it reads - ``cfg["agent_name"]``. + ``cfg["agent_id"]``. """ from deerflow.agents.lead_agent.agent import make_lead_agent @@ -160,11 +161,11 @@ def build_run_config( """Build a RunnableConfig dict for the agent. When *assistant_id* refers to a custom agent (anything other than - ``"lead_agent"`` / ``None``), the name is forwarded as ``agent_name`` in + ``"lead_agent"`` / ``None``), the id is forwarded as ``agent_id`` in whichever runtime options container is active: ``context`` for LangGraph >= 0.6.0 requests, otherwise ``configurable``. ``make_lead_agent`` reads this key to load the matching - ``agents//SOUL.md`` and per-agent config — without it the agent + ``agents//SOUL.md`` and per-agent config — without it the agent silently runs as the default lead agent. This mirrors the channel manager's ``_resolve_run_params`` logic so that @@ -202,25 +203,96 @@ def build_run_config( else: config["configurable"] = {"thread_id": thread_id} - # Inject custom agent name when the caller specified a non-default assistant. - # Honour an explicit agent_name in the active runtime options container. + # Inject custom agent id when the caller specified a non-default assistant. + # Honour an explicit agent_id in the active runtime options container. if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID: - normalized = assistant_id.strip().lower().replace("_", "-") - if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): - raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") + normalized = assistant_id.strip() + if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized): + raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, hyphens, and underscores.") if "configurable" in config: target = config["configurable"] elif "context" in config: target = config["context"] else: target = config.setdefault("configurable", {}) - if target is not None and "agent_name" not in target: - target["agent_name"] = normalized + if target is not None and "agent_id" not in target: + target["agent_id"] = normalized if metadata: config.setdefault("metadata", {}).update(metadata) return config +def _collect_requested_agent_ids(body: Any) -> set[str]: + """Collect agent ids from assistant_id/config/context for access checks.""" + agent_ids: set[str] = set() + if body.assistant_id and body.assistant_id != _DEFAULT_ASSISTANT_ID: + agent_ids.add(str(body.assistant_id)) + + def collect(container: Mapping[str, Any] | None) -> None: + if not container: + return + for key in ("agent_id", "agent_name"): + value = container.get(key) + if value: + agent_ids.add(str(value)) + + context = getattr(body, "context", None) + if isinstance(context, Mapping): + collect(context) + + config = body.config or {} + if isinstance(config, Mapping): + nested_context = config.get("context") + nested_configurable = config.get("configurable") + if isinstance(nested_context, Mapping): + collect(nested_context) + if isinstance(nested_configurable, Mapping): + collect(nested_configurable) + + return agent_ids + + +async def _sync_legacy_agent_records(store) -> None: + """Import existing folder-backed agents as built-in records.""" + from deerflow.config.agents_config import list_custom_agents + + for agent_cfg in list_custom_agents(): + agent_id = agent_cfg.id or agent_cfg.name + if not agent_id: + continue + await store.ensure_builtin( + { + "id": agent_id, + "name": agent_cfg.name, + "description": agent_cfg.description or "", + "published": True, + } + ) + + +async def _enforce_agent_access(body: Any, request: Request) -> None: + agent_ids = _collect_requested_agent_ids(body) + if not agent_ids: + return + + from app.gateway.deps import get_agent_store + from deerflow.config.agents_config import validate_agent_id + from deerflow.runtime.user_context import get_effective_user_id + + store = get_agent_store(request) + await _sync_legacy_agent_records(store) + user_id = get_effective_user_id() + for agent_id in agent_ids: + try: + normalized = validate_agent_id(agent_id) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if normalized is None: + continue + if await store.get_visible(normalized, user_id) is None: + raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") + + # --------------------------------------------------------------------------- # Run lifecycle # --------------------------------------------------------------------------- @@ -249,6 +321,8 @@ async def start_run( disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ + await _enforce_agent_access(body, request) + try: record = await run_mgr.create_or_reject( thread_id, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index f08e62bb..8f057a35 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -17,7 +17,7 @@ from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddlew from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware from deerflow.agents.thread_state import ThreadState -from deerflow.config.agents_config import load_agent_config, validate_agent_name +from deerflow.config.agents_config import load_agent_config, validate_agent_id from deerflow.config.app_config import AppConfig, get_app_config from deerflow.models import create_chat_model @@ -330,9 +330,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): subagent_enabled = cfg.get("subagent_enabled", False) max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) is_bootstrap = cfg.get("is_bootstrap", False) - agent_name = validate_agent_name(cfg.get("agent_name")) + agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) - agent_config = load_agent_config(agent_name) if not is_bootstrap else None + agent_config = load_agent_config(agent_id) if not is_bootstrap else None + agent_display_name = agent_config.name if agent_config else agent_id # Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default agent_model_name = agent_config.model if agent_config and agent_config.model else None @@ -349,7 +350,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): logger.info( "Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s", - agent_name or "default", + agent_id or "default", thinking_enabled, reasoning_effort, model_name, @@ -373,7 +374,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): config["metadata"].update( { - "agent_name": agent_name or "default", + "agent_id": agent_id, + "agent_name": agent_display_name or "default", "model_name": model_name or "default", "thinking_enabled": thinking_enabled, "reasoning_effort": reasoning_effort, @@ -415,11 +417,12 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): is_scheduled_run=cfg.get("is_scheduled_run", False), app_config=resolved_app_config, ), - middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config), + middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config), system_prompt=apply_prompt_template( subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, - agent_name=agent_name, + agent_id=agent_id, + agent_name=agent_display_name, available_skills=(set(agent_config.skills or []) if agent_config else None), app_config=resolved_app_config, ), diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 61369f95..2f0aedb5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -696,9 +696,9 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c return _get_cached_skills_prompt_section(skill_signature, available_key, container_base_path, skill_evolution_section) -def get_agent_soul(agent_name: str | None) -> str: +def get_agent_soul(agent_id: str | None) -> str: # Append SOUL.md (agent personality) if present - soul = load_agent_soul(agent_name) + soul = load_agent_soul(agent_id) if soul: return f"\n{soul}\n\n" if soul else "" return "" @@ -789,12 +789,15 @@ def apply_prompt_template( subagent_enabled: bool = False, max_concurrent_subagents: int = 3, *, + agent_id: str | None = None, agent_name: str | None = None, available_skills: set[str] | None = None, app_config: AppConfig | None = None, ) -> str: + storage_agent_id = agent_id if agent_id is not None else agent_name + # Get memory context - memory_context = _get_memory_context(agent_name, app_config=app_config) + memory_context = _get_memory_context(storage_agent_id, app_config=app_config) # Include subagent section only if enabled (from runtime parameter) n = max_concurrent_subagents @@ -834,8 +837,8 @@ def apply_prompt_template( # Format the prompt with dynamic skills and memory prompt = SYSTEM_PROMPT_TEMPLATE.format( - agent_name=agent_name or "DeerFlow 2.0", - soul=get_agent_soul(agent_name), + agent_name=agent_name or storage_agent_id or "DeerFlow 2.0", + soul=get_agent_soul(storage_agent_id), skills_section=skills_section, deferred_tools_section=deferred_tools_section, memory_context=memory_context, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py index b256b291..1d40b084 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py @@ -12,23 +12,33 @@ from deerflow.config.paths import get_paths logger = logging.getLogger(__name__) SOUL_FILENAME = "SOUL.md" -AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") +AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") +# Backward-compatible alias. Historically this validated the display name; +# after the DB-backed agent table, display names can be Chinese and duplicated. +# The pattern now applies only to storage/runtime ids. +AGENT_NAME_PATTERN = AGENT_ID_PATTERN + + +def validate_agent_id(agent_id: str | None) -> str | None: + """Validate a custom agent id before using it in filesystem paths.""" + if agent_id is None: + return None + if not isinstance(agent_id, str): + raise ValueError("Invalid agent id. Expected a string or None.") + if not AGENT_ID_PATTERN.fullmatch(agent_id): + raise ValueError(f"Invalid agent id '{agent_id}'. Must match pattern: {AGENT_ID_PATTERN.pattern}") + return agent_id def validate_agent_name(name: str | None) -> str | None: - """Validate a custom agent name before using it in filesystem paths.""" - if name is None: - return None - if not isinstance(name, str): - raise ValueError("Invalid agent name. Expected a string or None.") - if not AGENT_NAME_PATTERN.fullmatch(name): - raise ValueError(f"Invalid agent name '{name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}") - return name + """Backward-compatible alias for validating a storage/runtime agent id.""" + return validate_agent_id(name) class AgentConfig(BaseModel): """Configuration for a custom agent.""" + id: str | None = None name: str description: str = "" model: str | None = None @@ -40,11 +50,11 @@ class AgentConfig(BaseModel): skills: list[str] | None = None -def load_agent_config(name: str | None) -> AgentConfig | None: +def load_agent_config(agent_id: str | None) -> AgentConfig | None: """Load the custom or default agent's config from its directory. Args: - name: The agent name. + agent_id: The agent storage/runtime id. Returns: AgentConfig instance. @@ -54,28 +64,32 @@ def load_agent_config(name: str | None) -> AgentConfig | None: ValueError: If config.yaml cannot be parsed. """ - if name is None: + if agent_id is None: return None - name = validate_agent_name(name) - agent_dir = get_paths().agent_dir(name) + agent_id = validate_agent_id(agent_id) + agent_dir = get_paths().agent_dir(agent_id) config_file = agent_dir / "config.yaml" if not agent_dir.exists(): raise FileNotFoundError(f"Agent directory not found: {agent_dir}") - if not config_file.exists(): + if not config_file.exists() and not (agent_dir / SOUL_FILENAME).exists(): raise FileNotFoundError(f"Agent config not found: {config_file}") - try: - with open(config_file, encoding="utf-8") as f: - data: dict[str, Any] = yaml.safe_load(f) or {} - except yaml.YAMLError as e: - raise ValueError(f"Failed to parse agent config {config_file}: {e}") from e + data: dict[str, Any] = {} + if config_file.exists(): + try: + with open(config_file, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + except yaml.YAMLError as e: + raise ValueError(f"Failed to parse agent config {config_file}: {e}") from e - # Ensure name is set from directory name if not in file + # Ensure id/name are set from directory id if not in file. + if "id" not in data: + data["id"] = agent_id if "name" not in data: - data["name"] = name + data["name"] = agent_id # Strip unknown fields before passing to Pydantic (e.g. legacy prompt_file) known_fields = set(AgentConfig.model_fields.keys()) @@ -84,19 +98,19 @@ def load_agent_config(name: str | None) -> AgentConfig | None: return AgentConfig(**data) -def load_agent_soul(agent_name: str | None) -> str | None: +def load_agent_soul(agent_id: str | None) -> str | None: """Read the SOUL.md file for a custom agent, if it exists. SOUL.md defines the agent's personality, values, and behavioral guardrails. It is injected into the lead agent's system prompt as additional context. Args: - agent_name: The name of the agent or None for the default agent. + agent_id: The storage/runtime id of the agent or None for the default agent. Returns: The SOUL.md content as a string, or None if the file does not exist. """ - agent_dir = get_paths().agent_dir(agent_name) if agent_name else get_paths().base_dir + agent_dir = get_paths().agent_dir(validate_agent_id(agent_id)) if agent_id else get_paths().base_dir soul_path = agent_dir / SOUL_FILENAME if not soul_path.exists(): return None @@ -122,8 +136,9 @@ def list_custom_agents() -> list[AgentConfig]: continue config_file = entry / "config.yaml" - if not config_file.exists(): - logger.debug(f"Skipping {entry.name}: no config.yaml") + soul_file = entry / SOUL_FILENAME + if not config_file.exists() and not soul_file.exists(): + logger.debug(f"Skipping {entry.name}: no config.yaml or SOUL.md") continue try: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/paths.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/paths.py index 9fa633f5..98f58b79 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/paths.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/paths.py @@ -68,7 +68,7 @@ class Paths: ├── memory.json ├── USER.md <-- global user profile (injected into all agents) ├── agents/ - │ └── {agent_name}/ + │ └── {agent_id}/ │ ├── config.yaml │ ├── SOUL.md <-- agent personality/identity (injected alongside lead prompt) │ └── memory.json @@ -136,11 +136,11 @@ class Paths: return self.base_dir / "agents" def agent_dir(self, name: str) -> Path: - """Directory for a specific agent: `{base_dir}/agents/{name}/`.""" + """Directory for a specific agent id: `{base_dir}/agents/{id}/`.""" return self.agents_dir / name.lower() def agent_memory_file(self, name: str) -> Path: - """Per-agent memory file: `{base_dir}/agents/{name}/memory.json`.""" + """Per-agent memory file: `{base_dir}/agents/{id}/memory.json`.""" return self.agent_dir(name) / "memory.json" def user_dir(self, user_id: str) -> Path: @@ -152,7 +152,7 @@ class Paths: return self.user_dir(user_id) / "memory.json" def user_agent_memory_file(self, user_id: str, agent_name: str) -> Path: - """Per-user per-agent memory: `{base_dir}/users/{user_id}/agents/{name}/memory.json`.""" + """Per-user per-agent memory: `{base_dir}/users/{user_id}/agents/{id}/memory.json`.""" return self.user_dir(user_id) / "agents" / agent_name.lower() / "memory.json" def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/__init__.py new file mode 100644 index 00000000..fab44fcd --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/__init__.py @@ -0,0 +1,22 @@ +"""Custom agent persistence.""" + +from __future__ import annotations + +from deerflow.persistence.agents.base import AgentStore +from deerflow.persistence.agents.memory import MemoryAgentStore +from deerflow.persistence.agents.model import AgentRow +from deerflow.persistence.agents.sql import AgentRepository + +__all__ = [ + "AgentRepository", + "AgentRow", + "AgentStore", + "MemoryAgentStore", + "make_agent_store", +] + + +def make_agent_store(session_factory): + if session_factory is None: + return MemoryAgentStore() + return AgentRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py new file mode 100644 index 00000000..f39e7600 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py @@ -0,0 +1,40 @@ +"""Abstract persistence API for custom agents.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class AgentStore(ABC): + @abstractmethod + async def list_visible(self, user_id: str) -> list[dict[str, Any]]: + raise NotImplementedError + + @abstractmethod + async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any] | None: + raise NotImplementedError + + @abstractmethod + async def get_any(self, agent_id: str) -> dict[str, Any] | None: + raise NotImplementedError + + @abstractmethod + async def get_owned(self, agent_id: str, user_id: str) -> dict[str, Any] | None: + raise NotImplementedError + + @abstractmethod + async def create(self, data: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def ensure_builtin(self, data: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None: + raise NotImplementedError + + @abstractmethod + async def delete(self, agent_id: str, user_id: str) -> bool: + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py new file mode 100644 index 00000000..9f043b1e --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -0,0 +1,99 @@ +"""In-memory custom agent store for memory persistence mode.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from deerflow.persistence.agents.base import AgentStore + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _copy(data: dict[str, Any]) -> dict[str, Any]: + copied = dict(data) + copied["builtin"] = copied.get("user_id") is None + return copied + + +class MemoryAgentStore(AgentStore): + def __init__(self) -> None: + self._agents: dict[str, dict[str, Any]] = {} + + @staticmethod + def _is_visible(agent: dict[str, Any], user_id: str) -> bool: + return agent.get("user_id") is None or agent.get("user_id") == user_id or bool(agent.get("published")) + + async def list_visible(self, user_id: str) -> list[dict[str, Any]]: + rows = [_copy(agent) for agent in self._agents.values() if self._is_visible(agent, user_id)] + rows.sort(key=lambda agent: agent.get("created_at") or "") + return rows + + async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any] | None: + agent = self._agents.get(agent_id) + if agent is None or not self._is_visible(agent, user_id): + return None + return _copy(agent) + + async def get_any(self, agent_id: str) -> dict[str, Any] | None: + agent = self._agents.get(agent_id) + return _copy(agent) if agent is not None else None + + async def get_owned(self, agent_id: str, user_id: str) -> dict[str, Any] | None: + agent = self._agents.get(agent_id) + if agent is None or agent.get("user_id") != user_id: + return None + return _copy(agent) + + async def create(self, data: dict[str, Any]) -> dict[str, Any]: + now = _now() + row = { + "id": data["id"], + "name": data["name"], + "description": data.get("description") or "", + "user_id": data.get("user_id"), + "published": bool(data.get("published", False)), + "created_at": now, + "updated_at": now, + } + self._agents[row["id"]] = row + return _copy(row) + + async def ensure_builtin(self, data: dict[str, Any]) -> dict[str, Any]: + row = self._agents.get(data["id"]) + if row is None: + row = { + "id": data["id"], + "name": data["name"], + "description": data.get("description") or "", + "user_id": None, + "published": bool(data.get("published", True)), + "created_at": _now(), + "updated_at": _now(), + } + self._agents[row["id"]] = row + elif row.get("user_id") is None: + row["name"] = data["name"] + row["description"] = data.get("description") or "" + row["published"] = bool(data.get("published", True)) + row["updated_at"] = _now() + return _copy(row) + + async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None: + row = self._agents.get(agent_id) + if row is None or row.get("user_id") != user_id: + return None + for key in ("name", "description", "published"): + if key in data: + row[key] = data[key] + row["updated_at"] = _now() + return _copy(row) + + async def delete(self, agent_id: str, user_id: str) -> bool: + row = self._agents.get(agent_id) + if row is None or row.get("user_id") != user_id: + return False + self._agents.pop(agent_id, None) + return True diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py new file mode 100644 index 00000000..058886dd --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py @@ -0,0 +1,24 @@ +"""ORM model for custom agents.""" + +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 AgentRow(Base): + __tablename__ = "agents" + + # Stored as a plain string so the same id can be used as the filesystem + # directory name under {base_dir}/agents/{id}/. + id: Mapped[str] = mapped_column(String(64), primary_key=True) + name: Mapped[str] = mapped_column(String(256), nullable=False, index=True) + description: Mapped[str] = mapped_column(String(1024), nullable=False, default="") + 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)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py new file mode 100644 index 00000000..a01b5043 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -0,0 +1,117 @@ +"""SQLAlchemy-backed custom agent repository.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.agents.base import AgentStore +from deerflow.persistence.agents.model import AgentRow + + +def _row_to_dict(row: AgentRow) -> dict[str, Any]: + data = row.to_dict() + data["builtin"] = data.get("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 AgentRepository(AgentStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + @staticmethod + def _visible_filter(user_id: str): + return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True)) + + async def list_visible(self, user_id: str) -> list[dict[str, Any]]: + stmt = select(AgentRow).where(self._visible_filter(user_id)).order_by(AgentRow.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, agent_id: str, user_id: str) -> dict[str, Any] | None: + stmt = select(AgentRow).where(AgentRow.id == agent_id, 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, agent_id: str) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(AgentRow, agent_id) + return _row_to_dict(row) if row is not None else None + + async def get_owned(self, agent_id: str, user_id: str) -> dict[str, Any] | None: + stmt = select(AgentRow).where(AgentRow.id == agent_id, AgentRow.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 = AgentRow( + id=data["id"], + name=data["name"], + description=data.get("description") or "", + user_id=data.get("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_builtin(self, data: dict[str, Any]) -> dict[str, Any]: + async with self._sf() as session: + row = await session.get(AgentRow, data["id"]) + if row is None: + now = datetime.now(UTC) + row = AgentRow( + id=data["id"], + name=data["name"], + description=data.get("description") or "", + user_id=None, + published=bool(data.get("published", True)), + created_at=now, + updated_at=now, + ) + session.add(row) + elif row.user_id is None: + row.name = data["name"] + row.description = data.get("description") or "" + row.published = bool(data.get("published", True)) + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return _row_to_dict(row) + + async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(AgentRow, agent_id) + if row is None or row.user_id != user_id: + return None + for key in ("name", "description", "published"): + if key in data: + setattr(row, key, data[key]) + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return _row_to_dict(row) + + async def delete(self, agent_id: str, user_id: str) -> bool: + async with self._sf() as session: + row = await session.get(AgentRow, agent_id) + if row is None or row.user_id != user_id: + return False + await session.delete(row) + await session.commit() + return True diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260513_01_agents_table.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260513_01_agents_table.py new file mode 100644 index 00000000..d61bd254 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260513_01_agents_table.py @@ -0,0 +1,47 @@ +"""Create agents table. + +Revision ID: 20260513_01 +Revises: 20260512_02 +Create Date: 2026-05-13 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260513_01" +down_revision = "20260512_02" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + if _has_table("agents"): + return + + op.create_table( + "agents", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("description", sa.String(1024), nullable=False, server_default=""), + sa.Column("user_id", sa.String(64), nullable=True), + sa.Column("published", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_agents_name", "agents", ["name"]) + op.create_index("ix_agents_user_id", "agents", ["user_id"]) + op.create_index("ix_agents_published", "agents", ["published"]) + + +def downgrade() -> None: + if _has_table("agents"): + op.drop_table("agents") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 0ff2e7d6..0839465d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -8,12 +8,14 @@ The actual ORM classes have moved to entity-specific subpackages: - ``deerflow.persistence.run`` - ``deerflow.persistence.feedback`` - ``deerflow.persistence.user`` +- ``deerflow.persistence.agents`` ``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because its storage implementation lives in ``deerflow.runtime.events.store.db`` and 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.run.model import RunRow @@ -23,6 +25,7 @@ from deerflow.persistence.user.model import UserRow __all__ = [ "FeedbackRow", + "AgentRow", "RunEventRow", "RunRow", "ScheduledTaskDeliveryProfileRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py index 4ecfc639..3fa46184 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py @@ -6,7 +6,7 @@ from langchain_core.tools import tool from langgraph.prebuilt import ToolRuntime from langgraph.types import Command -from deerflow.config.agents_config import validate_agent_name +from deerflow.config.agents_config import validate_agent_id from deerflow.config.paths import get_paths logger = logging.getLogger(__name__) @@ -27,12 +27,12 @@ def setup_agent( skills: Optional list of skill names this agent should use. None or empty means no skills. """ - agent_name: str | None = runtime.context.get("agent_name") if runtime.context else None + agent_name: str | None = (runtime.context.get("agent_id") or runtime.context.get("agent_name")) if runtime.context else None agent_dir = None is_new_dir = False try: - agent_name = validate_agent_name(agent_name) + agent_name = validate_agent_id(agent_name) paths = get_paths() agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir is_new_dir = not agent_dir.exists() @@ -40,7 +40,7 @@ def setup_agent( if agent_name: # If agent_name is provided, we are creating a custom agent in the agents/ directory - config_data: dict = {"name": agent_name} + config_data: dict = {"id": agent_name, "name": agent_name} if description: config_data["description"] = description if skills is not None: diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py new file mode 100644 index 00000000..6aa4b885 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import pytest +import yaml + +from app.gateway.routers.agents import _load_config_extras, _write_agent_config +from app.gateway.services import build_run_config +from deerflow.config import paths as paths_module +from deerflow.persistence.agents import MemoryAgentStore + + +@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.mark.asyncio +async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None: + store = MemoryAgentStore() + chinese_name = "\u4e2d\u6587\u52a9\u624b" + + await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"}) + await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"}) + await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True}) + + user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")} + assert user_a_ids == {"builtin-agent", "private-a", "published-b"} + + user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")} + assert user_c_ids == {"builtin-agent", "published-b"} + + +@pytest.mark.asyncio +async def test_agent_store_only_owner_can_update_or_delete() -> None: + store = MemoryAgentStore() + + await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"}) + await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"}) + + assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None + assert await store.update("private-a", "user-b", {"name": "Nope"}) is None + + updated = await store.update("private-a", "user-a", {"name": "Renamed"}) + assert updated is not None + assert updated["name"] == "Renamed" + + assert await store.delete("private-a", "user-b") is False + assert await store.delete("private-a", "user-a") is True + + +def test_build_run_config_injects_custom_assistant_as_agent_id() -> None: + config = build_run_config("thread-1", None, None, assistant_id="agent-123") + + assert config["configurable"]["thread_id"] == "thread-1" + assert config["configurable"]["agent_id"] == "agent-123" + assert "agent_name" not in config["configurable"] + + +def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None: + agent_id = "agent-001" + _write_agent_config( + agent_id, + name="中文助手", + description="desc", + skills=["s1", "s2"], + model="gpt-x", + tool_groups=["tg-a"], + ) + + config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml" + raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) + + assert raw["id"] == agent_id + assert raw["name"] == "中文助手" + assert raw["description"] == "desc" + assert raw["skills"] == ["s1", "s2"] + assert raw["model"] == "gpt-x" + assert raw["tool_groups"] == ["tg-a"] + + +def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None: + agent_id = "agent-002" + _write_agent_config( + agent_id, + name="agent-002", + description="", + skills=["s1"], + model=None, + tool_groups=None, + ) + + extras = _load_config_extras(agent_id) + assert extras == {"skills": ["s1"], "model": None, "tool_groups": None} + + +def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None: + extras = _load_config_extras("does-not-exist") + assert extras == {"skills": None, "model": None, "tool_groups": None} + + +def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None: + agent_id = "agent-003" + _write_agent_config(agent_id, name="x", description="", skills=[]) + + extras = _load_config_extras(agent_id) + assert extras["skills"] == [] + + +def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None: + agent_id = "agent-004" + _write_agent_config(agent_id, name="x", description="") + + config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml" + raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) + + assert "skills" not in raw + assert "model" not in raw + assert "tool_groups" not in raw
- 不选任何技能会保存为无技能(skills = [])。 + 不选任何技能会保存为无技能(skills = [])。
公开发布
+ 打开后,该智能体对所有用户可见。 +