feat: add master agent chat controls

This commit is contained in:
kris
2026-03-31 19:30:26 +08:00
parent bc464905a5
commit e741952295
7 changed files with 1632 additions and 1 deletions

View File

@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import {
getProjectAgentControls,
hasPersistedProject,
updateProjectAgentControls,
} from "@/lib/boss-data";
const reasoningEffortValues = new Set(["low", "medium", "high"]);
export async function GET(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await context.params;
if (projectId !== "master-agent") {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
const projectExists = await hasPersistedProject(projectId);
if (!projectExists) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const controls = await getProjectAgentControls(projectId);
return NextResponse.json({ ok: true, controls });
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
if (projectId !== "master-agent") {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
if (session.role !== "highest_admin") {
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
const rawBody = await request.text().catch(() => "");
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 });
}
if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ ok: false, message: "INVALID_AGENT_CONTROLS_PAYLOAD" }, { status: 400 });
}
const payload = body as {
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
};
const hasModelOverride = Object.prototype.hasOwnProperty.call(payload, "modelOverride");
const hasReasoningEffortOverride = Object.prototype.hasOwnProperty.call(
payload,
"reasoningEffortOverride",
);
const allowedKeys = new Set(["modelOverride", "reasoningEffortOverride"]);
const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key));
if ((!hasModelOverride && !hasReasoningEffortOverride) || hasUnsupportedKeys) {
return NextResponse.json({ ok: false, message: "INVALID_AGENT_CONTROLS_PAYLOAD" }, { status: 400 });
}
if (hasModelOverride && payload.modelOverride !== undefined && payload.modelOverride !== null && typeof payload.modelOverride !== "string") {
return NextResponse.json({ ok: false, message: "INVALID_MODEL_OVERRIDE" }, { status: 400 });
}
if (hasReasoningEffortOverride && payload.reasoningEffortOverride !== undefined && payload.reasoningEffortOverride !== null && typeof payload.reasoningEffortOverride !== "string") {
return NextResponse.json(
{ ok: false, message: "INVALID_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (hasReasoningEffortOverride && typeof payload.reasoningEffortOverride === "string" && !reasoningEffortValues.has(payload.reasoningEffortOverride)) {
return NextResponse.json(
{ ok: false, message: "INVALID_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
try {
const controls = await updateProjectAgentControls(
projectId,
{
...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}),
...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}),
},
);
return NextResponse.json({ ok: true, controls: controls ?? null });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: error instanceof Error && error.message === "PROJECT_NOT_FOUND" ? 404 : 400 },
);
}
}

View File

@@ -19,5 +19,8 @@ export async function GET(
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json({ ok: true, ...detail });
return NextResponse.json({
ok: true,
...detail,
});
}

View File

