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:
+96
-69
@@ -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 function parseModelReference(reference: string) {
|
||||
const separator = reference.indexOf("/");
|
||||
if (separator < 1 || separator === reference.length - 1 || /[\s#]/.test(reference)) throw new Error("model reference must contain provider/model without whitespace or variants");
|
||||
return { providerID: reference.slice(0, separator), modelID: reference.slice(separator + 1) };
|
||||
}
|
||||
|
||||
export const defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions);
|
||||
export const toRuntimeModel = (model?: string) => model ? parseModelReference(model) : undefined;
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
function configuredToolModels(configuration: AgentModelConfig): AgentModelOption[] {
|
||||
return Object.entries(configuration.providers).flatMap(([providerID, provider]) =>
|
||||
Object.entries(provider.models).filter(([, model]) => !model.disabled && model.capabilities.tools && model.capabilities.output.includes("text")).map(([modelID, model]) => ({
|
||||
id: `${providerID}/${modelID}`, label: model.name ?? modelID,
|
||||
description: model.description ?? provider.name, icon: model.icon,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export const parseAgentModelOptions = (value: string): AgentModelOption[] => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error("OPENCODE_MODEL_OPTIONS must be valid JSON");
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("OPENCODE_MODEL_OPTIONS must be a JSON array");
|
||||
}
|
||||
|
||||
const seen = new Set<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);
|
||||
|
||||
const label =
|
||||
typeof item.label === "string" && item.label.trim()
|
||||
? item.label.trim()
|
||||
: id;
|
||||
const description =
|
||||
typeof item.description === "string" && item.description.trim()
|
||||
? item.description.trim()
|
||||
: label;
|
||||
const icon = item.icon === "bolt" || item.icon === "sparkle"
|
||||
? item.icon
|
||||
: "sparkle";
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
};
|
||||
export function getAgentModelOptions(configuration: AgentModelConfig) {
|
||||
return configuredToolModels(configuration).filter((option) => {
|
||||
const { providerID, modelID } = parseModelReference(option.id);
|
||||
return configuration.providers[providerID]!.models[modelID]!.selectable;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const getAgentModelIds = (options: AgentModelOption[]) =>
|
||||
options.map((option) => option.id);
|
||||
export const getAgentModelIds = (options: AgentModelOption[]) => options.map((model) => model.id);
|
||||
|
||||
export function parseAgentModelConfig(input: unknown): AgentModelConfig {
|
||||
const parsed = modelConfigSchema.safeParse(input);
|
||||
// Do not echo invalid values: provider configuration can contain credentials.
|
||||
if (!parsed.success) throw new Error("invalid agent model configuration structure");
|
||||
const configuration = parsed.data;
|
||||
for (const [id, provider] of Object.entries(configuration.providers)) {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error("provider ID must contain only letters, digits, _ or -");
|
||||
try {
|
||||
const endpoint = provider.api_url.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name: string) => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error();
|
||||
return value;
|
||||
});
|
||||
const url = new URL(endpoint);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error();
|
||||
} catch { throw new Error("provider api_url must be an HTTP(S) base URL without credentials, query or fragment"); }
|
||||
if (!Object.keys(provider.models).length) throw new Error("provider models must include at least one model");
|
||||
for (const modelID of Object.keys(provider.models)) parseModelReference(`${id}/${modelID}`);
|
||||
}
|
||||
const allowed = getAgentModelIds(configuredToolModels(configuration));
|
||||
for (const role of ["default_model", "task_model", "learning_gate_model"] as const) {
|
||||
if (configuration[role] && !allowed.includes(configuration[role]!)) throw new Error(`${role} must name an enabled configured text model with tool support`);
|
||||
}
|
||||
if (!getAgentModelIds(getAgentModelOptions(configuration)).includes(configuration.default_model)) throw new Error("default_model must be selectable in the chat catalog");
|
||||
return configuration;
|
||||
}
|
||||
|
||||
export function loadAgentModelConfig(path = process.env.AGENT_MODEL_CONFIG_PATH?.trim() || defaultModelConfigPath) {
|
||||
let input: unknown;
|
||||
try { input = JSON.parse(readFileSync(resolve(path), "utf8")); }
|
||||
catch { throw new Error("agent model configuration file must be readable valid JSON"); }
|
||||
return parseAgentModelConfig(input);
|
||||
}
|
||||
|
||||
export function compileOpencodeModels(configuration: AgentModelConfig) {
|
||||
const providers = Object.fromEntries(Object.entries(configuration.providers).map(([id, provider]) => {
|
||||
const keyVariable = provider.api_key?.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1];
|
||||
return [id, {
|
||||
name: provider.name, package: provider.package,
|
||||
...(keyVariable ? { env: [keyVariable] } : {}),
|
||||
settings: { baseURL: provider.api_url, ...(provider.api_key ? { apiKey: provider.api_key } : {}) },
|
||||
models: Object.fromEntries(Object.entries(provider.models).map(([modelID, model]) => [modelID, {
|
||||
name: model.name ?? modelID, disabled: model.disabled, capabilities: model.capabilities,
|
||||
...(model.model_id ? { modelID: model.model_id } : {}),
|
||||
...(model.limit ? { limit: model.limit } : {}),
|
||||
...(model.compatibility ? { compatibility: model.compatibility } : {}),
|
||||
}])),
|
||||
}];
|
||||
}));
|
||||
return { model: configuration.default_model, providers };
|
||||
}
|
||||
|
||||
+2
-4
@@ -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
@@ -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,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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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