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 ? (