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
+1
View File
@@ -5,6 +5,7 @@ node_modules
.env
.env.*
.local.env
agent-models.local.json
dist
.vscode
data
+7 -3
View File
@@ -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 模式可留空。
+1
View File
@@ -3,6 +3,7 @@ __pycache__/
.opencode/node_modules/
.opencode/skills/
.local.env
agent-models.local.json
.vscode
docker-compose.yml
data/
+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"
+165 -39
View File
@@ -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-<uuid>` 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.
+1 -1
View File
@@ -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
+268 -146
View File
@@ -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-<uuid>/
├── 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" }
```
工具的有效名称为 `<server>_<tool>`,不支持的字符由 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)。
+30
View File
@@ -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
}
}
}
}
}
}
-76
View File
@@ -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,
+1
View File
@@ -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"
+51
View File
@@ -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<ReturnType<typeof startOpencode2>> | 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);
}
+95 -68
View File
@@ -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<typeof modelConfigSchema>;
export const defaultAgentModelOptions: AgentModelOption[] = [
{
id: "deepseek/deepseek-flash",
label: "快速",
description: "快速回答和任务执行",
icon: "bolt",
},
{
id: "deepseek/deepseek-v4-pro",
label: "专家",
description: "探索、解决复杂任务",
icon: "sparkle",
},
];
export const defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions);
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
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");
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) };
}
const seen = new Set<string>();
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);
export const toRuntimeModel = (model?: string) => model ? parseModelReference(model) : undefined;
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";
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,
})),
);
}
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 };
}
+2 -4
View File
@@ -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);
+17 -46
View File
@@ -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<typeof envSchema>;
export type AppConfig = z.infer<typeof envSchema> & {
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,
};
+2 -20
View File
@@ -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) => {
+1 -14
View File
@@ -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 {
+35
View File
@@ -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<typeof modelCheckInputSchema>;
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<typeof modelCheckReportSchema>;
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"}`);
}
+13 -7
View File
@@ -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<void> {
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<string, unknown> {
return serviceOpencodeConfig(deepMerge(
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
{
model: config.OPENCODE_MODEL,
},
));
return serviceOpencodeConfig(deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), config.MODEL_CONFIG);
}
function readProjectOpencodeConfig(): Record<string, unknown> {
+4 -2
View File
@@ -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<string, unknown>) {
export function serviceOpencodeConfig(input: Record<string, unknown>, models: AgentModelConfig = loadAgentModelConfig()) {
const agents = (input.agents ?? {}) as Record<string, unknown>;
const instruction = (agents.instruction ?? {}) as Record<string, unknown>;
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,
} },
};
}
+10 -3
View File
@@ -16,17 +16,23 @@ export async function startOpencode2(options: {
timeout: number;
config: Record<string, unknown>;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
}): Promise<{ client: OpenCodeClient; url: string; password: string; close: () => void; exited: Promise<number | null> }> {
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<number | null>((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;
}
}
+60 -36
View File
@@ -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);
});
});
@@ -0,0 +1,2 @@
// Opt-in native catalog verification. Shares the startup check and never infers.
import "../../scripts/check-models.js";
+73
View File
@@ -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; }
});
});
+47
View File
@@ -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<typeof spawn> | 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);
}
});
+19 -2
View File
@@ -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", () => {