diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 2ca7c4d..5b57c64 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -18,6 +18,11 @@ jobs: shell: bash steps: + - name: Setup tools + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq jq + jq --version + - name: Checkout code env: SERVER_URL: ${{ github.server_url }} diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index 7560413..b1fd6a6 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -78,7 +78,8 @@ CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell **触发时机**: - 用户明确说"保存/沉淀/记录工作流" -- 完成任务时发现稳定可复用的多步流程 +- 任务完成且所有工具调用已结束、产生最终结果后,再判断当前流程是否稳定可复用 +- **禁止**在规划任务未完成、工具调用链中间(即仍有 pending 步骤时)触发沉淀 - 严禁写入:token、password、secret、API key、system prompt、隐私数据 ## 用户偏好持久化(memory_manager) diff --git a/.opencode/skills/SKILL.md b/.opencode/skills/SKILL.md index 3a82cf6..ea9d8ea 100644 --- a/.opencode/skills/SKILL.md +++ b/.opencode/skills/SKILL.md @@ -1,5 +1,5 @@ --- -name: tjwater-skills +name: skills description: TJWater Skills — 动态生长的分析工作流树。后端服务由 Agent 自行通过 tjwater-cli help 发现。 --- diff --git a/.opencode/skills/runbook.md b/.opencode/skills/runbook.md index bc7021d..2143c74 100644 --- a/.opencode/skills/runbook.md +++ b/.opencode/skills/runbook.md @@ -64,9 +64,9 @@ SSE 事件: - CLI 返回 `"ok": false` → 检查 `error.code` 和 `error.message` - `UNAUTHENTICATED`:检查 token 和项目权限 -- `NOT_FOUND`:检查资源 ID 或命令拼写 +- `COMMAND_NOT_FOUND` / `INPUT_NOT_FOUND`:检查命令拼写或文件路径 - `SERVER_ERROR`:记录 `request_id`,结合后端日志排查 -- CLI 超时:增大 `timeout` 参数 +- `REQUEST_TIMEOUT` / `TIMEOUT`:增大 `timeout` 参数 ## 8) 前端工具调用链 diff --git a/.opencode/skills/tjwater-cli/SKILL.md b/.opencode/skills/tjwater-cli/SKILL.md new file mode 100644 index 0000000..ed0fe46 --- /dev/null +++ b/.opencode/skills/tjwater-cli/SKILL.md @@ -0,0 +1,164 @@ +--- +name: tjwater-cli +description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输出格式、命令族、错误处理及最佳实践。 +--- + +# tjwater-cli 使用说明 + +## 概述 + +`tjwater-cli` 是 TJWater 供水管网系统的命令行工具,用于与后端服务交互,支持数据查询、分析和工程操作。所有输出统一为 JSON 格式。 + +## 工具调用 + +通过 `tjwater_cli` 工具执行 CLI 命令: + +```json +{ + "reason": "说明调用原因", + "command": "project list", + "timeout": 60 +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `reason` | string | 是 | 调用原因 | +| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context`) | +| `timeout` | number | 否 | 超时秒数,默认 60,大结果集建议 300+ | + +认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。 + +## 命令发现 + +Agent 通过 `help` 动态发现可用命令,而非依赖硬编码清单。 + +**重要:命令分为两类——触发动作与数据获取。** + +- **触发动作**(`simulation`、`analysis`):向服务端发起计算请求,返回任务状态/ID,**不直接返回分析结果**。 +- **数据获取**(`data timeseries`):所有计算结果(仿真压力、分析指标等)的唯一数据出口,需在触发动作完成后调用。 + +``` +simulation/analysis → 触发计算 → 返回状态/任务ID + ↓ + data timeseries → 获取计算结果 +``` + +通过 `help` 发现命令: + +``` +tjwater-cli help → 一级命令清单(含 commands 数组和 summary) +tjwater-cli help data timeseries → data timeseries 的子命令与参数详情 +tjwater-cli help simulation → simulation 的子命令与参数详情 +tjwater-cli help COMMAND → 子命令与参数详情 +``` + +`help` 返回 JSON 格式,Agent 可直接解析 `commands` 数组识别可用能力。 + +**严禁猜测命令或参数!** 所有命令路径、子命令和参数(名称、类型、必填/可选)均以 `help` 输出为准。执行任何命令前,必须先通过 `help` 确认其存在及参数签名,禁止凭经验拼写。 + +### 已知命令族 + +| 命令族 | 典型子命令 | 用途 | +|------|-----------|------| +| `project` | `list`, `db-health` | 项目管理、数据库健康检查 | +| `data` | `timeseries realtime links / nodes`, `timeseries scada query` | **时序数据查询**(实时/SCADA),所有分析结果的唯一获取渠道 | +| `simulation` | 通过 `help simulation` 发现 | **触发水力仿真计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) | +| `analysis` | 通过 `help analysis` 发现 | **触发分析计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) | +| `net` | `list-pipes` | 管网拓扑查询 | +| `help` | (无子命令) | 命令发现入口 | + +> 完整命令清单始终以 `tjwater-cli help` 实时输出为准。 + +## 输出格式 + +所有命令返回统一 JSON 结构: + +```json +{ + "schema_version": "tjwater-cli/v1", + "ok": true, + "data": { ... }, + "error": { + "code": "COMMAND_NOT_FOUND", + "message": "详细错误描述" + } +} +``` + +- `ok: true` — 成功,数据在 `data` 字段 +- `ok: false` — 失败,检查 `error.code` 和 `error.message` + +### 大结果集处理 + +禁止完整读取超大结果集。优先使用: +- 采样/截断参数(如 `--limit`、`--offset`) +- `--field` 按字段过滤 +- `jq` 管道提取关键字段 + +## 错误码速查 + +| error.code | 含义 | 来源 | 处理建议 | +|------|------|------|------| +| `UNAUTHENTICATED` | 缺少 access token | CLI `core.py:162` | 检查认证上下文注入 | +| `SERVER_ERROR` | 后端返回 error 状态 | CLI `core.py:400` | 记录 `request_id`,结合后端日志排查 | +| `REQUEST_TIMEOUT` | CLI 请求后端超时 | CLI `core.py:445` | 增大 `timeout` 参数或检查后端负载 | +| `TIMEOUT` | bridge 层进程超时 | Agent `server.ts:199` | 增大 `tjwater_cli` 的 `timeout` 参数 | +| `COMMAND_NOT_FOUND` | 命令/子命令不存在 | CLI `helping.py:300` | 执行 `help` 确认命令拼写 | +| `INPUT_NOT_FOUND` | `--input` 文件不存在 | CLI `core.py:243` | 检查文件路径 | +| `REQUEST_FAILED` | 网络连接失败 | CLI `core.py:453` | 检查服务端可达性 | +| `AUTH_CONTEXT_INVALID` | 认证上下文格式错误 | CLI `core.py:111` | 检查 auth headers 格式 | + +## 最佳实践 + +1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写 +2. **reason 必填** — 每次调用必须说明具体理由 +3. **触发后取数据** — `simulation`/`analysis` 仅触发计算,结果必须从 `data timeseries` 获取,勿将触发返回的状态信息当作分析结果 +4. **管道串联** — workflow 脚本中用 shell pipe 串联多个 CLI 命令,减少 `subprocess.run` 次数 +5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试 +6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据 + +## 示例 + +### 查询所有实时节点数据 +```json +{ + "reason": "获取最近1小时内全部节点的实时数据", + "command": "data timeseries realtime nodes --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00" +} +``` +> `data timeseries realtime nodes` 仅接受 `--start-time` / `--end-time`,返回全量节点数据。 + +### 按节点查询方案时序字段 +```json +{ + "reason": "查询节点 J-001 最近1小时的压力数据", + "command": "data timeseries scheme node-field --node J-001 --field pressure --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00" +} +``` + +### 查询 SCADA 时序数据 +```json +{ + "reason": "查询 SCADA 设备 170490 在指定时间范围的 monitored_value", + "command": "data timeseries scada query --device-id 170490 --field monitored_value --start-time 2026-06-02T00:00:00+08:00 --end-time 2026-06-03T00:00:00+08:00" +} +``` + +### 触发仿真并获取结果 + +`simulation run` 仅接受 `--start-time`(RFC3339,必填)和 `--duration`(整数分钟,必填)。结果需从 `data timeseries` 获取: + +```json +// step 1: 触发仿真 (duration 为分钟数) +{ + "reason": "触发24小时水力仿真", + "command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 1440" +} +// step 2: 按节点和时间获取仿真结果 +{ + "reason": "获取仿真结果中节点 J-001 09:00 时刻的压力", + "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" +} +``` + diff --git a/Dockerfile b/Dockerfile index d5a306b..c2c732e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/ unzip \ python3 \ python3-venv && \ + jq && \ curl -LsSf https://astral.sh/uv/install.sh | sh && \ ln -s /root/.local/bin/uv /usr/local/bin/uv && \ ln -sf /usr/bin/python3 /usr/local/bin/python && \ diff --git a/src/config.ts b/src/config.ts index 30a4022..5053a6e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,8 @@ const envSchema = z OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), // 提供给本地 opencode tools 读取的会话上下文目录。 SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"), + // tjwater-cli 可执行文件路径。 + TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"), // TJWater 后端 API 的基础地址。 TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"), // 代理调用 TJWater 后端 API 的超时时间(毫秒)。 diff --git a/src/server.ts b/src/server.ts index 24bcc46..0e4504e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; import cors from "cors"; import express from "express"; @@ -114,6 +115,118 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => { } }); +app.post("/internal/tools/tjwater-cli-call", async (req, res) => { + if (req.header("x-agent-internal-token") !== internalToken) { + res.status(403).json({ message: "forbidden" }); + return; + } + + const sessionScopeKey = + typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; + const threadContext = await toolContextStore.read(sessionScopeKey); + const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey); + if (!threadContext && !runtimeContext) { + res.status(404).json({ + message: "runtime or session context not found", + detail: sessionScopeKey, + }); + return; + } + const context = runtimeContext ?? threadContext; + if (!context) { + res.status(404).json({ + message: "runtime or session context not found", + detail: sessionScopeKey, + }); + return; + } + + const command = typeof req.body?.command === "string" ? req.body.command.trim() : ""; + if (!command) { + res.status(400).json({ message: "command is required" }); + return; + } + + const timeoutSec = + typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 60; + + const authJson = JSON.stringify({ + server: config.TJWATER_API_BASE_URL, + access_token: runtimeContext?.accessToken, + project_id: context.projectId, + network:"tjwater", + }); + + const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)]; + + const child = spawn(config.TJWATER_CLI_PATH, cliArgs, { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString("utf-8"); + }); + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString("utf-8"); + }); + + child.stdin.write(authJson); + child.stdin.end(); + + const exitCode = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGTERM"); + resolve(-1); + }, timeoutSec * 1000); + child.on("close", (code) => { + clearTimeout(timer); + resolve(code); + }); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); + + if (exitCode === -1) { + res.status(504).json({ + ok: false, + schema_version: "tjwater-cli/v1", + summary: "命令超时", + error: { + code: "TIMEOUT", + message: `command timed out after ${timeoutSec}s`, + retryable: true, + }, + }); + return; + } + + if (exitCode !== 0) { + res.status(502).json({ + ok: false, + exit_code: exitCode, + stderr: stderr.slice(0, 2000), + stdout: stdout.slice(0, 2000), + message: `CLI exited with code ${exitCode}`, + }); + return; + } + + try { + res.json(JSON.parse(stdout)); + } catch { + res.json({ + ok: true, + schema_version: "tjwater-cli/v1", + raw: stdout, + stderr: stderr || undefined, + }); + } +}); + app.post("/internal/tools/fetch-result-ref", async (req, res) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" });