From 66c460a5797393b1e5db9c45f6ec2a40705d491b Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Sun, 2 Aug 2026 16:00:00 +0530 Subject: [PATCH 1/2] feat(sandbox): add edit_file (str_replace) tool for partial edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold for #1523. Adds a framework `edit_file` tool that replaces one unique substring with a new one — a lightweight partial-edit primitive that avoids the token cost of regenerating entire files via `write_file`. Behavior: - Reads file, verifies `oldString` appears exactly once, replaces, writes back. - Fails with descriptive error when `oldString` is missing, empty, or matches > 1 position. - Returns `{ path, replacements: 1 }`. Files changed: - `runtime/framework-tools/edit-file.ts` (NEW) — zod input/output schemas, executor, tool definition, unique-substring guarantee via occurrence count. - `runtime/framework-tools/index.ts` — register EDIT_FILE_TOOL_DEFINITION. - `public/tools/defaults.ts` — export `editFile` alongside existing readFile/writeFile. Notes / caveats for reviewers: - Uses only existing sandbox session primitives (`readTextFile`, `writeTextFile`) and `resolveAbsoluteFilePath` — no new deps, no new API surface. - Typecheck not run in this commit's context (no node_modules in this workspace); CI `tsc --noEmit` should be the verification gate. - This PR is a SCAFFOLD: maintainer decisions on (a) maximum diff size, (b) atomicity (read-+write within sandbox exec vs split tools), (c) telemetry/trace surface for edit_file's unique-substring failures, are intentionally deferred to design review. Related: #1523 Signed-off-by: Sarthak Singh --- packages/eve/src/public/tools/defaults.ts | 8 ++ .../src/runtime/framework-tools/edit-file.ts | 129 ++++++++++++++++++ .../eve/src/runtime/framework-tools/index.ts | 2 + 3 files changed, 139 insertions(+) create mode 100644 packages/eve/src/runtime/framework-tools/edit-file.ts diff --git a/packages/eve/src/public/tools/defaults.ts b/packages/eve/src/public/tools/defaults.ts index dd1b0ef74..afc743479 100644 --- a/packages/eve/src/public/tools/defaults.ts +++ b/packages/eve/src/public/tools/defaults.ts @@ -4,6 +4,7 @@ * `agent/tools/*.ts` files. */ import { BASH_TOOL_DEFINITION } from "#runtime/framework-tools/bash.js"; +import { EDIT_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/edit-file.js"; import { GLOB_TOOL_DEFINITION } from "#runtime/framework-tools/glob.js"; import { GREP_TOOL_DEFINITION } from "#runtime/framework-tools/grep.js"; import { READ_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/read-file.js"; @@ -47,6 +48,13 @@ export const readFile: ToolDefinition = toPublicToolDefinition(READ_FILE_TOOL_DE */ export const writeFile: ToolDefinition = toPublicToolDefinition(WRITE_FILE_TOOL_DEFINITION); +/** + * Framework-provided partial-edit tool (`edit_file`). Replaces one unique + * substring with a new one; fails if the substring is missing or ambiguous. + * Prefer `edit_file` over `write_file` for targeted changes on existing files. + */ +export const editFile: ToolDefinition = toPublicToolDefinition(EDIT_FILE_TOOL_DEFINITION); + /** * Framework-provided HTTP fetch tool. Spread or wrap to customize. */ diff --git a/packages/eve/src/runtime/framework-tools/edit-file.ts b/packages/eve/src/runtime/framework-tools/edit-file.ts new file mode 100644 index 000000000..bd98b2858 --- /dev/null +++ b/packages/eve/src/runtime/framework-tools/edit-file.ts @@ -0,0 +1,129 @@ +import { z } from "#compiled/zod/index.js"; + +import { requireSandboxSession } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import type { ResolvedToolDefinition } from "#runtime/types.js"; +import type { ToolExecuteOptions } from "#shared/tool-definition.js"; + +/** + * Typed input accepted by {@link executeEditFile}. + * + * Replaces exactly one occurrence of `old_string` with `new_string` in the + * target file. If `old_string` is not found (or is found more than once), the + * call fails with a descriptive error and the file is left untouched. + */ +export const EDIT_FILE_INPUT_SCHEMA = z.strictObject({ + filePath: z + .string() + .describe("The absolute path to the file to edit. A leading $HOME is supported."), + oldString: z + .string() + .describe( + "The exact substring to replace. Must appear exactly once in the file. Use enough surrounding context to make the match unique.", + ), + newString: z + .string() + .describe("The replacement text. The file is rewritten as: before + newString + after."), +}); + +/** + * Structured result returned from {@link executeEditFile}. + */ +export const EDIT_FILE_OUTPUT_SCHEMA = z.strictObject({ + path: z.string(), + replacements: z.number().int(), +}); + +export interface EditFileInput { + readonly filePath: string; + readonly newString: string; + readonly oldString: string; +} + +export interface EditFileResult { + readonly path: string; + readonly replacements: number; +} + +/** + * Executor that reads the file, performs the unique-substring replacement, + * and writes the result back. Fails when the substring is missing or ambiguous. + */ +export async function executeEditFileOnSandbox( + sandbox: import("#shared/sandbox-session.js").SandboxSession, + args: EditFileInput, +): Promise { + const resolvedPath = await resolveAbsoluteFilePath(sandbox, args.filePath); + + const current = await sandbox.readTextFile({ path: resolvedPath }); + if (current === null) { + throw new Error( + `edit_file: file does not exist at ${resolvedPath}. Use write_file to create it, or check the path.`, + ); + } + + const oldLen = args.oldString.length; + if (oldLen === 0) { + throw new Error("edit_file: oldString must be non-empty."); + } + + // Count occurrences strictly. + let occurrences = 0; + let idx = current.indexOf(args.oldString); + while (idx >= 0) { + occurrences += 1; + idx = current.indexOf(args.oldString, idx + oldLen); + } + + if (occurrences === 0) { + throw new Error( + `edit_file: oldString not found in ${resolvedPath}. The file may have drifted — re-read it with read_file and try again.`, + ); + } + if (occurrences > 1) { + throw new Error( + `edit_file: oldString matched ${occurrences} times in ${resolvedPath}. Add more surrounding context to make the match unique.`, + ); + } + + // Perform the guaranteed-unique replacement. + const next = current.replace(args.oldString, args.newString); + await sandbox.writeTextFile({ content: next, path: resolvedPath }); + + return { path: resolvedPath, replacements: 1 }; +} + +/** + * Framework-owned executor that delegates to the default sandbox. + */ +async function executeEditFile(input: unknown, options?: ToolExecuteOptions): Promise { + return executeEditFileOnSandbox( + await requireSandboxSession(options?.abortSignal), + input as EditFileInput, + ); +} + +/** + * Framework `edit_file` tool — partial-edit primitive. + * + * Use this when the change is localized and the file is already correct + * around the edit region. It avoids the latency and context bloat of + * regenerating the entire file through `write_file`. + */ +export const EDIT_FILE_TOOL_DEFINITION: ResolvedToolDefinition = { + description: [ + "Performs a unique substring replacement in a file. Use when you only need", + "to edit a small region of an existing file.", + "", + "- Fails with a descriptive error if `oldString` is not found or matches more than once.", + "- Always prefer `edit_file` over `write_file` for targeted changes.", + "- Use `write_file` when creating a new file or when the change is too large for a unique substring.", + ].join("\n"), + execute: executeEditFile, + inputSchema: EDIT_FILE_INPUT_SCHEMA, + logicalPath: "eve:framework/edit-file", + name: "edit_file", + outputSchema: EDIT_FILE_OUTPUT_SCHEMA, + sourceId: "eve:edit-file-tool", + sourceKind: "module", +}; diff --git a/packages/eve/src/runtime/framework-tools/index.ts b/packages/eve/src/runtime/framework-tools/index.ts index 8495b018a..3f23e6650 100644 --- a/packages/eve/src/runtime/framework-tools/index.ts +++ b/packages/eve/src/runtime/framework-tools/index.ts @@ -1,6 +1,7 @@ import { AGENT_TOOL_DEFINITION } from "#runtime/framework-tools/agent.js"; import { ASK_QUESTION_TOOL_DEFINITION } from "#runtime/framework-tools/ask-question.js"; import { BASH_TOOL_DEFINITION } from "#runtime/framework-tools/bash.js"; +import { EDIT_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/edit-file.js"; import { GLOB_TOOL_DEFINITION } from "#runtime/framework-tools/glob.js"; import { GREP_TOOL_DEFINITION } from "#runtime/framework-tools/grep.js"; import { READ_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/read-file.js"; @@ -47,6 +48,7 @@ const REGISTERED_FRAMEWORK_DYNAMIC_TOOLS: readonly FrameworkDynamicToolDefinitio const REGISTERED_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ ASK_QUESTION_TOOL_DEFINITION, BASH_TOOL_DEFINITION, + EDIT_FILE_TOOL_DEFINITION, GLOB_TOOL_DEFINITION, GREP_TOOL_DEFINITION, READ_FILE_TOOL_DEFINITION, From a8919ad55979969edbde5cc3e59e65393ad0645e Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Sun, 2 Aug 2026 16:06:47 +0530 Subject: [PATCH 2/2] fix(sandbox): edit_file refreshes read-file stamp + escapes $-replacer patterns Two VADE review findings on PR #1533: 1. edit_file wrote without refreshing the read-file stamp, so a subsequent write_file on the same file spuriously failed with "File has been modified since it was last read". Now refreshes the stamp after a successful write, mirroring executeWriteFileOnSandbox. 2. String.replace(oldString, newString) interpreted $$, $&, $`, $', $n in newString as special replacement patterns, silently corrupting the output. Now passes a replacer function so newString is inserted literally. Signed-off-by: iroiro147 --- .../src/runtime/framework-tools/edit-file.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/eve/src/runtime/framework-tools/edit-file.ts b/packages/eve/src/runtime/framework-tools/edit-file.ts index bd98b2858..688c6614c 100644 --- a/packages/eve/src/runtime/framework-tools/edit-file.ts +++ b/packages/eve/src/runtime/framework-tools/edit-file.ts @@ -1,7 +1,14 @@ import { z } from "#compiled/zod/index.js"; +import { loadContext } from "#context/container.js"; import { requireSandboxSession } from "#execution/sandbox/require-sandbox.js"; import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { + buildReadFileTargetKey, + createReadFileStamp, + normalizeModelPath, + setReadFileStamp, +} from "#runtime/framework-tools/file-state.js"; import type { ResolvedToolDefinition } from "#runtime/types.js"; import type { ToolExecuteOptions } from "#shared/tool-definition.js"; @@ -86,10 +93,20 @@ export async function executeEditFileOnSandbox( ); } - // Perform the guaranteed-unique replacement. - const next = current.replace(args.oldString, args.newString); + // Perform the guaranteed-unique replacement. Use a replacer function so that + // `$`-prefixed sequences in `newString` ($$, $&, $`, $', $n) are inserted + // literally instead of being interpreted as special replacement patterns. + const next = current.replace(args.oldString, () => args.newString); await sandbox.writeTextFile({ content: next, path: resolvedPath }); + // Refresh the read-file stamp so that a subsequent `write_file` on this file + // does not spuriously fail with "modified since it was last read" — the only + // mutation was this authorized edit. + const ctx = loadContext(); + const normalizedPath = normalizeModelPath(resolvedPath); + const targetKey = buildReadFileTargetKey(normalizedPath); + setReadFileStamp(ctx, targetKey, createReadFileStamp({ content: next, filePath: normalizedPath })); + return { path: resolvedPath, replacements: 1 }; }