feat(agents): Add reusable workspace edit tools (no-changelog) (#30013)

This commit is contained in:
oleg 2026-05-07 18:03:13 +02:00 committed by GitHub
parent 730c3e12a5
commit ffcf63691f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 968 additions and 589 deletions

View File

@ -38,6 +38,7 @@
"@ai-sdk/xai": "^3.0.67",
"@libsql/client": "^0.17.0",
"@modelcontextprotocol/sdk": "1.26.0",
"@n8n/ai-utilities": "workspace:*",
"@openrouter/ai-sdk-provider": "catalog:",
"ai": "^6.0.116",
"ajv": "^8.18.0",

View File

@ -45,6 +45,8 @@ describe('Workspace integration with fakes', () => {
const names = tools.map((t) => t.name);
expect(names).toContain('workspace_read_file');
expect(names).toContain('workspace_str_replace_file');
expect(names).toContain('workspace_batch_str_replace_file');
expect(names).toContain('workspace_write_file');
expect(names).toContain('workspace_list_files');
expect(names).toContain('workspace_file_stat');

View File

@ -1,3 +1,4 @@
import { zodToJsonSchema } from '../../utils/zod';
import { createWorkspaceTools } from '../../workspace/tools/workspace-tools';
import type { WorkspaceFilesystem, WorkspaceSandbox, CommandResult } from '../../workspace/types';
@ -62,6 +63,8 @@ describe('createWorkspaceTools', () => {
expect(names).toEqual([
'workspace_read_file',
'workspace_str_replace_file',
'workspace_batch_str_replace_file',
'workspace_write_file',
'workspace_list_files',
'workspace_file_stat',
@ -97,8 +100,10 @@ describe('createWorkspaceTools', () => {
const names = tools.map((t) => t.name);
expect(names).toContain('workspace_read_file');
expect(names).toContain('workspace_str_replace_file');
expect(names).toContain('workspace_batch_str_replace_file');
expect(names).toContain('workspace_execute_command');
expect(names).toHaveLength(11);
expect(names).toHaveLength(13);
});
describe('tool handlers', () => {
@ -113,6 +118,124 @@ describe('createWorkspaceTools', () => {
expect(result).toEqual({ content: 'file content' });
});
it('targeted edit input schemas serialize with a top-level object type', () => {
const tools = createWorkspaceTools({ filesystem: makeFakeFilesystem() });
const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!;
const batchStrReplaceTool = tools.find((t) => t.name === 'workspace_batch_str_replace_file')!;
expect(zodToJsonSchema(strReplaceTool.inputSchema)).toMatchObject({ type: 'object' });
expect(zodToJsonSchema(batchStrReplaceTool.inputSchema)).toMatchObject({
type: 'object',
});
});
it('str_replace_file handler reads then writes changed content', async () => {
const fs = makeFakeFilesystem({
readFile: jest.fn().mockResolvedValue('first\nsecond'),
});
const tools = createWorkspaceTools({ filesystem: fs });
const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!;
const result = await strReplaceTool.handler!(
{
path: '/test.txt',
old_str: 'second',
new_str: 'changed',
},
{} as never,
);
expect(fs.writeFile).toHaveBeenCalledWith('/test.txt', 'first\nchanged', {
overwrite: true,
});
expect(result).toEqual({ success: true, result: 'Edit applied successfully.' });
});
it('str_replace_file handler returns errors without writing when replacement is not unique', async () => {
const fs = makeFakeFilesystem({
readFile: jest.fn().mockResolvedValue('same\nsame'),
});
const tools = createWorkspaceTools({ filesystem: fs });
const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!;
const result = await strReplaceTool.handler!(
{
path: '/test.txt',
old_str: 'same',
new_str: 'changed',
},
{} as never,
);
expect(fs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
success: false,
error: 'Found 2 matches. Please provide more context to make the replacement unique.',
});
});
it('batch_str_replace_file handler applies all replacements atomically', async () => {
const fs = makeFakeFilesystem({
readFile: jest.fn().mockResolvedValue('const a = 1;\nconst b = 2;'),
});
const tools = createWorkspaceTools({ filesystem: fs });
const batchStrReplaceTool = tools.find((t) => t.name === 'workspace_batch_str_replace_file')!;
const result = await batchStrReplaceTool.handler!(
{
path: '/test.ts',
replacements: [
{ old_str: 'const a = 1;', new_str: 'const a = 10;' },
{ old_str: 'const b = 2;', new_str: 'const b = 20;' },
],
},
{} as never,
);
expect(fs.writeFile).toHaveBeenCalledWith('/test.ts', 'const a = 10;\nconst b = 20;', {
overwrite: true,
});
expect(result).toEqual({
success: true,
result: 'All 2 replacements applied successfully.',
});
});
it('batch_str_replace_file handler does not write when any replacement fails', async () => {
const fs = makeFakeFilesystem({
readFile: jest.fn().mockResolvedValue('const a = 1;\nconst b = 2;'),
});
const tools = createWorkspaceTools({ filesystem: fs });
const batchStrReplaceTool = tools.find((t) => t.name === 'workspace_batch_str_replace_file')!;
const result = await batchStrReplaceTool.handler!(
{
path: '/test.ts',
replacements: [
{ old_str: 'const a = 1;', new_str: 'const a = 10;' },
{ old_str: 'const missing = 0;', new_str: 'const missing = 1;' },
],
},
{} as never,
);
expect(fs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
success: false,
error: 'Batch replacement failed.',
results: [
{ index: 0, old_str: 'const a = 1;', status: 'success' },
{
index: 1,
old_str: 'const missing = 0;',
status: 'failed',
error:
'No exact match found for str_replace. The old_str content was not found in the file.',
},
],
});
});
it('write_file handler calls filesystem.writeFile', async () => {
const fs = makeFakeFilesystem();
const tools = createWorkspaceTools({ filesystem: fs });

View File

@ -275,6 +275,8 @@ describe('Workspace', () => {
const names = tools.map((t) => t.name);
expect(names).toContain('workspace_read_file');
expect(names).toContain('workspace_str_replace_file');
expect(names).toContain('workspace_batch_str_replace_file');
expect(names).toContain('workspace_write_file');
expect(names).toContain('workspace_list_files');
expect(names).toContain('workspace_file_stat');

View File

@ -0,0 +1,81 @@
import { TextEditorDocument, type BatchReplaceResult } from '@n8n/ai-utilities/text-editor';
import { z } from 'zod';
import { Tool } from '../../sdk/tool';
import type { BuiltTool } from '../../types/sdk/tool';
import type { WorkspaceFilesystem } from '../types';
const strReplacementSchema = z.object({
old_str: z.string().describe('Exact text to replace. Must match exactly and be unique.'),
new_str: z.string().describe('Replacement text to write in place of old_str.'),
});
const inputSchema = z.object({
path: z.string().describe('Path to the file to edit'),
replacements: z
.array(strReplacementSchema)
.describe('Ordered exact string replacements applied atomically.'),
});
const batchReplaceResultSchema = z.object({
index: z.number().int(),
old_str: z.string(),
status: z.enum(['success', 'failed', 'not_attempted']),
error: z.string().optional(),
});
const outputSchema = z.object({
success: z.boolean().describe('Whether all replacements were applied'),
result: z.string().optional().describe('Success message'),
error: z.string().optional().describe('Error message when replacements could not be applied'),
results: z
.array(batchReplaceResultSchema)
.optional()
.describe('Per-replacement statuses for a failed batch edit'),
});
type BatchStrReplaceFileOutput = z.infer<typeof outputSchema>;
function createErrorOutput(error: unknown): BatchStrReplaceFileOutput {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown workspace edit error.',
};
}
function isBatchReplaceResult(
result: string | BatchReplaceResult[],
): result is BatchReplaceResult[] {
return Array.isArray(result);
}
export function createBatchStrReplaceFileTool(filesystem: WorkspaceFilesystem): BuiltTool {
return new Tool('workspace_batch_str_replace_file')
.description(
'Apply multiple exact text replacements to a workspace file atomically. If any replacement fails, no changes are written.',
)
.input(inputSchema)
.output(outputSchema)
.handler(async (input) => {
try {
const content = await filesystem.readFile(input.path, { encoding: 'utf-8' });
const editor = new TextEditorDocument({ initialText: content.toString() });
const result = editor.executeBatch(input.replacements);
if (isBatchReplaceResult(result)) {
return { success: false, error: 'Batch replacement failed.', results: result };
}
const editedContent = editor.getText();
if (editedContent === null) {
throw new Error(`File "${input.path}" is not loaded.`);
}
await filesystem.writeFile(input.path, editedContent, { overwrite: true });
return { success: true, result };
} catch (error) {
return createErrorOutput(error);
}
})
.build();
}

View File

@ -0,0 +1,58 @@
import { TextEditorDocument } from '@n8n/ai-utilities/text-editor';
import { z } from 'zod';
import { Tool } from '../../sdk/tool';
import type { BuiltTool } from '../../types/sdk/tool';
import type { WorkspaceFilesystem } from '../types';
const inputSchema = z.object({
path: z.string().describe('Path to the file to edit'),
old_str: z.string().describe('Exact text to replace. Must match exactly and be unique.'),
new_str: z.string().describe('Replacement text to write in place of old_str.'),
});
const outputSchema = z.object({
success: z.boolean().describe('Whether the edit was applied'),
result: z.string().optional().describe('Success message'),
error: z.string().optional().describe('Error message when the edit could not be applied'),
});
type StrReplaceFileOutput = z.infer<typeof outputSchema>;
function createErrorOutput(error: unknown): StrReplaceFileOutput {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown workspace edit error.',
};
}
export function createStrReplaceFileTool(filesystem: WorkspaceFilesystem): BuiltTool {
return new Tool('workspace_str_replace_file')
.description(
'Replace one exact, unique text match in a workspace file without rewriting the whole file.',
)
.input(inputSchema)
.output(outputSchema)
.handler(async (input) => {
try {
const content = await filesystem.readFile(input.path, { encoding: 'utf-8' });
const editor = new TextEditorDocument({ initialText: content.toString() });
const result = editor.execute({
command: 'str_replace',
path: input.path,
old_str: input.old_str,
new_str: input.new_str,
});
const editedContent = editor.getText();
if (editedContent === null) {
throw new Error(`File "${input.path}" is not loaded.`);
}
await filesystem.writeFile(input.path, editedContent, { overwrite: true });
return { success: true, result };
} catch (error) {
return createErrorOutput(error);
}
})
.build();
}

View File

@ -1,6 +1,7 @@
import type { BuiltTool } from '../../types/sdk/tool';
import type { WorkspaceFilesystem, WorkspaceSandbox } from '../types';
import { createAppendFileTool } from './append-file';
import { createBatchStrReplaceFileTool } from './batch-str-replace-file';
import { createCopyFileTool } from './copy-file';
import { createDeleteFileTool } from './delete-file';
import { createExecuteCommandTool } from './execute-command';
@ -11,6 +12,7 @@ import { createMoveFileTool } from './move-file';
import { createKillProcessTool, createListProcessesTool } from './process-tools';
import { createReadFileTool } from './read-file';
import { createRmdirTool } from './rmdir';
import { createStrReplaceFileTool } from './str-replace-file';
import { createWriteFileTool } from './write-file';
interface WorkspaceLike {
@ -23,6 +25,8 @@ export function createWorkspaceTools(workspace: WorkspaceLike): BuiltTool[] {
if (workspace.filesystem) {
tools.push(createReadFileTool(workspace.filesystem));
tools.push(createStrReplaceFileTool(workspace.filesystem));
tools.push(createBatchStrReplaceFileTool(workspace.filesystem));
tools.push(createWriteFileTool(workspace.filesystem));
tools.push(createListFilesTool(workspace.filesystem));
tools.push(createFileStatTool(workspace.filesystem));

View File

@ -5,12 +5,24 @@
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"main": "dist/cjs/index.js",
"typesVersions": {
"*": {
"text-editor": [
"dist/esm/utils/text-editor.d.ts"
]
}
},
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./text-editor": {
"types": "./dist/esm/utils/text-editor.d.ts",
"import": "./dist/esm/utils/text-editor.js",
"require": "./dist/cjs/utils/text-editor.js"
},
"./*": "./*"
},
"scripts": {

View File

@ -0,0 +1,128 @@
import {
MultipleMatchesError,
NoMatchFoundError,
TextEditorDocument,
findDivergenceContext,
parseStrReplacements,
} from '../../utils/text-editor';
describe('TextEditorDocument', () => {
it('views text with line numbers', () => {
const editor = new TextEditorDocument({ initialText: 'line1\nline2\nline3' });
const result = editor.execute({ command: 'view', path: '/file.ts' });
expect(result).toBe('1: line1\n2: line2\n3: line3');
});
it('replaces a unique exact match', () => {
const editor = new TextEditorDocument({ initialText: 'const x = 1;\nconst y = 2;' });
const result = editor.execute({
command: 'str_replace',
path: '/file.ts',
old_str: 'const y = 2;',
new_str: 'const y = 3;',
});
expect(result).toBe('Edit applied successfully.');
expect(editor.getText()).toBe('const x = 1;\nconst y = 3;');
});
it('preserves dollar characters literally in replacement text', () => {
const editor = new TextEditorDocument({ initialText: 'const value = "";' });
editor.execute({
command: 'str_replace',
path: '/file.ts',
old_str: 'const value = "";',
new_str: 'const value = "$& $1 $$";',
});
expect(editor.getText()).toBe('const value = "$& $1 $$";');
});
it('rejects missing and non-unique matches', () => {
const editor = new TextEditorDocument({ initialText: 'const x = 1;\nconst x = 1;' });
expect(() =>
editor.execute({
command: 'str_replace',
path: '/file.ts',
old_str: 'const y = 2;',
new_str: 'const y = 3;',
}),
).toThrow(NoMatchFoundError);
expect(() =>
editor.execute({
command: 'str_replace',
path: '/file.ts',
old_str: 'const x = 1;',
new_str: 'const x = 2;',
}),
).toThrow(MultipleMatchesError);
});
it('inserts text after the requested line', () => {
const editor = new TextEditorDocument({ initialText: 'line1\nline3' });
const result = editor.execute({
command: 'insert',
path: '/file.ts',
insert_line: 1,
insert_text: 'line2',
});
expect(result).toBe('Text inserted successfully.');
expect(editor.getText()).toBe('line1\nline2\nline3');
});
it('applies batch replacements atomically', () => {
const original = 'const a = 1;\nconst b = 2;\nconst c = 3;';
const editor = new TextEditorDocument({ initialText: original });
const result = editor.executeBatch([
{ old_str: 'const a = 1;', new_str: 'const a = 10;' },
{ old_str: 'const missing = 0;', new_str: 'const missing = 1;' },
{ old_str: 'const c = 3;', new_str: 'const c = 30;' },
]);
expect(result).toEqual([
{ index: 0, old_str: 'const a = 1;', status: 'success' },
{
index: 1,
old_str: 'const missing = 0;',
status: 'failed',
error: expect.stringContaining('No exact match found'),
},
{ index: 2, old_str: 'const c = 3;', status: 'not_attempted' },
]);
expect(editor.getText()).toBe(original);
});
it('supports a configurable single-file path guard', () => {
const editor = new TextEditorDocument({ supportedPath: '/workflow.js' });
expect(() => editor.execute({ command: 'view', path: '/other.js' })).toThrow(
'Only /workflow.js is supported',
);
});
});
describe('text editor helpers', () => {
it('finds useful divergence context', () => {
const result = findDivergenceContext('line1\nline2\nline3', 'line1\nline2\nwrong');
expect(result).toContain('line 3');
expect(result).toContain('line3');
});
it('parses replacements from arrays and JSON strings', () => {
const arrayResult = parseStrReplacements([{ old_str: 'old', new_str: 'new' }]);
const stringResult = parseStrReplacements('[{"old_str":"old","new_str":"new"}]');
expect(arrayResult).toEqual([{ old_str: 'old', new_str: 'new' }]);
expect(stringResult).toEqual([{ old_str: 'old', new_str: 'new' }]);
});
});

View File

@ -13,6 +13,34 @@ export { getMetadataFiltersValues, hasLongSequentialRepeat } from './utils/helpe
export { N8nBinaryLoader } from './utils/n8n-binary-loader';
export { N8nJsonLoader } from './utils/n8n-json-loader';
export { N8nLlmTracing } from './utils/n8n-llm-tracing';
export {
TextEditorDocument,
NoMatchFoundError,
MultipleMatchesError,
InvalidLineNumberError,
InvalidViewRangeError,
InvalidPathError,
FileExistsError,
FileNotFoundError,
BatchReplacementError,
formatTextWithLineNumbers,
findDivergenceContext,
parseStrReplacements,
} from './utils/text-editor';
export type {
ViewCommand,
CreateCommand,
StrReplaceCommand,
InsertCommand,
BatchStrReplaceCommand,
TextEditorCommand,
TextEditorCommandWithBatch,
TextEditorToolCall,
TextEditorResult,
StrReplacement,
BatchReplaceResult,
TextEditorDocumentOptions,
} from './utils/text-editor';
export {
estimateTokensFromStringList,
estimateTokensByCharCount,

View File

@ -0,0 +1,480 @@
export interface ViewCommand {
command: 'view';
path: string;
/** Optional line range to view [start, end] (1-indexed, inclusive). */
view_range?: [number, number];
}
export interface CreateCommand {
command: 'create';
path: string;
file_text: string;
}
export interface StrReplaceCommand {
command: 'str_replace';
path: string;
old_str: string;
new_str: string;
}
export interface InsertCommand {
command: 'insert';
path: string;
/** Line number after which to insert (0 = beginning of file). */
insert_line: number;
insert_text: string;
}
export interface BatchStrReplaceCommand {
command: 'batch_str_replace';
path: string;
replacements: StrReplacement[];
}
export type TextEditorCommand = ViewCommand | CreateCommand | StrReplaceCommand | InsertCommand;
export type TextEditorCommandWithBatch = TextEditorCommand | BatchStrReplaceCommand;
export interface TextEditorToolCall {
name: 'str_replace_based_edit_tool';
args: TextEditorCommand;
id: string;
}
export interface TextEditorResult {
content: string;
}
export class NoMatchFoundError extends Error {
constructor(_searchStr: string, nearMatchContext?: string) {
const base =
'No exact match found for str_replace. The old_str content was not found in the file.';
const message = nearMatchContext ? `${base}\n${nearMatchContext}` : base;
super(message);
this.name = 'NoMatchFoundError';
}
}
export class MultipleMatchesError extends Error {
constructor(count: number) {
super(`Found ${count} matches. Please provide more context to make the replacement unique.`);
this.name = 'MultipleMatchesError';
}
}
export class InvalidLineNumberError extends Error {
constructor(line: number, maxLine: number) {
super(`Invalid line number ${line}. File has ${maxLine} lines (valid range: 0-${maxLine}).`);
this.name = 'InvalidLineNumberError';
}
}
export class InvalidViewRangeError extends Error {
constructor(start: number, end: number, maxLine: number) {
super(
`Invalid view range: end (${end}) must be >= start (${start}). File has ${maxLine} lines (valid range: 1-${maxLine}).`,
);
this.name = 'InvalidViewRangeError';
}
}
export class InvalidPathError extends Error {
constructor(path: string, supportedPath = '/workflow.js', message?: string) {
super(message ?? `Invalid path "${path}". Only ${supportedPath} is supported.`);
this.name = 'InvalidPathError';
}
}
export class FileExistsError extends Error {
constructor() {
super('File already exists. Use text editor tools to modify existing content.');
this.name = 'FileExistsError';
}
}
export class FileNotFoundError extends Error {
constructor(message = 'No workflow code exists yet. Use create first.') {
super(message);
this.name = 'FileNotFoundError';
}
}
export interface StrReplacement {
old_str: string;
new_str: string;
}
export interface BatchReplaceResult {
index: number;
/** Truncated preview of old_str for context. */
old_str: string;
status: 'success' | 'failed' | 'not_attempted';
error?: string;
}
export class BatchReplacementError extends Error {
readonly failedIndex: number;
readonly totalCount: number;
override readonly cause: NoMatchFoundError | MultipleMatchesError;
constructor(
failedIndex: number,
totalCount: number,
cause: NoMatchFoundError | MultipleMatchesError,
) {
super(
`Batch replacement failed at index ${failedIndex} of ${totalCount}: ${cause.message}. All changes have been rolled back.`,
);
this.name = 'BatchReplacementError';
this.failedIndex = failedIndex;
this.totalCount = totalCount;
this.cause = cause;
}
}
export interface TextEditorDocumentOptions {
initialText?: string | null;
supportedPath?: string;
createInvalidPathMessage?: (path: string, supportedPath: string) => string;
invalidPathMessage?: (path: string, supportedPath: string) => string;
fileNotFoundMessage?: string;
}
const PREVIEW_MAX_LENGTH = 80;
const MIN_PREFIX_LENGTH = 10;
const CONTEXT_LINES = 3;
const OLD_STR_CONTEXT_LENGTH = 40;
function truncatePreview(str: string): string {
if (str.length <= PREVIEW_MAX_LENGTH) return str;
return str.slice(0, PREVIEW_MAX_LENGTH) + '...';
}
function escapeWhitespace(str: string): string {
return str.replace(/\n/g, '\\n').replace(/\t/g, '\\t').replace(/\r/g, '\\r');
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function formatTextWithLineNumbers(text: string): string {
const lines = text.split('\n');
return lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
}
export function findDivergenceContext(code: string, searchStr: string): string | undefined {
let lo = 0;
let hi = searchStr.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (code.includes(searchStr.substring(0, mid))) {
lo = mid;
} else {
hi = mid - 1;
}
}
const matchLength = lo;
if (matchLength < MIN_PREFIX_LENGTH) return undefined;
const matchPos = code.indexOf(searchStr.substring(0, matchLength));
const divergePos = matchPos + matchLength;
const percentage = Math.round((matchLength / searchStr.length) * 100);
const codeUpToDiverge = code.substring(0, divergePos);
const divergeLine = codeUpToDiverge.split('\n').length;
const oldStrRemainder = searchStr.substring(matchLength, matchLength + OLD_STR_CONTEXT_LENGTH);
const oldStrPrefix = searchStr.substring(Math.max(0, matchLength - 20), matchLength);
const codeLines = code.split('\n');
const startLine = Math.max(0, divergeLine - CONTEXT_LINES);
const endLine = Math.min(codeLines.length, divergeLine + 1);
const fileContext = codeLines
.slice(startLine, endLine)
.map((line, i) => ` ${startLine + i + 1}: ${line}`)
.join('\n');
return (
`Closest match (${percentage}% of old_str matched, diverges at line ${divergeLine}):\n` +
` old_str: ...${escapeWhitespace(oldStrPrefix)}>>> ${escapeWhitespace(oldStrRemainder)}\n` +
` file:\n${fileContext}`
);
}
export function parseStrReplacements(raw: unknown): StrReplacement[] {
let parsed = raw;
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed);
} catch {
throw new Error(
'replacements must be a JSON array of {old_str, new_str} objects, but received an invalid JSON string.',
);
}
}
if (!Array.isArray(parsed)) {
throw new Error(
'replacements must be an array of {old_str, new_str} objects. Example: {"replacements": [{"old_str": "foo", "new_str": "bar"}]}',
);
}
const replacements: StrReplacement[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
if (!isObjectRecord(item) || typeof item.old_str !== 'string') {
throw new Error(
`replacements[${i}] is missing a valid "old_str" string. Each replacement must have {old_str: string, new_str: string}.`,
);
}
if (typeof item.new_str !== 'string') {
throw new Error(
`replacements[${i}] is missing a valid "new_str" string. Each replacement must have {old_str: string, new_str: string}.`,
);
}
replacements.push({ old_str: item.old_str, new_str: item.new_str });
}
return replacements;
}
export class TextEditorDocument {
private text: string | null;
private readonly options: TextEditorDocumentOptions;
constructor(options: TextEditorDocumentOptions = {}) {
this.text = options.initialText ?? null;
this.options = options;
}
execute(command: TextEditorCommand): string {
if (command.command === 'create') {
this.validateCreatePath(command.path);
}
this.validatePath(command.path);
switch (command.command) {
case 'view':
return this.handleView(command);
case 'create':
return this.handleCreate(command);
case 'str_replace':
return this.handleStrReplace(command);
case 'insert':
return this.handleInsert(command);
}
}
executeBatch(replacements: StrReplacement[]): string | BatchReplaceResult[] {
if (this.text === null) {
throw this.createFileNotFoundError();
}
if (replacements.length === 0) {
return 'No replacements to apply.';
}
const snapshot = this.text;
const results: BatchReplaceResult[] = [];
for (let i = 0; i < replacements.length; i++) {
const { old_str, new_str } = replacements[i];
const preview = truncatePreview(old_str);
const count = this.countOccurrences(this.text, old_str);
if (count === 0) {
this.text = snapshot;
results.push({
index: i,
old_str: preview,
status: 'failed',
error: new NoMatchFoundError(old_str).message,
});
this.appendNotAttemptedResults(results, replacements, i + 1);
return results;
}
if (count > 1) {
this.text = snapshot;
results.push({
index: i,
old_str: preview,
status: 'failed',
error: new MultipleMatchesError(count).message,
});
this.appendNotAttemptedResults(results, replacements, i + 1);
return results;
}
this.text = this.text.replace(old_str, this.escapeReplacement(new_str));
results.push({ index: i, old_str: preview, status: 'success' });
}
return `All ${replacements.length} replacements applied successfully.`;
}
getText(): string | null {
return this.text;
}
setText(text: string): void {
this.text = text;
}
hasText(): boolean {
return this.text !== null;
}
clearText(): void {
this.text = null;
}
private validateCreatePath(path: string): void {
const supportedPath = this.options.supportedPath;
if (supportedPath === undefined || path === supportedPath) {
return;
}
throw new Error(
this.options.createInvalidPathMessage?.(path, supportedPath) ??
`Cannot create "${path}". Only ${supportedPath} is supported.`,
);
}
private validatePath(path: string): void {
const supportedPath = this.options.supportedPath;
if (supportedPath === undefined || path === supportedPath) {
return;
}
throw new InvalidPathError(
path,
supportedPath,
this.options.invalidPathMessage?.(path, supportedPath),
);
}
private handleView(command: ViewCommand): string {
if (this.text === null) {
throw this.createFileNotFoundError();
}
const lines = this.text.split('\n');
if (command.view_range) {
const [start, rawEnd] = command.view_range;
const end = rawEnd === -1 ? lines.length : rawEnd;
if (start < 1 || start > lines.length) {
throw new InvalidLineNumberError(start, lines.length);
}
if (end < start) {
throw new InvalidViewRangeError(start, end, lines.length);
}
const startIndex = start - 1;
const endIndex = Math.min(end, lines.length);
const selectedLines = lines.slice(startIndex, endIndex);
return selectedLines.map((line, i) => `${startIndex + i + 1}: ${line}`).join('\n');
}
return formatTextWithLineNumbers(this.text);
}
private handleCreate(command: CreateCommand): string {
this.text = command.file_text;
return 'File created successfully.';
}
private handleStrReplace(command: StrReplaceCommand): string {
if (this.text === null) {
throw this.createFileNotFoundError();
}
const { old_str, new_str } = command;
const count = this.countOccurrences(this.text, old_str);
if (count === 0) {
const normalized = old_str.endsWith('\n') ? old_str.slice(0, -1) : old_str + '\n';
const normalizedCount = this.countOccurrences(this.text, normalized);
if (normalizedCount === 1) {
this.text = this.text.replace(normalized, this.escapeReplacement(new_str));
return 'Edit applied successfully.';
}
const context = findDivergenceContext(this.text, old_str);
throw new NoMatchFoundError(old_str, context);
}
if (count > 1) {
throw new MultipleMatchesError(count);
}
this.text = this.text.replace(old_str, this.escapeReplacement(new_str));
return 'Edit applied successfully.';
}
private handleInsert(command: InsertCommand): string {
if (this.text === null) {
throw this.createFileNotFoundError();
}
const { insert_line, insert_text } = command;
const lines = this.text.split('\n');
if (insert_line < 0 || insert_line > lines.length) {
throw new InvalidLineNumberError(insert_line, lines.length);
}
lines.splice(insert_line, 0, insert_text);
this.text = lines.join('\n');
return 'Text inserted successfully.';
}
private countOccurrences(text: string, search: string): number {
if (search.length === 0) {
return 0;
}
let count = 0;
let pos = 0;
while ((pos = text.indexOf(search, pos)) !== -1) {
count++;
pos += search.length;
}
return count;
}
private appendNotAttemptedResults(
results: BatchReplaceResult[],
replacements: StrReplacement[],
startIndex: number,
): void {
for (let i = startIndex; i < replacements.length; i++) {
results.push({
index: i,
old_str: truncatePreview(replacements[i].old_str),
status: 'not_attempted',
});
}
}
private createFileNotFoundError(): FileNotFoundError {
return new FileNotFoundError(this.options.fileNotFoundMessage);
}
private escapeReplacement(replacement: string): string {
return replacement.replace(/\$/g, '$$$$');
}
}

View File

@ -60,6 +60,7 @@
"@langchain/openai": "catalog:",
"@mozilla/readability": "0.6.0",
"@n8n/api-types": "workspace:*",
"@n8n/ai-utilities": "workspace:*",
"@n8n/backend-common": "workspace:*",
"@n8n/config": "workspace:*",
"@n8n/di": "workspace:*",

View File

@ -1,391 +1,40 @@
/**
* Text Editor Handler
*
* Handles text editor tool commands for the code builder agent.
* Implements the Anthropic str_replace_based_edit_tool interface for
* managing workflow code as a virtual file (/workflow.js).
*/
import type {
TextEditorCommand,
ViewCommand,
CreateCommand,
StrReplaceCommand,
InsertCommand,
StrReplacement,
BatchReplaceResult,
} from './text-editor.types';
import {
NoMatchFoundError,
MultipleMatchesError,
InvalidLineNumberError,
InvalidViewRangeError,
InvalidPathError,
FileNotFoundError,
} from './text-editor.types';
TextEditorDocument,
findDivergenceContext,
formatTextWithLineNumbers,
} from '@n8n/ai-utilities/text-editor';
/** The only supported file path for workflow code */
const WORKFLOW_FILE_PATH = '/workflow.js';
/** Max length for old_str previews in batch results */
const PREVIEW_MAX_LENGTH = 80;
function truncatePreview(str: string): string {
if (str.length <= PREVIEW_MAX_LENGTH) return str;
return str.slice(0, PREVIEW_MAX_LENGTH) + '...';
}
export { findDivergenceContext };
export { formatTextWithLineNumbers as formatCodeWithLineNumbers };
/**
* Format code with line numbers (matches view command output)
*
* @param code - The code to format
* @returns Code with line numbers in "N: content" format
* Manages the virtual workflow code file used by the code builder agent.
*/
export function formatCodeWithLineNumbers(code: string): string {
const lines = code.split('\n');
return lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
}
/** Min prefix length to consider a near-match useful */
const MIN_PREFIX_LENGTH = 10;
/** Number of file lines to show around divergence point */
const CONTEXT_LINES = 3;
/** Max chars of old_str to show around divergence */
const OLD_STR_CONTEXT_LENGTH = 40;
function escapeWhitespace(str: string): string {
return str.replace(/\n/g, '\\n').replace(/\t/g, '\\t').replace(/\r/g, '\\r');
}
/**
* Find where old_str diverges from the code and return diagnostic context.
* Uses binary search for the longest prefix of searchStr that exists in code,
* then shows the divergence point with actual file lines.
*/
export function findDivergenceContext(code: string, searchStr: string): string | undefined {
// Binary search for longest matching prefix
let lo = 0;
let hi = searchStr.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (code.includes(searchStr.substring(0, mid))) {
lo = mid;
} else {
hi = mid - 1;
}
}
const matchLength = lo;
if (matchLength < MIN_PREFIX_LENGTH) return undefined;
const matchPos = code.indexOf(searchStr.substring(0, matchLength));
const divergePos = matchPos + matchLength;
const percentage = Math.round((matchLength / searchStr.length) * 100);
// Compute divergence line number (1-indexed)
const codeUpToDiverge = code.substring(0, divergePos);
const divergeLine = codeUpToDiverge.split('\n').length;
// Extract old_str context around divergence
const oldStrRemainder = searchStr.substring(matchLength, matchLength + OLD_STR_CONTEXT_LENGTH);
const oldStrPrefix = searchStr.substring(Math.max(0, matchLength - 20), matchLength);
// Extract full file lines around divergence
const codeLines = code.split('\n');
const startLine = Math.max(0, divergeLine - CONTEXT_LINES);
const endLine = Math.min(codeLines.length, divergeLine + 1);
const fileContext = codeLines
.slice(startLine, endLine)
.map((line, i) => ` ${startLine + i + 1}: ${line}`)
.join('\n');
return (
`Closest match (${percentage}% of old_str matched, diverges at line ${divergeLine}):\n` +
` old_str: ...${escapeWhitespace(oldStrPrefix)}>>> ${escapeWhitespace(oldStrRemainder)}\n` +
` file:\n${fileContext}`
);
}
/**
* Handler for text editor tool commands
*
* Manages a single virtual file (/workflow.js) containing workflow SDK code.
* Supports view, create, str_replace, and insert commands.
*/
export class TextEditorHandler {
private code: string | null = null;
constructor() {}
/**
* Execute a text editor command
*
* @param command - The command to execute
* @returns Result message for the LLM
* @throws Various errors for invalid operations
*/
execute(command: TextEditorCommand): string {
// Create-specific path validation with a more helpful error message
if (command.command === 'create' && command.path !== WORKFLOW_FILE_PATH) {
throw new Error(
export class TextEditorHandler extends TextEditorDocument {
constructor() {
super({
supportedPath: WORKFLOW_FILE_PATH,
createInvalidPathMessage: () =>
'Cannot create multiple workflows. You can only extend the existing workflow at /workflow.js.',
);
}
// Validate path for all commands
this.validatePath(command.path);
let result: string;
switch (command.command) {
case 'view':
result = this.handleView(command);
break;
case 'create':
result = this.handleCreate(command);
break;
case 'str_replace':
result = this.handleStrReplace(command);
break;
case 'insert':
result = this.handleInsert(command);
break;
default:
result = `Unknown command: ${(command as { command: string }).command}`;
}
return result;
fileNotFoundMessage: 'No workflow code exists yet. Use create first.',
});
}
/**
* Validate that the path is the supported workflow file
*/
private validatePath(path: string): void {
if (path !== WORKFLOW_FILE_PATH) {
throw new InvalidPathError(path);
}
}
/**
* Handle view command - display file content with line numbers
*/
private handleView(command: ViewCommand): string {
if (!this.code) {
throw new FileNotFoundError();
}
const lines = this.code.split('\n');
// Handle view_range if specified
if (command.view_range) {
const [start, rawEnd] = command.view_range;
const end = rawEnd === -1 ? lines.length : rawEnd;
// Validate range (1-indexed)
if (start < 1 || start > lines.length) {
throw new InvalidLineNumberError(start, lines.length);
}
if (end < start) {
throw new InvalidViewRangeError(start, end, lines.length);
}
// Convert to 0-indexed and extract range
const startIdx = start - 1;
const endIdx = Math.min(end, lines.length);
const selectedLines = lines.slice(startIdx, endIdx);
return selectedLines.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
}
// Return full file with line numbers
return formatCodeWithLineNumbers(this.code);
}
/**
* Handle create command - create or overwrite the workflow file
*/
private handleCreate(command: CreateCommand): string {
this.code = command.file_text;
return 'File created successfully.';
}
/**
* Handle str_replace command - replace exact string match
*/
private handleStrReplace(command: StrReplaceCommand): string {
if (this.code === null) {
throw new FileNotFoundError();
}
const { old_str, new_str } = command;
// Count occurrences
const count = this.countOccurrences(this.code, old_str);
if (count === 0) {
// Try toggling trailing newline — common LLM mistake
const normalized = old_str.endsWith('\n') ? old_str.slice(0, -1) : old_str + '\n';
const normalizedCount = this.countOccurrences(this.code, normalized);
if (normalizedCount === 1) {
const escapedNew = new_str.replace(/\$/g, '$$$$');
this.code = this.code.replace(normalized, escapedNew);
return 'Edit applied successfully.';
}
const context = findDivergenceContext(this.code, old_str);
throw new NoMatchFoundError(old_str, context);
}
if (count > 1) {
throw new MultipleMatchesError(count);
}
// Replace the single occurrence
// Escape $ characters in new_str to prevent special replacement patterns
// ($', $&, $`, $1-$9) from being interpreted by String.prototype.replace()
const escapedNewStr = new_str.replace(/\$/g, '$$$$');
this.code = this.code.replace(old_str, escapedNewStr);
return 'Edit applied successfully.';
}
/**
* Handle insert command - insert text at specific line
*/
private handleInsert(command: InsertCommand): string {
if (this.code === null) {
throw new FileNotFoundError();
}
const { insert_line, insert_text } = command;
const lines = this.code.split('\n');
// Validate line number (0 = beginning, 1-n = after that line)
if (insert_line < 0 || insert_line > lines.length) {
throw new InvalidLineNumberError(insert_line, lines.length);
}
// Insert at the specified position
lines.splice(insert_line, 0, insert_text);
this.code = lines.join('\n');
return 'Text inserted successfully.';
}
/**
* Count non-overlapping occurrences of a substring
*/
private countOccurrences(text: string, search: string): number {
if (search.length === 0) {
return 0;
}
let count = 0;
let pos = 0;
while ((pos = text.indexOf(search, pos)) !== -1) {
count++;
pos += search.length;
}
return count;
}
/**
* Apply multiple str_replace operations atomically.
* Rolls back all changes if any single replacement fails.
*
* @param replacements - Ordered list of replacements to apply
* @returns Success message string when all succeed, or BatchReplaceResult[] on failure
* @throws FileNotFoundError if no code exists
*/
executeBatch(replacements: StrReplacement[]): string | BatchReplaceResult[] {
if (this.code === null) {
throw new FileNotFoundError();
}
if (replacements.length === 0) {
return 'No replacements to apply.';
}
const snapshot = this.code;
const results: BatchReplaceResult[] = [];
for (let i = 0; i < replacements.length; i++) {
const { old_str, new_str } = replacements[i];
const preview = truncatePreview(old_str);
const count = this.countOccurrences(this.code, old_str);
if (count === 0) {
this.code = snapshot;
results.push({
index: i,
old_str: preview,
status: 'failed',
error: new NoMatchFoundError(old_str).message,
});
for (let j = i + 1; j < replacements.length; j++) {
results.push({
index: j,
old_str: truncatePreview(replacements[j].old_str),
status: 'not_attempted',
});
}
return results;
}
if (count > 1) {
this.code = snapshot;
results.push({
index: i,
old_str: preview,
status: 'failed',
error: new MultipleMatchesError(count).message,
});
for (let j = i + 1; j < replacements.length; j++) {
results.push({
index: j,
old_str: truncatePreview(replacements[j].old_str),
status: 'not_attempted',
});
}
return results;
}
const escapedNewStr = new_str.replace(/\$/g, '$$$$');
this.code = this.code.replace(old_str, escapedNewStr);
results.push({ index: i, old_str: preview, status: 'success' });
}
return `All ${replacements.length} replacements applied successfully.`;
}
/**
* Get the current workflow code
*/
getWorkflowCode(): string | null {
return this.code;
return this.getText();
}
/**
* Set the workflow code (for pre-populating with existing workflow)
*/
setWorkflowCode(code: string): void {
this.code = code;
this.setText(code);
}
/**
* Check if workflow code exists
*/
hasWorkflowCode(): boolean {
return this.code !== null;
return this.hasText();
}
/**
* Clear the workflow code
*/
clearWorkflowCode(): void {
this.code = null;
this.clearText();
}
}

View File

@ -1,187 +1,22 @@
/**
* Text Editor Tool Types
*
* Type definitions for the Anthropic str_replace_based_edit_tool (text_editor_20250728)
* used by the code builder agent for targeted workflow code edits.
*/
export {
NoMatchFoundError,
MultipleMatchesError,
InvalidLineNumberError,
InvalidViewRangeError,
InvalidPathError,
FileExistsError,
FileNotFoundError,
BatchReplacementError,
} from '@n8n/ai-utilities/text-editor';
/**
* View command - displays file content with line numbers
*/
export interface ViewCommand {
command: 'view';
path: string;
/** Optional line range to view [start, end] (1-indexed, inclusive) */
view_range?: [number, number];
}
/**
* Create command - creates a new file with content
*/
export interface CreateCommand {
command: 'create';
path: string;
file_text: string;
}
/**
* String replace command - replaces exact text match
*/
export interface StrReplaceCommand {
command: 'str_replace';
path: string;
old_str: string;
new_str: string;
}
/**
* Insert command - inserts text at a specific line
*/
export interface InsertCommand {
command: 'insert';
path: string;
/** Line number after which to insert (0 = beginning of file) */
insert_line: number;
insert_text: string;
}
/**
* Union type for all text editor commands
*/
export type TextEditorCommand = ViewCommand | CreateCommand | StrReplaceCommand | InsertCommand;
/**
* Text editor tool call from LLM response
*/
export interface TextEditorToolCall {
name: 'str_replace_based_edit_tool';
args: TextEditorCommand;
id: string;
}
/**
* Result from text editor command execution
*/
export interface TextEditorResult {
/** Result message to send back to the LLM */
content: string;
}
/**
* Error thrown when no match is found for str_replace
*/
export class NoMatchFoundError extends Error {
constructor(_searchStr: string, nearMatchContext?: string) {
const base =
'No exact match found for str_replace. The old_str content was not found in the file.';
const message = nearMatchContext ? `${base}\n${nearMatchContext}` : base;
super(message);
this.name = 'NoMatchFoundError';
}
}
/**
* Error thrown when multiple matches are found for str_replace
*/
export class MultipleMatchesError extends Error {
constructor(count: number) {
super(`Found ${count} matches. Please provide more context to make the replacement unique.`);
this.name = 'MultipleMatchesError';
}
}
/**
* Error thrown for invalid line numbers
*/
export class InvalidLineNumberError extends Error {
constructor(line: number, maxLine: number) {
super(`Invalid line number ${line}. File has ${maxLine} lines (valid range: 0-${maxLine}).`);
this.name = 'InvalidLineNumberError';
}
}
/**
* Error thrown when view_range end is less than start
*/
export class InvalidViewRangeError extends Error {
constructor(start: number, end: number, maxLine: number) {
super(
`Invalid view range: end (${end}) must be >= start (${start}). File has ${maxLine} lines (valid range: 1-${maxLine}).`,
);
this.name = 'InvalidViewRangeError';
}
}
/**
* Error thrown for invalid file paths
*/
export class InvalidPathError extends Error {
constructor(path: string) {
super(`Invalid path "${path}". Only /workflow.js is supported.`);
this.name = 'InvalidPathError';
}
}
/**
* Error thrown when file already exists for create command
*/
export class FileExistsError extends Error {
constructor() {
super('File already exists. Use text editor tools to modify existing content.');
this.name = 'FileExistsError';
}
}
/**
* Error thrown when file doesn't exist for edit commands
*/
export class FileNotFoundError extends Error {
constructor() {
super('No workflow code exists yet. Use create first.');
this.name = 'FileNotFoundError';
}
}
/**
* A single replacement in a batch operation
*/
export interface StrReplacement {
old_str: string;
new_str: string;
}
/**
* Per-replacement result in a batch operation.
* Returned as an array when any replacement fails.
*/
export interface BatchReplaceResult {
index: number;
/** Truncated preview of old_str for context */
old_str: string;
status: 'success' | 'failed' | 'not_attempted';
error?: string;
}
/**
* Error thrown when a batch replacement fails.
* Contains the index that failed and the underlying cause.
*/
export class BatchReplacementError extends Error {
readonly failedIndex: number;
readonly totalCount: number;
override readonly cause: NoMatchFoundError | MultipleMatchesError;
constructor(
failedIndex: number,
totalCount: number,
cause: NoMatchFoundError | MultipleMatchesError,
) {
super(
`Batch replacement failed at index ${failedIndex} of ${totalCount}: ${cause.message}. All changes have been rolled back.`,
);
this.name = 'BatchReplacementError';
this.failedIndex = failedIndex;
this.totalCount = totalCount;
this.cause = cause;
}
}
export type {
ViewCommand,
CreateCommand,
StrReplaceCommand,
InsertCommand,
TextEditorCommand,
TextEditorToolCall,
TextEditorResult,
StrReplacement,
BatchReplaceResult,
} from '@n8n/ai-utilities/text-editor';

View File

@ -8,6 +8,7 @@
import type { BaseMessage } from '@langchain/core/messages';
import { ToolMessage } from '@langchain/core/messages';
import type { StructuredToolInterface } from '@langchain/core/tools';
import { parseStrReplacements } from '@n8n/ai-utilities/text-editor';
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { TextEditorHandler } from './text-editor-handler';
@ -22,39 +23,7 @@ import type { WarningTracker } from '../state/warning-tracker';
* Handles the case where the LLM sends a JSON string instead of an array.
*/
export function parseReplacements(raw: unknown): StrReplacement[] {
let parsed: unknown = raw;
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed);
} catch {
throw new Error(
'replacements must be a JSON array of {old_str, new_str} objects, but received an invalid JSON string.',
);
}
}
if (!Array.isArray(parsed)) {
throw new Error(
'replacements must be an array of {old_str, new_str} objects. Example: {"replacements": [{"old_str": "foo", "new_str": "bar"}]}',
);
}
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i] as Record<string, unknown>;
if (typeof item?.old_str !== 'string') {
throw new Error(
`replacements[${i}] is missing a valid "old_str" string. Each replacement must have {old_str: string, new_str: string}.`,
);
}
if (typeof item?.new_str !== 'string') {
throw new Error(
`replacements[${i}] is missing a valid "new_str" string. Each replacement must have {old_str: string, new_str: string}.`,
);
}
}
return parsed as StrReplacement[];
return parseStrReplacements(raw);
}
/**

View File

@ -643,6 +643,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: 1.26.0
version: 1.26.0(zod@3.25.67)
'@n8n/ai-utilities':
specifier: workspace:*
version: link:../ai-utilities
'@openrouter/ai-sdk-provider':
specifier: 'catalog:'
version: 2.8.1(ai@6.0.134(zod@3.25.67))(zod@3.25.67)
@ -778,6 +781,9 @@ importers:
'@mozilla/readability':
specifier: 0.6.0
version: 0.6.0
'@n8n/ai-utilities':
specifier: workspace:*
version: link:../ai-utilities
'@n8n/api-types':
specifier: workspace:*
version: link:../api-types