fix: require plan approval before complex edits

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-05-01 17:53:57 -05:00
parent 6d313cfdc4
commit 3e912159e1
6 changed files with 99 additions and 38 deletions

View File

@ -9,6 +9,7 @@ import { toolRegistry } from "@/agent/tools/registry";
import "@/agent/tools";
import { useChatStore } from "@/stores/chat-store";
import { useAgentStore } from "@/stores/agent-store";
import { usePlanStore } from "@/stores/plan-store";
const MAX_ITERATIONS = 20;
@ -33,12 +34,6 @@ const WRITE_TOOLS = new Set([
"update_keyframe_curve",
]);
const PLAN_ONLY_TOOLS = new Set([
"submit_plan",
"ask_user",
"request_plan_approval",
]);
const EXECUTE_ONLY_TOOLS = new Set(["update_plan_step"]);
interface APIResponse {
@ -221,10 +216,25 @@ async function resolveToolCalls(
): Promise<ToolResult[]> {
const agentStore = useAgentStore.getState();
const results: ToolResult[] = [];
const currentMode = useAgentStore.getState().mode;
for (const tc of toolCalls) {
useAgentStore.getState().setActiveTool(tc.name);
const currentMode = useAgentStore.getState().mode;
const currentPlan = usePlanStore.getState().plan;
if (
WRITE_TOOLS.has(tc.name) &&
currentPlan?.status === "awaiting_approval"
) {
results.push({
toolCallId: tc.id,
name: tc.name,
result: null,
error:
"Cannot edit while a plan is awaiting user approval. Call request_plan_approval and wait for the user to choose Go edit.",
});
continue;
}
if (currentMode === "plan" && WRITE_TOOLS.has(tc.name)) {
results.push({
@ -246,16 +256,6 @@ async function resolveToolCalls(
continue;
}
if (currentMode === "execute" && PLAN_ONLY_TOOLS.has(tc.name)) {
results.push({
toolCallId: tc.id,
name: tc.name,
result: null,
error: `Cannot use '${tc.name}' in execute mode. This tool is only available during planning.`,
});
continue;
}
const currentPermissionMode = useAgentStore.getState().permissionMode;
if (currentPermissionMode === "ask" && WRITE_TOOLS.has(tc.name)) {
const approved = await requestApproval(tc);

View File

@ -21,6 +21,7 @@ You are in PLAN mode. You CANNOT make any edits to the timeline. You can only:
2. **Discover**: If the user's request matches a known editing pattern, call list_skills to find relevant techniques.
3. **Clarify**: If anything is ambiguous, use ask_user to get clarification before planning.
4. **Plan**: Once you understand the footage and the goal, call submit_plan with a structured step-by-step plan.
5. **Approval**: Immediately after submit_plan succeeds, call request_plan_approval. Do NOT edit until the user approves.
## PLAN QUALITY RULES:
@ -37,13 +38,24 @@ Submit the plan when:
- All clarifying questions have been answered
- You have a concrete, actionable plan
After submitting, the user will review and either approve (switching to execute mode) or request changes.
After submitting, you MUST ask for approval through request_plan_approval. The user will choose Keep planning or Go edit.
`;
const EXECUTE_MODE_INSTRUCTIONS = `
## CURRENT MODE: EXECUTE
You are in EXECUTE mode. You can read AND write to the timeline. Follow the approved plan step by step.
You are in EXECUTE mode. You can read AND write to the timeline, but complex editing requests still require a plan first.
## MANDATORY PLANNING GATE
For any complex editing request that would require multiple timeline edits (cuts, moving media, adding titles/effects, or touching 3+ tools), you MUST:
1. Analyze the project with read-only tools.
2. Call submit_plan with a concise structured checklist.
3. Call request_plan_approval.
4. Wait for the user to choose Go edit.
Do NOT perform write tools for complex edits before approval. This is required even if the current mode is EXECUTE.
## EXECUTION RULES:
@ -55,7 +67,7 @@ You are in EXECUTE mode. You can read AND write to the timeline. Follow the appr
## IF NO ACTIVE PLAN:
Execute the user's request directly. For complex edits (3+ tool calls), consider using submit_plan first to get user approval.
Simple one-shot edits may execute directly. Complex edits must go through submit_plan + request_plan_approval first.
`;
export function buildSystemPrompt(

View File

@ -18,12 +18,8 @@ const requestPlanApprovalTool: ToolDefinition = {
};
}
const agentStore = useAgentStore.getState();
if (agentStore.mode !== "plan") {
return { error: "Already in execute mode. No transition needed." };
}
planStore.updatePlanStatus("approved");
useAgentStore.getState().setMode("plan");
planStore.updatePlanStatus("awaiting_approval");
return new Promise((resolve) => {
useAgentStore.getState().setPendingModeTransition({
@ -32,6 +28,10 @@ const requestPlanApprovalTool: ToolDefinition = {
useAgentStore.getState().setPendingModeTransition(null);
if (approved) {
useAgentStore.getState().setMode("execute");
usePlanStore.getState().updatePlanStatus("executing");
} else {
useAgentStore.getState().setMode("plan");
usePlanStore.getState().updatePlanStatus("awaiting_approval");
}
resolve({
approved,

View File

@ -308,7 +308,7 @@ export const loadSkillSchema: ToolSchema = {
export const submitPlanSchema: ToolSchema = {
name: "submit_plan",
description:
"Submits a structured editing plan with steps. Use ONLY in plan mode after analyzing the footage and understanding the user's request. Each step should describe a specific action with the tools to use. After submission, the user reviews and approves or requests changes before execution begins.",
"Submits a structured editing plan with steps. Use this before complex edits, even from execute mode. Each step should describe a specific action with the tools to use. After submission, call request_plan_approval and wait for the user before any write tools.",
parameters: [
{ key: "summary", type: "string", required: true },
{ key: "steps", type: "array", required: true },
@ -319,7 +319,7 @@ export const submitPlanSchema: ToolSchema = {
export const askUserSchema: ToolSchema = {
name: "ask_user",
description:
"Asks the user a question during plan mode. Use when you need clarification about their intent, preferences, or content details before finalizing the plan. Optionally provide quick-reply options for common answers. The user can also type a free-form response.",
"Asks the user a question before or during planning. Use when you need clarification about their intent, preferences, or content details before finalizing the plan. Optionally provide quick-reply options for common answers. The user can also type a free-form response.",
parameters: [
{ key: "question", type: "string", required: true },
{ key: "options", type: "array", required: false },
@ -329,7 +329,7 @@ export const askUserSchema: ToolSchema = {
export const requestPlanApprovalSchema: ToolSchema = {
name: "request_plan_approval",
description:
"Requests user approval to switch from plan mode to execute mode. The user sees a modal with 'Continue planning' and 'Start editing' options. ALWAYS requires explicit user approval — there is no auto-approve. Use this when the plan is ready for execution.",
"Requests user approval to switch from planning to editing. The user sees a modal with Keep planning and Go edit options. ALWAYS requires explicit user approval — there is no auto-approve. Use immediately after submit_plan succeeds.",
parameters: [],
};

View File

@ -2,16 +2,26 @@ import type { AgentContext, ToolDefinition } from "@/agent/types";
import { toolRegistry } from "@/agent/tools/registry";
import { submitPlanSchema } from "@/agent/tools/schemas";
import { usePlanStore, createPlanFromSteps } from "@/stores/plan-store";
import { useAgentStore } from "@/stores/agent-store";
type SubmittedStep = { description: string; tools: string[] };
const submitPlanTool: ToolDefinition = {
...submitPlanSchema,
execute: async (
args: Record<string, unknown>,
_context: AgentContext,
): Promise<{ planId: string; stepCount: number } | { error: string }> => {
): Promise<
| {
planId: string;
stepCount: number;
nextAction: "request_plan_approval";
}
| { error: string }
> => {
const { summary, steps, questions } = args as {
summary: string;
steps: Array<{ description: string; tools: string[] }>;
steps: unknown;
questions?: string[];
};
@ -19,11 +29,14 @@ const submitPlanTool: ToolDefinition = {
return { error: "summary is required and must be a string" };
}
if (!Array.isArray(steps) || steps.length === 0) {
return { error: "steps must be a non-empty array" };
const normalizedSteps = normalizeSteps(steps);
if (normalizedSteps.length === 0) {
return {
error: "steps must be a non-empty array or a numbered list string",
};
}
for (const [i, step] of steps.entries()) {
for (const [i, step] of normalizedSteps.entries()) {
if (!step.description || typeof step.description !== "string") {
return { error: `Step ${i + 1} must have a description string` };
}
@ -32,11 +45,47 @@ const submitPlanTool: ToolDefinition = {
}
}
const plan = createPlanFromSteps(summary, steps, questions);
const plan = createPlanFromSteps(summary, normalizedSteps, questions);
usePlanStore.getState().setPlan(plan);
useAgentStore.getState().setMode("plan");
return { planId: plan.id, stepCount: plan.steps.length };
return {
planId: plan.id,
stepCount: plan.steps.length,
nextAction: "request_plan_approval",
};
},
};
toolRegistry.register(submitPlanSchema.name, submitPlanTool);
function normalizeSteps(steps: unknown): SubmittedStep[] {
if (Array.isArray(steps)) {
return steps
.map((step) => {
if (typeof step === "string") {
return { description: step, tools: [] };
}
if (typeof step !== "object" || step === null) return null;
const raw = step as { description?: unknown; tools?: unknown };
return {
description:
typeof raw.description === "string" ? raw.description : "",
tools: Array.isArray(raw.tools)
? raw.tools.filter(
(tool): tool is string => typeof tool === "string",
)
: [],
};
})
.filter((step): step is SubmittedStep => step !== null);
}
if (typeof steps !== "string") return [];
return steps
.split("\n")
.map((line) => line.replace(/^\s*\d+[.)-]?\s*/, "").trim())
.filter(Boolean)
.map((description) => ({ description, tools: [] }));
}

View File

@ -82,7 +82,7 @@ export function ModeTransitionModal() {
className="mr-2 size-4"
strokeWidth={1.5}
/>
Start editing
Go edit
</Button>
<Button
variant="outline"
@ -95,7 +95,7 @@ export function ModeTransitionModal() {
className="mr-2 size-4"
strokeWidth={1.5}
/>
Continue planning
Keep planning
</Button>
</div>
</div>