diff --git a/docs/ai-chat-api-design.md b/docs/gns3-copilot/ai-chat-api-design.md similarity index 100% rename from docs/ai-chat-api-design.md rename to docs/gns3-copilot/ai-chat-api-design.md diff --git a/docs/gns3-copilot/hitl-implementation-plan.md b/docs/gns3-copilot/hitl-implementation-plan.md new file mode 100644 index 000000000..64d763c46 --- /dev/null +++ b/docs/gns3-copilot/hitl-implementation-plan.md @@ -0,0 +1,1402 @@ +# GNS3 Copilot HITL 功能实现方案 + +## 概述 + +本文档详细描述了 GNS3 Copilot 中 Human-in-the-Loop (HITL) 功能的完整实现方案。HITL 功能允许在执行敏感操作(如设备配置)前要求用户确认,提高系统安全性。 + +## 目标 + +- 在执行配置工具前要求用户确认 +- 支持单个和批量工具确认 +- 提供清晰的工具执行预览 +- 保持现有功能完全兼容 + +## 核心设计 + +### 流程图 + +``` +用户消息 → LLM → 工具调用决策 + ↓ + 检测是否需要确认 + ↙ ↘ + 需要 HITL 直接执行 + ↓ ↓ + 暂停,等待前端 执行工具 + ↓ ↓ + 用户确认/拒绝 返回结果 + ↓ + 执行已确认的工具 + ↓ + 返回结果 +``` + +### 架构分层 + +``` +┌─────────────────────────────────────────┐ +│ Frontend (Web UI) │ +│ - 显示确认对话框 │ +│ - 列出待确认的工具 │ +│ - 处理用户确认/拒绝操作 │ +└─────────────────────────────────────────┘ + ↕ SSE/HTTP +┌─────────────────────────────────────────┐ +│ API Layer (FastAPI) │ +│ - /hitl/status: 获取待确认工具 │ +│ - /hitl/confirm: 确认执行 │ +│ - /hitl/reject: 拒绝执行 │ +└─────────────────────────────────────────┘ + ↕ +┌─────────────────────────────────────────┐ +│ AgentService (状态管理) │ +│ - 管理 HITL 状态 │ +│ - 处理确认/拒绝逻辑 │ +│ - checkpoint 持久化 │ +└─────────────────────────────────────────┘ + ↕ +┌─────────────────────────────────────────┐ +│ LangGraph Agent (工作流) │ +│ - llm_call: LLM 调用 │ +│ - check_hitl: 检查是否需要确认 │ +│ - hitl_confirmation: 等待确认 │ +│ - conditional_execution: 条件执行 │ +└─────────────────────────────────────────┘ +``` + +## 实现改动 + +### 1. LangGraph Agent 层 + +#### 文件:`gns3server/agent/gns3_copilot/agent/gns3_copilot.py` + +##### 改动 1.1:扩展 MessagesState + +**位置**:第 98-124 行 + +```python +# 扩展状态定义,添加 HITL 相关字段 +class MessagesState(TypedDict): + """GNS3-Copilot 对话状态管理类""" + + messages: Annotated[list[AnyMessage], operator.add] + llm_calls: int + remaining_steps: RemainingSteps + conversation_title: str | None + topology_info: dict | None + + # 新增:HITL 相关字段 + pending_tool_calls: list[dict] # 等待确认的工具调用列表 + hitl_confirmation_required: bool # 是否需要用户确认 + hitl_session_id: str | None # HITL 会话唯一标识 + confirmed_tool_calls: list[dict] # 用户已确认的工具调用 + rejected_tool_calls: list[dict] # 用户拒绝的工具调用 +``` + +**改动影响**: +- checkpoint 数据库中新增 5 个字段 +- 现有状态读取代码需要兼容新字段 + +##### 改动 1.2:添加 HITL 检测节点 + +**位置**:在 `generate_title` 函数后添加(约第 310 行) + +```python +# 需要确认的工具列表 +HITL_TOOLS = { + "execute_multiple_device_config_commands", + # 可根据需要添加: + # "delete_node", + # "start_gns3_node", +} + +DANGEROUS_PATTERNS = [ + "reload", "reboot", "write erase", "erase startup-config", + "factory reset", "format", "delete", "no ip routing" +] + + +def _is_dangerous_config(tool_name: str, tool_args: dict) -> bool: + """检测配置是否包含危险命令""" + if tool_name == "execute_multiple_device_config_commands": + device_configs = tool_args.get("device_configs", []) + for device in device_configs: + commands = device.get("config_commands", []) + for cmd in commands: + cmd_lower = cmd.lower() + if any(pattern in cmd_lower for pattern in DANGEROUS_PATTERNS): + return True + return False + + +def check_hitl_requirement(state: MessagesState) -> dict: + """ + 检查工具调用是否需要用户确认 (HITL) + + Returns: + dict: 包含 pending_tool_calls 和 hitl_confirmation_required 的状态更新 + """ + last_message = state["messages"][-1] + + # 如果最后一条消息没有工具调用,直接返回 + if not hasattr(last_message, 'tool_calls') or not last_message.tool_calls: + return {"hitl_confirmation_required": False} + + pending_tools = [] + + for tool_call in last_message.tool_calls: + tool_name = tool_call["name"] + tool_args = tool_call["args"] + + # 只处理需要 HITL 的工具 + if tool_name in HITL_TOOLS: + # 检查是否为危险操作 + is_dangerous = _is_dangerous_config(tool_name, tool_args) + + tool_info = { + "tool_call_id": tool_call["id"], + "tool_name": tool_name, + "tool_args": tool_args, + "danger_level": "high" if is_dangerous else "medium" + } + + # 添加描述信息 + if tool_name == "execute_multiple_device_config_commands": + device_count = len(tool_args.get("device_configs", [])) + tool_info["description"] = f"配置 {device_count} 个设备" + + pending_tools.append(tool_info) + logger.info("HITL: Tool '%s' requires confirmation (danger_level=%s)", + tool_name, tool_info["danger_level"]) + + if pending_tools: + logger.info("HITL: %d tools require confirmation", len(pending_tools)) + return { + "pending_tool_calls": pending_tools, + "hitl_confirmation_required": True, + "hitl_session_id": str(uuid4()) + } + + return {"hitl_confirmation_required": False} +``` + +##### 改动 1.3:修改 should_continue 路由 + +**位置**:第 337-370 行 + +```python +def should_continue( + state: MessagesState, +) -> Literal["conditional_tool_execution", "hitl_confirmation", "title_generator_node", END]: + """ + LLM 响应后的路由决策 + + Returns: + Literal: 路由到下一个节点 + """ + last_message = state["messages"][-1] + current_title = state.get("conversation_title") + + # LLM 请求工具调用 + if last_message.tool_calls: + # 检查是否需要 HITL 确认 + if state.get("hitl_confirmation_required"): + logger.info("Routing to hitl_confirmation node") + return "hitl_confirmation" + + logger.info("Routing to conditional_tool_execution node") + return "conditional_tool_execution" + + # 首次交互完成,生成标题 + if current_title in [None, "New Conversation"]: + return "title_generator_node" + + return END +``` + +**关键变更**: +- 原路由 `"tool_node"` 改为 `"conditional_tool_execution"` +- 新增 `"hitl_confirmation"` 路由 +- 路由名称变更,需要同步更新所有引用 + +##### 改动 1.4:添加 HITL 确认等待节点 + +**位置**:在 `should_continue` 函数后添加 + +```python +def hitl_confirmation_node(state: MessagesState) -> dict: + """ + HITL 确认节点 - 暂停执行,等待用户确认 + + 这是一个特殊的节点,它不执行任何操作,只是保持状态。 + 实际的确认流程由 API 层处理。 + + 工作原理: + 1. 节点被调用时,状态中包含 pending_tool_calls + 2. LangGraph 保存状态到 checkpoint + 3. 执行流程暂停,等待外部状态更新 + 4. 用户通过 API 确认/拒绝后,状态被更新 + 5. 从 checkpoint 恢复并继续执行 + + Returns: + dict: 空字典,保持状态不变 + """ + logger.info("HITL: Pausing for user confirmation (session_id=%s)", + state.get("hitl_session_id")) + + # 返回空字典,保持状态不变 + # 状态将在用户确认后通过 API 更新 + return {} +``` + +##### 改动 1.5:添加条件执行节点 + +**位置**:在 `hitl_confirmation_node` 函数后添加 + +```python +def conditional_tool_execution(state: MessagesState) -> dict: + """ + 条件工具执行节点 - 根据用户确认决定是否执行工具 + + 这个节点替代原来的 tool_node,增加了: + 1. 只执行用户确认的工具 + 2. 处理用户拒绝的工具 + 3. 更新 HITL 状态 + + Returns: + dict: 包含执行结果和状态更新的字典 + """ + confirmed_calls = state.get("confirmed_tool_calls", []) + rejected_calls = state.get("rejected_tool_calls", []) + + logger.info("HITL: Executing %d confirmed tools, %d rejected", + len(confirmed_calls), len(rejected_calls)) + + # 处理用户拒绝的工具 + if rejected_calls: + rejected_names = [c["tool_name"] for c in rejected_calls] + rejection_msg = ( + f"用户拒绝了以下操作: {', '.join(rejected_names)}。\n" + f"请提供替代方案或解释不执行这些操作的原因。" + ) + + # 添加系统消息通知 LLM + return { + "messages": [SystemMessage(content=rejection_msg)], + "pending_tool_calls": [], + "hitl_confirmation_required": False, + "confirmed_tool_calls": [], + "rejected_tool_calls": [] + } + + # 执行已确认的工具 + if not confirmed_calls: + logger.warning("HITL: No confirmed tools to execute") + return { + "pending_tool_calls": [], + "hitl_confirmation_required": False + } + + results = [] + for tool_call_dict in confirmed_calls: + tool_call_id = tool_call_dict["tool_call_id"] + tool_name = tool_call_dict["tool_name"] + tool_args = tool_call_dict["tool_args"] + + logger.info("Executing confirmed tool: %s", tool_name) + + tool = tools_by_name[tool_name] + try: + observation = tool.invoke(tool_args) + results.append(ToolMessage( + content=observation, + tool_call_id=tool_call_id, + name=tool_name + )) + logger.debug("Tool %s completed successfully", tool_name) + except Exception as e: + logger.error("Tool %s failed: %s", tool_name, e, exc_info=True) + results.append(ToolMessage( + content=f"Error: {str(e)}", + tool_call_id=tool_call_id, + name=tool_name + )) + + # 清理 HITL 状态 + return { + "messages": results, + "pending_tool_calls": [], + "hitl_confirmation_required": False, + "confirmed_tool_calls": [], + "rejected_tool_calls": [] + } +``` + +**关键变更**: +- 替代原 `tool_node` 函数 +- 增加确认逻辑处理 +- 支持部分确认、部分拒绝 + +##### 改动 1.6:更新 LangGraph 构建流程 + +**位置**:第 393-427 行 + +```python +# 构建工作流 +agent_builder = StateGraph(MessagesState) + +# 添加节点 +agent_builder.add_node("llm_call", llm_call) +agent_builder.add_node("hitl_confirmation", hitl_confirmation_node) +agent_builder.add_node("conditional_tool_execution", conditional_tool_execution) +agent_builder.add_node("title_generator_node", generate_title) + +# 添加边:START → llm_call +agent_builder.add_edge(START, "llm_call") + +# 添加边:LLM 后的条件路由 +agent_builder.add_conditional_edges( + "llm_call", + should_continue, + { + "hitl_confirmation": "hitl_confirmation", # 需要 HITL 确认 + "conditional_tool_execution": "conditional_tool_execution", # 直接执行 + "title_generator_node": "title_generator_node", # 生成标题 + END: END # 结束对话 + }, +) + +# HITL 确认节点是特殊的,它需要等待外部状态更新 +# 状态更新后,下一轮调用将从 checkpoint 恢复并继续 +# 这个循环通过 API 触发新的 graph.ainvoke() 调用完成 + +# 添加边:条件执行后继续 LLM 调用 +agent_builder.add_conditional_edges( + "conditional_tool_execution", + recursion_limit_continue, + { + "llm_call": "llm_call", + END: END + }, +) + +# 添加边:标题生成后结束 +agent_builder.add_edge("title_generator_node", END) +``` + +**工作流图**: +``` +START → llm_call → should_continue + ↓ + ┌────────────┼────────────┐ + ↓ ↓ ↓ +hitl_confirmation conditional title_generator +(等待 API) _execution ↓ + └────────────┴────────────→ END +``` + +#### 文件:`gns3server/agent/gns3_copilot/agent_service.py` + +##### 改动 2.1:添加 HITL 事件处理 + +**位置**:第 333-376 行的 `_convert_event_to_chunk` 函数 + +```python +def _convert_event_to_chunk(self, event: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]: + """ + 转换 LangGraph 事件为 API 响应块 + + 支持 HITL 事件类型 + """ + event_type = event.get("event", "") + data = event.get("data", {}) + + if event_type == "on_chat_model_stream": + chunk = data.get("chunk", {}) + content = getattr(chunk, "content", "") + if content: + return {"type": "content", "content": content} + + elif event_type == "on_tool_start": + return { + "type": "tool_start", + "tool_name": event.get("name", ""), + "session_id": session_id + } + + elif event_type == "on_tool_end": + output = data.get("output", "") + if not isinstance(output, str): + output = str(output) + return { + "type": "tool_end", + "tool_name": event.get("name", ""), + "tool_output": output, + "session_id": session_id + } + + # 新增:HITL 确认要求事件 + elif event_type == "hitl_required": + return { + "type": "hitl_required", + "pending_tools": data.get("pending_tool_calls", []), + "hitl_session_id": data.get("hitl_session_id"), + "timeout": 300, # 5分钟超时 + "session_id": session_id + } + + return None +``` + +**注意**:实际实现中,HITL 事件不是通过 LangGraph 的 `astream_events` 触发的,而是通过状态查询实现的。因此,此函数主要用于处理工具执行事件。 + +--- + +### 2. API 层 + +#### 文件:`gns3server/api/routes/controller/chat.py` + +##### 改动 2.1:添加 HITL 端点 + +**位置**:在文件末尾添加(约第 325 行后) + +```python +from gns3server import schemas +from typing import List + + +# ============================================================================= +# HITL (Human-in-the-Loop) 端点 +# ============================================================================= + +@router.get( + "/sessions/{session_id}/hitl-status", + response_model=schemas.HITLStatusResponse, + summary="获取 HITL 状态", + description="获取当前会话中待确认的工具列表" +) +async def get_hitl_status( + session_id: str, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.HITLStatusResponse: + """ + 获取 HITL 状态 + + 返回当前会话中等待用户确认的工具调用列表。 + 前端应定期轮询此端点以检查是否有新的待确认工具。 + """ + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened. Current status: {project.status}" + ) + + agent_manager = await get_project_agent_manager() + agent_service = await agent_manager.get_agent(str(project.id), project.path) + + # 从 checkpoint 获取状态 + config = {"configurable": {"thread_id": session_id}} + state = await agent_service._graph.aget_state(config) + + if not state or not state.values: + return schemas.HITLStatusResponse( + status="idle", + pending_tools=[], + hitl_session_id=None, + session_id=session_id + ) + + values = state.values + pending_tools = values.get("pending_tool_calls", []) + hitl_session_id = values.get("hitl_session_id") + + # 转换为 Schema 格式 + pending_tool_schemas = [] + for tool in pending_tools: + pending_tool_schemas.append(schemas.PendingTool( + tool_call_id=tool["tool_call_id"], + tool_name=tool["tool_name"], + tool_args=tool["tool_args"], + danger_level=tool.get("danger_level", "medium"), + description=tool.get("description", "") + )) + + return schemas.HITLStatusResponse( + status="waiting" if pending_tool_schemas else "idle", + pending_tools=pending_tool_schemas, + hitl_session_id=hitl_session_id, + session_id=session_id + ) + + +@router.post( + "/sessions/{session_id}/hitl/confirm", + response_model=schemas.HITLConfirmationResponse, + summary="确认执行工具", + description="用户确认执行一个或多个待确认的工具" +) +async def confirm_tool_execution( + session_id: str, + request: schemas.HITLConfirmationRequest, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.HITLConfirmationResponse: + """ + 确认执行工具 + + 用户可以选择: + - confirm_all=true: 确认所有待确认的工具 + - tool_call_ids=[...]: 确认指定的工具 + + 确认后,工具将被执行,结果将通过 SSE 流返回。 + """ + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened. Current status: {project.status}" + ) + + agent_manager = await get_project_agent_manager() + agent_service = await agent_manager.get_agent(str(project.id), project.path) + + config = {"configurable": {"thread_id": session_id}} + state = await agent_service._graph.aget_state(config) + + if not state or not state.values: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session '{session_id}' not found" + ) + + values = state.values + pending_tools = values.get("pending_tool_calls", []) + + if not pending_tools: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No pending tools to confirm" + ) + + # 根据请求选择要确认的工具 + confirmed_tools = [] + if request.confirm_all: + confirmed_tools = pending_tools + elif request.tool_call_ids: + confirmed_tools = [t for t in pending_tools if t["tool_call_id"] in request.tool_call_ids] + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Must specify either confirm_all=true or tool_call_ids" + ) + + if not confirmed_tools: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No tools matched the confirmation criteria" + ) + + # 更新状态:标记为已确认 + await agent_service._graph.aupdate_state( + config, + { + "confirmed_tool_calls": confirmed_tools, + "pending_tool_calls": [], + "hitl_confirmation_required": False + } + ) + + # 继续执行流程 + try: + new_state = await agent_service._graph.ainvoke(None, config) + except Exception as e: + logger.error("Error continuing graph execution: %s", e, exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error executing tools: {str(e)}" + ) + + return schemas.HITLConfirmationResponse( + status="confirmed", + confirmed_count=len(confirmed_tools), + message=f"已确认 {len(confirmed_tools)} 个工具执行" + ) + + +@router.post( + "/sessions/{session_id}/hitl/reject", + response_model=schemas.HITLConfirmationResponse, + summary="拒绝执行工具", + description="用户拒绝执行一个或多个待确认的工具" +) +async def reject_tool_execution( + session_id: str, + request: schemas.HITLRejectionRequest, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.HITLConfirmationResponse: + """ + 拒绝执行工具 + + 用户可以选择: + - reject_all=true: 拒绝所有待确认的工具 + - tool_call_ids=[...]: 拒绝指定的工具 + - reason: 拒绝原因(将反馈给 LLM) + + 拒绝后,LLM 将收到通知并可以提供替代方案。 + """ + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened. Current status: {project.status}" + ) + + agent_manager = await get_project_agent_manager() + agent_service = await agent_manager.get_agent(str(project.id), project.path) + + config = {"configurable": {"thread_id": session_id}} + + rejected_tools = [] + if request.reject_all: + state = await agent_service._graph.aget_state(config) + if state and state.values: + rejected_tools = state.values.get("pending_tool_calls", []) + elif request.tool_call_ids: + state = await agent_service._graph.aget_state(config) + if state and state.values: + pending = state.values.get("pending_tool_calls", []) + rejected_tools = [t for t in pending if t["tool_call_id"] in request.tool_call_ids] + + if not rejected_tools: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No pending tools to reject" + ) + + # 更新状态:标记为已拒绝 + await agent_service._graph.aupdate_state( + config, + { + "rejected_tool_calls": rejected_tools, + "pending_tool_calls": [], + "hitl_confirmation_required": False + } + ) + + # 继续执行流程,LLM 将收到拒绝通知 + try: + new_state = await agent_service._graph.ainvoke(None, config) + except Exception as e: + logger.error("Error continuing graph execution: %s", e, exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing rejection: {str(e)}" + ) + + rejected_names = [t["tool_name"] for t in rejected_tools] + return schemas.HITLConfirmationResponse( + status="rejected", + confirmed_count=0, + message=f"已拒绝 {len(rejected_tools)} 个工具执行: {', '.join(rejected_names)}" + ) +``` + +--- + +### 3. Schema 层 + +#### 文件:`gns3server/schemas/controller/chat.py` + +##### 改动 3.1:添加 HITL 相关的 Pydantic 模型 + +**位置**:在文件末尾添加(约第 112 行后) + +```python +class PendingTool(BaseModel): + """待确认的工具信息""" + tool_call_id: str = Field(..., description="工具调用的唯一 ID") + tool_name: str = Field(..., description="工具名称") + tool_args: Dict[str, Any] = Field(..., description="工具参数") + danger_level: Literal["low", "medium", "high"] = Field( + default="medium", + description="危险等级" + ) + description: Optional[str] = Field(None, description="工具执行的描述") + + +class HITLStatusResponse(BaseModel): + """HITL 状态响应""" + status: Literal["idle", "waiting", "confirmed", "rejected"] = Field( + ..., + description="当前状态" + ) + pending_tools: List[PendingTool] = Field( + default_factory=list, + description="待确认的工具列表" + ) + hitl_session_id: Optional[str] = Field( + None, + description="HITL 会话 ID" + ) + session_id: str = Field(..., description="聊天会话 ID") + + +class HITLConfirmationRequest(BaseModel): + """HITL 确认请求""" + confirm_all: bool = Field( + default=False, + description="是否确认所有待确认的工具" + ) + tool_call_ids: Optional[List[str]] = Field( + None, + description="要确认的工具调用 ID 列表" + ) + + +class HITLRejectionRequest(BaseModel): + """HITL 拒绝请求""" + reject_all: bool = Field( + default=False, + description="是否拒绝所有待确认的工具" + ) + tool_call_ids: Optional[List[str]] = Field( + None, + description="要拒绝的工具调用 ID 列表" + ) + reason: Optional[str] = Field( + None, + description="拒绝原因(将反馈给 LLM)" + ) + + +class HITLConfirmationResponse(BaseModel): + """HITL 确认响应""" + status: Literal["confirmed", "rejected"] = Field( + ..., + description="操作状态" + ) + confirmed_count: int = Field( + ..., + description="已确认的工具数量" + ) + message: str = Field(..., description="响应消息") +``` + +**同时更新 `__init__.py` 导出**: + +```python +# 文件:gns3server/schemas/__init__.py + +# 添加到导入列表 +from .controller.chat import ( + # ... 现有导入 ... + PendingTool, + HITLStatusResponse, + HITLConfirmationRequest, + HITLRejectionRequest, + HITLConfirmationResponse, +) +``` + +--- + +### 4. 文件改动汇总 + +| 文件路径 | 改动类型 | 行数变化 | 风险等级 | +|---------|---------|----------|----------| +| `gns3_copilot.py` | 修改/新增 | +200 行 | 🟡 中 | +| `agent_service.py` | 修改 | +10 行 | 🟢 低 | +| `chat.py` (API) | 新增 | +150 行 | 🟢 低 | +| `chat.py` (Schema) | 新增 | +50 行 | 🟢 低 | +| **总计** | - | **+410 行** | - | + +--- + +## 数据库变更 + +### Checkpoint 表结构 + +**表名**:`checkpoints` (LangGraph 自动管理) + +**新增字段**(通过 MessagesState 扩展自动添加): + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| `pending_tool_calls` | TEXT (JSON) | 待确认的工具调用列表 | +| `hitl_confirmation_required` | BOOLEAN | 是否需要 HITL 确认 | +| `hitl_session_id` | TEXT | HITL 会话 ID | +| `confirmed_tool_calls` | TEXT (JSON) | 已确认的工具调用 | +| `rejected_tool_calls` | TEXT (JSON) | 已拒绝的工具调用 | + +**迁移说明**: +- LangGraph 自动处理新的 state 字段 +- 无需手动执行数据库迁移 +- 现有 checkpoint 向后兼容 + +--- + +## API 规范 + +### 1. 获取 HITL 状态 + +**端点**:`GET /v3/projects/{project_id}/chat/sessions/{session_id}/hitl-status` + +**响应示例**: +```json +{ + "status": "waiting", + "pending_tools": [ + { + "tool_call_id": "call_abc123", + "tool_name": "execute_multiple_device_config_commands", + "tool_args": { + "project_id": "xxx", + "device_configs": [ + { + "device_name": "R1", + "config_commands": ["interface gig0/0", "ip address 10.0.0.1/24"] + } + ] + }, + "danger_level": "medium", + "description": "配置 1 个设备" + } + ], + "hitl_session_id": "hitl_12345", + "session_id": "chat_session_id" +} +``` + +### 2. 确认执行 + +**端点**:`POST /v3/projects/{project_id}/chat/sessions/{session_id}/hitl/confirm` + +**请求体**: +```json +{ + "confirm_all": true, + "tool_call_ids": null +} +``` + +**或指定工具**: +```json +{ + "confirm_all": false, + "tool_call_ids": ["call_abc123", "call_def456"] +} +``` + +**响应示例**: +```json +{ + "status": "confirmed", + "confirmed_count": 2, + "message": "已确认 2 个工具执行" +} +``` + +### 3. 拒绝执行 + +**端点**:`POST /v3/projects/{project_id}/chat/sessions/{session_id}/hitl/reject` + +**请求体**: +```json +{ + "reject_all": true, + "reason": "配置有误,需要重新规划" +} +``` + +**响应示例**: +```json +{ + "status": "rejected", + "confirmed_count": 0, + "message": "已拒绝 1 个工具执行: execute_multiple_device_config_commands" +} +``` + +--- + +## 前端集成指南 + +### 1. 检测 HITL 状态 + +```javascript +// 定期轮询 HITL 状态 +async function pollHITLStatus(sessionId) { + const response = await fetch( + `/api/v3/projects/${projectId}/chat/sessions/${sessionId}/hitl-status` + ); + const status = await response.json(); + + if (status.status === 'waiting' && status.pending_tools.length > 0) { + showConfirmationDialog(status); + } +} + +// 每 2 秒轮询一次 +setInterval(() => pollHITLStatus(sessionId), 2000); +``` + +### 2. 显示确认对话框 + +```javascript +function showConfirmationDialog(hitlStatus) { + const { pending_tools, hitl_session_id } = hitlStatus; + + const dialog = document.createElement('div'); + dialog.className = 'hitl-confirmation-dialog'; + + let html = ` +

