智能体分权
This commit is contained in:
1 parent
e7a455f22e
commit
cf2e4bdd9c
31 files changed
+1237
-436
No files matched your search
@@ -134,11 +134,11 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/workspace/agents/:agent_name/chats"
|
path="/workspace/agents/:agent_id/chats"
|
||||||
element={<Navigate to="/workspace/agents" replace />}
|
element={<Navigate to="/workspace/agents" replace />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/workspace/agents/:agent_name/chats/:thread_id"
|
path="/workspace/agents/:agent_id/chats/:thread_id"
|
||||||
element={
|
element={
|
||||||
<WorkspaceLayout>
|
<WorkspaceLayout>
|
||||||
<ChatRuntime>
|
<ChatRuntime>
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -24,9 +31,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
|
|
||||||
@@ -42,28 +51,45 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editName, setEditName] = useState(agent.name);
|
||||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||||
|
const [editPublished, setEditPublished] = useState(agent.published);
|
||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||||
agent.skills ?? [],
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editOpen) {
|
if (!editOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setEditName(agent.name);
|
||||||
setEditDescription(agent.description ?? "");
|
setEditDescription(agent.description ?? "");
|
||||||
setEditSoul(agent.soul ?? "");
|
setEditSoul(agent.soul ?? "");
|
||||||
|
setEditPublished(agent.published);
|
||||||
setSelectedSkills(agent.skills ?? []);
|
setSelectedSkills(agent.skills ?? []);
|
||||||
}, [agent.description, agent.skills, agent.soul, editOpen]);
|
}, [
|
||||||
|
agent.description,
|
||||||
|
agent.name,
|
||||||
|
agent.published,
|
||||||
|
agent.skills,
|
||||||
|
agent.soul,
|
||||||
|
editOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
function handleChat() {
|
function handleChat() {
|
||||||
router.push(`/workspace/agents/${agent.name}/chats/new`);
|
router.push(`/workspace/agents/${agent.id}/chats/new`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
try {
|
try {
|
||||||
await deleteAgent.mutateAsync(agent.name);
|
await deleteAgent.mutateAsync(agent.id);
|
||||||
toast.success(t.agents.deleteSuccess);
|
toast.success(t.agents.deleteSuccess);
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -72,12 +98,19 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveEdit() {
|
async function handleSaveEdit() {
|
||||||
|
const trimmedName = editName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateAgent.mutateAsync({
|
await updateAgent.mutateAsync({
|
||||||
name: agent.name,
|
id: agent.id,
|
||||||
request: {
|
request: {
|
||||||
|
name: trimmedName,
|
||||||
description: editDescription,
|
description: editDescription,
|
||||||
soul: editSoul,
|
soul: editSoul,
|
||||||
|
published: editPublished,
|
||||||
skills: selectedSkills,
|
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) {
|
function toggleSkill(skillName: string) {
|
||||||
setSelectedSkills((prev) => {
|
setSelectedSkills((prev) => {
|
||||||
if (prev.includes(skillName)) {
|
if (prev.includes(skillName)) {
|
||||||
@@ -119,6 +164,39 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{agent.description && (
|
{agent.description && (
|
||||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
<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" />
|
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{t.agents.chat}
|
{t.agents.chat}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-1">
|
{isOwned && (
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
size="icon"
|
<Button
|
||||||
variant="ghost"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
variant="ghost"
|
||||||
onClick={() => setEditOpen(true)}
|
className="h-8 w-8 shrink-0"
|
||||||
title="编辑智能体"
|
onClick={() => void handleTogglePublished()}
|
||||||
>
|
disabled={updateAgent.isPending}
|
||||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
title={agent.published ? "取消发布" : "发布"}
|
||||||
</Button>
|
>
|
||||||
<Button
|
{agent.published ? (
|
||||||
size="icon"
|
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||||
variant="ghost"
|
) : (
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
<EyeIcon className="h-3.5 w-3.5" />
|
||||||
onClick={() => setDeleteOpen(true)}
|
)}
|
||||||
title={t.agents.delete}
|
</Button>
|
||||||
>
|
<Button
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
size="icon"
|
||||||
</Button>
|
variant="ghost"
|
||||||
</div>
|
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>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
|
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
可以修改描述、技能白名单和 SOUL 内容。
|
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">名称</label>
|
<label className="text-sm font-medium">名称</label>
|
||||||
<Input value={agent.name} disabled />
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
placeholder="输入智能体名称(允许中文)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">描述</label>
|
<label className="text-sm font-medium">描述</label>
|
||||||
@@ -238,7 +336,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
不选任何技能会保存为无技能(skills = [])。
|
不选任何技能会保存为无技能(skills = [])。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -250,6 +348,18 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
className="min-h-40 font-mono text-xs"
|
className="min-h-40 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<AgentCard key={agent.name} agent={agent} />
|
<AgentCard key={agent.id} agent={agent} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export function RecentChatList() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
|
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
|
||||||
useParams<{
|
useParams<{
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
agent_name?: string;
|
agent_id?: string;
|
||||||
}>();
|
}>();
|
||||||
const { data: threads = [] } = useThreads();
|
const { data: threads = [] } = useThreads();
|
||||||
const { mutate: deleteThread } = useDeleteThread();
|
const { mutate: deleteThread } = useDeleteThread();
|
||||||
@@ -82,7 +82,7 @@ export function RecentChatList() {
|
|||||||
if (threadId === threadIdFromPath) {
|
if (threadId === threadIdFromPath) {
|
||||||
const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
|
const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
|
||||||
let nextThreadPath = pathOfThread("new", {
|
let nextThreadPath = pathOfThread("new", {
|
||||||
agent_name: agentNameFromPath,
|
agent_id: agentIdFromPath,
|
||||||
});
|
});
|
||||||
if (threadIndex > -1) {
|
if (threadIndex > -1) {
|
||||||
if (threads[threadIndex + 1]) {
|
if (threads[threadIndex + 1]) {
|
||||||
@@ -94,7 +94,7 @@ export function RecentChatList() {
|
|||||||
void router.push(nextThreadPath);
|
void router.push(nextThreadPath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentNameFromPath, deleteThread, router, threadIdFromPath, threads],
|
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameClick = useCallback(
|
const handleRenameClick = useCallback(
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ export async function listAgents(): Promise<Agent[]> {
|
|||||||
return data.agents;
|
return data.agents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAgent(name: string): Promise<Agent> {
|
export async function getAgent(id: string): Promise<Agent> {
|
||||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${name}`);
|
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
||||||
if (!res.ok) throw new Error(`Agent '${name}' not found`);
|
if (!res.ok) throw new Error(`Agent '${id}' not found`);
|
||||||
return res.json() as Promise<Agent>;
|
return res.json() as Promise<Agent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +42,10 @@ export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAgent(
|
export async function updateAgent(
|
||||||
name: string,
|
id: string,
|
||||||
request: UpdateAgentRequest,
|
request: UpdateAgentRequest,
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
@@ -57,8 +57,8 @@ export async function updateAgent(
|
|||||||
return res.json() as Promise<Agent>;
|
return res.json() as Promise<Agent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAgent(name: string): Promise<void> {
|
export async function deleteAgent(id: string): Promise<void> {
|
||||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ export function useAgents() {
|
|||||||
return { agents: data ?? [], isLoading, error };
|
return { agents: data ?? [], isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgent(name: string | null | undefined) {
|
export function useAgent(id: string | null | undefined) {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["agents", name],
|
queryKey: ["agents", id],
|
||||||
queryFn: () => getAgent(name!),
|
queryFn: () => getAgent(id!),
|
||||||
enabled: !!name,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
return { agent: data ?? null, isLoading, error };
|
return { agent: data ?? null, isLoading, error };
|
||||||
}
|
}
|
||||||
@@ -40,15 +40,15 @@ export function useUpdateAgent() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
name,
|
id,
|
||||||
request,
|
request,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
id: string;
|
||||||
request: UpdateAgentRequest;
|
request: UpdateAgentRequest;
|
||||||
}) => updateAgent(name, request),
|
}) => updateAgent(id, request),
|
||||||
onSuccess: (_data, { name }) => {
|
onSuccess: (_data, { id }) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
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() {
|
export function useDeleteAgent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (name: string) => deleteAgent(name),
|
mutationFn: (id: string) => deleteAgent(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
export interface Agent {
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
model: string | null;
|
user_id: string | null;
|
||||||
tool_groups: string[] | null;
|
published: boolean;
|
||||||
skills: string[] | null;
|
builtin: boolean;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
soul?: string | null;
|
soul?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
tool_groups?: string[] | null;
|
||||||
|
skills?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAgentRequest {
|
export interface CreateAgentRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
soul?: string;
|
||||||
|
published?: boolean;
|
||||||
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
skills?: string[] | null;
|
|
||||||
soul?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAgentRequest {
|
export interface UpdateAgentRequest {
|
||||||
|
name?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
soul?: string | null;
|
||||||
|
published?: boolean | null;
|
||||||
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
skills?: string[] | null;
|
|
||||||
soul?: string | null;
|
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ export type ThreadStreamOptions = {
|
|||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
|
assistantId?: string;
|
||||||
onStart?: (threadId: string) => void;
|
onStart?: (threadId: string) => void;
|
||||||
onFinish?: (state: AgentThreadState) => void;
|
onFinish?: (state: AgentThreadState) => void;
|
||||||
onToolEnd?: (event: ToolEndEvent) => void;
|
onToolEnd?: (event: ToolEndEvent) => void;
|
||||||
@@ -139,6 +140,7 @@ export function useThreadStream({
|
|||||||
threadId,
|
threadId,
|
||||||
context,
|
context,
|
||||||
isMock,
|
isMock,
|
||||||
|
assistantId,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
@@ -204,7 +206,7 @@ export function useThreadStream({
|
|||||||
|
|
||||||
const thread = useStream<AgentThreadState>({
|
const thread = useStream<AgentThreadState>({
|
||||||
client: getAPIClient(isMock),
|
client: getAPIClient(isMock),
|
||||||
assistantId: "lead_agent",
|
assistantId: assistantId ?? "lead_agent",
|
||||||
threadId: onStreamThreadId,
|
threadId: onStreamThreadId,
|
||||||
reconnectOnMount: runMetadataStorageRef.current
|
reconnectOnMount: runMetadataStorageRef.current
|
||||||
? () => runMetadataStorageRef.current!
|
? () => runMetadataStorageRef.current!
|
||||||
@@ -213,10 +215,11 @@ export function useThreadStream({
|
|||||||
onCreated(meta) {
|
onCreated(meta) {
|
||||||
handleStreamStart(meta.thread_id);
|
handleStreamStart(meta.thread_id);
|
||||||
setOnStreamThreadId(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()
|
void getAPIClient()
|
||||||
.threads.update(meta.thread_id, {
|
.threads.update(meta.thread_id, {
|
||||||
metadata: { agent_name: context.agent_name },
|
metadata: { agent_id: metadataAgentId },
|
||||||
})
|
})
|
||||||
.catch(() => ({}));
|
.catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
is_plan_mode: boolean;
|
is_plan_mode: boolean;
|
||||||
subagent_enabled: boolean;
|
subagent_enabled: boolean;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
|
agent_id?: string;
|
||||||
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,34 +2,41 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
|
|
||||||
import type { AgentThread, AgentThreadContext } from "./types";
|
import type { AgentThread, AgentThreadContext } from "./types";
|
||||||
|
|
||||||
|
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
|
||||||
|
|
||||||
type ThreadRouteTarget =
|
type ThreadRouteTarget =
|
||||||
| string
|
| string
|
||||||
| {
|
| {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
context?: Pick<AgentThreadContext, "agent_name"> | null;
|
context?: ThreadAgentRef | null;
|
||||||
metadata?: Record<string, unknown> | 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(
|
export function pathOfThread(
|
||||||
thread: ThreadRouteTarget,
|
thread: ThreadRouteTarget,
|
||||||
context?: Pick<AgentThreadContext, "agent_name"> | null,
|
context?: ThreadAgentRef | null,
|
||||||
) {
|
) {
|
||||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||||
let agentName: string | undefined;
|
const agentRef =
|
||||||
if (typeof thread === "string") {
|
typeof thread === "string"
|
||||||
agentName = context?.agent_name;
|
? pickAgentRef(context, null)
|
||||||
} else {
|
: pickAgentRef(thread.context, thread.metadata);
|
||||||
agentName = thread.context?.agent_name;
|
|
||||||
if (!agentName) {
|
|
||||||
const metaAgent = thread.metadata?.agent_name;
|
|
||||||
if (typeof metaAgent === "string") {
|
|
||||||
agentName = metaAgent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return agentName
|
return agentRef
|
||||||
? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${threadId}`
|
? `/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||||
: `/workspace/chats/${threadId}`;
|
: `/workspace/chats/${threadId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ export default function AgentChatPage() {
|
|||||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { agent_name } = useParams<{
|
const { agent_id } = useParams<{
|
||||||
agent_name: string;
|
agent_id: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { agent } = useAgent(agent_name);
|
const { agent } = useAgent(agent_id);
|
||||||
|
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||||
useThreadChat();
|
useThreadChat();
|
||||||
@@ -57,7 +57,8 @@ export default function AgentChatPage() {
|
|||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
context: { ...settings.context, agent_name: agent_name },
|
assistantId: agent_id,
|
||||||
|
context: { ...settings.context, agent_id },
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
setThreadId(createdThreadId);
|
setThreadId(createdThreadId);
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
@@ -65,7 +66,7 @@ export default function AgentChatPage() {
|
|||||||
history.replaceState(
|
history.replaceState(
|
||||||
null,
|
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) => {
|
onFinish: (state) => {
|
||||||
@@ -92,9 +93,9 @@ export default function AgentChatPage() {
|
|||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
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 () => {
|
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">
|
<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" />
|
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
{agent?.name ?? agent_name}
|
{agent?.name ?? agent_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ export default function AgentChatPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/workspace/agents/${agent_name}/chats/new`);
|
router.push(`/workspace/agents/${agent_id}/chats/new`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusSquare /> {t.agents.newChat}
|
<PlusSquare /> {t.agents.newChat}
|
||||||
@@ -199,7 +200,7 @@ export default function AgentChatPage() {
|
|||||||
context={settings.context}
|
context={settings.context}
|
||||||
extraHeader={
|
extraHeader={
|
||||||
isLandingState && (
|
isLandingState && (
|
||||||
<AgentWelcome agent={agent} agentName={agent_name} />
|
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import { cn } from "@/lib/utils";
|
|||||||
type Step = "name" | "chat";
|
type Step = "name" | "chat";
|
||||||
type SetupAgentStatus = "idle" | "requested" | "completed";
|
type SetupAgentStatus = "idle" | "requested" | "completed";
|
||||||
|
|
||||||
const NAME_RE = /^[A-Za-z0-9-]+$/;
|
|
||||||
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
|
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
|
||||||
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||||
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
||||||
@@ -58,14 +57,14 @@ function wait(ms: number) {
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
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]) {
|
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
await wait(delay);
|
await wait(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await getAgent(agentName);
|
return await getAgent(agentId);
|
||||||
} catch {
|
} catch {
|
||||||
// Retry until the write settles or the attempts are exhausted.
|
// Retry until the write settles or the attempts are exhausted.
|
||||||
}
|
}
|
||||||
@@ -97,7 +96,7 @@ export default function NewAgentPage() {
|
|||||||
const [nameError, setNameError] = useState("");
|
const [nameError, setNameError] = useState("");
|
||||||
const [isCheckingName, setIsCheckingName] = useState(false);
|
const [isCheckingName, setIsCheckingName] = useState(false);
|
||||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||||
const [agentName, setAgentName] = useState("");
|
const [agentId, setAgentId] = useState("");
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [showSaveHint, setShowSaveHint] = useState(false);
|
const [showSaveHint, setShowSaveHint] = useState(false);
|
||||||
const [setupAgentStatus, setSetupAgentStatus] =
|
const [setupAgentStatus, setSetupAgentStatus] =
|
||||||
@@ -125,6 +124,7 @@ export default function NewAgentPage() {
|
|||||||
|
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
threadId: step === "chat" ? threadId : undefined,
|
threadId: step === "chat" ? threadId : undefined,
|
||||||
|
assistantId: agentId || undefined,
|
||||||
context: {
|
context: {
|
||||||
mode: "flash",
|
mode: "flash",
|
||||||
is_bootstrap: true,
|
is_bootstrap: true,
|
||||||
@@ -135,9 +135,9 @@ export default function NewAgentPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToolEnd({ name }) {
|
onToolEnd({ name }) {
|
||||||
if (name !== "setup_agent" || !agentName) return;
|
if (name !== "setup_agent" || !agentId) return;
|
||||||
setSetupAgentStatus("completed");
|
setSetupAgentStatus("completed");
|
||||||
void getAgentWithRetry(agentName).then((fetched) => {
|
void getAgentWithRetry(agentId).then((fetched) => {
|
||||||
if (fetched) {
|
if (fetched) {
|
||||||
setAgent(fetched);
|
setAgent(fetched);
|
||||||
return;
|
return;
|
||||||
@@ -171,8 +171,7 @@ export default function NewAgentPage() {
|
|||||||
|
|
||||||
const handleConfirmName = useCallback(async () => {
|
const handleConfirmName = useCallback(async () => {
|
||||||
const trimmed = nameInput.trim();
|
const trimmed = nameInput.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) {
|
||||||
if (!NAME_RE.test(trimmed)) {
|
|
||||||
setNameError(t.agents.nameStepInvalidError);
|
setNameError(t.agents.nameStepInvalidError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -185,11 +184,7 @@ export default function NewAgentPage() {
|
|||||||
setNameError("");
|
setNameError("");
|
||||||
setIsCheckingName(true);
|
setIsCheckingName(true);
|
||||||
try {
|
try {
|
||||||
const result = await checkAgentName(trimmed);
|
await checkAgentName(trimmed);
|
||||||
if (!result.available) {
|
|
||||||
setNameError(t.agents.nameStepAlreadyExistsError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof AgentNameCheckError &&
|
err instanceof AgentNameCheckError &&
|
||||||
@@ -204,14 +199,18 @@ export default function NewAgentPage() {
|
|||||||
setIsCheckingName(false);
|
setIsCheckingName(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let createdId: string;
|
||||||
|
let createdName: string;
|
||||||
setIsCreatingAgent(true);
|
setIsCreatingAgent(true);
|
||||||
try {
|
try {
|
||||||
await createAgent({
|
const created = await createAgent({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
description: "",
|
description: "",
|
||||||
skills: selectedSkills,
|
skills: selectedSkills,
|
||||||
soul: "",
|
soul: "",
|
||||||
});
|
});
|
||||||
|
createdId = created.id;
|
||||||
|
createdName = created.name;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNameError(
|
setNameError(
|
||||||
getCreateAgentErrorMessage(
|
getCreateAgentErrorMessage(
|
||||||
@@ -225,17 +224,16 @@ export default function NewAgentPage() {
|
|||||||
setIsCreatingAgent(false);
|
setIsCreatingAgent(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAgentName(trimmed);
|
setAgentId(createdId);
|
||||||
setStep("chat");
|
setStep("chat");
|
||||||
await ensureThreadExists();
|
await ensureThreadExists();
|
||||||
await sendMessage(threadId, {
|
await sendMessage(threadId, {
|
||||||
text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed),
|
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
|
||||||
files: [],
|
files: [],
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
nameInput,
|
nameInput,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
t.agents.nameStepAlreadyExistsError,
|
|
||||||
t.agents.nameStepNetworkError,
|
t.agents.nameStepNetworkError,
|
||||||
t.agents.nameStepBootstrapMessage,
|
t.agents.nameStepBootstrapMessage,
|
||||||
t.agents.nameStepCheckError,
|
t.agents.nameStepCheckError,
|
||||||
@@ -272,15 +270,15 @@ export default function NewAgentPage() {
|
|||||||
await sendMessage(
|
await sendMessage(
|
||||||
threadId,
|
threadId,
|
||||||
{ text: trimmed, files: [] },
|
{ 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 () => {
|
const handleSaveAgent = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!agentName ||
|
!agentId ||
|
||||||
agent ||
|
agent ||
|
||||||
thread.isLoading ||
|
thread.isLoading ||
|
||||||
setupAgentStatus !== "idle"
|
setupAgentStatus !== "idle"
|
||||||
@@ -294,7 +292,7 @@ export default function NewAgentPage() {
|
|||||||
await sendMessage(
|
await sendMessage(
|
||||||
threadId,
|
threadId,
|
||||||
{ text: t.agents.saveCommandMessage, files: [] },
|
{ text: t.agents.saveCommandMessage, files: [] },
|
||||||
{ agent_name: agentName },
|
{ agent_id: agentId },
|
||||||
{ additionalKwargs: { hide_from_ui: true } },
|
{ additionalKwargs: { hide_from_ui: true } },
|
||||||
);
|
);
|
||||||
toast.success(t.agents.saveRequested);
|
toast.success(t.agents.saveRequested);
|
||||||
@@ -304,7 +302,7 @@ export default function NewAgentPage() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
agent,
|
agent,
|
||||||
agentName,
|
agentId,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
setupAgentStatus,
|
setupAgentStatus,
|
||||||
t.agents.saveCommandMessage,
|
t.agents.saveCommandMessage,
|
||||||
@@ -480,7 +478,7 @@ export default function NewAgentPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(
|
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) |
|
| **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`) |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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.
|
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/`)
|
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||||
|
|
||||||
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
**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/mcp/config` | Manage MCP server configurations |
|
||||||
| `GET/PUT /api/skills` | List and manage skills |
|
| `GET/PUT /api/skills` | List and manage skills |
|
||||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
| `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 |
|
| `GET /api/memory` | Retrieve memory data |
|
||||||
| `POST /api/memory/reload` | Force memory reload |
|
| `POST /api/memory/reload` | Force memory reload |
|
||||||
| `GET /api/memory/config` | Memory configuration |
|
| `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 |
|
| `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 |
|
| `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
|
### 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.
|
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:
|
if TYPE_CHECKING:
|
||||||
from app.gateway.auth.local_provider import LocalAuthProvider
|
from app.gateway.auth.local_provider import LocalAuthProvider
|
||||||
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||||
|
from deerflow.persistence.agents import AgentStore
|
||||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
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)
|
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 event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
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
|
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:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,50 @@
|
|||||||
"""CRUD API for custom agents."""
|
"""CRUD API for custom agents."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
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_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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
|
|
||||||
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
|
||||||
|
|
||||||
|
|
||||||
class AgentResponse(BaseModel):
|
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")
|
description: str = Field(default="", description="Agent description")
|
||||||
model: str | None = Field(default=None, description="Optional model override")
|
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
||||||
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None/[]=none)")
|
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")
|
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):
|
class AgentsListResponse(BaseModel):
|
||||||
"""Response model for listing all custom agents."""
|
"""Response model for listing all visible agents."""
|
||||||
|
|
||||||
agents: list[AgentResponse]
|
agents: list[AgentResponse]
|
||||||
|
|
||||||
@@ -38,43 +52,25 @@ class AgentsListResponse(BaseModel):
|
|||||||
class AgentCreateRequest(BaseModel):
|
class AgentCreateRequest(BaseModel):
|
||||||
"""Request body for creating a custom agent."""
|
"""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")
|
description: str = Field(default="", description="Agent description")
|
||||||
model: str | None = Field(default=None, description="Optional model override")
|
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
published: bool = Field(default=False, description="Whether the new agent should be public")
|
||||||
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None/[]=none)")
|
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
||||||
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
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):
|
class AgentUpdateRequest(BaseModel):
|
||||||
"""Request body for updating a custom agent."""
|
"""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")
|
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")
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||||
|
published: bool | None = Field(default=None, description="Updated publication state")
|
||||||
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
||||||
def _validate_agent_name(name: str) -> None:
|
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
||||||
"""Validate agent name against allowed pattern.
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_agents_api_enabled() -> None:
|
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:
|
def _current_user_id(request: Request) -> str:
|
||||||
"""Convert AgentConfig to AgentResponse."""
|
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
|
soul: str | None = None
|
||||||
if include_soul:
|
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(
|
return AgentResponse(
|
||||||
name=agent_cfg.name,
|
id=record["id"],
|
||||||
description=agent_cfg.description,
|
name=record["name"],
|
||||||
model=agent_cfg.model,
|
description=record.get("description") or "",
|
||||||
tool_groups=agent_cfg.tool_groups,
|
user_id=record.get("user_id"),
|
||||||
skills=agent_cfg.skills,
|
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,
|
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(
|
@router.get(
|
||||||
"/agents",
|
"/agents",
|
||||||
response_model=AgentsListResponse,
|
response_model=AgentsListResponse,
|
||||||
summary="List Custom Agents",
|
summary="List Agents",
|
||||||
description="List all custom agents available in the agents directory, including their soul content.",
|
description="List built-in agents, the current user's agents, and published agents.",
|
||||||
)
|
)
|
||||||
async def list_agents() -> AgentsListResponse:
|
async def list_agents(request: Request) -> AgentsListResponse:
|
||||||
"""List all custom agents.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of all custom agents with their metadata and soul content.
|
|
||||||
"""
|
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agents = list_custom_agents()
|
store = get_agent_store(request)
|
||||||
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents])
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
logger.error("Failed to list agents: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/agents/check",
|
"/agents/check",
|
||||||
summary="Check Agent Name",
|
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:
|
async def check_agent_name(name: str) -> dict[str, Any]:
|
||||||
"""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.
|
|
||||||
"""
|
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
normalized = _validate_display_name(name)
|
||||||
normalized = _normalize_agent_name(name)
|
return {"available": True, "name": normalized}
|
||||||
available = not get_paths().agent_dir(normalized).exists()
|
|
||||||
return {"available": available, "name": normalized}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/agents/{name}",
|
"/agents/{agent_id}",
|
||||||
response_model=AgentResponse,
|
response_model=AgentResponse,
|
||||||
summary="Get Custom Agent",
|
summary="Get Agent",
|
||||||
description="Retrieve details and SOUL.md content for a specific custom agent.",
|
description="Retrieve details and SOUL.md content for a visible agent.",
|
||||||
)
|
)
|
||||||
async def get_agent(name: str) -> AgentResponse:
|
async def get_agent(agent_id: str, request: Request) -> 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.
|
|
||||||
"""
|
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
agent_id = _validate_storage_id(agent_id)
|
||||||
name = _normalize_agent_name(name)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_cfg = load_agent_config(name)
|
store = get_agent_store(request)
|
||||||
return _agent_config_to_response(agent_cfg, include_soul=True)
|
await _sync_legacy_agents(store)
|
||||||
except FileNotFoundError:
|
record = await store.get_visible(agent_id, _current_user_id(request))
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get agent '{name}': {e}", exc_info=True)
|
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)}")
|
raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/agents",
|
"/agents",
|
||||||
response_model=AgentResponse,
|
response_model=AgentResponse,
|
||||||
status_code=201,
|
status_code=201,
|
||||||
summary="Create Custom Agent",
|
summary="Create Agent",
|
||||||
description="Create a new custom agent with its config and SOUL.md.",
|
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:
|
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> 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.
|
|
||||||
"""
|
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(request.name)
|
name = _validate_display_name(request.name)
|
||||||
normalized_name = _normalize_agent_name(request.name)
|
agent_id = uuid4().hex
|
||||||
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
agent_dir = get_paths().agent_dir(normalized_name)
|
|
||||||
|
|
||||||
if agent_dir.exists():
|
|
||||||
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
|
||||||
|
|
||||||
try:
|
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
|
_write_agent_config(
|
||||||
config_data: dict = {"name": normalized_name}
|
agent_id,
|
||||||
if request.description:
|
name=name,
|
||||||
config_data["description"] = request.description
|
description=request.description or "",
|
||||||
if request.model is not None:
|
skills=request.skills,
|
||||||
config_data["model"] = request.model
|
model=request.model,
|
||||||
if request.tool_groups is not None:
|
tool_groups=request.tool_groups,
|
||||||
config_data["tool_groups"] = request.tool_groups
|
)
|
||||||
if request.skills is not None:
|
_write_agent_soul(agent_id, request.soul)
|
||||||
config_data["skills"] = request.skills
|
|
||||||
|
|
||||||
config_file = agent_dir / "config.yaml"
|
record = await store.create(
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
{
|
||||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
"id": agent_id,
|
||||||
|
"name": name,
|
||||||
# Write SOUL.md
|
"description": request.description or "",
|
||||||
soul_file = agent_dir / "SOUL.md"
|
"user_id": _current_user_id(http_request),
|
||||||
soul_file.write_text(request.soul, encoding="utf-8")
|
"published": request.published,
|
||||||
|
}
|
||||||
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
)
|
||||||
|
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
||||||
agent_cfg = load_agent_config(normalized_name)
|
return _record_to_response(record, include_soul=True)
|
||||||
return _agent_config_to_response(agent_cfg, include_soul=True)
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Clean up on failure
|
|
||||||
if agent_dir.exists():
|
if agent_dir.exists():
|
||||||
shutil.rmtree(agent_dir)
|
shutil.rmtree(agent_dir)
|
||||||
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
|
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)}")
|
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/agents/{name}",
|
"/agents/{agent_id}",
|
||||||
response_model=AgentResponse,
|
response_model=AgentResponse,
|
||||||
summary="Update Custom Agent",
|
summary="Update Agent",
|
||||||
description="Update an existing custom agent's config and/or SOUL.md.",
|
description="Update an existing user-owned agent.",
|
||||||
)
|
)
|
||||||
async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: Request) -> 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.
|
|
||||||
"""
|
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
agent_id = _validate_storage_id(agent_id)
|
||||||
name = _normalize_agent_name(name)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_cfg = load_agent_config(name)
|
store = get_agent_store(http_request)
|
||||||
except FileNotFoundError:
|
user_id = _current_user_id(http_request)
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
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
|
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:
|
next_name = update_data.get("name", current["name"])
|
||||||
updated: dict = {
|
next_description = update_data.get("description", current.get("description") or "")
|
||||||
"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
|
|
||||||
|
|
||||||
new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
|
existing_extras = _load_config_extras(agent_id)
|
||||||
if new_tool_groups is not None:
|
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
||||||
updated["tool_groups"] = new_tool_groups
|
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
|
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups"))
|
||||||
if "skills" in fields_set:
|
if config_dirty:
|
||||||
new_skills = request.skills
|
_write_agent_config(
|
||||||
else:
|
agent_id,
|
||||||
new_skills = agent_cfg.skills
|
name=next_name,
|
||||||
if new_skills is not None:
|
description=next_description,
|
||||||
updated["skills"] = new_skills
|
skills=next_skills,
|
||||||
|
model=next_model,
|
||||||
config_file = agent_dir / "config.yaml"
|
tool_groups=next_tool_groups,
|
||||||
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
|
|
||||||
if request.soul is not None:
|
if request.soul is not None:
|
||||||
soul_path = agent_dir / "SOUL.md"
|
_write_agent_soul(agent_id, request.soul)
|
||||||
soul_path.write_text(request.soul, encoding="utf-8")
|
|
||||||
|
|
||||||
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)
|
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
||||||
return _agent_config_to_response(refreshed_cfg, include_soul=True)
|
return _record_to_response(record, include_soul=True)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update agent '{name}': {e}", exc_info=True)
|
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)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
class UserProfileResponse(BaseModel):
|
||||||
@@ -333,7 +377,7 @@ class UserProfileResponse(BaseModel):
|
|||||||
class UserProfileUpdateRequest(BaseModel):
|
class UserProfileUpdateRequest(BaseModel):
|
||||||
"""Request body for setting the global user profile."""
|
"""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(
|
@router.get(
|
||||||
@@ -343,11 +387,6 @@ class UserProfileUpdateRequest(BaseModel):
|
|||||||
description="Read the global USER.md file that is injected into all custom agents.",
|
description="Read the global USER.md file that is injected into all custom agents.",
|
||||||
)
|
)
|
||||||
async def get_user_profile() -> UserProfileResponse:
|
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()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -357,8 +396,8 @@ async def get_user_profile() -> UserProfileResponse:
|
|||||||
raw = user_md_path.read_text(encoding="utf-8").strip()
|
raw = user_md_path.read_text(encoding="utf-8").strip()
|
||||||
return UserProfileResponse(content=raw or None)
|
return UserProfileResponse(content=raw or None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to read user profile: {e}", exc_info=True)
|
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)}")
|
raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@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.",
|
description="Write the global USER.md file that is injected into all custom agents.",
|
||||||
)
|
)
|
||||||
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
|
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()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
paths.user_md_file.write_text(request.content, encoding="utf-8")
|
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)
|
return UserProfileResponse(content=request.content or None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update user profile: {e}", exc_info=True)
|
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)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/agents/{name}",
|
"/agents/{agent_id}",
|
||||||
status_code=204,
|
status_code=204,
|
||||||
summary="Delete Custom Agent",
|
summary="Delete Agent",
|
||||||
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
|
description="Delete a user-owned agent and all its files.",
|
||||||
)
|
)
|
||||||
async def delete_agent(name: str) -> None:
|
async def delete_agent(agent_id: str, request: Request) -> None:
|
||||||
"""Delete a custom agent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: The agent name.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 404 if agent not found.
|
|
||||||
"""
|
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
agent_id = _validate_storage_id(agent_id)
|
||||||
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")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(agent_dir)
|
store = get_agent_store(request)
|
||||||
logger.info(f"Deleted agent '{name}' from {agent_dir}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
|
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)}")
|
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 datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -56,23 +56,37 @@ def _get_default_assistant() -> AssistantResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _list_assistants() -> list[AssistantResponse]:
|
async def _sync_legacy_agents(store) -> None:
|
||||||
"""List all available assistants from config."""
|
from deerflow.config.agents_config import list_custom_agents
|
||||||
assistants = [_get_default_assistant()]
|
|
||||||
|
|
||||||
# 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:
|
try:
|
||||||
from deerflow.config.agents_config import list_custom_agents
|
from deerflow.config.agents_config import list_custom_agents
|
||||||
|
|
||||||
for agent_cfg in list_custom_agents():
|
for agent_cfg in list_custom_agents():
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
|
assistant_id = agent_cfg.id or agent_cfg.name
|
||||||
assistants.append(
|
assistants.append(
|
||||||
AssistantResponse(
|
AssistantResponse(
|
||||||
assistant_id=agent_cfg.name,
|
assistant_id=assistant_id,
|
||||||
graph_id="lead_agent", # All agents use the same graph
|
graph_id="lead_agent",
|
||||||
name=agent_cfg.name,
|
name=agent_cfg.name,
|
||||||
config={},
|
config={},
|
||||||
metadata={"created_by": "user"},
|
metadata={"created_by": "system", "agent_id": assistant_id},
|
||||||
description=agent_cfg.description or "",
|
description=agent_cfg.description or "",
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -80,18 +94,54 @@ def _list_assistants() -> list[AssistantResponse]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
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
|
return assistants
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=list[AssistantResponse])
|
@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.
|
"""Search assistants.
|
||||||
|
|
||||||
Returns all registered assistants (lead_agent + custom agents from config).
|
Returns all registered assistants (lead_agent + custom agents from config).
|
||||||
"""
|
"""
|
||||||
assistants = _list_assistants()
|
assistants = await _list_assistants(request)
|
||||||
|
|
||||||
if body and body.graph_id:
|
if body and body.graph_id:
|
||||||
assistants = [a for a in assistants if a.graph_id == 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)
|
@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."""
|
"""Get an assistant by ID."""
|
||||||
for a in _list_assistants():
|
for a in await _list_assistants(request):
|
||||||
if a.assistant_id == assistant_id:
|
if a.assistant_id == assistant_id:
|
||||||
return a
|
return a
|
||||||
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{assistant_id}/graph")
|
@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.
|
"""Get the graph structure for an assistant.
|
||||||
|
|
||||||
Returns a minimal graph description. Full graph introspection is
|
Returns a minimal graph description. Full graph introspection is
|
||||||
not supported in the Gateway — this stub satisfies SDK validation.
|
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:
|
if not found:
|
||||||
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} 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")
|
@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.
|
"""Get JSON schemas for an assistant's input/output/state.
|
||||||
|
|
||||||
Returns empty schemas — full introspection not supported in Gateway.
|
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:
|
if not found:
|
||||||
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} 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``.
|
# ``configurable`` for consumers like ``setup_agent``.
|
||||||
_CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
_CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
|
"agent_id",
|
||||||
"model_name",
|
"model_name",
|
||||||
"mode",
|
"mode",
|
||||||
"thinking_enabled",
|
"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):
|
def resolve_agent_factory(assistant_id: str | None):
|
||||||
"""Resolve the agent factory callable from config.
|
"""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
|
injected into ``configurable`` or ``context`` — see
|
||||||
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
||||||
same factory; the routing happens inside ``make_lead_agent`` when it reads
|
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
|
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.
|
"""Build a RunnableConfig dict for the agent.
|
||||||
|
|
||||||
When *assistant_id* refers to a custom agent (anything other than
|
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
|
whichever runtime options container is active: ``context`` for
|
||||||
LangGraph >= 0.6.0 requests, otherwise ``configurable``.
|
LangGraph >= 0.6.0 requests, otherwise ``configurable``.
|
||||||
``make_lead_agent`` reads this key to load the matching
|
``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.
|
silently runs as the default lead agent.
|
||||||
|
|
||||||
This mirrors the channel manager's ``_resolve_run_params`` logic so that
|
This mirrors the channel manager's ``_resolve_run_params`` logic so that
|
||||||
@@ -202,25 +203,96 @@ def build_run_config(
|
|||||||
else:
|
else:
|
||||||
config["configurable"] = {"thread_id": thread_id}
|
config["configurable"] = {"thread_id": thread_id}
|
||||||
|
|
||||||
# Inject custom agent name when the caller specified a non-default assistant.
|
# Inject custom agent id when the caller specified a non-default assistant.
|
||||||
# Honour an explicit agent_name in the active runtime options container.
|
# Honour an explicit agent_id in the active runtime options container.
|
||||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
||||||
normalized = assistant_id.strip().lower().replace("_", "-")
|
normalized = assistant_id.strip()
|
||||||
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
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, and hyphens after normalization.")
|
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, hyphens, and underscores.")
|
||||||
if "configurable" in config:
|
if "configurable" in config:
|
||||||
target = config["configurable"]
|
target = config["configurable"]
|
||||||
elif "context" in config:
|
elif "context" in config:
|
||||||
target = config["context"]
|
target = config["context"]
|
||||||
else:
|
else:
|
||||||
target = config.setdefault("configurable", {})
|
target = config.setdefault("configurable", {})
|
||||||
if target is not None and "agent_name" not in target:
|
if target is not None and "agent_id" not in target:
|
||||||
target["agent_name"] = normalized
|
target["agent_id"] = normalized
|
||||||
if metadata:
|
if metadata:
|
||||||
config.setdefault("metadata", {}).update(metadata)
|
config.setdefault("metadata", {}).update(metadata)
|
||||||
return config
|
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
|
# Run lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -249,6 +321,8 @@ async def start_run(
|
|||||||
|
|
||||||
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
||||||
|
|
||||||
|
await _enforce_agent_access(body, request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
record = await run_mgr.create_or_reject(
|
record = await run_mgr.create_or_reject(
|
||||||
thread_id,
|
thread_id,
|
||||||
|
|||||||
+10
-7
@@ -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.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||||||
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||||
from deerflow.agents.thread_state import ThreadState
|
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.config.app_config import AppConfig, get_app_config
|
||||||
from deerflow.models import create_chat_model
|
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)
|
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
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
|
# 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
|
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(
|
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",
|
"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,
|
thinking_enabled,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
model_name,
|
model_name,
|
||||||
@@ -373,7 +374,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
|
|
||||||
config["metadata"].update(
|
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",
|
"model_name": model_name or "default",
|
||||||
"thinking_enabled": thinking_enabled,
|
"thinking_enabled": thinking_enabled,
|
||||||
"reasoning_effort": reasoning_effort,
|
"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),
|
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||||
app_config=resolved_app_config,
|
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(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
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),
|
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
),
|
),
|
||||||
|
|||||||
+8
-5
@@ -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)
|
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
|
# Append SOUL.md (agent personality) if present
|
||||||
soul = load_agent_soul(agent_name)
|
soul = load_agent_soul(agent_id)
|
||||||
if soul:
|
if soul:
|
||||||
return f"<soul>\n{soul}\n</soul>\n" if soul else ""
|
return f"<soul>\n{soul}\n</soul>\n" if soul else ""
|
||||||
return ""
|
return ""
|
||||||
@@ -789,12 +789,15 @@ def apply_prompt_template(
|
|||||||
subagent_enabled: bool = False,
|
subagent_enabled: bool = False,
|
||||||
max_concurrent_subagents: int = 3,
|
max_concurrent_subagents: int = 3,
|
||||||
*,
|
*,
|
||||||
|
agent_id: str | None = None,
|
||||||
agent_name: str | None = None,
|
agent_name: str | None = None,
|
||||||
available_skills: set[str] | None = None,
|
available_skills: set[str] | None = None,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||||
|
|
||||||
# Get memory context
|
# 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)
|
# Include subagent section only if enabled (from runtime parameter)
|
||||||
n = max_concurrent_subagents
|
n = max_concurrent_subagents
|
||||||
@@ -834,8 +837,8 @@ def apply_prompt_template(
|
|||||||
|
|
||||||
# Format the prompt with dynamic skills and memory
|
# Format the prompt with dynamic skills and memory
|
||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
agent_name=agent_name or "DeerFlow 2.0",
|
agent_name=agent_name or storage_agent_id or "DeerFlow 2.0",
|
||||||
soul=get_agent_soul(agent_name),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
memory_context=memory_context,
|
memory_context=memory_context,
|
||||||
|
|||||||
+42
-27
@@ -12,23 +12,33 @@ from deerflow.config.paths import get_paths
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SOUL_FILENAME = "SOUL.md"
|
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:
|
def validate_agent_name(name: str | None) -> str | None:
|
||||||
"""Validate a custom agent name before using it in filesystem paths."""
|
"""Backward-compatible alias for validating a storage/runtime agent id."""
|
||||||
if name is None:
|
return validate_agent_id(name)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class AgentConfig(BaseModel):
|
class AgentConfig(BaseModel):
|
||||||
"""Configuration for a custom agent."""
|
"""Configuration for a custom agent."""
|
||||||
|
|
||||||
|
id: str | None = None
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
@@ -40,11 +50,11 @@ class AgentConfig(BaseModel):
|
|||||||
skills: list[str] | None = None
|
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.
|
"""Load the custom or default agent's config from its directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: The agent name.
|
agent_id: The agent storage/runtime id.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AgentConfig instance.
|
AgentConfig instance.
|
||||||
@@ -54,28 +64,32 @@ def load_agent_config(name: str | None) -> AgentConfig | None:
|
|||||||
ValueError: If config.yaml cannot be parsed.
|
ValueError: If config.yaml cannot be parsed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if name is None:
|
if agent_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
name = validate_agent_name(name)
|
agent_id = validate_agent_id(agent_id)
|
||||||
agent_dir = get_paths().agent_dir(name)
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
config_file = agent_dir / "config.yaml"
|
config_file = agent_dir / "config.yaml"
|
||||||
|
|
||||||
if not agent_dir.exists():
|
if not agent_dir.exists():
|
||||||
raise FileNotFoundError(f"Agent directory not found: {agent_dir}")
|
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}")
|
raise FileNotFoundError(f"Agent config not found: {config_file}")
|
||||||
|
|
||||||
try:
|
data: dict[str, Any] = {}
|
||||||
with open(config_file, encoding="utf-8") as f:
|
if config_file.exists():
|
||||||
data: dict[str, Any] = yaml.safe_load(f) or {}
|
try:
|
||||||
except yaml.YAMLError as e:
|
with open(config_file, encoding="utf-8") as f:
|
||||||
raise ValueError(f"Failed to parse agent config {config_file}: {e}") from e
|
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:
|
if "name" not in data:
|
||||||
data["name"] = name
|
data["name"] = agent_id
|
||||||
|
|
||||||
# Strip unknown fields before passing to Pydantic (e.g. legacy prompt_file)
|
# Strip unknown fields before passing to Pydantic (e.g. legacy prompt_file)
|
||||||
known_fields = set(AgentConfig.model_fields.keys())
|
known_fields = set(AgentConfig.model_fields.keys())
|
||||||
@@ -84,19 +98,19 @@ def load_agent_config(name: str | None) -> AgentConfig | None:
|
|||||||
return AgentConfig(**data)
|
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.
|
"""Read the SOUL.md file for a custom agent, if it exists.
|
||||||
|
|
||||||
SOUL.md defines the agent's personality, values, and behavioral guardrails.
|
SOUL.md defines the agent's personality, values, and behavioral guardrails.
|
||||||
It is injected into the lead agent's system prompt as additional context.
|
It is injected into the lead agent's system prompt as additional context.
|
||||||
|
|
||||||
Args:
|
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:
|
Returns:
|
||||||
The SOUL.md content as a string, or None if the file does not exist.
|
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
|
soul_path = agent_dir / SOUL_FILENAME
|
||||||
if not soul_path.exists():
|
if not soul_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -122,8 +136,9 @@ def list_custom_agents() -> list[AgentConfig]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
config_file = entry / "config.yaml"
|
config_file = entry / "config.yaml"
|
||||||
if not config_file.exists():
|
soul_file = entry / SOUL_FILENAME
|
||||||
logger.debug(f"Skipping {entry.name}: no config.yaml")
|
if not config_file.exists() and not soul_file.exists():
|
||||||
|
logger.debug(f"Skipping {entry.name}: no config.yaml or SOUL.md")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Paths:
|
|||||||
├── memory.json
|
├── memory.json
|
||||||
├── USER.md <-- global user profile (injected into all agents)
|
├── USER.md <-- global user profile (injected into all agents)
|
||||||
├── agents/
|
├── agents/
|
||||||
│ └── {agent_name}/
|
│ └── {agent_id}/
|
||||||
│ ├── config.yaml
|
│ ├── config.yaml
|
||||||
│ ├── SOUL.md <-- agent personality/identity (injected alongside lead prompt)
|
│ ├── SOUL.md <-- agent personality/identity (injected alongside lead prompt)
|
||||||
│ └── memory.json
|
│ └── memory.json
|
||||||
@@ -136,11 +136,11 @@ class Paths:
|
|||||||
return self.base_dir / "agents"
|
return self.base_dir / "agents"
|
||||||
|
|
||||||
def agent_dir(self, name: str) -> Path:
|
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()
|
return self.agents_dir / name.lower()
|
||||||
|
|
||||||
def agent_memory_file(self, name: str) -> Path:
|
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"
|
return self.agent_dir(name) / "memory.json"
|
||||||
|
|
||||||
def user_dir(self, user_id: str) -> Path:
|
def user_dir(self, user_id: str) -> Path:
|
||||||
@@ -152,7 +152,7 @@ class Paths:
|
|||||||
return self.user_dir(user_id) / "memory.json"
|
return self.user_dir(user_id) / "memory.json"
|
||||||
|
|
||||||
def user_agent_memory_file(self, user_id: str, agent_name: str) -> Path:
|
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"
|
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:
|
def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||||
|
|||||||
+22
@@ -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
|
||||||
+99
@@ -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
|
||||||
+24
@@ -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
|
||||||
+47
@@ -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")
|
||||||
+3
@@ -8,12 +8,14 @@ The actual ORM classes have moved to entity-specific subpackages:
|
|||||||
- ``deerflow.persistence.run``
|
- ``deerflow.persistence.run``
|
||||||
- ``deerflow.persistence.feedback``
|
- ``deerflow.persistence.feedback``
|
||||||
- ``deerflow.persistence.user``
|
- ``deerflow.persistence.user``
|
||||||
|
- ``deerflow.persistence.agents``
|
||||||
|
|
||||||
``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because
|
``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because
|
||||||
its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
||||||
there is no matching entity directory.
|
there is no matching entity directory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from deerflow.persistence.agents.model import AgentRow
|
||||||
from deerflow.persistence.feedback.model import FeedbackRow
|
from deerflow.persistence.feedback.model import FeedbackRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
from deerflow.persistence.run.model import RunRow
|
from deerflow.persistence.run.model import RunRow
|
||||||
@@ -23,6 +25,7 @@ from deerflow.persistence.user.model import UserRow
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FeedbackRow",
|
"FeedbackRow",
|
||||||
|
"AgentRow",
|
||||||
"RunEventRow",
|
"RunEventRow",
|
||||||
"RunRow",
|
"RunRow",
|
||||||
"ScheduledTaskDeliveryProfileRow",
|
"ScheduledTaskDeliveryProfileRow",
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@ from langchain_core.tools import tool
|
|||||||
from langgraph.prebuilt import ToolRuntime
|
from langgraph.prebuilt import ToolRuntime
|
||||||
from langgraph.types import Command
|
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
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
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
|
agent_dir = None
|
||||||
is_new_dir = False
|
is_new_dir = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_name = validate_agent_name(agent_name)
|
agent_name = validate_agent_id(agent_name)
|
||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir
|
agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir
|
||||||
is_new_dir = not agent_dir.exists()
|
is_new_dir = not agent_dir.exists()
|
||||||
@@ -40,7 +40,7 @@ def setup_agent(
|
|||||||
|
|
||||||
if agent_name:
|
if agent_name:
|
||||||
# If agent_name is provided, we are creating a custom agent in the agents/ directory
|
# 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:
|
if description:
|
||||||
config_data["description"] = description
|
config_data["description"] = description
|
||||||
if skills is not None:
|
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
|
||||||
Reference in new issue
Block a user