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

@@ -57,7 +57,8 @@
- `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包
- 当前这台开发机的 `launchd` 常驻 `local-agent` 已恢复:`GET http://127.0.0.1:4317/health` 现在可在数十毫秒内返回,且在手动 heartbeat 执行期间仍能正常回包
- 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束
- 当前 `claw-code` `oh-my-codex` 仍未正式接入生产执行链;当前状态是 contract-ready可在后续通过 adapter 方式接入
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 并在 `master-agent` 当前对话里显式选择 `claw-runtime` 时才会参与执行候选
- 当前 `oh-my-codex` 仍未正式接入生产执行链;当前状态是 orchestration-ready后续将通过独立 adapter 接入
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报
- `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`

View File

@@ -4,8 +4,11 @@ import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.TextUtils;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.Nullable;
@@ -13,6 +16,8 @@ import androidx.annotation.Nullable;
import org.json.JSONObject;
public class MasterAgentPromptActivity extends BossScreenActivity {
private static final String[] BACKEND_OVERRIDE_VALUES = {"", "claw-runtime"};
private static final String[] BACKEND_OVERRIDE_LABELS = {"默认", "Claw Runtime"};
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
@@ -25,8 +30,10 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
private @Nullable String adminPromptText;
private @Nullable String userPromptText;
private @Nullable String projectPromptOverrideText;
private @Nullable String backendOverrideText;
private EditText userPromptInput;
private EditText projectPromptInput;
private Spinner backendSpinner;
private TextView previewTextView;
@Override
@@ -78,6 +85,7 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
"projectPromptOverride",
projectControls == null ? "" : projectControls.optString("promptOverride", "")
);
backendOverrideText = projectControls == null ? "" : projectControls.optString("backendOverride", "");
replaceContent();
appendContent(BossUi.buildSimpleProfileHeader(
@@ -115,6 +123,27 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
projectPromptInput
));
backendSpinner = new Spinner(this);
backendSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, BACKEND_OVERRIDE_LABELS));
backendSpinner.setSelection(indexOfBackendOverride(backendOverrideText));
backendSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, android.view.View view, int position, long id) {
refreshPreview();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
refreshPreview();
}
});
appendContent(BossUi.buildFormCell(
this,
"执行后端",
"默认沿用 Boss 当前主链;需要时可显式切到 Claw Runtime。",
backendSpinner
));
previewTextView = new TextView(this);
previewTextView.setText(buildPreviewText());
previewTextView.setTextSize(14);
@@ -172,6 +201,12 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
if (!TextUtils.isEmpty(projectText)) {
builder.append("【当前对话提示词】\n").append(projectText).append("\n\n");
}
String backendValue = backendSpinner == null
? (backendOverrideText == null ? "" : backendOverrideText)
: BACKEND_OVERRIDE_VALUES[backendSpinner.getSelectedItemPosition()];
if (!TextUtils.isEmpty(backendValue)) {
builder.append("【执行后端】\n").append(backendValue).append("\n\n");
}
if (builder.length() == 0) {
return "当前没有任何提示词内容。";
}
@@ -185,12 +220,16 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
}
final String userContent = userPromptInput == null ? "" : userPromptInput.getText().toString();
final String promptOverride = projectPromptInput == null ? "" : projectPromptInput.getText().toString();
final String backendOverride = backendSpinner == null
? ""
: BACKEND_OVERRIDE_VALUES[backendSpinner.getSelectedItemPosition()];
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("userPromptContent", userContent);
payload.put("promptOverride", promptOverride);
payload.put("backendOverride", TextUtils.isEmpty(backendOverride) ? JSONObject.NULL : backendOverride);
BossApiClient.ApiResponse response = apiClient.updateMasterAgentPromptProfile(projectId, payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
@@ -215,4 +254,16 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f);
}
}
private int indexOfBackendOverride(@Nullable String value) {
if (TextUtils.isEmpty(value)) {
return 0;
}
for (int index = 0; index < BACKEND_OVERRIDE_VALUES.length; index += 1) {
if (value.equals(BACKEND_OVERRIDE_VALUES[index])) {
return index;
}
}
return 0;
}
}

