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
+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