diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index 1187c31c..750defe7 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -1,5 +1,6 @@ import traceback import uuid +from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File from fastapi.responses import StreamingResponse @@ -502,19 +503,57 @@ async def update_chat_models(model_provider: str, model_names: list[str], curren async def resume_agent_chat( agent_id: str, thread_id: str = Body(...), - approved: bool = Body(...), + approved: bool | None = Body(None), + answer: dict | list | str | None = Body(None), config: dict = Body({}), current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): """恢复被人工审批中断的对话(需要登录)""" - logger.info(f"Resuming agent_id: {agent_id}, thread_id: {thread_id}, approved: {approved}") + def normalize_resume_input(raw_answer: Any, raw_approved: bool | None) -> Any: + if raw_answer is not None: + if isinstance(raw_answer, str): + normalized = raw_answer.strip() + if not normalized: + raise HTTPException(status_code=422, detail="answer 不能为空") + return normalized + + if isinstance(raw_answer, list): + if len(raw_answer) == 0: + raise HTTPException(status_code=422, detail="answer 不能为空") + return raw_answer + + if isinstance(raw_answer, dict): + if raw_answer.get("type") == "other": + text = raw_answer.get("text") + if not isinstance(text, str) or not text.strip(): + raise HTTPException(status_code=422, detail="other 文本不能为空") + return raw_answer + + raise HTTPException(status_code=422, detail="answer 类型不支持") + + if raw_approved is not None: + return "approve" if raw_approved else "reject" + + raise HTTPException(status_code=422, detail="approved 或 answer 至少提供一个") + + resume_input = normalize_resume_input(answer, approved) + + logger.info( + "Resuming agent_id: %s, thread_id: %s, approved: %s, answer_type: %s", + agent_id, + thread_id, + approved, + type(answer).__name__ if answer is not None else "None", + ) meta = { "agent_id": agent_id, "thread_id": thread_id, "user_id": current_user.id, "approved": approved, + "answer": answer, + "resume_input": resume_input, } if "request_id" not in meta or not meta.get("request_id"): meta["request_id"] = str(uuid.uuid4()) @@ -522,7 +561,7 @@ async def resume_agent_chat( stream_agent_resume( agent_id=agent_id, thread_id=thread_id, - approved=approved, + resume_input=resume_input, meta=meta, config=config, current_user=current_user, diff --git a/src/agents/common/toolkits/buildin/__init__.py b/src/agents/common/toolkits/buildin/__init__.py index f51d0a80..7459ccc3 100644 --- a/src/agents/common/toolkits/buildin/__init__.py +++ b/src/agents/common/toolkits/buildin/__init__.py @@ -1,7 +1,8 @@ # buildin 工具包 -from .tools import calculator, query_knowledge_graph, text_to_img_qwen_image +from .tools import calculator, query_knowledge_graph, text_to_img_qwen_image, ask_user_question __all__ = [ + "ask_user_question", "calculator", "query_knowledge_graph", "text_to_img_qwen_image", diff --git a/src/agents/common/toolkits/buildin/tools.py b/src/agents/common/toolkits/buildin/tools.py index ed23665a..466b1a62 100644 --- a/src/agents/common/toolkits/buildin/tools.py +++ b/src/agents/common/toolkits/buildin/tools.py @@ -68,6 +68,73 @@ def calculator(a: float, b: float, operation: str) -> float: raise +ASK_USER_QUESTION_DESCRIPTION = """ +在执行过程中,当你需要用户做决定或补充需求时,使用这个工具向用户提问。 + +适用场景: +1. 收集用户偏好或需求(例如风格、范围、优先级) +2. 澄清模糊指令(存在多种合理解释时) +3. 在实现过程中让用户选择方案方向 +4. 在有明显权衡时让用户做取舍 + +使用规范: +1. 问题应当简短、具体、可回答,避免开放式长问句 +2. options 提供 2-5 个有区分度的选项,每项包含 label 和 value +3. 若有推荐选项:把推荐项放在第一位,并在 label 末尾加 "(Recommended)" +4. 若需要多选:将 multi_select 设为 true +5. allow_other 通常保持 true,用户可通过 Other 输入自定义答案 + +注意事项: +1. 不要用这个工具询问“是否继续执行”“计划是否准备好”这类流程控制问题 +2. 不要在信息已充分、无需用户决策时滥用该工具 +3. 先基于现有上下文自行决策,只有关键不确定性时才提问 + +返回结果: +answer 可能是 string(单选)、list(多选)或 object(Other 文本)。 +""" + + +@tool( + category="buildin", + tags=["交互"], + display_name="向用户提问", + description=ASK_USER_QUESTION_DESCRIPTION, +) +def ask_user_question( + question: Annotated[str, "向用户展示的问题"], + options: Annotated[list[dict], "候选项列表,格式 [{label, value}],推荐项请在 label 后追加 (Recommended)"], + multi_select: Annotated[bool, "是否允许多选"] = False, + allow_other: Annotated[bool, "是否允许用户输入 Other 自定义答案"] = True, +) -> dict: + """向用户发起问题并等待回答。""" + normalized_options: list[dict[str, str]] = [] + for item in options or []: + if not isinstance(item, dict): + continue + label = str(item.get("label") or item.get("value") or "").strip() + value = str(item.get("value") or item.get("label") or "").strip() + if label and value: + normalized_options.append({"label": label, "value": value}) + + interrupt_payload = { + "question": question, + "question_id": str(uuid.uuid4()), + "options": normalized_options, + "multi_select": multi_select, + "allow_other": allow_other, + "source": "ask_user_question", + } + answer = interrupt(interrupt_payload) + + return { + "question": question, + "question_id": interrupt_payload["question_id"], + "answer": answer, + "multi_select": multi_select, + "allow_other": allow_other, + } + + KG_QUERY_DESCRIPTION = """ 使用这个工具可以查询知识图谱中包含的三元组信息。 关键词(query),使用可能帮助回答这个问题的关键词进行查询,不要直接使用用户的原始输入去查询。 diff --git a/src/agents/common/toolkits/debug/tools.py b/src/agents/common/toolkits/debug/tools.py index af5ae0b2..c85ae9b0 100644 --- a/src/agents/common/toolkits/debug/tools.py +++ b/src/agents/common/toolkits/debug/tools.py @@ -22,7 +22,25 @@ def get_approved_user_goal( } # 触发人工审批 - is_approved = interrupt(interrupt_info) + 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: diff --git a/src/services/chat_stream_service.py b/src/services/chat_stream_service.py index dfb14a93..7539d50d 100644 --- a/src/services/chat_stream_service.py +++ b/src/services/chat_stream_service.py @@ -4,6 +4,7 @@ import traceback import uuid from collections.abc import AsyncIterator from datetime import UTC, datetime +from typing import Any from langchain.messages import AIMessage, AIMessageChunk, HumanMessage from langgraph.types import Command @@ -193,7 +194,7 @@ async def save_messages_from_langgraph_state( logger.error(traceback.format_exc()) -def _extract_interrupt_info(state) -> dict | None: +def _extract_interrupt_info(state) -> Any | None: """从 LangGraph state 中提取中断信息""" if hasattr(state, "tasks") and state.tasks: for task in state.tasks: @@ -207,12 +208,73 @@ def _extract_interrupt_info(state) -> dict | None: return None -def _get_interrupt_fields(info) -> tuple[str, str]: - """从中断信息中提取 question 和 operation""" - defaults = ("是否批准以下操作?", "需要人工审批的操作") +def _coerce_interrupt_payload(info: Any) -> dict: + """将 LangGraph interrupt 对象转换为 dict 结构。""" if isinstance(info, dict): - return info.get("question", defaults[0]), info.get("operation", defaults[1]) - return getattr(info, "question", defaults[0]), getattr(info, "operation", defaults[1]) + return info + + payload = getattr(info, "value", None) + if isinstance(payload, dict): + return payload + + question = getattr(info, "question", None) + operation = getattr(info, "operation", None) + result: dict[str, Any] = {} + if isinstance(question, str) and question.strip(): + result["question"] = question + if isinstance(operation, str) and operation.strip(): + result["operation"] = operation + return result + + +def _normalize_interrupt_options(raw_options: Any) -> list[dict[str, str]]: + if not isinstance(raw_options, list): + return [] + + options: list[dict[str, str]] = [] + for item in raw_options: + if isinstance(item, dict): + label = str(item.get("label") or item.get("value") or "").strip() + value = str(item.get("value") or item.get("label") or "").strip() + else: + label = str(item).strip() + value = label + if label and value: + options.append({"label": label, "value": value}) + return options + + +def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any]: + """将 interrupt 信息标准化为 ask_user_question_required 载荷。""" + payload = _coerce_interrupt_payload(info) + + question = str(payload.get("question") or "请选择一个选项").strip() + question_id = str(payload.get("question_id") or uuid.uuid4()) + source = str(payload.get("source") or payload.get("tool_name") or "interrupt") + multi_select = bool(payload.get("multi_select", False)) + allow_other = bool(payload.get("allow_other", True)) + 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, + "question": question, + "options": options, + "multi_select": multi_select, + "allow_other": allow_other, + "source": source, + "operation": operation if isinstance(operation, str) else "", + "thread_id": thread_id, + } def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None: @@ -262,13 +324,9 @@ async def check_and_handle_interrupts( interrupt_info = _extract_interrupt_info(state) if interrupt_info: - question, operation = _get_interrupt_fields(interrupt_info) - meta["interrupt"] = { - "question": question, - "operation": operation, - "thread_id": thread_id, - } - yield make_chunk(status="interrupted", message=question, meta=meta) + 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.error(f"Error checking interrupts: {e}") @@ -357,6 +415,8 @@ async def stream_agent_chat( "agent_config_id": agent_config_id, "agent_config": agent_config, } + full_msg = None + accumulated_content: list[str] = [] try: conv_repo = ConversationRepository(db) @@ -489,7 +549,7 @@ async def stream_agent_resume( *, agent_id: str, thread_id: str, - approved: bool, + resume_input: Any, meta: dict, config: dict, current_user, @@ -515,10 +575,10 @@ async def stream_agent_resume( ) return - init_msg = {"type": "system", "content": f"Resume with approved: {approved}"} + init_msg = {"type": "system", "content": f"Resume with input: {resume_input}"} yield make_resume_chunk(status="init", meta=meta, msg=init_msg) - resume_command = Command(resume=approved) + resume_command = Command(resume=resume_input) graph = await agent.get_graph() user_id = str(current_user.id) diff --git a/src/services/run_worker.py b/src/services/run_worker.py index d6c13797..ebd6403f 100644 --- a/src/services/run_worker.py +++ b/src/services/run_worker.py @@ -275,6 +275,14 @@ async def process_agent_run(ctx, run_id: str): error_message=chunk.get("message"), ) terminal_set = True + elif status == "ask_user_question_required": + await mark_run_terminal( + run_id, + "interrupted", + error_type="ask_user_question_required", + error_message=chunk.get("question") or "需要用户回答问题", + ) + terminal_set = True if await run_ctx.is_cancelled(): raise asyncio.CancelledError(f"run {run_id} cancelled") diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index b0625afe..493cb395 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -112,8 +112,11 @@ :visible="approvalState.showModal" :question="approvalState.question" :operation="approvalState.operation" - @approve="handleApprove" - @reject="handleReject" + :options="approvalState.options" + :multi-select="approvalState.multiSelect" + :allow-other="approvalState.allowOther" + @submit="handleQuestionSubmit" + @cancel="handleQuestionCancel" />
-