feat: add pluggable multi-CLI backend system
Implement BackendAdapter interface with four CLI backends: - ClaudeCodeBackend (extracted from AgentRuntime) - CodexBackend (OpenAI Codex CLI) - GeminiBackend (Google Gemini CLI) - OpenCodeBackend (OpenCode CLI) Add BackendRegistry for resolution/creation via AGENT_BACKEND env var. Refactor AgentRuntime to delegate to BackendAdapter instead of hardcoding Claude CLI. Update GatewayConfig with new env vars (AGENT_BACKEND, BACKEND_CLI_PATH, BACKEND_MODEL, BACKEND_MAX_TURNS). Includes 10 property-based test files and unit tests for edge cases.
This commit is contained in:
@@ -1,9 +1,3 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js";
|
||||
import type { MarkdownConfigLoader } from "./markdown-config-loader.js";
|
||||
import type { SystemPromptAssembler } from "./system-prompt-assembler.js";
|
||||
@@ -13,6 +7,7 @@ import type { HookManager } from "./hook-manager.js";
|
||||
import type { GatewayConfig } from "./config.js";
|
||||
import { loadSkills } from "./skills-loader.js";
|
||||
import { logger } from "./logger.js";
|
||||
import type { BackendAdapter, BackendEventResult } from "./backends/types.js";
|
||||
|
||||
export interface EventResult {
|
||||
responseText?: string;
|
||||
@@ -23,6 +18,14 @@ export interface EventResult {
|
||||
|
||||
export type OnStreamResult = (text: string, channelId: string) => Promise<void>;
|
||||
|
||||
/** Maps a BackendEventResult to the gateway's EventResult, adding the target channel ID. */
|
||||
export function mapBackendEventResult(backendResult: BackendEventResult, targetChannelId?: string): EventResult {
|
||||
if (backendResult.isError) {
|
||||
return { error: backendResult.responseText, targetChannelId };
|
||||
}
|
||||
return { responseText: backendResult.responseText, targetChannelId, sessionId: backendResult.sessionId };
|
||||
}
|
||||
|
||||
function isTransientError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
@@ -57,20 +60,9 @@ export async function withRetry<T>(
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
interface ClaudeJsonResponse {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
result?: string;
|
||||
is_error?: boolean;
|
||||
duration_ms?: number;
|
||||
duration_api_ms?: number;
|
||||
num_turns?: number;
|
||||
cost_usd?: number;
|
||||
}
|
||||
|
||||
export class AgentRuntime {
|
||||
private config: GatewayConfig;
|
||||
private backend: BackendAdapter;
|
||||
private sessionManager: SessionManager;
|
||||
private markdownConfigLoader: MarkdownConfigLoader;
|
||||
private systemPromptAssembler: SystemPromptAssembler;
|
||||
@@ -78,12 +70,14 @@ export class AgentRuntime {
|
||||
|
||||
constructor(
|
||||
config: GatewayConfig,
|
||||
backend: BackendAdapter,
|
||||
sessionManager: SessionManager,
|
||||
markdownConfigLoader: MarkdownConfigLoader,
|
||||
systemPromptAssembler: SystemPromptAssembler,
|
||||
hookManager: HookManager,
|
||||
) {
|
||||
this.config = config;
|
||||
this.backend = backend;
|
||||
this.sessionManager = sessionManager;
|
||||
this.markdownConfigLoader = markdownConfigLoader;
|
||||
this.systemPromptAssembler = systemPromptAssembler;
|
||||
@@ -137,26 +131,22 @@ export class AgentRuntime {
|
||||
const existingSessionId = this.sessionManager.getSessionId(channelId);
|
||||
|
||||
const streamCallback = onStreamResult
|
||||
? (text: string) => onStreamResult(text, channelId)
|
||||
? async (text: string) => { await onStreamResult(text, channelId); }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const response = await withRetry(
|
||||
() => this.executeClaude(promptText, systemPrompt, existingSessionId, streamCallback),
|
||||
const backendResult = await withRetry(
|
||||
() => this.backend.execute(promptText, systemPrompt, existingSessionId, streamCallback),
|
||||
3,
|
||||
5000,
|
||||
isTransientError,
|
||||
);
|
||||
|
||||
if (response.session_id && channelId) {
|
||||
this.sessionManager.setSessionId(channelId, response.session_id);
|
||||
if (backendResult.sessionId && channelId) {
|
||||
this.sessionManager.setSessionId(channelId, backendResult.sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
responseText: response.result || undefined,
|
||||
targetChannelId: channelId,
|
||||
sessionId: response.session_id,
|
||||
};
|
||||
return mapBackendEventResult(backendResult, channelId);
|
||||
} catch (error) {
|
||||
if (this.isSessionCorrupted(error)) {
|
||||
this.sessionManager.removeSession(channelId);
|
||||
@@ -169,11 +159,11 @@ export class AgentRuntime {
|
||||
const payload = event.payload as HeartbeatPayload;
|
||||
const targetChannelId = this.config.outputChannelId;
|
||||
const streamCallback = onStreamResult && targetChannelId
|
||||
? (text: string) => onStreamResult(text, targetChannelId)
|
||||
? async (text: string) => { await onStreamResult(text, targetChannelId); }
|
||||
: undefined;
|
||||
try {
|
||||
const response = await this.executeClaude(payload.instruction, systemPrompt, undefined, streamCallback);
|
||||
return { responseText: response.result, targetChannelId: this.config.outputChannelId };
|
||||
const backendResult = await this.backend.execute(payload.instruction, systemPrompt, undefined, streamCallback);
|
||||
return mapBackendEventResult(backendResult, this.config.outputChannelId);
|
||||
} catch (error) {
|
||||
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
|
||||
}
|
||||
@@ -183,11 +173,11 @@ export class AgentRuntime {
|
||||
const payload = event.payload as CronPayload;
|
||||
const targetChannelId = this.config.outputChannelId;
|
||||
const streamCallback = onStreamResult && targetChannelId
|
||||
? (text: string) => onStreamResult(text, targetChannelId)
|
||||
? async (text: string) => { await onStreamResult(text, targetChannelId); }
|
||||
: undefined;
|
||||
try {
|
||||
const response = await this.executeClaude(payload.instruction, systemPrompt, undefined, streamCallback);
|
||||
return { responseText: response.result, targetChannelId: this.config.outputChannelId };
|
||||
const backendResult = await this.backend.execute(payload.instruction, systemPrompt, undefined, streamCallback);
|
||||
return mapBackendEventResult(backendResult, this.config.outputChannelId);
|
||||
} catch (error) {
|
||||
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
|
||||
}
|
||||
@@ -197,171 +187,13 @@ export class AgentRuntime {
|
||||
const payload = event.payload as HookPayload;
|
||||
if (!payload.instruction) return {};
|
||||
try {
|
||||
const response = await this.executeClaude(payload.instruction, systemPrompt);
|
||||
return { responseText: response.result, targetChannelId: this.config.outputChannelId };
|
||||
const backendResult = await this.backend.execute(payload.instruction, systemPrompt);
|
||||
return mapBackendEventResult(backendResult, this.config.outputChannelId);
|
||||
} catch (error) {
|
||||
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
|
||||
}
|
||||
}
|
||||
|
||||
private async executeClaude(
|
||||
promptText: string,
|
||||
systemPrompt: string,
|
||||
sessionId?: string,
|
||||
onResult?: (text: string) => Promise<void>,
|
||||
): Promise<ClaudeJsonResponse> {
|
||||
const tmpFile = join(tmpdir(), `aetheel-prompt-${randomUUID()}.txt`);
|
||||
await writeFile(tmpFile, systemPrompt, "utf-8");
|
||||
|
||||
try {
|
||||
return await this.runClaude(promptText, tmpFile, sessionId, onResult);
|
||||
} finally {
|
||||
unlink(tmpFile).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private runClaude(
|
||||
promptText: string,
|
||||
systemPromptFile: string,
|
||||
sessionId?: string,
|
||||
onResult?: (text: string) => Promise<void>,
|
||||
): Promise<ClaudeJsonResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args: string[] = [
|
||||
"-p", promptText,
|
||||
"--output-format", "json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--append-system-prompt-file", systemPromptFile,
|
||||
];
|
||||
|
||||
if (sessionId) {
|
||||
args.push("--resume", sessionId);
|
||||
}
|
||||
|
||||
for (const tool of this.config.allowedTools) {
|
||||
args.push("--allowedTools", tool);
|
||||
}
|
||||
|
||||
args.push("--max-turns", "25");
|
||||
|
||||
const configDir = path.resolve(this.config.configDir);
|
||||
logger.debug({ cliPath: this.config.claudeCliPath, cwd: configDir, argCount: args.length }, "Spawning Claude CLI");
|
||||
|
||||
const child = spawn(this.config.claudeCliPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: configDir,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let parsedSessionId: string | undefined;
|
||||
let lastResultText = "";
|
||||
let streamedResults = false;
|
||||
|
||||
// Parse JSON objects from stdout as they arrive for streaming
|
||||
let parseBuffer = "";
|
||||
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
stdout += chunk;
|
||||
parseBuffer += chunk;
|
||||
|
||||
// Try to parse complete JSON objects from the buffer
|
||||
// The output is a JSON array like [{...},{...},...] or newline-delimited
|
||||
const lines = parseBuffer.split("\n");
|
||||
parseBuffer = lines.pop() || ""; // Keep incomplete last line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
const cleaned = line.replace(/^\[/, "").replace(/,?\]$/, "").replace(/^,/, "").trim();
|
||||
if (!cleaned) continue;
|
||||
try {
|
||||
const obj = JSON.parse(cleaned);
|
||||
if (obj.type === "system" && obj.subtype === "init" && obj.session_id) {
|
||||
parsedSessionId = obj.session_id;
|
||||
}
|
||||
if (obj.type === "result" && obj.result) {
|
||||
lastResultText = obj.result;
|
||||
if (onResult) {
|
||||
streamedResults = true;
|
||||
onResult(obj.result).catch((err) =>
|
||||
logger.error({ err }, "Stream callback error")
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON yet
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
logger.debug("Timeout reached, killing Claude CLI process");
|
||||
child.kill("SIGTERM");
|
||||
reject(new Error("Query timed out"));
|
||||
}, this.config.queryTimeoutMs);
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
logger.debug({ code, stdoutLength: stdout.length, streamed: streamedResults }, "Claude CLI exited");
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
reject(new Error(`Claude CLI error (exit ${code}): ${stderr.slice(0, 500) || "unknown error"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Final parse of any remaining buffer
|
||||
if (parseBuffer.trim()) {
|
||||
try {
|
||||
const cleaned = parseBuffer.replace(/^\[/, "").replace(/,?\]$/, "").replace(/^,/, "").trim();
|
||||
const obj = JSON.parse(cleaned);
|
||||
if (obj.type === "system" && obj.subtype === "init" && obj.session_id) {
|
||||
parsedSessionId = obj.session_id;
|
||||
}
|
||||
if (obj.type === "result" && obj.result) {
|
||||
lastResultText = obj.result;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// If we didn't get results from line-by-line parsing, try the full output
|
||||
if (!lastResultText) {
|
||||
try {
|
||||
const arr = JSON.parse(stdout.trim());
|
||||
if (Array.isArray(arr)) {
|
||||
for (const obj of arr) {
|
||||
if (obj.type === "system" && obj.subtype === "init" && obj.session_id) {
|
||||
parsedSessionId = obj.session_id;
|
||||
}
|
||||
if (obj.type === "result" && obj.result) {
|
||||
lastResultText = obj.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
logger.debug({ resultLength: lastResultText.length, session: parsedSessionId ?? "none" }, "Parsed Claude response");
|
||||
|
||||
resolve({
|
||||
type: "result",
|
||||
result: streamedResults ? undefined : lastResultText || undefined,
|
||||
session_id: parsedSessionId,
|
||||
is_error: false,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
logger.error({ err }, "Failed to spawn Claude CLI");
|
||||
reject(new Error(`Failed to spawn Claude CLI: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isSessionCorrupted(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user