feat: add claw backend adapter

This commit is contained in:
kris
2026-04-03 01:36:29 +08:00
parent 8daaea01fd
commit 39b576cc42
23 changed files with 1212 additions and 23 deletions

View File

@@ -3,11 +3,17 @@ import {
ALIYUN_QWEN_BACKEND,
isReadyAliyunQwenBackend,
} from "@/lib/execution/backends/aliyun-qwen-backend";
import {
CLAW_BACKEND,
type ClawBackendSelectionState,
isClawRequestKindSupported,
} from "@/lib/execution/backends/claw-backend";
import {
MASTER_CODEX_NODE_BACKEND,
isReadyMasterCodexNodeBackend,
} from "@/lib/execution/backends/master-codex-node-backend";
import { OPENAI_BACKEND, isReadyOpenAiBackend } from "@/lib/execution/backends/openai-backend";
import type { ExecutionRequestKind } from "@/lib/execution/types";
export interface ExecutionBackendSelectionInput {
primary: {
@@ -18,9 +24,13 @@ export interface ExecutionBackendSelectionInput {
provider: AiProvider;
status: AiAccountStatus;
}>;
requestKind?: ExecutionRequestKind;
requestedBackendId?: string;
claw?: ClawBackendSelectionState;
}
export type ExecutionBackendChoice =
| typeof CLAW_BACKEND
| typeof MASTER_CODEX_NODE_BACKEND
| typeof OPENAI_BACKEND
| typeof ALIYUN_QWEN_BACKEND;
@@ -39,6 +49,14 @@ function resolveBackendByProvider(provider: AiProvider): ExecutionBackendChoice
}
function isReadyBackend(choice: ExecutionBackendChoice, input: ExecutionBackendSelectionInput) {
if (choice.backendId === CLAW_BACKEND.backendId) {
const requestKind = input.requestKind;
if (!input.claw?.enabled || !requestKind) {
return false;
}
return isClawRequestKindSupported(requestKind);
}
const candidates = [
...(input.primary.provider === choice.provider ? [input.primary] : []),
...input.backups.filter((item) => item.provider === choice.provider),
@@ -79,6 +97,13 @@ export function listExecutionBackendChoices(
seen.add(backend.backendId);
};
if (
input.requestedBackendId === CLAW_BACKEND.backendId &&
isReadyBackend(CLAW_BACKEND, input)
) {
pushBackend(CLAW_BACKEND);
}
if (input.primary.status === "ready") {
pushBackend(primaryBackend);
}

View File

@@ -0,0 +1,103 @@
import type { ExecutionBackend } from "@/lib/execution/execution-backend";
import type {
ExecutionBackendDescription,
ExecutionImmediateResult,
ExecutionRequest,
ExecutionRequestKind,
} from "@/lib/execution/types";
import {
getClawBackendConfig,
isClawBackendConfigured,
type ClawBackendConfig,
} from "@/lib/execution/backends/claw-config";
import { runClawCommand } from "@/lib/execution/backends/claw-runner";
export const CLAW_BACKEND_ID = "claw-runtime";
export const CLAW_BACKEND = {
backendId: CLAW_BACKEND_ID,
label: "Claw Runtime",
mode: "local",
} as const satisfies ExecutionBackendDescription;
const SUPPORTED_CLAW_KINDS = new Set<ExecutionRequestKind>([
"master_agent_reply",
"thread_reply",
]);
type ClawRunnerInput = Parameters<typeof runClawCommand>[0];
type ClawRunner = (input: ClawRunnerInput) => Promise<ExecutionImmediateResult>;
export interface ClawBackendSelectionState {
enabled: boolean;
supportsKinds: ExecutionRequestKind[];
}
function createFailedResult(error: string): ExecutionImmediateResult {
return {
status: "failed",
backendId: CLAW_BACKEND_ID,
error,
};
}
export function isClawRequestKindSupported(kind: ExecutionRequestKind) {
return SUPPORTED_CLAW_KINDS.has(kind);
}
export function getClawBackendSelectionState(
config: ClawBackendConfig = getClawBackendConfig(),
): ClawBackendSelectionState {
return {
enabled: isClawBackendConfigured(config),
supportsKinds: [...SUPPORTED_CLAW_KINDS],
};
}
function buildClawPayload(input: ExecutionRequest, config: ClawBackendConfig) {
return {
kind: input.kind,
projectId: input.projectId,
requestMessageId: input.requestMessageId,
body: input.body,
executionPrompt: input.executionPrompt ?? input.body,
model: input.modelOverride ?? config.defaultModel,
reasoningEffort: input.reasoningEffortOverride ?? "medium",
...(input.targetProjectId ? { targetProjectId: input.targetProjectId } : {}),
...(input.targetThreadId ? { targetThreadId: input.targetThreadId } : {}),
...(input.requestedByAccount ? { requestedByAccount: input.requestedByAccount } : {}),
...(input.requestedByLabel ? { requestedByLabel: input.requestedByLabel } : {}),
...(input.taskId ? { taskId: input.taskId } : {}),
};
}
export function createClawBackend(options?: {
config?: ClawBackendConfig;
runner?: ClawRunner;
}): ExecutionBackend {
const config = options?.config ?? getClawBackendConfig();
const runner = options?.runner ?? runClawCommand;
return {
backendId: CLAW_BACKEND_ID,
async canHandle(input) {
return isClawBackendConfigured(config) && isClawRequestKindSupported(input.kind);
},
async execute(input) {
const canHandle = await this.canHandle(input);
if (!canHandle) {
return createFailedResult("CLAW_BACKEND_NOT_AVAILABLE");
}
return runner({
config,
payload: buildClawPayload(input, config),
});
},
async describe() {
return CLAW_BACKEND;
},
};
}
export const CLAW_BACKEND_ADAPTER = createClawBackend();
export const createClawBackendForTesting = createClawBackend;

View File

@@ -0,0 +1,42 @@
export interface ClawBackendConfig {
enabled: boolean;
command?: string;
args: string[];
cwd?: string;
timeoutMs: number;
defaultModel?: string;
}
function parseBoolean(value: string | undefined) {
return value?.trim().toLowerCase() === "true";
}
function parseArgs(value: string | undefined) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseTimeoutMs(value: string | undefined) {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
}
export function getClawBackendConfig(): ClawBackendConfig {
return {
enabled: parseBoolean(process.env.BOSS_CLAW_ENABLED),
command: process.env.BOSS_CLAW_COMMAND?.trim() || undefined,
args: parseArgs(process.env.BOSS_CLAW_ARGS),
cwd: process.env.BOSS_CLAW_WORKDIR?.trim() || undefined,
timeoutMs: parseTimeoutMs(process.env.BOSS_CLAW_TIMEOUT_MS),
defaultModel: process.env.BOSS_CLAW_DEFAULT_MODEL?.trim() || undefined,
};
}
export function isClawBackendConfigured(config: ClawBackendConfig) {
return config.enabled && Boolean(config.command);
}
export const getClawBackendConfigForTesting = getClawBackendConfig;
export const isClawBackendConfiguredForTesting = isClawBackendConfigured;

View File

@@ -0,0 +1,118 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ClawBackendConfig } from "@/lib/execution/backends/claw-config";
import type { ExecutionImmediateResult } from "@/lib/execution/types";
const CLAW_BACKEND_ID = "claw-runtime";
function createFailedResult(error: string): ExecutionImmediateResult {
return {
status: "failed",
backendId: CLAW_BACKEND_ID,
error,
};
}
function normalizeClawProcessResult(input: {
exitCode: number;
stdout: string;
stderr: string;
}): ExecutionImmediateResult {
if (input.exitCode !== 0) {
return createFailedResult(input.stderr.trim() || `CLAW_EXIT_${input.exitCode}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(input.stdout);
} catch {
return createFailedResult("INVALID_CLAW_RESPONSE");
}
if (
typeof parsed === "object" &&
parsed !== null &&
(parsed as { status?: unknown }).status === "completed" &&
typeof (parsed as { output?: unknown }).output === "string"
) {
return {
status: "completed",
backendId: CLAW_BACKEND_ID,
output: (parsed as { output: string }).output,
};
}
if (
typeof parsed === "object" &&
parsed !== null &&
(parsed as { status?: unknown }).status === "failed" &&
typeof (parsed as { error?: unknown }).error === "string"
) {
return createFailedResult((parsed as { error: string }).error);
}
return createFailedResult("INVALID_CLAW_RESPONSE");
}
export async function runClawCommand(input: {
config: ClawBackendConfig;
payload: unknown;
}): Promise<ExecutionImmediateResult> {
const command = input.config.command;
if (!command) {
return createFailedResult("CLAW_COMMAND_NOT_CONFIGURED");
}
return new Promise((resolve) => {
const child: ChildProcessWithoutNullStreams = spawn(command, input.config.args, {
cwd: input.config.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"] as const,
});
let stdout = "";
let stderr = "";
let settled = false;
const finish = (result: ExecutionImmediateResult) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish(createFailedResult("CLAW_TIMEOUT"));
}, input.config.timeoutMs);
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += String(chunk);
});
child.on("error", (error: Error) => {
finish(createFailedResult(error.message));
});
child.on("close", (code: number | null) => {
finish(
normalizeClawProcessResult({
exitCode: code ?? 1,
stdout,
stderr,
}),
);
});
child.stdin.end(JSON.stringify(input.payload ?? null));
});
}
export const runClawCommandForTesting = runClawCommand;
export const createClawProcessResultForTesting = normalizeClawProcessResult;

View File

@@ -11,8 +11,10 @@ export interface ExecutionRequest {
projectId: string;
requestMessageId: string;
body: string;
executionPrompt?: string;
requestedByAccount?: string;
requestedByLabel?: string;
requestedBackendId?: string;
taskId?: string;
targetThreadId?: string;
targetProjectId?: string;
@@ -55,8 +57,10 @@ export function createExecutionRequest(input: ExecutionRequest): ExecutionReques
projectId: input.projectId,
requestMessageId: input.requestMessageId,
body: input.body,
executionPrompt: input.executionPrompt ?? undefined,
requestedByAccount: input.requestedByAccount ?? undefined,
requestedByLabel: input.requestedByLabel ?? undefined,
requestedBackendId: input.requestedBackendId ?? undefined,
taskId: input.taskId ?? undefined,
targetThreadId: input.targetThreadId ?? undefined,
targetProjectId: input.targetProjectId ?? undefined,