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

@@ -59,6 +59,7 @@ export async function POST(
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
promptOverride?: unknown;
backendOverride?: unknown;
};
const hasModelOverride = Object.prototype.hasOwnProperty.call(payload, "modelOverride");
const hasReasoningEffortOverride = Object.prototype.hasOwnProperty.call(
@@ -66,9 +67,10 @@ export async function POST(
"reasoningEffortOverride",
);
const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride");
const allowedKeys = new Set(["modelOverride", "reasoningEffortOverride", "promptOverride"]);
const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride");
const allowedKeys = new Set(["modelOverride", "reasoningEffortOverride", "promptOverride", "backendOverride"]);
const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key));
if ((!hasModelOverride && !hasReasoningEffortOverride && !hasPromptOverride) || hasUnsupportedKeys) {
if ((!hasModelOverride && !hasReasoningEffortOverride && !hasPromptOverride && !hasBackendOverride) || hasUnsupportedKeys) {
return NextResponse.json({ ok: false, message: "INVALID_AGENT_CONTROLS_PAYLOAD" }, { status: 400 });
}
@@ -90,6 +92,14 @@ export async function POST(
if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") {
return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 });
}
if (
hasBackendOverride &&
payload.backendOverride !== undefined &&
payload.backendOverride !== null &&
payload.backendOverride !== "claw-runtime"
) {
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
}
try {
const controls = await updateProjectAgentControls(
@@ -98,6 +108,7 @@ export async function POST(
...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}),
...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}),
...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}),
...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}),
},
session.account,
);

View File

@@ -71,12 +71,14 @@ export async function POST(
const payload = body as {
userPromptContent?: unknown;
promptOverride?: unknown;
backendOverride?: unknown;
};
const hasUserPromptContent = Object.prototype.hasOwnProperty.call(payload, "userPromptContent");
const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride");
const allowedKeys = new Set(["userPromptContent", "promptOverride"]);
const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride");
const allowedKeys = new Set(["userPromptContent", "promptOverride", "backendOverride"]);
const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key));
if ((!hasUserPromptContent && !hasPromptOverride) || hasUnsupportedKeys) {
if ((!hasUserPromptContent && !hasPromptOverride && !hasBackendOverride) || hasUnsupportedKeys) {
return NextResponse.json({ ok: false, message: "INVALID_PROMPT_PROFILE_PAYLOAD" }, { status: 400 });
}
if (hasUserPromptContent && payload.userPromptContent !== undefined && payload.userPromptContent !== null && typeof payload.userPromptContent !== "string") {
@@ -85,6 +87,22 @@ export async function POST(
if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") {
return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 });
}
if (
hasBackendOverride
&& payload.backendOverride !== undefined
&& payload.backendOverride !== null
&& typeof payload.backendOverride !== "string"
) {
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
}
if (
hasBackendOverride
&& typeof payload.backendOverride === "string"
&& payload.backendOverride.trim() !== ""
&& payload.backendOverride.trim() !== "claw-runtime"
) {
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
}
try {
if (hasUserPromptContent) {
@@ -96,9 +114,10 @@ export async function POST(
}
}
if (hasPromptOverride) {
if (hasPromptOverride || hasBackendOverride) {
await updateProjectAgentControls(projectId, {
promptOverride: payload.promptOverride,
...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}),
...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}),
}, session.account);
}

View File

