From 4cc8b3198b6531a3d47f4f876e481ef1a802894a Mon Sep 17 00:00:00 2001 From: xuhd Date: Thu, 14 May 2026 12:35:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20setup=5Fagent=20?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=B8=93=E5=AE=B6=E6=B5=81=E7=A8=8B=E7=9A=84?= =?UTF-8?q?=E5=A4=9A=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 (setup_agent_approval_middleware.py): - 拦截策略从 wrap_tool_call + Command(goto=END) 改为 after_model + jump_to="end"。在 tool_node 执行前直接终止图, 避免模型并行调多个 tool_call 时 Command 输出被稀释、graph 路由不稳定的问题(症状:审批 ToolMessage 发出后流不结束)。 - 用 @hook_config(can_jump_to=["end"]) 装饰器声明跳转能力, 否则 LangChain 不会为该 hook 构造 conditional edge。 前端 (NewAgentPage.tsx): - 聊天阶段不再调 ensureAgent / POST /api/agents,placeholder agent 不再落库;agent 仅在用户点右上角“保存”时创建。 - 自动生成的占位名 "未命名专家-xxx" 不再回写到输入框,保持空白 直到用户输入或 setup_agent 审批回填。 - handleApproveSetupAgent 仅把模型 propose 的 payload 填入表单, 不再 PUT /api/agents/{id};落库交给手动保存按钮。 - onToolEnd 不再 getAgentWithRetry —— 工具被 middleware 拦截、 根本不会执行,没有 agent 状态可拉。清理对应死代码(getAgent import、wait()、AGENT_READ_RETRY_DELAYS_MS、getAgentWithRetry)。 - 路由跳转补齐 /page 前缀:返回按钮、“开始对话”按钮。 - 右侧表单:SOUL.md textarea 加 max-h-[400px],技能列表外框加 max-h-[240px],各自 overflow-y-auto,避免长内容撑高整列。 前端 (agent-card.tsx): - 列表卡片“聊天”按钮的跳转路径补齐 /page 前缀,避免点击后 404。 无新依赖。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspace/agents/agent-card.tsx | 2 +- frontend-web/src/pages/NewAgentPage.tsx | 111 +++++------------- .../setup_agent_approval_middleware.py | 88 +++++++++----- 3 files changed, 87 insertions(+), 114 deletions(-) diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 86f32274..bbae87fd 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -91,7 +91,7 @@ export function AgentCard({ agent }: AgentCardProps) { ]); function handleChat() { - router.push(`/workspace/agents/${agent.id}/chats/new`); + router.push(`/page/workspace/agents/${agent.id}/chats/new`); } async function handleDelete() { diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 23adff1f..f60e7642 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -30,7 +30,6 @@ import { AgentNameCheckError, checkAgentName, createAgent, - getAgent, updateAgent, } from "@/core/agents/api"; import { useI18n } from "@/core/i18n/hooks"; @@ -40,27 +39,8 @@ 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, @@ -116,19 +96,11 @@ export default function NewAgentPage() { 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("专家配置已创建"); - } - }); + onToolEnd() { + // setup_agent never actually executes in this flow — it gets + // intercepted by SetupAgentApprovalMiddleware and the form is filled + // from the approval ToolMessage payload (handleApproveSetupAgent). + // No agent record is fetched/refreshed during chat. }, }); @@ -159,10 +131,13 @@ export default function NewAgentPage() { } 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. + // Names are display-only and may collide, so a timestamped placeholder + // is safe to send to the backend just to satisfy the required-field + // validation. Intentionally NOT written back into the visible input — + // the user wants the name box to stay blank while they chat, and we + // refill it later (with the model's proposed name) on setup_agent + // approval. name = `未命名专家-${Date.now().toString(36)}`; - setNameInput(name); } if (agentId) { @@ -239,63 +214,37 @@ export default function NewAgentPage() { 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; - } + // Chat is purely a discussion channel. The agent record is only + // created when the user explicitly clicks Save (top-right). If the + // model proposes a config via setup_agent, our approval handler + // prefills the form locally — it does NOT persist anything. await ensureThreadExists(); - await sendMessage( - threadId, - { text: trimmed, files: [] }, - { agent_id: activeAgentId }, - ); + await sendMessage(threadId, { text: trimmed, files: [] }); }, - [agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId], + [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. + // Approving the model's setup_agent proposal **does not** persist + // anything — it only mirrors the proposal into the form so the user + // can review it. Creation/update happens later when the user clicks + // the top-right Save button. 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); + if (proposedName) { + setNameInput(proposedName); + } 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 : "保存失败"); - } + toast.success("已套用模型建议,可继续编辑或点保存"); }, - [agent, agentId, nameInput], + [], ); const handleRejectSetupAgent = useCallback( @@ -326,7 +275,7 @@ export default function NewAgentPage() { @@ -336,7 +285,7 @@ export default function NewAgentPage() {