fix: 修复 setup_agent 创建专家流程的多个问题

后端 (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) <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Opus 4.7 committed 2026-05-14 12:35:49 +08:00
1 parent 8f4af5ece4
commit 4cc8b3198b
3 files changed
+87 -114

No files matched your search

@@ -91,7 +91,7 @@ export function AgentCard({ agent }: AgentCardProps) {
]); ]);
function handleChat() { function handleChat() {
router.push(`/workspace/agents/${agent.id}/chats/new`); router.push(`/page/workspace/agents/${agent.id}/chats/new`);
} }
async function handleDelete() { async function handleDelete() {
+30 -81
View File
@@ -30,7 +30,6 @@ import {
AgentNameCheckError, AgentNameCheckError,
checkAgentName, checkAgentName,
createAgent, createAgent,
getAgent,
updateAgent, updateAgent,
} from "@/core/agents/api"; } from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -40,27 +39,8 @@ import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
const DEFAULT_SELECTED_SKILL_COUNT = 3; 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( function getCreateAgentErrorMessage(
error: unknown, error: unknown,
networkErrorMessage: string, networkErrorMessage: string,
@@ -116,19 +96,11 @@ export default function NewAgentPage() {
mode: "flash", mode: "flash",
is_bootstrap: true, is_bootstrap: true,
}, },
onToolEnd({ name }) { onToolEnd() {
if (name !== "setup_agent" || !agentId) { // setup_agent never actually executes in this flow — it gets
return; // intercepted by SetupAgentApprovalMiddleware and the form is filled
} // from the approval ToolMessage payload (handleApproveSetupAgent).
void getAgentWithRetry(agentId).then((fetched) => { // No agent record is fetched/refreshed during chat.
if (fetched) {
setAgent(fetched);
setDescriptionInput(fetched.description ?? "");
setSoulInput(fetched.soul ?? "");
setSelectedSkills(fetched.skills ?? []);
toast.success("专家配置已创建");
}
});
}, },
}); });
@@ -159,10 +131,13 @@ export default function NewAgentPage() {
} }
let name = provided; let name = provided;
if (!name) { if (!name) {
// Names are display-only and may collide, so a timestamped placeholder is // Names are display-only and may collide, so a timestamped placeholder
// safe. Reflect it in the form so the user sees what got created. // 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)}`; name = `未命名专家-${Date.now().toString(36)}`;
setNameInput(name);
} }
if (agentId) { if (agentId) {
@@ -239,63 +214,37 @@ export default function NewAgentPage() {
if (!trimmed || thread.isLoading) { if (!trimmed || thread.isLoading) {
return; return;
} }
// Lazily create the agent on first message so the user can just start // Chat is purely a discussion channel. The agent record is only
// typing — ensureAgent generates a placeholder name when none is provided. // created when the user explicitly clicks Save (top-right). If the
let activeAgentId = agentId; // model proposes a config via setup_agent, our approval handler
if (!activeAgentId) { // prefills the form locally — it does NOT persist anything.
const created = await ensureAgent();
if (!created) {
return;
}
activeAgentId = created.id;
}
await ensureThreadExists(); await ensureThreadExists();
await sendMessage( await sendMessage(threadId, { text: trimmed, files: [] });
threadId,
{ text: trimmed, files: [] },
{ agent_id: activeAgentId },
);
}, },
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId], [ensureThreadExists, sendMessage, thread.isLoading, threadId],
); );
const handleApproveSetupAgent = useCallback( const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => { async (payload: SetupAgentApprovalPayload) => {
if (!agentId) { // Approving the model's setup_agent proposal **does not** persist
return; // anything — it only mirrors the proposal into the form so the user
} // can review it. Creation/update happens later when the user clicks
// Approval persists directly to the backend — no LLM round-trip. The // the top-right Save button.
// 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 proposedName = payload.name?.trim() ?? "";
const newDescription = payload.description ?? ""; const newDescription = payload.description ?? "";
const newSoul = payload.soul ?? ""; const newSoul = payload.soul ?? "";
const newSkills = payload.skills ?? []; const newSkills = payload.skills ?? [];
// Prefer the model's proposed name when present; fall back to whatever if (proposedName) {
// the user already typed, or the existing agent record. setNameInput(proposedName);
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId; }
setNameInput(resolvedName);
setDescriptionInput(newDescription); setDescriptionInput(newDescription);
setSoulInput(newSoul); setSoulInput(newSoul);
setSelectedSkills(newSkills); setSelectedSkills(newSkills);
setIsSkillSelectionTouched(true); setIsSkillSelectionTouched(true);
toast.success("已套用模型建议,可继续编辑或点保存");
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( const handleRejectSetupAgent = useCallback(
@@ -326,7 +275,7 @@ export default function NewAgentPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => router.push("/workspace/agents")} onClick={() => router.push("/page/workspace/agents")}
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
</Button> </Button>
@@ -336,7 +285,7 @@ export default function NewAgentPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)} onClick={() => router.push(`/page/workspace/agents/${agent.id}/chats/new`)}
> >
<CheckCircleIcon className="h-4 w-4" /> <CheckCircleIcon className="h-4 w-4" />
开始对话 开始对话
@@ -411,7 +360,7 @@ export default function NewAgentPage() {
placeholder="写下专家的人设、边界、工作方式和输出要求" placeholder="写下专家的人设、边界、工作方式和输出要求"
value={soulInput} value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)} onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 resize-y font-mono text-xs" className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
/> />
</div> </div>
@@ -422,7 +371,7 @@ export default function NewAgentPage() {
已选 {selectedSkills.length} 个 已选 {selectedSkills.length} 个
</span> </span>
</div> </div>
<div className="rounded-md border p-2"> <div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? ( {isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p> <p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? ( ) : enabledSkills.length === 0 ? (
@@ -3,16 +3,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable
from hashlib import sha256 from hashlib import sha256
from typing import Any, override from typing import Any, override
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.messages import ToolMessage from langchain_core.messages import AIMessage, ToolMessage
from langgraph.graph import END from langgraph.runtime import Runtime
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,15 +23,22 @@ class SetupAgentApprovalMiddlewareState(AgentState):
class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareState]): class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareState]):
"""Stop before ``setup_agent`` writes files and ask the user to approve. """Stop before ``setup_agent`` writes files and ask the user to approve.
The first ``setup_agent`` call is converted into a structured tool message The interception runs in ``after_model``: as soon as the model emits an
and the run ends. A subsequent request can set ``setup_agent_approved`` in AIMessage that contains a ``setup_agent`` tool call (and the run has not
runtime context to allow the tool to execute. been pre-approved via ``setup_agent_approved=True`` in runtime context),
we append a structured approval ``ToolMessage`` and return
``jump_to="end"`` to terminate the LangGraph run *before* the tool node
executes. This is the LangChain-recommended way to short-circuit after
the model; using ``wrap_tool_call`` + ``Command(goto=END)`` proved
unreliable when the AIMessage contained multiple parallel tool calls
(the ``Command`` got mixed with normal tool outputs and the graph
looped back to the model instead of terminating).
""" """
state_schema = SetupAgentApprovalMiddlewareState state_schema = SetupAgentApprovalMiddlewareState
def _is_approved(self, request: ToolCallRequest) -> bool: def _is_approved(self, runtime: Runtime) -> bool:
context = getattr(request.runtime, "context", None) or {} context = getattr(runtime, "context", None) or {}
if not isinstance(context, dict): if not isinstance(context, dict):
return False return False
return context.get("setup_agent_approved") is True return context.get("setup_agent_approved") is True
@@ -54,37 +58,57 @@ class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareS
"skills": skills if isinstance(skills, list) else [], "skills": skills if isinstance(skills, list) else [],
} }
def _handle_setup_agent(self, request: ToolCallRequest) -> Command: def _intercept(self, last: AIMessage) -> dict[str, Any] | None:
args = request.tool_call.get("args", {}) or {} """Return a state update if setup_agent must be gated; None to passthrough."""
tool_call_id = request.tool_call.get("id", "") tool_calls = list(last.tool_calls or [])
setup_call = next((tc for tc in tool_calls if tc.get("name") == "setup_agent"), None)
if setup_call is None:
return None
args = setup_call.get("args") or {}
tool_call_id = setup_call.get("id") or ""
payload = self._build_payload(args) payload = self._build_payload(args)
logger.info("Setup agent approval requested before writing agent files") logger.info("Setup agent approval requested before writing agent files")
message = ToolMessage( approval_msg = ToolMessage(
id=self._stable_message_id(tool_call_id, args), id=self._stable_message_id(tool_call_id, args),
content="请确认是否创建/更新这个专家配置。", content="请确认是否创建/更新这个专家配置。",
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
name="setup_agent", name="setup_agent",
additional_kwargs={"setup_agent_approval": payload}, additional_kwargs={"setup_agent_approval": payload},
) )
return Command(update={"messages": [message]}, goto=END)
@override # Any other tool_calls on the same AIMessage are left dangling; the
def wrap_tool_call( # existing DanglingToolCallMiddleware will inject placeholder
self, # ToolMessages on the next model invocation, which matches the
request: ToolCallRequest, # desired semantics: every action pauses until the user approves.
handler: Callable[[ToolCallRequest], ToolMessage | Command], return {
) -> ToolMessage | Command: "messages": [approval_msg],
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request): "jump_to": "end",
return handler(request) }
return self._handle_setup_agent(request)
@hook_config(can_jump_to=["end"])
@override @override
async def awrap_tool_call( def after_model(
self, self,
request: ToolCallRequest, state: SetupAgentApprovalMiddlewareState,
handler: Callable[[ToolCallRequest], ToolMessage | Command], runtime: Runtime,
) -> ToolMessage | Command: ) -> dict[str, Any] | None:
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request): if self._is_approved(runtime):
return await handler(request) return None
return self._handle_setup_agent(request) messages = state.get("messages") or []
if not messages:
return None
last = messages[-1]
if not isinstance(last, AIMessage):
return None
return self._intercept(last)
@hook_config(can_jump_to=["end"])
@override
async def aafter_model(
self,
state: SetupAgentApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
return self.after_model(state, runtime)