/ 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 优雅降级 |