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

@@ -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)