gns3-server/docs/gns3-copilot/hitl-implementation-plan.md
YueGuobin 6a9f745c13 feat(copilot): add HITL confirmation for sensitive operations
- Move AI chat API design document to gns3-copilot directory
- Add comprehensive HITL implementation plan with architecture and workflow
- Introduce human-in-the-loop confirmation for dangerous configuration commands
- Include state management, API endpoints, and frontend integration details
- Provide testing plan and deployment steps for the new feature
2026-03-04 23:53:50 +08:00

40 KiB
Raw Blame History

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 行

# 扩展状态定义,添加 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 行)

# 需要确认的工具列表
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 行

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 函数后添加

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 函数后添加

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 行

# 构建工作流
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 函数

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 行后)

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 行后)

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 导出

# 文件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

响应示例

{
    "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

请求体

{
    "confirm_all": true,
    "tool_call_ids": null
}

或指定工具

{
    "confirm_all": false,
    "tool_call_ids": ["call_abc123", "call_def456"]
}

响应示例

{
    "status": "confirmed",
    "confirmed_count": 2,
    "message": "已确认 2 个工具执行"
}

3. 拒绝执行

端点POST /v3/projects/{project_id}/chat/sessions/{session_id}/hitl/reject

请求体

{
    "reject_all": true,
    "reason": "配置有误,需要重新规划"
}

响应示例

{
    "status": "rejected",
    "confirmed_count": 0,
    "message": "已拒绝 1 个工具执行: execute_multiple_device_config_commands"
}

前端集成指南

1. 检测 HITL 状态

// 定期轮询 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. 显示确认对话框

function showConfirmationDialog(hitlStatus) {
    const { pending_tools, hitl_session_id } = hitlStatus;

    const dialog = document.createElement('div');
    dialog.className = 'hitl-confirmation-dialog';

    let html = `
        <h3>⚠️ 需要确认以下操作</h3>
        <div class="pending-tools">
    `;

    pending_tools.forEach(tool => {
        const icon = tool.danger_level === 'high' ? '🔴' : '🟡';
        html += `
            <div class="tool-item">
                <span class="danger-icon">${icon}</span>
                <span class="tool-name">${tool.tool_name}</span>
                <span class="tool-description">${tool.description}</span>
            </div>
        `;
    });

    html += `
        </div>
        <div class="actions">
            <button onclick="confirmAll('${hitl_session_id}')">全部确认</button>
            <button onclick="rejectAll('${hitl_session_id}')">全部拒绝</button>
        </div>
    `;

    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();
    }
}

测试计划

单元测试

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

集成测试

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 中添加:

# 用户修改的字段
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

请求体

{
    "tool_call_id": "call_abc123",
    "modified_args": {
        # 修改后的完整参数
    }
}

响应

{
    "status": "modified",
    "modification_summary": "显示参数差异",
    "message": "已将修改反馈给 AI"
}

3. 后端处理流程

  1. 接收修改后的参数
  2. 生成参数差异摘要
  3. 添加 HumanMessage 到对话,说明用户的修改
  4. 清除 pending_tool_callshitl_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

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
        }

    # ... 其他处理逻辑

参数差异生成

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_continuetool_node
  3. 删除新增的 API 端点
  4. checkpoint 中的新字段会被自动忽略

回滚时间:约 30 分钟


后续增强

  1. 批量操作优化:支持选择性确认部分工具
  2. 操作历史:记录所有 HITL 操作
  3. 自动审批:对低风险操作设置自动审批规则
  4. 多用户协作:支持多人审批流程
  5. 模板管理:保存常用配置为模板

参考文档


文档版本v1.0 创建日期2026-03-04 作者GNS3 Development Team