From 4e28adffb6235abf14ffd13c49fd3b18c0b578ea Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 4 Mar 2026 23:41:29 +0800 Subject: [PATCH] feat(copilot): add tool response normalization utility Add `normalize_tool_response` function to standardize tool output formats for consistent frontend display. The function converts various response types (dict, list, string) into a unified structure with success metrics, detailed data arrays, and metadata. This ensures backward compatibility while providing predictable response formats for UI components. --- docs/tool-response-format-standard.md | 215 ++++++++++++++++++ .../agent/gns3_copilot/utils/__init__.py | 7 +- .../gns3_copilot/utils/parse_tool_content.py | 201 ++++++++++++++++ 3 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 docs/tool-response-format-standard.md diff --git a/docs/tool-response-format-standard.md b/docs/tool-response-format-standard.md new file mode 100644 index 000000000..9ca78eb01 --- /dev/null +++ b/docs/tool-response-format-standard.md @@ -0,0 +1,215 @@ +# GNS3 Copilot Tool Response Format Standard + +## 概述 + +本文档定义了 GNS3 Copilot 工具的标准响应格式,确保所有工具返回统一的数据结构,便于前端处理和美化显示。 + +## 标准响应格式 + +### 顶层结构 + +所有工具应返回以下标准格式: + +```python +{ + "success": bool, # 整体操作是否成功 + "total": int, # 总操作数量 + "successful": int, # 成功数量 + "failed": int, # 失败数量 + "data": list[dict], # 详细结果列表 + "error": str, # 全局错误信息(可选,操作完全失败时) + "metadata": dict # 元数据(可选) +} +``` + +**字段说明**: + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `success` | `bool` | 是 | 整体操作是否成功(`failed == 0` 时为 `True`) | +| `total` | `int` | 是 | 处理的项目总数 | +| `successful` | `int` | 是 | 成功的项目数量 | +| `failed` | `int` | 是 | 失败的项目数量 | +| `data` | `list[dict]` | 是 | 每个项目的详细结果 | +| `error` | `str` | 否 | 全局错误消息(当整个操作失败时) | +| `metadata` | `dict` | 否 | 元数据(时间戳、执行时间等) | + +### 单个项目格式 + +`data` 数组中的每个项目应遵循以下格式: + +```python +{ + "id": str, # 设备/节点/链接 ID + "name": str, # 人类可读的名称 + "status": "success" | "failed", # 项目状态 + "result": str, # 成功时的结果或输出 + "error": str # 失败时的错误信息 +} +``` + +**字段说明**: + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `id` | `str` | 是 | 设备/节点/链接的唯一标识符 | +| `name` | `str` | 是 | 人类可读的名称 | +| `status` | `str` | 是 | `"success"` 或 `"failed"` | +| `result` | `str` | 条件 | 状态为 `success` 时的输出 | +| `error` | `str` | 条件 | 状态为 `failed` 时的错误信息 | + +## 示例 + +### 成功响应示例 + +```python +# 执行多个设备的显示命令 +{ + "success": True, + "total": 3, + "successful": 2, + "failed": 1, + "data": [ + { + "id": "R1", + "name": "Router1", + "status": "success", + "result": "Cisco IOS Software...\nRouter1# show version\n..." + }, + { + "id": "R2", + "name": "Router2", + "status": "success", + "result": "Cisco IOS Software...\nRouter2# show version\n..." + }, + { + "id": "R3", + "name": "Router3", + "status": "failed", + "error": "Connection refused" + } + ], + "metadata": { + "tool_name": "execute_multiple_device_commands", + "execution_time": 5.2 + } +} +``` + +### 完全失败示例 + +```python +# 整个操作失败(如参数错误) +{ + "success": False, + "total": 0, + "successful": 0, + "failed": 0, + "data": [], + "error": "Invalid project_id format", + "metadata": { + "tool_name": "execute_multiple_device_commands" + } +} +``` + +### 单个设备操作示例 + +```python +# 操作单个设备 +{ + "success": True, + "total": 1, + "successful": 1, + "failed": 0, + "data": [ + { + "id": "PC1", + "name": "VPCS-1", + "status": "success", + "result": "IP configuration updated: 192.168.1.10/24" + } + ], + "metadata": {} +} +``` + +## 使用标准化函数 + +在 `gns3server.agent.gns3_copilot.utils` 模块中提供了 `normalize_tool_response` 函数,用于将各种格式转换为标准格式: + +```python +from gns3server.agent.gns3_copilot.utils import normalize_tool_response + +# 标准化工具响应 +normalized = normalize_tool_response(raw_response, tool_name="my_tool") +``` + +该函数支持: +- 列表格式(`[{...}, {...}]`) +- 字典格式(`{"nodes": [...]}`) +- 字符串格式(自动解析 JSON/Python literal) +- 混合格式(兼容旧工具) + +## 兼容性 + +### 向后兼容 + +`normalize_tool_response` 函数设计为向后兼容,可以处理现有工具的各种格式: + +- `status` / `error` 字段 +- `output` / `result` 字段 +- `device_name` / `name` 字段 +- `total_nodes` / `total` 字段 + +### 推荐的迁移策略 + +1. **新工具**:直接返回标准格式 +2. **现有工具**:保持不变,使用 `normalize_tool_response` 标准化 +3. **前端**:依赖标准格式处理显示 + +## 前端集成建议 + +### 渲染逻辑 + +```javascript +function renderToolResponse(response) { + if (!response.success) { + // 显示全局错误 + showError(response.error); + return; + } + + // 显示统计摘要 + showSummary(response.total, response.successful, response.failed); + + // 渲染每个项目 + response.data.forEach(item => { + if (item.status === 'success') { + showSuccess(item.name, item.result); + } else { + showError(item.name, item.error); + } + }); +} +``` + +### 状态图标 + +| 状态 | 图标建议 | 颜色 | +|------|----------|------| +| `success` | ✓ 绿色 | 绿色 | +| `failed` | ✗ 红色 | 红色 | +| `unknown` | ? 灰色 | 灰色 | + +## 版本控制 + +当前标准版本:`v1.0` + +格式变更时,应更新 `metadata.version` 字段,前端据此适配。 + +## 参考 + +- 实现:`gns3server/agent/gns3_copilot/utils/parse_tool_content.py` +- 消息转换:`gns3server/agent/gns3_copilot/utils/message_converters.py` +- 工具示例:`gns3server/agent/gns3_copilot/tools_v2/` diff --git a/gns3server/agent/gns3_copilot/utils/__init__.py b/gns3server/agent/gns3_copilot/utils/__init__.py index 4b780ffbb..8646724fd 100644 --- a/gns3server/agent/gns3_copilot/utils/__init__.py +++ b/gns3server/agent/gns3_copilot/utils/__init__.py @@ -13,7 +13,11 @@ Author: Guobin Yue # Import main utility functions from .get_gns3_device_port import get_device_ports_from_topology -from .parse_tool_content import format_tool_response, parse_tool_content +from .parse_tool_content import ( + format_tool_response, + normalize_tool_response, + parse_tool_content +) # Dynamic version management try: @@ -33,4 +37,5 @@ __all__ = [ "get_device_ports_from_topology", "parse_tool_content", "format_tool_response", + "normalize_tool_response", ] diff --git a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py index e2a62b9d4..e8374d08d 100644 --- a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py +++ b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py @@ -14,6 +14,28 @@ Supported formats: - Error message strings - Plain text output +Standard Tool Response Format: + All tools should follow this standardized format for consistency: + + { + "success": bool, # Whether the overall operation succeeded + "total": int, # Total number of items processed + "successful": int, # Number of successful operations + "failed": int, # Number of failed operations + "data": list[dict], # Detailed results (one entry per item) + "error": str, # Global error message (if operation failed entirely) + "metadata": dict # Optional metadata (timestamp, execution_time, etc.) + } + + Single item format (for data array items): + { + "id": str, # Device/node/link ID + "name": str, # Human-readable name + "status": "success" | "failed", # Item status + "result": str, # Success result or output + "error": str # Error message (if failed) + } + Author: Guobin Yue """ @@ -232,6 +254,185 @@ def format_tool_response( return result +def normalize_tool_response( + response: dict | list | str, + tool_name: str = "unknown" +) -> dict: + """ + Normalize tool response to standard format for consistent frontend display. + + This function converts various tool response formats into a standardized structure + that frontend code can rely on. It handles both legacy formats and new formats, + ensuring backward compatibility. + + Args: + response: Raw tool response (dict, list, or string) + tool_name: Name of the tool (for error messages) + + Returns: + dict: Normalized response in standard format: + { + "success": bool, + "total": int, + "successful": int, + "failed": int, + "data": list[dict], + "error": str (optional), + "metadata": dict + } + + Examples: + >>> normalize_tool_response({"status": "success", "output": "OK"}) + {'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}} + + >>> normalize_tool_response([{"device_name": "R1", "status": "success"}]) + {'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [...], 'metadata': {}} + """ + from datetime import datetime + + metadata = { + "tool_name": tool_name, + "normalized_at": datetime.utcnow().isoformat() + } + + # Handle error responses + if isinstance(response, dict) and "error" in response and len(response) == 1: + return { + "success": False, + "total": 0, + "successful": 0, + "failed": 0, + "data": [], + "error": str(response["error"]), + "metadata": metadata + } + + # Handle empty responses + if not response: + return { + "success": True, + "total": 0, + "successful": 0, + "failed": 0, + "data": [], + "metadata": metadata + } + + # Handle list responses (most tools return list of device results) + if isinstance(response, list): + successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success") + failed = len(response) - successful + + normalized_data = [] + for item in response: + if isinstance(item, dict): + normalized_item = { + "id": item.get("device_id") or item.get("node_id") or item.get("id") or "", + "name": item.get("device_name") or item.get("name") or "", + "status": item.get("status", "unknown"), + } + if normalized_item["status"] == "success": + normalized_item["result"] = item.get("output") or item.get("result") or "" + else: + normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error" + normalized_data.append(normalized_item) + else: + # Non-dict items in list + normalized_data.append({ + "id": "", + "name": "", + "status": "unknown", + "result": str(item) + }) + + return { + "success": failed == 0, + "total": len(response), + "successful": successful, + "failed": failed, + "data": normalized_data, + "metadata": metadata + } + + # Handle dict responses (some tools return summary + results) + if isinstance(response, dict): + # Check if already in standard format + if "success" in response and "data" in response: + return { + "success": response.get("success", True), + "total": response.get("total", len(response.get("data", []))), + "successful": response.get("successful", 0), + "failed": response.get("failed", 0), + "data": response.get("data", []), + "error": response.get("error"), + "metadata": {**metadata, **response.get("metadata", {})} + } + + # Legacy format: extract common fields + total = response.get("total_nodes") or response.get("total") or response.get("count", 1) + successful = response.get("successful_nodes") or response.get("successful") or 0 + failed = response.get("failed_nodes") or response.get("failed") or 0 + + # Extract data from various possible locations + data = [] + if "nodes" in response: + data = response["nodes"] + elif "results" in response: + data = response["results"] + elif "data" in response: + data = response["data"] + elif "output" in response: + # Single device response + data = [{ + "name": response.get("device_name", ""), + "status": response.get("status", "success"), + "result": response["output"] + }] + + # If no data found but have status, create single item + if not data and "status" in response: + data = [{ + "name": response.get("device_name") or response.get("name") or "", + "status": response["status"], + "result": response.get("output") or response.get("result") or "", + "error": response.get("error") or "" + }] + + # Recursively normalize data items + if data and isinstance(data, list): + return normalize_tool_response(data, tool_name) + else: + # No data array, return empty but preserve counts + return { + "success": failed == 0, + "total": total, + "successful": successful, + "failed": failed, + "data": [], + "metadata": metadata + } + + # Handle string responses (parse first) + if isinstance(response, str): + parsed = parse_tool_content(response, fallback_to_raw=True) + return normalize_tool_response(parsed, tool_name) + + # Fallback for unknown types + return { + "success": True, + "total": 1, + "successful": 1, + "failed": 0, + "data": [{ + "id": "", + "name": "", + "status": "unknown", + "result": str(response) + }], + "metadata": metadata + } + + # Test function to verify the implementation def _test_parse_tool_content() -> None: """Test function to verify parse_tool_content works correctly with all input types"""