639 lines
19 KiB
TypeScript
639 lines
19 KiB
TypeScript
import {
|
||
createOpencode,
|
||
type OpencodeClient,
|
||
} from "@opencode-ai/sdk/v2";
|
||
import { randomUUID } from "node:crypto";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { chmod, mkdir, rmdir } from "node:fs/promises";
|
||
import { resolve } from "node:path";
|
||
|
||
import { config } from "../config.js";
|
||
import { logger } from "../logger.js";
|
||
import { ensureDirectory } from "../utils/fileStore.js";
|
||
import { setSandboxOwnership } from "./conversationWorkspace.js";
|
||
import {
|
||
cleanupExpiredToolOutputs,
|
||
resolveOpencodeToolOutputDirectory,
|
||
} from "./opencodeToolOutputCleanup.js";
|
||
|
||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||
|
||
const logDevelopmentDebug = (
|
||
message: string,
|
||
metadata: Record<string, unknown>,
|
||
) => {
|
||
if (!isDevelopmentDebugLoggingEnabled) {
|
||
return;
|
||
}
|
||
logger.info(metadata, message);
|
||
};
|
||
|
||
export type RuntimeHealth = {
|
||
healthy: boolean;
|
||
version: string;
|
||
};
|
||
|
||
type RuntimeModelOverride = {
|
||
providerID: string;
|
||
modelID: string;
|
||
};
|
||
|
||
export type PermissionReply = "once" | "always" | "reject";
|
||
export type QuestionAnswers = string[][];
|
||
|
||
type RuntimeMessage = {
|
||
info: {
|
||
id: string;
|
||
role: string;
|
||
};
|
||
};
|
||
|
||
const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
|
||
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
|
||
|
||
export class OpencodeRuntimeAdapter {
|
||
private clientPromise: Promise<OpencodeClient> | null = null;
|
||
private closeServer: (() => void) | null = null;
|
||
private toolOutputCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
async ensureClient(): Promise<OpencodeClient> {
|
||
if (!this.clientPromise) {
|
||
this.clientPromise = this.bootstrapClient().catch((error) => {
|
||
this.clientPromise = null;
|
||
throw error;
|
||
});
|
||
}
|
||
return this.clientPromise;
|
||
}
|
||
|
||
async health(): Promise<RuntimeHealth> {
|
||
const client = await this.ensureClient();
|
||
const response = await client.global.health();
|
||
return requireData(response.data, "global.health");
|
||
}
|
||
|
||
async warmup(): Promise<void> {
|
||
const client = await this.ensureClient();
|
||
const healthStartedAt = Date.now();
|
||
const healthResponse = await client.global.health();
|
||
const health = requireData(healthResponse.data, "global.health");
|
||
logDevelopmentDebug("opencode warmup health check completed", {
|
||
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
|
||
healthy: health.healthy,
|
||
version: health.version,
|
||
});
|
||
|
||
const sessionStartedAt = Date.now();
|
||
const sessionResponse = await client.session.create({
|
||
title: "tjwater-agent-warmup",
|
||
});
|
||
const session = requireData(sessionResponse.data, "session.create");
|
||
logDevelopmentDebug("opencode warmup session created", {
|
||
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
|
||
sessionId: session.id,
|
||
});
|
||
|
||
try {
|
||
const [provider, model] = config.OPENCODE_MODEL.split("/");
|
||
if (!provider || !model) {
|
||
throw new Error(
|
||
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
|
||
);
|
||
}
|
||
const toolsStartedAt = Date.now();
|
||
const toolsResponse = await client.tool.list({ provider, model });
|
||
const tools = requireData(toolsResponse.data, "tool.list");
|
||
logDevelopmentDebug("opencode warmup tools loaded", {
|
||
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
|
||
model: config.OPENCODE_MODEL,
|
||
sessionId: session.id,
|
||
toolCount: tools.length,
|
||
});
|
||
} finally {
|
||
const cleanupStartedAt = Date.now();
|
||
let cleanupSucceeded = true;
|
||
await client.session.delete(
|
||
{ sessionID: session.id },
|
||
{ throwOnError: true },
|
||
).catch((error) => {
|
||
cleanupSucceeded = false;
|
||
logger.warn(
|
||
{ err: error, sessionId: session.id },
|
||
"failed to remove opencode warmup session",
|
||
);
|
||
});
|
||
logDevelopmentDebug("opencode warmup session cleanup completed", {
|
||
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
|
||
sessionId: session.id,
|
||
succeeded: cleanupSucceeded,
|
||
});
|
||
}
|
||
}
|
||
|
||
async createSession(
|
||
title?: string,
|
||
options: {
|
||
conversationWorkspace?: boolean;
|
||
workspaceRoot?: string;
|
||
} = {},
|
||
) {
|
||
const client = await this.ensureClient();
|
||
if (!options.conversationWorkspace) {
|
||
const response = await client.session.create({ title });
|
||
return requireData(response.data, "session.create");
|
||
}
|
||
|
||
const workspaceRoot = resolve(
|
||
options.workspaceRoot ?? config.RESULT_REF_IMPORT_DIR,
|
||
);
|
||
const directory = resolve(workspaceRoot, `conversation-${randomUUID()}`);
|
||
await ensureDirectory(workspaceRoot);
|
||
await chmod(workspaceRoot, 0o700);
|
||
await mkdir(directory, { mode: 0o700 });
|
||
await setSandboxOwnership(directory);
|
||
try {
|
||
const response = await client.session.create({
|
||
directory,
|
||
title,
|
||
permission: [
|
||
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
|
||
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
|
||
],
|
||
});
|
||
return requireData(response.data, "session.create");
|
||
} catch (error) {
|
||
await rmdir(directory).catch(() => undefined);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getSession(sessionId: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.get({ sessionID: sessionId });
|
||
return requireData(response.data, "session.get");
|
||
}
|
||
|
||
async sendPrompt(sessionId: string, text: string) {
|
||
await this.prompt(sessionId, text);
|
||
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
||
// 所以这里紧跟一次 messages() 回读,给上层路由统一消费。
|
||
return this.messages(sessionId);
|
||
}
|
||
|
||
async prompt(
|
||
sessionId: string,
|
||
text: string,
|
||
model?: RuntimeModelOverride,
|
||
) {
|
||
const client = await this.ensureClient();
|
||
const startedAt = Date.now();
|
||
logDevelopmentDebug(
|
||
"dispatching opencode session.prompt",
|
||
{
|
||
sessionId,
|
||
model: model ?? null,
|
||
textChars: text.length,
|
||
},
|
||
);
|
||
await client.session.prompt({
|
||
sessionID: sessionId,
|
||
model,
|
||
parts: [{ type: "text", text }],
|
||
});
|
||
logDevelopmentDebug(
|
||
"opencode session.prompt returned",
|
||
{
|
||
sessionId,
|
||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||
},
|
||
);
|
||
}
|
||
|
||
async messages(sessionId: string, limit = 20) {
|
||
const client = await this.ensureClient();
|
||
const messages = await client.session.messages({
|
||
sessionID: sessionId,
|
||
limit,
|
||
});
|
||
return requireData(messages.data, "session.messages");
|
||
}
|
||
|
||
async revertMessage(sessionId: string, messageId: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.revert({
|
||
sessionID: sessionId,
|
||
messageID: messageId,
|
||
});
|
||
return response.data;
|
||
}
|
||
|
||
async removeMessage(sessionId: string, messageId: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.deleteMessage({
|
||
sessionID: sessionId,
|
||
messageID: messageId,
|
||
});
|
||
return response.data;
|
||
}
|
||
|
||
async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) {
|
||
const messages = await this.messages(sessionId, 80);
|
||
const userMessages = messages.filter(
|
||
(message) => getRuntimeMessageRole(message) === "user",
|
||
);
|
||
const targetUserMessage = userMessages[options.userOrdinal - 1];
|
||
|
||
if (!targetUserMessage) {
|
||
if (messages.length === 0 && options.userOrdinal === 1) {
|
||
logger.warn(
|
||
{ sessionId, userOrdinal: options.userOrdinal },
|
||
"skipping opencode revert because runtime session has no messages",
|
||
);
|
||
return;
|
||
}
|
||
throw new Error("target user message not found to revert");
|
||
}
|
||
|
||
const targetMessageId = getRuntimeMessageId(targetUserMessage);
|
||
const targetIndex = messages.findIndex(
|
||
(message) => getRuntimeMessageId(message) === targetMessageId,
|
||
);
|
||
const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage];
|
||
|
||
await this.revertMessage(sessionId, targetMessageId);
|
||
|
||
for (const message of messagesToRemove.reverse()) {
|
||
const messageId = getRuntimeMessageId(message);
|
||
try {
|
||
await this.removeMessage(sessionId, messageId);
|
||
} catch (error) {
|
||
logger.warn(
|
||
{ err: error, sessionId, messageId },
|
||
"failed to remove reverted opencode message",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async abortSession(sessionId: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.abort({
|
||
sessionID: sessionId,
|
||
});
|
||
return requireData(response.data, "session.abort");
|
||
}
|
||
|
||
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
|
||
const client = await this.ensureClient();
|
||
const startedAt = Date.now();
|
||
|
||
while (Date.now() - startedAt < timeoutMs) {
|
||
const response = await client.session.status({});
|
||
const statuses = requireData(response.data, "session.status");
|
||
const status = statuses[sessionId];
|
||
if (!status || status.type === "idle") {
|
||
return;
|
||
}
|
||
await delay(100);
|
||
}
|
||
|
||
logger.warn(
|
||
{ sessionId, timeoutMs },
|
||
"timed out waiting for opencode session to become idle",
|
||
);
|
||
}
|
||
|
||
async subscribeEvents(directory?: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.event.subscribe(
|
||
directory ? { directory } : undefined,
|
||
);
|
||
return response.stream;
|
||
}
|
||
|
||
async replyPermission(options: {
|
||
requestId: string;
|
||
sessionId?: string;
|
||
directory?: string;
|
||
reply: PermissionReply;
|
||
message?: string;
|
||
}) {
|
||
const client = await this.ensureClient();
|
||
const directory = await this.resolveInteractionDirectory(options);
|
||
if ("permission" in client && client.permission?.reply) {
|
||
const response = await client.permission.reply({
|
||
requestID: options.requestId,
|
||
directory,
|
||
reply: options.reply,
|
||
message: options.message,
|
||
});
|
||
return requireData(response.data, "permission.reply");
|
||
}
|
||
|
||
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
||
const response = await client.permission.respond({
|
||
sessionID: options.sessionId,
|
||
permissionID: options.requestId,
|
||
directory,
|
||
response: options.reply,
|
||
});
|
||
return requireData(response.data, "permission.respond");
|
||
}
|
||
|
||
throw new Error("opencode permission reply API is unavailable");
|
||
}
|
||
|
||
async replyQuestion(options: {
|
||
requestId: string;
|
||
sessionId?: string;
|
||
directory?: string;
|
||
answers: QuestionAnswers;
|
||
}) {
|
||
const client = await this.ensureClient();
|
||
const directory = await this.resolveInteractionDirectory(options);
|
||
if ("question" in client && client.question?.reply) {
|
||
try {
|
||
const response = await client.question.reply({
|
||
requestID: options.requestId,
|
||
directory,
|
||
answers: options.answers,
|
||
});
|
||
return requireData(response.data, "question.reply");
|
||
} catch (error) {
|
||
if (!options.sessionId) {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
const v2Question = (client as unknown as {
|
||
v2?: {
|
||
session?: {
|
||
question?: {
|
||
reply?: (parameters: {
|
||
sessionID: string;
|
||
requestID: string;
|
||
directory?: string;
|
||
questionV2Reply: { answers: QuestionAnswers };
|
||
}) => Promise<{ data: unknown }>;
|
||
};
|
||
};
|
||
};
|
||
}).v2?.session?.question;
|
||
|
||
if (v2Question?.reply && options.sessionId) {
|
||
const response = await v2Question.reply({
|
||
sessionID: options.sessionId,
|
||
requestID: options.requestId,
|
||
directory,
|
||
questionV2Reply: {
|
||
answers: options.answers,
|
||
},
|
||
});
|
||
return requireData(response.data, "question.v2.reply");
|
||
}
|
||
|
||
throw new Error("opencode question reply API is unavailable");
|
||
}
|
||
|
||
async rejectQuestion(options: {
|
||
requestId: string;
|
||
sessionId?: string;
|
||
directory?: string;
|
||
}) {
|
||
const client = await this.ensureClient();
|
||
const directory = await this.resolveInteractionDirectory(options);
|
||
if ("question" in client && client.question?.reject) {
|
||
try {
|
||
const response = await client.question.reject({
|
||
requestID: options.requestId,
|
||
directory,
|
||
});
|
||
return requireData(response.data, "question.reject");
|
||
} catch (error) {
|
||
if (!options.sessionId) {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
const v2Question = (client as unknown as {
|
||
v2?: {
|
||
session?: {
|
||
question?: {
|
||
reject?: (parameters: {
|
||
sessionID: string;
|
||
requestID: string;
|
||
directory?: string;
|
||
}) => Promise<{ data: unknown }>;
|
||
};
|
||
};
|
||
};
|
||
}).v2?.session?.question;
|
||
|
||
if (v2Question?.reject && options.sessionId) {
|
||
const response = await v2Question.reject({
|
||
sessionID: options.sessionId,
|
||
requestID: options.requestId,
|
||
directory,
|
||
});
|
||
return requireData(response.data, "question.v2.reject");
|
||
}
|
||
|
||
throw new Error("opencode question reject API is unavailable");
|
||
}
|
||
|
||
private async resolveInteractionDirectory(options: {
|
||
sessionId?: string;
|
||
directory?: string;
|
||
}): Promise<string | undefined> {
|
||
const directory = options.directory?.trim();
|
||
if (directory) {
|
||
return directory;
|
||
}
|
||
if (!options.sessionId) {
|
||
return undefined;
|
||
}
|
||
const session = await this.getSession(options.sessionId);
|
||
return session.directory;
|
||
}
|
||
|
||
async dispose(): Promise<void> {
|
||
if (this.toolOutputCleanupTimer) {
|
||
clearInterval(this.toolOutputCleanupTimer);
|
||
this.toolOutputCleanupTimer = null;
|
||
}
|
||
this.closeServer?.();
|
||
this.closeServer = null;
|
||
this.clientPromise = null;
|
||
}
|
||
|
||
private async bootstrapClient(): Promise<OpencodeClient> {
|
||
await this.cleanupToolOutputs();
|
||
|
||
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
||
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
|
||
process.env.TJWATER_AGENT_INTERNAL_TOKEN =
|
||
config.AGENT_INTERNAL_TOKEN ??
|
||
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
||
"";
|
||
process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR;
|
||
|
||
logger.info(
|
||
{
|
||
hostname: config.OPENCODE_HOSTNAME,
|
||
port: config.OPENCODE_PORT,
|
||
model: config.OPENCODE_MODEL,
|
||
mode: config.OPENCODE_MODE,
|
||
},
|
||
"starting opencode server in embedded mode",
|
||
);
|
||
|
||
const startedAt = Date.now();
|
||
let runtime;
|
||
try {
|
||
runtime = await createOpencode({
|
||
hostname: config.OPENCODE_HOSTNAME,
|
||
port: config.OPENCODE_PORT,
|
||
timeout: config.OPENCODE_TIMEOUT_MS,
|
||
config: buildOpencodeConfig(),
|
||
});
|
||
} catch (error) {
|
||
if (isMissingOpencodeCli(error)) {
|
||
throw new Error(
|
||
"embedded mode requires the opencode CLI to be installed and available in PATH",
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
logger.info(
|
||
{
|
||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||
hostname: config.OPENCODE_HOSTNAME,
|
||
port: config.OPENCODE_PORT,
|
||
mode: config.OPENCODE_MODE,
|
||
},
|
||
"opencode server started in embedded mode",
|
||
);
|
||
|
||
this.closeServer = () => {
|
||
runtime.server.close();
|
||
};
|
||
this.startToolOutputCleanupLoop();
|
||
|
||
return runtime.client;
|
||
}
|
||
|
||
private async cleanupToolOutputs(): Promise<void> {
|
||
const directory = resolveOpencodeToolOutputDirectory();
|
||
const ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000;
|
||
try {
|
||
const result = await cleanupExpiredToolOutputs(directory, ttlMs);
|
||
if (result.removed > 0) {
|
||
logger.info(
|
||
{ directory, ...result },
|
||
"removed expired opencode tool output files",
|
||
);
|
||
}
|
||
} catch (error) {
|
||
logger.warn(
|
||
{ err: error, directory },
|
||
"failed to clean expired opencode tool output files",
|
||
);
|
||
}
|
||
}
|
||
|
||
private startToolOutputCleanupLoop(): void {
|
||
if (this.toolOutputCleanupTimer) {
|
||
return;
|
||
}
|
||
this.toolOutputCleanupTimer = setInterval(() => {
|
||
void this.cleanupToolOutputs();
|
||
}, config.RESULT_REF_CLEANUP_INTERVAL_MS);
|
||
this.toolOutputCleanupTimer.unref();
|
||
}
|
||
}
|
||
|
||
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
||
|
||
function buildOpencodeConfig(): Record<string, unknown> {
|
||
return deepMerge(
|
||
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
|
||
{
|
||
model: config.OPENCODE_MODEL,
|
||
tool_output: {
|
||
max_bytes: config.MAX_INLINE_RESULT_BYTES,
|
||
max_lines: 2000,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
function readProjectOpencodeConfig(): Record<string, unknown> {
|
||
const path = resolve(process.cwd(), "opencode.json");
|
||
if (!existsSync(path)) {
|
||
return {};
|
||
}
|
||
return parseConfigJson(readFileSync(path, "utf8"), path);
|
||
}
|
||
|
||
function readEnvOpencodeConfig(): Record<string, unknown> {
|
||
const content = process.env.OPENCODE_CONFIG_CONTENT;
|
||
if (!content?.trim()) {
|
||
return {};
|
||
}
|
||
return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT");
|
||
}
|
||
|
||
function parseConfigJson(content: string, source: string): Record<string, unknown> {
|
||
const parsed = JSON.parse(content) as unknown;
|
||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||
throw new Error(`${source} must contain a JSON object`);
|
||
}
|
||
return parsed as Record<string, unknown>;
|
||
}
|
||
|
||
function deepMerge(
|
||
left: Record<string, unknown>,
|
||
right: Record<string, unknown>,
|
||
): Record<string, unknown> {
|
||
const next = { ...left };
|
||
for (const [key, value] of Object.entries(right)) {
|
||
const existing = next[key];
|
||
if (isPlainObject(existing) && isPlainObject(value)) {
|
||
next[key] = deepMerge(existing, value);
|
||
} else {
|
||
next[key] = value;
|
||
}
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
|
||
return (
|
||
typeof error === "object" &&
|
||
error !== null &&
|
||
"code" in error &&
|
||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||
);
|
||
}
|
||
|
||
function requireData<T>(data: T | undefined, operation: string): T {
|
||
if (data === undefined) {
|
||
throw new Error(`${operation} returned no data`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function delay(ms: number) {
|
||
return new Promise<void>((resolve) => {
|
||
setTimeout(resolve, ms);
|
||
});
|
||
}
|