智能体分权

This commit is contained in:
吉鑫升 committed 2026-05-13 11:36:17 +08:00
1 parent e7a455f22e
commit cf2e4bdd9c
31 files changed
+1237 -436

No files matched your search

+2 -2
View File
@@ -134,11 +134,11 @@ export function App() {
}
/>
<Route
path="/workspace/agents/:agent_name/chats"
path="/workspace/agents/:agent_id/chats"
element={<Navigate to="/workspace/agents" replace />}
/>
<Route
path="/workspace/agents/:agent_name/chats/:thread_id"
path="/workspace/agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
@@ -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<string[]>(
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) {
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
我的
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
</Badge>
)}
{isOwned && agent.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title="该智能体已对所有用户可见"
>
已发布
</Badge>
)}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
@@ -157,41 +235,61 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
{isOwned && (
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<EyeOffIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
)}
</CardFooter>
</Card>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
<DialogDescription>
可以修改描述、技能白名单和 SOUL 内容。
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input value={agent.name} disabled />
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="输入智能体名称(允许中文)"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
@@ -238,7 +336,7 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</div>
<p className="text-muted-foreground text-xs">
不选任何技能会保存为无技能(skills = [])。
不选任何技能会保存为无技能(skills = [])。
</p>
</div>
<div className="space-y-2">
@@ -250,6 +348,18 @@ export function AgentCard({ agent }: AgentCardProps) {
className="min-h-40 font-mono text-xs"
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体对所有用户可见。
</p>
</div>
<Switch
checked={editPublished}
onCheckedChange={setEditPublished}
/>
</div>
</div>
<DialogFooter>
<Button
@@ -59,7 +59,7 @@ export function AgentGallery() {
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.name} agent={agent} />
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
@@ -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(
+7 -7
View File
@@ -22,9 +22,9 @@ export async function listAgents(): Promise<Agent[]> {
return data.agents;
}
export async function getAgent(name: string): Promise<Agent> {
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<Agent> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
if (!res.ok) throw new Error(`Agent '${id}' not found`);
return res.json() as Promise<Agent>;
}
@@ -42,10 +42,10 @@ export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
}
export async function updateAgent(
name: string,
id: string,
request: UpdateAgentRequest,
): Promise<Agent> {
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<Agent>;
}
export async function deleteAgent(name: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${name}`, {
export async function deleteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
+10 -10
View File
@@ -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"] });
},
+16 -7
View File
@@ -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;
}
+6 -3
View File
@@ -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<AgentThreadState>({
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(() => ({}));
}
+2
View File
@@ -16,6 +16,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
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;
}
+23 -16
View File
@@ -2,34 +2,41 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
type ThreadRouteTarget =
| string
| {
thread_id: string;
context?: Pick<AgentThreadContext, "agent_name"> | null;
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
};
function pickAgentRef(
context: ThreadAgentRef | null | undefined,
metadata: Record<string, unknown> | 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<AgentThreadContext, "agent_name"> | 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}`;
}
+11 -10
View File
@@ -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() {
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_name}
{agent?.name ?? agent_id}
</span>
</div>
@@ -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`);
}}
>
<PlusSquare /> {t.agents.newChat}
@@ -199,7 +200,7 @@ export default function AgentChatPage() {
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent_name} />
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
+21 -23
View File
@@ -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<Agent | null>(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() {
<Button
onClick={() =>
router.push(
`/workspace/agents/${agentName}/chats/new`,
`/workspace/agents/${agentId}/chats/new`,
)
}
>
@@ -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`
@@ -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.
@@ -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.
@@ -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": "<normalized>"}``
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
@@ -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")
@@ -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/<name>/SOUL.md`` and per-agent config — without it the agent
``agents/<id>/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,
@@ -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,
),
@@ -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"<soul>\n{soul}\n</soul>\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,
@@ -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:
@@ -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:
@@ -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)
@@ -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
@@ -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
@@ -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))
@@ -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
@@ -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")
@@ -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",
@@ -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:
@@ -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