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
+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;
}
}