refactor: extract execution permission policy
This commit is contained in:
@@ -500,6 +500,11 @@ export function listExecutionTools() {
|
||||
}
|
||||
```
|
||||
|
||||
如果在落地时发现当前生产审批流需要把“先生成推荐、再等待确认”的动作显式建模,允许在同一个 `tool-registry.ts` 中补一个 `group_dispatch_plan` 工具定义,并同步把 `PermissionPolicy` 和测试调整为**以真实运行时语义为准**。约束如下:
|
||||
- `approval_required` 群聊不能再暴露与 `canDispatchDirectly=false` 矛盾的 `dispatch_execution` 直接权限;
|
||||
- `toolPolicy` 必须能清楚表达“当前允许先生成推荐,但不允许直接跨线程执行”的状态;
|
||||
- 任何新增工具定义都必须只服务于现有审批主链,不提前引入 Task 4+ 的执行后端能力。
|
||||
|
||||
在 `/Users/kris/code/boss/src/app/api/v1/projects/[projectId]/messages/route.ts` 中,把 `approval_required + pending plan` 判断替换成:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage, readState } from "@/lib/boss-data";
|
||||
import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data";
|
||||
import {
|
||||
queueGroupDispatchPlan,
|
||||
queueThreadConversationReplyTask,
|
||||
replyToMasterAgentUserMessage,
|
||||
} from "@/lib/boss-master-agent";
|
||||
|
||||
function buildCollaborationGate(project?: {
|
||||
isGroup: boolean;
|
||||
collaborationMode: "development" | "approval_required";
|
||||
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
|
||||
}) {
|
||||
return project
|
||||
? {
|
||||
isGroup: project.isGroup,
|
||||
collaborationMode: project.collaborationMode,
|
||||
requiresMasterAgentApproval: project.isGroup && project.collaborationMode === "approval_required",
|
||||
approvalState: project.approvalState,
|
||||
}
|
||||
: {
|
||||
isGroup: false,
|
||||
collaborationMode: "development" as const,
|
||||
requiresMasterAgentApproval: false,
|
||||
approvalState: "not_required" as const,
|
||||
};
|
||||
}
|
||||
import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy";
|
||||
|
||||
function dispatchFailureNotice(error?: string) {
|
||||
switch (error) {
|
||||
@@ -61,23 +42,28 @@ export async function POST(
|
||||
(body.kind ?? "text") === "text" &&
|
||||
(body.body ?? "").trim().length > 0;
|
||||
|
||||
if (shouldCreateDispatchPlan && project.collaborationMode === "approval_required") {
|
||||
const pendingPlan = [...state.dispatchPlans]
|
||||
.filter(
|
||||
(plan) => plan.groupProjectId === projectId && plan.status === "pending_user_confirmation",
|
||||
)
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
||||
if (pendingPlan) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message: "当前还有一条主 Agent 推荐等待你确认,请先确认或拒绝后再继续发送新指令。",
|
||||
pendingPlan,
|
||||
collaborationGate: buildCollaborationGate(project),
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const pendingPlan = shouldCreateDispatchPlan
|
||||
? [...state.dispatchPlans]
|
||||
.filter(
|
||||
(plan) => plan.groupProjectId === projectId && plan.status === "pending_user_confirmation",
|
||||
)
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0]
|
||||
: null;
|
||||
const permission = evaluatePermissionPolicy({
|
||||
project,
|
||||
hasPendingDispatchPlan: Boolean(pendingPlan),
|
||||
});
|
||||
|
||||
if (!permission.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message: permission.reason,
|
||||
pendingPlan,
|
||||
collaborationGate: buildCollaborationGate(project),
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const message = await appendProjectMessage({
|
||||
|
||||
@@ -354,7 +354,18 @@ export interface DispatchExecution {
|
||||
completedByDeviceId?: string;
|
||||
}
|
||||
|
||||
function buildCollaborationGate(project: Pick<Project, "isGroup" | "collaborationMode" | "approvalState">) {
|
||||
export function buildCollaborationGate(
|
||||
project?: Pick<Project, "isGroup" | "collaborationMode" | "approvalState">,
|
||||
) {
|
||||
if (!project) {
|
||||
return {
|
||||
isGroup: false,
|
||||
collaborationMode: "development" as const,
|
||||
requiresMasterAgentApproval: false,
|
||||
approvalState: "not_required" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isGroup: project.isGroup,
|
||||
collaborationMode: project.collaborationMode,
|
||||
|
||||
104
src/lib/execution/permission-policy.ts
Normal file
104
src/lib/execution/permission-policy.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { listExecutionTools, type ExecutionToolName } from "@/lib/execution/tool-registry";
|
||||
|
||||
type CollaborationMode = "development" | "approval_required";
|
||||
|
||||
type ApprovalState = "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
|
||||
|
||||
type PermissionPolicyProject = {
|
||||
id: string;
|
||||
isGroup: boolean;
|
||||
collaborationMode: CollaborationMode;
|
||||
approvalState: ApprovalState;
|
||||
};
|
||||
|
||||
type ToolPolicy = {
|
||||
allowedTools: ExecutionToolName[];
|
||||
deniedTools: ExecutionToolName[];
|
||||
};
|
||||
|
||||
type CollaborationPolicy = {
|
||||
mode: CollaborationMode;
|
||||
canDispatchDirectly: boolean;
|
||||
canCrossThreadTalk: boolean;
|
||||
};
|
||||
|
||||
type PermissionPolicyResult = {
|
||||
allowed: boolean;
|
||||
requiresApproval: boolean;
|
||||
reason?: string;
|
||||
toolPolicy: ToolPolicy;
|
||||
collaborationPolicy: CollaborationPolicy;
|
||||
};
|
||||
|
||||
const DEFAULT_APPROVAL_REQUIRED_REASON =
|
||||
"当前还有一条主 Agent 推荐等待确认,请先确认或拒绝后再继续发送新指令。";
|
||||
|
||||
const EMPTY_TOOL_POLICY: ToolPolicy = {
|
||||
allowedTools: [],
|
||||
deniedTools: [],
|
||||
};
|
||||
|
||||
function listDefaultAllowedTools(): ExecutionToolName[] {
|
||||
return listExecutionTools()
|
||||
.filter((tool) => tool.kind === "execution")
|
||||
.map((tool) => tool.name);
|
||||
}
|
||||
|
||||
function buildCollaborationPolicy(mode: CollaborationMode): CollaborationPolicy {
|
||||
return {
|
||||
mode,
|
||||
canDispatchDirectly: mode === "development",
|
||||
canCrossThreadTalk: mode === "development",
|
||||
};
|
||||
}
|
||||
|
||||
function buildAllowedPolicy(mode: CollaborationMode, requiresApproval: boolean): PermissionPolicyResult {
|
||||
return {
|
||||
allowed: true,
|
||||
requiresApproval,
|
||||
toolPolicy: {
|
||||
allowedTools:
|
||||
mode === "approval_required"
|
||||
? ["group_dispatch_plan"]
|
||||
: listDefaultAllowedTools(),
|
||||
deniedTools: [],
|
||||
},
|
||||
collaborationPolicy: buildCollaborationPolicy(mode),
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluatePermissionPolicy(input: {
|
||||
project?: PermissionPolicyProject;
|
||||
hasPendingDispatchPlan?: boolean;
|
||||
}): PermissionPolicyResult {
|
||||
const project = input.project;
|
||||
|
||||
if (!project) {
|
||||
return {
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
toolPolicy: EMPTY_TOOL_POLICY,
|
||||
collaborationPolicy: buildCollaborationPolicy("development"),
|
||||
};
|
||||
}
|
||||
|
||||
if (project.isGroup && project.collaborationMode === "approval_required" && input.hasPendingDispatchPlan) {
|
||||
return {
|
||||
allowed: false,
|
||||
requiresApproval: true,
|
||||
reason: DEFAULT_APPROVAL_REQUIRED_REASON,
|
||||
toolPolicy: {
|
||||
allowedTools: ["group_dispatch_plan"],
|
||||
deniedTools: ["dispatch_execution"],
|
||||
},
|
||||
collaborationPolicy: buildCollaborationPolicy("approval_required"),
|
||||
};
|
||||
}
|
||||
|
||||
return buildAllowedPolicy(
|
||||
project.collaborationMode,
|
||||
project.isGroup && project.collaborationMode === "approval_required",
|
||||
);
|
||||
}
|
||||
|
||||
export const evaluatePermissionPolicyForTesting = evaluatePermissionPolicy;
|
||||
23
src/lib/execution/tool-registry.ts
Normal file
23
src/lib/execution/tool-registry.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type ExecutionToolKind = "execution" | "analysis";
|
||||
|
||||
export type ExecutionToolName =
|
||||
| "conversation_reply"
|
||||
| "group_dispatch_plan"
|
||||
| "dispatch_execution"
|
||||
| "attachment_analysis";
|
||||
|
||||
export interface ExecutionToolDefinition {
|
||||
name: ExecutionToolName;
|
||||
kind: ExecutionToolKind;
|
||||
}
|
||||
|
||||
const EXECUTION_TOOLS: readonly ExecutionToolDefinition[] = [
|
||||
{ name: "conversation_reply", kind: "execution" },
|
||||
{ name: "group_dispatch_plan", kind: "execution" },
|
||||
{ name: "dispatch_execution", kind: "execution" },
|
||||
{ name: "attachment_analysis", kind: "analysis" },
|
||||
] as const;
|
||||
|
||||
export function listExecutionTools() {
|
||||
return EXECUTION_TOOLS;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDisp
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) {
|
||||
@@ -47,6 +48,7 @@ async function setup() {
|
||||
isDispatchableThreadProject = data.isDispatchableThreadProject;
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
baseState = structuredClone(await readState());
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
@@ -56,6 +58,11 @@ test.after(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await writeState(structuredClone(baseState));
|
||||
});
|
||||
|
||||
async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
|
||||
22
tests/execution-permission-policy.test.ts
Normal file
22
tests/execution-permission-policy.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { evaluatePermissionPolicyForTesting } from "@/lib/execution/permission-policy";
|
||||
|
||||
test("approval_required 群聊在已有待确认推荐时拒绝继续直接执行", () => {
|
||||
const result = evaluatePermissionPolicyForTesting({
|
||||
project: {
|
||||
id: "group-1",
|
||||
isGroup: true,
|
||||
collaborationMode: "approval_required",
|
||||
approvalState: "pending_user",
|
||||
},
|
||||
hasPendingDispatchPlan: true,
|
||||
});
|
||||
|
||||
assert.equal(result.allowed, false);
|
||||
assert.equal(result.requiresApproval, true);
|
||||
assert.match(result.reason ?? "", /等待确认/);
|
||||
assert.deepEqual(result.toolPolicy.allowedTools, ["group_dispatch_plan"]);
|
||||
assert.deepEqual(result.toolPolicy.deniedTools, ["dispatch_execution"]);
|
||||
assert.equal(result.collaborationPolicy.canDispatchDirectly, false);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ let createIndependentGroupChat: (typeof import("../src/lib/boss-data"))["createI
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let AUTH_SESSION_COOKIE: string;
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) {
|
||||
@@ -33,6 +34,7 @@ async function setup() {
|
||||
createIndependentGroupChat = data.createIndependentGroupChat;
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
baseState = structuredClone(await readState());
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
@@ -42,6 +44,11 @@ test.after(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await writeState(structuredClone(baseState));
|
||||
});
|
||||
|
||||
async function createAuthedRequest(projectId: string, body: { body: string; kind?: string }) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
|
||||
Reference in New Issue
Block a user