From 70024617a9674ab51345cdf174b8f4fd07da428d Mon Sep 17 00:00:00 2001 From: xiangchang_24 <1476897511@qq.com> Date: Tue, 23 Jun 2026 13:08:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(agents):=20=E4=BF=AE=E5=A4=8D=20HIL=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=AE=A1=E6=89=B9=20interrupt=20=E5=9C=A8?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E4=B8=89=E5=A4=84=E8=A2=AB=E8=AF=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HumanInTheLoopMiddleware 触发的工具审批 interrupt 载荷为 action_requests/review_configs(HITLRequest),与 ask_user_question 的 questions 结构不同,原有逻辑在三处按 questions 假设导致 HIL 不可用: 1. check_and_handle_interrupts 无 questions 时塞默认空问题 「请选择一个选项」,前端收到空弹窗。现按 payload 是否含 action_requests 分流,HIL 中断发 human_approval_required 并携带 action_requests/review_configs,ask_user_question 保持原有行为。 2. _compact_stream_chunk 的 verbose=false 精简白名单漏掉 action_requests/review_configs,前端拿不到审批载荷。白名单补入 这两个字段。 3. run_worker 的 interrupt 摘要只取 questions,HIL 摘要为空。 _interrupt_summary 兼容两类载荷,HIL 摘要改为 「操作需要确认: 工具名(参数)」。 补充单元测试覆盖 payload 分流、compact 字段保留与摘要生成。 --- .../yuxi/services/agent_run_service.py | 2 + backend/package/yuxi/services/chat_service.py | 51 ++++++- backend/package/yuxi/services/run_worker.py | 36 ++++- .../unit/services/test_agent_run_service.py | 43 ++++++ .../services/test_chat_stream_interrupt.py | 142 +++++++++++++++++- 5 files changed, 262 insertions(+), 12 deletions(-) diff --git a/backend/package/yuxi/services/agent_run_service.py b/backend/package/yuxi/services/agent_run_service.py index d8f662636..80c2dac2d 100644 --- a/backend/package/yuxi/services/agent_run_service.py +++ b/backend/package/yuxi/services/agent_run_service.py @@ -146,6 +146,8 @@ def _compact_stream_chunk(chunk: dict) -> dict: "interrupt_info", "source", "agent_state", + "action_requests", + "review_configs", ) if chunk.get(key) is not None and chunk.get(key) != "" } diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index b005942fb..0c3ee0e90 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -593,6 +593,45 @@ def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any } +def _is_human_approval_payload(payload: dict) -> bool: + """判断 interrupt 是否为 HumanInTheLoopMiddleware 的工具审批载荷。 + + HIL 中间件产生的 interrupt value 含 ``action_requests``(待审批的工具调用) + 与 ``review_configs``(每个工具允许的决策类型),与 ask_user_question 的 + ``questions`` 结构不同。用 ``action_requests`` 作为判别依据。 + """ + action_requests = payload.get("action_requests") + return isinstance(action_requests, list) and len(action_requests) > 0 + + +def _build_human_approval_payload(info: Any, thread_id: str) -> dict[str, Any]: + """将 HIL 工具审批 interrupt 标准化为 human_approval_required 载荷。""" + payload = _coerce_interrupt_payload(info) + + action_requests = payload.get("action_requests") or [] + review_configs = payload.get("review_configs") or [] + + # 为每个 action_request 补齐 description(供前端展示),保留原始字段 + normalized_actions: list[dict[str, Any]] = [] + for action in action_requests: + if not isinstance(action, dict): + continue + action = dict(action) + if not action.get("description"): + action["description"] = "操作需要确认\n\nTool: {name}\nArgs: {args}".format( + name=action.get("name", ""), + args=action.get("args", {}), + ) + normalized_actions.append(action) + + return { + "action_requests": normalized_actions, + "review_configs": review_configs, + "source": "human_approval", + "thread_id": thread_id, + } + + def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None: """如果 full_msg 为空且有累积内容,构建 AIMessage""" if not full_msg and accumulated_content: @@ -673,9 +712,15 @@ async def check_and_handle_interrupts( interrupt_info = _extract_interrupt_info(state) if interrupt_info: - question_payload = _build_ask_user_question_payload(interrupt_info, thread_id) - meta["interrupt"] = question_payload - yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload) + payload = _coerce_interrupt_payload(interrupt_info) + if _is_human_approval_payload(payload): + approval_payload = _build_human_approval_payload(interrupt_info, thread_id) + meta["interrupt"] = approval_payload + yield make_chunk(status="human_approval_required", meta=meta, **approval_payload) + else: + question_payload = _build_ask_user_question_payload(interrupt_info, thread_id) + meta["interrupt"] = question_payload + yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload) except Exception as e: logger.exception(f"Error checking interrupts: {e}") diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index 140808826..d42e21c77 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -229,6 +229,35 @@ def _chunk_thread_id(chunk: dict, fallback: str | None) -> str | None: return _thread_id_from_mapping(chunk) or fallback +def _interrupt_summary(chunk: dict) -> str: + """从 interrupt chunk 提取人类可读的摘要。 + + 兼容两类 interrupt 载荷:ask_user_question 的 ``questions`` 与 + HumanInTheLoop 的 ``action_requests``(工具审批)。无可用信息时返回空串。 + """ + if not isinstance(chunk, dict): + return "" + + questions = chunk.get("questions") + if isinstance(questions, list) and questions: + first = questions[0] + if isinstance(first, dict): + return str(first.get("question") or "").strip() + + action_requests = chunk.get("action_requests") + if isinstance(action_requests, list) and action_requests: + first = action_requests[0] + if isinstance(first, dict): + name = str(first.get("name") or "").strip() + args = first.get("args") + if name: + if args: + return f"操作需要确认: {name}({args})" + return f"操作需要确认: {name}" + + return "" + + def _map_chunk_to_run_event(chunk: dict) -> tuple[str, dict]: status = chunk.get("status") or "event" if status == "loading": @@ -407,12 +436,7 @@ async def process_agent_run(ctx, run_id: str): await _append_end_event(run_id, status_value, thread_id=thread_id, payload={"chunk": chunk}) terminal_set = True elif status in {"ask_user_question_required", "human_approval_required"}: - questions = chunk.get("questions") if isinstance(chunk, dict) else None - first_question = "" - if isinstance(questions, list) and questions: - first = questions[0] - if isinstance(first, dict): - first_question = str(first.get("question") or "").strip() + first_question = _interrupt_summary(chunk) await mark_run_terminal( run_id, diff --git a/backend/test/unit/services/test_agent_run_service.py b/backend/test/unit/services/test_agent_run_service.py index e92dab05f..7552ea630 100644 --- a/backend/test/unit/services/test_agent_run_service.py +++ b/backend/test/unit/services/test_agent_run_service.py @@ -1145,3 +1145,46 @@ async def set_input_message(self, run_id: str, message_id: int): ) assert captured["input_payload"]["model_spec"] == "parent-model" + + +class TestCompactStreamChunkHil: + """测试 _compact_stream_chunk 在 verbose=false 精简时保留 HIL 工具审批字段。 + + 回归:修复前白名单漏掉 action_requests/review_configs,前端收到空弹窗。 + """ + + def test_compact_keeps_human_approval_action_requests(self): + chunk = { + "status": "human_approval_required", + "action_requests": [{"name": "delete_file", "args": {"path": "/x"}}], + "review_configs": [{"action_name": "delete_file", "allowed_decisions": ["approve", "reject"]}], + "source": "human_approval", + } + compact = agent_run_service._compact_stream_chunk(chunk) + + assert compact["status"] == "human_approval_required" + assert compact["action_requests"] == chunk["action_requests"] + assert compact["review_configs"] == chunk["review_configs"] + + def test_compact_keeps_ask_user_question_questions(self): + """ask_user_question 的 questions 仍保留(未回归)。""" + chunk = { + "status": "ask_user_question_required", + "questions": [{"question": "选择一个", "options": ["A"]}], + } + compact = agent_run_service._compact_stream_chunk(chunk) + + assert compact["questions"] == chunk["questions"] + + def test_compact_drops_missing_fields(self): + """白名单外的字段被丢弃;白名单内但未提供的字段不出现。""" + chunk = { + "status": "human_approval_required", + "extra_field": "should be dropped", + } + compact = agent_run_service._compact_stream_chunk(chunk) + + assert compact["status"] == "human_approval_required" + assert "extra_field" not in compact + assert "action_requests" not in compact + assert "review_configs" not in compact diff --git a/backend/test/unit/services/test_chat_stream_interrupt.py b/backend/test/unit/services/test_chat_stream_interrupt.py index b8e7edf74..72c40d75f 100644 --- a/backend/test/unit/services/test_chat_stream_interrupt.py +++ b/backend/test/unit/services/test_chat_stream_interrupt.py @@ -12,10 +12,14 @@ from yuxi.services.chat_service import ( _normalize_interrupt_questions, _build_ask_user_question_payload, + _build_human_approval_payload, + _is_human_approval_payload, _coerce_interrupt_payload, + check_and_handle_interrupts, stream_agent_resume, ) from yuxi.services import chat_service as svc +from yuxi.services import run_worker from yuxi.utils.question_utils import normalize_options @@ -215,9 +219,12 @@ class FakeAgent: context_schema = FakeContext async def stream_resume_with_state(self, resume_command, input_context=None, **kwargs): - yield "messages", ( - {"content": "child token", "id": "msg-child"}, - {"namespace": ["task:1"], "thread_id": "child-thread"}, + yield ( + "messages", + ( + {"content": "child token", "id": "msg-child"}, + {"namespace": ["task:1"], "thread_id": "child-thread"}, + ), ) async def get_graph(self, context=None): @@ -290,3 +297,132 @@ def test_string_input(self): def test_none_input(self): result = _coerce_interrupt_payload(None) assert isinstance(result, dict) + + +class TestHumanApprovalPayloadDetection: + """测试 HIL 工具审批 interrupt 的判别与载荷构建。""" + + def test_is_human_approval_payload_true_for_action_requests(self): + payload = {"action_requests": [{"name": "delete_file", "args": {"path": "/x"}}], "review_configs": []} + assert _is_human_approval_payload(payload) is True + + def test_is_human_approval_payload_false_for_questions(self): + assert _is_human_approval_payload({"questions": [{"question": "Q"}]}) is False + + def test_is_human_approval_payload_false_for_empty(self): + assert _is_human_approval_payload({}) is False + assert _is_human_approval_payload({"action_requests": []}) is False + + def test_build_human_approval_payload_preserves_action_requests(self): + info = { + "action_requests": [{"name": "send_email", "args": {"to": "a@b.com"}, "description": "需要确认"}], + "review_configs": [{"action_name": "send_email", "allowed_decisions": ["approve", "reject"]}], + } + result = _build_human_approval_payload(info, "thread-hil-1") + + assert result["source"] == "human_approval" + assert result["thread_id"] == "thread-hil-1" + assert len(result["action_requests"]) == 1 + assert result["action_requests"][0]["name"] == "send_email" + assert result["action_requests"][0]["description"] == "需要确认" + assert result["review_configs"][0]["allowed_decisions"] == ["approve", "reject"] + + def test_build_human_approval_payload_fills_missing_description(self): + """HIL payload 缺 description 时补默认提示,便于前端展示。""" + info = {"action_requests": [{"name": "delete_file", "args": {"path": "/tmp/x"}}], "review_configs": []} + result = _build_human_approval_payload(info, "thread-hil-2") + + desc = result["action_requests"][0]["description"] + assert "delete_file" in desc + assert "/tmp/x" in desc + + +class TestCheckAndHandleInterruptsRouting: + """测试 check_and_handle_interrupts 按载荷类型分流到 ask_user_question / human_approval。""" + + @pytest.mark.asyncio + async def test_routes_human_approval_interrupt_to_human_approval_required(self): + """HIL interrupt(含 action_requests)应发 human_approval_required,不再塞默认空问题。""" + hil_value = { + "action_requests": [{"name": "delete_file", "args": {"path": "/x"}}], + "review_configs": [{"action_name": "delete_file", "allowed_decisions": ["approve", "reject"]}], + } + + class FakeGraph: + async def aget_state(self, _config): + return SimpleNamespace( + values={"__interrupt__": [SimpleNamespace(value=hil_value)]}, + tasks=[], + ) + + class FakeAgent: + async def get_graph(self): + return FakeGraph() + + chunks: list[dict] = [] + + def make_chunk(status, meta, **kwargs): + return json.dumps({"status": status, "meta": meta, **kwargs}, ensure_ascii=False).encode("utf-8") + + stream = check_and_handle_interrupts(FakeAgent(), {"configurable": {"thread_id": "t"}}, make_chunk, {}, "t") + async for raw in stream: + chunks.append(json.loads(raw.decode("utf-8"))) + + assert len(chunks) == 1 + assert chunks[0]["status"] == "human_approval_required" + assert "action_requests" in chunks[0] + assert chunks[0]["action_requests"][0]["name"] == "delete_file" + assert "review_configs" in chunks[0] + # 不应再出现被误塞的默认空问题 + assert "questions" not in chunks[0] + + @pytest.mark.asyncio + async def test_routes_ask_user_question_interrupt_unchanged(self): + """ask_user_question interrupt(含 questions)仍走原有 ask_user_question_required 通道。""" + info_value = {"questions": [{"question": "选择一个", "options": ["A", "B"]}]} + + class FakeGraph: + async def aget_state(self, _config): + return SimpleNamespace( + values={"__interrupt__": [SimpleNamespace(value=info_value)]}, + tasks=[], + ) + + class FakeAgent: + async def get_graph(self): + return FakeGraph() + + chunks: list[dict] = [] + + def make_chunk(status, meta, **kwargs): + return json.dumps({"status": status, "meta": meta, **kwargs}, ensure_ascii=False).encode("utf-8") + + stream = check_and_handle_interrupts(FakeAgent(), {"configurable": {"thread_id": "t"}}, make_chunk, {}, "t") + async for raw in stream: + chunks.append(json.loads(raw.decode("utf-8"))) + + assert len(chunks) == 1 + assert chunks[0]["status"] == "ask_user_question_required" + assert chunks[0]["questions"][0]["question"] == "选择一个" + + +class TestInterruptSummary: + """测试 run_worker._interrupt_summary 兼容两类 interrupt 载荷。""" + + def test_summary_from_questions(self): + chunk = {"questions": [{"question": "是否继续?"}]} + assert run_worker._interrupt_summary(chunk) == "是否继续?" + + def test_summary_from_action_requests_with_args(self): + chunk = {"action_requests": [{"name": "delete_file", "args": {"path": "/x"}}]} + summary = run_worker._interrupt_summary(chunk) + assert "delete_file" in summary + assert "/x" in summary + + def test_summary_from_action_requests_without_args(self): + chunk = {"action_requests": [{"name": "send_email", "args": {}}]} + assert run_worker._interrupt_summary(chunk) == "操作需要确认: send_email" + + def test_summary_empty_when_no_payload(self): + assert run_worker._interrupt_summary({}) == "" + assert run_worker._interrupt_summary(None) == "" From 4fdaba5bc086f110bf37a774c5c524ce2b718dba Mon Sep 17 00:00:00 2001 From: xiangchang_24 <1476897511@qq.com> Date: Tue, 23 Jun 2026 13:08:55 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=20HIL=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=AE=A1=E6=89=B9=E5=BC=B9=E7=AA=97,?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20approve/reject/edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端按 interrupt 类型分流: - human_approval_required 渲染新的 HumanToolApprovalModal, 展示待审批工具名与参数,提供确认执行/编辑参数/拒绝三种决策, 提交 { decisions: [{ type: "approve" | "reject" | "edit", ... }] } 与后端 Command(resume=...) 恢复语义一致 - ask_user_question_required 仍走原有选项式弹窗,行为不变 useApproval 与 useAgentRunStream 的 hasPendingInterruptForRun 兼容 actionRequests(HIL 中断无 questions),避免弹窗一闪而过。 将 extractPendingInterrupt 抽到 approvalInterrupt.js 纯函数文件, 便于纯 node 单测。补充前端分流单测。 --- web/src/components/AgentChatComponent.vue | 37 +- web/src/components/HumanToolApprovalModal.vue | 406 ++++++++++++++++++ .../composables/__tests__/useApproval.test.js | 46 ++ web/src/composables/approvalInterrupt.js | 52 +++ web/src/composables/useAgentRunStream.js | 5 +- web/src/composables/useApproval.js | 53 +-- 6 files changed, 561 insertions(+), 38 deletions(-) create mode 100644 web/src/components/HumanToolApprovalModal.vue create mode 100644 web/src/composables/__tests__/useApproval.test.js create mode 100644 web/src/composables/approvalInterrupt.js diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index a501978ab..f0f0c9e5e 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -125,11 +125,21 @@
+
@@ -596,6 +606,7 @@ import { storeToRefs } from 'pinia' import { MessageProcessor } from '@/utils/messageProcessor' import { agentApi, threadApi } from '@/apis' import HumanApprovalModal from '@/components/HumanApprovalModal.vue' +import HumanToolApprovalModal from '@/components/HumanToolApprovalModal.vue' import { useApproval } from '@/composables/useApproval' import { useAgentThreadState } from '@/composables/useAgentThreadState' import { useAgentRunStream } from '@/composables/useAgentRunStream' @@ -1307,8 +1318,23 @@ const currentApprovalModalVisible = computed( Boolean(approvalState.threadId) && approvalState.threadId === currentChatId.value ) +const isAskUserApproval = computed( + () => approvalState.kind !== 'human_approval' +) const currentApprovalQuestions = computed(() => - currentApprovalModalVisible.value ? approvalState.questions : [] + currentApprovalModalVisible.value && isAskUserApproval.value + ? approvalState.questions + : [] +) +const currentApprovalActionRequests = computed(() => + currentApprovalModalVisible.value && !isAskUserApproval.value + ? approvalState.actionRequests + : [] +) +const currentApprovalReviewConfigs = computed(() => + currentApprovalModalVisible.value && !isAskUserApproval.value + ? approvalState.reviewConfigs + : [] ) const shouldSuppressRefsForApproval = () => @@ -2488,6 +2514,15 @@ const handleQuestionCancel = () => { handleApprovalWithStream('reject') } +// HIL 工具审批:approve/edit/reject 均发送 { decisions: [...] } +const handleToolApprovalSubmit = (payload) => { + handleApprovalWithStream(payload) +} + +const handleToolApprovalReject = (payload) => { + handleApprovalWithStream(payload) +} + const buildExportPayload = () => { const agentId = currentAgentId.value let agentDescription = '' diff --git a/web/src/components/HumanToolApprovalModal.vue b/web/src/components/HumanToolApprovalModal.vue new file mode 100644 index 000000000..87ed83ee1 --- /dev/null +++ b/web/src/components/HumanToolApprovalModal.vue @@ -0,0 +1,406 @@ +