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() {
router.push(`/workspace/agents/${agent.id}/chats/new`);
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
async function handleDelete() {
+30 -81
View File
@@ -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() {
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
onClick={() => router.push("/page/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
@@ -336,7 +285,7 @@ export default function NewAgentPage() {
<Button
variant="outline"
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" />
开始对话
@@ -411,7 +360,7 @@ export default function NewAgentPage() {
placeholder="写下专家的人设、边界、工作方式和输出要求"
value={soulInput}
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>
@@ -422,7 +371,7 @@ export default function NewAgentPage() {
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
@@ -3,16 +3,13 @@
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
from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.messages import AIMessage, ToolMessage
from langgraph.runtime import Runtime
logger = logging.getLogger(__name__)
@@ -26,15 +23,22 @@ class SetupAgentApprovalMiddlewareState(AgentState):
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.
The interception runs in ``after_model``: as soon as the model emits an
AIMessage that contains a ``setup_agent`` tool call (and the run has not
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
def _is_approved(self, request: ToolCallRequest) -> bool:
context = getattr(request.runtime, "context", None) or {}
def _is_approved(self, runtime: Runtime) -> bool:
context = getattr(runtime, "context", None) or {}
if not isinstance(context, dict):
return False
return context.get("setup_agent_approved") is True
@@ -54,37 +58,57 @@ class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareS
"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", "")
def _intercept(self, last: AIMessage) -> dict[str, Any] | None:
"""Return a state update if setup_agent must be gated; None to passthrough."""
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)
logger.info("Setup agent approval requested before writing agent files")
message = ToolMessage(
approval_msg = 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)
# Any other tool_calls on the same AIMessage are left dangling; the
# existing DanglingToolCallMiddleware will inject placeholder
# ToolMessages on the next model invocation, which matches the
# desired semantics: every action pauses until the user approves.
return {
"messages": [approval_msg],
"jump_to": "end",
}
@hook_config(can_jump_to=["end"])
@override
async def awrap_tool_call(
def after_model(
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)
state: SetupAgentApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
if self._is_approved(runtime):
return None
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)