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:
2026-02-22 23:41:30 -05:00
parent f2247ea3ac
commit 453389f55c
25 changed files with 3262 additions and 195 deletions

View File

@@ -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();