src / agents / tools / edit.ts
src / agents / tools / edit.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { errorResult, okResult } from "../../core/result";
import {
describeDestructiveCauses,
normalizeEditOperations,
type RawEditOperation,
} from "../../workspace/transactions";
import {
MAX_RUN_FILES_CHANGED,
MAX_RUN_TRANSACTIONS,
unique,
type AgentToolContext,
} from "./context";
/** `edit_files` — the transactional multi-file edit. */
export function createEditFilesTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "edit_files",
description:
"Preview and apply a transactional multi-file edit. Commits automatically when this run may commit; otherwise the transaction stays planned for the user's approval.",
parameters: {
operations: z.array(
z.object({
type: z.enum([
"create",
"rewrite",
"replace",
"splice",
"delete",
"move",
"copy",
"mkdir",
]),
path: z.string().optional(),
content: z.string().optional(),
overwrite: z.boolean().optional(),
create_if_missing: z.boolean().optional(),
search: z.string().optional(),
replacement: z.string().optional(),
expected_matches: z.number().int().optional(),
start_line: z.number().int().optional(),
delete_count: z.number().int().optional(),
ignore_missing: z.boolean().optional(),
from: z.string().optional(),
to: z.string().optional(),
allow_line_numbers: z.boolean().optional(),
}),
),
idempotency_key: z.string().optional(),
},
implementation: async (input: {
operations: RawEditOperation[];
idempotency_key?: string;
}) => {
await beforeTool("edit_files");
try {
const operations = normalizeEditOperations(input.operations);
const preview = await ctx.transactions.preview(operations, {
idempotencyKey: input.idempotency_key
? `${state.id}:${input.idempotency_key}`
: `${state.id}:pass${state.pass}:tool${state.toolCalls}`,
maxPreviewChars: ctx.options.maxResultChars,
runId: state.id,
});
const destructiveBlocked =
preview.plan.destructive && !ctx.options.destructiveEditsEnabled;
const shouldCommit = state.commitEdits && !destructiveBlocked;
const plan = shouldCommit
? await ctx.transactions.commit(preview.plan.id, state.id)
: preview.plan;
// Bounded like every other accumulating field on the run state
// (commands -50, filesRead -100, notes -30): a long run otherwise grows
// the persisted state and every later pass prompt without limit.
state.transactions = unique([...state.transactions, plan.id]).slice(
-MAX_RUN_TRANSACTIONS,
);
if (plan.status === "applied") {
state.filesChanged = unique([
...state.filesChanged,
...plan.files.map((file) => file.path),
]).slice(-MAX_RUN_FILES_CHANGED);
}
await ctx.store.save(state);
const reviewArtifact = await ctx.transactions.reviewArtifact(plan);
return okResult({
operation: shouldCommit ? "edit.apply" : "edit.preview",
summary:
plan.summary +
(shouldCommit
? ""
: destructiveBlocked
? ` The transaction remains planned because the plugin setting allowDestructiveEdits ("Allow destructive commits") is off. ${describeDestructiveCauses(
plan,
)}`.trimEnd()
: ` The transaction remains planned for user approval (workspace_edit commit ${plan.id}).`),
data: {
transactionId: plan.id,
status: plan.status,
requiresReview: plan.requiresReview,
files: plan.files.map((file) => ({
path: file.path,
added: file.addedLines,
removed: file.removedLines,
})),
diffPreview: preview.diffPreview,
diffPath: plan.diffPath,
reviewPath: plan.reviewPath,
},
artifacts: [preview.artifact, reviewArtifact],
importance: "high",
facts: [
`transaction ${plan.id}: ${plan.status}`,
...plan.files.map((file) => `${file.path} +${file.addedLines}/-${file.removedLines}`),
],
omit: ["data.diffPreview"],
});
} catch (error) {
return errorResult("edit.apply", error, "high");
}
},
});
}
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { errorResult, okResult } from "../../core/result";
import {
describeDestructiveCauses,
normalizeEditOperations,
type RawEditOperation,
} from "../../workspace/transactions";
import {
MAX_RUN_FILES_CHANGED,
MAX_RUN_TRANSACTIONS,
unique,
type AgentToolContext,
} from "./context";
/** `edit_files` — the transactional multi-file edit. */
export function createEditFilesTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "edit_files",
description:
"Preview and apply a transactional multi-file edit. Commits automatically when this run may commit; otherwise the transaction stays planned for the user's approval.",
parameters: {
operations: z.array(
z.object({
type: z.enum([
"create",
"rewrite",
"replace",
"splice",
"delete",
"move",
"copy",
"mkdir",
]),
path: z.string().optional(),
content: z.string().optional(),
overwrite: z.boolean().optional(),
create_if_missing: z.boolean().optional(),
search: z.string().optional(),
replacement: z.string().optional(),
expected_matches: z.number().int().optional(),
start_line: z.number().int().optional(),
delete_count: z.number().int().optional(),
ignore_missing: z.boolean().optional(),
from: z.string().optional(),
to: z.string().optional(),
allow_line_numbers: z.boolean().optional(),
}),
),
idempotency_key: z.string().optional(),
},
implementation: async (input: {
operations: RawEditOperation[];
idempotency_key?: string;
}) => {
await beforeTool("edit_files");
try {
const operations = normalizeEditOperations(input.operations);
const preview = await ctx.transactions.preview(operations, {
idempotencyKey: input.idempotency_key
? `${state.id}:${input.idempotency_key}`
: `${state.id}:pass${state.pass}:tool${state.toolCalls}`,
maxPreviewChars: ctx.options.maxResultChars,
runId: state.id,
});
const destructiveBlocked =
preview.plan.destructive && !ctx.options.destructiveEditsEnabled;
const shouldCommit = state.commitEdits && !destructiveBlocked;
const plan = shouldCommit
? await ctx.transactions.commit(preview.plan.id, state.id)
: preview.plan;
// Bounded like every other accumulating field on the run state
// (commands -50, filesRead -100, notes -30): a long run otherwise grows
// the persisted state and every later pass prompt without limit.
state.transactions = unique([...state.transactions, plan.id]).slice(
-MAX_RUN_TRANSACTIONS,
);
if (plan.status === "applied") {
state.filesChanged = unique([
...state.filesChanged,
...plan.files.map((file) => file.path),
]).slice(-MAX_RUN_FILES_CHANGED);
}
await ctx.store.save(state);
const reviewArtifact = await ctx.transactions.reviewArtifact(plan);
return okResult({
operation: shouldCommit ? "edit.apply" : "edit.preview",
summary:
plan.summary +
(shouldCommit
? ""
: destructiveBlocked
? ` The transaction remains planned because the plugin setting allowDestructiveEdits ("Allow destructive commits") is off. ${describeDestructiveCauses(
plan,
)}`.trimEnd()
: ` The transaction remains planned for user approval (workspace_edit commit ${plan.id}).`),
data: {
transactionId: plan.id,
status: plan.status,
requiresReview: plan.requiresReview,
files: plan.files.map((file) => ({
path: file.path,
added: file.addedLines,
removed: file.removedLines,
})),
diffPreview: preview.diffPreview,
diffPath: plan.diffPath,
reviewPath: plan.reviewPath,
},
artifacts: [preview.artifact, reviewArtifact],
importance: "high",
facts: [
`transaction ${plan.id}: ${plan.status}`,
...plan.files.map((file) => `${file.path} +${file.addedLines}/-${file.removedLines}`),
],
omit: ["data.diffPreview"],
});
} catch (error) {
return errorResult("edit.apply", error, "high");
}
},
});
}