From a4331b85e595b024cb811f6661b3a3e1543c52c8 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 3 Mar 2025 16:21:41 -0500 Subject: [PATCH 1/2] Add textEditor tool that combines readFile and updateFile functionality --- .changeset/text-editor.md | 5 + packages/agent/src/tools/getTools.ts | 2 + .../agent/src/tools/io/textEditor.test.ts | 309 ++++++++++++++++ packages/agent/src/tools/io/textEditor.ts | 337 ++++++++++++++++++ 4 files changed, 653 insertions(+) create mode 100644 .changeset/text-editor.md create mode 100644 packages/agent/src/tools/io/textEditor.test.ts create mode 100644 packages/agent/src/tools/io/textEditor.ts diff --git a/.changeset/text-editor.md b/.changeset/text-editor.md new file mode 100644 index 0000000..b99b5be --- /dev/null +++ b/.changeset/text-editor.md @@ -0,0 +1,5 @@ +--- +"mycoder-agent": minor +--- + +Add textEditor tool that combines readFile and updateFile functionality diff --git a/packages/agent/src/tools/getTools.ts b/packages/agent/src/tools/getTools.ts index 1acadf3..3c99857 100644 --- a/packages/agent/src/tools/getTools.ts +++ b/packages/agent/src/tools/getTools.ts @@ -6,6 +6,7 @@ import { subAgentTool } from './interaction/subAgent.js'; import { userPromptTool } from './interaction/userPrompt.js'; import { fetchTool } from './io/fetch.js'; import { readFileTool } from './io/readFile.js'; +import { textEditorTool } from './io/textEditor.js'; import { updateFileTool } from './io/updateFile.js'; import { respawnTool } from './system/respawn.js'; import { sequenceCompleteTool } from './system/sequenceComplete.js'; @@ -15,6 +16,7 @@ import { sleepTool } from './system/sleep.js'; export function getTools(): Tool[] { return [ + textEditorTool, subAgentTool, readFileTool, updateFileTool, diff --git a/packages/agent/src/tools/io/textEditor.test.ts b/packages/agent/src/tools/io/textEditor.test.ts new file mode 100644 index 0000000..c218e6f --- /dev/null +++ b/packages/agent/src/tools/io/textEditor.test.ts @@ -0,0 +1,309 @@ +import { randomUUID } from 'crypto'; +import { mkdtemp, readFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { TokenTracker } from '../../core/tokens.js'; +import { ToolContext } from '../../core/types.js'; +import { MockLogger } from '../../utils/mockLogger.js'; +import { shellExecuteTool } from '../system/shellExecute.js'; + +import { textEditorTool } from './textEditor.js'; + +const toolContext: ToolContext = { + logger: new MockLogger(), + headless: true, + workingDirectory: '.', + userSession: false, + pageFilter: 'simple', + tokenTracker: new TokenTracker(), +}; + +describe('textEditor', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'texteditor-test-')); + }); + + afterEach(async () => { + await shellExecuteTool.execute( + { command: `rm -rf "${testDir}"`, description: 'test' }, + toolContext, + ); + }); + + it('should create a file', async () => { + const testContent = 'test content'; + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Create the file + const result = await textEditorTool.execute( + { + command: 'create', + path: testPath, + file_text: testContent, + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(true); + expect(result.message).toContain('File created'); + + // Verify content + const content = await readFile(testPath, 'utf8'); + expect(content).toBe(testContent); + }); + + it('should view a file', async () => { + const testContent = 'line 1\nline 2\nline 3'; + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Create the file + await textEditorTool.execute( + { + command: 'create', + path: testPath, + file_text: testContent, + description: 'test', + }, + toolContext, + ); + + // View the file + const result = await textEditorTool.execute( + { + command: 'view', + path: testPath, + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(true); + expect(result.content).toContain('1: line 1'); + expect(result.content).toContain('2: line 2'); + expect(result.content).toContain('3: line 3'); + }); + + it('should view a file with range', async () => { + const testContent = 'line 1\nline 2\nline 3\nline 4\nline 5'; + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Create the file + await textEditorTool.execute( + { + command: 'create', + path: testPath, + file_text: testContent, + description: 'test', + }, + toolContext, + ); + + // View the file with range + const result = await textEditorTool.execute( + { + command: 'view', + path: testPath, + view_range: [2, 4], + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(true); + expect(result.content).not.toContain('1: line 1'); + expect(result.content).toContain('2: line 2'); + expect(result.content).toContain('3: line 3'); + expect(result.content).toContain('4: line 4'); + expect(result.content).not.toContain('5: line 5'); + }); + + it('should replace text in a file', async () => { + const initialContent = 'Hello world! This is a test.'; + const oldStr = 'world'; + const newStr = 'universe'; + const expectedContent = 'Hello universe! This is a test.'; + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Create initial file + await textEditorTool.execute( + { + command: 'create', + path: testPath, + file_text: initialContent, + description: 'test', + }, + toolContext, + ); + + // Replace text + const result = await textEditorTool.execute( + { + command: 'str_replace', + path: testPath, + old_str: oldStr, + new_str: newStr, + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(true); + expect(result.message).toContain('Successfully replaced'); + + // Verify content + const content = await readFile(testPath, 'utf8'); + expect(content).toBe(expectedContent); + }); + + it('should insert text at a specific line', async () => { + const initialContent = 'line 1\nline 2\nline 4'; + const insertLine = 2; // After "line 2" + const newStr = 'line 3'; + const expectedContent = 'line 1\nline 2\nline 3\nline 4'; + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Create initial file + await textEditorTool.execute( + { + command: 'create', + path: testPath, + file_text: initialContent, + description: 'test', + }, + toolContext, + ); + + // Insert text + const result = await textEditorTool.execute( + { + command: 'insert', + path: testPath, + insert_line: insertLine, + new_str: newStr, + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(true); + expect(result.message).toContain('Successfully inserted'); + + // Verify content + const content = await readFile(testPath, 'utf8'); + expect(content).toBe(expectedContent); + }); + + it('should undo an edit', async () => { + const initialContent = 'Hello world!'; + const modifiedContent = 'Hello universe!'; + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Create initial file + await textEditorTool.execute( + { + command: 'create', + path: testPath, + file_text: initialContent, + description: 'test', + }, + toolContext, + ); + + // Modify the file + await textEditorTool.execute( + { + command: 'str_replace', + path: testPath, + old_str: 'world', + new_str: 'universe', + description: 'test', + }, + toolContext, + ); + + // Verify modified content + let content = await readFile(testPath, 'utf8'); + expect(content).toBe(modifiedContent); + + // Undo the edit + const result = await textEditorTool.execute( + { + command: 'undo_edit', + path: testPath, + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(true); + expect(result.message).toContain('Successfully reverted'); + + // Verify content is back to initial + content = await readFile(testPath, 'utf8'); + expect(content).toBe(initialContent); + }); + + it('should handle errors for non-existent files', async () => { + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Try to view a non-existent file + const result = await textEditorTool.execute( + { + command: 'view', + path: testPath, + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(false); + expect(result.message).toContain('not found'); + }); + + it('should handle errors for duplicate string replacements', async () => { + const initialContent = 'Hello world! This is a world test.'; + const oldStr = 'world'; + const newStr = 'universe'; + const testPath = join(testDir, `${randomUUID()}.txt`); + + // Create initial file + await textEditorTool.execute( + { + command: 'create', + path: testPath, + file_text: initialContent, + description: 'test', + }, + toolContext, + ); + + // Try to replace text with multiple occurrences + const result = await textEditorTool.execute( + { + command: 'str_replace', + path: testPath, + old_str: oldStr, + new_str: newStr, + description: 'test', + }, + toolContext, + ); + + // Verify return value + expect(result.success).toBe(false); + expect(result.message).toContain('Found 2 occurrences'); + }); +}); diff --git a/packages/agent/src/tools/io/textEditor.ts b/packages/agent/src/tools/io/textEditor.ts new file mode 100644 index 0000000..5d58ced --- /dev/null +++ b/packages/agent/src/tools/io/textEditor.ts @@ -0,0 +1,337 @@ +import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Tool } from '../../core/types.js'; + +const OUTPUT_LIMIT = 10 * 1024; // 10KB limit + +// Store file states for undo functionality +const fileStateHistory: Record = {}; + +const parameterSchema = z.object({ + command: z + .enum(['view', 'create', 'str_replace', 'insert', 'undo_edit']) + .describe( + 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.', + ), + path: z + .string() + .describe('Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.'), + file_text: z + .string() + .optional() + .describe( + 'Required parameter of `create` command, with the content of the file to be created.', + ), + insert_line: z + .number() + .optional() + .describe( + 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.', + ), + new_str: z + .string() + .optional() + .describe( + 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.', + ), + old_str: z + .string() + .optional() + .describe( + 'Required parameter of `str_replace` command containing the string in `path` to replace.', + ), + view_range: z + .array(z.number()) + .optional() + .describe( + 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.', + ), + description: z + .string() + .max(80) + .describe('The reason you are using the text editor (max 80 chars)'), +}); + +const returnSchema = z.object({ + success: z.boolean(), + message: z.string(), + content: z.string().optional(), +}); + +type Parameters = z.infer; +type ReturnType = z.infer; + +export const textEditorTool: Tool = { + name: 'textEditor', + description: + 'View, create, and edit files with persistent state across command calls', + logPrefix: '📝', + parameters: zodToJsonSchema(parameterSchema), + returns: zodToJsonSchema(returnSchema), + execute: async ( + { command, path: filePath, file_text, insert_line, new_str, old_str, view_range }, + context, + ) => { + const normalizedPath = path.normalize(filePath); + const absolutePath = path.isAbsolute(normalizedPath) + ? normalizedPath + : context?.workingDirectory + ? path.join(context.workingDirectory, normalizedPath) + : path.resolve(normalizedPath); + + try { + switch (command) { + case 'view': { + // Check if path is a directory + const stats = await fs.stat(absolutePath).catch(() => null); + if (!stats) { + return { + success: false, + message: `File or directory not found: ${filePath}`, + }; + } + + if (stats.isDirectory()) { + // List directory contents up to 2 levels deep + try { + const output = execSync( + `find "${absolutePath}" -type f -not -path "*/\\.*" -maxdepth 2 | sort`, + { encoding: 'utf8' }, + ); + return { + success: true, + message: `Directory listing for ${filePath}:`, + content: output, + }; + } catch (error) { + return { + success: false, + message: `Error listing directory: ${error}`, + }; + } + } else { + // Read file content + const content = await fs.readFile(absolutePath, 'utf8'); + const lines = content.split('\n'); + + // Apply view range if specified + let displayContent = content; + if (view_range && view_range.length === 2) { + const [start, end] = view_range; + const startLine = Math.max(1, start || 1) - 1; // Convert to 0-indexed + const endLine = end === -1 ? lines.length : end; + displayContent = lines + .slice(startLine, endLine) + .join('\n'); + } + + // Add line numbers + const startLineNum = view_range && view_range.length === 2 ? view_range[0] : 1; + const numberedContent = displayContent + .split('\n') + .map((line, i) => `${(startLineNum || 1) + i}: ${line}`) + .join('\n'); + + // Truncate if too large + if (numberedContent.length > OUTPUT_LIMIT) { + const truncatedContent = numberedContent.substring(0, OUTPUT_LIMIT); + return { + success: true, + message: `File content (truncated):`, + content: `${truncatedContent}\n`, + }; + } + + return { + success: true, + message: `File content:`, + content: numberedContent, + }; + } + } + + case 'create': { + // Check if file already exists + if (fsSync.existsSync(absolutePath)) { + return { + success: false, + message: `File already exists: ${filePath}. Use str_replace to modify it.`, + }; + } + + if (!file_text) { + return { + success: false, + message: 'file_text parameter is required for create command', + }; + } + + // Create parent directories if they don't exist + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + + // Create the file + await fs.writeFile(absolutePath, file_text, 'utf8'); + + // Store initial state for undo + fileStateHistory[absolutePath] = [file_text]; + + return { + success: true, + message: `File created: ${filePath}`, + }; + } + + case 'str_replace': { + if (!old_str) { + return { + success: false, + message: 'old_str parameter is required for str_replace command', + }; + } + + // Ensure the file exists + if (!fsSync.existsSync(absolutePath)) { + return { + success: false, + message: `File not found: ${filePath}`, + }; + } + + // Read the current content + const content = await fs.readFile(absolutePath, 'utf8'); + + // Check if old_str exists uniquely in the file + const occurrences = content.split(old_str).length - 1; + if (occurrences === 0) { + return { + success: false, + message: `The specified old_str was not found in the file`, + }; + } + if (occurrences > 1) { + return { + success: false, + message: `Found ${occurrences} occurrences of old_str, expected exactly 1`, + }; + } + + // Save current state for undo + if (!fileStateHistory[absolutePath]) { + fileStateHistory[absolutePath] = []; + } + fileStateHistory[absolutePath].push(content); + + // Replace the content + const updatedContent = content.replace(old_str, new_str || ''); + await fs.writeFile(absolutePath, updatedContent, 'utf8'); + + return { + success: true, + message: `Successfully replaced text in ${filePath}`, + }; + } + + case 'insert': { + if (insert_line === undefined) { + return { + success: false, + message: 'insert_line parameter is required for insert command', + }; + } + + if (!new_str) { + return { + success: false, + message: 'new_str parameter is required for insert command', + }; + } + + // Ensure the file exists + if (!fsSync.existsSync(absolutePath)) { + return { + success: false, + message: `File not found: ${filePath}`, + }; + } + + // Read the current content + const content = await fs.readFile(absolutePath, 'utf8'); + const lines = content.split('\n'); + + // Validate line number + if (insert_line < 0 || insert_line > lines.length) { + return { + success: false, + message: `Invalid line number: ${insert_line}. File has ${lines.length} lines.`, + }; + } + + // Save current state for undo + if (!fileStateHistory[absolutePath]) { + fileStateHistory[absolutePath] = []; + } + fileStateHistory[absolutePath].push(content); + + // Insert the new content after the specified line + lines.splice(insert_line, 0, new_str); + const updatedContent = lines.join('\n'); + await fs.writeFile(absolutePath, updatedContent, 'utf8'); + + return { + success: true, + message: `Successfully inserted text after line ${insert_line} in ${filePath}`, + }; + } + + case 'undo_edit': { + // Check if we have history for this file + if ( + !fileStateHistory[absolutePath] || + fileStateHistory[absolutePath].length === 0 + ) { + return { + success: false, + message: `No edit history found for ${filePath}`, + }; + } + + // Get the previous state + const previousState = fileStateHistory[absolutePath].pop(); + await fs.writeFile(absolutePath, previousState as string, 'utf8'); + + return { + success: true, + message: `Successfully reverted last edit to ${filePath}`, + }; + } + + default: + return { + success: false, + message: `Unknown command: ${command}`, + }; + } + } catch (error: any) { + return { + success: false, + message: `Error: ${error.message}`, + }; + } + }, + logParameters: (input, { logger }) => { + logger.info( + `${input.command} operation on "${input.path}", ${input.description}`, + ); + }, + logReturns: (result, { logger }) => { + if (!result.success) { + logger.error(`Text editor operation failed: ${result.message}`); + } + }, +}; From 72656425f8ecaa70338da40f1e3b7aea5203175a Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Tue, 4 Mar 2025 08:37:22 -0500 Subject: [PATCH 2/2] remove old old read and update file tools. --- .changeset/temp-changeset-message.txt | 1 + packages/agent/package.json | 4 +- packages/agent/src/index.ts | 3 +- packages/agent/src/tools/getTools.ts | 4 - packages/agent/src/tools/io/readFile.test.ts | 38 --- packages/agent/src/tools/io/readFile.ts | 103 -------- .../agent/src/tools/io/updateFile.test.ts | 222 ------------------ packages/agent/src/tools/io/updateFile.ts | 93 -------- pnpm-lock.yaml | 4 +- 9 files changed, 6 insertions(+), 466 deletions(-) create mode 100644 .changeset/temp-changeset-message.txt delete mode 100644 packages/agent/src/tools/io/readFile.test.ts delete mode 100644 packages/agent/src/tools/io/readFile.ts delete mode 100644 packages/agent/src/tools/io/updateFile.test.ts delete mode 100644 packages/agent/src/tools/io/updateFile.ts diff --git a/.changeset/temp-changeset-message.txt b/.changeset/temp-changeset-message.txt new file mode 100644 index 0000000..da31bbe --- /dev/null +++ b/.changeset/temp-changeset-message.txt @@ -0,0 +1 @@ +Add textEditor tool that combines readFile and updateFile functionality diff --git a/packages/agent/package.json b/packages/agent/package.json index 6126113..6e20de1 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -48,12 +48,12 @@ "@mozilla/readability": "^0.5.0", "@playwright/test": "^1.50.1", "@vitest/browser": "^3.0.5", - "chalk": "^5", + "chalk": "^5.4.1", "dotenv": "^16", "jsdom": "^26.0.0", "playwright": "^1.50.1", "uuid": "^11", - "zod": "^3", + "zod": "^3.24.2", "zod-to-json-schema": "^3" }, "devDependencies": { diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index b86b6ce..8447faa 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,6 +1,5 @@ // Tools - IO -export * from './tools/io/readFile.js'; -export * from './tools/io/updateFile.js'; + export * from './tools/io/fetch.js'; // Tools - System diff --git a/packages/agent/src/tools/getTools.ts b/packages/agent/src/tools/getTools.ts index 3c99857..5bec1aa 100644 --- a/packages/agent/src/tools/getTools.ts +++ b/packages/agent/src/tools/getTools.ts @@ -5,9 +5,7 @@ import { browseStartTool } from './browser/browseStart.js'; import { subAgentTool } from './interaction/subAgent.js'; import { userPromptTool } from './interaction/userPrompt.js'; import { fetchTool } from './io/fetch.js'; -import { readFileTool } from './io/readFile.js'; import { textEditorTool } from './io/textEditor.js'; -import { updateFileTool } from './io/updateFile.js'; import { respawnTool } from './system/respawn.js'; import { sequenceCompleteTool } from './system/sequenceComplete.js'; import { shellMessageTool } from './system/shellMessage.js'; @@ -18,8 +16,6 @@ export function getTools(): Tool[] { return [ textEditorTool, subAgentTool, - readFileTool, - updateFileTool, userPromptTool, sequenceCompleteTool, fetchTool, diff --git a/packages/agent/src/tools/io/readFile.test.ts b/packages/agent/src/tools/io/readFile.test.ts deleted file mode 100644 index 71845bd..0000000 --- a/packages/agent/src/tools/io/readFile.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { TokenTracker } from '../../core/tokens.js'; -import { ToolContext } from '../../core/types.js'; -import { MockLogger } from '../../utils/mockLogger'; - -import { readFileTool } from './readFile.js'; - -const toolContext: ToolContext = { - logger: new MockLogger(), - headless: true, - workingDirectory: '.', - userSession: false, - pageFilter: 'simple', - tokenTracker: new TokenTracker(), -}; - -describe('readFile', () => { - it('should read a file', async () => { - const { content } = await readFileTool.execute( - { path: 'package.json', description: 'test' }, - toolContext, - ); - expect(content).toContain('mycoder'); - }); - - it('should handle missing files', async () => { - try { - await readFileTool.execute( - { path: 'nonexistent.txt', description: 'test' }, - toolContext, - ); - expect(true).toBe(false); // Should not reach here - } catch (error: any) { - expect(error.message).toContain('ENOENT'); - } - }); -}); diff --git a/packages/agent/src/tools/io/readFile.ts b/packages/agent/src/tools/io/readFile.ts deleted file mode 100644 index 2edb166..0000000 --- a/packages/agent/src/tools/io/readFile.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as fs from 'fs/promises'; -import * as path from 'path'; - -import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; - -import { Tool } from '../../core/types.js'; - -const OUTPUT_LIMIT = 10 * 1024; // 10KB limit - -const parameterSchema = z.object({ - path: z.string().describe('Path to the file to read'), - range: z - .object({ - start: z.number(), - end: z.number(), - }) - .optional() - .describe('Range of bytes to read'), - maxSize: z - .number() - .optional() - .describe( - 'Maximum size to read, prevents reading arbitrarily large files that blow up the context window, max is 10KB', - ), - description: z - .string() - .max(80) - .describe('The reason you are reading this file (max 80 chars)'), -}); - -const returnSchema = z.object({ - path: z.string(), - content: z.string().optional(), - size: z.number(), - range: z - .object({ - start: z.number(), - end: z.number(), - }) - .optional(), -}); - -type Parameters = z.infer; -type ReturnType = z.infer; - -export const readFileTool: Tool = { - name: 'readFile', - description: 'Reads file content within size limits and optional range', - logPrefix: '📖', - parameters: zodToJsonSchema(parameterSchema), - returns: zodToJsonSchema(returnSchema), - execute: async ( - { path: filePath, range, maxSize = OUTPUT_LIMIT }, - context, - ) => { - const normalizedPath = path.normalize(filePath); - const absolutePath = path.isAbsolute(normalizedPath) - ? normalizedPath - : context?.workingDirectory - ? path.join(context.workingDirectory, normalizedPath) - : path.resolve(normalizedPath); - const stats = await fs.stat(absolutePath); - - const readSize = range ? range.end - range.start : stats.size; - if (readSize > maxSize) { - throw new Error( - `Requested size ${readSize} bytes exceeds maximum ${maxSize} bytes, make a request for a subset of the file using the range parameter`, - ); - } - - if (range) { - const fileHandle = await fs.open(absolutePath); - try { - const buffer = Buffer.alloc(readSize); - const { bytesRead } = await fileHandle.read( - buffer, - 0, - readSize, - range.start, - ); - return { - path: filePath, - content: buffer.toString('utf8', 0, bytesRead), - size: stats.size, - range, - }; - } finally { - await fileHandle.close(); - } - } - - return { - path: filePath, - content: await fs.readFile(absolutePath, 'utf8'), - size: stats.size, - }; - }, - logParameters: (input, { logger }) => { - logger.info(`Reading "${input.path}", ${input.description}`); - }, - logReturns: () => {}, -}; diff --git a/packages/agent/src/tools/io/updateFile.test.ts b/packages/agent/src/tools/io/updateFile.test.ts deleted file mode 100644 index d6295e4..0000000 --- a/packages/agent/src/tools/io/updateFile.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { randomUUID } from 'crypto'; -import { mkdtemp } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; - -import { TokenTracker } from '../../core/tokens.js'; -import { ToolContext } from '../../core/types.js'; -import { MockLogger } from '../../utils/mockLogger.js'; -import { shellExecuteTool } from '../system/shellExecute.js'; - -import { readFileTool } from './readFile.js'; -import { updateFileTool } from './updateFile.js'; - -const toolContext: ToolContext = { - logger: new MockLogger(), - headless: true, - workingDirectory: '.', - userSession: false, - pageFilter: 'simple', - tokenTracker: new TokenTracker(), -}; - -describe('updateFile', () => { - let testDir: string; - - beforeEach(async () => { - testDir = await mkdtemp(join(tmpdir(), 'updatefile-test-')); - }); - - afterEach(async () => { - await shellExecuteTool.execute( - { command: `rm -rf "${testDir}"`, description: 'test' }, - toolContext, - ); - }); - - it("should rewrite a file's content", async () => { - const testContent = 'test content'; - const testPath = join(testDir, `${randomUUID()}.txt`); - - // Create and rewrite the file - const result = await updateFileTool.execute( - { - path: testPath, - operation: { - command: 'rewrite', - content: testContent, - }, - description: 'test', - }, - toolContext, - ); - - // Verify return value - expect(result.path).toBe(testPath); - expect(result.operation).toBe('rewrite'); - - // Verify content - const readResult = await readFileTool.execute( - { path: testPath, description: 'test' }, - toolContext, - ); - expect(readResult.content).toBe(testContent); - }); - - it('should append content to a file', async () => { - const initialContent = 'initial content\n'; - const appendContent = 'appended content'; - const expectedContent = initialContent + appendContent; - const testPath = join(testDir, `${randomUUID()}.txt`); - - // Create initial file - await updateFileTool.execute( - { - path: testPath, - operation: { - command: 'rewrite', - content: initialContent, - }, - description: 'test', - }, - toolContext, - ); - - // Append content - const result = await updateFileTool.execute( - { - path: testPath, - operation: { - command: 'append', - content: appendContent, - }, - description: 'test', - }, - toolContext, - ); - - // Verify return value - expect(result.path).toBe(testPath); - expect(result.operation).toBe('append'); - - // Verify content - const readResult = await readFileTool.execute( - { path: testPath, description: 'test' }, - toolContext, - ); - expect(readResult.content).toBe(expectedContent); - }); - - it('should update specific text in a file', async () => { - const initialContent = 'Hello world! This is a test.'; - const oldStr = 'world'; - const newStr = 'universe'; - const expectedContent = 'Hello universe! This is a test.'; - const testPath = join(testDir, `${randomUUID()}.txt`); - - // Create initial file - await updateFileTool.execute( - { - path: testPath, - operation: { - command: 'rewrite', - content: initialContent, - }, - description: 'test', - }, - toolContext, - ); - - // Update specific text - const result = await updateFileTool.execute( - { - path: testPath, - operation: { - command: 'update', - oldStr, - newStr, - }, - description: 'test', - }, - toolContext, - ); - - // Verify return value - expect(result.path).toBe(testPath); - expect(result.operation).toBe('update'); - - // Verify content - const readResult = await readFileTool.execute( - { path: testPath, description: 'test' }, - toolContext, - ); - expect(readResult.content).toBe(expectedContent); - }); - - it('should throw error when update finds multiple occurrences', async () => { - const initialContent = 'Hello world! This is a world test.'; - const oldStr = 'world'; - const newStr = 'universe'; - const testPath = join(testDir, `${randomUUID()}.txt`); - - // Create initial file - await updateFileTool.execute( - { - path: testPath, - operation: { - command: 'rewrite', - content: initialContent, - }, - description: 'test', - }, - toolContext, - ); - - // Attempt update that should fail - await expect( - updateFileTool.execute( - { - path: testPath, - operation: { - command: 'update', - oldStr, - newStr, - }, - description: 'test', - }, - toolContext, - ), - ).rejects.toThrow('Found 2 occurrences of oldStr, expected exactly 1'); - }); - - it("should create parent directories if they don't exist", async () => { - const testContent = 'test content'; - const nestedPath = join(testDir, 'nested', 'dir', `${randomUUID()}.txt`); - - // Create file in nested directory - const result = await updateFileTool.execute( - { - path: nestedPath, - operation: { - command: 'rewrite', - content: testContent, - }, - description: 'test', - }, - toolContext, - ); - - // Verify return value - expect(result.path).toBe(nestedPath); - expect(result.operation).toBe('rewrite'); - - // Verify content - const readResult = await readFileTool.execute( - { path: nestedPath, description: 'test' }, - toolContext, - ); - expect(readResult.content).toBe(testContent); - }); -}); diff --git a/packages/agent/src/tools/io/updateFile.ts b/packages/agent/src/tools/io/updateFile.ts deleted file mode 100644 index 3355344..0000000 --- a/packages/agent/src/tools/io/updateFile.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as fs from 'fs'; -import * as fsPromises from 'fs/promises'; -import * as path from 'path'; - -import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; - -import { Tool } from '../../core/types.js'; - -const updateOperationSchema = z.discriminatedUnion('command', [ - z.object({ - command: z.literal('update'), - oldStr: z.string().describe('Existing text to replace (must be unique)'), - newStr: z.string().describe('New text to insert'), - }), - z.object({ - command: z.literal('rewrite'), - content: z.string().describe('Complete new file content'), - }), - z.object({ - command: z.literal('append'), - content: z.string().describe('Content to append to file'), - }), -]); - -const parameterSchema = z.object({ - path: z.string().describe('Path to the file'), - operation: updateOperationSchema.describe('Update operation to perform'), - description: z - .string() - .max(80) - .describe('The reason you are modifying this file (max 80 chars)'), -}); - -const returnSchema = z.object({ - path: z.string().describe('Path to the updated file'), - operation: z.enum(['update', 'rewrite', 'append']), -}); - -type Parameters = z.infer; -type ReturnType = z.infer; - -export const updateFileTool: Tool = { - name: 'updateFile', - description: - 'Creates a file or updates a file by rewriting, patching, or appending content', - logPrefix: '📝', - parameters: zodToJsonSchema(parameterSchema), - returns: zodToJsonSchema(returnSchema), - execute: async ( - { path: filePath, operation }, - { logger, workingDirectory }, - ) => { - const normalizedPath = path.normalize(filePath); - const absolutePath = path.isAbsolute(normalizedPath) - ? normalizedPath - : workingDirectory - ? path.join(workingDirectory, normalizedPath) - : path.resolve(normalizedPath); - logger.verbose(`Updating file: ${absolutePath}`); - - await fsPromises.mkdir(path.dirname(absolutePath), { recursive: true }); - - if (operation.command === 'update') { - const content = await fsPromises.readFile(absolutePath, 'utf8'); - const occurrences = content.split(operation.oldStr).length - 1; - if (occurrences !== 1) { - throw new Error( - `Found ${occurrences} occurrences of oldStr, expected exactly 1`, - ); - } - await fsPromises.writeFile( - absolutePath, - content.replace(operation.oldStr, operation.newStr), - 'utf8', - ); - } else if (operation.command === 'append') { - await fsPromises.appendFile(absolutePath, operation.content, 'utf8'); - } else { - await fsPromises.writeFile(absolutePath, operation.content, 'utf8'); - } - - logger.verbose(`Operation complete: ${operation.command}`); - return { path: filePath, operation: operation.command }; - }, - logParameters: (input, { logger }) => { - const isFile = fs.existsSync(input.path); - logger.info( - `${isFile ? 'Modifying' : '✏️ Creating'} "${input.path}", ${input.description}`, - ); - }, - logReturns: () => {}, -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2a1fd5..5c1760f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: specifier: ^3.0.5 version: 3.0.6(@types/node@18.19.76)(playwright@1.50.1)(typescript@5.7.3)(vite@6.1.1(@types/node@18.19.76)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))(vitest@3.0.6) chalk: - specifier: ^5 + specifier: ^5.4.1 version: 5.4.1 dotenv: specifier: ^16 @@ -82,7 +82,7 @@ importers: specifier: ^11 version: 11.1.0 zod: - specifier: ^3 + specifier: ^3.24.2 version: 3.24.2 zod-to-json-schema: specifier: ^3