feat(FN-3548): add agent action routes and approval pause/resume lifecycle
Merges the FN-3548 approval pause/resume system: agents now stall at workflow gates pending approval, with a full lifecycle spanning gate context lookup, action-gate pause/retry, executor and heartbeat pause callbacks, and dedicated approval decision routes. Also lands FN-3744 agent action routes an Fusion-Task-Id: FN-3548
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
evaluateAgentActionGate,
|
||||
getExemptToolNames,
|
||||
reloadExemptTools,
|
||||
resolveGateOutcome,
|
||||
} from "../agent-action-gate.js";
|
||||
import type { AgentPermissionPolicy } from "@fusion/core";
|
||||
|
||||
@@ -215,6 +216,65 @@ describe("agent-action-gate", () => {
|
||||
expect(result.disposition).toBe("require-approval");
|
||||
});
|
||||
|
||||
it("resolveGateOutcome waits when there is no latest request", () => {
|
||||
const decision = evaluateAgentActionGate({
|
||||
agentId: "a1",
|
||||
toolName: "write",
|
||||
args: { path: "a.ts" },
|
||||
permissionPolicy: approvalPolicy,
|
||||
});
|
||||
expect(resolveGateOutcome(decision, null)).toEqual({ outcome: "wait-for-approval" });
|
||||
});
|
||||
|
||||
it("resolveGateOutcome reuses pending request", () => {
|
||||
const decision = evaluateAgentActionGate({
|
||||
agentId: "a1",
|
||||
toolName: "write",
|
||||
args: { path: "a.ts" },
|
||||
permissionPolicy: approvalPolicy,
|
||||
});
|
||||
expect(resolveGateOutcome(decision, { id: "apr-1", status: "pending" })).toEqual({
|
||||
outcome: "wait-for-approval",
|
||||
approvalRequestId: "apr-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveGateOutcome executes once on approved request", () => {
|
||||
const decision = evaluateAgentActionGate({
|
||||
agentId: "a1",
|
||||
toolName: "write",
|
||||
args: { path: "a.ts" },
|
||||
permissionPolicy: approvalPolicy,
|
||||
});
|
||||
expect(resolveGateOutcome(decision, { id: "apr-1", status: "approved" })).toEqual({
|
||||
outcome: "execute-once-then-complete",
|
||||
approvalRequestId: "apr-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveGateOutcome blocks denied request", () => {
|
||||
const decision = evaluateAgentActionGate({
|
||||
agentId: "a1",
|
||||
toolName: "write",
|
||||
args: { path: "a.ts" },
|
||||
permissionPolicy: approvalPolicy,
|
||||
});
|
||||
expect(resolveGateOutcome(decision, { id: "apr-1", status: "denied" })).toEqual({
|
||||
outcome: "block",
|
||||
approvalRequestId: "apr-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveGateOutcome requires new approval after completion", () => {
|
||||
const decision = evaluateAgentActionGate({
|
||||
agentId: "a1",
|
||||
toolName: "write",
|
||||
args: { path: "a.ts" },
|
||||
permissionPolicy: approvalPolicy,
|
||||
});
|
||||
expect(resolveGateOutcome(decision, { id: "apr-1", status: "completed" })).toEqual({ outcome: "wait-for-approval" });
|
||||
});
|
||||
|
||||
it("computes deterministic dedupe key", () => {
|
||||
const key = computeApprovalDedupeKey({
|
||||
agentId: "agent-1",
|
||||
|
||||
@@ -151,6 +151,42 @@ describe("buildExecutionPrompt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor action gate context", () => {
|
||||
it("pauses task and agent for approval and marks completion", async () => {
|
||||
const store = createMockStore();
|
||||
store.pauseTask = vi.fn().mockResolvedValue(undefined);
|
||||
store.logEntry = vi.fn().mockResolvedValue(undefined);
|
||||
const agentStore = {
|
||||
updateAgentState: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgent: vi.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
const executor = new TaskExecutor(store as any, "/tmp/project", { agentStore });
|
||||
(executor as any).currentRunContext = { runId: "run-1" };
|
||||
|
||||
const context = (executor as any).buildActionGateContext("FN-1", { id: "agent-1", name: "Agent One", permissionPolicy: undefined });
|
||||
|
||||
await context.pauseForApproval({
|
||||
approvalRequestId: "apr-1",
|
||||
decision: {
|
||||
disposition: "require-approval",
|
||||
category: "command_execution",
|
||||
toolName: "bash",
|
||||
operation: "git commit",
|
||||
summary: "bash: git commit",
|
||||
resourceType: "git",
|
||||
approvalDedupeKey: "dedupe-1",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.pauseTask).toHaveBeenCalledWith("FN-1", true, { runId: "run-1" }, { pausedByAgentId: "agent-1" });
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-1", "paused");
|
||||
expect(agentStore.updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: "awaiting-approval" });
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// ── Skill Selection Regression Tests (FN-1514) ──────────────────────────
|
||||
|
||||
describe("TaskExecutor skillSelection regression (FN-1511)", () => {
|
||||
|
||||
@@ -267,6 +267,59 @@ describe("executeHeartbeat", () => {
|
||||
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
|
||||
});
|
||||
|
||||
it("pauseForApproval pauses task and agent when taskId exists", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||
const pauseTask = vi.fn().mockResolvedValue(undefined);
|
||||
const logEntry = vi.fn().mockResolvedValue(undefined);
|
||||
mockTaskStore = createMockTaskStore({ pauseTask, logEntry });
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, "FN-001", "run-1");
|
||||
await ctx.pauseForApproval({
|
||||
approvalRequestId: "apr-1",
|
||||
decision: {
|
||||
disposition: "require-approval",
|
||||
category: "command_execution",
|
||||
toolName: "bash",
|
||||
operation: "git commit",
|
||||
summary: "bash: git commit",
|
||||
resourceType: "git",
|
||||
approvalDedupeKey: "dedupe-1",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(pauseTask).toHaveBeenCalledWith("FN-001", true, undefined, { pausedByAgentId: "agent-001" });
|
||||
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
|
||||
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
|
||||
});
|
||||
|
||||
it("pauseForApproval still pauses agent when taskId is undefined", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined });
|
||||
const pauseTask = vi.fn().mockResolvedValue(undefined);
|
||||
mockTaskStore = createMockTaskStore({ pauseTask });
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, undefined, "run-1");
|
||||
await ctx.pauseForApproval({
|
||||
approvalRequestId: "apr-1",
|
||||
decision: {
|
||||
disposition: "require-approval",
|
||||
category: "command_execution",
|
||||
toolName: "bash",
|
||||
operation: "git commit",
|
||||
summary: "bash: git commit",
|
||||
resourceType: "git",
|
||||
approvalDedupeKey: "dedupe-1",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(pauseTask).not.toHaveBeenCalled();
|
||||
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
|
||||
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
|
||||
});
|
||||
|
||||
it("omits permanent-agent gating context for ephemeral heartbeat agents", async () => {
|
||||
const store = createStoreWithAgentForExec({
|
||||
taskId: "FN-001",
|
||||
|
||||
@@ -532,6 +532,22 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
||||
});
|
||||
|
||||
describe("wrapToolsWithActionGate", () => {
|
||||
const lockedDownRules = {
|
||||
"git_write": "block",
|
||||
"file_write_delete": "block",
|
||||
"command_execution": "block",
|
||||
"network_api": "block",
|
||||
"task_agent_mutation": "block",
|
||||
} as const;
|
||||
|
||||
const approvalRules = {
|
||||
"git_write": "require-approval",
|
||||
"file_write_delete": "require-approval",
|
||||
"command_execution": "require-approval",
|
||||
"network_api": "require-approval",
|
||||
"task_agent_mutation": "require-approval",
|
||||
} as const;
|
||||
|
||||
it("blocks disallowed actions and skips underlying tool", async () => {
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
@@ -540,18 +556,9 @@ describe("wrapToolsWithActionGate", () => {
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: {
|
||||
presetId: "locked-down",
|
||||
rules: {
|
||||
"git_write": "block",
|
||||
"file_write_delete": "block",
|
||||
"command_execution": "block",
|
||||
"network_api": "block",
|
||||
"task_agent_mutation": "block",
|
||||
},
|
||||
},
|
||||
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
|
||||
createApprovalRequest: vi.fn(),
|
||||
findPendingApprovalByDedupeKey: vi.fn(),
|
||||
findApprovalByDedupeKey: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
@@ -566,51 +573,100 @@ describe("wrapToolsWithActionGate", () => {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: true,
|
||||
permissionPolicy: {
|
||||
presetId: "locked-down",
|
||||
rules: {
|
||||
"git_write": "block",
|
||||
"file_write_delete": "block",
|
||||
"command_execution": "block",
|
||||
"network_api": "block",
|
||||
"task_agent_mutation": "block",
|
||||
},
|
||||
},
|
||||
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
|
||||
createApprovalRequest: vi.fn(),
|
||||
findPendingApprovalByDedupeKey: vi.fn(),
|
||||
findApprovalByDedupeKey: vi.fn(),
|
||||
});
|
||||
|
||||
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
expect(tool.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates approval request once for require-approval", async () => {
|
||||
it("creates request once and pauses once while pending", async () => {
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
|
||||
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-1" });
|
||||
const findPendingApprovalByDedupeKey = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ id: "apr-1" });
|
||||
const pauseForApproval = vi.fn();
|
||||
const findApprovalByDedupeKey = vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: "apr-1", status: "pending" });
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: {
|
||||
presetId: "approval-required",
|
||||
rules: {
|
||||
"git_write": "require-approval",
|
||||
"file_write_delete": "require-approval",
|
||||
"command_execution": "require-approval",
|
||||
"network_api": "require-approval",
|
||||
"task_agent_mutation": "require-approval",
|
||||
},
|
||||
},
|
||||
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
|
||||
createApprovalRequest,
|
||||
findPendingApprovalByDedupeKey,
|
||||
findApprovalByDedupeKey,
|
||||
pauseForApproval,
|
||||
});
|
||||
|
||||
const first = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
const second = await (wrapped[0] as any).execute("t2", { path: "a.ts" });
|
||||
|
||||
expect((first as any).decision.metadata.approvalRequestId).toBe("apr-1");
|
||||
expect((second as any).decision.metadata.approvalRequestId).toBe("apr-1");
|
||||
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
||||
expect(pauseForApproval).toHaveBeenCalledTimes(1);
|
||||
expect(tool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes once and marks completed for approved retry", async () => {
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) };
|
||||
const markApprovalCompleted = vi.fn();
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
|
||||
createApprovalRequest: vi.fn(),
|
||||
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-2", status: "approved" }),
|
||||
markApprovalCompleted,
|
||||
});
|
||||
|
||||
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
await (wrapped[0] as any).execute("t2", { path: "a.ts" });
|
||||
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
||||
expect(tool.execute).toHaveBeenCalledTimes(1);
|
||||
expect(markApprovalCompleted).toHaveBeenCalledWith("apr-2");
|
||||
});
|
||||
|
||||
it("does not mark completed when approved execution throws", async () => {
|
||||
const error = new Error("write failed");
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn().mockRejectedValue(error) };
|
||||
const markApprovalCompleted = vi.fn();
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
|
||||
createApprovalRequest: vi.fn(),
|
||||
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-2", status: "approved" }),
|
||||
markApprovalCompleted,
|
||||
});
|
||||
|
||||
await expect((wrapped[0] as any).execute("t1", { path: "a.ts" })).rejects.toThrow("write failed");
|
||||
expect(markApprovalCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns rejection and never executes when latest decision is denied", async () => {
|
||||
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
|
||||
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
|
||||
createApprovalRequest: vi.fn(),
|
||||
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-3", status: "denied" }),
|
||||
});
|
||||
|
||||
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
|
||||
expect((result as any).isError).toBe(true);
|
||||
expect((result as any).error).toContain("denied by approver");
|
||||
expect(tool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -623,18 +679,9 @@ describe("wrapToolsWithActionGate", () => {
|
||||
agentName: "Agent",
|
||||
isEphemeral: false,
|
||||
taskId: "FN-1",
|
||||
permissionPolicy: {
|
||||
presetId: "locked-down",
|
||||
rules: {
|
||||
"git_write": "block",
|
||||
"file_write_delete": "block",
|
||||
"command_execution": "block",
|
||||
"network_api": "block",
|
||||
"task_agent_mutation": "block",
|
||||
},
|
||||
},
|
||||
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
|
||||
createApprovalRequest: vi.fn(),
|
||||
findPendingApprovalByDedupeKey: vi.fn(),
|
||||
findApprovalByDedupeKey: vi.fn(),
|
||||
});
|
||||
|
||||
await (wrapped[0] as any).execute("t1", {});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentPermissionPolicy,
|
||||
AgentPermissionPolicyActionCategory,
|
||||
AgentPermissionPolicyDisposition,
|
||||
ApprovalRequestStatus,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
ACTION_GATE_NETWORK_API_TOOLS,
|
||||
@@ -34,7 +35,11 @@ export interface AgentActionGateContext {
|
||||
runId?: string;
|
||||
permissionPolicy: AgentPermissionPolicy;
|
||||
createApprovalRequest: (decision: AgentActionGateDecision, args: Record<string, unknown>) => Promise<unknown>;
|
||||
findPendingApprovalByDedupeKey: (dedupeKey: string) => Promise<unknown | null>;
|
||||
findApprovalByDedupeKey?: (dedupeKey: string) => Promise<{ id: string; status: ApprovalRequestStatus } | null>;
|
||||
/** @deprecated Use findApprovalByDedupeKey */
|
||||
findPendingApprovalByDedupeKey?: (dedupeKey: string) => Promise<{ id: string } | null>;
|
||||
pauseForApproval?: (info: { approvalRequestId: string; decision: AgentActionGateDecision }) => Promise<void>;
|
||||
markApprovalCompleted?: (approvalRequestId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// FN-3724: Internal Fusion runtime/coordinator tools never perform external mutations.
|
||||
@@ -187,6 +192,31 @@ export function evaluateAgentActionGate(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGateOutcome(
|
||||
decision: AgentActionGateDecision,
|
||||
latestRequest: { id: string; status: ApprovalRequestStatus } | null,
|
||||
): { outcome: "allow" | "block" | "execute-once-then-complete" | "wait-for-approval"; approvalRequestId?: string } {
|
||||
if (decision.disposition === "allow") {
|
||||
return { outcome: "allow" };
|
||||
}
|
||||
if (decision.disposition === "block") {
|
||||
return { outcome: "block" };
|
||||
}
|
||||
if (!latestRequest) {
|
||||
return { outcome: "wait-for-approval" };
|
||||
}
|
||||
if (latestRequest.status === "pending") {
|
||||
return { outcome: "wait-for-approval", approvalRequestId: latestRequest.id };
|
||||
}
|
||||
if (latestRequest.status === "approved") {
|
||||
return { outcome: "execute-once-then-complete", approvalRequestId: latestRequest.id };
|
||||
}
|
||||
if (latestRequest.status === "denied") {
|
||||
return { outcome: "block", approvalRequestId: latestRequest.id };
|
||||
}
|
||||
return { outcome: "wait-for-approval" };
|
||||
}
|
||||
|
||||
export function buildGateRejection(decision: AgentActionGateDecision, reason: string) {
|
||||
return {
|
||||
content: [{ type: "text", text: reason }],
|
||||
|
||||
@@ -617,9 +617,30 @@ export class HeartbeatMonitor {
|
||||
context: { ...decision.metadata, approvalDedupeKey: decision.approvalDedupeKey, toolName: decision.toolName, toolArgs: args },
|
||||
},
|
||||
}),
|
||||
findApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const latest = this.getApprovalRequestStore().findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
|
||||
return latest ? { id: latest.id, status: latest.status } : null;
|
||||
},
|
||||
findPendingApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const pending = this.getApprovalRequestStore().list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
|
||||
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||
const latest = this.getApprovalRequestStore().findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
|
||||
return latest?.status === "pending" ? { id: latest.id } : null;
|
||||
},
|
||||
pauseForApproval: async ({ approvalRequestId, decision }) => {
|
||||
if (taskId && this.taskStore) {
|
||||
await this.taskStore.pauseTask(taskId, true, undefined, { pausedByAgentId: agent.id });
|
||||
await this.taskStore.logEntry(
|
||||
taskId,
|
||||
`Approval required for ${decision.toolName}. Request ${approvalRequestId} created; task and agent paused awaiting decision.`,
|
||||
);
|
||||
}
|
||||
await this.store.updateAgentState(agent.id, "paused");
|
||||
await this.store.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
|
||||
},
|
||||
markApprovalCompleted: async (approvalRequestId) => {
|
||||
await this.getApprovalRequestStore().markCompleted(approvalRequestId, {
|
||||
actor: { actorId: agent.id, actorType: "agent", actorName: agent.name },
|
||||
note: "Tool executed after approval",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -755,9 +755,34 @@ export class TaskExecutor {
|
||||
},
|
||||
},
|
||||
}),
|
||||
findApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
|
||||
return latest ? { id: latest.id, status: latest.status } : null;
|
||||
},
|
||||
findPendingApprovalByDedupeKey: async (dedupeKey) => {
|
||||
const pending = this.approvalRequestStore.list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
|
||||
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
|
||||
return latest?.status === "pending" ? { id: latest.id } : null;
|
||||
},
|
||||
pauseForApproval: async ({ approvalRequestId, decision }) => {
|
||||
if (taskId) {
|
||||
await this.store.pauseTask(taskId, true, this.currentRunContext, { pausedByAgentId: agent.id });
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Approval required for ${decision.toolName}. Request ${approvalRequestId} created; task and agent paused awaiting decision.`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
}
|
||||
if (this.options.agentStore) {
|
||||
await this.options.agentStore.updateAgentState(agent.id, "paused");
|
||||
await this.options.agentStore.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
|
||||
}
|
||||
},
|
||||
markApprovalCompleted: async (approvalRequestId) => {
|
||||
await this.approvalRequestStore.markCompleted(approvalRequestId, {
|
||||
actor: { actorId: agent.id, actorType: "agent", actorName: agent.name },
|
||||
note: "Tool executed after approval",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import { readCustomProviders } from "./custom-providers.js";
|
||||
import {
|
||||
buildGateRejection,
|
||||
evaluateAgentActionGate,
|
||||
resolveGateOutcome,
|
||||
type AgentActionGateContext,
|
||||
} from "./agent-action-gate.js";
|
||||
import { resolvePermanentAgentToolDecision } from "./permanent-agent-gating.js";
|
||||
@@ -1190,25 +1191,70 @@ export function wrapToolsWithActionGate(
|
||||
permissionPolicy: gateContext.permissionPolicy,
|
||||
});
|
||||
|
||||
if (decision.disposition === "allow") {
|
||||
const latestApproval = gateContext.findApprovalByDedupeKey
|
||||
? await gateContext.findApprovalByDedupeKey(decision.approvalDedupeKey)
|
||||
: await gateContext.findPendingApprovalByDedupeKey?.(decision.approvalDedupeKey).then((request) =>
|
||||
request ? { id: request.id, status: "pending" as const } : null
|
||||
);
|
||||
|
||||
const gateOutcome = resolveGateOutcome(decision, latestApproval ?? null);
|
||||
|
||||
if (gateOutcome.outcome === "allow") {
|
||||
return originalExecute(...args);
|
||||
}
|
||||
|
||||
if (decision.disposition === "block") {
|
||||
if (gateOutcome.outcome === "execute-once-then-complete") {
|
||||
try {
|
||||
const result = await originalExecute(...args);
|
||||
if (gateOutcome.approvalRequestId) {
|
||||
await gateContext.markApprovalCompleted?.(gateOutcome.approvalRequestId);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (gateOutcome.outcome === "block") {
|
||||
if (latestApproval?.status === "denied") {
|
||||
return buildGateRejection(
|
||||
{
|
||||
...decision,
|
||||
metadata: {
|
||||
...decision.metadata,
|
||||
approvalRequestId: latestApproval.id,
|
||||
dedupeKey: decision.approvalDedupeKey,
|
||||
},
|
||||
},
|
||||
"Action was denied by approver. The agent must not retry this action.",
|
||||
);
|
||||
}
|
||||
|
||||
return buildGateRejection(
|
||||
decision,
|
||||
`Action blocked by permission policy (${decision.category}) for ${gateContext.agentName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await gateContext.findPendingApprovalByDedupeKey(decision.approvalDedupeKey);
|
||||
if (!existing) {
|
||||
await gateContext.createApprovalRequest(decision, params);
|
||||
let approvalRequestId = gateOutcome.approvalRequestId;
|
||||
if (!approvalRequestId) {
|
||||
const created = await gateContext.createApprovalRequest(decision, params) as { id?: string } | null;
|
||||
approvalRequestId = created?.id;
|
||||
if (approvalRequestId) {
|
||||
await gateContext.pauseForApproval?.({ approvalRequestId, decision });
|
||||
}
|
||||
}
|
||||
|
||||
return buildGateRejection(
|
||||
decision,
|
||||
`Action requires approval (${decision.category}). Approval request queued.`,
|
||||
{
|
||||
...decision,
|
||||
metadata: {
|
||||
...decision.metadata,
|
||||
...(approvalRequestId ? { approvalRequestId } : {}),
|
||||
dedupeKey: decision.approvalDedupeKey,
|
||||
},
|
||||
},
|
||||
`Action requires approval (request ${approvalRequestId ?? "pending"}). Agent and task have been paused; will resume once a decision is made.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user