feat: add master agent chat controls
This commit is contained in:
108
src/app/api/v1/projects/[projectId]/agent-controls/route.ts
Normal file
108
src/app/api/v1/projects/[projectId]/agent-controls/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user