Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions docs/roadmap/10-multi-edit.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -920,6 +921,7 @@ export class Config {
registerCoreTool(SubAgentTool, this);
registerCoreTool(ThinkTool);
registerCoreTool(DiffPreviewTool);
registerCoreTool(MultiEditTool);

await registry.discoverAllTools();
return registry;
Expand Down
163 changes: 163 additions & 0 deletions packages/core/src/tools/multiEdit.ts
Original file line number Diff line number Diff line change
@@ -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<MultiEditParams, ToolResult> {
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<ToolResult> {
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<string, string>();
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<string, EditEntry[]>();
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<MultiEditParams, ToolResult> {
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);
}
}
Loading