/ SPEC-serial-tasks-and-web-access.md
SPEC-serial-tasks-and-web-access.md
1 # Spec: 串行任务编排 + AI 网络访问能力 2 3 ## 问题一:任务编排缺少串行支持(Planner 不感知 DAG) 4 5 ### 现状分析 6 7 后端 **已经完整实现了** DAG 执行引擎(`DagExecutor.java`),支持: 8 - `dependsOn`: 步骤依赖关系(有向无环图) 9 - `condition`: 条件表达式(如 `step[1].status == 'completed'`) 10 - `checkpoint`: 人工审批卡点 11 - `outputVar` / `inputVars`: 跨步骤变量传递(`${varName}` 语法) 12 - `onFailure`: 失败策略(`abort` / `skip` / `goto:{stepIndex}`) 13 - `timeoutSec`: 步骤级别超时 14 15 但 **Planner Agent 的 prompt 完全没有提及这些字段**,导致 LLM 永远不会生成 DAG 计划。 16 17 `PlanExecutor.executePlan()` 的路由逻辑: 18 ```java 19 if (isDagPlan(plan)) { 20 dagExecutor.executeDag(plan, request, sink, cancelled); // DAG 模式 21 } else { 22 executeSimplePlan(plan, request, sink, cancelled); // 简单模式 23 } 24 ``` 25 26 `isDagPlan()` 判断条件:步骤中存在 `dependsOn`、`condition` 或 `checkpoint` 任一字段。由于 Planner 从不生成这些字段,所以 **永远走简单模式**。 27 28 ### 解决方案 29 30 #### 变更 1: 更新 PLANNER_AGENT Prompt 31 32 **文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/chat/SystemPrompts.java` 33 34 将 `PLANNER_AGENT` 常量的输出格式部分从当前的简单格式扩展为包含 DAG 字段的完整格式。具体改动: 35 36 **当前输出格式(第 108-125 行):** 37 ```json 38 { 39 "summary": "计划概要说明", 40 "steps": [ 41 { 42 "index": 0, 43 "description": "步骤描述", 44 "agent": "execute", 45 "tools": ["executeScript"], 46 "hosts": ["host1-id"], 47 "parallel_group": 0, 48 "rollback_hint": "回滚提示(可选)" 49 } 50 ], 51 "requires_confirmation": true, 52 "estimated_risk": "LOW" 53 } 54 ``` 55 56 **改为:** 57 ```json 58 { 59 "summary": "计划概要说明", 60 "steps": [ 61 { 62 "index": 0, 63 "description": "步骤描述", 64 "agent": "explore", 65 "tools": ["listHosts"], 66 "hosts": ["host1-id"], 67 "depends_on": [], 68 "output_var": "host_list", 69 "input_vars": {}, 70 "on_failure": "abort", 71 "timeout_sec": 60, 72 "rollback_hint": "无需回滚(只读操作)" 73 }, 74 { 75 "index": 1, 76 "description": "在目标主机上执行脚本", 77 "agent": "execute", 78 "tools": ["executeScript"], 79 "hosts": ["host1-id"], 80 "depends_on": [0], 81 "input_vars": {"target": "${host_list}"}, 82 "on_failure": "abort", 83 "timeout_sec": 300, 84 "rollback_hint": "需要手动清理" 85 } 86 ], 87 "requires_confirmation": true, 88 "estimated_risk": "MEDIUM" 89 } 90 ``` 91 92 同时新增 prompt 规则说明: 93 94 ``` 95 ## 步骤依赖与编排 96 - depends_on: 数组,包含当前步骤依赖的步骤 index。依赖的步骤必须先完成。为空或省略表示无依赖 97 - output_var: 将步骤结果存储到变量中,供后续步骤通过 input_vars 引用 98 - input_vars: 从前序步骤获取数据,格式 {"参数名": "${变量名}"} 99 - on_failure: 步骤失败时的策略 — "abort"(终止计划)/ "skip"(跳过继续)/ "goto:N"(跳转到步骤N) 100 - timeout_sec: 步骤超时秒数,默认 300 101 102 ## 串行 vs 并行判断原则 103 - 步骤 B 需要步骤 A 的结果 → B.depends_on = [A.index](串行) 104 - 多个步骤相互独立、操作不同主机 → 不设 depends_on(并行) 105 - 先查询再执行 → 查询步骤的 output_var 被执行步骤的 input_vars 引用 106 - 删除旧 parallel_group 字段,统一使用 depends_on 表达依赖关系 107 ``` 108 109 #### 变更 2: DagExecutor 补充 Host Context 注入 110 111 **文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/orchestrator/DagExecutor.java` 112 113 **问题**: `DagExecutor.executeDagStep()` 第 177 行直接将 `taskDescription` 传给 `executeAsSubAgent()`,没有像 `PlanExecutor.buildSubAgentPrompt()` 那样注入 host context 和 tool hints。 114 115 **方案**: 复用 `PlanExecutor.buildSubAgentPrompt()` 的逻辑。有两种实现路径: 116 117 - **方案 A(推荐)**: 将 `buildSubAgentPrompt()` 提取为共享工具方法(如 `SubAgentPromptBuilder` 工具类或 `PlanExecutor` 上的 `public static` 方法),让 `DagExecutor` 也调用 118 - **方案 B**: 在 `DagExecutor` 中内联同样的逻辑 119 120 改动点(以方案 A 为例): 121 122 1. 新建 `SubAgentPromptBuilder.java`(或将方法移到已有的工具类中): 123 ```java 124 public static String buildSubAgentPrompt(ExecutionPlan.PlanStep step, OrchestratorRequest request) { 125 // 现有 PlanExecutor.buildSubAgentPrompt() 的逻辑,原封不动 126 } 127 ``` 128 129 2. `PlanExecutor.executeSingleStep()` 改为调用共享方法 130 3. `DagExecutor.executeDagStep()` 第 153-156 行,将: 131 ```java 132 String taskDescription = substituteVariables(step.getDescription(), step.getInputVars(), variables); 133 ``` 134 改为: 135 ```java 136 String taskDescription = substituteVariables(step.getDescription(), step.getInputVars(), variables); 137 // 在变量替换后,注入 host context 138 step.setDescription(taskDescription); // 更新为替换后的描述 139 taskDescription = SubAgentPromptBuilder.buildSubAgentPrompt(step, request); 140 ``` 141 142 #### 变更 3: 移除 parallel_group,统一用 depends_on 143 144 **文件**: `ExecutionPlan.java` 145 146 `parallelGroup` 字段可以保留向后兼容,但 Planner prompt 不再推荐使用。`DagExecutor` 天然支持无 `dependsOn` 的步骤并行执行(`findReadySteps()` 会把所有无依赖的 pending 步骤同时放入 ready 列表)。 147 148 **无需改动代码** — DAG 引擎已经隐式支持并行:没有 `dependsOn` 的步骤自动并行执行。只需确保 Planner prompt 不再输出 `parallel_group`。 149 150 #### 变更 4: 简单模式作为降级兜底 151 152 `executeSimplePlan()` 保留不动,作为 LLM 输出不含 DAG 字段时的降级路径。无需修改。 153 154 ### 涉及文件 155 156 | 文件 | 改动 | 157 |------|------| 158 | `SystemPrompts.java` | 更新 `PLANNER_AGENT` prompt,增加 DAG 字段文档和示例 | 159 | `DagExecutor.java` | 调用 `buildSubAgentPrompt()` 注入 host context | 160 | `PlanExecutor.java` | 将 `buildSubAgentPrompt()` 提取为共享方法 | 161 | 新建 `SubAgentPromptBuilder.java`(可选)| 共享 prompt 构建逻辑 | 162 163 ### 风险评估 164 165 - **低风险**: Prompt 更新不影响任何代码逻辑,只影响 LLM 生成的计划结构 166 - **低风险**: DagExecutor 的 host 注入是纯增量改动 167 - **需验证**: LLM 是否能稳定生成符合 DAG schema 的 JSON(建议在 prompt 中给出 2-3 个示例覆盖串行、并行、混合场景) 168 169 --- 170 171 ## 问题二:AI 无法访问网络(URL 获取 / 网络搜索) 172 173 ### 现状分析 174 175 当前 AI Agent 拥有 15 个工具,全部面向内部运维操作(主机、脚本、任务、监控等)。**没有任何网络访问能力**。当用户给 AI 一个 URL 让其分析时,AI 无法获取该 URL 的内容。 176 177 ### 解决方案:新增两个工具 — WebFetchTool + WebSearchTool 178 179 #### 工具 1: WebFetchTool — URL 内容获取 180 181 **目的**: 获取指定 URL 的网页内容,提取纯文本,返回给 AI 分析。 182 183 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/WebFetchTool.java` 184 185 ```java 186 @Slf4j 187 @Component 188 @RequiredArgsConstructor 189 public class WebFetchTool { 190 191 private final AgenticConfigService configService; 192 193 @Tool(description = "获取指定 URL 的网页内容并提取纯文本。适用于用户提供了一个网址需要 AI 分析其内容的场景。支持 HTTP/HTTPS 网页。注意:返回的是提取后的纯文本,不包含 HTML 标签、脚本、样式等。") 194 public String fetchUrl( 195 @ToolParam(description = "要获取的完整 URL 地址,必须以 http:// 或 https:// 开头") String url) { 196 // 1. URL 校验(白名单/黑名单) 197 // 2. Jsoup 获取 + 提取纯文本 198 // 3. 截断到配置的最大字符数 199 // 4. 返回结构化结果(标题 + 正文摘要) 200 } 201 } 202 ``` 203 204 **核心实现逻辑:** 205 206 ```java 207 // 安全校验 208 if (!url.startsWith("http://") && !url.startsWith("https://")) { 209 return "URL 格式无效,必须以 http:// 或 https:// 开头"; 210 } 211 212 // 域名黑名单检查(可配置) 213 List<String> blockedDomains = configService.getList("ai.web.blocked-domains", 214 List.of("localhost", "127.0.0.1", "10.*", "172.16.*", "192.168.*")); 215 // ... 校验逻辑 216 217 // 获取页面 218 int timeout = configService.getInt("ai.web.fetch-timeout-ms", 10000); 219 int maxSize = configService.getInt("ai.web.max-body-size-bytes", 1048576); // 1MB 220 221 Document doc = Jsoup.connect(url) 222 .userAgent("EasyShell-AI/1.0") 223 .timeout(timeout) 224 .maxBodySize(maxSize) 225 .followRedirects(true) 226 .get(); 227 228 // 移除非内容元素 229 doc.select("script, style, nav, footer, header, noscript, aside, iframe, form").remove(); 230 231 // 提取标题和正文 232 String title = doc.title(); 233 String bodyText = doc.body().text(); 234 235 // 截断 236 int maxChars = configService.getInt("ai.web.max-content-chars", 8000); 237 if (bodyText.length() > maxChars) { 238 bodyText = bodyText.substring(0, maxChars) + "\n... [内容已截断,共 " + bodyText.length() + " 字符]"; 239 } 240 241 return String.format("【网页标题】%s\n【来源 URL】%s\n【正文内容】\n%s", title, url, bodyText); 242 ``` 243 244 **依赖新增**: `build.gradle.kts` 添加 Jsoup 245 246 ```kotlin 247 implementation("org.jsoup:jsoup:1.18.3") 248 ``` 249 250 #### 工具 2: WebSearchTool — 网络搜索 251 252 **目的**: 用关键词搜索互联网,返回搜索结果摘要,帮助 AI 获取实时信息。 253 254 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/WebSearchTool.java` 255 256 **搜索引擎选型**: 257 258 | 方案 | 优点 | 缺点 | 推荐度 | 259 |------|------|------|--------| 260 | **Tavily API** | 专为 AI 设计,返回干净文本;业界标准(OpenAI/LangChain 都用) | 需要 API Key,免费额度 1000次/月 | ⭐⭐⭐⭐⭐ | 261 | **SerpAPI** | 包装 Google 搜索,结果质量高 | 收费,$50/月起 | ⭐⭐⭐ | 262 | **Google Custom Search** | 官方 API | 每天 100 次免费,之后 $5/1000 次 | ⭐⭐⭐ | 263 | **DuckDuckGo HTML** | 免费,无需 API Key | 不稳定,可能被反爬 | ⭐⭐ | 264 | **SearXNG 自建** | 完全免费,隐私友好 | 需自行部署和维护 | ⭐⭐⭐ | 265 266 **推荐方案:Tavily(主) + 可配置切换(备)** 267 268 选择 Tavily 的理由: 269 1. 专为 AI Agent 设计,返回的是 LLM-ready 的干净文本(不是 HTML) 270 2. 单次 API 调用即可获得搜索结果 + 页面正文提取 271 3. 免费额度 1000 次/月,对运维场景绰绰有余 272 4. 被 OpenAI Function Calling、LangChain、LlamaIndex 等主流框架广泛使用 273 274 ```java 275 @Slf4j 276 @Component 277 @RequiredArgsConstructor 278 public class WebSearchTool { 279 280 private final AgenticConfigService configService; 281 private final RestClient.Builder restClientBuilder; 282 283 @Tool(description = "使用搜索引擎搜索互联网信息。适用于需要获取实时信息、查找技术文档、搜索错误解决方案等场景。返回搜索结果的标题、URL 和内容摘要。") 284 public String webSearch( 285 @ToolParam(description = "搜索关键词") String query, 286 @ToolParam(description = "返回结果数量,默认 5,最大 10") int maxResults) { 287 288 String provider = configService.get("ai.web.search-provider", "tavily"); 289 290 return switch (provider) { 291 case "tavily" -> searchViaTavily(query, Math.min(maxResults, 10)); 292 case "searxng" -> searchViaSearXNG(query, Math.min(maxResults, 10)); 293 default -> "未配置搜索引擎,请在系统设置中配置 ai.web.search-provider"; 294 }; 295 } 296 297 private String searchViaTavily(String query, int maxResults) { 298 String apiKey = configService.get("ai.web.tavily-api-key", ""); 299 if (apiKey.isBlank()) { 300 return "Tavily API Key 未配置,请在系统设置中配置 ai.web.tavily-api-key"; 301 } 302 303 // POST https://api.tavily.com/search 304 // Body: { "query": "...", "max_results": 5, "search_depth": "basic" } 305 // Response: { "results": [{ "title": "...", "url": "...", "content": "..." }] } 306 307 // ... RestClient 调用并格式化结果 308 } 309 310 private String searchViaSearXNG(String query, int maxResults) { 311 String baseUrl = configService.get("ai.web.searxng-url", "http://localhost:8888"); 312 // GET {baseUrl}/search?q={query}&format=json&engines=google,bing&limit={maxResults} 313 // ... RestClient 调用并格式化结果 314 } 315 } 316 ``` 317 318 #### 变更 3: 注册工具到 OrchestratorEngine 319 320 **文件**: `OrchestratorEngine.java` 321 322 1. 注入 `WebFetchTool` 和 `WebSearchTool`: 323 ```java 324 private final WebFetchTool webFetchTool; 325 private final WebSearchTool webSearchTool; 326 ``` 327 328 2. 在 `getAllTools()` 中注册: 329 ```java 330 private Object[] getAllTools() { 331 return new Object[]{ 332 hostListTool, hostTagTool, scriptExecuteTool, softwareDetectTool, 333 taskManageTool, scriptManageTool, clusterManageTool, 334 monitoringTool, auditQueryTool, scheduledTaskTool, approvalTool, 335 subAgentTool, delegateTaskTool, getTaskResultTool, 336 webFetchTool, webSearchTool // 新增 337 }; 338 } 339 ``` 340 341 #### 变更 4: 更新 ToolSetSelector 白名单 342 343 **文件**: `ToolSetSelector.java` 344 345 将 `fetchUrl` 和 `webSearch` 加入相关任务类型的白名单: 346 347 ```java 348 TaskType.QUERY → 加入 "fetchUrl", "webSearch" 349 TaskType.GENERAL → 加入 "fetchUrl", "webSearch" 350 // EXECUTE/TROUBLESHOOT/DEPLOY 本就允许所有工具,无需改动 351 ``` 352 353 #### 变更 5: 更新系统 Prompt 354 355 **文件**: `SystemPrompts.java` 356 357 在 `OPS_ASSISTANT` 的能力清单中新增: 358 359 ``` 360 ### 网络访问 361 - 获取指定 URL 的网页内容并提取纯文本用于分析 362 - 使用搜索引擎搜索互联网信息(技术文档、错误解决方案等) 363 ``` 364 365 #### 变更 6: 安全配置项 366 367 **通过 AgenticConfigService(数据库配置)新增以下配置项:** 368 369 | 配置键 | 默认值 | 说明 | 370 |--------|--------|------| 371 | `ai.web.enabled` | `true` | 总开关:是否启用 AI 网络访问能力 | 372 | `ai.web.fetch-timeout-ms` | `10000` | URL 获取超时(毫秒) | 373 | `ai.web.max-body-size-bytes` | `1048576` | 最大下载体积(1MB) | 374 | `ai.web.max-content-chars` | `8000` | 返回给 AI 的最大文本字符数 | 375 | `ai.web.blocked-domains` | `localhost,127.0.0.1,10.*,172.16.*,192.168.*` | 禁止访问的域名/IP 模式 | 376 | `ai.web.allowed-schemes` | `http,https` | 允许的 URL 协议 | 377 | `ai.web.search-provider` | `tavily` | 搜索引擎提供商 | 378 | `ai.web.tavily-api-key` | (空) | Tavily API Key | 379 | `ai.web.searxng-url` | `http://localhost:8888` | SearXNG 实例地址 | 380 381 **安全考量:** 382 - 默认禁止访问内网地址(SSRF 防护) 383 - 配置 `ai.web.enabled = false` 可完全关闭此能力 384 - URL 获取限制体积(1MB)和超时(10s),防止 DoS 385 - 域名黑名单支持通配符匹配 386 387 #### 变更 7: 新增依赖 388 389 **文件**: `build.gradle.kts` 390 391 ```kotlin 392 // HTML 解析与文本提取 393 implementation("org.jsoup:jsoup:1.18.3") 394 ``` 395 396 > 注:Tavily API 调用使用 Spring 自带的 `RestClient`,不需要额外依赖。 397 398 ### 涉及文件汇总 399 400 | 文件 | 改动类型 | 401 |------|----------| 402 | `build.gradle.kts` | 新增 Jsoup 依赖 | 403 | 新建 `WebFetchTool.java` | URL 获取 + 文本提取 | 404 | 新建 `WebSearchTool.java` | 网络搜索(Tavily / SearXNG) | 405 | `OrchestratorEngine.java` | 注入并注册两个新工具 | 406 | `ToolSetSelector.java` | 白名单加入 `fetchUrl`、`webSearch` | 407 | `SystemPrompts.java` | OPS_ASSISTANT 能力清单新增网络访问 | 408 | `DataInitializer.java`(可选) | 初始化默认配置值 | 409 410 ### 风险评估 411 412 - **低风险**: 新增工具是纯增量改动,不修改现有逻辑 413 - **安全敏感**: SSRF 防护必须到位(内网地址阻断),建议上线前做安全审查 414 - **需配置**: Tavily 需要 API Key,首次部署需在系统设置中配置 415 - **降级方案**: 若未配置 API Key 或 `ai.web.enabled=false`,工具返回友好提示而非报错 416 417 --- 418 419 ## 问题三:AI 缺少通用工具能力 420 421 ### 现状分析 422 423 当前 AI 的 15 个工具全部面向运维场景(主机管理、脚本执行、任务监控等)。缺少通用的实用工具,导致 AI 在处理一些基础任务时能力受限: 424 425 - 无法获取当前时间或进行时间计算 426 - 无法进行数学运算 427 - 无法处理文本(正则提取、diff 对比等) 428 - 无法转换数据格式(JSON ↔ YAML 等) 429 - 无法发送通知 430 - 无法查询知识库(如果有的话) 431 - 无法进行编码/解码操作 432 433 ### 解决方案:新增 7 个通用工具 434 435 #### 工具 1: DateTimeTool — 时间日期处理(P0) 436 437 **目的**: 获取当前时间、解析时间字符串、计算时间差。运维场景中经常需要处理时间(日志时间、任务调度、超时计算等)。 438 439 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/DateTimeTool.java` 440 441 ```java 442 @Slf4j 443 @Component 444 public class DateTimeTool { 445 446 @Tool(description = "获取当前时间。可指定时区和输出格式。") 447 public String getCurrentTime( 448 @ToolParam(description = "时区,如 'Asia/Shanghai'、'UTC'、'America/New_York',默认 UTC") String timezone, 449 @ToolParam(description = "输出格式,如 'yyyy-MM-dd HH:mm:ss'、'ISO8601',默认 ISO8601") String format) { 450 // 返回格式化的当前时间 451 } 452 453 @Tool(description = "解析时间字符串为标准格式。支持多种常见格式的自动识别。") 454 public String parseTime( 455 @ToolParam(description = "要解析的时间字符串") String timeStr, 456 @ToolParam(description = "输入时间的格式(可选,不提供则自动识别)") String inputFormat, 457 @ToolParam(description = "目标输出格式") String outputFormat) { 458 // 解析并转换时间格式 459 } 460 461 @Tool(description = "计算两个时间点之间的差值。") 462 public String timeDiff( 463 @ToolParam(description = "开始时间") String startTime, 464 @ToolParam(description = "结束时间") String endTime, 465 @ToolParam(description = "返回单位:'seconds'、'minutes'、'hours'、'days'、'human'(人类可读)") String unit) { 466 // 计算时间差并按指定单位返回 467 } 468 } 469 ``` 470 471 **使用场景**: 472 - "现在几点了?" 473 - "这个日志时间戳 1709123456 是什么时候?" 474 - "任务从 10:30 到 15:45 执行了多久?" 475 476 --- 477 478 #### 工具 2: CalculatorTool — 数学计算(P1) 479 480 **目的**: 进行数学表达式计算和单位换算。运维场景中经常需要计算(磁盘容量、内存百分比、带宽换算等)。 481 482 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/CalculatorTool.java` 483 484 ```java 485 @Slf4j 486 @Component 487 public class CalculatorTool { 488 489 @Tool(description = "计算数学表达式。支持 +、-、*、/、^(幂)、%(取模)、括号、常用数学函数(sqrt、sin、cos、log、abs 等)。") 490 public String calculate( 491 @ToolParam(description = "数学表达式,如 '(100 - 75) / 100 * 100' 或 'sqrt(144) + 2^3'") String expression) { 492 // 使用 exp4j 库解析和计算表达式 493 } 494 495 @Tool(description = "存储单位换算。在 B、KB、MB、GB、TB、PB 之间转换。") 496 public String convertStorageUnit( 497 @ToolParam(description = "数值") double value, 498 @ToolParam(description = "源单位:B/KB/MB/GB/TB/PB") String fromUnit, 499 @ToolParam(description = "目标单位:B/KB/MB/GB/TB/PB") String toUnit) { 500 // 单位换算 501 } 502 } 503 ``` 504 505 **依赖**: `net.objecthunter:exp4j:0.4.8` — 轻量级数学表达式解析器 506 507 **使用场景**: 508 - "磁盘使用了 800GB,总共 2TB,使用率是多少?" 509 - "1.5TB 等于多少 GB?" 510 - "计算 (1024 * 1024 * 500) / (1024 * 1024 * 1024)" 511 512 --- 513 514 #### 工具 3: TextProcessTool — 文本处理(P1) 515 516 **目的**: 正则提取、文本对比、统计等。运维场景中经常需要从日志/输出中提取信息。 517 518 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/TextProcessTool.java` 519 520 ```java 521 @Slf4j 522 @Component 523 public class TextProcessTool { 524 525 @Tool(description = "使用正则表达式从文本中提取内容。返回所有匹配项。") 526 public String extractByRegex( 527 @ToolParam(description = "要搜索的文本") String text, 528 @ToolParam(description = "正则表达式") String regex, 529 @ToolParam(description = "要提取的捕获组编号,0 表示整个匹配,默认 0") int group) { 530 // 正则匹配并提取 531 } 532 533 @Tool(description = "对比两段文本的差异,返回 diff 结果。") 534 public String diffText( 535 @ToolParam(description = "原始文本") String originalText, 536 @ToolParam(description = "修改后的文本") String modifiedText, 537 @ToolParam(description = "输出格式:'unified'(统一 diff)、'inline'(行内标记)") String format) { 538 // 使用 java-diff-utils 生成 diff 539 } 540 541 @Tool(description = "统计文本信息:字符数、单词数、行数等。") 542 public String textStats( 543 @ToolParam(description = "要统计的文本") String text) { 544 // 返回统计信息 545 } 546 } 547 ``` 548 549 **依赖**: `io.github.java-diff-utils:java-diff-utils:4.12` — Diff 算法库 550 551 **使用场景**: 552 - "从这段日志中提取所有 IP 地址" 553 - "对比这两个配置文件的差异" 554 - "这段文本有多少行?" 555 556 --- 557 558 #### 工具 4: DataFormatTool — 数据格式转换(P2) 559 560 **目的**: JSON、YAML、XML、CSV 等格式之间的转换,以及 JSONPath 查询。 561 562 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/DataFormatTool.java` 563 564 ```java 565 @Slf4j 566 @Component 567 @RequiredArgsConstructor 568 public class DataFormatTool { 569 570 private final ObjectMapper objectMapper; 571 private final YAMLMapper yamlMapper; 572 573 @Tool(description = "在不同数据格式之间转换。支持 JSON、YAML、Properties 格式。") 574 public String convertFormat( 575 @ToolParam(description = "要转换的数据") String data, 576 @ToolParam(description = "源格式:json/yaml/properties") String fromFormat, 577 @ToolParam(description = "目标格式:json/yaml/properties") String toFormat) { 578 // 格式转换 579 } 580 581 @Tool(description = "格式化 JSON 字符串,使其更易读。") 582 public String prettyPrintJson( 583 @ToolParam(description = "要格式化的 JSON 字符串") String json) { 584 // Pretty print 585 } 586 587 @Tool(description = "使用 JSONPath 表达式从 JSON 中提取数据。") 588 public String extractJsonPath( 589 @ToolParam(description = "JSON 数据") String json, 590 @ToolParam(description = "JSONPath 表达式,如 '$.store.book[0].title' 或 '$..author'") String jsonPath) { 591 // 使用 JsonPath 库提取 592 } 593 } 594 ``` 595 596 **依赖**: 597 - `com.fasterxml.jackson.dataformat:jackson-dataformat-yaml` — YAML 支持(Spring Boot 已有) 598 - `com.jayway.jsonpath:json-path:2.9.0` — JSONPath 查询 599 600 **使用场景**: 601 - "把这个 JSON 转换成 YAML" 602 - "从这个 API 响应中提取所有用户的 email" 603 - "格式化这个压缩的 JSON" 604 605 --- 606 607 #### 工具 5: NotificationTool — 发送通知(P1) 608 609 **目的**: 复用现有的 BotChannelService,让 AI 能够主动发送通知到配置的渠道。 610 611 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/NotificationTool.java` 612 613 ```java 614 @Slf4j 615 @Component 616 @RequiredArgsConstructor 617 public class NotificationTool { 618 619 private final BotChannelRepository botChannelRepository; 620 private final BotChannelService botChannelService; 621 622 @Tool(description = "发送通知消息到配置的 Bot 渠道(Telegram、Discord、Slack、钉钉、飞书、企业微信)。") 623 public String sendNotification( 624 @ToolParam(description = "通知消息内容") String message, 625 @ToolParam(description = "渠道名称(可选,不提供则发送到所有已启用的渠道)") String channelName, 626 @ToolParam(description = "消息类型:'info'、'warning'、'error'、'success',影响消息样式") String messageType) { 627 // 调用 BotChannelService 发送 628 } 629 630 @Tool(description = "列出所有可用的通知渠道。") 631 public String listChannels() { 632 // 返回已配置的渠道列表 633 } 634 } 635 ``` 636 637 **使用场景**: 638 - "发现磁盘使用率超过 90%,通知运维群" 639 - "任务执行完成,发送结果到 Telegram" 640 - "发送告警到钉钉" 641 642 **注意**: 复用现有 `BotChannelService`,无需新增外部依赖。 643 644 --- 645 646 #### 工具 6: KnowledgeBaseTool — 知识库查询(P2) 647 648 **目的**: 利用 Spring AI 的 VectorStore 实现 RAG(检索增强生成),让 AI 能够查询内部知识库。 649 650 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/KnowledgeBaseTool.java` 651 652 ```java 653 @Slf4j 654 @Component 655 @RequiredArgsConstructor 656 @ConditionalOnBean(VectorStore.class) // 仅在配置了 VectorStore 时启用 657 public class KnowledgeBaseTool { 658 659 private final VectorStore vectorStore; 660 661 @Tool(description = "从知识库中搜索相关文档。适用于查找内部文档、操作手册、FAQ 等。") 662 public String searchKnowledge( 663 @ToolParam(description = "搜索查询") String query, 664 @ToolParam(description = "返回结果数量,默认 5") int topK) { 665 // 向量检索 666 List<Document> results = vectorStore.similaritySearch( 667 SearchRequest.query(query).withTopK(Math.min(topK, 10))); 668 // 格式化返回结果 669 } 670 } 671 ``` 672 673 **依赖**: Spring AI VectorStore(可选配置,支持 Chroma、Pinecone、PGVector 等) 674 675 **使用场景**: 676 - "查一下我们的运维手册中关于 MySQL 备份的说明" 677 - "搜索内部文档:如何申请服务器权限" 678 679 **注意**: 这是可选功能,需要额外配置 VectorStore。使用 `@ConditionalOnBean` 确保未配置时不会报错。 680 681 --- 682 683 #### 工具 7: EncodingTool — 编码解码(P2) 684 685 **目的**: Base64、URL 编码/解码、哈希计算等。 686 687 **新文件**: `easyshell-server/src/main/java/com/easyshell/server/ai/tool/EncodingTool.java` 688 689 ```java 690 @Slf4j 691 @Component 692 public class EncodingTool { 693 694 @Tool(description = "Base64 编码或解码。") 695 public String base64( 696 @ToolParam(description = "要处理的字符串") String input, 697 @ToolParam(description = "操作:'encode'(编码)或 'decode'(解码)") String operation) { 698 // Base64 编解码 699 } 700 701 @Tool(description = "URL 编码或解码。") 702 public String urlEncode( 703 @ToolParam(description = "要处理的字符串") String input, 704 @ToolParam(description = "操作:'encode'(编码)或 'decode'(解码)") String operation) { 705 // URL 编解码 706 } 707 708 @Tool(description = "计算字符串的哈希值。") 709 public String hash( 710 @ToolParam(description = "要计算哈希的字符串") String input, 711 @ToolParam(description = "哈希算法:'MD5'、'SHA-1'、'SHA-256'、'SHA-512'") String algorithm) { 712 // 计算哈希 713 } 714 } 715 ``` 716 717 **使用场景**: 718 - "把这个字符串 Base64 编码一下" 719 - "解码这个 URL 编码的参数" 720 - "计算这个文件内容的 SHA-256" 721 722 --- 723 724 ### 涉及文件汇总 725 726 | 文件 | 改动类型 | 优先级 | 727 |------|----------|--------| 728 | 新建 `DateTimeTool.java` | 时间日期处理 | P0 | 729 | 新建 `CalculatorTool.java` | 数学计算 | P1 | 730 | 新建 `TextProcessTool.java` | 文本处理 | P1 | 731 | 新建 `DataFormatTool.java` | 数据格式转换 | P2 | 732 | 新建 `NotificationTool.java` | 发送通知 | P1 | 733 | 新建 `KnowledgeBaseTool.java` | 知识库查询(可选) | P2 | 734 | 新建 `EncodingTool.java` | 编码解码 | P2 | 735 | `OrchestratorEngine.java` | 注入并注册所有新工具 | - | 736 | `ToolSetSelector.java` | 白名单加入所有新工具 | - | 737 | `SystemPrompts.java` | OPS_ASSISTANT 能力清单新增通用工具说明 | - | 738 739 ### 新增依赖汇总 740 741 ```kotlin 742 // build.gradle.kts 新增 743 744 // 数学表达式解析 745 implementation("net.objecthunter:exp4j:0.4.8") 746 747 // 文本 Diff 748 implementation("io.github.java-diff-utils:java-diff-utils:4.12") 749 750 // JSONPath 查询 751 implementation("com.jayway.jsonpath:json-path:2.9.0") 752 753 // YAML 支持(Spring Boot 已自带 jackson-dataformat-yaml,无需额外添加) 754 // VectorStore(可选,按需配置) 755 ``` 756 757 --- 758 759 ## 实施建议 760 761 ### 优先级总览 762 763 | 优先级 | 任务 | 工作量 | 价值 | 764 |--------|------|--------|------| 765 | **P0** | Planner Prompt 更新(问题一变更 1) | 低 | 高 — 解锁已有 DAG 引擎 | 766 | **P0** | DagExecutor Host 注入(问题一变更 2) | 低 | 高 — 修复 DAG 模式 host 丢失 | 767 | **P0** | DateTimeTool | 低 | 高 — 基础能力 | 768 | **P1** | WebFetchTool(问题二工具 1) | 中 | 高 — 核心网络能力 | 769 | **P1** | CalculatorTool | 低 | 中 | 770 | **P1** | TextProcessTool | 中 | 中 | 771 | **P1** | NotificationTool | 低 | 中 — 复用现有服务 | 772 | **P2** | WebSearchTool(问题二工具 2) | 中 | 中 — 需要外部 API | 773 | **P2** | DataFormatTool | 中 | 中 | 774 | **P2** | EncodingTool | 低 | 低 | 775 | **P2** | KnowledgeBaseTool | 高 | 中 — 需要 VectorStore 配置 | 776 777 ### 分阶段实施 778 779 **阶段一(核心能力)— 预计 1 天**: 780 1. Planner Prompt 更新 + DagExecutor Host 注入(问题一) 781 2. WebFetchTool(问题二核心) 782 3. DateTimeTool 783 784 **阶段二(增强能力)— 预计 1 天**: 785 1. CalculatorTool 786 2. TextProcessTool 787 3. NotificationTool 788 4. WebSearchTool(需要 Tavily API Key) 789 790 **阶段三(扩展能力)— 预计 1-2 天**: 791 1. DataFormatTool 792 2. EncodingTool 793 3. KnowledgeBaseTool(可选,取决于是否需要 RAG) 794 795 ### ToolSetSelector 白名单更新 796 797 ```java 798 // ToolSetSelector.java 更新 799 800 // 通用工具 — 所有任务类型都可用 801 private static final Set<String> UNIVERSAL_TOOLS = Set.of( 802 "getCurrentTime", "parseTime", "timeDiff", // DateTime 803 "calculate", "convertStorageUnit", // Calculator 804 "extractByRegex", "diffText", "textStats", // TextProcess 805 "convertFormat", "prettyPrintJson", "extractJsonPath", // DataFormat 806 "base64", "urlEncode", "hash" // Encoding 807 ); 808 809 // 需要特定权限的工具 810 TaskType.QUERY → 加入 "fetchUrl", "webSearch", "searchKnowledge" 811 TaskType.GENERAL → 加入 "fetchUrl", "webSearch", "searchKnowledge" 812 TaskType.EXECUTE → 加入 "sendNotification", "listChannels" // 执行后可能需要通知 813 ``` 814 815 ### 系统 Prompt 更新 816 817 **文件**: `SystemPrompts.java` — `OPS_ASSISTANT` 818 819 在能力清单中新增: 820 821 ``` 822 ### 网络访问 823 - 获取指定 URL 的网页内容并提取纯文本用于分析 824 - 使用搜索引擎搜索互联网信息(技术文档、错误解决方案等) 825 826 ### 通用工具 827 - 时间日期:获取当前时间、解析时间、计算时间差 828 - 数学计算:表达式计算、存储单位换算 829 - 文本处理:正则提取、文本对比 diff、统计 830 - 数据格式:JSON/YAML/Properties 互转、JSONPath 查询、格式化 831 - 编码解码:Base64、URL 编码、哈希计算 832 - 发送通知:将消息推送到配置的 Bot 渠道 833 - 知识库搜索:查询内部文档(如已配置) 834 ``` 835 836 --- 837 838 ## 测试验证 839 840 ### 问题一(串行任务)测试用例 841 842 1. **串行依赖测试**: "检查所有在线主机的磁盘使用率,对超过 80% 的主机执行清理脚本" 843 - 预期:计划包含 `depends_on`(步骤 2 依赖步骤 1) 844 - 验证:DAG 模式被触发(`isDagPlan()` 返回 true) 845 846 2. **变量传递测试**: "先获取负载最高的主机,然后在该主机上执行诊断脚本" 847 - 预期:步骤 1 有 `output_var`,步骤 2 的 `input_vars` 引用该变量 848 849 3. **Host Context 测试**: 验证 DAG 模式下子 Agent 收到的 prompt 包含主机信息 850 851 ### 问题二(网络访问)测试用例 852 853 1. **URL 获取测试**: 给 AI 发送一个公开 URL,验证能获取并分析内容 854 2. **SSRF 防护测试**: 尝试获取 `http://192.168.1.1` — 应被阻断 855 3. **大页面截断测试**: 获取一个大于 1MB 的页面 — 应正确截断 856 4. **搜索测试**: "搜索 Linux 磁盘清理最佳实践" — 返回搜索结果 857 858 ### 问题三(通用工具)测试用例 859 860 1. **DateTime**: "现在北京时间几点?" 861 2. **Calculator**: "500GB 等于多少 TB?" 862 3. **TextProcess**: "从这段日志中提取所有 ERROR 开头的行" 863 4. **DataFormat**: "把这个 JSON 转换成 YAML" 864 5. **Notification**: "发送测试消息到 Telegram" 865 6. **Encoding**: "Base64 编码 'hello world'" 866 867 --- 868 869 ## 风险评估 870 871 | 风险点 | 级别 | 缓解措施 | 872 |--------|------|----------| 873 | SSRF(WebFetchTool 访问内网) | 高 | 域名黑名单 + IP 范围检查 | 874 | LLM 无法稳定生成 DAG JSON | 中 | Prompt 中提供 2-3 个完整示例 | 875 | Tavily API Key 未配置 | 低 | 返回友好提示而非报错 | 876 | 表达式注入(CalculatorTool) | 低 | exp4j 只支持数学表达式,无代码执行风险 | 877 | 正则 ReDoS(TextProcessTool) | 中 | 设置匹配超时 | 878 | VectorStore 未配置 | 低 | 使用 @ConditionalOnBean 优雅降级 |