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:
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
} },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user