From ffcf63691fca23082e28f95c76553b813839db89 Mon Sep 17 00:00:00 2001 From: oleg Date: Thu, 7 May 2026 18:03:13 +0200 Subject: [PATCH] feat(agents): Add reusable workspace edit tools (no-changelog) (#30013) --- packages/@n8n/agents/package.json | 1 + .../workspace/workspace-integration.test.ts | 2 + .../workspace/workspace-tools.test.ts | 125 ++++- .../src/__tests__/workspace/workspace.test.ts | 2 + .../workspace/tools/batch-str-replace-file.ts | 81 +++ .../src/workspace/tools/str-replace-file.ts | 58 +++ .../src/workspace/tools/workspace-tools.ts | 4 + packages/@n8n/ai-utilities/package.json | 12 + .../src/__tests__/utils/text-editor.test.ts | 128 +++++ packages/@n8n/ai-utilities/src/index.ts | 28 + .../ai-utilities/src/utils/text-editor.ts | 480 ++++++++++++++++++ .../@n8n/ai-workflow-builder.ee/package.json | 1 + .../handlers/text-editor-handler.ts | 387 +------------- .../handlers/text-editor.types.ts | 207 +------- .../handlers/tool-dispatch-handler.ts | 35 +- pnpm-lock.yaml | 6 + 16 files changed, 968 insertions(+), 589 deletions(-) create mode 100644 packages/@n8n/agents/src/workspace/tools/batch-str-replace-file.ts create mode 100644 packages/@n8n/agents/src/workspace/tools/str-replace-file.ts create mode 100644 packages/@n8n/ai-utilities/src/__tests__/utils/text-editor.test.ts create mode 100644 packages/@n8n/ai-utilities/src/utils/text-editor.ts diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index 78ed6f76a5e..fb2d08f54a1 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -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", diff --git a/packages/@n8n/agents/src/__tests__/workspace/workspace-integration.test.ts b/packages/@n8n/agents/src/__tests__/workspace/workspace-integration.test.ts index 5d4d49de2c7..c80c3c7cc99 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/workspace-integration.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/workspace-integration.test.ts @@ -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'); diff --git a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts index 479574e1420..0316bc47ec7 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts @@ -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 }); diff --git a/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts b/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts index 9583dad3d03..d39c78d956e 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts @@ -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'); diff --git a/packages/@n8n/agents/src/workspace/tools/batch-str-replace-file.ts b/packages/@n8n/agents/src/workspace/tools/batch-str-replace-file.ts new file mode 100644 index 00000000000..59276e4e0ca --- /dev/null +++ b/packages/@n8n/agents/src/workspace/tools/batch-str-replace-file.ts @@ -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; + +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(); +} diff --git a/packages/@n8n/agents/src/workspace/tools/str-replace-file.ts b/packages/@n8n/agents/src/workspace/tools/str-replace-file.ts new file mode 100644 index 00000000000..f9128d87871 --- /dev/null +++ b/packages/@n8n/agents/src/workspace/tools/str-replace-file.ts @@ -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; + +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(); +} diff --git a/packages/@n8n/agents/src/workspace/tools/workspace-tools.ts b/packages/@n8n/agents/src/workspace/tools/workspace-tools.ts index 4cebad0e4ce..afc4c4667bb 100644 --- a/packages/@n8n/agents/src/workspace/tools/workspace-tools.ts +++ b/packages/@n8n/agents/src/workspace/tools/workspace-tools.ts @@ -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)); diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index b01e82e5e51..87ccaa3f36e 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -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": { diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/text-editor.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/text-editor.test.ts new file mode 100644 index 00000000000..c9b9f0c1810 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/text-editor.test.ts @@ -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' }]); + }); +}); diff --git a/packages/@n8n/ai-utilities/src/index.ts b/packages/@n8n/ai-utilities/src/index.ts index fad0c46c57f..21be845c8b1 100644 --- a/packages/@n8n/ai-utilities/src/index.ts +++ b/packages/@n8n/ai-utilities/src/index.ts @@ -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, diff --git a/packages/@n8n/ai-utilities/src/utils/text-editor.ts b/packages/@n8n/ai-utilities/src/utils/text-editor.ts new file mode 100644 index 00000000000..631b1081d07 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/utils/text-editor.ts @@ -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 { + 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, '$$$$'); + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json index 3aa55cdcf95..a591192c74e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/package.json +++ b/packages/@n8n/ai-workflow-builder.ee/package.json @@ -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:*", diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor-handler.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor-handler.ts index f9ace47fb07..302be71ae2b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor-handler.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor-handler.ts @@ -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(); } } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor.types.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor.types.ts index be5b6f205af..db6ab654ff3 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor.types.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/text-editor.types.ts @@ -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'; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/tool-dispatch-handler.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/tool-dispatch-handler.ts index 4a84eb5c862..3ffed75bb19 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/tool-dispatch-handler.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/handlers/tool-dispatch-handler.ts @@ -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; - 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); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa756b69c28..0b35224faec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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