diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 64d97c1d..629a7673 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -88,12 +88,12 @@ When a user asks for ANY task that might require external capabilities (web sear ## Workspace Access & Scope - You have full read/write access inside \`\${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually. - If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location. -- Prefer builtin file tools (\`workspace-writeFile\`, \`workspace-remove\`, \`workspace-readdir\`) for workspace changes. Reserve refusal or "you do it" responses for cases that are truly outside the Rowboat sandbox. +- Prefer builtin file tools (\`workspace-edit\` for modifications, \`workspace-writeFile\` for new files, \`workspace-remove\`, \`workspace-readdir\`) for workspace changes. Reserve refusal or "you do it" responses for cases that are truly outside the Rowboat sandbox. ## Builtin Tools vs Shell Commands **IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries: -- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-remove\` - File operations +- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations - \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\` - Directory exploration - \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management - \`analyzeAgent\` - Agent analysis diff --git a/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts b/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts index ceb2f940..40a61960 100644 --- a/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts @@ -168,6 +168,7 @@ The Rowboat copilot has access to special builtin tools that regular agents don' - \`workspace-readdir\` - List directory contents (supports recursive exploration) - \`workspace-readFile\` - Read file contents - \`workspace-writeFile\` - Create or update file contents +- \`workspace-edit\` - Make precise edits by replacing specific text (safer than full rewrites) - \`workspace-remove\` - Remove files or directories - \`workspace-exists\` - Check if a file or directory exists - \`workspace-stat\` - Get file/directory statistics diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index eff523a0..dcd763de 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -156,14 +156,14 @@ export const BuiltinTools: z.infer = { mkdirp: z.boolean().optional().describe('Create parent directories if needed (default: true)'), expectedEtag: z.string().optional().describe('ETag to check for concurrent modifications (conflict detection)'), }), - execute: async ({ - path: relPath, - data, - encoding, - atomic, - mkdirp, - expectedEtag - }: { + execute: async ({ + path: relPath, + data, + encoding, + atomic, + mkdirp, + expectedEtag + }: { path: string; data: string; encoding?: 'utf8' | 'base64' | 'binary'; @@ -186,6 +186,57 @@ export const BuiltinTools: z.infer = { }, }, + 'workspace-edit': { + description: 'Make precise edits to a file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss.', + inputSchema: z.object({ + path: z.string().min(1).describe('Workspace-relative file path'), + oldString: z.string().describe('Exact text to find and replace'), + newString: z.string().describe('Replacement text'), + replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, fails if not unique)'), + }), + execute: async ({ + path: relPath, + oldString, + newString, + replaceAll = false + }: { + path: string; + oldString: string; + newString: string; + replaceAll?: boolean; + }) => { + try { + const result = await workspace.readFile(relPath, 'utf8'); + const content = result.data; + + const occurrences = content.split(oldString).length - 1; + + if (occurrences === 0) { + return { error: 'oldString not found in file' }; + } + + if (occurrences > 1 && !replaceAll) { + return { + error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.` + }; + } + + const newContent = replaceAll + ? content.replaceAll(oldString, newString) + : content.replace(oldString, newString); + + await workspace.writeFile(relPath, newContent, { encoding: 'utf8' }); + + return { + success: true, + replacements: replaceAll ? occurrences : 1 + }; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Unknown error' }; + } + }, + }, + 'workspace-mkdir': { description: 'Create a directory in the workspace', inputSchema: z.object({