refactor: extract execution permission policy

This commit is contained in:
kris
2026-04-02 22:46:41 +08:00
parent 384dd570de
commit a3a4f3e980
8 changed files with 204 additions and 39 deletions

View File

@@ -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

View File

@@ -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({

View File

@@ -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,

View 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;

View 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;
}

View File

@@ -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",

View 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);
});

View File

@@ -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",