feat(coding-agent): added atom mode to EditTool with path fallback
- Added `atom` edit mode to `EditTool` with single-point hashline-style dispatch and `executeAtomSingle`. - Added `AtomToolEdit`/`AtomParams` schema and `atom` prompt docs to require one op per edit entry. - Changed `EditTool` entry processing to inherit per-entry `path` from a top-level request `path` fallback. - Added top-level `path` defaults to patch and replace schemas and now reject missing entry paths. - Added an `atom` streaming strategy and registered it in `EDIT_MODE_STRATEGIES` for live mode support. - Enabled hashline read anchors for `atom` mode via `file-display-mode.ts` path-display logic. - Added `atom` unit tests covering schema checks, anchor conflicts, and hash mismatch diagnostics.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
writethroughNoop,
|
||||
} from "../lsp";
|
||||
import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
|
||||
import atomDescription from "../prompts/tools/atom.md" with { type: "text" };
|
||||
import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" };
|
||||
import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
|
||||
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
|
||||
@@ -24,6 +25,13 @@ import {
|
||||
isApplyPatchParams,
|
||||
} from "./modes/apply-patch";
|
||||
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
|
||||
import {
|
||||
type AtomParams,
|
||||
type AtomToolEdit,
|
||||
atomEditParamsSchema,
|
||||
executeAtomSingle,
|
||||
isAtomParams,
|
||||
} from "./modes/atom";
|
||||
import {
|
||||
type ChunkParams,
|
||||
type ChunkToolEdit,
|
||||
@@ -62,6 +70,7 @@ export * from "./apply-patch";
|
||||
export * from "./diff";
|
||||
export * from "./line-hash";
|
||||
export * from "./modes/apply-patch";
|
||||
export * from "./modes/atom";
|
||||
export * from "./modes/chunk";
|
||||
export * from "./modes/hashline";
|
||||
export * from "./modes/patch";
|
||||
@@ -74,12 +83,20 @@ type TInput =
|
||||
| typeof replaceEditSchema
|
||||
| typeof patchEditSchema
|
||||
| typeof hashlineEditParamsSchema
|
||||
| typeof atomEditParamsSchema
|
||||
| typeof chunkEditParamsSchema
|
||||
| typeof vimSchema
|
||||
| typeof applyPatchSchema;
|
||||
|
||||
type VimParams = Static<typeof vimSchema>;
|
||||
type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams | ApplyPatchParams;
|
||||
type EditParams =
|
||||
| ReplaceParams
|
||||
| PatchParams
|
||||
| HashlineParams
|
||||
| AtomParams
|
||||
| ChunkParams
|
||||
| VimParams
|
||||
| ApplyPatchParams;
|
||||
type EditToolResultDetails = EditToolDetails | VimToolDetails;
|
||||
|
||||
type EditModeDefinition = {
|
||||
@@ -148,6 +165,25 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback {
|
||||
return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve per-entry `path` against an optional top-level `path` default.
|
||||
* If both are absent on an entry, throws a descriptive error.
|
||||
*/
|
||||
function resolveEntryPaths<T extends { path?: string }>(
|
||||
edits: readonly T[],
|
||||
topLevelPath: string | undefined,
|
||||
): (T & { path: string })[] {
|
||||
return edits.map((edit, i) => {
|
||||
const path = (edit && typeof edit.path === "string" && edit.path) || topLevelPath;
|
||||
if (!path) {
|
||||
throw new Error(
|
||||
`Edit ${i}: missing \`path\`. Provide \`path\` on this edit or supply a top-level \`path\` for the request.`,
|
||||
);
|
||||
}
|
||||
return { ...edit, path };
|
||||
});
|
||||
}
|
||||
|
||||
/** Group items by a key, preserving insertion order. */
|
||||
function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
|
||||
const map = new Map<K, T[]>();
|
||||
@@ -333,8 +369,9 @@ export class EditTool implements AgentTool<TInput> {
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const { edits } = params as ChunkParams;
|
||||
const byFile = groupBy(edits, (e: ChunkToolEdit) => parseChunkEditPath(e.path).filePath);
|
||||
const { edits, path: topPath } = params as ChunkParams & { path?: string };
|
||||
const resolved = resolveEntryPaths(edits as ChunkToolEdit[], topPath);
|
||||
const byFile = groupBy(resolved, (e: ChunkToolEdit) => parseChunkEditPath(e.path).filePath);
|
||||
const entries = [...byFile.entries()].map(([filePath, fileEdits]) => ({
|
||||
path: filePath,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
@@ -363,8 +400,9 @@ export class EditTool implements AgentTool<TInput> {
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const { edits } = params as PatchParams;
|
||||
const entries = edits.map((entry: PatchEditEntry) => ({
|
||||
const { edits, path: topPath } = params as PatchParams & { path?: string };
|
||||
const resolved = resolveEntryPaths(edits as PatchEditEntry[], topPath);
|
||||
const entries = resolved.map(entry => ({
|
||||
path: entry.path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executePatchSingle({
|
||||
@@ -394,8 +432,8 @@ export class EditTool implements AgentTool<TInput> {
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
|
||||
const perFile = entries.map((entry: PatchEditEntry) => ({
|
||||
path: entry.path,
|
||||
const perFile = entries.map(entry => ({
|
||||
path: entry.path!,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executePatchSingle({
|
||||
session: tool.session,
|
||||
@@ -423,8 +461,9 @@ export class EditTool implements AgentTool<TInput> {
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const { edits } = params as HashlineParams;
|
||||
const byFile = groupBy(edits, (e: HashlineToolEdit) => e.path);
|
||||
const { edits, path: topPath } = params as HashlineParams & { path?: string };
|
||||
const resolved = resolveEntryPaths(edits as HashlineToolEdit[], topPath);
|
||||
const byFile = groupBy(resolved, e => e.path);
|
||||
const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
|
||||
path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
@@ -441,6 +480,38 @@ export class EditTool implements AgentTool<TInput> {
|
||||
return executePerFile(entries, batchRequest, onUpdate);
|
||||
},
|
||||
},
|
||||
atom: {
|
||||
description: () => prompt.render(atomDescription),
|
||||
parameters: atomEditParamsSchema,
|
||||
invalidParamsMessage:
|
||||
"Edit tool requires `{ edits: [...] }` (an array of entries). Each entry needs exactly one op key (set, before, after, del, sub, ins, append, prepend) plus optional `path`.",
|
||||
validate: isAtomParams,
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const { edits, path: topPath } = params as AtomParams & { path?: string };
|
||||
const resolved = resolveEntryPaths(edits as AtomToolEdit[], topPath);
|
||||
const byFile = groupBy(resolved, e => e.path);
|
||||
const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
|
||||
path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executeAtomSingle({
|
||||
session: tool.session,
|
||||
path,
|
||||
edits: fileEdits,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
||||
}),
|
||||
}));
|
||||
return executePerFile(entries, batchRequest, onUpdate);
|
||||
},
|
||||
},
|
||||
replace: {
|
||||
description: () => prompt.render(replaceDescription),
|
||||
parameters: replaceEditSchema,
|
||||
@@ -453,8 +524,9 @@ export class EditTool implements AgentTool<TInput> {
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const { edits } = params as ReplaceParams;
|
||||
const entries = edits.map((entry: ReplaceEditEntry) => ({
|
||||
const { edits, path: topPath } = params as ReplaceParams & { path?: string };
|
||||
const resolved = resolveEntryPaths(edits as ReplaceEditEntry[], topPath);
|
||||
const entries = resolved.map(entry => ({
|
||||
path: entry.path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executeReplaceSingle({
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Atom edit mode — single-point hashline-anchored edits.
|
||||
*
|
||||
* Each op references exactly **one** anchor (`LINE#HASH`). Range endpoints,
|
||||
* vim-style motions, and column addressing are intentionally absent: to
|
||||
* replace many lines, the model issues many ops. Reuses hashline's anchor
|
||||
* staleness scheme (`computeLineHash`) verbatim.
|
||||
*
|
||||
* Op shapes (one per entry):
|
||||
* { path, set: "5#th", to: "..." | ["..."] } // replace one line
|
||||
* { path, before: "5#th", lines: "..." | ["..."] } // insert above anchor
|
||||
* { path, after: "5#th", lines: "..." | ["..."] } // insert below anchor
|
||||
* { path, del: "5#th" } // delete one line
|
||||
* { path, sub: "5#th", find: "...", to: "..." } // substring rewrite on anchor line
|
||||
* { path, ins: "5#th", find: "...", to: "..." } // overwrite from substring to EOL
|
||||
* { path, append: "..." | ["..."] } // append to EOF
|
||||
* { path, prepend: "..." | ["..."] } // prepend at BOF
|
||||
*
|
||||
* For deleting or moving files, the agent should use bash.
|
||||
*/
|
||||
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
||||
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
||||
import { outputMeta } from "../../tools/output-meta";
|
||||
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
||||
import { generateDiffString } from "../diff";
|
||||
import { computeLineHash } from "../line-hash";
|
||||
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
||||
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
||||
import {
|
||||
type Anchor,
|
||||
buildCompactHashlineDiffPreview,
|
||||
HashlineMismatchError,
|
||||
type HashMismatch,
|
||||
hashlineParseText,
|
||||
parseTag,
|
||||
} from "./hashline";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Schema
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const linesSchema = Type.Union([Type.Array(Type.String()), Type.String()]);
|
||||
|
||||
/**
|
||||
* Flat entry shape: every op key is optional, and the runtime validator
|
||||
* (`isAtomParams` + `resolveAtomToolEdit`) enforces that exactly one op key
|
||||
* is present per entry. We use a flat schema instead of a 9-member discriminated
|
||||
* union to keep the tool definition compact (the schema is re-sent on every
|
||||
* turn, so duplicating `path` + descriptions across 9 union members 2×'s
|
||||
* total token usage on long benchmarks).
|
||||
*/
|
||||
export const atomEditSchema = Type.Object(
|
||||
{
|
||||
path: Type.Optional(Type.String({ description: "file path override" })),
|
||||
// Exactly one of the following op keys is required per entry:
|
||||
set: Type.Optional(Type.String({ description: "line anchor to replace, 123#th" })),
|
||||
before: Type.Optional(Type.String({ description: "line anchor to insert before, 123#th" })),
|
||||
after: Type.Optional(Type.String({ description: "line anchor to insert after, 123#th" })),
|
||||
del: Type.Optional(Type.String({ description: "line anchor to delete, 123#th" })),
|
||||
sub: Type.Optional(Type.String({ description: "line anchor to rewrite, 123#th" })),
|
||||
ins: Type.Optional(
|
||||
Type.String({ description: "line anchor to overwrite from a substring to end-of-line, 123#th" }),
|
||||
),
|
||||
append: Type.Optional(linesSchema),
|
||||
prepend: Type.Optional(linesSchema),
|
||||
// Payload (used by set/before/after/sub/ins/append/prepend):
|
||||
lines: Type.Optional(linesSchema),
|
||||
find: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"sub/ins: substring on the anchored line that must occur exactly once. Use the shortest unique fragment.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const atomEditParamsSchema = Type.Object(
|
||||
{
|
||||
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
|
||||
edits: Type.Array(atomEditSchema, { description: "edits" }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export type AtomToolEdit = Static<typeof atomEditSchema>;
|
||||
export type AtomParams = Static<typeof atomEditParamsSchema>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Internal resolved op shapes
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type AtomEdit =
|
||||
| { op: "set"; pos: Anchor; lines: string[] }
|
||||
| { op: "before"; pos: Anchor; lines: string[] }
|
||||
| { op: "after"; pos: Anchor; lines: string[] }
|
||||
| { op: "del"; pos: Anchor }
|
||||
| { op: "sub"; pos: Anchor; find: string; to: string }
|
||||
| { op: "ins"; pos: Anchor; find: string; to: string }
|
||||
| { op: "append_file"; lines: string[] }
|
||||
| { op: "prepend_file"; lines: string[] };
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Param guards
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ATOM_OP_KEYS = ["set", "before", "after", "del", "sub", "ins", "append", "prepend"] as const;
|
||||
|
||||
export function isAtomParams(params: unknown): params is AtomParams {
|
||||
// Minimal shape check. Per-entry validation (op key presence, exclusivity,
|
||||
// payload sanity) all happens in `resolveAtomToolEdit` so the model gets a
|
||||
// specific actionable error rather than a generic "invalid parameters" message.
|
||||
if (typeof params !== "object" || params === null) return false;
|
||||
if (!("edits" in params) || !Array.isArray(params.edits)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Resolution
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Parse an anchor reference like `"5#th"`.
|
||||
*
|
||||
* Tolerant: on a malformed reference we still try to extract a 1-indexed line
|
||||
* number from the leading digits so the validator can surface the *correct*
|
||||
* `LINE#HASH:content` for the user. The bogus hash is preserved in the returned
|
||||
* anchor so the validator emits a content-rich mismatch error.
|
||||
*
|
||||
* If we cannot recover even a line number, throw a usage-style error with the
|
||||
* raw reference quoted.
|
||||
*/
|
||||
function parseAnchor(raw: string, opName: string): Anchor {
|
||||
if (typeof raw !== "string" || raw.length === 0) {
|
||||
throw new Error(`${opName} requires an anchor of the form "LINE#ID" (e.g. "5#th").`);
|
||||
}
|
||||
try {
|
||||
return parseTag(raw);
|
||||
} catch {
|
||||
const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
|
||||
if (lineMatch) {
|
||||
const line = Number.parseInt(lineMatch[1], 10);
|
||||
if (line >= 1) {
|
||||
// Sentinel hash that will never match a real line, forcing the validator
|
||||
// to report a mismatch with the actual hash + line content.
|
||||
return { line, hash: "??" };
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${opName} requires an anchor of the form "LINE#ID" (e.g. "5#th"). Received ${JSON.stringify(raw)}; could not extract a line number.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function subInsLinesToString(lines: unknown, opName: string): string {
|
||||
if (typeof lines === "string") return lines;
|
||||
if (Array.isArray(lines)) return lines.join("\n");
|
||||
throw new Error(`${opName} requires a string or array \`lines\` value (the replacement text).`);
|
||||
}
|
||||
|
||||
function classifyAtomEdit(edit: AtomToolEdit): string {
|
||||
for (const k of ATOM_OP_KEYS) {
|
||||
if (k in edit) return k;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit {
|
||||
const opKeysPresent = ATOM_OP_KEYS.filter(k => k in edit);
|
||||
if (opKeysPresent.length === 0) {
|
||||
throw new Error(
|
||||
`Edit ${editIndex}: missing op key. Each entry must include exactly one of: ${ATOM_OP_KEYS.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
if (opKeysPresent.length > 1) {
|
||||
throw new Error(
|
||||
`Edit ${editIndex}: multiple op keys (${opKeysPresent.join(", ")}). Each entry is exactly one op — split into ${opKeysPresent.length} separate entries.`,
|
||||
);
|
||||
}
|
||||
if ("set" in edit && typeof edit.set === "string") {
|
||||
return { op: "set", pos: parseAnchor(edit.set, "set"), lines: hashlineParseText(edit.lines) };
|
||||
}
|
||||
if ("before" in edit && typeof edit.before === "string") {
|
||||
return { op: "before", pos: parseAnchor(edit.before, "before"), lines: hashlineParseText(edit.lines) };
|
||||
}
|
||||
if ("after" in edit && typeof edit.after === "string") {
|
||||
return { op: "after", pos: parseAnchor(edit.after, "after"), lines: hashlineParseText(edit.lines) };
|
||||
}
|
||||
if ("del" in edit && typeof edit.del === "string") {
|
||||
return { op: "del", pos: parseAnchor(edit.del, "del") };
|
||||
}
|
||||
if ("sub" in edit && typeof edit.sub === "string") {
|
||||
if (typeof edit.find !== "string" || edit.find.length === 0) {
|
||||
throw new Error("sub requires a non-empty `find` string.");
|
||||
}
|
||||
const to = subInsLinesToString(edit.lines, "sub");
|
||||
return { op: "sub", pos: parseAnchor(edit.sub, "sub"), find: edit.find, to };
|
||||
}
|
||||
if ("ins" in edit && typeof edit.ins === "string") {
|
||||
if (typeof edit.find !== "string" || edit.find.length === 0) {
|
||||
throw new Error("ins requires a non-empty `find` string (the position-anchor on the line).");
|
||||
}
|
||||
const to = subInsLinesToString(edit.lines, "ins");
|
||||
return { op: "ins", pos: parseAnchor(edit.ins, "ins"), find: edit.find, to };
|
||||
}
|
||||
if ("append" in edit) {
|
||||
return { op: "append_file", lines: hashlineParseText(edit.append) };
|
||||
}
|
||||
if ("prepend" in edit) {
|
||||
return { op: "prepend_file", lines: hashlineParseText(edit.prepend) };
|
||||
}
|
||||
throw new Error(`Unknown atom edit shape: ${JSON.stringify(edit)}`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Validation
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getAtomAnchor(edit: AtomEdit): Anchor | undefined {
|
||||
switch (edit.op) {
|
||||
case "set":
|
||||
case "before":
|
||||
case "after":
|
||||
case "del":
|
||||
case "sub":
|
||||
case "ins":
|
||||
return edit.pos;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[]): HashMismatch[] {
|
||||
const mismatches: HashMismatch[] = [];
|
||||
for (const edit of edits) {
|
||||
const anchor = getAtomAnchor(edit);
|
||||
if (!anchor) continue;
|
||||
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
||||
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
||||
}
|
||||
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
||||
if (actualHash !== anchor.hash) {
|
||||
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
|
||||
}
|
||||
}
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
||||
// For each anchor line, at most one mutating op (set/del/sub/ins).
|
||||
// before/after may coexist (they don't mutate the anchor line).
|
||||
const mutatingPerLine = new Map<number, string>();
|
||||
for (const edit of edits) {
|
||||
if (edit.op === "set" || edit.op === "del" || edit.op === "sub" || edit.op === "ins") {
|
||||
const existing = mutatingPerLine.get(edit.pos.line);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
|
||||
`At most one of set/del/sub is allowed per anchor.`,
|
||||
);
|
||||
}
|
||||
mutatingPerLine.set(edit.pos.line, edit.op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Apply
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getAtomEditSortKey(edit: AtomEdit, fileLineCount: number): { sortLine: number; precedence: number } {
|
||||
switch (edit.op) {
|
||||
case "sub":
|
||||
case "ins":
|
||||
return { sortLine: edit.pos.line, precedence: -1 };
|
||||
case "set":
|
||||
case "del":
|
||||
return { sortLine: edit.pos.line, precedence: 0 };
|
||||
case "after":
|
||||
return { sortLine: edit.pos.line, precedence: 1 };
|
||||
case "before":
|
||||
return { sortLine: edit.pos.line, precedence: 2 };
|
||||
case "append_file":
|
||||
return { sortLine: fileLineCount + 1, precedence: 3 };
|
||||
case "prepend_file":
|
||||
return { sortLine: 0, precedence: 3 };
|
||||
}
|
||||
}
|
||||
|
||||
function applyAtomEditToLines(edit: AtomEdit, fileLines: string[], trackFirstChanged: (line: number) => void): void {
|
||||
switch (edit.op) {
|
||||
case "set": {
|
||||
const lines = edit.lines.length === 0 ? [""] : edit.lines;
|
||||
fileLines.splice(edit.pos.line - 1, 1, ...lines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "del": {
|
||||
fileLines.splice(edit.pos.line - 1, 1);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "before": {
|
||||
if (edit.lines.length === 0) break;
|
||||
fileLines.splice(edit.pos.line - 1, 0, ...edit.lines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "after": {
|
||||
if (edit.lines.length === 0) break;
|
||||
fileLines.splice(edit.pos.line, 0, ...edit.lines);
|
||||
trackFirstChanged(edit.pos.line + 1);
|
||||
break;
|
||||
}
|
||||
case "sub": {
|
||||
const idx = edit.pos.line - 1;
|
||||
const current = fileLines[idx];
|
||||
const first = current.indexOf(edit.find);
|
||||
if (first === -1) {
|
||||
throw new Error(
|
||||
`sub: substring \`${edit.find}\` not found on line ${edit.pos.line}. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
const second = current.indexOf(edit.find, first + 1);
|
||||
if (second !== -1) {
|
||||
throw new Error(
|
||||
`sub: substring \`${edit.find}\` occurs more than once on line ${edit.pos.line}; ` +
|
||||
`use a longer substring that uniquely identifies the target. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
const next = current.slice(0, first) + edit.to + current.slice(first + edit.find.length);
|
||||
// Allow `to` to introduce newlines, expanding into multiple lines.
|
||||
const newLines = next.includes("\n") ? next.split("\n") : [next];
|
||||
fileLines.splice(idx, 1, ...newLines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "ins": {
|
||||
const idx = edit.pos.line - 1;
|
||||
const current = fileLines[idx];
|
||||
const first = current.indexOf(edit.find);
|
||||
if (first === -1) {
|
||||
throw new Error(
|
||||
`ins: substring \`${edit.find}\` not found on line ${edit.pos.line}. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
const second = current.indexOf(edit.find, first + 1);
|
||||
if (second !== -1) {
|
||||
throw new Error(
|
||||
`ins: substring \`${edit.find}\` occurs more than once on line ${edit.pos.line}; ` +
|
||||
`use a longer substring that uniquely identifies the position. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
// Replace from start of `find` to end-of-line with `to` (vim-insert style).
|
||||
const next = current.slice(0, first) + edit.to;
|
||||
const newLines = next.includes("\n") ? next.split("\n") : [next];
|
||||
fileLines.splice(idx, 1, ...newLines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "append_file": {
|
||||
if (edit.lines.length === 0) break;
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...edit.lines);
|
||||
trackFirstChanged(1);
|
||||
} else {
|
||||
fileLines.splice(fileLines.length, 0, ...edit.lines);
|
||||
trackFirstChanged(fileLines.length - edit.lines.length + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "prepend_file": {
|
||||
if (edit.lines.length === 0) break;
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...edit.lines);
|
||||
} else {
|
||||
fileLines.splice(0, 0, ...edit.lines);
|
||||
}
|
||||
trackFirstChanged(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
|
||||
const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
|
||||
if (!enabled) return;
|
||||
for (const edit of edits) {
|
||||
if (edit.op !== "set" && edit.op !== "before" && edit.op !== "after") continue;
|
||||
if (edit.lines.length === 0) continue;
|
||||
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
||||
if (!hasEscapedTabs) continue;
|
||||
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
||||
if (hasRealTabs) continue;
|
||||
let correctedCount = 0;
|
||||
const corrected = edit.lines.map(line =>
|
||||
line.replace(/^((?:\\t)+)/, escaped => {
|
||||
correctedCount += escaped.length / 2;
|
||||
return "\t".repeat(escaped.length / 2);
|
||||
}),
|
||||
);
|
||||
if (correctedCount === 0) continue;
|
||||
edit.lines = corrected;
|
||||
warnings.push(
|
||||
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAtomEdits(
|
||||
text: string,
|
||||
edits: AtomEdit[],
|
||||
): {
|
||||
lines: string;
|
||||
firstChangedLine: number | undefined;
|
||||
warnings?: string[];
|
||||
} {
|
||||
if (edits.length === 0) {
|
||||
return { lines: text, firstChangedLine: undefined };
|
||||
}
|
||||
|
||||
const fileLines = text.split("\n");
|
||||
const warnings: string[] = [];
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
const mismatches = validateAtomAnchors(edits, fileLines);
|
||||
if (mismatches.length > 0) {
|
||||
throw new HashlineMismatchError(mismatches, fileLines);
|
||||
}
|
||||
validateNoConflictingAnchorOps(edits);
|
||||
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
||||
|
||||
const annotated = edits
|
||||
.map((edit, idx) => {
|
||||
const { sortLine, precedence } = getAtomEditSortKey(edit, fileLines.length);
|
||||
return { edit, idx, sortLine, precedence };
|
||||
})
|
||||
.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
|
||||
|
||||
const trackFirstChanged = (line: number) => {
|
||||
if (firstChangedLine === undefined || line < firstChangedLine) {
|
||||
firstChangedLine = line;
|
||||
}
|
||||
};
|
||||
|
||||
for (const { edit } of annotated) {
|
||||
applyAtomEditToLines(edit, fileLines, trackFirstChanged);
|
||||
}
|
||||
|
||||
return {
|
||||
lines: fileLines.join("\n"),
|
||||
firstChangedLine,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Executor
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface ExecuteAtomSingleOptions {
|
||||
session: ToolSession;
|
||||
path: string;
|
||||
edits: AtomToolEdit[];
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
writethrough: WritethroughCallback;
|
||||
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
||||
}
|
||||
|
||||
export async function executeAtomSingle(
|
||||
options: ExecuteAtomSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
||||
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
||||
|
||||
const contentEdits = edits.map((edit, i) => resolveAtomToolEdit(edit, i));
|
||||
|
||||
enforcePlanModeWrite(session, path, { op: "update" });
|
||||
|
||||
if (path.endsWith(".ipynb") && contentEdits.length > 0) {
|
||||
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
||||
}
|
||||
|
||||
const absolutePath = resolvePlanPath(session, path);
|
||||
|
||||
const sourceFile = Bun.file(absolutePath);
|
||||
const sourceExists = await sourceFile.exists();
|
||||
|
||||
if (!sourceExists) {
|
||||
const lines: string[] = [];
|
||||
for (const edit of contentEdits) {
|
||||
if (edit.op === "append_file") {
|
||||
lines.push(...edit.lines);
|
||||
} else if (edit.op === "prepend_file") {
|
||||
lines.unshift(...edit.lines);
|
||||
} else {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
await Bun.write(absolutePath, lines.join("\n"));
|
||||
invalidateFsScanAfterWrite(absolutePath);
|
||||
return {
|
||||
content: [{ type: "text", text: `Created ${path}` }],
|
||||
details: {
|
||||
diff: "",
|
||||
op: "create",
|
||||
meta: outputMeta().get(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rawContent = await sourceFile.text();
|
||||
assertEditableFileContent(rawContent, path);
|
||||
|
||||
const { bom, text } = stripBom(rawContent);
|
||||
const originalEnding = detectLineEnding(text);
|
||||
const originalNormalized = normalizeToLF(text);
|
||||
|
||||
const result = applyAtomEdits(originalNormalized, contentEdits);
|
||||
if (originalNormalized === result.lines) {
|
||||
throw new Error(`No changes made to ${path}. The edits produced identical content.`);
|
||||
}
|
||||
|
||||
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
|
||||
const diagnostics = await writethrough(
|
||||
absolutePath,
|
||||
finalContent,
|
||||
signal,
|
||||
Bun.file(absolutePath),
|
||||
batchRequest,
|
||||
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
|
||||
);
|
||||
invalidateFsScanAfterWrite(absolutePath);
|
||||
|
||||
const diffResult = generateDiffString(originalNormalized, result.lines);
|
||||
const meta = outputMeta()
|
||||
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
||||
.get();
|
||||
|
||||
const resultText = `Updated ${path}`;
|
||||
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
||||
const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
|
||||
preview.preview ? "" : " (no textual diff preview)"
|
||||
}`;
|
||||
const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
||||
const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
diff: diffResult.diff,
|
||||
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
||||
diagnostics,
|
||||
op: "update",
|
||||
meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers exposed for tests / external dispatch.
|
||||
export { classifyAtomEdit, parseAnchor, resolveAtomToolEdit };
|
||||
@@ -1577,7 +1577,7 @@ export async function computePatchDiff(
|
||||
}
|
||||
|
||||
export const patchEditEntrySchema = Type.Object({
|
||||
path: Type.String({ description: "File path" }),
|
||||
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
|
||||
op: Type.Optional(
|
||||
StringEnum(["create", "delete", "update"], {
|
||||
description: "Operation (default: update)",
|
||||
@@ -1588,6 +1588,7 @@ export const patchEditEntrySchema = Type.Object({
|
||||
});
|
||||
|
||||
export const patchEditSchema = Type.Object({
|
||||
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
|
||||
edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
|
||||
});
|
||||
|
||||
@@ -1710,6 +1711,9 @@ export async function executePatchSingle(
|
||||
beginDeferredDiagnosticsForPath,
|
||||
} = options;
|
||||
const { path, op: rawOp, rename, diff } = params;
|
||||
if (typeof path !== "string" || path.length === 0) {
|
||||
throw new Error("patch edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
|
||||
}
|
||||
|
||||
const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
|
||||
|
||||
|
||||
@@ -977,13 +977,14 @@ export function findContextLine(
|
||||
}
|
||||
|
||||
export const replaceEditEntrySchema = Type.Object({
|
||||
path: Type.String({ description: "File path (relative or absolute)" }),
|
||||
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
|
||||
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
|
||||
new_text: Type.String({ description: "Replacement text" }),
|
||||
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
|
||||
});
|
||||
|
||||
export const replaceEditSchema = Type.Object({
|
||||
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
|
||||
edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }),
|
||||
});
|
||||
|
||||
@@ -1022,6 +1023,9 @@ export async function executeReplaceSingle(
|
||||
beginDeferredDiagnosticsForPath,
|
||||
} = options;
|
||||
const { path, old_text, new_text, all } = params;
|
||||
if (typeof path !== "string" || path.length === 0) {
|
||||
throw new Error("replace edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
|
||||
}
|
||||
|
||||
enforcePlanModeWrite(session, path);
|
||||
|
||||
|
||||
@@ -489,10 +489,9 @@ function renderSingleFileResult(
|
||||
const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
|
||||
const { language } = formatEditDescription(rawPath, uiTheme, { rename });
|
||||
|
||||
const metadataLine =
|
||||
op !== "delete"
|
||||
? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
|
||||
: "";
|
||||
const editTextSource = args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch;
|
||||
const metadataLineCount = editTextSource ? countLines(editTextSource) : null;
|
||||
const metadataLine = op !== "delete" ? `\n${formatMetadataLine(metadataLineCount, language, uiTheme)}` : "";
|
||||
|
||||
const errorText = isError
|
||||
? (details && "errorText" in details && details.errorText) ||
|
||||
|
||||
@@ -190,7 +190,6 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
|
||||
|
||||
interface HashlineArgs {
|
||||
edits?: HashlineToolEdit[];
|
||||
move?: string;
|
||||
__partialJson?: string;
|
||||
}
|
||||
|
||||
@@ -207,7 +206,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
||||
return !!e && typeof e === "object" && (e as { path?: string }).path === path;
|
||||
});
|
||||
ctx.signal.throwIfAborted();
|
||||
const result = await computeHashlineDiff({ path, edits: fileEdits, move: args.move }, ctx.cwd);
|
||||
const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
|
||||
ctx.signal.throwIfAborted();
|
||||
return [toPerFilePreview(path, result)];
|
||||
},
|
||||
@@ -328,6 +327,26 @@ const vimStrategy: EditStreamingStrategy<unknown> = {
|
||||
},
|
||||
};
|
||||
|
||||
interface AtomArgs {
|
||||
path?: string;
|
||||
edits?: unknown[];
|
||||
}
|
||||
|
||||
const atomStrategy: EditStreamingStrategy<AtomArgs> = {
|
||||
extractCompleteEdits(args, partialJson) {
|
||||
if (!args.edits) return args;
|
||||
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
||||
},
|
||||
async computeDiffPreview() {
|
||||
// Atom edits are line-anchored and validated against live file hashes; a
|
||||
// streaming preview without that validation could mislead. Skip for now.
|
||||
return null;
|
||||
},
|
||||
renderStreamingFallback() {
|
||||
return "";
|
||||
},
|
||||
};
|
||||
|
||||
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
|
||||
replace: replaceStrategy as EditStreamingStrategy<unknown>,
|
||||
patch: patchStrategy as EditStreamingStrategy<unknown>,
|
||||
@@ -335,6 +354,7 @@ export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknow
|
||||
chunk: chunkStrategy as EditStreamingStrategy<unknown>,
|
||||
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
|
||||
vim: vimStrategy,
|
||||
atom: atomStrategy as EditStreamingStrategy<unknown>,
|
||||
};
|
||||
|
||||
export { resolveEditMode };
|
||||
|
||||
Reference in New Issue
Block a user