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:
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,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 ? (
|
||||
|
||||
+56
-32
@@ -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)
|
||||
Reference in new issue
Block a user