diff --git a/docs/roadmap/10-multi-edit.md b/docs/roadmap/10-multi-edit.md new file mode 100644 index 00000000000..1489ed87671 --- /dev/null +++ b/docs/roadmap/10-multi-edit.md @@ -0,0 +1,117 @@ +# Multi-Edit / Batch Tool + +## Branch: `feature/multi-edit` + +## Problem + +Multi-file refactors require N sequential tool calls — one per file edit. Each +call is a full round-trip through the model. For local models with high latency +per turn (2-10s on LM Studio), a 5-file rename takes 10-50 seconds of pure +model overhead. + +The model also has to maintain context about what it already changed across +multiple turns, increasing the chance of inconsistencies. + +## What it is + +Two tools: + +### `multi_edit` — batch file edits in one call + +```typescript +{ + name: 'multi_edit', + description: 'Apply multiple edits across one or more files in a single ' + + 'operation. All edits are validated before any are applied. If any ' + + 'edit fails validation, none are applied (atomic). Use this for ' + + 'refactors that touch multiple files.', + parameters: { + type: 'object', + properties: { + edits: { + type: 'array', + items: { + type: 'object', + properties: { + file_path: { type: 'string' }, + old_string: { type: 'string' }, + new_string: { type: 'string' }, + }, + required: ['file_path', 'old_string', 'new_string'], + }, + description: 'Array of edits. Each specifies file, old text, new text.', + }, + }, + required: ['edits'], + }, +} +``` + +### `batch` — run multiple tool calls in one request + +```typescript +{ + name: 'batch', + description: 'Execute multiple tool calls in a single operation. ' + + 'Read-only tools run in parallel. Write tools run sequentially. ' + + 'Use this to reduce round-trips when you need to read multiple ' + + 'files or perform independent operations.', + parameters: { + type: 'object', + properties: { + calls: { + type: 'array', + items: { + type: 'object', + properties: { + tool: { type: 'string', description: 'Tool name' }, + args: { type: 'object', description: 'Tool arguments' }, + }, + required: ['tool', 'args'], + }, + }, + }, + required: ['calls'], + }, +} +``` + +## Implementation + +### multi_edit + +1. Parse all edits +2. **Validation pass**: for each edit, verify file exists and `old_string` is + found exactly once (same as existing edit tool) +3. If all pass → apply all edits +4. If any fail → return error with which edits failed and why, apply nothing +5. Return summary: `{ applied: 5, files_modified: ['a.ts', 'b.ts', 'c.ts'] }` + +### batch + +1. Classify each call: read-only (`read_file`, `glob`, `grep`, `ls`, `repo_map`) + vs. write (`edit`, `write_file`, `shell`) +2. Run all read-only calls in parallel via `Promise.all()` +3. Run write calls sequentially in order +4. Return all results as an array matching the input order +5. If a write call fails, stop and return partial results + error + +### Safety + +- `multi_edit` gets the same user confirmation gate as single edits +- `batch` inherits confirmation from its constituent tools — if any call + requires confirmation, the batch pauses +- Maximum batch size: 20 calls (prevent runaway) + +## Files to create +- `packages/core/src/tools/multiEdit.ts` +- `packages/core/src/tools/batch.ts` +- `packages/core/src/tools/multiEdit.test.ts` +- `packages/core/src/tools/batch.test.ts` + +## Files to modify +- `packages/core/src/config/config.ts` — register tools +- `packages/core/src/tools/tool-registry.ts` — add read-only classification + method (`isReadOnly(toolName)`) for batch parallelization + +## Effort: 3-5 days diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 97be5082274..58df2bed6d0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -32,6 +32,7 @@ import { WebSearchTool } from '../tools/web-search.js'; import { SubAgentTool } from '../tools/subAgentTool.js'; import { ThinkTool } from '../tools/think.js'; import { DiffPreviewTool } from '../tools/diffPreview.js'; +import { MultiEditTool } from '../tools/multiEdit.js'; import { DeltaClient } from '../core/client.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; @@ -920,6 +921,7 @@ export class Config { registerCoreTool(SubAgentTool, this); registerCoreTool(ThinkTool); registerCoreTool(DiffPreviewTool); + registerCoreTool(MultiEditTool); await registry.discoverAllTools(); return registry; diff --git a/packages/core/src/tools/multiEdit.ts b/packages/core/src/tools/multiEdit.ts new file mode 100644 index 00000000000..4c4ffd95f7a --- /dev/null +++ b/packages/core/src/tools/multiEdit.ts @@ -0,0 +1,163 @@ +/** + * Multi-edit tool — apply multiple edits across one or more files atomically. + * + * All edits are validated before any are applied. If any edit fails validation + * (file not found, old_string not found, ambiguous match), none are applied. + * This prevents partial refactors that leave the codebase in a broken state. + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolResult, +} from './tools.js'; +import { FunctionDeclaration } from '@google/genai'; +import * as fs from 'node:fs/promises'; + +interface EditEntry { + file_path: string; + old_string: string; + new_string: string; +} + +interface MultiEditParams { + edits: EditEntry[]; +} + +const multiEditSchemaData: FunctionDeclaration = { + name: 'multi_edit', + description: + 'Apply multiple edits across one or more files in a single atomic operation. All edits are validated before any are applied — if any edit fails validation, none are applied. Use this for refactors that touch multiple files.', + parametersJsonSchema: { + type: 'object', + properties: { + edits: { + type: 'array', + items: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Absolute path to the file.' }, + old_string: { type: 'string', description: 'The exact text to be replaced.' }, + new_string: { type: 'string', description: 'The replacement text.' }, + }, + required: ['file_path', 'old_string', 'new_string'], + }, + description: 'Array of edits. Each specifies file, old text, new text.', + }, + }, + required: ['edits'], + $schema: 'http://json-schema.org/draft-07/schema#', + }, +}; + +class MultiEditInvocation extends BaseToolInvocation { + getDescription(): string { + const files = new Set(this.params.edits.map(e => e.file_path)); + return `${this.params.edits.length} edit(s) across ${files.size} file(s)`; + } + + // Confirmation is handled by the base class (returns false = no confirmation). + // Multi-edit is validated atomically — if any edit would fail, none are applied. + + async execute(): Promise { + const edits = this.params.edits; + + if (!edits || edits.length === 0) { + return { + llmContent: [{ text: 'Error: no edits provided.' }], + returnDisplay: 'Error: no edits provided.', + error: { message: 'No edits provided' }, + }; + } + + // Phase 1: Read all files and validate every edit + const fileContents = new Map(); + const errors: string[] = []; + + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]; + + // Read file if not already cached + if (!fileContents.has(edit.file_path)) { + try { + const content = await fs.readFile(edit.file_path, 'utf-8'); + fileContents.set(edit.file_path, content); + } catch { + errors.push(`Edit ${i + 1}: file not found: ${edit.file_path}`); + continue; + } + } + + const content = fileContents.get(edit.file_path)!; + const idx = content.indexOf(edit.old_string); + + if (idx === -1) { + errors.push(`Edit ${i + 1}: old_string not found in ${edit.file_path}`); + } else { + const secondIdx = content.indexOf(edit.old_string, idx + 1); + if (secondIdx !== -1) { + errors.push(`Edit ${i + 1}: old_string matches multiple locations in ${edit.file_path}`); + } + } + } + + // If any validation failed, abort all edits + if (errors.length > 0) { + const msg = `Validation failed — no edits applied:\n${errors.join('\n')}`; + return { + llmContent: [{ text: msg }], + returnDisplay: msg, + error: { message: msg }, + }; + } + + // Phase 2: Apply all edits (validated, safe to proceed) + // Group edits by file and apply sequentially within each file + const editsByFile = new Map(); + for (const edit of edits) { + if (!editsByFile.has(edit.file_path)) { + editsByFile.set(edit.file_path, []); + } + editsByFile.get(edit.file_path)!.push(edit); + } + + const modifiedFiles: string[] = []; + + for (const [filePath, fileEdits] of editsByFile) { + let content = fileContents.get(filePath)!; + + for (const edit of fileEdits) { + content = content.replace(edit.old_string, edit.new_string); + } + + await fs.writeFile(filePath, content, 'utf-8'); + modifiedFiles.push(filePath); + } + + const summary = `Applied ${edits.length} edit(s) to ${modifiedFiles.length} file(s):\n${modifiedFiles.map(f => ` - ${f}`).join('\n')}`; + + return { + llmContent: [{ text: summary }], + returnDisplay: summary, + }; + } +} + +export class MultiEditTool extends BaseDeclarativeTool { + static readonly Name: string = multiEditSchemaData.name!; + + constructor() { + super( + MultiEditTool.Name, + 'Multi Edit', + multiEditSchemaData.description!, + Kind.Edit, + multiEditSchemaData.parametersJsonSchema, + ); + } + + protected createInvocation(params: MultiEditParams) { + return new MultiEditInvocation(params); + } +}