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.
This commit is contained in:
2026-10-09 11:28:37 +08:00
parent 75d8d526f9
commit d58bd19039
30 changed files with 1125 additions and 469 deletions
+101
View File
@@ -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)。
+49
View File
@@ -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"
}
}
-1
View File
@@ -1,7 +1,6 @@
---
description: TJWater experimental Agent for structured UI orchestration
mode: primary
model: deepseek/deepseek-flash
temperature: 0.2
---
你是 TJWater 实验性供水数据 Agent。回复用户时使用简体中文,内容简洁准确。
+59
View File
@@ -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<Plugin.Plugin["setup"]>[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<Context, "model" | "provider">, input: ModelCheckInput, signal: AbortSignal): Promise<ModelCheckReport> {
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;
}
+9
View File
@@ -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,
+1
View File
@@ -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"