From d58bd19039259da48b7736c7811c02a971d8f273 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 9 Oct 2026 11:28:37 +0800 Subject: [PATCH] feat(agent): unify models and validate OpenCode providers Generate chat, task and learning models from agent-models.json. Use WaterNetwork fast/expert models with 272k context and validate upstream names before readiness. Add an isolated model check command with signal-safe cleanup and plugin, Skill and MCP management docs. Verified in isolation: type checks, 26 focused tests and both model catalog checks; no model inference requests. --- .dockerignore | 1 + .env.example | 10 +- .gitignore | 1 + .opencode/README.md | 101 +++++ .opencode/agent-plugin.json | 49 +++ .opencode/agents/instruction.md | 1 - .opencode/model-check.ts | 59 +++ .opencode/plugins/tjwater-tools.ts | 9 + .opencode/tjwater-plugin/package.json | 1 + AGENTS.md | 204 ++++++++-- Dockerfile | 2 +- README.md | 414 +++++++++++++------- agent-models.json | 30 ++ opencode.json | 76 ---- package.json | 1 + scripts/check-models.ts | 51 +++ src/chat/modelConfig.ts | 165 ++++---- src/chat/models.ts | 6 +- src/config.ts | 63 +-- src/learning/orchestrator.ts | 22 +- src/routes/chatStream.ts | 15 +- src/runtime/model-check.ts | 35 ++ src/runtime/opencode.ts | 20 +- src/runtime/opencode2-config.ts | 6 +- src/runtime/opencode2-server.ts | 13 +- tests/chat/models.test.ts | 96 +++-- tests/integration/provider-catalog-smoke.ts | 2 + tests/opencode/modelCheck.test.ts | 73 ++++ tests/runtime/modelCheckCommand.test.ts | 47 +++ tests/runtime/opencode.test.ts | 21 +- 30 files changed, 1125 insertions(+), 469 deletions(-) create mode 100644 .opencode/README.md create mode 100644 .opencode/agent-plugin.json create mode 100644 .opencode/model-check.ts create mode 100644 agent-models.json create mode 100644 scripts/check-models.ts create mode 100644 src/runtime/model-check.ts create mode 100644 tests/integration/provider-catalog-smoke.ts create mode 100644 tests/opencode/modelCheck.test.ts create mode 100644 tests/runtime/modelCheckCommand.test.ts diff --git a/.dockerignore b/.dockerignore index ec39f14..6ec420e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ node_modules .env .env.* .local.env +agent-models.local.json dist .vscode data diff --git a/.env.example b/.env.example index 9d6a45a..f28ca94 100644 --- a/.env.example +++ b/.env.example @@ -12,9 +12,13 @@ OPENCODE_MODE=embedded OPENCODE_COMMAND=opencode2 # client 模式连接私有 V2 server 时,填写其 server password(不要提交实际值)。 # OPENCODE_CLIENT_PASSWORD= -OPENCODE_MODEL=deepseek/deepseek-flash -# 可选:后台工况任务首选模型,必须同时出现在 OPENCODE_MODEL_OPTIONS 中。 -# OPERATIONAL_TASK_MODEL=deepseek/deepseek-v4-pro +# 模型、provider、默认聊天/后台/学习模型统一在 agent-models.json 配置。 +# 可选:使用独立的本地配置文件,修改后重启 Agent 和外部 OpenCode。 +# AGENT_MODEL_CONFIG_PATH=./agent-models.local.json +# API key 仅在本地或部署环境填写,不提交实际值。 +# 当前配置未启用 DeepSeek;重新配置该 provider 时再填写。 +# DEEPSEEK_API_KEY= +WATERNETWORK_API_KEY= OPENCODE_HOSTNAME=127.0.0.1 OPENCODE_PORT=0 # client 模式必须在 Agent 与外部 OpenCode 进程配置同一个高熵值;embedded 模式可留空。 diff --git a/.gitignore b/.gitignore index 5a5db47..dfd7b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ .opencode/node_modules/ .opencode/skills/ .local.env +agent-models.local.json .vscode docker-compose.yml data/ diff --git a/.opencode/README.md b/.opencode/README.md new file mode 100644 index 0000000..5ea6f00 --- /dev/null +++ b/.opencode/README.md @@ -0,0 +1,101 @@ +# 供水 Agent 的 OpenCode 资产 + +这里维护服务使用的 Agent 指令、插件和工具。管理员通过这些入口管理可复用 Skill 和外部 MCP 连接。 + +[agent-plugin.json](agent-plugin.json) 是本项目的描述清单,列出各项能力的用途、文件路径和配置来源,方便检索和交接。它是管理索引,不参与运行时加载;字段中的相对路径以 `.opencode/` 为基准,环境变量指定的路径仍遵循服务原有解析规则。 + +## 目录与加载入口 + +| 入口 | 作用 | 实际加载方式 | +| --- | --- | --- | +| [agents/instruction.md](agents/instruction.md) | 供水 Agent 的业务指令 | 配置生成器读取正文,设为 `instruction` Agent 的 system 指令 | +| [tjwater-plugin/package.json](tjwater-plugin/package.json) | 插件包名称、版本、描述和导出入口 | OpenCode 显式加载该目录包 | +| [tjwater-plugin/index.ts](tjwater-plugin/index.ts) | 插件包入口 | 导出 `plugins/tjwater-tools.ts` 的默认插件 | +| [plugins/tjwater-tools.ts](plugins/tjwater-tools.ts) | `tjwater.tools` 插件实现 | 注册业务与待办工具、沙箱、MCP 策略和内部 RPC | +| `skills/` | 可复用流程及引用、脚本 | 默认 Skill 根目录,可由 `OPENCODE_SKILLS_ROOT_DIR` 替换 | +| [../opencode.json](../opencode.json) | 工具权限和 MCP 连接 | 服务合并部署覆盖配置后校验并交给 OpenCode | +| [../agent-models.json](../agent-models.json) | 模型和 provider | 统一生成 OpenCode 模型定义,不在描述清单中重复配置 | + +[serviceOpencodeConfig](../src/runtime/opencode2-config.ts) 将可信插件包、Agent 指令和 Skill 根目录解析为绝对路径。会话目录位于 `.agent-workspaces/` 时也能加载这些服务资产。插件包名 `tjwater-tools-plugin`、运行时插件 ID `tjwater.tools`、Agent ID `instruction` 各有用途,新增描述时应保留它们的对应关系。 + +原生插件通过 `Plugin.define({ id, setup })` 注册,额外插件使用 OpenCode 的 `plugins` 配置。参考 [OpenCode 插件文档](https://opencode.ai/v2/docs/build/plugins/)。本项目显式加载可信目录包,新增文件不等于已经注册了业务能力,需检查运行时工具目录。 + +## 管理 Skill + +Skill 是模型按需读取的流程说明,工具负责实际执行。一个 Skill 推荐使用以下结构: + +```text +skills/ +└── supply-data-review/ + ├── SKILL.md + ├── references/ + │ └── field-guide.md + └── scripts/ + └── summarize.py +``` + +`SKILL.md` 至少包含名称、用途和适用条件,正文引用的文件路径相对于该 Skill 目录: + +```markdown +--- +name: supply-data-review +description: 核对供水数据的时间范围、单位和缺失情况。 +--- + +# 供水数据核对 + +适用于已通过受控工具取得的数据。 + +1. 核对查询范围、时间和单位。 +2. 将真实缺失与接口错误分别记录。 +3. 输出数据结论和未确认事项,保留实际证据来源。 +``` + +目录名称决定 OpenCode 的 Skill ID,frontmatter 的 `name` 用于显示。推荐使用唯一的小写名称,避免同名 Skill 被后加载的来源覆盖;完整规则见 [OpenCode Skill 文档](https://opencode.ai/v2/docs/skills/)。 + +当前 Skill 根目录由 `OPENCODE_SKILLS_ROOT_DIR` 统一指定,供 OpenCode 加载和 Agent 的 SkillStore 使用。描述清单只引用该入口,不维护另一份 Skill 名单。使用绝对路径可减少外部 OpenCode 和 Agent 的启动目录差异。 + +`skill_manager` 支持列出、写入和删除 Skill,以及维护 pattern、reference 和 script。它通过可信内部工具桥调用 [SkillStore](../src/skills/store.ts),对内容、路径和写入历史进行处理;默认备份位于 `data/backup/skills/`。当前 Skill 存储按部署实例共享,应保存通用方法,避免写入用户凭据、具体会话数据和未经验证的业务结论。 + +服务当前的指令要求:流程已经验证可复用,且用户明确需要保存时,才通过 `skill_manager` 沉淀。Skill 中的执行步骤不能改变工具权限;引用脚本仍需通过批准的会话沙箱执行,业务 API 仍使用类型化工具。 + +`skills/` 被 Git 忽略,保存或交付 Skill 时需要单独准备目录和持久化挂载,克隆仓库不包含已有实例的 Skill。修改后重启相关运行时以重新发现;需要后续实现在线刷新时使用原生 Skill reload 接口,当前管理桥不保证即时刷新 OpenCode 目录。 + +## 管理 MCP + +真实连接配置统一在仓库根目录 `opencode.json` 的 `mcp.servers` 中,部署覆盖使用 `OPENCODE_CONFIG_CONTENT`。`agent-plugin.json` 只指向该位置,不存地址、命令或认证头。 + +服务名使用 `mcp_` 前缀。添加、修改或禁用连接时,按根目录 README 的 [MCP 配置说明](../README.md#配置外部-mcp) 操作;支持远程 Streamable HTTP 和本地 stdio,详细字段参考 [OpenCode MCP 文档](https://opencode.ai/v2/docs/mcp-servers/)。 + +密钥通过 `{env:NAME}` 引用 `.local.env` 或部署环境。需要 OAuth 时,由管理员使用同一服务账号和 XDG 数据目录执行认证,不把 TJWater 用户令牌转交给 MCP 服务。修改连接后重启运行时;client 模式需同步外部 OpenCode 的配置与可信插件。 + +查看会话中实际加载的服务和工具: + +```text +GET /api/v1/agent/chat/session/:session_id/mcp +``` + +该接口使用产品 Bearer、项目头及会话归属校验,只返回服务名、类型、状态、配置协议和实际工具 ID。`connected` 只表示连接状态,仍需检查工具 ID 是否已出现;响应不包含地址、进程命令或凭据。 + +MCP 执行策略由 [mcp-policy.ts](mcp-policy.ts) 和 [服务配置校验](../src/runtime/mcp-config.ts) 维护: + +| 会话 | 调用规则 | +| --- | --- | +| 普通聊天、任务协作 | 管理员明确允许的只读工具可自动执行,其他调用需审批 | +| 后台任务 | 仅调用精确的只读白名单工具 | +| 学习复盘 | 禁用 MCP 调用 | + +只读白名单需在 `permissions` 中使用状态接口返回的精确工具名,不能用宽泛的 `mcp_* allow` 替代。Code Mode 和通用资源访问也应用同一策略。本地 stdio MCP 运行于可信宿主进程,不受会话 Shell 沙箱约束,环境过滤不等于 OS 隔离。 + +## 修改后的检查 + +更新 Agent、插件或管理入口时,同步修改描述清单和相关说明,保持路径及 ID 对应。插件的运行时代码变更需要类型检查和相关测试: + +```bash +bun run check +bun test tests/frontendAction/callIdPlugin.test.ts tests/runtime/opencode.test.ts +``` + +MCP 策略变更使用 MCP 相关测试,实际连接联调可选择根目录 README 列出的本地 smoke 脚本。模型配置修改使用 `bun run check:models`。这些命令从仓库根目录运行;仅修改描述或说明时检查路径、JSON 和 `git diff --check` 即可。 + +运行时确认以 Agent 的启动预热和 `/health` 为准。client 模式要同时更新外部 OpenCode 使用的资产。服务启动、工作区保留和容器挂载参见 [根目录 README](../README.md),代码修改约束见 [AGENTS.md](../AGENTS.md)。 diff --git a/.opencode/agent-plugin.json b/.opencode/agent-plugin.json new file mode 100644 index 0000000..f95815c --- /dev/null +++ b/.opencode/agent-plugin.json @@ -0,0 +1,49 @@ +{ + "schema_version": 1, + "id": "tjwater.supply-agent", + "name": "TJWater 供水 Agent", + "description": "供水 Agent 的指令、OpenCode 插件、Skill 与外部 MCP 管理索引。", + "purpose": "documentation-index", + "path_base": ".opencode", + "runtime": { + "name": "opencode2", + "config_builder": "../src/runtime/opencode2-config.ts" + }, + "agents": [ + { + "id": "instruction", + "description": "编排供水数据查询、地图展示和工况任务,遵守类型化工具与业务证据边界。", + "path": "agents/instruction.md", + "mode": "primary" + } + ], + "plugins": [ + { + "id": "tjwater.tools", + "description": "注册业务工具和待办工具,安装会话 Shell 沙箱与 MCP 权限策略,并提供模型校验和工具发现 RPC。", + "package": "tjwater-plugin", + "entrypoint": "tjwater-plugin/index.ts", + "implementation": "plugins/tjwater-tools.ts" + } + ], + "skills": { + "description": "部署实例的可复用流程和辅助文件,由 skill_manager 经可信内部工具桥维护。", + "default_root": "skills", + "root_env": "OPENCODE_SKILLS_ROOT_DIR", + "entry_file": "SKILL.md", + "manager_tool": "skill_manager", + "store": "../src/skills/store.ts" + }, + "mcp": { + "description": "管理员配置的远程 HTTP 或本地 stdio 连接,执行时按会话类型应用权限策略。", + "config_file": "../opencode.json", + "config_key": "mcp.servers", + "override_env": "OPENCODE_CONFIG_CONTENT", + "policy": "mcp-policy.ts", + "status_endpoint": "/api/v1/agent/chat/session/:session_id/mcp" + }, + "models": { + "config_file": "../agent-models.json", + "override_env": "AGENT_MODEL_CONFIG_PATH" + } +} diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index e329ccf..eb0f984 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -1,7 +1,6 @@ --- description: TJWater experimental Agent for structured UI orchestration mode: primary -model: deepseek/deepseek-flash temperature: 0.2 --- 你是 TJWater 实验性供水数据 Agent。回复用户时使用简体中文,内容简洁准确。 diff --git a/.opencode/model-check.ts b/.opencode/model-check.ts new file mode 100644 index 0000000..ceb4c1d --- /dev/null +++ b/.opencode/model-check.ts @@ -0,0 +1,59 @@ +import type { Plugin } from "@opencode/plugin"; +import { modelCheckInputSchema, type ModelCheckInput, type ModelCheckReport } from "../src/runtime/model-check.js"; + +type Context = Parameters[0]; +const catalogPackage = /^(?:@opencode\/ai\/providers\/(?:openai(?:\/chat|\/responses)?|openai-compatible(?:\/responses)?|openrouter|xai))$/; + +// Runs inside OpenCode and uses its effective provider and model definitions. +// A manually registered model ID is only valid after upstream advertisement. +export async function checkOpencodeModels(ctx: Pick, input: ModelCheckInput, signal: AbortSignal): Promise { + const targets = modelCheckInputSchema.parse(input).models; + const nativeModels = (await ctx.model.list({}, { signal })).data; + const report: ModelCheckReport = { + providers: [], + models: targets.map((target) => { + const model = nativeModels.find((model) => model.providerID === target.provider_id && model.id === target.model_id); + return { id: target.id, model_id: target.wire_id, status: !model ? "not_registered" : !model.enabled ? "not_enabled" + : target.requires_tools && !model.capabilities.tools ? "no_tools" : model.modelID !== target.wire_id ? "wire_id_mismatch" : "unchecked" }; + }), + }; + await Promise.all([...new Set(targets.map((target) => target.provider_id))].map(async (providerID) => { + const providerReport: ModelCheckReport["providers"][number] = { id: providerID, status: "not_registered" }; + report.providers.push(providerReport); + let provider; + try { provider = (await ctx.provider.get({ providerID }, { signal })).data; } + catch { return; } + if (!catalogPackage.test(provider.package)) { providerReport.status = "unsupported_catalog"; return; } + const baseURL = provider.settings?.baseURL; + if (typeof baseURL !== "string") { providerReport.status = "catalog_unavailable"; return; } + let endpoint: URL; + try { + endpoint = new URL(`${baseURL.replace(/\/+$/, "")}/models`); + if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error(); + } catch { providerReport.status = "catalog_unavailable"; return; } + let headers: Headers; + try { + headers = new Headers(provider.headers); + headers.set("Accept", "application/json"); + const apiKey = provider.settings?.apiKey; + if (!headers.has("Authorization") && typeof apiKey === "string" && apiKey.trim()) headers.set("Authorization", `Bearer ${apiKey}`); + // An explicit header can also carry a service-owned credential. + if (!headers.has("Authorization")) throw new Error(); + } catch { providerReport.status = "credentials_unavailable"; return; } + try { + const response = await fetch(endpoint, { headers, redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) }); + if (!response.ok) { providerReport.status = "catalog_unavailable"; providerReport.http_status = response.status; return; } + const payload: unknown = await response.json(); + const data = payload && typeof payload === "object" && "data" in payload ? payload.data : undefined; + if (!Array.isArray(data) || data.some((model) => !model || typeof model.id !== "string")) { providerReport.status = "catalog_invalid"; return; } + const advertised = new Set(data.map((model) => model.id as string)); + providerReport.status = "checked"; + for (const target of targets.filter((target) => target.provider_id === providerID)) { + const model = report.models.find((model) => model.id === target.id)!; + if (model.status === "unchecked") model.status = advertised.has(target.wire_id) ? "valid" : "not_advertised"; + } + } catch { providerReport.status = "catalog_unavailable"; } + })); + report.providers.sort((a, b) => a.id.localeCompare(b.id)); + return report; +} diff --git a/.opencode/plugins/tjwater-tools.ts b/.opencode/plugins/tjwater-tools.ts index 1ccac1b..f3a6d7f 100644 --- a/.opencode/plugins/tjwater-tools.ts +++ b/.opencode/plugins/tjwater-tools.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import { resolve } from "node:path"; import { registerSessionSandbox } from "../session-sandbox.js"; import { registerMcpPolicy } from "../mcp-policy.js"; +import { checkOpencodeModels } from "../model-check.js"; import add_task_human_evidence from "../tools/add_task_human_evidence.js"; import clear_scada_analysis from "../tools/clear_scada_analysis.js"; @@ -104,6 +105,13 @@ export default Plugin.define({ await ctx.rpc.register(Rpc.define({ id: "tjwater.tools", methods: { + check_models: { + input: z.object({ models: z.array(z.object({ id: z.string(), provider_id: z.string(), model_id: z.string(), wire_id: z.string(), requires_tools: z.boolean() }).strict()).min(1).max(1000) }).strict(), + output: z.object({ + models: z.array(z.object({ id: z.string(), model_id: z.string(), status: z.enum(["valid", "not_registered", "not_enabled", "no_tools", "wire_id_mismatch", "not_advertised", "unchecked"]) })), + providers: z.array(z.object({ id: z.string(), status: z.enum(["checked", "not_registered", "unsupported_catalog", "catalog_unavailable", "catalog_invalid", "credentials_unavailable"]), http_status: z.number().int().optional() })), + }), + }, list: { input: z.object({}), output: z.array(z.object({ id: z.string(), direct: z.boolean(), sandboxed: z.boolean(), workspaceRoot: z.string().optional() })), @@ -122,6 +130,7 @@ export default Plugin.define({ }, events: {}, }), { + check_models: (input, context) => checkOpencodeModels(ctx, input, context.signal), mcp: mcpReport, list: async () => (await ctx.tool.list()).map((tool) => ({ id: tool.id, diff --git a/.opencode/tjwater-plugin/package.json b/.opencode/tjwater-plugin/package.json index 63c48eb..c51f73f 100644 --- a/.opencode/tjwater-plugin/package.json +++ b/.opencode/tjwater-plugin/package.json @@ -1,6 +1,7 @@ { "name": "tjwater-tools-plugin", "version": "1.0.0", + "description": "TJWater 供水 Agent 的 OpenCode 插件:业务工具、Skill 管理、MCP 会话策略、Shell 沙箱与模型校验。", "private": true, "type": "module", "exports": "./index.ts" diff --git a/AGENTS.md b/AGENTS.md index 8950dbd..cda7c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,59 +1,185 @@ # Repository Guidelines -## Project Structure +## Scope and Structure -This is the experimental TJWater supply Agent service. Runtime TypeScript lives -in `src/`; opencode agent assets live in `.opencode/`; tests live in `tests/`. -The project intentionally has no `cli/` directory and no user-auth middleware. +This repository is the experimental TJWater supply Agent service. Keep drainage +data, workflows, layer IDs, and terminology in the drainage repositories. +Read [README.md](README.md) for setup, configuration, and operational behavior. -Key boundaries: +- `src/server.ts` assembles dependencies and starts the service. +- `src/routes/` owns HTTP/SSE wiring; keep orchestration thin. +- `src/context/` owns local and Server-verified request context. +- `src/product/` owns typed TJWater Server adapters and business contracts. +- `src/frontendAction/` owns controlled browser actions and their receipts. +- `src/uiEnvelope/` owns the `agent-ui@1` compatibility protocol and registry. +- `src/runtime/` owns OpenCode lifecycle, readiness, and workspace binding. +- `src/sandbox/` and `scripts/landlock_sandbox.py` own Shell enforcement. +- `src/sessions/`, `src/results/`, `src/memory/`, and `src/learning/` retain their + respective persistence responsibilities. +- `.opencode/` contains trusted service instructions, plugins, and tools. +- `.opencode/agent-plugin.json` describes agents, plugins, Skill storage, and MCP + configuration sources; `.opencode/README.md` explains their management. +- `tests/` contains focused tests and opt-in native integration scripts. -- `src/context/` owns local request context. -- `src/uiEnvelope/` owns the `agent-ui@1` protocol, registry, and tool mapping. -- `src/routes/` owns HTTP/SSE wiring. -- `src/results/`, `src/sessions/`, `src/memory/`, and `src/learning/` keep their existing persistence responsibilities. +Use TypeScript ESM, two-space indentation, kebab-case module filenames, and +explicit types at public boundaries. Preserve established service patterns +before adding new abstractions. Backend access must use typed tools/adapters; +do not introduce `cli/`, `tjwater_cli`, or a free-form business command bridge. +The existing approved sandbox Shell is the only local command execution path. -Generated `data/`, `logs/`, and dependency folders must not be committed. Keep -drainage datasets, drainage workflows, and drainage-network terminology out of -this repository. +## Model Configuration -## Commands +`agent-models.json` is the single model configuration source. An explicit +`AGENT_MODEL_CONFIG_PATH` selects a complete replacement; relative paths resolve +from the process working directory. `agent-models.local.json` is ignored by Git +and the Docker build. -Use Bun: +- Define provider IDs, `api_url`, API key references, models, aliases, capabilities, + limits, and chat/task/learning role defaults in that file. +- Generate OpenCode definitions and the public chat model menu from it through + `src/chat/modelConfig.ts` and `src/runtime/opencode2-config.ts`. +- Do not add independent model defaults in `opencode.json`, agent frontmatter, + routes, learning code, or environment variables. Legacy `OPENCODE_MODEL`, + `OPERATIONAL_TASK_MODEL`, and `OPENCODE_MODEL_OPTIONS` no longer select models. +- Use `model_id` when the Agent-facing name differs from the upstream name; + preserve existing public references when correcting upstream aliases. +- Keep real API keys in ignored `.local.env` or deployment environment variables; + use `{env:NAME}` references in model files. Existing system environment values + take precedence over `.local.env`. +- Never return provider URLs, keys, headers, or effective credential settings in + model menus, validation reports, status endpoints, or logs. + +After model/provider changes, run `bun run check:models`. It launches an isolated +OpenCode and validates all non-disabled models, including hidden task/learning +models, against effective native definitions and upstream `/models` inventories. +A self-declared `model.list` entry alone is not validation. `disabled` skips a +model; `selectable: false` only hides it from the chat menu and does not skip it. +Do not use paid inference as the default configuration check. + +The checker lives in `.opencode/model-check.ts`, with shared report/target types +in `src/runtime/model-check.ts` and the command in `scripts/check-models.ts`. +Preserve safe errors and cancellation. SIGINT/SIGTERM must abort model checking, +wait for its owned OpenCode process to exit, and clean its temporary directories. +Unsupported catalogs, absent upstream +names, authentication failures, and invalid inventories must fail validation. +Changes require an Agent restart; client mode also requires synchronized external +OpenCode configuration and plugins. + +## Runtime and Security + +Production chat requests require Bearer authorization and `X-Project-Id`, verified +by TJWater Server. Preserve actor/project/user ownership checks on session create, +read, actions, and MCP status. Development-only local context does not grant +business-data access. Never accept a tool-supplied identity, project scope, +authorization header, or arbitrary Server URL. + +Complete the OS sandbox probe, OpenCode health check, warmup session, business +tool discovery, enabled-model validation, workspace-root check, and MCP +configuration check before HTTP starts listening. A failure must fail startup. +Preserve `/health` fields `ready`, `warmed_up`, and `sandbox`. + +Use the V2 SDK and trusted plugin registration. A cold Location may need plugin +initialization before its tool/model catalog is usable. Verify actual tool IDs; +a version string or MCP connection status alone does not prove availability. +Keep native call IDs and the existing frontend SSE contracts. + +Client mode requires the same stable `AGENT_INTERNAL_TOKEN` in both processes, +the external OpenCode server password, and trusted assets loaded through +`serviceOpencodeConfig`. Its absolute `TJWATER_AGENT_WORKSPACE_ROOT` must match +the Agent workspace root. Prefer `bun run start:integration:isolated` when an +existing user service must remain available. Stop only processes started for the +current task; do not terminate unrelated services. + +Preserve static sensitive-path denials in `opencode.json`, explicit approval for +Shell/file edits, and disabled subagents. Do not replace enforcement with prompt +instructions. The Shell replacement must retain Landlock/seccomp, credential +isolation, cancellation, process-group cleanup, and denied alternate host Shell +entry points. Code Mode must not bypass these controls. + +## Workspaces and Persistence + +`CONVERSATION_WORKSPACE_ROOT` defaults to `.agent-workspaces/`, separate from +protected `data/` and `logs/`. `src/runtime/conversationWorkspace.ts` creates one +`conversation-` per native session, with writable `files/`, `tmp/`, and +`.cache/`; the location root is read-only and Shell starts in `files/`. + +Keep canonical-path and symlink checks, per-session access restrictions, native +session directory bindings, and empty independent workspaces for new branches. +Do not copy source code, credentials, or other sessions into a workspace. + +Workspaces contain persistent session files. They currently have no expiration +cleanup, and deleting chat metadata does not remove their directories. Do not +clean them as build artifacts or describe them as automatically disposable. +Startup probes, warmup, and failed creation clean their own temporary workspaces. +Changing the root requires preserving directory bindings and associated native +session data; do not silently move or delete existing user files. + +Deployment must preserve the workspace, Agent data, Skill files, and OpenCode +session database. Mounting only `/app/data` does not preserve session files. +Result-reference TTL cleanup is separate from workspace retention. + +## MCP and Business Boundaries + +Keep the `.opencode/agent-plugin.json` descriptor and package descriptions aligned +with actual IDs and paths when agent/plugin assets change. The descriptor is a +documentation index, not a runtime configuration file or native OpenCode plugin +manifest. Do not duplicate model definitions, Skill inventories, MCP connections, +or credentials in it. Use the established configuration builder and source files. +Skill storage is shared by the deployment instance; do not describe it as isolated +per user or project, and preserve the existing validated persistence workflow. + +Configure external MCP only through administrator-owned `mcp.servers` in +`opencode.json` or `OPENCODE_CONFIG_CONTENT`; server names use the `mcp_` prefix. +Preserve exact read-only tool allow rules, interactive approval outside that +allowlist, read-only background task access, and disabled MCP in learning review. +Broad wildcard allows and server `readOnlyHint` values are not read-only grants. +Apply the same policy to Code Mode and generic MCP resource access. + +Local stdio MCP is a trusted host process, not a sandbox Shell process. Preserve +its explicit environment filtering and never forward Agent/OpenCode credentials. +Return only sanitized server status and actual tool IDs to the browser. + +Keep business state authoritative in Server. Background execution uses persisted +`task_id`, `run_id`, and `execution_request_id`, with explicit claim, heartbeat, +and complete/fail transitions. Plans, reports, browser receipts, and final text +are distinct from execution completion. See [docs/execution-plan.md](docs/execution-plan.md) +and [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md). + +## Commands and Verification + +Run commands from this repository with Bun: ```bash bun install bun run dev +bun run start bun run check -bun test tests/**/*.test.ts +bun run check:models +bun run start:integration:isolated ``` -`bun run dev` starts `src/server.ts` in watch mode. `bun run check` runs -TypeScript checks for the main project and `.opencode`. +`bun run check` type-checks both the service and `.opencode`; this repository has +no separate lint command. Run focused tests for the changed behavior. For model +and readiness changes, the relevant suite is: -## Coding Notes +```bash +bun test tests/chat/models.test.ts tests/opencode/modelCheck.test.ts tests/runtime/opencode.test.ts tests/runtime/modelCheckCommand.test.ts tests/frontendAction/callIdPlugin.test.ts +``` -Use TypeScript ESM and keep types explicit at module boundaries. Preserve the -local service patterns before adding new abstractions. Do not reintroduce -free-form CLI command execution; future backend access should use typed tools or -adapters. +Do not over-test reversible low-impact edits. Documentation-only changes need +local link/path and command validation plus `git diff --check`, without starting +services or calling providers. Tests must not depend on mutable `data/` files. +Use opt-in native smoke scripts only when actual integration behavior needs +verification; configuration/catalog checks must not invoke model inference. -## Tests +## Repository Hygiene and Delivery -Add focused Bun tests for route behavior, runtime/session state, result refs, -and UIEnvelope mapping. Tests must not depend on mutable files under `data/`. +Never commit secrets, private environment files, generated runtime data, logs, +dependencies, `.agent-workspaces/`, session dumps, or result references. Avoid +printing private configuration contents while inspecting a workspace. -## Security - -This experimental service has no user authentication. Do not add secrets, -tokens, passwords, API keys, session dumps, or generated result references to -the repository. Keep `.env`, `data/`, and `logs/` statically denied in -`opencode.json`; do not replace those denials with prompt-only guidance. Keep -arbitrary Bash and edit operations approval-gated and subagents disabled. - -## Runtime Readiness - -Complete the OpenCode health check, warmup session, and model tool discovery -before the HTTP server starts listening. A warmup failure must fail startup. -Once listening, `/health` must expose the `ready` and `warmed_up` readiness -fields used by deployment checks. +Preserve unrelated dirty and untracked work. Keep `README.md` useful to people: +configuration locations, runnable setup steps, model checks, workspace retention, +and current capabilities. Update it when those operational behaviors change. +Use concise Conventional Commit subjects and scope commits to requested files. +Do not push or deploy without authorization for the current task. diff --git a/Dockerfile b/Dockerfile index b4a2822..b6fefe4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,7 +62,7 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules COPY package.json bun.lock ./ -COPY tsconfig.json opencode.json README.md .gitignore ./ +COPY tsconfig.json opencode.json agent-models.json README.md .gitignore ./ COPY scripts ./scripts COPY src ./src COPY .opencode ./.opencode diff --git a/README.md b/README.md index 2ab4429..5b13958 100644 --- a/README.md +++ b/README.md @@ -1,178 +1,243 @@ -# TJWater 供水 Agent 实验服务 +# TJWater 供水 Agent -基于 OpenCode 的实验性供水 Agent 服务,用于编排 TJWater 供水管网前端交互。排水数据、排水分析流程和排水前端交互不在本仓库范围内。 +这个服务把供水 WebGIS 中的对话、数据查询、地图操作和工况任务接到 OpenCode 2。浏览器负责展示和交互,Agent 编排工具,TJWater Server 校验身份、项目权限并保存业务状态。 -本仓库与生产环境中的 `TJWaterAgent` 历史代码相互独立。浏览器身份和项目范围由 TJWater Server 验证,业务访问使用类型化工具。 +本仓库仍是实验服务,与历史 `TJWaterAgent` 仓库独立。排水业务由排水 Agent 仓库维护。 -## 当前边界 +## 配置文件在哪里 -- 生产请求必须携带 `Authorization: Bearer ...` 和 `X-Project-Id`,Server 验证身份及项目权限。开发环境允许无凭据的本地对话,但不能访问 Server 业务工具。 -- 不包含 `cli/`、`tjwater_cli` OpenCode 工具或自由形式的 CLI 命令桥接。 -- Agent 的结构化界面输出使用 `agent-ui@1` UIEnvelope 事件;受控浏览器操作使用 `frontend-action@1` 前端动作。 -- Server 访问通过有限类型化工具;`web_search` 和 `geocode` 使用现有 Server 搜索、地理编码接口。Shell 仅在经过审批的会话 OS 沙箱中执行本地文件计算。 +| 要修改什么 | 文件或位置 | 说明 | +| --- | --- | --- | +| 模型、provider、API 地址、默认模型 | [agent-models.json](agent-models.json) | 模型配置的唯一入口 | +| API key、服务地址、端口等环境变量 | 仓库根目录 `.local.env` | 本地私有文件,已被 Git 忽略;字段参考 [.env.example](.env.example) | +| 工具权限、外部 MCP 连接 | [opencode.json](opencode.json) | 不再单独配置模型和 provider | +| Agent 的业务指令 | [.opencode/agents/instruction.md](.opencode/agents/instruction.md) | 指令文件不指定模型 | +| Agent、插件、Skill 和 MCP 的管理索引 | [.opencode/agent-plugin.json](.opencode/agent-plugin.json) | 描述用途和实际配置入口;管理方法见 [.opencode/README.md](.opencode/README.md) | +| 代码修改规则 | [AGENTS.md](AGENTS.md) | 面向维护代码的 Agent | -## 主要 API +服务自动读取根目录的 `.local.env`,已有系统环境变量优先。普通启动不会自动读取 `.env`;部署时应由容器或进程管理器注入环境变量。 -```text -GET /health -GET /api/v1/agent/chat/models -GET /api/v1/agent/chat/ui-registry -GET /api/v1/agent/chat/frontend-action-registry -POST /api/v1/agent/chat/frontend-actions/:action_id/result -POST /api/v1/agent/chat/session -POST /api/v1/agent/chat/task-executions -GET /api/v1/agent/chat/sessions -GET /api/v1/agent/chat/session/:session_id -GET /api/v1/agent/chat/session/:session_id/mcp -GET /api/v1/agent/chat/session/:session_id/stream -POST /api/v1/agent/chat/stream -POST /api/v1/agent/chat/abort +如果不想修改仓库自带的模型配置,可以复制为 `agent-models.local.json`,再在 `.local.env` 中设置: + +```dotenv +AGENT_MODEL_CONFIG_PATH=./agent-models.local.json ``` -工况 worker 通过 `POST /api/v1/agent/chat/task-executions` 提交 Server 已持久化的 `task_id`、`run_id` 和 `execution_request_id`。三者缺一即拒绝;Agent 不从进程内 Map 推断当前执行代次。Agent claim 成功后每 20 秒对该 run 心跳,正常流结束显式调用 `complete`,失败或中止显式调用 `fail`。计划、报告和完成命令均携带同一 run 身份,Server 是活动 run 与租约状态的唯一权威。 +该文件已被 Git 和镜像构建忽略。它会替换整份模型配置,不与 `agent-models.json` 合并;相对路径按启动目录解析。 -聊天 UIMessage 流与工作台业务事件流相互独立;业务状态由 Server 的 `/api/v1/workbench/events` 推送,不能混入 token SSE。 +## 本地启动 -`POST /api/v1/agent/chat/stream` 通过 SSE 返回事件流。主要事件如下: +在本仓库目录执行命令。运行环境需要 Bun、OpenCode 2、Python 3 和 `libseccomp2`,Linux 内核需支持 Landlock ABI 4 或更高版本。服务启动时会实际探测沙箱,失败会停止启动。 -| 事件 | 用途 | -| --- | --- | -| `progress` | Agent 规划和工具执行进度 | -| `token` | 助手回复的增量文本 | -| `ui_envelope` | 供可信前端渲染的结构化界面描述 | -| `frontend_action` | 请求可信前端执行受控动作 | -| `tool_call` | 用于兼容和调试的旧版工具调用事件 | -| `permission_request` | 运行时权限请求 | -| `question_request` | 运行时向用户发起的澄清请求 | -| `todo_update` | Agent 待办事项更新 | -| `session_title` | 会话标题更新 | -| `done` | 本次运行完成 | -| `error` | 本次运行失败或被中止 | - -## 本地上下文 - -服务通过 `src/context/localContext.ts` 提供本地上下文,默认值如下: - -```text -AGENT_LOCAL_USER_ID=local-user -AGENT_LOCAL_PROJECT_ID=local-project -AGENT_LOCAL_NETWORK=local-network -``` - -供水业务数据只来自用户输入、经过校验的事件上下文或已启用的受控工具。`web_search`、`geocode` 和 `query_project_data` 已开放,通过当前会话的可信凭据访问 Server;不接受模型指定的 URL、鉴权头或项目范围。`get_project_context`、`list_scada_assets` 和 `query_scada_readings` 仍未开放。 - -实验期间可以通过以下请求头覆盖本地上下文: - -```text -X-Agent-Local-User -X-Agent-Local-Project -X-Agent-Local-Network -X-Trace-Id -``` - -## UIEnvelope 与前端动作 - -UIEnvelope 注册表通过以下接口提供: - -```text -GET /api/v1/agent/chat/ui-registry -``` - -该接口用于兼容 `agent-ui@1` 协议。目前没有注册图表语法、界面组件或 UIEnvelope 动作。 - -前端动作使用独立的注册表: - -```text -GET /api/v1/agent/chat/frontend-action-registry -``` - -当前提供 `render_scada_analysis` 和 `clear_scada_analysis` 两个地图叠加层动作。浏览器需要在流式请求中声明 `frontend-action@1` 能力,执行动作后再通过以下接口回传结果: - -```text -POST /api/v1/agent/chat/frontend-actions/:action_id/result -``` - -协议详情参见 [Agent UI 协议](docs/agent-ui-protocol.md)。 - -## 本地开发 +仓库使用 OpenCode `2.0.24`,安装并检查 CLI: ```bash +bun add --global --trust --exact @opencode/cli@2.0.24 +opencode2 --version bun install -bun run dev -bun run check -bun test tests/**/*.test.ts ``` -`bun run check` 会检查服务代码和 `.opencode` 工具的类型。 +首次配置时创建本地环境文件,已有文件则直接编辑: -工况任务工具通过本机内部桥访问 Agent,再由 Agent 使用当前请求的短期产品凭据调用 TJWater Server。`embedded` 模式会把启动时生成的内部密钥传给子进程;`client` 模式必须在 Agent 与外部 OpenCode 服务中配置相同的 `AGENT_INTERNAL_TOKEN` 后分别重启。缺少共享密钥时 Agent 会拒绝启动,工具桥也不会退化为仅凭 session ID 访问。 +```bash +test -e .local.env || cp .env.example .local.env +chmod 600 .local.env +``` -独立管理 Agent 与 OpenCode 时必须显式配置稳定的 `AGENT_INTERNAL_TOKEN`;如果 `bun --watch` 使用自动生成的密钥重启 Agent,仍在运行的 OpenCode 子进程会保留旧密钥,内部工具桥将拒绝请求。需要保留现有开发服务做隔离联调时,可运行 `bun run start:integration:isolated`。它默认使用 Agent `18787`、OpenCode `14096`,从当前仓库目录加载 `opencode.json`、`.opencode/` 与现有 provider 环境,并把 Agent Session、memory、result ref、Skill 及 OpenCode XDG 运行数据写入 `data/integration-18787/`,不会复用默认运行实例的数据目录。可用 `INTEGRATION_AGENT_PORT`、`INTEGRATION_OPENCODE_PORT`、`INTEGRATION_DATA_DIR` 覆盖这些隔离值;启动器不会输出内部密钥。 +在 `.local.env` 中填写启用的 provider 所需密钥,以及 TJWater Server 的实际地址。下面只列字段,密钥占位值需要替换: -## CI/CD +```dotenv +HOST=127.0.0.1 +PORT=8787 +OPENCODE_MODE=embedded +TJWATER_SERVER_BASE_URL=http://127.0.0.1:8000 +WATERNETWORK_API_KEY=replace-with-your-key +``` -Gitea Actions 通过 `.gitea/workflows/package.yml` 复用 -`OrgTJWater/ci-templates` 的容器发布流程。手动触发只构建镜像并在 Dev -服务器运行候选验证;从 `main` 创建并推送 `v*` tag 后,候选验证通过才会 -提升为正式 `agent` 服务。 +默认仅启用 WaterNetwork,填写 `WATERNETWORK_API_KEY` 即可。增加其他 provider 时,还需提供对应凭据,并将默认模型引用指向有效的已配置模型。 -仓库需要配置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD` 和 -`DEV_DEPLOY_SSH_KEY` 三个 Gitea Secrets。`.env`、持久化数据和日志不会写入镜像;它们由 Dev 服务器通过环境文件和卷挂载。 +配置完成后检查模型,再启动服务: -## OpenCode 2 接入 +```bash +bun run check:models +bun run dev +``` -运行时使用 `@opencode/client@2.0.24`,私有进程默认启动 `opencode2 serve`。 -Ubuntu 安装:`bun add -g --trust @opencode/cli@2.0.24`,然后检查 `opencode2 --version`。 -`OPENCODE_COMMAND` 可指定绝对路径。Client 模式除共享 `AGENT_INTERNAL_TOKEN` 外,还需 -用 `OPENCODE_CLIENT_PASSWORD` 提供外部 V2 Server 的密码;嵌入模式自动捕获密码,不写日志。 +另开一个终端查看启动结果,地址和端口按实际配置替换: -业务工具由 `.opencode/plugins/tjwater-tools.ts` 的 V2 插件显式注册,直接提供给模型, -保留业务输入验证及前端动作的原生 call ID。启动前检查版本、模型、工具完整性;缺失即失败。 -`@opencode-ai/sdk` 仅为现有产品 SSE 契约提供开发期类型,不参与 V2 请求或进程启动。 +```bash +curl --fail http://127.0.0.1:8787/health +``` -工具变化:`bash` → `shell`,`task` → `subagent`,`apply_patch` → `patch`。 -V2 内置目录不含 `todo`、`todoread`、`todowrite`;本项目插件恢复会话内持久化的 -`todoread`、`todowrite`,并将更新继续转换为 `todo_update`。问答改用 Session Form API, -权限回复使用 session ID;文本、推理、工具与错误事件在运行时转换为现有前端 SSE。 -已移除 V2 忽略的 `server` 和 `experimental.continue_loop_on_deny` 配置,权限转换为 -有序 `permissions` 规则,保留敏感路径静态拒绝、shell/edit 审批和禁用子代理。 +正常响应中 `ok`、`ready`、`warmed_up` 均为 `true`,并包含 OpenCode 版本和沙箱探测结果。`bun run dev` 使用 watch 模式;无 watch 的启动命令是 `bun run start`。修改模型配置或密钥后需重启服务,不依赖 watch 自动加载配置文件。 -官方依据:[迁移指南](https://opencode.ai/v2/docs/migrate-v1/)、 -[插件](https://opencode.ai/v2/docs/build/plugins/)、[工具](https://opencode.ai/v2/docs/tools/)、 -[客户端](https://opencode.ai/v2/docs/build/client/)。旧 `dev.opencode.ai` beta 文档及 -`@opencode-ai/*@next` 标签不可作为当前稳定版安装依据。 +开发环境允许无凭据的本地对话,但访问 Server 业务数据仍需产品凭据。生产环境中的聊天接口要求 `Authorization: Bearer ...` 和 `X-Project-Id`,用户身份和项目权限由 Server 验证。项目 ID 应使用 Server 返回的项目 UUID;本地上下文头不能替代产品鉴权。 -## 旧 Agent 的 P0/P1 能力迁移 +## 配置模型和自定义 provider -- `memory_manager` 和 `skill_manager` 经内部令牌保护的 HTTP 桥接调用主服务,共用主服务的会话上下文和存储。身份与项目范围不由模型传入;新增记忆仍须先读取同 scope。 -- 交互会话收到 `credential_refresh_required` 后,前端续期并向 `POST /api/v1/agent/chat/session/:session_id/credentials` 提交 `request_id`;令牌只在认证 header 中传递,不写入会话文件。工具请求最多重试一次,403 不触发续期。续期超时或新凭据再次被拒绝时停止本次执行。 -- 后台 worker 使用已有 OAuth provider,依据 Server 的 claimed/running 运行快照向上述接口更新凭据,body 为 `{}`。后台运行不等待浏览器。若凭据已失效而无法写回失败,Server 的租约恢复仍负责终结失效运行。 -- 普通会话通过 `query_project_data` 读取有限数据源:供水元素属性、SCADA 设备与历史、元素历史、分析运行及分页结果。历史窗口最长24小时;历史查询接口按 scada.view 只读权限校验,数据清洗仍需 scada.clean;SCADA 字段只接受 `monitored_value/cleaned_value`,物理指标和单位以设备元数据为准。任务会话仍使用任务专属查询工具。 -- `locate_features`、`zoom_to_map`、`show_chart`、`view_history` 使用 `frontend-action@1`,等待实际浏览器回执。地图缺失资产返回 missing_ids;历史缺数不生成假曲线。图表仅接收有限、对齐的数值序列。历史展示当前最多100个非空样本,不代表完整高频历史。 -- 业务阶段以 progress 快照保存,保留并行操作和错误;任务中的分支、等待和证据仍由 execution-plan@2 表达。`final_answer` 用于最终答复呈现,在运行成功结束后发布;不强制结束模型循环,也不代表工单或业务任务完成。任务运行仍由 Server 的 complete/fail 接口确认。 +[agent-models.json](agent-models.json) 同时控制聊天菜单和 OpenCode 的模型定义。当前仅包含地址为 `https://llm.waternetwork.cn/v1` 的 `waternetwork` provider,保留两个模型:`gpt-6-luna` 显示为“快速”,使用闪电图标;`gpt-6.1-sol` 显示为“专家”,使用星光图标。两者的上下文窗口均配置为 272,000 tokens。 -Shell 已迁移为 Landlock + seccomp 内核沙箱,替换 OpenCode 2 自带的 `shell` 工具;其他宿主 Shell 创建入口关闭。Code Mode 嵌套调用也不能恢复原执行器。此边界覆盖 Shell 与子进程,OpenCode 服务和类型化工具仍在可信服务进程运行。参考 [OpenCode V2 插件接口](https://opencode.ai/v2/docs/build/plugins/) 和 [权限文档](https://opencode.ai/v2/docs/permissions/)。 +三个默认模型分别用于: -- Ubuntu/Linux 必须支持 Landlock ABI >= 4,并安装 Python 3、`libseccomp2`。当前 WSL Ubuntu 实测 ABI 7。启动时实际执行一次限制网络的探测;失败则不监听 HTTP,不回退到宿主 Shell。`/health` 的 `sandbox` 显示探测结果。Docker 已加入所需库和执行脚本,宿主内核也必须支持 Landlock。 -- `CONVERSATION_WORKSPACE_ROOT` 默认 `./.agent-workspaces`,与受保护的 `data/`、`logs/` 分开。每个会话使用唯一 `conversation-*` 目录,Native Session Location 保存绑定;恢复后继续使用原目录,旧的 checkout 会话在继续执行前迁入新目录。新分支使用独立空目录,不复制源码、凭据或其他会话文件。 -- 容器部署时须将 `/app/.agent-workspaces` 挂载到持久卷,并保留 OpenCode 的会话数据库卷。仅挂载 `/app/data` 不能保留新会话文件,例如 Compose 增加 `./agent-workspaces:/app/.agent-workspaces`。此次迁移未修改本地忽略的 Compose 配置或远端部署。 -- 根目录只读,可写文件放在 `files/`、`tmp/`、`.cache/`,避免模型生成的插件或配置被 OpenCode 自动加载。原生文件工具也检查规范路径、符号链接和会话范围,保留 `.env`、`data`、`logs` 静态拒绝与编辑审批。 -- root 容器中的 Shell 降为 UID/GID 10001;普通 Ubuntu 用户运行时保留本身 UID,由 Landlock/seccomp 提供限制。环境变量只保留明确的基础运行白名单,模型、用户和内部服务凭据不进入沙箱。系统程序、Python 环境和 Skill 目录只读,网络 socket、进程检查/发信号及脱离进程组被拒绝。 -- Shell 的 timeout 单位是秒,默认 120,最大 1800。stdout 默认限 1 MiB(`SANDBOX_MAX_OUTPUT_BYTES`),stderr 最多 256 KiB,单文件写入最多 128 MiB。较长 stdout/stderr 保存在本会话 files/,响应返回 3000 字节预览和 stdout_file/stderr_file 路径,避免落到无法读取的 OpenCode 全局截断文件。超时、取消、输出超限及命令结束均清理该进程组,不提供持久后台进程。会话文件保留在配置根目录,当前未增加自动文件保留期限或附件下载接口。 -- Shell 和文件修改即使在 `approval_mode=always` 也会请求用户确认;常规业务工具沿用自动批准。配置中的 deny 继续生效。 +| 字段 | 用途 | 当前配置 | +| --- | --- | --- | +| `default_model` | 普通聊天默认值 | `waternetwork/gpt-6-luna` | +| `task_model` | 后台工况任务首选模型 | `waternetwork/gpt-6-luna` | +| `learning_gate_model` | 判断是否需要学习复盘 | `waternetwork/gpt-6-luna` | -`web_search` 对接 `POST /api/v1/web-searches`(Bocha),支持 query、五种 freshness、summary、1–50 条结果及域名 include/exclude。`geocode` 对接 `POST /api/v1/geocoding-requests`(天地图),校验成功状态和坐标范围,并返回 `center=[lon,lat]`、`source_crs=EPSG:4326`。坐标可直接用于 `zoom_to_map`。两个工具使用现有凭据续期机制,403 不触发续期;服务密钥只配置在 Server 的 `BOCHA_API_KEY` 和 `TIANDITU_GEOCODER_TOKEN` 中。 +后两项省略时沿用 `default_model`。三个引用都必须指向已定义、启用且声明支持文本输出和工具调用的模型;聊天默认模型还必须在菜单中可选。 -client 模式的外部 OpenCode 需要使用 `serviceOpencodeConfig` 加载可信插件包 `.opencode/tjwater-plugin/` 和 Agent 指令,并设置与 Agent 相同的绝对 `TJWATER_AGENT_WORKSPACE_ROOT`(等于 `CONVERSATION_WORKSPACE_ROOT`)、内部桥地址和密钥。隔离联调启动器已完成这些设置。2.0.24 的显式插件配置要求目录包;冷 Location 的模型发现必须等插件初始化完成。 +### 使用 WaterNetwork 的最小配置 +下面是一份完整示例,三个用途都使用 `gpt-6.1-sol`。可以将它保存到自己的模型配置文件中: -P2 的大结果文件中转、供水瓶颈和服务区算法未迁入。 +```json +{ + "default_model": "waternetwork/gpt-6.1-sol", + "task_model": "waternetwork/gpt-6.1-sol", + "learning_gate_model": "waternetwork/gpt-6.1-sol", + "providers": { + "waternetwork": { + "name": "WaterNetwork", + "api_url": "https://llm.waternetwork.cn/v1", + "api_key": "{env:WATERNETWORK_API_KEY}", + "models": { + "gpt-6.1-sol": { + "name": "通用助手", + "description": "对话与任务处理", + "icon": "sparkle", + "limit": { "context": 272000 } + } + } + } + } +} +``` -## 外部 MCP 客户端 +新增其他 provider 时,在 `providers` 下增加同样结构的一项,并换成自己的地址、密钥变量名和模型名。provider 名只允许字母、数字、下划线和连字符。 -供水 Agent 复用 OpenCode 2.0.24 的 MCP 客户端,支持远程 Streamable HTTP、管理员配置的本地 stdio、原生 OAuth、工具目录更新和取消调用。当前 MCP 正式协议为 [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28),配置依据见 [OpenCode MCP 文档](https://opencode.ai/v2/docs/mcp-servers/)。本次接入不发布 TJWater MCP 服务,也不增加 MCP Apps 或 Tasks 的前端界面。 +`api_url` 填 API 基础地址,例如以 `/v1` 结尾的地址,不填 `/chat/completions`;不允许在地址中嵌入账号、密码、查询参数或片段。默认使用 OpenAI compatible 运行包,真实密钥始终放在环境变量中。 -仓库中的 `mcp.servers` 默认是 `{}`。管理员在服务的 `opencode.json` 或 `OPENCODE_CONFIG_CONTENT` 中配置连接;模型和浏览器不能提交 MCP 地址、进程命令或认证头。所有服务名必须使用 `mcp_` 前缀,其余字符限英文字母、数字、下划线和连字符。下面是配置片段,示例默认禁用;替换为实际地址/程序后再启用: +| 模型字段 | 怎么填写 | +| --- | --- | +| `models` 的键 | Agent 使用的模型名;完整引用为 `provider/模型名` | +| `model_id` | 上游实际接收的名称,省略时与键名相同;支持 `org/model` 这样的名称 | +| `name`、`description`、`icon` | 聊天菜单的名称、说明和图标;图标可用 `bolt` 或 `sparkle` | +| `disabled: true` | 禁用模型,不进入聊天菜单,也不参与运行时校验 | +| `selectable: false` | 从聊天菜单隐藏,仍可供后台或学习使用,仍需通过校验 | +| `capabilities` | 工具调用和输入、输出类型;默认声明支持工具调用和文本输入、输出 | +| `limit`、`compatibility` | provider 所需的上下文限制和兼容设置,按实际服务能力填写 | + +DeepSeek provider 已停用,默认角色不再引用它。重新启用 Flash 时,模型名使用 `deepseek-flash`;其他自定义别名可通过 `model_id` 映射到上游名称。配置文件使用标准 JSON,移除停用条目或给模型设置 `disabled: true`,不使用注释禁用配置。 + +旧的 `OPENCODE_MODEL`、`OPERATIONAL_TASK_MODEL`、`OPENCODE_MODEL_OPTIONS` 环境变量已不控制模型选择。在 `opencode.json` 或指令文件中另写模型定义也不会覆盖统一配置。 + +### 保存后检查模型 + +```bash +bun run check:models +``` + +该命令启动独立的 OpenCode 2,核对所有未禁用模型的注册结果、启用状态、工具能力声明和实际请求名称,再由插件使用 OpenCode 生效的 provider 配置读取上游 `/models` 目录。手工注册了一个名字,也必须在上游目录中找到对应名称才能通过。 + +命令不发送模型推理请求,结束后清理自己启动的进程和临时目录。检查通过表示模型名称和运行时配置一致;模型的推理效果、额度和实际工具调用表现需在使用时验证。 + +使用 Ctrl+C 或发送 SIGTERM 取消检查时,命令会等待自己的 OpenCode 子进程退出,再清理临时目录。 + +Agent 启动时也执行同一检查,失败则不监听 HTTP。常见结果如下: + +| 检查结果 | 排查方向 | +| --- | --- | +| `not_advertised` | 上游没有该名称,检查 `model_id` 和 provider 的模型目录 | +| `not_registered`、`not_enabled` | OpenCode 未加载或未启用该定义;client 模式需同步配置 | +| `wire_id_mismatch`、`no_tools` | 运行时的名称映射或工具能力声明与配置不一致 | +| `credentials_unavailable` | OpenCode 没有取得该 provider 的凭据 | +| `catalog_unavailable` | 检查网络、API 基础地址或鉴权;HTTP 401/403 应排查凭据和权限 | +| `catalog_invalid` | `/models` 返回的数据结构不符合预期 | +| `unsupported_catalog` | 当前检查器不支持该运行包的目录接口 | + +检查器支持 OpenAI compatible、OpenAI、OpenRouter 和 xAI 运行包的 `/models` 目录,其他运行包会拒绝启动。报告不输出密钥、连接地址或上游原始错误内容。 + +## Agent 与 OpenCode 的运行方式 + +默认 `OPENCODE_MODE=embedded`,Agent 启动和关闭自己的 OpenCode 子进程,并自动管理连接密码和内部工具桥密钥。`OPENCODE_COMMAND` 可指定 CLI 的绝对路径,`OPENCODE_PORT=0` 让系统分配空闲端口。 + +`OPENCODE_MODE=client` 用于连接单独管理的 OpenCode 服务,需要配置 `OPENCODE_CLIENT_BASE_URL`、`OPENCODE_CLIENT_PASSWORD`,并在两个进程中设置相同的稳定 `AGENT_INTERNAL_TOKEN`。 + +外部 OpenCode 还需通过 `serviceOpencodeConfig` 加载同一份模型配置、可信插件包 `.opencode/tjwater-plugin/` 和业务指令。其 `TJWATER_AGENT_WORKSPACE_ROOT` 必须与 Agent 的 `CONVERSATION_WORKSPACE_ROOT` 指向同一个绝对目录,同时设置 `TJWATER_AGENT_INTERNAL_BASE_URL` 指向 Agent 内部工具桥。只启动一个普通 OpenCode 服务不足以完成接入;缺少业务工具、模型检查 RPC 或目录绑定会导致预热失败。 + +要保留已有服务进行隔离联调,可以使用仓库启动器: + +```bash +bun run start:integration:isolated +``` + +默认端口为 Agent `18787`、OpenCode `14096`,数据保存在 `data/integration-18787/`,不复用默认会话数据。可通过 `INTEGRATION_AGENT_PORT`、`INTEGRATION_OPENCODE_PORT` 和 `INTEGRATION_DATA_DIR` 修改。启动器负责配对配置和停止自己创建的两个进程。 + +## `.agent-workspaces` 是什么 + +这是会话的文件工作区,默认位置由 `CONVERSATION_WORKSPACE_ROOT=./.agent-workspaces` 决定。每创建一个会话,就创建一个独立目录,即使该会话尚未调用 Shell,也可能已有空工作区: + +```text +.agent-workspaces/ +└── conversation-/ + ├── files/ 生成文件、计算脚本、结果和较长的 Shell 输出 + ├── tmp/ 临时文件 + └── .cache/ 运行缓存 +``` + +OpenCode 会话保存与该目录的绑定,恢复时继续使用原目录。会话分支使用独立的空工作区;历史上绑定到仓库根目录的旧会话会在继续执行前迁入新工作区,不复制源码、凭据或其他会话文件。 + +Shell 默认在 `files/` 中运行,只能写入上述三个子目录,工作区根目录只读。目录隔离配合 Landlock 和 seccomp 限制文件访问、网络与进程行为;目录本身不等于 OS 沙箱。限制覆盖 Shell 及其子进程,OpenCode 服务和类型化业务工具仍由可信宿主进程运行。本地 stdio MCP 也不在这个沙箱中。 + +Shell 和文件修改需要用户审批,即使聊天设置了 `approval_mode=always` 也会请求确认。Shell 不能联网或访问源码、密钥、其他会话;业务查询、网页搜索和地址解析使用类型化工具。超时单位为秒,默认 120 秒、最大 1800 秒;stdout 默认限制 1 MiB、stderr 最多 256 KiB、单文件最多 128 MiB。较长输出以文件保存在 `files/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。 + +工作区已被 Git 和镜像构建忽略,但其中的文件需要随会话保留。目前没有自动过期清理,删除聊天记录也不会同步删除工作区。启动探测、预热和创建失败产生的临时工作区会清理。 + +删除仍被会话引用的目录会丢失生成文件,并可能使会话后续文件操作失败。需要改变根目录时,应同步保留会话数据库及目录绑定,不能只改环境变量后丢弃原目录。服务目前没有工作区附件下载接口。 + +持久化部署需要分别保存: + +| 数据 | 默认位置 | 用途 | +| --- | --- | --- | +| 会话文件 | `.agent-workspaces/` | 文件计算和生成结果 | +| Agent 数据 | `data/` | 会话元数据、对话记录、记忆、学习状态和结果引用 | +| Skill 文件 | `.opencode/skills/` | 工具可读取的 Skill,目录可由 `OPENCODE_SKILLS_ROOT_DIR` 指定 | +| OpenCode 数据 | OpenCode 的 XDG 数据目录 | 原生会话数据库;部署时保留对应数据卷 | +| 日志 | `logs/` | 运行审计和学习日志 | + +仅持久化 `data/` 无法保留会话生成的文件。`result_ref` 有独立的过期清理,不代表工作区也会自动清理。 + +## 当前能做什么 + +| 场景 | 工具或协议 | 说明 | +| --- | --- | --- | +| 查询供水数据 | `query_project_data` | 有限数据源:元素属性、SCADA 设备与历史、元素历史、分析运行和分页结果 | +| 地图与图表交互 | `locate_features`、`zoom_to_map`、`show_chart`、`view_history` | 通过 `frontend-action@1` 等待浏览器实际回执 | +| SCADA 地图分析 | `render_scada_analysis`、`clear_scada_analysis` | 展示和清除受控叠加层 | +| 网页搜索与地址解析 | `web_search`、`geocode` | 调用 Server 的 Bocha、天地图接口,模型不能指定任意 URL 或鉴权头 | +| 工况任务 | 查询、计划、报告、行动建议和人工证据工具 | 通过 Server 的任务与运行身份访问业务 | +| 会话与学习 | `session_search`、`memory_manager`、`skill_manager` | 按会话、用户和项目范围管理记录、记忆和 Skill | +| 本地计算 | `shell` | 在会话 OS 沙箱中运行经过审批的命令 | +| 外部资料和工具 | MCP 客户端 | 由管理员配置,并按会话权限调用 | + +历史查询的时间窗口最长 24 小时,SCADA 字段限 `monitored_value` 和 `cleaned_value`;物理指标和单位以设备元数据为准。历史查询由 Server 校验只读权限,清洗操作仍需清洗权限。`view_history` 当前最多展示 100 个非空样本,不代表完整高频历史;缺失资产和历史缺数会返回实际状态。 + +`web_search` 支持 freshness、summary、1 至 50 条结果以及域名筛选;`geocode` 返回 `center=[lon,lat]`、`source_crs=EPSG:4326`,可用于地图定位。服务密钥配置在 Server 的 `BOCHA_API_KEY` 和 `TIANDITU_GEOCODER_TOKEN` 中。 + +后台执行由 worker 提交 Server 已保存的 `task_id`、`run_id`、`execution_request_id`。Agent claim 成功后每 20 秒发送心跳,运行正常结束显式调用 `complete`,失败或中止调用 `fail`。Server 决定活动运行和租约状态;计划、报告或 `final_answer` 的发布不代表业务任务完成。执行计划规则见 [工况执行计划](docs/execution-plan.md)。 + +交互会话遇到 `credential_refresh_required` 后,前端更新凭据;令牌只通过鉴权头传递,工具请求最多重试一次,403 不触发续期。后台 worker 使用已有 OAuth provider 更新凭据,不等待浏览器;无法写回失败时仍由 Server 的租约恢复处理。 + +服务保留 `agent-ui@1` UIEnvelope 兼容接口,目前该注册表为空。当前 6 个浏览器动作使用独立的 `frontend-action@1` 注册表;前端需声明能力并回传动作结果。待办使用 `todoread`、`todowrite`,通过 `todo_update` 更新前端。协议见 [Agent UI 协议](docs/agent-ui-protocol.md)。 + +大结果文件中转、供水瓶颈和服务区算法仍未从旧仓库迁入。 + +## 配置外部 MCP + +在 `opencode.json` 的 `mcp.servers` 中添加服务,或由部署环境提供 `OPENCODE_CONFIG_CONTENT`。默认未连接任何外部 MCP。连接地址、命令和凭据由管理员配置,不接受浏览器或模型临时提交。 + +下面给出远程 HTTP 和本地 stdio 两种配置片段,示例均禁用,填写真实连接信息后再启用: ```json { @@ -200,22 +265,79 @@ P2 的大结果文件中转、供水瓶颈和服务区算法未迁入。 } ``` -远程默认 `protocol: "auto"`,探测新协议并兼容旧协议;本地默认 `legacy`,避免对旧 stdio 程序额外启动探测进程。只服务新协议时可显式设为 `"2026-07-28"`。`codemode` 沿用原生默认值 `true`,设为 `false` 可直接暴露工具;两种入口执行相同的会话策略。超时单位为毫秒,全局默认启动/目录各 10 秒、执行 60 秒,已有显式全局值和每个服务的覆盖值优先。 +服务名必须以 `mcp_` 开头。远程默认使用 `auto`,本地默认使用 `legacy`;代码还支持显式协议值 `2026-07-28`。超时单位为毫秒,默认启动和目录查询各 10 秒、执行 60 秒,可按服务覆盖。`codemode` 默认开启,改为 `false` 时直接暴露工具,两种方式使用同一权限策略。 -只有管理员核实为只读的工具才自动放行:在已有 `permissions` 数组的 `mcp_*` 审批规则后追加精确工具名的 `allow`,保留原有敏感路径拒绝规则。例如: +管理员核实为只读的工具,可以在 `permissions` 中添加精确的自动放行规则;名称以会话 MCP 状态接口返回的工具 ID 为准,后续匹配的拒绝规则仍生效: ```json { "action": "mcp_docs_search", "resource": "*", "effect": "allow" } ``` -工具的有效名称为 `_`,不支持的字符由 OpenCode 规范为 `_`,以状态接口实际返回的名称为准。宽泛的 `mcp_* allow`、服务自报的 `readOnlyHint`、历史“始终允许”都不构成只读白名单。后续匹配的拒绝规则继续生效。普通聊天和任务协作中,白名单外调用进入现有 `permission_request` 审批,即使 `approval_mode=always` 也不会自动批准;后台 `task_execution` 只能调用白名单工具,学习复盘 `review` 禁止 MCP 调用,不等待浏览器审批。原生通用 MCP 资源列举/读取入口也受限制:交互会话审批,后台和复盘会话禁止,不能绕过逐个工具的白名单。外部结果是检索资料,不能直接替代 Server 已保存的业务证据或改变调用权限。 +宽泛的 `mcp_* allow`、服务自报的 `readOnlyHint` 和历史审批不构成只读白名单。普通聊天和任务协作中,白名单外调用需用户审批;后台任务只能调用白名单工具,学习复盘禁用 MCP。通用 MCP 资源读取也受会话策略约束,外部结果不能直接替代 Server 保存的业务证据。 -`GET /api/v1/agent/chat/session/:session_id/mcp` 沿用聊天接口的 Bearer、`X-Project-Id` 与会话归属校验。它返回 `servers`,每项仅含 `name`、`type`、`status`、`configured_protocol`、`tools`。状态为 `connected/pending/disabled/failed/needs_auth`;`configured_protocol` 是配置模式,不是协商后的版本。响应不包含连接地址、命令、认证头、OAuth 凭据或原始错误;查询不会创建或迁移会话目录。插件通过内部令牌保护的 `/internal/mcp/session-policy` 获取主服务已有的会话策略,不能凭请求 body 伪造用户或项目。 +需要 OAuth 的服务应省略 `oauth: false`,由管理员以同一服务账号和 XDG 数据目录执行 `opencode2 mcp auth mcp_docs`。TJWater 的 Bearer 和项目头不会自动转交外部 MCP。 -需要 OAuth 的服务应省略 `oauth: false`。管理员用同一服务账号、配置、环境和 XDG 数据目录执行 `opencode2 mcp auth mcp_docs`,认证流程、PKCE 与令牌续期由 OpenCode 管理;本项目不向前端暴露 OAuth 凭据,也不把 TJWater 的 Bearer 或项目头自动转交给外部 MCP。原生客户端发送的 OpenCode session ID 仅供调用关联,不是授权凭据。 +本地 stdio MCP 是管理员信任的宿主进程,不受会话 Shell 沙箱约束。插件收敛继承环境,只传入基础运行变量及明确配置的 MCP 环境;该进程仍有服务账号的宿主文件和网络权限。 -本地 stdio 是管理员信任的宿主进程,**不在会话 Shell 的 Landlock/seccomp 沙箱中**。插件将继承环境中除基础运行变量以外的值置空,只添加该服务明确配置的环境变量,内部 Agent/OpenCode 凭据变量始终置空;它仍具有服务账号的宿主文件和网络权限,因此环境收敛不等于 OS 沙箱。 +会话 MCP 状态接口返回服务名、类型、状态、配置协议和工具 ID,不返回连接地址、命令或凭据。状态可能为 `connected`、`pending`、`disabled`、`failed`、`needs_auth`;配置协议不代表实际协商版本,连接成功也不代表工具目录已加载。结构错误会阻止启动,可选服务连接失败或等待 OAuth 不影响核心服务就绪。 -配置结构错误会使启动失败;可选服务的连接失败或 `needs_auth` 不使核心 Agent 的 `/health` 变为未就绪。client 模式须同步更新可信插件和服务配置,缺少新版状态 RPC 将不能通过 warmup。禁用或移除服务并重启运行时即可回退,无需数据迁移。 +## 前端与任务接入 -本次定向验证:`bun run check`、`bun test tests/runtime/mcpConfig.test.ts tests/opencode/mcpPolicy.test.ts tests/routes/mcpRoutes.test.ts tests/runtime/opencode.test.ts tests/frontendAction/callIdPlugin.test.ts`。可选的本地原生联调为 `bun run tests/integration/mcp-smoke.ts`:使用临时 XDG 目录和样例 HTTP/stdio 服务,结束后清理自己的进程与数据,不请求模型。 +聊天 API 的前缀是 `/api/v1/agent/chat`。生产请求需携带产品 Bearer 和项目头;涉及会话的操作还校验会话归属。 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/health` | OpenCode 和沙箱就绪状态,无聊天前缀 | +| GET | `/models` | 聊天模型菜单与默认值,不含 provider 凭据 | +| POST | `/session` | 创建会话 | +| GET | `/sessions`、`/session/:session_id` | 列表和会话内容 | +| DELETE | `/session/:session_id` | 删除聊天记录,不同步清理工作区 | +| POST | `/stream` | 发起对话并接收 SSE | +| GET | `/session/:session_id/stream` | 订阅会话流 | +| POST | `/abort` | 中止运行 | +| POST | `/permission/:request_id/reply` | 回复工具审批 | +| POST | `/question/:request_id/reply`、`/question/:request_id/reject` | 回复或拒绝澄清问题 | +| POST | `/session/:session_id/credentials` | 刷新运行凭据 | +| GET | `/ui-registry`、`/frontend-action-registry` | 查询界面与浏览器动作协议 | +| POST | `/frontend-actions/:action_id/result` | 浏览器回传动作结果 | +| GET | `/session/:session_id/mcp` | 查询本会话 MCP 状态和实际工具 ID | +| POST | `/task-executions` | 提交后台任务执行 | +| POST | `/task/:task_id/collaboration/session` | 创建任务协作会话 | + +SSE 包含 `token`、`progress`、`ui_envelope`、`frontend_action`、`permission_request`、`question_request`、`todo_update`、`credential_refresh_required`、`session_title`、`done` 和 `error`,并保留兼容用途的 `tool_call` 事件。聊天 token 流与 Server 的 `/api/v1/workbench/events` 业务事件流相互独立。 + +开发环境的本地上下文可通过 `AGENT_LOCAL_USER_ID`、`AGENT_LOCAL_PROJECT_ID`、`AGENT_LOCAL_NETWORK` 设置,对应请求头为 `X-Agent-Local-User`、`X-Agent-Local-Project`、`X-Agent-Local-Network`。`X-Trace-Id` 用于请求追踪。 + +## 部署与维护 + +[Dockerfile](Dockerfile) 安装匹配的 OpenCode 2 和沙箱依赖,并把统一模型配置复制到镜像。环境文件、运行数据和日志不进入镜像。容器仍依赖宿主内核支持 Landlock。 + +部署时通过环境文件或密钥管理器注入凭据,并将模型文件及运行数据按需要挂载。比如 Compose 服务可包含以下片段,需合并到自己的完整部署配置中: + +```yaml +env_file: + - .local.env +volumes: + - ./agent-models.json:/app/agent-models.json:ro + - ./agent-workspaces:/app/.agent-workspaces + - ./data:/app/data + - ./logs:/app/logs + - ./skills:/app/.opencode/skills + - ./opencode-data:/root/.local/share/opencode +``` + +配置 `AGENT_MODEL_CONFIG_PATH` 时需把相应文件挂载到容器可读路径。`docker-compose.yml` 是被 Git 忽略的本地配置,使用前需核对当前模型入口、环境变量和数据卷;不要沿用旧模型环境变量。 + +常用维护命令: + +| 命令 | 用途 | +| --- | --- | +| `bun run check` | 检查服务和 OpenCode 插件的 TypeScript 类型 | +| `bun run check:models` | 通过实际 OpenCode 和上游目录检查模型,不请求推理 | +| `bun run start:prod` | 类型检查通过后启动服务 | +| `bun test tests/**/*.test.ts` | 全部单元测试,日常修改优先运行相关测试 | +| `bun run tests/integration/mcp-smoke.ts` | 可选的本地 MCP 联调,使用自己的样例服务和临时数据,不请求推理 | + +[CI 工作流](.gitea/workflows/package.yml) 在推送 `v*` 标签或手动触发时调用共享容器流水线。当前镜像名为 `gitea.waternetwork.cn/orgtjwater/next-tjwater-agent`,部署服务名为 `next-agent`;仓库需设置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD`、`DEV_DEPLOY_SSH_KEY`。具体候选验证和发布步骤由共享模板定义。 + +代码修改约束见 [AGENTS.md](AGENTS.md),前端协议见 [Agent UI 协议](docs/agent-ui-protocol.md),任务计划规则见 [工况执行计划](docs/execution-plan.md)。 diff --git a/agent-models.json b/agent-models.json new file mode 100644 index 0000000..4b11f6a --- /dev/null +++ b/agent-models.json @@ -0,0 +1,30 @@ +{ + "default_model": "waternetwork/gpt-6-luna", + "task_model": "waternetwork/gpt-6-luna", + "learning_gate_model": "waternetwork/gpt-6-luna", + "providers": { + "waternetwork": { + "name": "WaterNetwork", + "api_url": "https://llm.waternetwork.cn/v1", + "api_key": "{env:WATERNETWORK_API_KEY}", + "models": { + "gpt-6-luna": { + "name": "快速", + "description": "快速回答和日常任务处理", + "icon": "bolt", + "limit": { + "context": 272000 + } + }, + "gpt-6.1-sol": { + "name": "专家", + "description": "深入分析和复杂任务处理", + "icon": "sparkle", + "limit": { + "context": 272000 + } + } + } + } + } +} diff --git a/opencode.json b/opencode.json index 7d86869..fa2b7f5 100644 --- a/opencode.json +++ b/opencode.json @@ -1,6 +1,5 @@ { "$schema": "https://opencode.ai/config.json", - "model": "deepseek/deepseek-flash", "default_agent": "instruction", "permissions": [ { @@ -304,81 +303,6 @@ "effect": "ask" } ], - "providers": { - "deepseek": { - "name": "DeepSeek", - "env": [ - "DEEPSEEK_API_KEY" - ], - "package": "@opencode/ai/providers/openai-compatible", - "settings": { - "baseURL": "https://api.deepseek.com/v1", - "apiKey": "{env:DEEPSEEK_API_KEY}" - }, - "models": { - "deepseek-flash": { - "name": "deepseek-flash", - "capabilities": { - "tools": true, - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 393216 - }, - "compatibility": { - "reasoningField": "reasoning_content", - "requireReasoning": true - } - }, - "deepseek-v4-flash": { - "name": "deepseek-v4-flash", - "capabilities": { - "tools": true, - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 393216 - }, - "compatibility": { - "reasoningField": "reasoning_content", - "requireReasoning": true - } - }, - "deepseek-v4-pro": { - "name": "deepseek-v4-pro", - "capabilities": { - "tools": true, - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 393216 - }, - "compatibility": { - "reasoningField": "reasoning_content", - "requireReasoning": true - } - } - } - } - }, "mcp": { "timeout": { "startup": 10000, diff --git a/package.json b/package.json index afe1cbc..d8c0a2b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts", "check:openapi": "bun run generate:openapi && git diff --exit-code -- openapi/openapi.json src/generated/server-api.d.ts", "check": "bun run typecheck && bun run typecheck:opencode", + "check:models": "bun scripts/check-models.ts", "start": "bun src/server.ts", "start:integration:isolated": "bun scripts/start-isolated-integration.ts", "start:prod": "bun run check && bun src/server.ts" diff --git a/scripts/check-models.ts b/scripts/check-models.ts new file mode 100644 index 0000000..b337af8 --- /dev/null +++ b/scripts/check-models.ts @@ -0,0 +1,51 @@ +import { randomBytes } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { config } from "../src/config.js"; +import { assertModelCheckPassed, modelCheckReportSchema, modelCheckTargets } from "../src/runtime/model-check.js"; +import { serviceOpencodeConfig } from "../src/runtime/opencode2-config.js"; +import { startOpencode2 } from "../src/runtime/opencode2-server.js"; + +// Validate the saved configuration in a fresh OpenCode, without model inference. +const controller = new AbortController(); +let root: string | undefined; +let runtime: Awaited> | undefined; +const cancel = (exitCode: number) => { + if (controller.signal.aborted) return; + process.exitCode = exitCode; + controller.abort(); +}; +const onInterrupt = () => cancel(130); +const onTerminate = () => cancel(143); +process.on("SIGINT", onInterrupt); +process.on("SIGTERM", onTerminate); +try { + root = await mkdtemp(join(tmpdir(), "tjwater-model-check-")); + runtime = await startOpencode2({ + command: config.OPENCODE_COMMAND, hostname: "127.0.0.1", port: 0, timeout: 30_000, + signal: controller.signal, + config: serviceOpencodeConfig(JSON.parse(await readFile("opencode.json", "utf8")), config.MODEL_CONFIG), + env: { + AGENT_INTERNAL_TOKEN: randomBytes(32).toString("hex"), TJWATER_AGENT_WORKSPACE_ROOT: join(root, "workspaces"), + XDG_DATA_HOME: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state"), + }, + }); + const result = await runtime.client.rpc.call({ rpcID: "tjwater.tools", method: "check_models", input: modelCheckTargets(config.MODEL_CONFIG), location: { directory: process.cwd() } }, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) }); + const report = modelCheckReportSchema.parse(result.output); + console.log(JSON.stringify(report, null, 2)); + assertModelCheckPassed(report); + console.log(`OpenCode model check passed: ${report.models.length} models; no inference requests.`); +} catch (error) { + if (controller.signal.aborted) console.error("OpenCode model check cancelled."); + else { + console.error(error instanceof Error ? error.message : "OpenCode model check failed"); + process.exitCode = 1; + } +} finally { + runtime?.close(); + if (runtime) await runtime.exited; + if (root) await rm(root, { recursive: true, force: true }); + process.removeListener("SIGINT", onInterrupt); + process.removeListener("SIGTERM", onTerminate); +} diff --git a/src/chat/modelConfig.ts b/src/chat/modelConfig.ts index 74842f8..75aabf2 100644 --- a/src/chat/modelConfig.ts +++ b/src/chat/modelConfig.ts @@ -1,78 +1,105 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; + export type SupportedModel = string; export type AgentModelIcon = "bolt" | "sparkle"; +export type AgentModelOption = { id: SupportedModel; label: string; description: string; icon: AgentModelIcon }; +export const defaultModelConfigPath = fileURLToPath(new URL("../../agent-models.json", import.meta.url)); -export type AgentModelOption = { - id: SupportedModel; - label: string; - description: string; - icon: AgentModelIcon; -}; +const text = z.string().trim().min(1); +const modelSchema = z.object({ + name: text.optional(), description: text.optional(), icon: z.enum(["bolt", "sparkle"]).default("sparkle"), + model_id: text.optional(), disabled: z.boolean().default(false), selectable: z.boolean().default(true), + capabilities: z.object({ tools: z.boolean().default(true), input: z.array(text).default(["text"]), output: z.array(text).default(["text"]) }).strict().default({}), + limit: z.object({ context: z.number().int().positive().optional(), output: z.number().int().positive().optional() }).strict().optional(), + compatibility: z.record(z.unknown()).optional(), +}).strict(); +const modelConfigSchema = z.object({ + default_model: text, task_model: text.optional(), learning_gate_model: text.optional(), + providers: z.record(z.object({ + name: text, api_url: text, api_key: text.optional(), + package: text.default("@opencode/ai/providers/openai-compatible"), + models: z.record(modelSchema), + }).strict()), +}).strict(); +export type AgentModelConfig = z.infer; -export const defaultAgentModelOptions: AgentModelOption[] = [ - { - id: "deepseek/deepseek-flash", - label: "快速", - description: "快速回答和任务执行", - icon: "bolt", - }, - { - id: "deepseek/deepseek-v4-pro", - label: "专家", - description: "探索、解决复杂任务", - icon: "sparkle", - }, -]; +export function parseModelReference(reference: string) { + const separator = reference.indexOf("/"); + if (separator < 1 || separator === reference.length - 1 || /[\s#]/.test(reference)) throw new Error("model reference must contain provider/model without whitespace or variants"); + return { providerID: reference.slice(0, separator), modelID: reference.slice(separator + 1) }; +} -export const defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions); +export const toRuntimeModel = (model?: string) => model ? parseModelReference(model) : undefined; -const isObjectRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +function configuredToolModels(configuration: AgentModelConfig): AgentModelOption[] { + return Object.entries(configuration.providers).flatMap(([providerID, provider]) => + Object.entries(provider.models).filter(([, model]) => !model.disabled && model.capabilities.tools && model.capabilities.output.includes("text")).map(([modelID, model]) => ({ + id: `${providerID}/${modelID}`, label: model.name ?? modelID, + description: model.description ?? provider.name, icon: model.icon, + })), + ); +} -export const parseAgentModelOptions = (value: string): AgentModelOption[] => { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - throw new Error("OPENCODE_MODEL_OPTIONS must be valid JSON"); - } - if (!Array.isArray(parsed)) { - throw new Error("OPENCODE_MODEL_OPTIONS must be a JSON array"); - } - - const seen = new Set(); - return parsed.map((item, index) => { - if (!isObjectRecord(item)) { - throw new Error(`OPENCODE_MODEL_OPTIONS[${index}] must be an object`); - } - const id = typeof item.id === "string" ? item.id.trim() : ""; - if (!id) { - throw new Error(`OPENCODE_MODEL_OPTIONS[${index}].id is required`); - } - if (seen.has(id)) { - throw new Error(`duplicate OPENCODE_MODEL_OPTIONS id: ${id}`); - } - seen.add(id); - - const label = - typeof item.label === "string" && item.label.trim() - ? item.label.trim() - : id; - const description = - typeof item.description === "string" && item.description.trim() - ? item.description.trim() - : label; - const icon = item.icon === "bolt" || item.icon === "sparkle" - ? item.icon - : "sparkle"; - - return { - id, - label, - description, - icon, - }; +export function getAgentModelOptions(configuration: AgentModelConfig) { + return configuredToolModels(configuration).filter((option) => { + const { providerID, modelID } = parseModelReference(option.id); + return configuration.providers[providerID]!.models[modelID]!.selectable; }); -}; +} -export const getAgentModelIds = (options: AgentModelOption[]) => - options.map((option) => option.id); +export const getAgentModelIds = (options: AgentModelOption[]) => options.map((model) => model.id); + +export function parseAgentModelConfig(input: unknown): AgentModelConfig { + const parsed = modelConfigSchema.safeParse(input); + // Do not echo invalid values: provider configuration can contain credentials. + if (!parsed.success) throw new Error("invalid agent model configuration structure"); + const configuration = parsed.data; + for (const [id, provider] of Object.entries(configuration.providers)) { + if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error("provider ID must contain only letters, digits, _ or -"); + try { + const endpoint = provider.api_url.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name: string) => { + const value = process.env[name]?.trim(); + if (!value) throw new Error(); + return value; + }); + const url = new URL(endpoint); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error(); + } catch { throw new Error("provider api_url must be an HTTP(S) base URL without credentials, query or fragment"); } + if (!Object.keys(provider.models).length) throw new Error("provider models must include at least one model"); + for (const modelID of Object.keys(provider.models)) parseModelReference(`${id}/${modelID}`); + } + const allowed = getAgentModelIds(configuredToolModels(configuration)); + for (const role of ["default_model", "task_model", "learning_gate_model"] as const) { + if (configuration[role] && !allowed.includes(configuration[role]!)) throw new Error(`${role} must name an enabled configured text model with tool support`); + } + if (!getAgentModelIds(getAgentModelOptions(configuration)).includes(configuration.default_model)) throw new Error("default_model must be selectable in the chat catalog"); + return configuration; +} + +export function loadAgentModelConfig(path = process.env.AGENT_MODEL_CONFIG_PATH?.trim() || defaultModelConfigPath) { + let input: unknown; + try { input = JSON.parse(readFileSync(resolve(path), "utf8")); } + catch { throw new Error("agent model configuration file must be readable valid JSON"); } + return parseAgentModelConfig(input); +} + +export function compileOpencodeModels(configuration: AgentModelConfig) { + const providers = Object.fromEntries(Object.entries(configuration.providers).map(([id, provider]) => { + const keyVariable = provider.api_key?.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1]; + return [id, { + name: provider.name, package: provider.package, + ...(keyVariable ? { env: [keyVariable] } : {}), + settings: { baseURL: provider.api_url, ...(provider.api_key ? { apiKey: provider.api_key } : {}) }, + models: Object.fromEntries(Object.entries(provider.models).map(([modelID, model]) => [modelID, { + name: model.name ?? modelID, disabled: model.disabled, capabilities: model.capabilities, + ...(model.model_id ? { modelID: model.model_id } : {}), + ...(model.limit ? { limit: model.limit } : {}), + ...(model.compatibility ? { compatibility: model.compatibility } : {}), + }])), + }]; + })); + return { model: configuration.default_model, providers }; +} diff --git a/src/chat/models.ts b/src/chat/models.ts index 2cc66c2..8d77661 100644 --- a/src/chat/models.ts +++ b/src/chat/models.ts @@ -1,7 +1,7 @@ import { config } from "../config.js"; import { getAgentModelIds, - parseAgentModelOptions, + getAgentModelOptions, type SupportedModel, } from "./modelConfig.js"; @@ -11,9 +11,7 @@ export { type SupportedModel, } from "./modelConfig.js"; -export const agentModelOptions = parseAgentModelOptions( - config.OPENCODE_MODEL_OPTIONS, -); +export const agentModelOptions = getAgentModelOptions(config.MODEL_CONFIG); export const supportedModels = getAgentModelIds(agentModelOptions); diff --git a/src/config.ts b/src/config.ts index 036ca68..c80eaf7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,7 @@ import dotenv from "dotenv"; import { z } from "zod"; -import { - defaultAgentModelOptionsJson, - getAgentModelIds, - parseAgentModelOptions, -} from "./chat/modelConfig.js"; +import { loadAgentModelConfig, type AgentModelConfig } from "./chat/modelConfig.js"; // 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。 dotenv.config({ path: ".local.env", override: false }); @@ -73,12 +69,8 @@ const envSchema = z OPENCODE_PORT: z.coerce.number().int().nonnegative().default(0), // OpenCode 2 启动超时时间(毫秒)。 OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), - // 默认使用的 opencode 模型标识。 - OPENCODE_MODEL: z.string().default("deepseek/deepseek-flash"), - // 后台工况任务首选模型;缺省沿用 OPENCODE_MODEL,运行时会从实际可用模型中选择回退。 - OPERATIONAL_TASK_MODEL: optionalString(), - // 聊天 UI 和 /stream 允许选择的 opencode 模型完整配置,JSON 数组。 - OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson), + // 可选的统一模型配置文件;未设置时读取仓库中的 agent-models.json。 + AGENT_MODEL_CONFIG_PATH: optionalString(), // 平滑 token 输出,避免上游模型大块/抖动 token 直接传到前端。 AGENT_TOKEN_SMOOTHING_ENABLED: optionalBoolean(true), // 平滑输出每段之间的间隔(毫秒)。 @@ -161,42 +153,14 @@ const envSchema = z message: "AGENT_INTERNAL_TOKEN is required in client mode and must also be configured on the external OpenCode process", }); } - let modelOptions; - try { - modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS); - } catch (error) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["OPENCODE_MODEL_OPTIONS"], - message: error instanceof Error ? error.message : String(error), - }); - return; - } - const supportedModels = getAgentModelIds(modelOptions); - if (supportedModels.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["OPENCODE_MODEL_OPTIONS"], - message: "OPENCODE_MODEL_OPTIONS must include at least one model", - }); - } - if (!supportedModels.includes(env.OPENCODE_MODEL)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["OPENCODE_MODEL"], - message: "OPENCODE_MODEL must be included in OPENCODE_MODEL_OPTIONS", - }); - } - if (env.OPERATIONAL_TASK_MODEL && !supportedModels.includes(env.OPERATIONAL_TASK_MODEL)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["OPERATIONAL_TASK_MODEL"], - message: "OPERATIONAL_TASK_MODEL must be included in OPENCODE_MODEL_OPTIONS", - }); - } }); -export type AppConfig = z.infer; +export type AppConfig = z.infer & { + MODEL_CONFIG: AgentModelConfig; + OPENCODE_MODEL: string; + OPERATIONAL_TASK_MODEL: string; + LEARNING_GATE_MODEL: string; +}; const normalizedEnv = { ...process.env, @@ -209,4 +173,11 @@ const normalizedEnv = { process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL, }; -export const config: AppConfig = envSchema.parse(normalizedEnv); +const environment = envSchema.parse(normalizedEnv); +const modelConfiguration = loadAgentModelConfig(environment.AGENT_MODEL_CONFIG_PATH); +export const config: AppConfig = { + ...environment, MODEL_CONFIG: modelConfiguration, + OPENCODE_MODEL: modelConfiguration.default_model, + OPERATIONAL_TASK_MODEL: modelConfiguration.task_model ?? modelConfiguration.default_model, + LEARNING_GATE_MODEL: modelConfiguration.learning_gate_model ?? modelConfiguration.default_model, +}; diff --git a/src/learning/orchestrator.ts b/src/learning/orchestrator.ts index bf7fec9..a935f3d 100644 --- a/src/learning/orchestrator.ts +++ b/src/learning/orchestrator.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { writeLearningAuditLog } from "../audit/learningAudit.js"; import { type SupportedModel } from "../chat/models.js"; +import { toRuntimeModel } from "../chat/modelConfig.js"; import { type ChatRequestContext } from "../chat/sessionBridge.js"; import { config } from "../config.js"; import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js"; @@ -158,7 +159,7 @@ export class LearningOrchestrator { await this.runtime.prompt( gateSession.id, buildGatePrompt({ recentTurns }), - GATE_MODEL, + toRuntimeModel(config.LEARNING_GATE_MODEL), ); const messages = await this.runtime.messages(gateSession.id, 20); const assistantMessage = [...messages] @@ -558,25 +559,6 @@ const collectTextContent = ( .map((part) => part.text) .join(""); -const toRuntimeModel = (model?: SupportedModel) => { - if (!model) { - return undefined; - } - const [providerID, modelID] = model.split("/"); - if (!providerID || !modelID) { - return undefined; - } - return { - modelID, - providerID, - }; -}; - -const GATE_MODEL = { - modelID: "deepseek-flash", - providerID: "deepseek", -} as const; - const REDACTED_AUDIT_FIELD = "[redacted by persistence policy]"; const sanitizeAuditDetail = (detail?: string) => { diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index a960069..8e990b4 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -4,6 +4,7 @@ import { createBusinessStageTracker } from "./chatBusinessStages.js"; import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; import { type SupportedModel } from "../chat/models.js"; +import { toRuntimeModel } from "../chat/modelConfig.js"; import { config } from "../config.js"; import { executionPlanSchema } from "./executionPlan.js"; import { logger } from "../logger.js"; @@ -104,20 +105,6 @@ const getPermissionTarget = (metadata: unknown) => { return undefined; }; -const toRuntimeModel = (model?: SupportedModel) => { - if (!model) { - return undefined; - } - const [providerID, modelID] = model.split("/"); - if (!providerID || !modelID) { - return undefined; - } - return { - providerID, - modelID, - }; -}; - const parseToolOutput = (output: unknown): unknown => { if (typeof output !== "string") return output; try { diff --git a/src/runtime/model-check.ts b/src/runtime/model-check.ts new file mode 100644 index 0000000..e4b4d1a --- /dev/null +++ b/src/runtime/model-check.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { type AgentModelConfig } from "../chat/modelConfig.js"; + +export const modelCheckInputSchema = z.object({ models: z.array(z.object({ + id: z.string(), provider_id: z.string(), model_id: z.string(), wire_id: z.string(), requires_tools: z.boolean(), +}).strict()).min(1).max(1000) }).strict(); +export type ModelCheckInput = z.infer; +export const modelCheckReportSchema = z.object({ + models: z.array(z.object({ + id: z.string(), model_id: z.string(), + status: z.enum(["valid", "not_registered", "not_enabled", "no_tools", "wire_id_mismatch", "not_advertised", "unchecked"]), + }).strict()), + providers: z.array(z.object({ + id: z.string(), status: z.enum(["checked", "not_registered", "unsupported_catalog", "catalog_unavailable", "catalog_invalid", "credentials_unavailable"]), + http_status: z.number().int().optional(), + }).strict()), +}).strict(); +export type ModelCheckReport = z.infer; + +export function modelCheckTargets(configuration: AgentModelConfig): ModelCheckInput { + return { models: Object.entries(configuration.providers).flatMap(([providerID, provider]) => + Object.entries(provider.models).filter(([, model]) => !model.disabled).map(([modelID, model]) => ({ + id: `${providerID}/${modelID}`, provider_id: providerID, model_id: modelID, + wire_id: model.model_id ?? modelID, requires_tools: model.capabilities.tools, + })), + ) }; +} + +export function assertModelCheckPassed(report: ModelCheckReport) { + const failures = [ + ...report.providers.filter((provider) => provider.status !== "checked").map((provider) => `${provider.id}: ${provider.status}${provider.http_status ? ` (HTTP ${provider.http_status})` : ""}`), + ...report.models.filter((model) => model.status !== "valid").map((model) => `${model.id}: ${model.status}`), + ]; + if (!report.models.length || failures.length) throw new Error(`OpenCode model validation failed: ${failures.join("; ") || "empty model catalog"}`); +} diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index 4bde52e..816742f 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -8,6 +8,8 @@ import { realpath, rm } from "node:fs/promises"; import { createConversationWorkspace, resolveConversationWorkspace } from "./conversationWorkspace.js"; import { serviceOpencodeConfig } from "./opencode2-config.js"; import { mcpReadonlyTools, mcpRuntimeReportSchema } from "./mcp-config.js"; +import { parseModelReference } from "../chat/modelConfig.js"; +import { assertModelCheckPassed, modelCheckReportSchema, modelCheckTargets } from "./model-check.js"; import { config } from "../config.js"; import { logger } from "../logger.js"; @@ -127,6 +129,14 @@ export class OpencodeRuntimeAdapter { return selected; } + async checkModels(directory = process.cwd()) { + const client = await this.ensureClient(); + const response = await client.rpc.call({ + rpcID: "tjwater.tools", method: "check_models", input: modelCheckTargets(config.MODEL_CONFIG), location: { directory }, + }, { signal: AbortSignal.timeout(30_000) }); + return modelCheckReportSchema.parse(response.output); + } + async warmup(): Promise { const client = await this.ensureClient(); const health = await this.health(); @@ -148,6 +158,7 @@ export class OpencodeRuntimeAdapter { if (!actualRoot || await realpath(actualRoot) !== await realpath(config.CONVERSATION_WORKSPACE_ROOT)) throw new Error("OpenCode and Agent conversation workspace roots do not match"); const mcp = await this.readMcpReport(client, session.location.directory); if (mcp.configuration_errors.length) throw new Error("MCP configuration invalid"); + assertModelCheckPassed(await this.checkModels(session.location.directory)); const models = await client.model.list({ location: { directory: session.location.directory } }); const model = models.data.find((model) => `${model.providerID}/${model.id}` === config.OPENCODE_MODEL); if (!model?.enabled || !model.capabilities.tools) { @@ -162,7 +173,7 @@ export class OpencodeRuntimeAdapter { async createSession(title?: string) { const client = await this.ensureClient(); - const [providerID, id] = config.OPENCODE_MODEL.split("/"); + const { providerID, modelID: id } = parseModelReference(config.OPENCODE_MODEL); const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT); try { return await client.session.create({ title, agent: "instruction", model: { providerID, id }, location: { directory } }); @@ -468,12 +479,7 @@ export class OpencodeRuntimeAdapter { export const opencodeRuntime = new OpencodeRuntimeAdapter(); function buildOpencodeConfig(): Record { - return serviceOpencodeConfig(deepMerge( - deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), - { - model: config.OPENCODE_MODEL, - }, - )); + return serviceOpencodeConfig(deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), config.MODEL_CONFIG); } function readProjectOpencodeConfig(): Record { diff --git a/src/runtime/opencode2-config.ts b/src/runtime/opencode2-config.ts index ea4eb7e..5d581c9 100644 --- a/src/runtime/opencode2-config.ts +++ b/src/runtime/opencode2-config.ts @@ -1,22 +1,24 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { normalizeMcpConfig } from "./mcp-config.js"; +import { compileOpencodeModels, loadAgentModelConfig, type AgentModelConfig } from "../chat/modelConfig.js"; // A conversation Location can be outside the checkout. Load only the trusted // service assets explicitly, rather than relying on its current directory. -export function serviceOpencodeConfig(input: Record) { +export function serviceOpencodeConfig(input: Record, models: AgentModelConfig = loadAgentModelConfig()) { const agents = (input.agents ?? {}) as Record; const instruction = (agents.instruction ?? {}) as Record; const system = readFileSync(resolve(".opencode/agents/instruction.md"), "utf8") .replace(/^---\s*\n[\s\S]*?\n---\s*\n/, ""); return { ...input, + ...compileOpencodeModels(models), snapshots: false, mcp: normalizeMcpConfig(input.mcp), plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin")], skills: [resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills")], agents: { ...agents, instruction: { - description: "TJWater structured data and UI agent", mode: "primary", ...instruction, system, + description: "TJWater structured data and UI agent", mode: "primary", ...instruction, model: models.default_model, system, } }, }; } diff --git a/src/runtime/opencode2-server.ts b/src/runtime/opencode2-server.ts index d959716..44479e3 100644 --- a/src/runtime/opencode2-server.ts +++ b/src/runtime/opencode2-server.ts @@ -16,17 +16,23 @@ export async function startOpencode2(options: { timeout: number; config: Record; env?: NodeJS.ProcessEnv; + signal?: AbortSignal; }): Promise<{ client: OpenCodeClient; url: string; password: string; close: () => void; exited: Promise }> { + options.signal?.throwIfAborted(); const child = spawn(options.command, [ "serve", `--hostname=${options.hostname}`, `--port=${options.port}`, ], { cwd: process.cwd(), env: { ...process.env, ...options.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config) }, stdio: ["ignore", "pipe", "pipe"], + signal: options.signal, }); const stopOnExit = () => { child.kill("SIGTERM"); }; process.once("exit", stopOnExit); - child.once("exit", () => process.removeListener("exit", stopOnExit)); + const exited = new Promise((resolve) => child.once("close", (code) => { + process.removeListener("exit", stopOnExit); + resolve(code); + })); try { const endpoint = await new Promise<{ url: string; password: string }>((resolve, reject) => { let stdout = ""; @@ -44,10 +50,11 @@ export async function startOpencode2(options: { child.stderr.resume(); }); const client = createRuntimeClient(endpoint.url, endpoint.password); - await client.server.info({ signal: AbortSignal.timeout(options.timeout) }); - return { client, url: endpoint.url, password: endpoint.password, close: () => { child.kill("SIGTERM"); }, exited: new Promise((resolve) => child.once("exit", resolve)) }; + await client.server.info({ signal: AbortSignal.any([AbortSignal.timeout(options.timeout), ...(options.signal ? [options.signal] : [])]) }); + return { client, url: endpoint.url, password: endpoint.password, close: () => { child.kill("SIGTERM"); }, exited }; } catch (error) { child.kill("SIGTERM"); + await exited; throw error; } } diff --git a/tests/chat/models.test.ts b/tests/chat/models.test.ts index 78d8b58..daad59c 100644 --- a/tests/chat/models.test.ts +++ b/tests/chat/models.test.ts @@ -6,7 +6,9 @@ import { resolveDefaultModel, supportedModels, } from "../../src/chat/models.js"; -import { parseAgentModelOptions } from "../../src/chat/modelConfig.js"; +import { compileOpencodeModels, getAgentModelOptions, parseAgentModelConfig, parseModelReference } from "../../src/chat/modelConfig.js"; +import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js"; +import { modelCheckTargets } from "../../src/runtime/model-check.js"; describe("agent model config", () => { it("keeps every exposed option in the supported model list", () => { @@ -24,47 +26,69 @@ describe("agent model config", () => { }); it("rejects unsupported default models", () => { - expect(resolveDefaultModel("deepseek/deepseek-v4-pro")).toBe( - "deepseek/deepseek-v4-pro", - ); + expect(resolveDefaultModel(supportedModels[0]!)).toBe(supportedModels[0]!); expect(() => resolveDefaultModel("unknown/model")).toThrow( "unsupported default agent model", ); }); - it("parses full model option config from JSON", () => { - expect( - parseAgentModelOptions( - JSON.stringify([ - { - id: "provider/model", - label: "自定义", - description: "自定义模型", - icon: "bolt", - }, - ]), - ), - ).toEqual([ - { - id: "provider/model", - label: "自定义", - description: "自定义模型", - icon: "bolt", - }, - ]); + it("generates native providers and the public catalog from one custom provider definition", () => { + const configuration = parseAgentModelConfig({ + default_model: "custom/chat", task_model: "custom/org/model", learning_gate_model: "custom/chat", + providers: { custom: { name: "自定义服务", api_url: "https://example.com/v1", api_key: "{env:CUSTOM_API_KEY}", models: { + chat: { name: "自定义", description: "自定义模型", icon: "bolt", model_id: "wire-chat" }, + "org/model": {}, hidden: { selectable: false }, image: { disabled: true, capabilities: { tools: false, output: ["image"] } }, + } } }, + }); + const options = getAgentModelOptions(configuration); + expect(options.map((model) => model.id)).toEqual(["custom/chat", "custom/org/model"]); + expect(options[0]).toEqual({ id: "custom/chat", label: "自定义", description: "自定义模型", icon: "bolt" }); + const native = compileOpencodeModels(configuration); + expect(native.providers.custom).toMatchObject({ + env: ["CUSTOM_API_KEY"], package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: "https://example.com/v1", apiKey: "{env:CUSTOM_API_KEY}" }, + models: { chat: { modelID: "wire-chat", capabilities: { tools: true } }, image: { disabled: true } }, + }); + expect(JSON.stringify(options)).not.toContain("CUSTOM_API_KEY"); + expect(JSON.stringify(options)).not.toContain("example.com"); + expect(modelCheckTargets(configuration).models.map((model) => model.id)).toEqual(["custom/chat", "custom/org/model", "custom/hidden"]); + expect(modelCheckTargets(configuration).models[0]!.wire_id).toBe("wire-chat"); + const service = serviceOpencodeConfig({ model: "stale/model", providers: { stale: {} }, agents: { instruction: { model: "stale/model" } } }, configuration); + expect(service.model).toBe("custom/chat"); + expect(service.agents.instruction.model).toBe("custom/chat"); + expect(Object.keys(service.providers)).toEqual(["custom"]); }); - it("rejects invalid model option config", () => { - expect(() => parseAgentModelOptions("not-json")).toThrow( - "OPENCODE_MODEL_OPTIONS must be valid JSON", - ); - expect(() => - parseAgentModelOptions( - JSON.stringify([ - { id: "provider/model", label: "模型" }, - { id: "provider/model", label: "重复模型" }, - ]), - ), - ).toThrow("duplicate OPENCODE_MODEL_OPTIONS id"); + it("rejects unknown, disabled or non-tool role models and invalid endpoints without echoing keys", () => { + const base = { default_model: "custom/chat", providers: { custom: { name: "Custom", api_url: "https://example.com/v1", api_key: "TEST_ONLY_SECRET", models: { chat: {} } } } }; + for (const role of ["default_model", "task_model", "learning_gate_model"]) expect(() => parseAgentModelConfig({ ...base, [role]: "custom/missing" })).toThrow("enabled configured text model"); + for (const model of [{ disabled: true }, { capabilities: { tools: false } }, { capabilities: { output: ["image"] } }]) { + expect(() => parseAgentModelConfig({ ...base, providers: { custom: { ...base.providers.custom, models: { chat: model } } } })).toThrow(); + } + for (const url of ["file:///secret", "https://user:TEST_ONLY_SECRET@example.com/v1", "https://example.com/v1?key=TEST_ONLY_SECRET"]) { + try { parseAgentModelConfig({ ...base, providers: { custom: { ...base.providers.custom, api_url: url } } }); throw new Error("accepted invalid URL"); } + catch (error) { expect(String(error)).not.toContain("TEST_ONLY_SECRET"); expect(String(error)).toContain("api_url"); } + } + }); + + it("keeps namespaced model IDs intact when splitting runtime references", () => { + expect(parseModelReference("custom/org/model")).toEqual({ providerID: "custom", modelID: "org/model" }); + for (const invalid of ["model", "/model", "provider/", "provider/model name", "provider/model#variant"]) expect(() => parseModelReference(invalid)).toThrow(); + }); + + it("exposes only the two configured gateway models with their roles, icons and context limits", () => { + expect(supportedModels).toEqual(["waternetwork/gpt-6-luna", "waternetwork/gpt-6.1-sol"]); + expect(agentModelOptions.map(({ id, label, icon }) => ({ id, label, icon }))).toEqual([ + { id: "waternetwork/gpt-6-luna", label: "快速", icon: "bolt" }, + { id: "waternetwork/gpt-6.1-sol", label: "专家", icon: "sparkle" }, + ]); + expect(isSupportedModel("deepseek/deepseek-flash")).toBe(false); + expect(isSupportedModel("waternetwork/deepseek-flash")).toBe(false); + expect(isSupportedModel("waternetwork/gpt-image-2.5")).toBe(false); + expect(isSupportedModel("waternetwork/codex-auto-review")).toBe(false); + const native = serviceOpencodeConfig({}); + expect(Object.keys(native.providers)).toEqual(["waternetwork"]); + expect(native.model).toBe("waternetwork/gpt-6-luna"); + for (const model of Object.values(native.providers.waternetwork.models)) expect(model.limit?.context).toBe(272000); }); }); diff --git a/tests/integration/provider-catalog-smoke.ts b/tests/integration/provider-catalog-smoke.ts new file mode 100644 index 0000000..3c19c46 --- /dev/null +++ b/tests/integration/provider-catalog-smoke.ts @@ -0,0 +1,2 @@ +// Opt-in native catalog verification. Shares the startup check and never infers. +import "../../scripts/check-models.js"; diff --git a/tests/opencode/modelCheck.test.ts b/tests/opencode/modelCheck.test.ts new file mode 100644 index 0000000..513d9a9 --- /dev/null +++ b/tests/opencode/modelCheck.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import { checkOpencodeModels } from "../../.opencode/model-check.js"; +import { assertModelCheckPassed, type ModelCheckInput } from "../../src/runtime/model-check.js"; + +const targets: ModelCheckInput = { models: [ + { id: "custom/alias", provider_id: "custom", model_id: "alias", wire_id: "org/model", requires_tools: true }, + { id: "custom/typo", provider_id: "custom", model_id: "typo", wire_id: "does-not-exist", requires_tools: true }, +] }; + +describe("OpenCode model validation", () => { + it("rejects a manually registered invalid name and validates the actual upstream alias", async () => { + const originalFetch = globalThis.fetch; + let queries = 0; + globalThis.fetch = (async (input: unknown, init: RequestInit) => { + queries++; + expect(String(input)).toBe("https://example.com/v1/models"); + expect(new Headers(init.headers).get("Authorization")).toBe("Bearer TEST_ONLY_SECRET"); + expect(init.redirect).toBe("error"); + return Response.json({ data: [{ id: "org/model" }] }); + }) as typeof fetch; + try { + const report = await checkOpencodeModels({ + model: { list: async () => ({ data: targets.models.map((model) => ({ id: model.model_id, modelID: model.wire_id, providerID: "custom", enabled: true, capabilities: { tools: true } })) }) }, + provider: { get: async () => ({ data: { package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/v1", apiKey: "TEST_ONLY_SECRET" } } }) }, + } as never, targets, new AbortController().signal); + expect(report.models.map((model) => model.status)).toEqual(["valid", "not_advertised"]); + expect(queries).toBe(1); + expect(() => assertModelCheckPassed(report)).toThrow("custom/typo: not_advertised"); + expect(JSON.stringify(report)).not.toContain("TEST_ONLY_SECRET"); + expect(JSON.stringify(report)).not.toContain("example.com"); + } finally { globalThis.fetch = originalFetch; } + }); + + it("reports unavailable, non-tool and mismatched native models", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ data: [{ id: "org/model" }, { id: "does-not-exist" }] })) as unknown as typeof fetch; + try { + for (const [native, expected] of [ + [undefined, "not_registered"], + [{ enabled: false, modelID: "org/model", capabilities: { tools: true } }, "not_enabled"], + [{ enabled: true, modelID: "org/model", capabilities: { tools: false } }, "no_tools"], + [{ enabled: true, modelID: "different", capabilities: { tools: true } }, "wire_id_mismatch"], + ] as const) { + const report = await checkOpencodeModels({ + model: { list: async () => ({ data: native ? [{ id: "alias", providerID: "custom", ...native }] : [] }) }, + provider: { get: async () => ({ data: { package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/v1", apiKey: "TEST_ONLY_SECRET" } } }) }, + } as never, { models: [targets.models[0]!] }, new AbortController().signal); + expect(report.models[0]!.status).toBe(expected); + expect(() => assertModelCheckPassed(report)).toThrow(); + } + } finally { globalThis.fetch = originalFetch; } + }); + + it("fails closed on auth, empty/invalid inventory and cancellation without exposing upstream errors", async () => { + const originalFetch = globalThis.fetch; + const context = { + model: { list: async () => ({ data: [{ id: "alias", modelID: "org/model", providerID: "custom", enabled: true, capabilities: { tools: true } }] }) }, + provider: { get: async () => ({ data: { package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/v1", apiKey: "TEST_ONLY_SECRET" } } }) }, + } as never; + try { + for (const response of [Response.json({ secret: "TEST_ONLY_SECRET" }, { status: 401 }), Response.json({ data: [] }), Response.json({ data: [{ wrong: true }] })]) { + globalThis.fetch = (async () => response) as unknown as typeof fetch; + const report = await checkOpencodeModels(context, { models: [targets.models[0]!] }, new AbortController().signal); + expect(() => assertModelCheckPassed(report)).toThrow(); + expect(JSON.stringify(report)).not.toContain("TEST_ONLY_SECRET"); + } + globalThis.fetch = (async (_input: unknown, init: RequestInit) => { init.signal!.throwIfAborted(); throw new Error("should not query"); }) as unknown as typeof fetch; + const controller = new AbortController(); controller.abort(); + const report = await checkOpencodeModels(context, { models: [targets.models[0]!] }, controller.signal); + expect(report.providers[0]!.status).toBe("catalog_unavailable"); + } finally { globalThis.fetch = originalFetch; } + }); +}); diff --git a/tests/runtime/modelCheckCommand.test.ts b/tests/runtime/modelCheckCommand.test.ts new file mode 100644 index 0000000..6eb1b47 --- /dev/null +++ b/tests/runtime/modelCheckCommand.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const alive = (pid: number) => { + try { process.kill(pid, 0); return true; } catch { return false; } +}; + +describe("model checker cancellation", () => { + for (const signal of ["SIGTERM", "SIGINT"] as const) { + it(`cleans its own OpenCode process and workspace when receiving ${signal} during startup`, async () => { + const root = await mkdtemp(join(tmpdir(), "model-check-command-test-")); + const statePath = join(root, "state.json"); + const command = join(root, "opencode-test"); + let state: { pid: number; xdg: string } | undefined; + let checker: ReturnType | undefined; + try { + await writeFile(command, `#!${process.execPath}\nimport { writeFileSync } from "node:fs";\nwriteFileSync(process.env.MODEL_CHECK_TEST_STATE, JSON.stringify({ pid: process.pid, xdg: process.env.XDG_DATA_HOME }));\nsetInterval(() => {}, 1000);\n`, { mode: 0o700 }); + checker = spawn(process.execPath, ["scripts/check-models.ts"], { + cwd: resolve("."), env: { ...process.env, OPENCODE_COMMAND: command, MODEL_CHECK_TEST_STATE: statePath }, + stdio: ["ignore", "ignore", "ignore"], + }); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + checker!.once("error", reject); + checker!.once("close", (code, signal) => resolve({ code, signal })); + }); + const deadline = Date.now() + 5000; + while (!state && Date.now() < deadline) { + try { state = JSON.parse(await readFile(statePath, "utf8")); } catch { await Bun.sleep(10); } + } + expect(state).toBeDefined(); + checker.kill(signal); + const result = await Promise.race([exited, Bun.sleep(5000).then(() => { throw new Error("checker did not finish cancellation"); })]); + expect(alive(state!.pid)).toBe(false); + await expect(stat(dirname(state!.xdg))).rejects.toThrow(); + expect(result).toEqual({ code: signal === "SIGTERM" ? 143 : 130, signal: null }); + } finally { + checker?.kill("SIGKILL"); + if (state && alive(state.pid)) process.kill(state.pid, "SIGKILL"); + if (state) await rm(dirname(state.xdg), { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); + } + }, 10_000); + } +}); diff --git a/tests/runtime/opencode.test.ts b/tests/runtime/opencode.test.ts index 0f40a80..c3963de 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { config } from "../../src/config.js"; +import { modelCheckTargets } from "../../src/runtime/model-check.js"; import { OpencodeRuntimeAdapter, resolveEmbeddedOpencodePort, @@ -141,7 +142,7 @@ describe("resolveEmbeddedOpencodePort", () => { describe("OpencodeRuntimeAdapter.warmup", () => { const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"]; - async function createWarmupRuntime(missing: string[] = []) { + async function createWarmupRuntime(missing: string[] = [], invalidModel?: string) { const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-")); const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; config.CONVERSATION_WORKSPACE_ROOT = root; @@ -155,6 +156,14 @@ describe("OpencodeRuntimeAdapter.warmup", () => { }, model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } }, rpc: { call: async (input: { method: string }) => { + if (input.method === "check_models") { + calls.push("model.check"); + const targets = modelCheckTargets(config.MODEL_CONFIG).models; + return { output: { + models: targets.map((model) => ({ id: model.id, model_id: model.wire_id, status: model.id === invalidModel ? "not_advertised" : "valid" })), + providers: [...new Set(targets.map((model) => model.provider_id))].map((id) => ({ id, status: "checked" })), + } }; + } if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; } calls.push("tool.list"); return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) }; @@ -166,13 +175,21 @@ describe("OpencodeRuntimeAdapter.warmup", () => { it("verifies business tools and model before reporting ready and removes the warmup session", async () => { const { runtime, calls, cleanup } = await createWarmupRuntime(); try { await runtime.warmup(); - expect(calls).toEqual(["health", "session.create", "tool.list", "mcp.list", "model.list", "session.remove"]); } finally { await cleanup(); } + expect(calls).toEqual(["health", "session.create", "tool.list", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); } }); it("fails readiness if a business tool was lost and still cleans up", async () => { const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]); try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis"); expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); } }); + it("rejects an invalid non-default model before readiness and cleans up the session", async () => { + const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol"); + try { + await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-6.1-sol: not_advertised"); + expect(calls).not.toContain("model.list"); + expect(calls.at(-1)).toBe("session.remove"); + } finally { await cleanup(); } + }); }); describe("conversation workspace binding", () => {