智能体配置

This commit is contained in:
吉鑫升 committed 2026-05-13 20:04:06 +08:00
1 parent 83d7fa26fe
commit 31abad86d5
12 files changed
+768 -393

No files matched your search

@@ -59,11 +59,17 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.skills ?? [], agent.skills ?? [],
); );
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const isBuiltin = agent.builtin; const isBuiltin = agent.builtin;
const isOwned = const isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId; !isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
const isFromOthers = !isBuiltin && !isOwned; const isFromOthers = !isBuiltin && !isOwned;
// System admins are allowed to edit built-in agents in place. Delete and
// publish-toggle stay owner-only — built-ins shouldn't be removable, and
// they are inherently visible to everyone so toggling published is moot.
const canEdit = isOwned || (isBuiltin && isAdmin);
useEffect(() => { useEffect(() => {
if (!editOpen) { if (!editOpen) {
@@ -235,40 +241,46 @@ 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>
{isOwned && ( {(canEdit || isOwned) && (
<div className="flex gap-1"> <div className="flex gap-1">
<Button {isOwned && (
size="icon" <Button
variant="ghost" size="icon"
className="h-8 w-8 shrink-0" variant="ghost"
onClick={() => void handleTogglePublished()} className="h-8 w-8 shrink-0"
disabled={updateAgent.isPending} onClick={() => void handleTogglePublished()}
title={agent.published ? "取消发布" : "发布"} disabled={updateAgent.isPending}
> title={agent.published ? "取消发布" : "发布"}
{agent.published ? ( >
<EyeOffIcon className="h-3.5 w-3.5" /> {agent.published ? (
) : ( <EyeOffIcon className="h-3.5 w-3.5" />
<EyeIcon className="h-3.5 w-3.5" /> ) : (
)} <EyeIcon className="h-3.5 w-3.5" />
</Button> )}
<Button </Button>
size="icon" )}
variant="ghost" {canEdit && (
className="h-8 w-8 shrink-0" <Button
onClick={() => setEditOpen(true)} size="icon"
title="编辑智能体" variant="ghost"
> className="h-8 w-8 shrink-0"
<SquarePenIcon className="h-3.5 w-3.5" /> onClick={() => setEditOpen(true)}
</Button> title="编辑智能体"
<Button >
size="icon" <SquarePenIcon className="h-3.5 w-3.5" />
variant="ghost" </Button>
className="text-destructive hover:text-destructive h-8 w-8 shrink-0" )}
onClick={() => setDeleteOpen(true)} {isOwned && (
title={t.agents.delete} <Button
> size="icon"
<Trash2Icon className="h-3.5 w-3.5" /> variant="ghost"
</Button> 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> </div>
)} )}
</CardFooter> </CardFooter>
@@ -1,5 +1,5 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { SendIcon } from "lucide-react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
@@ -45,6 +45,8 @@ export function MessageList({
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
onClarificationSubmit, onClarificationSubmit,
onSetupAgentApprove,
onSetupAgentReject,
}: { }: {
className?: string; className?: string;
threadId: string; threadId: string;
@@ -52,6 +54,8 @@ export function MessageList({
paddingBottom?: number; paddingBottom?: number;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
onClarificationSubmit?: (answer: string) => void | Promise<void>; onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
@@ -99,6 +103,23 @@ export function MessageList({
); );
} }
return null; return null;
} else if (group.type === "assistant:setup-agent-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupAgentApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupAgentApprove}
onReject={onSetupAgentReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") { } else if (group.type === "assistant:present-files") {
const files: string[] = []; const files: string[] = [];
for (const message of group.messages) { for (const message of group.messages) {
@@ -263,6 +284,145 @@ type ClarificationPayload = {
allow_custom?: boolean; allow_custom?: boolean;
}; };
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: string;
skills?: string[];
};
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_agent_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupAgentApprovalPayload;
}
function SetupAgentApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupAgentApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
null,
);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">
创建专家审批
</div>
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
</div>
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">名称</div>
<p className="text-sm font-medium">{approvalPayload.name}</p>
</div>
) : null}
{approvalPayload.description ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.description}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">技能</div>
<div className="flex flex-wrap gap-1.5">
{skills.map((skill) => (
<span
key={skill}
className="bg-secondary text-secondary-foreground rounded-md px-2 py-1 text-xs"
>
{skill}
</span>
))}
</div>
</div>
) : null}
{approvalPayload.soul ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">SOUL.md</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.soul}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
拒绝
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
批准创建
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null { function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) { if (typeof message !== "object" || message === null) {
return null; return null;
+21 -1
View File
@@ -16,6 +16,8 @@ interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:pres
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {} interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup = type MessageGroup =
@@ -24,6 +26,7 @@ type MessageGroup =
| AssistantMessageGroup | AssistantMessageGroup
| AssistantPresentFilesGroup | AssistantPresentFilesGroup
| AssistantClarificationGroup | AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup; | AssistantSubagentGroup;
export function groupMessages<T>( export function groupMessages<T>(
@@ -44,7 +47,8 @@ export function groupMessages<T>(
last && last &&
last.type !== "human" && last.type !== "human" &&
last.type !== "assistant" && last.type !== "assistant" &&
last.type !== "assistant:clarification" last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
) { ) {
return last; return last;
} }
@@ -75,6 +79,13 @@ export function groupMessages<T>(
type: "assistant:clarification", type: "assistant:clarification",
messages: [message], messages: [message],
}); });
} else if (isSetupAgentApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-agent-approval",
messages: [message],
});
} else { } else {
const open = lastOpenGroup(); const open = lastOpenGroup();
if (open) { if (open) {
@@ -290,6 +301,15 @@ export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification"; return message.type === "tool" && message.name === "ask_clarification";
} }
export function isSetupAgentApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_agent" &&
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
message.additional_kwargs.setup_agent_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) { export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) { if (message.type !== "ai" || !hasPresentFiles(message)) {
return []; return [];
+314 -354
View File
@@ -2,10 +2,7 @@
import { import {
ArrowLeftIcon, ArrowLeftIcon,
BotIcon,
CheckCircleIcon, CheckCircleIcon,
InfoIcon,
MoreHorizontalIcon,
SaveIcon, SaveIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -18,17 +15,14 @@ import {
PromptInputSubmit, PromptInputSubmit,
PromptInputTextarea, PromptInputTextarea,
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages"; import {
MessageList,
type SetupAgentApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
@@ -37,19 +31,15 @@ import {
checkAgentName, checkAgentName,
createAgent, createAgent,
getAgent, getAgent,
updateAgent,
} from "@/core/agents/api"; } from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills"; import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Step = "name" | "chat";
type SetupAgentStatus = "idle" | "requested" | "completed";
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;
@@ -62,14 +52,12 @@ async function getAgentWithRetry(agentId: string) {
if (delay > 0) { if (delay > 0) {
await wait(delay); await wait(delay);
} }
try { try {
return await getAgent(agentId); return await getAgent(agentId);
} catch { } catch {
// Retry until the write settles or the attempts are exhausted. // Retry until the file/database write settles.
} }
} }
return null; return null;
} }
@@ -90,60 +78,56 @@ function getCreateAgentErrorMessage(
export default function NewAgentPage() { export default function NewAgentPage() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const threadId = useMemo(() => uuid(), []);
const [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState(""); const [descriptionInput, setDescriptionInput] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false); const [soulInput, setSoulInput] = useState("");
const [isCreatingAgent, setIsCreatingAgent] = useState(false); const [formError, setFormError] = useState("");
const [agentId, setAgentId] = 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 [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [setupAgentStatus, setSetupAgentStatus] = const [isSavingDraft, setIsSavingDraft] = useState(false);
useState<SetupAgentStatus>("idle");
const [selectedSkills, setSelectedSkills] = useState<string[]>([]); const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [threadPrepared, setThreadPrepared] = useState(false); const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills(); const { skills, isLoading: isSkillsLoading } = useSkills();
const enabledSkills = useMemo( const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled), () => skills.filter((skill) => skill.enabled),
[skills], [skills],
); );
const threadId = useMemo(() => uuid(), []);
useEffect(() => { useEffect(() => {
if (isSkillSelectionTouched) { if (isSkillSelectionTouched) {
return; return;
} }
const defaults = enabledSkills setSelectedSkills(
.slice(0, DEFAULT_SELECTED_SKILL_COUNT) enabledSkills
.map((skill) => skill.name); .slice(0, DEFAULT_SELECTED_SKILL_COUNT)
setSelectedSkills(defaults); .map((skill) => skill.name),
);
}, [enabledSkills, isSkillSelectionTouched]); }, [enabledSkills, isSkillSelectionTouched]);
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
threadId: step === "chat" ? threadId : undefined, threadId,
assistantId: agentId || undefined, assistantId: agentId || undefined,
context: { context: {
mode: "flash", mode: "flash",
is_bootstrap: true, is_bootstrap: true,
}, },
onFinish() {
if (!agent && setupAgentStatus === "requested") {
setSetupAgentStatus("idle");
}
},
onToolEnd({ name }) { onToolEnd({ name }) {
if (name !== "setup_agent" || !agentId) return; if (name !== "setup_agent" || !agentId) {
setSetupAgentStatus("completed"); return;
}
void getAgentWithRetry(agentId).then((fetched) => { void getAgentWithRetry(agentId).then((fetched) => {
if (fetched) { if (fetched) {
setAgent(fetched); setAgent(fetched);
return; setDescriptionInput(fetched.description ?? "");
setSoulInput(fetched.soul ?? "");
setSelectedSkills(fetched.skills ?? []);
toast.success("专家配置已创建");
} }
toast.error(t.agents.agentCreatedPendingRefresh);
}); });
}, },
}); });
@@ -152,363 +136,339 @@ export default function NewAgentPage() {
if (threadPrepared) { if (threadPrepared) {
return; return;
} }
await getAPIClient().threads.create({ await getAPIClient().threads.create({ threadId });
threadId,
});
setThreadPrepared(true); setThreadPrepared(true);
}, [threadPrepared, threadId]); }, [threadId, threadPrepared]);
useEffect(() => {
if (typeof window === "undefined" || step !== "chat") {
return;
}
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") {
return;
}
setShowSaveHint(true);
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1");
}, [step]);
const handleConfirmName = useCallback(async () => {
const trimmed = nameInput.trim();
if (!trimmed) {
setNameError(t.agents.nameStepInvalidError);
return;
}
const validateForm = useCallback(() => {
// Name is intentionally NOT required here so the user can start chatting
// immediately. If they leave it blank, ensureAgent() generates a placeholder
// and writes it back into the input so they can rename it later.
if (enabledSkills.length > 0 && selectedSkills.length === 0) { if (enabledSkills.length > 0 && selectedSkills.length === 0) {
setNameError("请至少选择一个技能"); setFormError("请至少选择一个技能");
return; return null;
}
setFormError("");
return nameInput.trim();
}, [enabledSkills.length, nameInput, selectedSkills.length]);
const ensureAgent = useCallback(async () => {
const provided = validateForm();
if (provided === null) {
return null;
}
let name = provided;
if (!name) {
// Names are display-only and may collide, so a timestamped placeholder is
// safe. Reflect it in the form so the user sees what got created.
name = `未命名专家-${Date.now().toString(36)}`;
setNameInput(name);
} }
setNameError(""); if (agentId) {
setIsCheckingName(true); setIsSavingDraft(true);
try { try {
await checkAgentName(trimmed); const updated = await updateAgent(agentId, {
} catch (err) { name,
if ( description: descriptionInput,
err instanceof AgentNameCheckError && soul: soulInput,
err.reason === "backend_unreachable" skills: selectedSkills,
) { });
setNameError(t.agents.nameStepNetworkError); setAgent(updated);
} else { return updated;
setNameError(t.agents.nameStepCheckError); } catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return null;
} finally {
setIsSavingDraft(false);
} }
return;
} finally {
setIsCheckingName(false);
} }
let createdId: string;
let createdName: string;
setIsCreatingAgent(true); setIsCreatingAgent(true);
try { try {
await checkAgentName(name);
const created = await createAgent({ const created = await createAgent({
name: trimmed, name,
description: "", description: descriptionInput,
soul: soulInput,
skills: selectedSkills, skills: selectedSkills,
soul: "",
}); });
createdId = created.id; setAgentId(created.id);
createdName = created.name; setAgent(created);
} catch (err) { return created;
setNameError( } catch (error) {
getCreateAgentErrorMessage( if (
err, error instanceof AgentNameCheckError &&
t.agents.nameStepNetworkError, error.reason === "backend_unreachable"
t.agents.nameStepCheckError, ) {
), setFormError(t.agents.nameStepNetworkError);
); } else {
return; setFormError(
getCreateAgentErrorMessage(
error,
t.agents.nameStepNetworkError,
t.agents.nameStepCheckError,
),
);
}
return null;
} finally { } finally {
setIsCreatingAgent(false); setIsCreatingAgent(false);
} }
setAgentId(createdId);
setStep("chat");
await ensureThreadExists();
await sendMessage(threadId, {
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
files: [],
});
}, [ }, [
nameInput, agentId,
sendMessage, descriptionInput,
t.agents.nameStepNetworkError,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepCheckError,
t.agents.nameStepInvalidError,
threadId,
enabledSkills.length,
selectedSkills, selectedSkills,
ensureThreadExists, soulInput,
t.agents.nameStepCheckError,
t.agents.nameStepNetworkError,
validateForm,
]); ]);
const toggleSkill = useCallback((skill: Skill) => { const handleSaveAgent = useCallback(async () => {
setIsSkillSelectionTouched(true); const saved = await ensureAgent();
setNameError(""); if (!saved) {
setSelectedSkills((prev) => { return;
if (prev.includes(skill.name)) {
return prev.filter((name) => name !== skill.name);
}
return [...prev, skill.name];
});
}, []);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleConfirmName();
} }
}; toast.success(`专家「${saved.name}」已保存`);
}, [ensureAgent]);
const handleChatSubmit = useCallback( const handleChatSubmit = useCallback(
async (text: string) => { async (text: string) => {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed || thread.isLoading) return; if (!trimmed || thread.isLoading) {
return;
}
// Lazily create the agent on first message so the user can just start
// typing — ensureAgent generates a placeholder name when none is provided.
let activeAgentId = agentId;
if (!activeAgentId) {
const created = await ensureAgent();
if (!created) {
return;
}
activeAgentId = created.id;
}
await ensureThreadExists(); await ensureThreadExists();
await sendMessage( await sendMessage(
threadId, threadId,
{ text: trimmed, files: [] }, { text: trimmed, files: [] },
{ agent_id: agentId }, { agent_id: activeAgentId },
); );
}, },
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists], [agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
); );
const handleSaveAgent = useCallback(async () => { const handleApproveSetupAgent = useCallback(
if ( async (payload: SetupAgentApprovalPayload) => {
!agentId || if (!agentId) {
agent || return;
thread.isLoading || }
setupAgentStatus !== "idle" // Approval persists directly to the backend — no LLM round-trip. The
) { // backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
return; // config.yaml on disk, which is exactly what setup_agent_tool would have
} // done if we'd let the model re-invoke it.
const proposedName = payload.name?.trim() ?? "";
const newDescription = payload.description ?? "";
const newSoul = payload.soul ?? "";
const newSkills = payload.skills ?? [];
setSetupAgentStatus("requested"); // Prefer the model's proposed name when present; fall back to whatever
setShowSaveHint(false); // the user already typed, or the existing agent record.
try { const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
await sendMessage( setNameInput(resolvedName);
threadId, setDescriptionInput(newDescription);
{ text: t.agents.saveCommandMessage, files: [] }, setSoulInput(newSoul);
{ agent_id: agentId }, setSelectedSkills(newSkills);
{ additionalKwargs: { hide_from_ui: true } }, setIsSkillSelectionTouched(true);
);
toast.success(t.agents.saveRequested);
} catch (error) {
setSetupAgentStatus("idle");
toast.error(error instanceof Error ? error.message : String(error));
}
}, [
agent,
agentId,
sendMessage,
setupAgentStatus,
t.agents.saveCommandMessage,
t.agents.saveRequested,
thread.isLoading,
threadId,
]);
const header = ( try {
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3"> const updated = await updateAgent(agentId, {
<div className="flex items-center gap-3"> name: resolvedName,
<Button description: newDescription,
variant="ghost" soul: newSoul,
size="icon-sm" skills: newSkills,
onClick={() => router.push("/workspace/agents")} });
> setAgent(updated);
<ArrowLeftIcon className="h-4 w-4" /> toast.success("专家配置已保存");
</Button> } catch (error) {
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1> toast.error(error instanceof Error ? error.message : "保存失败");
</div> }
},
{step === "chat" ? ( [agent, agentId, nameInput],
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => void handleSaveAgent()}
disabled={
!!agent || thread.isLoading || setupAgentStatus !== "idle"
}
>
<SaveIcon className="h-4 w-4" />
{setupAgentStatus === "requested"
? t.agents.saving
: t.agents.save}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</header>
); );
if (step === "name") { const handleRejectSetupAgent = useCallback(
return ( async () => {
<div className="flex size-full flex-col"> await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
{header} },
<main className="flex flex-1 flex-col items-center justify-center px-4"> [handleChatSubmit],
<div className="w-full max-w-sm space-y-8"> );
<div className="space-y-3 text-center">
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-primary h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
{t.agents.nameStepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{t.agents.nameStepHint}
</p>
</div>
</div>
<div className="space-y-3"> const toggleSkill = useCallback((skill: Skill) => {
<Input setIsSkillSelectionTouched(true);
autoFocus setFormError("");
placeholder={t.agents.nameStepPlaceholder} setSelectedSkills((prev) =>
value={nameInput} prev.includes(skill.name)
onChange={(e) => { ? prev.filter((name) => name !== skill.name)
setNameInput(e.target.value); : [...prev, skill.name],
setNameError("");
}}
onKeyDown={handleNameKeyDown}
className={cn(nameError && "border-destructive")}
/>
{nameError ? (
<p className="text-destructive text-sm">{nameError}</p>
) : null}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">默认技能(可自定义)</p>
<p className="text-muted-foreground text-xs">
已选 {selectedSkills.length} 个
</p>
</div>
<div className="rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能,将创建为无技能智能体。
</p>
) : (
<div className="flex flex-wrap gap-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
className={cn(
"h-8",
checked ? "" : "text-muted-foreground",
)}
title={skill.description}
>
{skill.name}
</Button>
);
})}
</div>
)}
</div>
</div>
<Button
className="w-full"
onClick={() => void handleConfirmName()}
disabled={
!nameInput.trim() ||
isCheckingName ||
isCreatingAgent ||
isSkillsLoading
}
>
{t.agents.nameStepContinue}
</Button>
</div>
</div>
</main>
</div>
); );
} }, []);
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
return ( return (
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider> <ArtifactsProvider>
<div className="flex size-full flex-col"> <div className="flex size-full flex-col">
{header} <header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-3">
<main className="flex min-h-0 flex-1 flex-col"> <Button
{showSaveHint ? ( variant="ghost"
<div className="px-4 pt-4"> size="icon-sm"
<div className="mx-auto w-full max-w-(--container-width-md)"> onClick={() => router.push("/workspace/agents")}
<Alert> >
<InfoIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
<AlertDescription>{t.agents.saveHint}</AlertDescription> </Button>
</Alert> <h1 className="text-sm font-semibold">创建专家</h1>
</div> </div>
</div> {agent ? (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)}
>
<CheckCircleIcon className="h-4 w-4" />
开始对话
</Button>
) : null} ) : null}
</header>
<div className="flex min-h-0 flex-1 justify-center"> <main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
<MessageList <section className="flex min-h-0 flex-col border-r">
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")} <div className="min-h-0 flex-1">
threadId={threadId} <MessageList
thread={thread} className="size-full"
onClarificationSubmit={handleChatSubmit} threadId={threadId}
/> thread={thread}
</div> onClarificationSubmit={handleChatSubmit}
onSetupAgentApprove={handleApproveSetupAgent}
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4"> onSetupAgentReject={handleRejectSetupAgent}
<div className="w-full max-w-(--container-width-md)"> />
{agent ? (
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
<CheckCircleIcon className="text-primary h-10 w-10" />
<p className="font-semibold">{t.agents.agentCreated}</p>
<div className="flex gap-2">
<Button
onClick={() =>
router.push(
`/workspace/agents/${agentId}/chats/new`,
)
}
>
{t.agents.startChatting}
</Button>
<Button
variant="outline"
onClick={() => router.push("/workspace/agents")}
>
{t.agents.backToGallery}
</Button>
</div>
</div>
) : (
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
autoFocus
placeholder={t.agents.createPageSubtitle}
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
)}
</div> </div>
</div> <div className="bg-background shrink-0 border-t px-4 py-4">
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</section>
<aside className="bg-background min-h-0 overflow-y-auto">
<div className="space-y-5 p-4">
<div>
<h2 className="text-sm font-semibold">专家配置</h2>
<p className="text-muted-foreground mt-1 text-xs">
这些内容会作为创建专家时的初始配置。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
placeholder="例如:合同审查专家"
value={nameInput}
onChange={(event) => {
setNameInput(event.currentTarget.value);
setFormError("");
}}
className={cn(formError && "border-destructive")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
placeholder="简要说明这个专家擅长什么"
value={descriptionInput}
onChange={(event) =>
setDescriptionInput(event.currentTarget.value)
}
className="min-h-24 resize-none"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
placeholder="写下专家的人设、边界、工作方式和输出要求"
value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 resize-y font-mono text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">技能</label>
<span className="text-muted-foreground text-xs">
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能。
</p>
) : (
<div className="flex flex-wrap gap-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 max-w-full",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">{skill.name}</span>
</Button>
);
})}
</div>
)}
</div>
</div>
{formError ? (
<p className="text-destructive text-sm">{formError}</p>
) : null}
<Button
className="w-full"
onClick={() => void handleSaveAgent()}
disabled={busy || isSkillsLoading}
>
<SaveIcon className="h-4 w-4" />
{agentId ? "保存专家" : "创建专家"}
</Button>
</div>
</aside>
</main> </main>
</div> </div>
</ArtifactsProvider> </ArtifactsProvider>
@@ -89,6 +89,12 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id() return get_effective_user_id()
def _is_admin_user(request: Request) -> bool:
"""Return True when the request is authenticated as a system admin."""
user = getattr(request.state, "user", None)
return getattr(user, "system_role", None) == "admin"
def _validate_display_name(name: str) -> str: def _validate_display_name(name: str) -> str:
normalized = name.strip() normalized = name.strip()
if not normalized: if not normalized:
@@ -203,6 +209,29 @@ async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) ->
raise HTTPException(status_code=403, detail="You can only modify agents that you created") raise HTTPException(status_code=403, detail="You can only modify agents that you created")
async def _get_editable_or_raise(
store: AgentStore,
agent_id: str,
user_id: str,
*,
is_admin: bool,
) -> tuple[dict[str, Any], str]:
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
caller is a system admin. Custom agents owned by other users still 403.
"""
owned = await store.get_owned(agent_id, user_id)
if owned is not None:
return owned, "owner"
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")
if is_admin and visible.get("user_id") is None:
return visible, "admin"
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,
@@ -322,7 +351,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
try: try:
store = get_agent_store(http_request) store = get_agent_store(http_request)
user_id = _current_user_id(http_request) user_id = _current_user_id(http_request)
current = await _get_owned_or_raise(store, agent_id, user_id) is_admin = _is_admin_user(http_request)
current, edit_mode = await _get_editable_or_raise(
store, agent_id, user_id, is_admin=is_admin
)
fields_set = request.model_fields_set fields_set = request.model_fields_set
update_data: dict[str, Any] = {} update_data: dict[str, Any] = {}
@@ -354,7 +386,12 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
if request.soul is not None: if request.soul is not None:
_write_agent_soul(agent_id, request.soul) _write_agent_soul(agent_id, request.soul)
record = await store.update(agent_id, user_id, update_data) if update_data else current if not update_data:
record = current
elif edit_mode == "admin":
record = await store.update_builtin(agent_id, update_data)
else:
record = await store.update(agent_id, user_id, update_data)
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
@@ -9,6 +9,7 @@ from deerflow.agents.memory.summarization_hook import memory_flush_hook
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware from deerflow.agents.middlewares.title_middleware import TitleMiddleware
@@ -303,6 +304,8 @@ def _build_middlewares(
if custom_middlewares: if custom_middlewares:
middlewares.extend(custom_middlewares) middlewares.extend(custom_middlewares)
middlewares.append(SetupAgentApprovalMiddleware())
# ClarificationMiddleware should always be last # ClarificationMiddleware should always be last
middlewares.append(ClarificationMiddleware()) middlewares.append(ClarificationMiddleware())
return middlewares return middlewares
@@ -0,0 +1,90 @@
"""Approval middleware for the custom-agent setup tool."""
from __future__ import annotations
import logging
from collections.abc import Callable
from hashlib import sha256
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.graph import END
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
logger = logging.getLogger(__name__)
class SetupAgentApprovalMiddlewareState(AgentState):
"""Compatible with the DeerFlow thread state schema."""
pass
class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareState]):
"""Stop before ``setup_agent`` writes files and ask the user to approve.
The first ``setup_agent`` call is converted into a structured tool message
and the run ends. A subsequent request can set ``setup_agent_approved`` in
runtime context to allow the tool to execute.
"""
state_schema = SetupAgentApprovalMiddlewareState
def _is_approved(self, request: ToolCallRequest) -> bool:
context = getattr(request.runtime, "context", None) or {}
if not isinstance(context, dict):
return False
return context.get("setup_agent_approved") is True
def _stable_message_id(self, tool_call_id: str, args: dict[str, Any]) -> str:
if tool_call_id:
return f"setup-agent-approval:{tool_call_id}"
digest = sha256(repr(sorted(args.items())).encode("utf-8")).hexdigest()[:16]
return f"setup-agent-approval:{digest}"
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
skills = args.get("skills")
return {
"name": str(args.get("name") or "").strip(),
"description": str(args.get("description") or "").strip(),
"soul": str(args.get("soul") or "").strip(),
"skills": skills if isinstance(skills, list) else [],
}
def _handle_setup_agent(self, request: ToolCallRequest) -> Command:
args = request.tool_call.get("args", {}) or {}
tool_call_id = request.tool_call.get("id", "")
payload = self._build_payload(args)
logger.info("Setup agent approval requested before writing agent files")
message = ToolMessage(
id=self._stable_message_id(tool_call_id, args),
content="请确认是否创建/更新这个专家配置。",
tool_call_id=tool_call_id,
name="setup_agent",
additional_kwargs={"setup_agent_approval": payload},
)
return Command(update={"messages": [message]}, goto=END)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request):
return handler(request)
return self._handle_setup_agent(request)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request):
return await handler(request)
return self._handle_setup_agent(request)
@@ -35,6 +35,15 @@ class AgentStore(ABC):
async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None: async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def update_builtin(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update a built-in agent (user_id IS NULL).
Caller is responsible for authorization — this method does NOT check
any user identity and returns ``None`` if the target is not a built-in.
"""
raise NotImplementedError
@abstractmethod @abstractmethod
async def delete(self, agent_id: str, user_id: str) -> bool: async def delete(self, agent_id: str, user_id: str) -> bool:
raise NotImplementedError raise NotImplementedError
@@ -91,6 +91,16 @@ class MemoryAgentStore(AgentStore):
row["updated_at"] = _now() row["updated_at"] = _now()
return _copy(row) return _copy(row)
async def update_builtin(self, agent_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") is not None:
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: async def delete(self, agent_id: str, user_id: str) -> bool:
row = self._agents.get(agent_id) row = self._agents.get(agent_id)
if row is None or row.get("user_id") != user_id: if row is None or row.get("user_id") != user_id:
@@ -107,6 +107,19 @@ class AgentRepository(AgentStore):
await session.refresh(row) await session.refresh(row)
return _row_to_dict(row) return _row_to_dict(row)
async def update_builtin(self, agent_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 is not None:
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 def delete(self, agent_id: str, user_id: str) -> bool:
async with self._sf() as session: async with self._sf() as session:
row = await session.get(AgentRow, agent_id) row = await session.get(AgentRow, agent_id)
@@ -17,6 +17,7 @@ def setup_agent(
soul: str, soul: str,
description: str, description: str,
runtime: ToolRuntime, runtime: ToolRuntime,
name: str | None = None,
skills: list[str] | None = None, skills: list[str] | None = None,
) -> Command: ) -> Command:
"""Setup the custom DeerFlow agent. """Setup the custom DeerFlow agent.
@@ -24,6 +25,8 @@ def setup_agent(
Args: Args:
soul: Full SOUL.md content defining the agent's personality and behavior. soul: Full SOUL.md content defining the agent's personality and behavior.
description: One-line description of what the agent does. description: One-line description of what the agent does.
name: Optional human-readable display name for the agent. If omitted,
the agent's stable id is used as the display name.
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.
""" """
@@ -40,7 +43,8 @@ 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 = {"id": agent_name, "name": agent_name} display_name = (name or "").strip() or agent_name
config_data: dict = {"id": agent_name, "name": display_name}
if description: if description:
config_data["description"] = description config_data["description"] = description
if skills is not None: if skills is not None:
@@ -52,6 +52,63 @@ async def test_agent_store_only_owner_can_update_or_delete() -> None:
assert await store.delete("private-a", "user-a") is True assert await store.delete("private-a", "user-a") is True
@pytest.mark.asyncio
async def test_update_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# update_builtin succeeds on a built-in agent
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
assert updated is not None
assert updated["name"] == "Renamed by admin"
# update_builtin refuses to touch a user-owned agent (returns None)
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
# And on a missing agent
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
@pytest.mark.asyncio
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
"""Route helper: admin gets edit access to built-ins; non-admin still 403."""
from fastapi import HTTPException
from app.gateway.routers.agents import _get_editable_or_raise
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# Owner edits their own agent → returns ('owner', record)
record, mode = await _get_editable_or_raise(
store, "owned-agent", "user-a", is_admin=False
)
assert mode == "owner" and record["id"] == "owned-agent"
# Admin edits a built-in agent → returns ('admin', record)
record, mode = await _get_editable_or_raise(
store, "builtin-agent", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "builtin-agent"
# Non-admin tries to edit a built-in → 403
with pytest.raises(HTTPException) as exc:
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
assert exc.value.status_code == 403
# Admin tries to edit someone else's PUBLISHED custom agent → still 403
# (admin override only applies to built-ins, not to others' custom agents).
await store.create(
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
)
with pytest.raises(HTTPException) as exc:
await _get_editable_or_raise(store, "published-other", "user-a", is_admin=True)
assert exc.value.status_code == 403
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None: def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
config = build_run_config("thread-1", None, None, assistant_id="agent-123") config = build_run_config("thread-1", None, None, assistant_id="agent-123")