diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx
index 6927b216..cc136d61 100644
--- a/frontend-web/src/components/workspace/agents/agent-card.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-card.tsx
@@ -59,11 +59,17 @@ export function AgentCard({ agent }: AgentCardProps) {
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 isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
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(() => {
if (!editOpen) {
@@ -235,40 +241,46 @@ export function AgentCard({ agent }: AgentCardProps) {
{t.agents.chat}
- {isOwned && (
+ {(canEdit || isOwned) && (
-
-
-
+ {isOwned && (
+
+ )}
+ {canEdit && (
+
+ )}
+ {isOwned && (
+
+ )}
)}
diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx
index b7f2d582..4a37f957 100644
--- a/frontend-web/src/components/workspace/messages/message-list.tsx
+++ b/frontend-web/src/components/workspace/messages/message-list.tsx
@@ -1,14 +1,14 @@
-import type { BaseStream } from "@langchain/langgraph-sdk/react";
-import { SendIcon } from "lucide-react";
-import { useMemo, useState } from "react";
-
-import {
- Conversation,
- ConversationContent,
-} from "@/components/ai-elements/conversation";
-import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
-import { useI18n } from "@/core/i18n/hooks";
+import type { BaseStream } from "@langchain/langgraph-sdk/react";
+import { CheckIcon, SendIcon, XIcon } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import {
+ Conversation,
+ ConversationContent,
+} from "@/components/ai-elements/conversation";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
@@ -42,17 +42,21 @@ export function MessageList({
className,
threadId,
thread,
- paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
- tokenUsageEnabled = false,
- onClarificationSubmit,
-}: {
- className?: string;
- threadId: string;
- thread: BaseStream;
- paddingBottom?: number;
- tokenUsageEnabled?: boolean;
- onClarificationSubmit?: (answer: string) => void | Promise;
-}) {
+ paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
+ tokenUsageEnabled = false,
+ onClarificationSubmit,
+ onSetupAgentApprove,
+ onSetupAgentReject,
+}: {
+ className?: string;
+ threadId: string;
+ thread: BaseStream;
+ paddingBottom?: number;
+ tokenUsageEnabled?: boolean;
+ onClarificationSubmit?: (answer: string) => void | Promise;
+ onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise;
+ onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise;
+}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
@@ -83,13 +87,13 @@ export function MessageList({
if (message && hasContent(message)) {
return (
-
+
+
+
+
+ ) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -246,150 +267,289 @@ export function MessageList({
- );
-}
-
-type ClarificationOption = {
- id: string;
- label: string;
- description?: string;
-};
-
-type ClarificationPayload = {
- question?: string;
- context?: string | null;
- clarification_type?: string;
- options?: ClarificationOption[];
- allow_custom?: boolean;
-};
-
-function getClarificationPayload(message: unknown): ClarificationPayload | 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 clarification = Reflect.get(additionalKwargs, "clarification");
- if (typeof clarification !== "object" || clarification === null) {
- return null;
- }
- return clarification as ClarificationPayload;
-}
-
-function ClarificationCard({
- fallbackContent,
- isLoading,
- message,
- onSubmit,
- rehypePlugins,
-}: {
- fallbackContent: string;
- isLoading: boolean;
- message: unknown;
- onSubmit?: (answer: string) => void | Promise;
- rehypePlugins: ReturnType;
-}) {
- const payload = getClarificationPayload(message);
- const [customAnswer, setCustomAnswer] = useState("");
- const [submitting, setSubmitting] = useState(false);
-
- const options = useMemo(
- () =>
- (payload?.options ?? []).filter(
- (option) => typeof option.label === "string" && option.label.trim(),
- ),
- [payload?.options],
- );
-
- if (!payload) {
- return (
-
- );
- }
-
- const disabled = isLoading || submitting || !onSubmit;
-
- async function submitAnswer(answer: string) {
- const trimmed = answer.trim();
- if (!trimmed || disabled) {
- return;
- }
- setSubmitting(true);
- try {
- await onSubmit?.(trimmed);
- } finally {
- setSubmitting(false);
- }
- }
-
- return (
-
-
-
- 需要你的确认
-
- {payload.context ? (
-
- {payload.context}
-
- ) : null}
-
- {payload.question || fallbackContent}
-
-
-
- {options.length > 0 ? (
-
- {options.map((option) => (
-
- ))}
-
- ) : null}
-
- {payload.allow_custom !== false ? (
-
- ) : null}
-
- );
-}
+ );
+}
+
+type ClarificationOption = {
+ id: string;
+ label: string;
+ description?: string;
+};
+
+type ClarificationPayload = {
+ question?: string;
+ context?: string | null;
+ clarification_type?: string;
+ options?: ClarificationOption[];
+ 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;
+ onReject?: (payload: SetupAgentApprovalPayload) => void | Promise;
+}) {
+ 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 (
+
+
+
+
+ 创建专家审批
+
+
请确认是否写入这个专家配置。
+
+
+ {approvalPayload.name ? (
+
+
名称
+
{approvalPayload.name}
+
+ ) : null}
+
+ {approvalPayload.description ? (
+
+
描述
+
{approvalPayload.description}
+
+ ) : null}
+
+ {skills.length > 0 ? (
+
+
技能
+
+ {skills.map((skill) => (
+
+ {skill}
+
+ ))}
+
+
+ ) : null}
+
+ {approvalPayload.soul ? (
+
+
SOUL.md
+
+ {approvalPayload.soul}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
+
+function getClarificationPayload(message: unknown): ClarificationPayload | 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 clarification = Reflect.get(additionalKwargs, "clarification");
+ if (typeof clarification !== "object" || clarification === null) {
+ return null;
+ }
+ return clarification as ClarificationPayload;
+}
+
+function ClarificationCard({
+ fallbackContent,
+ isLoading,
+ message,
+ onSubmit,
+ rehypePlugins,
+}: {
+ fallbackContent: string;
+ isLoading: boolean;
+ message: unknown;
+ onSubmit?: (answer: string) => void | Promise;
+ rehypePlugins: ReturnType;
+}) {
+ const payload = getClarificationPayload(message);
+ const [customAnswer, setCustomAnswer] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const options = useMemo(
+ () =>
+ (payload?.options ?? []).filter(
+ (option) => typeof option.label === "string" && option.label.trim(),
+ ),
+ [payload?.options],
+ );
+
+ if (!payload) {
+ return (
+
+ );
+ }
+
+ const disabled = isLoading || submitting || !onSubmit;
+
+ async function submitAnswer(answer: string) {
+ const trimmed = answer.trim();
+ if (!trimmed || disabled) {
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await onSubmit?.(trimmed);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ 需要你的确认
+
+ {payload.context ? (
+
+ {payload.context}
+
+ ) : null}
+
+ {payload.question || fallbackContent}
+
+
+
+ {options.length > 0 ? (
+
+ {options.map((option) => (
+
+ ))}
+
+ ) : null}
+
+ {payload.allow_custom !== false ? (
+
+ ) : null}
+
+ );
+}
diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts
index a2930687..e1c1a478 100644
--- a/frontend-web/src/core/messages/utils.ts
+++ b/frontend-web/src/core/messages/utils.ts
@@ -14,17 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
-interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
-
-interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
+interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
+
+interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
+
+interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
- | AssistantPresentFilesGroup
- | AssistantClarificationGroup
- | AssistantSubagentGroup;
+ | AssistantPresentFilesGroup
+ | AssistantClarificationGroup
+ | AssistantSetupAgentApprovalGroup
+ | AssistantSubagentGroup;
export function groupMessages(
messages: Message[],
@@ -41,10 +44,11 @@ export function groupMessages(
function lastOpenGroup() {
const last = groups[groups.length - 1];
if (
- last &&
- last.type !== "human" &&
- last.type !== "assistant" &&
- last.type !== "assistant:clarification"
+ last &&
+ last.type !== "human" &&
+ last.type !== "assistant" &&
+ last.type !== "assistant:clarification" &&
+ last.type !== "assistant:setup-agent-approval"
) {
return last;
}
@@ -66,16 +70,23 @@ export function groupMessages(
}
if (message.type === "tool") {
- if (isClarificationToolMessage(message)) {
+ if (isClarificationToolMessage(message)) {
// Add to the preceding processing group to preserve tool-call association,
// then also open a standalone clarification group for prominent display.
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:clarification",
- messages: [message],
- });
- } else {
+ messages: [message],
+ });
+ } else if (isSetupAgentApprovalToolMessage(message)) {
+ lastOpenGroup()?.messages.push(message);
+ groups.push({
+ id: message.id,
+ type: "assistant:setup-agent-approval",
+ messages: [message],
+ });
+ } else {
const open = lastOpenGroup();
if (open) {
open.messages.push(message);
@@ -286,10 +297,19 @@ export function hasPresentFiles(message: Message) {
);
}
-export function isClarificationToolMessage(message: Message) {
- return message.type === "tool" && message.name === "ask_clarification";
-}
-
+export function isClarificationToolMessage(message: Message) {
+ 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) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx
index 83ff8fc6..23adff1f 100644
--- a/frontend-web/src/pages/NewAgentPage.tsx
+++ b/frontend-web/src/pages/NewAgentPage.tsx
@@ -1,517 +1,477 @@
-"use client";
-
-import {
- ArrowLeftIcon,
- BotIcon,
- CheckCircleIcon,
- InfoIcon,
- MoreHorizontalIcon,
- SaveIcon,
-} from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { toast } from "sonner";
-
-import {
- PromptInput,
- PromptInputFooter,
- PromptInputSubmit,
- PromptInputTextarea,
-} from "@/components/ai-elements/prompt-input";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
-import { ArtifactsProvider } from "@/components/workspace/artifacts";
-import { MessageList } from "@/components/workspace/messages";
-import { ThreadContext } from "@/components/workspace/messages/context";
-import { getAPIClient } from "@/core/api";
-import type { Agent } from "@/core/agents";
-import {
- AgentNameCheckError,
- checkAgentName,
- createAgent,
- getAgent,
-} from "@/core/agents/api";
-import { useI18n } from "@/core/i18n/hooks";
-import { useSkills } from "@/core/skills/hooks";
-import type { Skill } from "@/core/skills";
-import { useThreadStream } from "@/core/threads/hooks";
-import { uuid } from "@/core/utils/uuid";
-import { isIMEComposing } from "@/lib/ime";
-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 DEFAULT_SELECTED_SKILL_COUNT = 3;
-
-function wait(ms: number) {
- return new Promise((resolve) => window.setTimeout(resolve, ms));
-}
-
-async function getAgentWithRetry(agentId: string) {
- for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
- if (delay > 0) {
- await wait(delay);
- }
-
- try {
- return await getAgent(agentId);
- } catch {
- // Retry until the write settles or the attempts are exhausted.
- }
- }
-
- return null;
-}
-
-function getCreateAgentErrorMessage(
- error: unknown,
- networkErrorMessage: string,
- fallbackMessage: string,
-) {
- if (error instanceof TypeError && error.message === "Failed to fetch") {
- return networkErrorMessage;
- }
- if (error instanceof Error && error.message) {
- return error.message;
- }
- return fallbackMessage;
-}
-
-export default function NewAgentPage() {
- const { t } = useI18n();
- const router = useRouter();
-
- const [step, setStep] = useState("name");
- const [nameInput, setNameInput] = useState("");
- const [nameError, setNameError] = useState("");
- const [isCheckingName, setIsCheckingName] = useState(false);
- const [isCreatingAgent, setIsCreatingAgent] = useState(false);
- const [agentId, setAgentId] = useState("");
- const [agent, setAgent] = useState(null);
- const [showSaveHint, setShowSaveHint] = useState(false);
- const [setupAgentStatus, setSetupAgentStatus] =
- useState("idle");
- const [selectedSkills, setSelectedSkills] = useState([]);
- const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
- const [threadPrepared, setThreadPrepared] = useState(false);
- const { skills, isLoading: isSkillsLoading } = useSkills();
- const enabledSkills = useMemo(
- () => skills.filter((skill) => skill.enabled),
- [skills],
- );
-
- const threadId = useMemo(() => uuid(), []);
-
- useEffect(() => {
- if (isSkillSelectionTouched) {
- return;
- }
- const defaults = enabledSkills
- .slice(0, DEFAULT_SELECTED_SKILL_COUNT)
- .map((skill) => skill.name);
- setSelectedSkills(defaults);
- }, [enabledSkills, isSkillSelectionTouched]);
-
- const [thread, sendMessage] = useThreadStream({
- threadId: step === "chat" ? threadId : undefined,
- assistantId: agentId || undefined,
- context: {
- mode: "flash",
- is_bootstrap: true,
- },
- onFinish() {
- if (!agent && setupAgentStatus === "requested") {
- setSetupAgentStatus("idle");
- }
- },
- onToolEnd({ name }) {
- if (name !== "setup_agent" || !agentId) return;
- setSetupAgentStatus("completed");
- void getAgentWithRetry(agentId).then((fetched) => {
- if (fetched) {
- setAgent(fetched);
- return;
- }
-
- toast.error(t.agents.agentCreatedPendingRefresh);
- });
- },
- });
-
- const ensureThreadExists = useCallback(async () => {
- if (threadPrepared) {
- return;
- }
- await getAPIClient().threads.create({
- threadId,
- });
- setThreadPrepared(true);
- }, [threadPrepared, threadId]);
-
- 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;
- }
-
- if (enabledSkills.length > 0 && selectedSkills.length === 0) {
- setNameError("请至少选择一个技能");
- return;
- }
-
- setNameError("");
- setIsCheckingName(true);
- try {
- await checkAgentName(trimmed);
- } catch (err) {
- if (
- err instanceof AgentNameCheckError &&
- err.reason === "backend_unreachable"
- ) {
- setNameError(t.agents.nameStepNetworkError);
- } else {
- setNameError(t.agents.nameStepCheckError);
- }
- return;
- } finally {
- setIsCheckingName(false);
- }
-
- let createdId: string;
- let createdName: string;
- setIsCreatingAgent(true);
- try {
- const created = await createAgent({
- name: trimmed,
- description: "",
- skills: selectedSkills,
- soul: "",
- });
- createdId = created.id;
- createdName = created.name;
- } catch (err) {
- setNameError(
- getCreateAgentErrorMessage(
- err,
- t.agents.nameStepNetworkError,
- t.agents.nameStepCheckError,
- ),
- );
- return;
- } finally {
- setIsCreatingAgent(false);
- }
-
- setAgentId(createdId);
- setStep("chat");
- await ensureThreadExists();
- await sendMessage(threadId, {
- text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
- files: [],
- });
- }, [
- nameInput,
- sendMessage,
- t.agents.nameStepNetworkError,
- t.agents.nameStepBootstrapMessage,
- t.agents.nameStepCheckError,
- t.agents.nameStepInvalidError,
- threadId,
- enabledSkills.length,
- selectedSkills,
- ensureThreadExists,
- ]);
-
- const toggleSkill = useCallback((skill: Skill) => {
- setIsSkillSelectionTouched(true);
- setNameError("");
- setSelectedSkills((prev) => {
- if (prev.includes(skill.name)) {
- return prev.filter((name) => name !== skill.name);
- }
- return [...prev, skill.name];
- });
- }, []);
-
- const handleNameKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !isIMEComposing(e)) {
- e.preventDefault();
- void handleConfirmName();
- }
- };
-
- const handleChatSubmit = useCallback(
- async (text: string) => {
- const trimmed = text.trim();
- if (!trimmed || thread.isLoading) return;
- await ensureThreadExists();
- await sendMessage(
- threadId,
- { text: trimmed, files: [] },
- { agent_id: agentId },
- );
- },
- [agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists],
- );
-
- const handleSaveAgent = useCallback(async () => {
- if (
- !agentId ||
- agent ||
- thread.isLoading ||
- setupAgentStatus !== "idle"
- ) {
- return;
- }
-
- setSetupAgentStatus("requested");
- setShowSaveHint(false);
- try {
- await sendMessage(
- threadId,
- { text: t.agents.saveCommandMessage, files: [] },
- { agent_id: agentId },
- { additionalKwargs: { hide_from_ui: 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 = (
-
-
-
-
{t.agents.createPageTitle}
-
-
- {step === "chat" ? (
-
-
-
-
-
- void handleSaveAgent()}
- disabled={
- !!agent || thread.isLoading || setupAgentStatus !== "idle"
- }
- >
-
- {setupAgentStatus === "requested"
- ? t.agents.saving
- : t.agents.save}
-
-
-
- ) : null}
-
- );
-
- if (step === "name") {
- return (
-
- {header}
-
-
-
-
-
-
-
-
- {t.agents.nameStepTitle}
-
-
- {t.agents.nameStepHint}
-
-
-
-
-
-
{
- setNameInput(e.target.value);
- setNameError("");
- }}
- onKeyDown={handleNameKeyDown}
- className={cn(nameError && "border-destructive")}
- />
- {nameError ? (
-
{nameError}
- ) : null}
-
-
-
默认技能(可自定义)
-
- 已选 {selectedSkills.length} 个
-
-
-
- {isSkillsLoading ? (
-
技能加载中...
- ) : enabledSkills.length === 0 ? (
-
- 当前没有可用技能,将创建为无技能智能体。
-
- ) : (
-
- {enabledSkills.map((skill) => {
- const checked = selectedSkills.includes(skill.name);
- return (
-
- );
- })}
-
- )}
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
- {header}
-
-
- {showSaveHint ? (
-
-
-
-
- {t.agents.saveHint}
-
-
-
- ) : null}
-
-
-
-
-
-
-
- {agent ? (
-
-
-
{t.agents.agentCreated}
-
-
-
-
-
- ) : (
-
void handleChatSubmit(text)}
- >
-
-
-
-
-
- )}
-
-
-
-
-
-
- );
-}
+"use client";
+
+import {
+ ArrowLeftIcon,
+ CheckCircleIcon,
+ SaveIcon,
+} from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+
+import {
+ PromptInput,
+ PromptInputFooter,
+ PromptInputSubmit,
+ PromptInputTextarea,
+} from "@/components/ai-elements/prompt-input";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { ArtifactsProvider } from "@/components/workspace/artifacts";
+import {
+ MessageList,
+ type SetupAgentApprovalPayload,
+} from "@/components/workspace/messages";
+import { ThreadContext } from "@/components/workspace/messages/context";
+import { getAPIClient } from "@/core/api";
+import type { Agent } from "@/core/agents";
+import {
+ AgentNameCheckError,
+ checkAgentName,
+ createAgent,
+ getAgent,
+ updateAgent,
+} from "@/core/agents/api";
+import { useI18n } from "@/core/i18n/hooks";
+import type { Skill } from "@/core/skills";
+import { useSkills } from "@/core/skills/hooks";
+import { useThreadStream } from "@/core/threads/hooks";
+import { uuid } from "@/core/utils/uuid";
+import { cn } from "@/lib/utils";
+
+const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
+const DEFAULT_SELECTED_SKILL_COUNT = 3;
+
+function wait(ms: number) {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
+async function getAgentWithRetry(agentId: string) {
+ for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
+ if (delay > 0) {
+ await wait(delay);
+ }
+ try {
+ return await getAgent(agentId);
+ } catch {
+ // Retry until the file/database write settles.
+ }
+ }
+ return null;
+}
+
+function getCreateAgentErrorMessage(
+ error: unknown,
+ networkErrorMessage: string,
+ fallbackMessage: string,
+) {
+ if (error instanceof TypeError && error.message === "Failed to fetch") {
+ return networkErrorMessage;
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallbackMessage;
+}
+
+export default function NewAgentPage() {
+ const { t } = useI18n();
+ const router = useRouter();
+ const threadId = useMemo(() => uuid(), []);
+
+ const [nameInput, setNameInput] = useState("");
+ const [descriptionInput, setDescriptionInput] = useState("");
+ const [soulInput, setSoulInput] = useState("");
+ const [formError, setFormError] = useState("");
+ const [agentId, setAgentId] = useState("");
+ const [agent, setAgent] = useState(null);
+ const [isCreatingAgent, setIsCreatingAgent] = useState(false);
+ const [isSavingDraft, setIsSavingDraft] = useState(false);
+ const [selectedSkills, setSelectedSkills] = useState([]);
+ const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
+ const [threadPrepared, setThreadPrepared] = useState(false);
+ const { skills, isLoading: isSkillsLoading } = useSkills();
+
+ const enabledSkills = useMemo(
+ () => skills.filter((skill) => skill.enabled),
+ [skills],
+ );
+
+ useEffect(() => {
+ if (isSkillSelectionTouched) {
+ return;
+ }
+ setSelectedSkills(
+ enabledSkills
+ .slice(0, DEFAULT_SELECTED_SKILL_COUNT)
+ .map((skill) => skill.name),
+ );
+ }, [enabledSkills, isSkillSelectionTouched]);
+
+ const [thread, sendMessage] = useThreadStream({
+ threadId,
+ assistantId: agentId || undefined,
+ context: {
+ mode: "flash",
+ is_bootstrap: true,
+ },
+ onToolEnd({ name }) {
+ if (name !== "setup_agent" || !agentId) {
+ return;
+ }
+ void getAgentWithRetry(agentId).then((fetched) => {
+ if (fetched) {
+ setAgent(fetched);
+ setDescriptionInput(fetched.description ?? "");
+ setSoulInput(fetched.soul ?? "");
+ setSelectedSkills(fetched.skills ?? []);
+ toast.success("专家配置已创建");
+ }
+ });
+ },
+ });
+
+ const ensureThreadExists = useCallback(async () => {
+ if (threadPrepared) {
+ return;
+ }
+ await getAPIClient().threads.create({ threadId });
+ setThreadPrepared(true);
+ }, [threadId, threadPrepared]);
+
+ 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) {
+ setFormError("请至少选择一个技能");
+ 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);
+ }
+
+ if (agentId) {
+ setIsSavingDraft(true);
+ try {
+ const updated = await updateAgent(agentId, {
+ name,
+ description: descriptionInput,
+ soul: soulInput,
+ skills: selectedSkills,
+ });
+ setAgent(updated);
+ return updated;
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : String(error));
+ return null;
+ } finally {
+ setIsSavingDraft(false);
+ }
+ }
+
+ setIsCreatingAgent(true);
+ try {
+ await checkAgentName(name);
+ const created = await createAgent({
+ name,
+ description: descriptionInput,
+ soul: soulInput,
+ skills: selectedSkills,
+ });
+ setAgentId(created.id);
+ setAgent(created);
+ return created;
+ } catch (error) {
+ if (
+ error instanceof AgentNameCheckError &&
+ error.reason === "backend_unreachable"
+ ) {
+ setFormError(t.agents.nameStepNetworkError);
+ } else {
+ setFormError(
+ getCreateAgentErrorMessage(
+ error,
+ t.agents.nameStepNetworkError,
+ t.agents.nameStepCheckError,
+ ),
+ );
+ }
+ return null;
+ } finally {
+ setIsCreatingAgent(false);
+ }
+ }, [
+ agentId,
+ descriptionInput,
+ selectedSkills,
+ soulInput,
+ t.agents.nameStepCheckError,
+ t.agents.nameStepNetworkError,
+ validateForm,
+ ]);
+
+ const handleSaveAgent = useCallback(async () => {
+ const saved = await ensureAgent();
+ if (!saved) {
+ return;
+ }
+ toast.success(`专家「${saved.name}」已保存`);
+ }, [ensureAgent]);
+
+ const handleChatSubmit = useCallback(
+ async (text: string) => {
+ const trimmed = text.trim();
+ 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 sendMessage(
+ threadId,
+ { text: trimmed, files: [] },
+ { agent_id: activeAgentId },
+ );
+ },
+ [agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
+ );
+
+ const handleApproveSetupAgent = useCallback(
+ async (payload: SetupAgentApprovalPayload) => {
+ if (!agentId) {
+ return;
+ }
+ // 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 /
+ // 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 ?? [];
+
+ // Prefer the model's proposed name when present; fall back to whatever
+ // the user already typed, or the existing agent record.
+ const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
+ setNameInput(resolvedName);
+ setDescriptionInput(newDescription);
+ setSoulInput(newSoul);
+ setSelectedSkills(newSkills);
+ setIsSkillSelectionTouched(true);
+
+ try {
+ const updated = await updateAgent(agentId, {
+ name: resolvedName,
+ description: newDescription,
+ soul: newSoul,
+ skills: newSkills,
+ });
+ setAgent(updated);
+ toast.success("专家配置已保存");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "保存失败");
+ }
+ },
+ [agent, agentId, nameInput],
+ );
+
+ const handleRejectSetupAgent = useCallback(
+ async () => {
+ await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
+ },
+ [handleChatSubmit],
+ );
+
+ const toggleSkill = useCallback((skill: Skill) => {
+ setIsSkillSelectionTouched(true);
+ setFormError("");
+ setSelectedSkills((prev) =>
+ prev.includes(skill.name)
+ ? prev.filter((name) => name !== skill.name)
+ : [...prev, skill.name],
+ );
+ }, []);
+
+ const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
+
+ return (
+
+
+
+
+
+
+
创建专家
+
+ {agent ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
void handleChatSubmit(text)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py
index e40cd29c..11527961 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/agents.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py
@@ -89,6 +89,12 @@ def _current_user_id(request: Request) -> str:
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:
normalized = name.strip()
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")
+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(
"/agents",
response_model=AgentsListResponse,
@@ -322,7 +351,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
try:
store = get_agent_store(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
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:
_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:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
index c1794647..481f1f7e 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
@@ -8,8 +8,9 @@ from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.memory.summarization_hook import memory_flush_hook
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
-from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
-from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
+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.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
@@ -303,8 +304,10 @@ def _build_middlewares(
if custom_middlewares:
middlewares.extend(custom_middlewares)
- # ClarificationMiddleware should always be last
- middlewares.append(ClarificationMiddleware())
+ middlewares.append(SetupAgentApprovalMiddleware())
+
+ # ClarificationMiddleware should always be last
+ middlewares.append(ClarificationMiddleware())
return middlewares
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_agent_approval_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_agent_approval_middleware.py
new file mode 100644
index 00000000..51a90017
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_agent_approval_middleware.py
@@ -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)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py
index f5bf22fe..dce4a983 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py
@@ -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:
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
async def delete(self, agent_id: str, user_id: str) -> bool:
raise NotImplementedError
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py
index e17b3c40..3220b330 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py
@@ -91,6 +91,16 @@ class MemoryAgentStore(AgentStore):
row["updated_at"] = _now()
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:
row = self._agents.get(agent_id)
if row is None or row.get("user_id") != user_id:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py
index 28db88a2..97b33ece 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py
@@ -107,6 +107,19 @@ class AgentRepository(AgentStore):
await session.refresh(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 with self._sf() as session:
row = await session.get(AgentRow, agent_id)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py
index 0b7f2b2b..aeb6a537 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py
@@ -17,6 +17,7 @@ def setup_agent(
soul: str,
description: str,
runtime: ToolRuntime,
+ name: str | None = None,
skills: list[str] | None = None,
) -> Command:
"""Setup the custom DeerFlow agent.
@@ -24,6 +25,8 @@ def setup_agent(
Args:
soul: Full SOUL.md content defining the agent's personality and behavior.
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.
"""
@@ -40,7 +43,8 @@ def setup_agent(
if agent_name:
# 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:
config_data["description"] = description
if skills is not None:
diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py
index b0459d3b..070be227 100644
--- a/offline-backend-20260512/backend/tests/test_agents_backend.py
+++ b/offline-backend-20260512/backend/tests/test_agents_backend.py
@@ -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
+@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:
config = build_run_config("thread-1", None, None, assistant_id="agent-123")