Files
TJWaterAgent/src/runtime/opencode.ts
T

237 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createOpencode,
createOpencodeClient,
type OpencodeClient,
} from "@opencode-ai/sdk/v2";
import { config } from "../config.js";
import { logger } from "../logger.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 class OpencodeRuntimeAdapter {
private clientPromise: Promise<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null;
async ensureClient(): Promise<OpencodeClient> {
if (!this.clientPromise) {
this.clientPromise = this.bootstrapClient();
}
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 createSession(title?: string) {
const client = await this.ensureClient();
const response = await client.session.create({
title,
});
return requireData(response.data, "session.create");
}
async getSession(id: string) {
const client = await this.ensureClient();
const response = await client.session.get({
sessionID: id,
});
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 forkSession(sessionId: string, messageId?: string) {
const client = await this.ensureClient();
const response = await client.session.fork({
sessionID: sessionId,
messageID: messageId,
});
return requireData(response.data, "session.fork");
}
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() {
const client = await this.ensureClient();
const response = await client.event.subscribe();
return response.stream;
}
async dispose(): Promise<void> {
this.closeServer?.();
this.closeServer = null;
this.clientPromise = null;
}
private async bootstrapClient(): Promise<OpencodeClient> {
if (config.OPENCODE_MODE === "client") {
logger.info(
{
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
mode: config.OPENCODE_MODE,
},
"connecting to opencode server in client mode",
);
return createOpencodeClient({
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
});
}
// 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 ??
"";
logger.info(
{
hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT,
model: config.OPENCODE_MODEL,
mode: config.OPENCODE_MODE,
},
"starting opencode server in embedded mode",
);
let runtime;
try {
runtime = await createOpencode({
hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT,
timeout: config.OPENCODE_TIMEOUT_MS,
config: {
model: config.OPENCODE_MODEL,
},
});
} catch (error) {
if (isMissingOpencodeCli(error)) {
throw new Error(
"embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
);
}
throw error;
}
this.closeServer = () => {
runtime.server.close();
};
return runtime.client;
}
}
export const opencodeRuntime = new OpencodeRuntimeAdapter();
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);
});
}