diff --git a/src/agents/common/toolkits/debug/__init__.py b/src/agents/common/toolkits/debug/__init__.py index 780a0fd8..649aff73 100644 --- a/src/agents/common/toolkits/debug/__init__.py +++ b/src/agents/common/toolkits/debug/__init__.py @@ -1,6 +1,3 @@ # debug 工具包 -from .tools import get_approved_user_goal -__all__ = [ - "get_approved_user_goal", -] +__all__ = [] diff --git a/src/agents/common/toolkits/debug/tools.py b/src/agents/common/toolkits/debug/tools.py index c85ae9b0..e69de29b 100644 --- a/src/agents/common/toolkits/debug/tools.py +++ b/src/agents/common/toolkits/debug/tools.py @@ -1,59 +0,0 @@ -from langgraph.types import interrupt - -from src.agents.common.toolkits.registry import tool - - -@tool(category="debug", tags=["内置", "审批"], display_name="人工审批") -def get_approved_user_goal( - operation_description: str, -) -> dict: - """ - 请求人工审批,在执行重要操作前获得人类确认。 - - Args: - operation_description: 需要审批的操作描述,例如 "调用知识库工具" - Returns: - dict: 包含审批结果的字典,格式为 {"approved": bool, "message": str} - """ - # 构建详细的中断信息 - interrupt_info = { - "question": "是否批准以下操作?", - "operation": operation_description, - } - - # 触发人工审批 - interrupt_result = interrupt(interrupt_info) - - if isinstance(interrupt_result, bool): - is_approved = interrupt_result - elif isinstance(interrupt_result, str): - is_approved = interrupt_result.strip().lower() in {"approve", "approved", "true", "yes", "1"} - elif isinstance(interrupt_result, list): - lowered = {str(item).strip().lower() for item in interrupt_result} - is_approved = "approve" in lowered or "approved" in lowered - elif isinstance(interrupt_result, dict): - selected = interrupt_result.get("selected") - if isinstance(selected, list): - lowered = {str(item).strip().lower() for item in selected} - is_approved = "approve" in lowered or "approved" in lowered - else: - text = str(interrupt_result.get("text") or "").strip().lower() - is_approved = text in {"approve", "approved", "true", "yes", "1"} - else: - is_approved = bool(interrupt_result) - - # 返回审批结果 - if is_approved: - result = { - "approved": True, - "message": f"✅ 操作已批准:{operation_description}", - } - print(f"✅ 人工审批通过: {operation_description}") - else: - result = { - "approved": False, - "message": f"❌ 操作被拒绝:{operation_description}", - } - print(f"❌ 人工审批被拒绝: {operation_description}") - - return result diff --git a/src/services/chat_stream_service.py b/src/services/chat_stream_service.py index 7539d50d..9133ad7a 100644 --- a/src/services/chat_stream_service.py +++ b/src/services/chat_stream_service.py @@ -256,14 +256,6 @@ def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any operation = payload.get("operation") options = _normalize_interrupt_options(payload.get("options")) - if not options and isinstance(operation, str) and operation.strip(): - # 兼容旧版 get_approved_user_goal 的 interrupt 结构 - options = [ - {"label": "批准 (Recommended)", "value": "approve"}, - {"label": "拒绝", "value": "reject"}, - ] - source = "get_approved_user_goal" - allow_other = False return { "question_id": question_id, diff --git a/test/test_chat_stream_interrupt.py b/test/test_chat_stream_interrupt.py new file mode 100644 index 00000000..14ef11fe --- /dev/null +++ b/test/test_chat_stream_interrupt.py @@ -0,0 +1,161 @@ +"""测试 chat_stream_service 中的 interrupt 相关函数""" +import pytest +import sys +import os + +sys.path.insert(0, os.getcwd()) + +from src.services.chat_stream_service import ( + _normalize_interrupt_options, + _build_ask_user_question_payload, + _coerce_interrupt_payload, +) + + +class TestNormalizeInterruptOptions: + """测试 _normalize_interrupt_options 函数""" + + def test_empty_input(self): + assert _normalize_interrupt_options(None) == [] + assert _normalize_interrupt_options([]) == [] + + def test_dict_options(self): + raw = [ + {"label": "选项1", "value": "option1"}, + {"label": "选项2", "value": "option2"}, + ] + result = _normalize_interrupt_options(raw) + assert len(result) == 2 + assert result[0] == {"label": "选项1", "value": "option1"} + assert result[1] == {"label": "选项2", "value": "option2"} + + def test_string_options(self): + raw = ["选项1", "选项2", "选项3"] + result = _normalize_interrupt_options(raw) + assert len(result) == 3 + assert result[0] == {"label": "选项1", "value": "选项1"} + + def test_mixed_options(self): + raw = [{"label": "选项1", "value": "option1"}, "选项2"] + result = _normalize_interrupt_options(raw) + assert len(result) == 2 + assert result[0] == {"label": "选项1", "value": "option1"} + assert result[1] == {"label": "选项2", "value": "选项2"} + + def test_invalid_options(self): + raw = [{"label": "只有label"}, {}, " "] + result = _normalize_interrupt_options(raw) + assert len(result) == 1 # 只有有效的选项 + assert result[0] == {"label": "只有label", "value": "只有label"} + + def test_value_only(self): + raw = [{"value": "only_value"}] + result = _normalize_interrupt_options(raw) + assert len(result) == 1 + assert result[0] == {"label": "only_value", "value": "only_value"} + + +class TestBuildAskUserQuestionPayload: + """测试 _build_ask_user_question_payload 函数""" + + def test_basic_question(self): + info = { + "question": "请确认是否继续?", + "options": [ + {"label": "确认", "value": "yes"}, + {"label": "取消", "value": "no"}, + ], + } + result = _build_ask_user_question_payload(info, "thread-123") + + assert result["question"] == "请确认是否继续?" + assert len(result["options"]) == 2 + assert result["options"][0] == {"label": "确认", "value": "yes"} + assert result["options"][1] == {"label": "取消", "value": "no"} + assert result["source"] == "interrupt" + assert result["thread_id"] == "thread-123" + assert result["multi_select"] is False + assert result["allow_other"] is True + + def test_question_with_source(self): + info = { + "question": "选择一个选项", + "options": ["A", "B", "C"], + "source": "ask_user_question", + } + result = _build_ask_user_question_payload(info, "thread-456") + + assert result["source"] == "ask_user_question" + assert len(result["options"]) == 3 + + def test_multi_select(self): + info = { + "question": "选择多个", + "options": ["A", "B", "C"], + "multi_select": True, + } + result = _build_ask_user_question_payload(info, "thread-789") + + assert result["multi_select"] is True + + def test_disable_allow_other(self): + info = { + "question": "只能选择", + "options": ["A", "B"], + "allow_other": False, + } + result = _build_ask_user_question_payload(info, "thread-000") + + assert result["allow_other"] is False + + def test_with_operation(self): + info = { + "question": "是否执行操作?", + "operation": "删除文件", + "options": [{"label": "批准", "value": "approve"}, {"label": "拒绝", "value": "reject"}], + } + result = _build_ask_user_question_payload(info, "thread-op") + + assert result["operation"] == "删除文件" + + def test_no_options(self): + """测试没有 options 的情况 - 不再自动填充 legacy 选项""" + info = { + "question": "请确认?", + } + result = _build_ask_user_question_payload(info, "thread-no-opt") + + # 不再有默认的 approve/reject 选项 + assert result["options"] == [] + assert result["source"] == "interrupt" + + def test_question_id_generation(self): + """测试 question_id 自动生成""" + info = {"question": "测试?"} + result = _build_ask_user_question_payload(info, "thread-id") + + # 应该生成了 UUID + assert result["question_id"] != "" + assert len(result["question_id"]) > 0 + + +class TestCoerceInterruptPayload: + """测试 _coerce_interrupt_payload 函数""" + + def test_dict_input(self): + info = {"question": "test?", "options": ["a", "b"]} + result = _coerce_interrupt_payload(info) + assert result == info + + def test_string_input(self): + info = "just a string" + result = _coerce_interrupt_payload(info) + assert isinstance(result, dict) + + def test_none_input(self): + result = _coerce_interrupt_payload(None) + assert isinstance(result, dict) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/web/src/composables/useApproval.js b/web/src/composables/useApproval.js index 52a87664..6ecbe87d 100644 --- a/web/src/composables/useApproval.js +++ b/web/src/composables/useApproval.js @@ -18,27 +18,17 @@ const normalizeOptions = (rawOptions) => { .filter(Boolean) } -const toLegacyApprovalOptions = () => [ - { label: '批准 (Recommended)', value: 'approve' }, - { label: '拒绝', value: 'reject' } -] - const extractQuestionPayload = (chunk) => { const interruptInfo = chunk?.interrupt_info || {} - const rawOptions = - chunk?.options || interruptInfo?.options || (interruptInfo?.operation ? toLegacyApprovalOptions() : []) + const rawOptions = chunk?.options || interruptInfo?.options || [] const options = normalizeOptions(rawOptions) const operation = chunk?.operation || interruptInfo?.operation || '' - const source = chunk?.source || interruptInfo?.source || (operation ? 'get_approved_user_goal' : 'interrupt') + const source = chunk?.source || interruptInfo?.source || 'interrupt' const multiSelect = Boolean(chunk?.multi_select ?? interruptInfo?.multi_select ?? false) - let allowOther = Boolean(chunk?.allow_other ?? interruptInfo?.allow_other ?? true) + const allowOther = Boolean(chunk?.allow_other ?? interruptInfo?.allow_other ?? true) const questionId = chunk?.question_id || interruptInfo?.question_id || '' const question = chunk?.question || interruptInfo?.question || '请选择一个选项' - const legacyMode = source === 'get_approved_user_goal' && options.length === 2 - if (source === 'get_approved_user_goal') { - allowOther = false - } return { questionId, @@ -47,17 +37,10 @@ const extractQuestionPayload = (chunk) => { multiSelect, allowOther, source, - operation, - legacyMode + operation } } -const inferApprovedFromAnswer = (answer) => { - if (answer === 'approve') return true - if (answer === 'reject') return false - return null -} - export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) { const approvalState = reactive({ showModal: false, @@ -68,7 +51,6 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa multiSelect: false, allowOther: true, source: '', - legacyMode: false, threadId: null }) @@ -98,15 +80,11 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa resetOnGoingConv(threadId) threadState.streamAbortController = new AbortController() - const approved = inferApprovedFromAnswer(answer) const requestBody = { thread_id: threadId, answer, config: agentConfigId ? { agent_config_id: agentConfigId } : {} } - if (approved !== null) { - requestBody.approved = approved - } try { const response = await agentApi.resumeAgentChat(currentAgentId, requestBody, { @@ -139,11 +117,6 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa if (!threadState) return false const payload = extractQuestionPayload(chunk) - if (!payload.options.length) { - payload.options = toLegacyApprovalOptions() - payload.legacyMode = true - payload.allowOther = false - } threadState.isStreaming = false @@ -155,7 +128,6 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa approvalState.multiSelect = payload.multiSelect approvalState.allowOther = payload.allowOther approvalState.source = payload.source - approvalState.legacyMode = payload.legacyMode approvalState.threadId = chunk.thread_id || threadId fetchThreadMessages({ agentId: currentAgentId, threadId }) @@ -172,7 +144,6 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa approvalState.multiSelect = false approvalState.allowOther = true approvalState.source = '' - approvalState.legacyMode = false approvalState.threadId = null }