mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(agents): Add reusable workspace edit tools (no-changelog) (#30013)
This commit is contained in:
parent
730c3e12a5
commit
ffcf63691f
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
58
packages/@n8n/agents/src/workspace/tools/str-replace-file.ts
Normal file
58
packages/@n8n/agents/src/workspace/tools/str-replace-file.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
480
packages/@n8n/ai-utilities/src/utils/text-editor.ts
Normal file
480
packages/@n8n/ai-utilities/src/utils/text-editor.ts
Normal 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, '$$$$');
|
||||
}
|
||||
}
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user