@@ -143,6 +143,7 @@ export type DispatchPlanStatus =
| "rejected"
| "dispatched";
export type DispatchExecutionStatus = "queued" | "running" | "completed" | "failed";
export type ReasoningEffort = "low" | "medium" | "high";
export interface UserSettings {
liveUpdates: boolean;
@@ -266,6 +267,7 @@ export interface Project {
createdByAgent: boolean;
collaborationMode: "development" | "approval_required";
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
agentControls?: ProjectAgentControls;
unreadCount: number;
riskLevel: RiskLevel;
contextBudgetPct?: number;
@@ -314,6 +316,12 @@ export interface DispatchExecution {
completedByDeviceId?: string;
}
export interface ProjectAgentControls {
modelOverride?: string;
reasoningEffortOverride?: ReasoningEffort;
updatedAt: string;
}
export interface DeviceImportCandidate {
candidateId: string;
deviceId: string;
@@ -1613,6 +1621,27 @@ function trimToDefined(value?: string) {
return trimmed ? trimmed : undefined;
}
function parseControlTextOverride(value: unknown) {
if (value === undefined || value === null) {
return { kind: "clear" as const };
}
if (typeof value !== "string") {
return { kind: "invalid" as const };
}
const trimmed = value.trim();
return trimmed ? { kind: "set" as const, value: trimmed } : { kind: "clear" as const };
}
function parseReasoningEffortOverride(value: unknown) {
if (value === undefined || value === null) {
return { kind: "clear" as const };
}
if (!isReasoningEffort(value)) {
return { kind: "invalid" as const };
}
return { kind: "set" as const, value };
}
function normalizeStringSet(values: string[]) {
return dedupeStrings(values.map((value) => value.trim()).filter(Boolean)).sort((a, b) => a.localeCompare(b));
}
@@ -2034,6 +2063,29 @@ function normalizeProjectConversationShape(
return project;
}
function normalizeProjectAgentControls(
raw: Partial<ProjectAgentControls> | undefined,
): ProjectAgentControls | undefined {
const modelOverride = trimToDefined(raw?.modelOverride);
const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride)
? raw.reasoningEffortOverride
: undefined;
if (!modelOverride && !reasoningEffortOverride) {
return undefined;
}
return {
modelOverride,
reasoningEffortOverride,
updatedAt: raw?.updatedAt ?? nowIso(),
};
}
function isReasoningEffort(value: unknown): value is ReasoningEffort {
return value === "low" || value === "medium" || value === "high";
}
function resolveProjectUpdatedAt(project: Pick<Project, "updatedAt" | "lastMessageAt" | "threadMeta">, latestActivityAt?: string) {
return latestIsoTimestamp(
project.updatedAt,
@@ -2490,6 +2542,7 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
createdByAgent: raw.createdByAgent ?? false,
collaborationMode: raw.collaborationMode ?? "development",
approvalState: raw.approvalState ?? "not_required",
agentControls: normalizeProjectAgentControls(raw.agentControls),
};
project.groupMembers = ensureArray(raw.groupMembers, []).map((member) =>
normalizeGroupMember(member, projectId, project.threadMeta),
@@ -3284,11 +3337,126 @@ async function mutateState<T>(mutator: (state: BossState) => Promise<T> | T) {
return result;
}
async function mutateStateIfChanged<T>(
mutator: (state: BossState) => Promise<{ result: T; changed: boolean }> | { result: T; changed: boolean },
) {
let result!: T;
const run = async () => {
const state = await readState();
const outcome = await mutator(state);
result = outcome.result;
if (outcome.changed) {
await writeState(state);
}
};
stateMutationQueue = stateMutationQueue.then(run, run);
await stateMutationQueue;
return result;
}
async function loadPersistedStateRaw() {
await ensureStateFile();
const parseStateText = (text: string) => JSON.parse(text) as Partial<BossState>;
const tryRead = async (filePath: string) => {
const text = await fs.readFile(filePath, "utf8");
return parseStateText(text);
};
try {
return await tryRead(dataFile);
} catch {
try {
return await tryRead(backupFile);
} catch {
if (lastPersistedStateText) {
return parseStateText(lastPersistedStateText);
}
return JSON.parse(JSON.stringify(syncDerivedState(cloneInitialState()))) as Partial<BossState>;
}
}
}
export async function getProject(projectId: string) {
const state = await readState();
return state.projects.find((project) => project.id === projectId) ?? null;
}
export async function hasPersistedProject(projectId: string) {
const rawState = await loadPersistedStateRaw();
return Array.isArray(rawState.projects) && rawState.projects.some((project) => project?.id === projectId);
}
export async function getProjectAgentControls(projectId: string) {
if (projectId !== "master-agent") {
return null;
}
const state = await readState();
return state.projects.find((project) => project.id === projectId)?.agentControls ?? null;
}
export async function updateProjectAgentControls(
projectId: string,
payload: {
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
},
) {
if (projectId !== "master-agent") {
throw new Error("MASTER_AGENT_CONTROLS_SCOPE_RESTRICTED");
}
const modelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "modelOverride")
? parseControlTextOverride(payload.modelOverride)
: { kind: "preserve" as const };
const reasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "reasoningEffortOverride")
? parseReasoningEffortOverride(payload.reasoningEffortOverride)
: { kind: "preserve" as const };
if (modelOverrideInput.kind === "invalid") {
throw new Error("INVALID_MODEL_OVERRIDE");
}
if (reasoningEffortInput.kind === "invalid") {
throw new Error("INVALID_REASONING_EFFORT_OVERRIDE");
}
return mutateStateIfChanged((state) => {
const project = state.projects.find((item) => item.id === projectId);
if (!project) throw new Error("PROJECT_NOT_FOUND");
const currentControls = project.agentControls;
const modelOverride =
modelOverrideInput.kind === "set"
? modelOverrideInput.value
: modelOverrideInput.kind === "clear"
? undefined
: currentControls?.modelOverride;
const reasoningEffortOverride =
reasoningEffortInput.kind === "set"
? reasoningEffortInput.value
: reasoningEffortInput.kind === "clear"
? undefined
: currentControls?.reasoningEffortOverride;
const currentModelOverride = currentControls?.modelOverride;
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
if (currentModelOverride === modelOverride && currentReasoningEffortOverride === reasoningEffortOverride) {
return { result: currentControls, changed: false };
}
const nextControls = {
modelOverride,
reasoningEffortOverride,
updatedAt: nowIso(),
} satisfies ProjectAgentControls;
project.agentControls = normalizeProjectAgentControls(nextControls);
project.threadMeta.updatedAt = nextControls.updatedAt;
project.updatedAt = nextControls.updatedAt;
return { result: project.agentControls, changed: true };
});
}
export async function getDevice(deviceId: string) {
const state = await readState();
return state.devices.find((device) => device.id === deviceId) ?? null;

View File

@@ -18,6 +18,7 @@ import type {
OpsRepairTicket,
OpsRepairVerification,
Project,
ProjectAgentControls,
RiskLevel,
ThreadContextAlert,
ThreadContextSnapshot,
@@ -75,6 +76,7 @@ export interface ThreadContextView {
export interface ProjectDetailView {
project: Project;
agentControls?: ProjectAgentControls | null;
devices: Device[];
masterIdentity?: MasterIdentitySummary;
activeThreadContexts: ThreadContextView[];
@@ -542,6 +544,7 @@ export function getProjectDetailView(state: BossState, projectId: string): Proje
return {
project,
agentControls: project.id === "master-agent" ? project.agentControls ?? null : undefined,
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
activeThreadContexts,