src / tools / planTool.ts
src / tools / planTool.ts
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { errorResult, okResult } from "../core/result";
import type { ApprovalRecord } from "../policy/approvalStore";
import { modeFacts } from "../policy/gateRuntime";
import { renderPlanMarkdown, validatePlan, type PlanDocument } from "../policy/planDocument";
import type { PluginRuntime } from "../runtime";
const stepKind = z.enum(["edit", "command", "vcs", "research", "other"]);
/** Bulky plan content the compressor may drop: the rendered markdown and the step list (the first ten steps stay in facts). */
const PLAN_OMIT = ["data.markdown", "data.plan.steps"];
function planData(runtime: PluginRuntime, record: ApprovalRecord) {
const plan = record.plan as PlanDocument;
const markdown = renderPlanMarkdown(plan, record.id);
return {
planId: record.id,
status: record.status,
revision: plan.revision,
...(plan.supersedes ? { supersedes: plan.supersedes } : {}),
title: plan.title,
plan,
planPath: runtime.approvals.planPath(record.id),
markdown,
decidedAt: record.decidedAt,
completedAt: record.completedAt,
};
}
function planArtifact(runtime: PluginRuntime, record: ApprovalRecord, markdown: string): ArtifactRef {
return {
kind: "text",
path: runtime.approvals.planPath(record.id),
sha256: sha256Text(markdown),
bytes: Buffer.byteLength(markdown),
description: `Plan ${record.id}: ${record.title}`,
};
}
export function createPlanTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_plan",
description:
"The plan the user approves before mutating tools run. Actions: propose, show, current, complete, list. Only needed in Plan mode; Manual needs an /accept per call. After propose, restate the plan verbatim and stop; the user replies /accept <plan id> or /deny. Once approved, do not propose again unless the plan itself must change: a new proposal supersedes it and locks mutating tools again. Call complete when every step is done.",
parameters: {
action: z.enum(["propose", "show", "current", "complete", "list"]),
title: z.string().optional(),
objective: z.string().optional(),
steps: z
.array(z.union([z.string(), z.object({ text: z.string(), kind: stepKind.optional() })]))
.optional(),
files: z
.array(z.object({ path: z.string(), change: z.enum(["create", "modify", "delete", "move"]) }))
.optional(),
commands: z.array(z.object({ executable: z.string(), args: z.array(z.string()).optional() })).optional(),
risks: z.array(z.string()).optional(),
questions: z.array(z.string()).optional(),
plan_id: z.string().optional(),
summary: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (
input: {
action: "propose" | "show" | "current" | "complete" | "list";
title?: string;
objective?: string;
steps?: Array<string | { text: string; kind?: PlanDocument["steps"][number]["kind"] }>;
files?: PlanDocument["files"];
commands?: Array<{ executable: string; args?: string[] }>;
risks?: string[];
questions?: string[];
plan_id?: string;
summary?: string;
limit?: number;
},
ctx: ToolCallContext,
) => {
try {
if (input.action === "propose") {
if (!input.title || !input.objective || !input.steps?.length) {
throw new AgenticError("INVALID_INPUT", "workspace_plan propose requires title, objective and steps.");
}
const prior = (await runtime.approvals.pendingPlan()) ?? (await runtime.approvals.activePlan());
const plan = validatePlan(
{
title: input.title,
objective: input.objective,
steps: input.steps,
files: input.files,
commands: input.commands,
risks: input.risks,
questions: input.questions,
},
prior ? { revision: ((prior.plan as PlanDocument).revision ?? 1) + 1, supersedes: prior.id } : {},
);
const record = await runtime.approvals.proposePlan(plan);
const data = planData(runtime, record);
ctx.warn(`Plan ${record.id} awaits approval — reply /accept ${record.id} or /deny ${record.id} <reason>.`);
// Mirrors renderDecisionMessage: in Manual mode the gate stages every
// call regardless of the plan, so "stay locked until /accept" would
// promise an unlock that never comes.
const manual = runtime.settings.permissionMode === "manual";
const decision = `/accept ${record.id} (or /deny ${record.id} <reason>)`;
const checklist =
"In Manual mode each mutating call still needs its own /accept; this plan is a shared checklist.";
const proposed = `Plan ${record.id} proposed (${plan.steps.length} step(s)): ${plan.title}.`;
return okResult({
operation: "plan.propose",
summary: manual
? `${proposed} Restate it to the user verbatim and stop until the user replies ${decision}. ${checklist}`
: `${proposed} Restate it to the user verbatim and stop; mutating tools stay locked until the user replies ${decision}.`,
data: {
...data,
instruction: manual
? `Restate this plan to the user verbatim, then stop until the user replies /accept ${record.id}. ${checklist}`
: `Restate this plan to the user verbatim, then stop. Do not call mutating tools until the user replies /accept ${record.id}.`,
},
artifacts: [planArtifact(runtime, record, data.markdown)],
importance: "critical",
facts: [
`plan ${record.id} pending approval: ${plan.title}`,
...modeFacts(runtime),
...plan.steps.slice(0, 10).map((step) => `step ${step.n}: ${step.text}`),
],
omit: PLAN_OMIT,
});
}
if (input.action === "current") {
const record = (await runtime.approvals.pendingPlan()) ?? (await runtime.approvals.activePlan());
if (!record) {
return okResult({
operation: "plan.current",
summary: "No plan is pending or approved.",
data: { status: "none" },
facts: ["no active plan", ...modeFacts(runtime)],
});
}
const data = planData(runtime, record);
return okResult({
operation: "plan.current",
summary: `Plan ${record.id} is ${record.status}: ${record.title}.`,
data,
importance: "high",
facts: [`plan ${record.id} ${record.status}: ${record.title}`, ...modeFacts(runtime)],
omit: PLAN_OMIT,
});
}
if (input.action === "list") {
const records = await runtime.approvals.list({ kind: "plan" });
const limited = records.slice(0, input.limit ?? 20);
return okResult({
operation: "plan.list",
summary: `Listed ${limited.length} plan(s).`,
data: limited.map((record) => ({
planId: record.id,
status: record.status,
title: record.title,
revision: (record.plan as PlanDocument).revision,
updatedAt: record.updatedAt,
planPath: runtime.approvals.planPath(record.id),
})),
facts: limited.slice(0, 10).map((record) => `plan ${record.id} ${record.status}`),
});
}
if (input.action === "complete") {
const target = input.plan_id ?? (await runtime.approvals.activePlan())?.id;
if (!target) {
// A 1.7B run spent 8 of its 13 rounds alternating complete/show on
// a plan the user had never accepted. "No approved plan" describes
// the state without naming the only move left.
const pending = await runtime.approvals.pendingPlan();
throw new AgenticError(
"TRANSACTION_STATE",
pending
? `Plan ${pending.id} is still awaiting the user's /accept; nothing runs until then. Stop and tell the user the plan id — calling complete or show again cannot change this.`
: "No approved plan to complete.",
);
}
const record = await runtime.approvals.completePlan(target, input.summary ?? "completed");
return okResult({
operation: "plan.complete",
summary: `Plan ${record.id} completed: ${input.summary ?? "completed"}`,
data: planData(runtime, record),
importance: "critical",
facts: [`plan ${record.id} completed`, ...modeFacts(runtime)],
omit: PLAN_OMIT,
});
}
if (!input.plan_id) {
throw new AgenticError("INVALID_INPUT", "workspace_plan show requires plan_id.");
}
const record = await runtime.approvals.get(input.plan_id);
if (record.kind !== "plan") {
throw new AgenticError("NOT_FOUND", `${input.plan_id} is not a plan.`);
}
const data = planData(runtime, record);
return okResult({
operation: "plan.show",
summary: `Plan ${record.id} is ${record.status}: ${record.title}.`,
data,
artifacts: [planArtifact(runtime, record, data.markdown)],
importance: "high",
facts: [`plan ${record.id} ${record.status}: ${record.title}`],
omit: PLAN_OMIT,
});
} catch (error) {
return errorResult(`plan.${input.action}`, error, "high");
}
},
});
}
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { errorResult, okResult } from "../core/result";
import type { ApprovalRecord } from "../policy/approvalStore";
import { modeFacts } from "../policy/gateRuntime";
import { renderPlanMarkdown, validatePlan, type PlanDocument } from "../policy/planDocument";
import type { PluginRuntime } from "../runtime";
const stepKind = z.enum(["edit", "command", "vcs", "research", "other"]);
/** Bulky plan content the compressor may drop: the rendered markdown and the step list (the first ten steps stay in facts). */
const PLAN_OMIT = ["data.markdown", "data.plan.steps"];
function planData(runtime: PluginRuntime, record: ApprovalRecord) {
const plan = record.plan as PlanDocument;
const markdown = renderPlanMarkdown(plan, record.id);
return {
planId: record.id,
status: record.status,
revision: plan.revision,
...(plan.supersedes ? { supersedes: plan.supersedes } : {}),
title: plan.title,
plan,
planPath: runtime.approvals.planPath(record.id),
markdown,
decidedAt: record.decidedAt,
completedAt: record.completedAt,
};
}
function planArtifact(runtime: PluginRuntime, record: ApprovalRecord, markdown: string): ArtifactRef {
return {
kind: "text",
path: runtime.approvals.planPath(record.id),
sha256: sha256Text(markdown),
bytes: Buffer.byteLength(markdown),
description: `Plan ${record.id}: ${record.title}`,
};
}
export function createPlanTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_plan",
description:
"The plan the user approves before mutating tools run. Actions: propose, show, current, complete, list. Only needed in Plan mode; Manual needs an /accept per call. After propose, restate the plan verbatim and stop; the user replies /accept <plan id> or /deny. Once approved, do not propose again unless the plan itself must change: a new proposal supersedes it and locks mutating tools again. Call complete when every step is done.",
parameters: {
action: z.enum(["propose", "show", "current", "complete", "list"]),
title: z.string().optional(),
objective: z.string().optional(),
steps: z
.array(z.union([z.string(), z.object({ text: z.string(), kind: stepKind.optional() })]))
.optional(),
files: z
.array(z.object({ path: z.string(), change: z.enum(["create", "modify", "delete", "move"]) }))
.optional(),
commands: z.array(z.object({ executable: z.string(), args: z.array(z.string()).optional() })).optional(),
risks: z.array(z.string()).optional(),
questions: z.array(z.string()).optional(),
plan_id: z.string().optional(),
summary: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (
input: {
action: "propose" | "show" | "current" | "complete" | "list";
title?: string;
objective?: string;
steps?: Array<string | { text: string; kind?: PlanDocument["steps"][number]["kind"] }>;
files?: PlanDocument["files"];
commands?: Array<{ executable: string; args?: string[] }>;
risks?: string[];
questions?: string[];
plan_id?: string;
summary?: string;
limit?: number;
},
ctx: ToolCallContext,
) => {
try {
if (input.action === "propose") {
if (!input.title || !input.objective || !input.steps?.length) {
throw new AgenticError("INVALID_INPUT", "workspace_plan propose requires title, objective and steps.");
}
const prior = (await runtime.approvals.pendingPlan()) ?? (await runtime.approvals.activePlan());
const plan = validatePlan(
{
title: input.title,
objective: input.objective,
steps: input.steps,
files: input.files,
commands: input.commands,
risks: input.risks,
questions: input.questions,
},
prior ? { revision: ((prior.plan as PlanDocument).revision ?? 1) + 1, supersedes: prior.id } : {},
);
const record = await runtime.approvals.proposePlan(plan);
const data = planData(runtime, record);
ctx.warn(`Plan ${record.id} awaits approval — reply /accept ${record.id} or /deny ${record.id} <reason>.`);
// Mirrors renderDecisionMessage: in Manual mode the gate stages every
// call regardless of the plan, so "stay locked until /accept" would
// promise an unlock that never comes.
const manual = runtime.settings.permissionMode === "manual";
const decision = `/accept ${record.id} (or /deny ${record.id} <reason>)`;
const checklist =
"In Manual mode each mutating call still needs its own /accept; this plan is a shared checklist.";
const proposed = `Plan ${record.id} proposed (${plan.steps.length} step(s)): ${plan.title}.`;
return okResult({
operation: "plan.propose",
summary: manual
? `${proposed} Restate it to the user verbatim and stop until the user replies ${decision}. ${checklist}`
: `${proposed} Restate it to the user verbatim and stop; mutating tools stay locked until the user replies ${decision}.`,
data: {
...data,
instruction: manual
? `Restate this plan to the user verbatim, then stop until the user replies /accept ${record.id}. ${checklist}`
: `Restate this plan to the user verbatim, then stop. Do not call mutating tools until the user replies /accept ${record.id}.`,
},
artifacts: [planArtifact(runtime, record, data.markdown)],
importance: "critical",
facts: [
`plan ${record.id} pending approval: ${plan.title}`,
...modeFacts(runtime),
...plan.steps.slice(0, 10).map((step) => `step ${step.n}: ${step.text}`),
],
omit: PLAN_OMIT,
});
}
if (input.action === "current") {
const record = (await runtime.approvals.pendingPlan()) ?? (await runtime.approvals.activePlan());
if (!record) {
return okResult({
operation: "plan.current",
summary: "No plan is pending or approved.",
data: { status: "none" },
facts: ["no active plan", ...modeFacts(runtime)],
});
}
const data = planData(runtime, record);
return okResult({
operation: "plan.current",
summary: `Plan ${record.id} is ${record.status}: ${record.title}.`,
data,
importance: "high",
facts: [`plan ${record.id} ${record.status}: ${record.title}`, ...modeFacts(runtime)],
omit: PLAN_OMIT,
});
}
if (input.action === "list") {
const records = await runtime.approvals.list({ kind: "plan" });
const limited = records.slice(0, input.limit ?? 20);
return okResult({
operation: "plan.list",
summary: `Listed ${limited.length} plan(s).`,
data: limited.map((record) => ({
planId: record.id,
status: record.status,
title: record.title,
revision: (record.plan as PlanDocument).revision,
updatedAt: record.updatedAt,
planPath: runtime.approvals.planPath(record.id),
})),
facts: limited.slice(0, 10).map((record) => `plan ${record.id} ${record.status}`),
});
}
if (input.action === "complete") {
const target = input.plan_id ?? (await runtime.approvals.activePlan())?.id;
if (!target) {
// A 1.7B run spent 8 of its 13 rounds alternating complete/show on
// a plan the user had never accepted. "No approved plan" describes
// the state without naming the only move left.
const pending = await runtime.approvals.pendingPlan();
throw new AgenticError(
"TRANSACTION_STATE",
pending
? `Plan ${pending.id} is still awaiting the user's /accept; nothing runs until then. Stop and tell the user the plan id — calling complete or show again cannot change this.`
: "No approved plan to complete.",
);
}
const record = await runtime.approvals.completePlan(target, input.summary ?? "completed");
return okResult({
operation: "plan.complete",
summary: `Plan ${record.id} completed: ${input.summary ?? "completed"}`,
data: planData(runtime, record),
importance: "critical",
facts: [`plan ${record.id} completed`, ...modeFacts(runtime)],
omit: PLAN_OMIT,
});
}
if (!input.plan_id) {
throw new AgenticError("INVALID_INPUT", "workspace_plan show requires plan_id.");
}
const record = await runtime.approvals.get(input.plan_id);
if (record.kind !== "plan") {
throw new AgenticError("NOT_FOUND", `${input.plan_id} is not a plan.`);
}
const data = planData(runtime, record);
return okResult({
operation: "plan.show",
summary: `Plan ${record.id} is ${record.status}: ${record.title}.`,
data,
artifacts: [planArtifact(runtime, record, data.markdown)],
importance: "high",
facts: [`plan ${record.id} ${record.status}: ${record.title}`],
omit: PLAN_OMIT,
});
} catch (error) {
return errorResult(`plan.${input.action}`, error, "high");
}
},
});
}