View File

@@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Spinner;
import android.widget.EditText;
import android.widget.TextView;
@@ -51,7 +52,9 @@ public class MasterAgentPromptActivityTest {
JSONObject payload = new JSONObject()
.put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词"))
.put("userPrompt", new JSONObject().put("content", "用户私有主提示词"))
.put("projectControls", new JSONObject().put("promptOverride", "当前对话提示词"));
.put("projectControls", new JSONObject()
.put("promptOverride", "当前对话提示词")
.put("backendOverride", "claw-runtime"));
ReflectionHelpers.callInstanceMethod(
activity,
@@ -64,6 +67,7 @@ public class MasterAgentPromptActivityTest {
assertTrue(viewTreeContainsText(content, "全局主提示词"));
assertTrue(viewTreeContainsText(content, "用户私有主提示词"));
assertTrue(viewTreeContainsText(content, "当前对话提示词"));
assertTrue(viewTreeContainsText(content, "执行后端"));
assertTrue(viewTreeContainsText(content, "合成预览"));
}
@@ -91,7 +95,9 @@ public class MasterAgentPromptActivityTest {
JSONObject payload = new JSONObject()
.put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词"))
.put("userPrompt", new JSONObject().put("content", "用户私有主提示词"))
.put("projectControls", new JSONObject().put("promptOverride", "当前对话提示词"));
.put("projectControls", new JSONObject()
.put("promptOverride", "当前对话提示词")
.put("backendOverride", "claw-runtime"));
ReflectionHelpers.callInstanceMethod(
activity,
@@ -101,14 +107,16 @@ public class MasterAgentPromptActivityTest {
EditText userInput = ReflectionHelpers.getField(activity, "userPromptInput");
EditText conversationInput = ReflectionHelpers.getField(activity, "projectPromptInput");
Spinner backendSpinner = ReflectionHelpers.getField(activity, "backendSpinner");
userInput.setText("更新后的用户提示词");
conversationInput.setText("更新后的对话提示词");
backendSpinner.setSelection(0);
ReflectionHelpers.callInstanceMethod(activity, "savePromptProfile");
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle();
assertEquals(
"{\"userPromptContent\":\"更新后的用户提示词\",\"promptOverride\":\"更新后的对话提示词\"}",
"{\"userPromptContent\":\"更新后的用户提示词\",\"promptOverride\":\"更新后的对话提示词\",\"backendOverride\":null}",
((ScriptedBossApiClient) ReflectionHelpers.getField(activity, "apiClient")).connection.requestBody()
);
}
@@ -128,7 +136,9 @@ public class MasterAgentPromptActivityTest {
JSONObject payload = new JSONObject()
.put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词"))
.put("userPrompt", new JSONObject().put("content", "用户私有主提示词"))
.put("projectControls", new JSONObject().put("promptOverride", "当前对话提示词"));
.put("projectControls", new JSONObject()
.put("promptOverride", "当前对话提示词")
.put("backendOverride", "claw-runtime"));
ReflectionHelpers.callInstanceMethod(
activity,

View File

@@ -177,7 +177,8 @@
- 当前状态:
- 已在生产代码中被 `boss-master-agent.ts``local-agent/server.mjs``master-agent task complete route` 使用
- 当前仍服务 Boss 自身执行链
- 当前未直接接入 `claw-code``oh-my-codex`
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置和显式选择时参与执行
- 当前尚未接入 `oh-my-codex`
### 3.2 认证相关
@@ -373,17 +374,18 @@
#### `GET /api/v1/projects/[projectId]/agent-controls`
- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride`
- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride`
- 当前约束:
- 当前只支持 `projectId=master-agent`
- 未配置时返回 `controls: null`
#### `POST /api/v1/projects/[projectId]/agent-controls`
- 用途:更新当前对话级别的 `modelOverride / reasoningEffortOverride`
- 用途:更新当前对话级别的 `modelOverride / reasoningEffortOverride / promptOverride / backendOverride`
- 当前约束:
- 当前只支持 `projectId=master-agent`
-`highest_admin` 可写
- `backendOverride` 当前仅支持 `claw-runtime`
- 显式传 `null` 或空字符串表示清空覆盖;省略字段表示保留原值
#### `GET /api/v1/projects/[projectId]/participants`

View File

@@ -28,7 +28,8 @@
- `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
- 当前执行底座抽象层已落地在 `src/lib/execution/`,并已补齐 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
- 当前生产主链仍然沿用 `local-agent -> codex exec resume -> /api/v1/master-agent/tasks/[taskId]/complete`,执行底座重构以“先抽象、不改行为”为准
- 当前 `claw-code` `oh-my-codex` 还未正式接入生产链,只是已经具备 adapter-ready 的 contract 基础
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 并在 `master-agent` 当前对话中选择 `claw-runtime` 时才会参与执行候选
- 当前 `oh-my-codex` 还未正式接入生产链,只是已经具备 orchestration adapter-ready 的 contract 基础
本地已知运行方式:
@@ -144,6 +145,7 @@ cd /Users/kris/code/boss
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
- 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt
- 当前对话级 `agentControls` 也已支持 `backendOverride``master-agent` 会话可显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
- `POST /api/v1/app-logs``POST /api/v1/devices/[deviceId]/skills``POST /api/v1/workers/[workerId]/thread-context` 当前都要求有效设备 token 或匹配登录会话

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,

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getClawBackendConfigForTesting,
isClawBackendConfiguredForTesting,
} from "../src/lib/execution/backends/claw-config.ts";
function snapshotEnv() {
return {
BOSS_CLAW_ENABLED: process.env.BOSS_CLAW_ENABLED,
BOSS_CLAW_COMMAND: process.env.BOSS_CLAW_COMMAND,
BOSS_CLAW_ARGS: process.env.BOSS_CLAW_ARGS,
BOSS_CLAW_WORKDIR: process.env.BOSS_CLAW_WORKDIR,
BOSS_CLAW_TIMEOUT_MS: process.env.BOSS_CLAW_TIMEOUT_MS,
BOSS_CLAW_DEFAULT_MODEL: process.env.BOSS_CLAW_DEFAULT_MODEL,
};
}
function restoreEnv(snapshot: ReturnType<typeof snapshotEnv>) {
process.env.BOSS_CLAW_ENABLED = snapshot.BOSS_CLAW_ENABLED;
process.env.BOSS_CLAW_COMMAND = snapshot.BOSS_CLAW_COMMAND;
process.env.BOSS_CLAW_ARGS = snapshot.BOSS_CLAW_ARGS;
process.env.BOSS_CLAW_WORKDIR = snapshot.BOSS_CLAW_WORKDIR;
process.env.BOSS_CLAW_TIMEOUT_MS = snapshot.BOSS_CLAW_TIMEOUT_MS;
process.env.BOSS_CLAW_DEFAULT_MODEL = snapshot.BOSS_CLAW_DEFAULT_MODEL;
}
test("Claw backend 在未配置时默认关闭", () => {
const previous = snapshotEnv();
delete process.env.BOSS_CLAW_ENABLED;
delete process.env.BOSS_CLAW_COMMAND;
delete process.env.BOSS_CLAW_ARGS;
delete process.env.BOSS_CLAW_WORKDIR;
delete process.env.BOSS_CLAW_TIMEOUT_MS;
delete process.env.BOSS_CLAW_DEFAULT_MODEL;
const config = getClawBackendConfigForTesting();
assert.equal(config.enabled, false);
assert.equal(isClawBackendConfiguredForTesting(config), false);
restoreEnv(previous);
});
test("Claw backend 在配置完整时返回 command、args 和 timeout", () => {
const previous = snapshotEnv();
process.env.BOSS_CLAW_ENABLED = "true";
process.env.BOSS_CLAW_COMMAND = "claw";
process.env.BOSS_CLAW_ARGS = "run --json";
process.env.BOSS_CLAW_WORKDIR = "/tmp/claw";
process.env.BOSS_CLAW_TIMEOUT_MS = "45000";
const config = getClawBackendConfigForTesting();
assert.equal(config.enabled, true);
assert.equal(config.command, "claw");
assert.deepEqual(config.args, ["run", "--json"]);
assert.equal(config.cwd, "/tmp/claw");
assert.equal(config.timeoutMs, 45000);
assert.equal(isClawBackendConfiguredForTesting(config), true);
restoreEnv(previous);
});

121
tests/claw-backend.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createClawBackendForTesting } from "../src/lib/execution/backends/claw-backend.ts";
test("Claw backend 只在启用且请求类型受支持时 canHandle", async () => {
const backend = createClawBackendForTesting({
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
},
runner: async () => ({
status: "completed",
backendId: "claw-runtime",
output: "ok",
}),
});
assert.equal(
await backend.canHandle({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续",
}),
true,
);
assert.equal(
await backend.canHandle({
kind: "dispatch_execution",
projectId: "project-1",
requestMessageId: "msg-2",
body: "继续",
}),
false,
);
});
test("Claw backend 执行时会把 executionPrompt、模型和推理强度交给 runner", async () => {
const calls: unknown[] = [];
const backend = createClawBackendForTesting({
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
defaultModel: "claude-sonnet",
},
runner: async (input) => {
calls.push(input);
return {
status: "completed",
backendId: "claw-runtime",
output: "链路正常",
};
},
});
const result = await backend.execute({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
modelOverride: "gpt-5.4",
reasoningEffortOverride: "high",
});
assert.equal(result.status, "completed");
assert.deepEqual(calls, [
{
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
defaultModel: "claude-sonnet",
},
payload: {
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
model: "gpt-5.4",
reasoningEffort: "high",
},
},
]);
});
test("Claw backend describe 返回稳定描述", async () => {
const backend = createClawBackendForTesting({
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
},
runner: async () => ({
status: "completed",
backendId: "claw-runtime",
output: "ok",
}),
});
const description = await backend.describe({
kind: "thread_reply",
projectId: "project-1",
requestMessageId: "msg-1",
body: "继续",
});
assert.deepEqual(description, {
backendId: "claw-runtime",
label: "Claw Runtime",
mode: "local",
});
});

180
tests/claw-runner.test.ts Normal file
View File

@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { runClawCommandForTesting } from "../src/lib/execution/backends/claw-runner.ts";
async function createTempScript(source: string) {
const dir = await mkdtemp(join(tmpdir(), "claw-runner-"));
const scriptPath = join(dir, "claw-script.mjs");
await writeFile(scriptPath, source, "utf8");
return { dir, scriptPath };
}
test("Claw runner 把 completed JSON 响应映射成 completed并把 payload 写入 stdin", async () => {
const workspace = await mkdtemp(join(tmpdir(), "claw-runner-cwd-"));
const expectedWorkspace = await realpath(workspace);
const { scriptPath } = await createTempScript(`
import { readFile } from "node:fs/promises";
let stdin = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdin += chunk;
}
const payload = JSON.parse(stdin);
process.stdout.write(JSON.stringify({
status: "completed",
output: JSON.stringify({
body: payload.body,
cwd: process.cwd(),
command: payload.command,
}),
}));
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
cwd: workspace,
timeoutMs: 1000,
},
payload: {
body: "继续执行",
command: "claw",
},
});
assert.equal(result.status, "completed");
if (result.status !== "completed") {
assert.fail("expected completed");
}
const output = JSON.parse(result.output) as {
body: string;
cwd: string;
command: string;
};
assert.equal(output.body, "继续执行");
assert.equal(output.cwd, expectedWorkspace);
assert.equal(output.command, "claw");
});
test("Claw runner 把 failed JSON 响应映射成 failed", async () => {
const { scriptPath } = await createTempScript(`
let stdin = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdin += chunk;
}
const payload = JSON.parse(stdin);
process.stdout.write(JSON.stringify({
status: "failed",
error: "bad-request:" + payload.body,
}));
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
},
payload: {
body: "格式不对",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.equal(result.error, "bad-request:格式不对");
});
test("Claw runner 把无效 JSON 响应映射成 INVALID_CLAW_RESPONSE", async () => {
const { scriptPath } = await createTempScript(`
process.stdout.write("not-json");
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
},
payload: {
body: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /INVALID_CLAW_RESPONSE/);
});
test("Claw runner 把非零退出码映射成 stderr 或退出码错误", async () => {
const { scriptPath } = await createTempScript(`
process.stderr.write("claw crashed");
process.exit(2);
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
},
payload: {
body: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /claw crashed/);
});
test("Claw runner 超时后返回 CLAW_TIMEOUT", async () => {
const { scriptPath } = await createTempScript(`
setTimeout(() => {
process.stdout.write(JSON.stringify({ status: "completed", output: "late" }));
}, 500);
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 50,
},
payload: {
body: "slow",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /CLAW_TIMEOUT/);
});