⚠️ 需要确认以下操作

+
+ `; + + pending_tools.forEach(tool => { + const icon = tool.danger_level === 'high' ? '🔴' : '🟡'; + html += ` +
+ ${icon} + ${tool.tool_name} + ${tool.description} +
+ `; + }); + + html += ` +
+
+ + +
+ `; + + dialog.innerHTML = html; + document.body.appendChild(dialog); +} + +async function confirmAll(sessionId) { + const response = await fetch( + `/api/v3/projects/${projectId}/chat/sessions/${sessionId}/hitl/confirm`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirm_all: true }) + } + ); + + if (response.ok) { + closeDialog(); + // 继续监听 SSE 流以获取执行结果 + } +} + +async function rejectAll(sessionId) { + const response = await fetch( + `/api/v3/projects/${projectId}/chat/sessions/${sessionId}/hitl/reject`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reject_all: true, reason: '用户取消操作' }) + } + ); + + if (response.ok) { + closeDialog(); + } +} +``` + +--- + +## 测试计划 + +### 单元测试 + +```python +import pytest +from gns3server.agent.gns3_copilot.agent.gns3_copilot import ( + check_hitl_requirement, + _is_dangerous_config +) + +def test_dangerous_config_detection(): + """测试危险命令检测""" + tool_args = { + "device_configs": [{ + "config_commands": ["reload", "write erase"] + }] + } + + assert _is_dangerous_config("execute_multiple_device_config_commands", tool_args) == True + +def test_safe_config_detection(): + """测试安全命令检测""" + tool_args = { + "device_configs": [{ + "config_commands": ["interface gig0/0", "ip address 10.0.0.1/24"] + }] + } + + assert _is_dangerous_config("execute_multiple_device_config_commands", tool_args) == False + +def test_hitl_requirement_check(): + """测试 HITL 需求检查""" + state = { + "messages": [ + HumanMessage(content="配置路由器"), + AIMessage( + content="", + tool_calls=[{ + "id": "call_123", + "name": "execute_multiple_device_config_commands", + "args": {"project_id": "test"} + }] + ) + ] + } + + result = check_hitl_requirement(state) + + assert result["hitl_confirmation_required"] == True + assert len(result["pending_tool_calls"]) == 1 +``` + +### 集成测试 + +```python +import pytest +from fastapi.testclient import TestClient + +def test_hitl_flow(): + """测试完整的 HITL 流程""" + client = TestClient(app) + + # 1. 开始聊天 + response = client.post( + f"/v3/projects/{project_id}/chat/stream", + json={"message": "配置所有路由器"} + ) + + # 2. 检查 HITL 状态 + status = client.get(f"/v3/projects/{project_id}/chat/sessions/{session_id}/hitl-status") + assert status.json()["status"] == "waiting" + assert len(status.json()["pending_tools"]) > 0 + + # 3. 确认执行 + confirm = client.post( + f"/v3/projects/{project_id}/chat/sessions/{session_id}/hitl/confirm", + json={"confirm_all": True} + ) + assert confirm.json()["status"] == "confirmed" +``` + +--- + +## 部署步骤 + +### 第一阶段:基础架构(1-2 天) + +1. ✅ 扩展 MessagesState +2. ✅ 实现 check_hitl_requirement 节点 +3. ✅ 实现 hitl_confirmation_node 和 conditional_tool_execution +4. ✅ 更新 LangGraph 工作流 +5. ✅ 单元测试 + +**验证**:现有功能不受影响 + +### 第二阶段:API 端点(1 天) + +1. ✅ 添加 HITL 状态查询端点 +2. ✅ 添加确认/拒绝端点 +3. ✅ 添加 Schema 定义 +4. ✅ API 测试 + +**验证**:API 可正常调用 + +### 第三阶段:前端集成(2-3 天) + +1. ✅ 实现轮询逻辑 +2. ✅ 显示确认对话框 +3. ✅ 处理确认/拒绝操作 +4. ✅ 显示执行结果 + +**验证**:端到端流程可用 + +### 第四阶段:优化和增强(1-2 天) + +1. ✅ 添加危险命令分级 +2. ✅ 实现超时处理 +3. ✅ 添加操作日志 +4. ✅ 性能优化 + +**验证**:生产就绪 + +--- + +## 参数修改功能(扩展) + +### 功能概述 + +除了"确认/拒绝"外,HITL 还支持用户修改即将执行的命令参数,然后将修改反馈给 LLM,由 LLM 理解并按新参数执行。 + +### 完整流程 + +``` +LLM 生成命令 A + ↓ + HITL 确认暂停 + ↓ + 显示给用户 + ↓ +┌─────────┼─────────┐ +│ │ │ +直接确认 拒绝 修改为B +│ │ │ +执行A 反馈LLM 反馈(A→B)给LLM + ↓ + LLM理解修改 + ↓ + 生成新tool call + ↓ + 执行B +``` + +### 对话示例 + +``` +用户: 配置 R1 的 gig0/0 接口为 10.0.0.1/24 + +LLM: 我将为您配置 R1 的接口。 + +HITL: ⚠️ 需要确认以下操作 + 工具: execute_multiple_device_config_commands + 参数: { + "device_configs": [{ + "device_name": "R1", + "config_commands": [ + "interface gig0/0", + "ip address 10.0.0.1 255.255.255.0" + ] + }] + } + +用户: [修改参数] + ip address 10.0.0.1 255.255.255.0 + → ip address 192.168.1.1 255.255.255.0 + [保存并执行] + +系统反馈给 LLM: + 用户修改了即将执行的命令参数: + 工具: execute_multiple_device_config_commands + R1: + 原命令: ['interface gig0/0', 'ip address 10.0.0.1 255.255.255.0'] + 修改为: ['interface gig0/0', 'ip address 192.168.1.1 255.255.255.0'] + 请按照修改后的参数执行。 + +LLM: 明白,我将使用修改后的 IP 地址 192.168.1.1/24 来配置 R1 的 gig0/0 接口。 + +[工具执行 execute_multiple_device_config_commands with modified args] + +LLM: 已完成配置,R1 的 gig0/0 接口已配置为 192.168.1.1/24。 +``` + +### 实现要点 + +#### 1. 状态扩展 + +在 `MessagesState` 中添加: + +```python +# 用户修改的字段 +user_modified_args: dict | None # 结构: +# { +# "tool_call_id": str, +# "original_args": dict, +# "modified_args": dict +# } +``` + +#### 2. API 扩展 + +新增端点:`POST /v3/projects/{project_id}/chat/sessions/{session_id}/hitl/modify` + +**请求体**: +```python +{ + "tool_call_id": "call_abc123", + "modified_args": { + # 修改后的完整参数 + } +} +``` + +**响应**: +```python +{ + "status": "modified", + "modification_summary": "显示参数差异", + "message": "已将修改反馈给 AI" +} +``` + +#### 3. 后端处理流程 + +1. 接收修改后的参数 +2. 生成参数差异摘要 +3. 添加 HumanMessage 到对话,说明用户的修改 +4. 清除 `pending_tool_calls` 和 `hitl_confirmation_required` +5. 设置 `user_modified_args`(用于触发 LLM 重新生成) +6. 继续执行,LLM 看到用户的修改后,生成新的 tool call +7. 执行新的 tool call + +#### 4. 前端实现要点 + +**界面组件**: +- 显示原始参数和编辑区域 +- 提供参数差异高亮(原值 vs 新值) +- 支持 JSON 格式验证 +- 保存修改并执行按钮 + +**交互流程**: +1. 用户点击"修改参数"按钮 +2. 展开参数编辑区域,显示原始 JSON +3. 用户在文本框中编辑 JSON +4. 实时验证 JSON 格式 +5. 点击"保存并执行"提交修改 +6. 系统反馈修改摘要并继续执行 + +**用户体验**: +- 对于配置工具,可以提供更友好的命令行界面而非纯 JSON +- 高亮显示修改的部分(红色删除线、绿色新增) +- 提供参数预设模板 +- 显示修改前后的对比视图 + +### 关键代码位置 + +**文件**:`gns3_copilot.py` + +增强 `conditional_tool_execution` 函数,检测 `user_modified_args`: + +```python +def conditional_tool_execution(state: MessagesState) -> dict: + """条件工具执行节点(支持用户修改)""" + + # 处理用户修改的情况 + if state.get("user_modified_args"): + # LLM 已通过 HumanMessage 收到用户修改 + # 清除标记,让 LLM 重新生成 tool call + return { + "user_modified_args": None, + "pending_tool_calls": [], + "hitl_confirmation_required": False + } + + # ... 其他处理逻辑 +``` + +**参数差异生成**: + +```python +def _generate_modification_summary(tool_name: str, original: dict, modified: dict) -> str: + """生成参数修改摘要""" + + if tool_name == "execute_multiple_device_config_commands": + # 特殊处理配置工具,逐命令对比 + orig_devices = original.get("device_configs", []) + mod_devices = modified.get("device_configs", []) + + summary = [] + for orig, mod in zip(orig_devices, mod_devices): + device = orig.get("device_name", "") + orig_cmds = orig.get("config_commands", []) + mod_cmds = mod.get("config_commands", []) + + if orig_cmds != mod_cmds: + summary.append(f"\n{device}:") + for oc, mc in zip(orig_cmds, mod_cmds): + if oc != mc: + summary.append(f" - {oc}") + summary.append(f" + {mc}") + + return "\n".join(summary) if summary else "无修改" + + # 其他工具的通用处理 + # ... +``` + +### 安全考虑 + +#### 参数验证 + +- 验证修改后的参数结构是否完整 +- 检查必填字段是否存在 +- 验证参数值是否在合法范围内 + +#### 危险命令二次确认 + +即使修改后,某些命令仍需二次确认: +- `erase startup-config` +- `reload` +- `format flash:` + +### 测试用例 + +**场景**:用户修改配置命令 + +1. LLM 生成配置命令:`ip address 10.0.0.1 255.255.255.0` +2. 用户修改为:`ip address 192.168.1.1 255.255.255.0` +3. 系统反馈修改摘要 +4. LLM 理解并确认使用新 IP +5. 执行工具,使用修改后的参数 +6. 验证配置结果 + +--- + +## 风险评估 + +| 风险 | 概率 | 影响 | 缓解措施 | +|------|------|------|----------| +| 破坏现有功能 | 低 | 高 | 完整的回归测试 | +| 状态不一致 | 中 | 中 | checkpoint 验证 | +| 性能影响 | 低 | 低 | 异步处理 | +| 前端集成问题 | 中 | 中 | 详细的前端文档 | + +--- + +## 回滚计划 + +如需回滚: + +1. 移除 HITL 相关节点 +2. 恢复原始 `should_continue` 和 `tool_node` +3. 删除新增的 API 端点 +4. checkpoint 中的新字段会被自动忽略 + +**回滚时间**:约 30 分钟 + +--- + +## 后续增强 + +1. **批量操作优化**:支持选择性确认部分工具 +2. **操作历史**:记录所有 HITL 操作 +3. **自动审批**:对低风险操作设置自动审批规则 +4. **多用户协作**:支持多人审批流程 +5. **模板管理**:保存常用配置为模板 + +--- + +## 参考文档 + +- [LangGraph Interrupts](https://langchain-ai.github.io/langgraph/concepts/low_level/#interruption) +- [GNS3 Copilot 架构](./ai-chat-api-design.md) +- [工具响应格式标准](./tool-response-format-standard.md) + +--- + +**文档版本**:v1.0 +**创建日期**:2026-03-04 +**作者**:GNS3 Development Team diff --git a/docs/llm-model-configs-api.md b/docs/gns3-copilot/llm-model-configs-api.md similarity index 100% rename from docs/llm-model-configs-api.md rename to docs/gns3-copilot/llm-model-configs-api.md diff --git a/docs/tool-response-format-standard.md b/docs/gns3-copilot/tool-response-format-standard.md similarity index 100% rename from docs/tool-response-format-standard.md rename to docs/gns3-copilot/tool-response-format-standard.md