feat: add claw backend adapter
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 或匹配登录会话
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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="当前对话附加提示词"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
103
src/lib/execution/backends/claw-backend.ts
Normal file
103
src/lib/execution/backends/claw-backend.ts
Normal 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;
|
||||
42
src/lib/execution/backends/claw-config.ts
Normal file
42
src/lib/execution/backends/claw-config.ts
Normal 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;
|
||||
118
src/lib/execution/backends/claw-runner.ts
Normal file
118
src/lib/execution/backends/claw-runner.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
65
tests/claw-backend-config.test.ts
Normal file
65
tests/claw-backend-config.test.ts
Normal 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
121
tests/claw-backend.test.ts
Normal 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
180
tests/claw-runner.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user