View File

@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { selectExecutionBackendForTesting } from "@/lib/execution/backend-selector";
import {
listExecutionBackendChoices,
selectExecutionBackendForTesting,
} from "@/lib/execution/backend-selector";
test("selectExecutionBackendForTesting prefers the ready primary master codex node", async () => {
const backend = await selectExecutionBackendForTesting({
@@ -73,3 +76,46 @@ test("selectExecutionBackendForTesting falls back to master node last when highe
assert.equal(backend.backendId, "master-codex-node");
});
test("listExecutionBackendChoices keeps claw disabled by default", () => {
const backends = listExecutionBackendChoices({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
});
assert.deepEqual(
backends.map((backend) => backend.backendId),
["master-codex-node", "openai-api"],
);
});
test("selectExecutionBackendForTesting honors an explicit claw request when claw is enabled", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "claw-runtime",
claw: {
enabled: true,
supportsKinds: ["master_agent_reply", "thread_reply"],
},
});
assert.equal(backend.backendId, "claw-runtime");
});
test("selectExecutionBackendForTesting falls back when claw is requested but unavailable", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "claw-runtime",
claw: {
enabled: false,
supportsKinds: ["master_agent_reply"],
},
});
assert.equal(backend.backendId, "master-codex-node");
});

View File

@@ -19,10 +19,13 @@ test("ExecutionRequest 工厂会生成稳定默认字段", () => {
assert.equal(request.projectId, "master-agent");
assert.equal(request.requestMessageId, "msg-1");
assert.equal(request.body, "你好");
assert.equal(request.executionPrompt, undefined);
assert.equal(request.targetProjectId, undefined);
assert.equal(request.targetThreadId, undefined);
assert.equal(Object.prototype.hasOwnProperty.call(request, "requestedByAccount"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "requestedByLabel"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "executionPrompt"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "requestedBackendId"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "taskId"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "targetProjectId"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "targetThreadId"), true);

View File

@@ -53,6 +53,9 @@ async function resetMasterAgentControls() {
const project = state.projects.find((item) => item.id === "master-agent");
assert.ok(project, "expected seeded master-agent project");
delete project.agentControls;
state.userProjectAgentControls = state.userProjectAgentControls.filter(
(item) => item.projectId !== "master-agent",
);
await writeState(state);
}
@@ -110,6 +113,7 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
body: JSON.stringify({
modelOverride: "gpt-5.4",
reasoningEffortOverride: "medium",
backendOverride: "claw-runtime",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
@@ -121,12 +125,14 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
};
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(postPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(postPayload.controls?.backendOverride, "claw-runtime");
const getResponse = await getAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
@@ -142,12 +148,14 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
};
assert.equal(getPayload.ok, true);
assert.equal(getPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(getPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(getPayload.controls?.backendOverride, "claw-runtime");
const projectResponse = await getProjectRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent", {
@@ -163,12 +171,14 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
agentControls: {
modelOverride?: string;
reasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
};
assert.equal(projectPayload.ok, true);
assert.equal(projectPayload.agentControls?.modelOverride, "gpt-5.4");
assert.equal(projectPayload.agentControls?.reasoningEffortOverride, "medium");
assert.equal(projectPayload.agentControls?.backendOverride, "claw-runtime");
});
test("master-agent 对话控制按当前账号隔离,不会串到其他用户", async () => {
@@ -873,6 +883,36 @@ test("POST /agent-controls rejects unknown-key payload and preserves controls",
assert.equal(afterProject?.updatedAt, beforeUpdatedAt);
});
test("master-agent 对话控制 POST 会稳定拒绝非法 backendOverride", async () => {
await setup();
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const response = await postAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
method: "POST",
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: JSON.stringify({
backendOverride: "bad-backend",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 400);
const payload = (await response.json()) as { ok: boolean; message?: string };
assert.equal(payload.ok, false);
assert.equal(payload.message, "INVALID_BACKEND_OVERRIDE");
});
test("master-agent controls helper 不会写入普通项目", async () => {
await setup();

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
@@ -240,6 +240,99 @@ test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队
}
});
test("master-agent enqueue 在显式选择 claw-runtime 时会通过 Claw 异步回写回复", async () => {
const clawDir = await mkdtemp(path.join(os.tmpdir(), "boss-claw-queue-"));
const clawScriptPath = path.join(clawDir, "claw-runtime.mjs");
await writeFile(
clawScriptPath,
`
let stdin = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdin += chunk;
}
const payload = JSON.parse(stdin);
process.stdout.write(JSON.stringify({
status: "completed",
output: "Claw 已接管当前主 Agent 会话:" + payload.body
}));
`,
"utf8",
);
const previousEnv = {
BOSS_CLAW_ENABLED: process.env.BOSS_CLAW_ENABLED,
BOSS_CLAW_COMMAND: process.env.BOSS_CLAW_COMMAND,
BOSS_CLAW_ARGS: process.env.BOSS_CLAW_ARGS,
BOSS_CLAW_TIMEOUT_MS: process.env.BOSS_CLAW_TIMEOUT_MS,
};
process.env.BOSS_CLAW_ENABLED = "true";
process.env.BOSS_CLAW_COMMAND = process.execPath;
process.env.BOSS_CLAW_ARGS = clawScriptPath;
process.env.BOSS_CLAW_TIMEOUT_MS = "1000";
await saveAiAccount({
accountId: "master-codex-primary-claw",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "用于 Claw backend 队列测试。",
});
await updateProjectAgentControls("master-agent", {
backendOverride: "claw-runtime",
});
try {
const response = await POST(
await createAuthedRequest("master-agent", {
body: "请走 Claw runtime",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; status: string } | null;
masterReply?: { accountId?: string } | null;
masterReplyState?: string | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "claw-runtime");
assert.equal(payload.masterReplyState, "queued");
assert.ok(payload.task?.taskId);
await waitFor(async () => {
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
return task?.status === "completed";
});
const nextState = await readState();
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.equal(task?.status, "completed");
assert.equal(task?.replyBody, "Claw 已接管当前主 Agent 会话:请走 Claw runtime");
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
const mirroredReply = masterProject?.messages.at(-1);
assert.match(mirroredReply?.body ?? "", /Claw 已接管当前主 Agent 会话/);
} finally {
process.env.BOSS_CLAW_ENABLED = previousEnv.BOSS_CLAW_ENABLED;
process.env.BOSS_CLAW_COMMAND = previousEnv.BOSS_CLAW_COMMAND;
process.env.BOSS_CLAW_ARGS = previousEnv.BOSS_CLAW_ARGS;
process.env.BOSS_CLAW_TIMEOUT_MS = previousEnv.BOSS_CLAW_TIMEOUT_MS;
await rm(clawDir, { recursive: true, force: true });
}
});
test("master-agent enqueue 在首选主节点离线时会回退到可用的备用主节点并返回实际账号", async () => {
await saveAiAccount({
accountId: "master-codex-primary-offline",

View File

@@ -198,6 +198,7 @@ test("prompt-profile 写入当前对话提示词时按当前账号隔离", async
headers: memberRequest.headers,
body: JSON.stringify({
promptOverride: "成员自己的当前对话提示词",
backendOverride: "claw-runtime",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
@@ -208,8 +209,12 @@ test("prompt-profile 写入当前对话提示词时按当前账号隔离", async
ok: boolean;
projectPromptOverride: string | null;
account: string;
projectControls: {
backendOverride?: string | null;
} | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.account, "18800001111");
assert.equal(payload.projectPromptOverride, "成员自己的当前对话提示词");
assert.equal(payload.projectControls?.backendOverride, "claw-runtime");
});