@@ -167,6 +167,9 @@ export function MasterAgentPromptMemoryClient({
projectControls?.reasoningEffortOverride ?? "",
);
const [promptOverride, setPromptOverride] = useState(projectControls?.promptOverride ?? "");
const [backendOverride, setBackendOverride] = useState(
projectControls?.backendOverride === "claw-runtime" ? "claw-runtime" : "",
);
const [newMemory, setNewMemory] = useState<MemoryDraft>(makeNewMemoryDraft());
const [memoryDrafts, setMemoryDrafts] = useState<Record<string, MemoryDraft>>(() => {
const next: Record<string, MemoryDraft> = {};
@@ -246,6 +249,7 @@ export function MasterAgentPromptMemoryClient({
modelOverride: modelOverride.trim() || null,
reasoningEffortOverride: reasoningEffortOverride.trim() || null,
promptOverride: promptOverride.trim() || null,
backendOverride: backendOverride.trim() || null,
}),
});
const result = (await response.json()) as { ok: boolean; message?: string };
@@ -402,7 +406,7 @@ export function MasterAgentPromptMemoryClient({
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<label id={anchors.model.split("#")[1]} className="space-y-1 scroll-mt-4">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
@@ -429,6 +433,17 @@ export function MasterAgentPromptMemoryClient({
<option value="high">high</option>
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={backendOverride}
onChange={(event) => setBackendOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="claw-runtime">Claw Runtime</option>
</select>
</label>
</div>
<TextArea
label="当前对话附加提示词"

View File

@@ -378,6 +378,7 @@ export interface ProjectAgentControls {
modelOverride?: string;
reasoningEffortOverride?: ReasoningEffort;
promptOverride?: string;
backendOverride?: "claw-runtime";
updatedAt: string;
}
@@ -1715,6 +1716,16 @@ function parseReasoningEffortOverride(value: unknown) {
return { kind: "set" as const, value };
}
function parseBackendOverride(value: unknown) {
if (value === undefined || value === null) {
return { kind: "clear" as const };
}
if (value !== "claw-runtime") {
return { kind: "invalid" as const };
}
return { kind: "set" as const, value: "claw-runtime" as const };
}
function normalizeStringSet(values: string[]) {
return dedupeStrings(values.map((value) => value.trim()).filter(Boolean)).sort((a, b) => a.localeCompare(b));
}
@@ -2144,8 +2155,9 @@ function normalizeProjectAgentControls(
? raw.reasoningEffortOverride
: undefined;
const promptOverride = trimToDefined(raw?.promptOverride);
const backendOverride = raw?.backendOverride === "claw-runtime" ? raw.backendOverride : undefined;
if (!modelOverride && !reasoningEffortOverride && !promptOverride) {
if (!modelOverride && !reasoningEffortOverride && !promptOverride && !backendOverride) {
return undefined;
}
@@ -2153,6 +2165,7 @@ function normalizeProjectAgentControls(
modelOverride,
reasoningEffortOverride,
promptOverride,
backendOverride,
updatedAt: raw?.updatedAt ?? nowIso(),
};
}
@@ -3610,6 +3623,7 @@ export async function updateProjectAgentControls(
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
promptOverride?: unknown;
backendOverride?: unknown;
},
account?: string,
) {
@@ -3626,6 +3640,9 @@ export async function updateProjectAgentControls(
const promptOverrideInput = Object.prototype.hasOwnProperty.call(payload, "promptOverride")
? parseControlTextOverride(payload.promptOverride)
: { kind: "preserve" as const };
const backendOverrideInput = Object.prototype.hasOwnProperty.call(payload, "backendOverride")
? parseBackendOverride(payload.backendOverride)
: { kind: "preserve" as const };
if (modelOverrideInput.kind === "invalid") {
throw new Error("INVALID_MODEL_OVERRIDE");
}
@@ -3635,6 +3652,9 @@ export async function updateProjectAgentControls(
if (promptOverrideInput.kind === "invalid") {
throw new Error("INVALID_PROMPT_OVERRIDE");
}
if (backendOverrideInput.kind === "invalid") {
throw new Error("INVALID_BACKEND_OVERRIDE");
}
return mutateStateIfChanged((state) => {
const project = state.projects.find((item) => item.id === projectId);
@@ -3661,14 +3681,22 @@ export async function updateProjectAgentControls(
: promptOverrideInput.kind === "clear"
? undefined
: currentControls?.promptOverride;
const backendOverride =
backendOverrideInput.kind === "set"
? backendOverrideInput.value
: backendOverrideInput.kind === "clear"
? undefined
: currentControls?.backendOverride;
const currentModelOverride = currentControls?.modelOverride;
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
const currentPromptOverride = currentControls?.promptOverride;
const currentBackendOverride = currentControls?.backendOverride;
if (
currentModelOverride === modelOverride &&
currentReasoningEffortOverride === reasoningEffortOverride &&
currentPromptOverride === promptOverride
currentPromptOverride === promptOverride &&
currentBackendOverride === backendOverride
) {
return { result: currentControls, changed: false };
}
@@ -3677,6 +3705,7 @@ export async function updateProjectAgentControls(
modelOverride,
reasoningEffortOverride,
promptOverride,
backendOverride,
updatedAt: nowIso(),
} satisfies ProjectAgentControls;
const normalizedControls = normalizeProjectAgentControls(nextControls) ?? null;

View File

@@ -29,6 +29,11 @@ import type {
ReasoningEffort,
} from "@/lib/boss-data";
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
import {
CLAW_BACKEND_ID,
createClawBackend,
getClawBackendSelectionState,
} from "@/lib/execution/backends/claw-backend";
import { listExecutionBackendChoices, selectExecutionBackend } from "@/lib/execution/backend-selector";
import { resolveRuntimeRelevantMemories } from "@/lib/execution/memory-resolver";
import type { RelevantMemory } from "@/lib/execution/memory-resolver";
@@ -44,6 +49,7 @@ import {
type MasterAgentReplyState = "queued" | "running" | "completed";
const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai";
const ALIYUN_QWEN_DEVICE_ID = "master-agent-aliyun-qwen";
const CLAW_RUNTIME_DEVICE_ID = "master-agent-claw";
type ApiCompatibleProvider = Extract<AiProvider, "openai_api" | "aliyun_qwen_api">;
@@ -144,6 +150,7 @@ function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
"当前对话覆盖:",
`model=${agentControls.modelOverride ?? "默认"}`,
`reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`,
`backend=${agentControls.backendOverride ?? "默认"}`,
`prompt=${agentControls.promptOverride ? "已配置" : "默认"}`,
].join(" ");
}
@@ -456,7 +463,7 @@ interface ApiExecutionCandidate {
}
async function buildApiExecutionCandidates(params: {
backendChoices: Array<{ provider: AiProvider }>;
backendChoices: Array<{ backendId?: string; provider?: AiProvider }>;
runtimeAccount: AiAccount;
agentControls?: ProjectAgentControls | null;
}) {
@@ -464,7 +471,7 @@ async function buildApiExecutionCandidates(params: {
const seenAccountIds = new Set<string>();
for (const backend of params.backendChoices) {
if (!isApiCompatibleProvider(backend.provider)) {
if (!backend.provider || !isApiCompatibleProvider(backend.provider)) {
continue;
}
@@ -492,7 +499,7 @@ async function buildApiExecutionCandidates(params: {
}
async function resolveMasterNodeExecutionCandidate(params: {
backendChoices: Array<{ backendId: string; provider: AiProvider }>;
backendChoices: Array<{ backendId: string; provider?: AiProvider }>;
runtimeAccount: AiAccount;
}) {
const wantsMasterNode = params.backendChoices.some((backend) => backend.backendId === "master-codex-node");
@@ -857,6 +864,106 @@ async function enqueueOpenAiMasterAgentReply(params: {
return queuedReply;
}
async function enqueueClawMasterAgentReply(params: {
requestMessageId?: string;
requestText: string;
requestedBy: string;
requestedByAccount: string;
executionPrompt: string;
agentControls?: ProjectAgentControls | null;
apiFallbackCandidates: ApiExecutionCandidate[];
masterFallback?: {
account: AiAccount;
executionPrompt: string;
} | null;
}) {
const task = await queueMasterAgentTask({
requestMessageId: params.requestMessageId ?? "master-agent-manual",
requestText: params.requestText,
executionPrompt: params.executionPrompt,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId: CLAW_RUNTIME_DEVICE_ID,
accountId: CLAW_BACKEND_ID,
accountLabel: "Claw Runtime",
});
const timer = setTimeout(() => {
void (async () => {
const currentTask = await getMasterAgentTask(task.taskId);
if (!currentTask || currentTask.status !== "queued") {
return;
}
const backend = createClawBackend();
const result = await backend.execute({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: params.requestMessageId ?? "master-agent-manual",
body: params.requestText,
executionPrompt: params.executionPrompt,
requestedByAccount: params.requestedByAccount,
requestedByLabel: params.requestedBy,
taskId: task.taskId,
modelOverride: params.agentControls?.modelOverride,
reasoningEffortOverride: params.agentControls?.reasoningEffortOverride,
});
if (result.status === "completed") {
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: CLAW_RUNTIME_DEVICE_ID,
status: "completed",
replyBody: result.output,
});
return;
}
if (result.status !== "failed") {
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: CLAW_RUNTIME_DEVICE_ID,
status: "failed",
errorMessage: "Claw Runtime 返回了当前链路尚不支持的状态。",
});
return;
}
if (params.apiFallbackCandidates.length > 0 || params.masterFallback) {
await queueAndStartOpenAiMasterAgentReply({
candidates: params.apiFallbackCandidates,
taskId: task.taskId,
requestText: params.requestText,
reasoningEffort: params.agentControls?.reasoningEffortOverride || "medium",
agentControls: params.agentControls,
masterFallback: params.masterFallback,
});
return;
}
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: CLAW_RUNTIME_DEVICE_ID,
status: "failed",
errorMessage: normalizeClawExecutionError(result.error),
});
})();
}, 0);
timer.unref?.();
return {
ok: true as const,
accountId: CLAW_BACKEND_ID,
taskId: task.taskId,
masterReplyState: "queued" as const,
task: {
taskId: task.taskId,
taskType: "conversation_reply" as const,
status: "queued" as const,
},
};
}
export async function probeApiCompatibleAccount(params: {
provider: ApiCompatibleProvider;
apiKey: string;
@@ -958,6 +1065,61 @@ function buildMasterCodexNodePrompt(
});
}
function normalizeClawExecutionError(message: string) {
const trimmed = message.trim();
if (!trimmed) {
return "Claw Runtime 当前执行失败。";
}
if (trimmed.length <= 240) {
return trimmed;
}
return `${trimmed.slice(0, 237)}...`;
}
async function replyViaClawBackend(params: {
requestMessageId?: string;
requestText: string;
requestedBy: string;
requestedByAccount: string;
executionPrompt: string;
agentControls?: ProjectAgentControls | null;
}) {
const backend = createClawBackend();
const result = await backend.execute({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: params.requestMessageId ?? "master-agent-manual",
body: params.requestText,
executionPrompt: params.executionPrompt,
requestedByAccount: params.requestedByAccount,
requestedByLabel: params.requestedBy,
modelOverride: params.agentControls?.modelOverride,
reasoningEffortOverride: params.agentControls?.reasoningEffortOverride,
});
if (result.status === "completed") {
await appendMasterAgentSystemReply(result.output, "主 Agent · Claw Runtime");
return {
ok: true as const,
accountId: CLAW_BACKEND_ID,
};
}
if (result.status !== "failed") {
return {
ok: false as const,
reason: "CLAW_EXEC_FAILED",
message: "Claw Runtime 返回了当前链路尚不支持的状态。",
};
}
return {
ok: false as const,
reason: "CLAW_EXEC_FAILED",
message: normalizeClawExecutionError(result.error),
};
}
function summarizeDispatchRequest(requestText: string) {
const compact = requestText.trim().replace(/\s+/g, " ");
if (!compact) {
@@ -1587,6 +1749,9 @@ export async function replyToMasterAgentUserMessage(params: {
provider: account.provider,
status: account.status,
})),
requestKind: "master_agent_reply" as const,
requestedBackendId: executionConfig.agentControls?.backendOverride,
claw: getClawBackendSelectionState(),
};
const selectedBackend = await selectExecutionBackend(backendSelectionInput);
const backendChoices = listExecutionBackendChoices(backendSelectionInput);
@@ -1701,6 +1866,24 @@ export async function replyToMasterAgentUserMessage(params: {
};
if (params.mode === "enqueue") {
if (selectedBackend.backendId === CLAW_BACKEND_ID) {
return enqueueClawMasterAgentReply({
requestMessageId: params.requestMessageId,
requestText: params.requestText,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
executionPrompt: masterExecutionPrompt,
agentControls,
apiFallbackCandidates: apiExecutionCandidates,
masterFallback: hasMasterFallback && selectedMasterAccount
? {
account: selectedMasterAccount,
executionPrompt: masterExecutionPrompt,
}
: null,
});
}
if (selectedBackend.backendId === "master-codex-node") {
return runMasterNodeExecution();
}
@@ -1729,6 +1912,27 @@ export async function replyToMasterAgentUserMessage(params: {
}
}
if (selectedBackend.backendId === CLAW_BACKEND_ID) {
const clawReply = await replyViaClawBackend({
requestMessageId: params.requestMessageId,
requestText: params.requestText,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
executionPrompt: masterExecutionPrompt,
agentControls,
});
if (clawReply.ok) {
return clawReply;
}
if (apiExecutionCandidates.length === 0 && !(hasMasterFallback && selectedMasterAccount)) {
await appendMasterAgentSystemReply(
`我已经收到你的消息,但 Claw Runtime 当前执行失败:${clawReply.message}。请检查 Claw 可执行入口,或先切回其他主控后再试。`,
"主 Agent · Claw Runtime",
);
return clawReply;
}
}
if (selectedBackend.backendId === "master-codex-node") {
return runMasterNodeExecution();
}

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,