Skip to content

Commit

Permalink
Add search and replace function to coder (#14774)
Browse files Browse the repository at this point in the history
fixed #14773

Signed-off-by: Jonas Helming <[email protected]>
  • Loading branch information
JonasHelming authored Jan 28, 2025
1 parent 621a45a commit 32f58e6
Show file tree
Hide file tree
Showing 6 changed files with 338 additions and 12 deletions.
6 changes: 3 additions & 3 deletions packages/ai-workspace-agent/src/browser/coder-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { AbstractStreamParsingChatAgent, ChatAgent, SystemMessageDescription } f
import { AgentSpecificVariables, PromptTemplate } from '@theia/ai-core';
import { injectable } from '@theia/core/shared/inversify';
import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID } from '../common/workspace-functions';
import { coderReplacePromptTemplate } from '../common/coder-replace-prompt-template';
import { CODER_DEFAULT_PROMPT_TEMPLATE_ID, getCoderReplacePromptTemplate } from '../common/coder-replace-prompt-template';
import { WriteChangeToFileProvider } from './file-changeset-functions';

@injectable()
Expand All @@ -36,14 +36,14 @@ export class CoderAgent extends AbstractStreamParsingChatAgent implements ChatAg
}], 'chat');
this.name = 'Coder';
this.description = 'An AI assistant integrated into Theia IDE, designed to assist software developers with code tasks.';
this.promptTemplates = [coderReplacePromptTemplate];
this.promptTemplates = [getCoderReplacePromptTemplate(false), getCoderReplacePromptTemplate(true)];
this.variables = [];
this.agentSpecificVariables = [];
this.functions = [GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID, WriteChangeToFileProvider.ID];
}

protected override async getSystemMessageDescription(): Promise<SystemMessageDescription | undefined> {
const resolvedPrompt = await this.promptService.getPrompt(coderReplacePromptTemplate.id);
const resolvedPrompt = await this.promptService.getPrompt(CODER_DEFAULT_PROMPT_TEMPLATE_ID);
return resolvedPrompt ? SystemMessageDescription.fromResolvedPromptTemplate(resolvedPrompt) : undefined;
}

Expand Down
92 changes: 92 additions & 0 deletions packages/ai-workspace-agent/src/browser/content-replacer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// **

import { expect } from 'chai';
import { ContentReplacer, Replacement } from './content-replacer';

describe('ContentReplacer', () => {
let contentReplacer: ContentReplacer;

before(() => {
contentReplacer = new ContentReplacer();
});

it('should replace content when oldContent matches exactly', () => {
const originalContent = 'Hello World!';
const replacements: Replacement[] = [
{ oldContent: 'World', newContent: 'Universe' }
];
const expectedContent = 'Hello Universe!';
const result = contentReplacer.applyReplacements(originalContent, replacements);
expect(result.updatedContent).to.equal(expectedContent);
expect(result.errors).to.be.empty;
});

it('should replace content when oldContent matches after trimming lines', () => {
const originalContent = 'Line one\n Line two \nLine three';
const replacements: Replacement[] = [
{ oldContent: 'Line two', newContent: 'Second Line' }
];
const expectedContent = 'Line one\n Second Line \nLine three';
const result = contentReplacer.applyReplacements(originalContent, replacements);
expect(result.updatedContent).to.equal(expectedContent);
expect(result.errors).to.be.empty;
});

it('should return an error when oldContent is not found', () => {
const originalContent = 'Sample content';
const replacements: Replacement[] = [
{ oldContent: 'Nonexistent', newContent: 'Replacement' }
];
const result = contentReplacer.applyReplacements(originalContent, replacements);
expect(result.updatedContent).to.equal(originalContent);
expect(result.errors).to.include('Content to replace not found: "Nonexistent"');
});

it('should return an error when oldContent has multiple occurrences', () => {
const originalContent = 'Repeat Repeat Repeat';
const replacements: Replacement[] = [
{ oldContent: 'Repeat', newContent: 'Once' }
];
const result = contentReplacer.applyReplacements(originalContent, replacements);
expect(result.updatedContent).to.equal(originalContent);
expect(result.errors).to.include('Multiple occurrences found for: "Repeat"');
});

it('should prepend newContent when oldContent is an empty string', () => {
const originalContent = 'Existing content';
const replacements: Replacement[] = [
{ oldContent: '', newContent: 'Prepended content\n' }
];
const expectedContent = 'Prepended content\nExisting content';
const result = contentReplacer.applyReplacements(originalContent, replacements);
expect(result.updatedContent).to.equal(expectedContent);
expect(result.errors).to.be.empty;
});

it('should handle multiple replacements correctly', () => {
const originalContent = 'Foo Bar Baz';
const replacements: Replacement[] = [
{ oldContent: 'Foo', newContent: 'FooModified' },
{ oldContent: 'Bar', newContent: 'BarModified' },
{ oldContent: 'Baz', newContent: 'BazModified' }
];
const expectedContent = 'FooModified BarModified BazModified';
const result = contentReplacer.applyReplacements(originalContent, replacements);
expect(result.updatedContent).to.equal(expectedContent);
expect(result.errors).to.be.empty;
});
});
126 changes: 126 additions & 0 deletions packages/ai-workspace-agent/src/browser/content-replacer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

export interface Replacement {
oldContent: string;
newContent: string;
}

export class ContentReplacer {
/**
* Applies a list of replacements to the original content using a multi-step matching strategy.
* @param originalContent The original file content.
* @param replacements Array of Replacement objects.
* @returns An object containing the updated content and any error messages.
*/
applyReplacements(originalContent: string, replacements: Replacement[]): { updatedContent: string, errors: string[] } {
let updatedContent = originalContent;
const errorMessages: string[] = [];

replacements.forEach(({ oldContent, newContent }) => {
// If the old content is empty, prepend the new content to the beginning of the file (e.g. in new file)
if (oldContent === '') {
updatedContent = newContent + updatedContent;
return;
}

let matchIndices = this.findExactMatches(updatedContent, oldContent);

if (matchIndices.length === 0) {
matchIndices = this.findLineTrimmedMatches(updatedContent, oldContent);
}

if (matchIndices.length === 0) {
errorMessages.push(`Content to replace not found: "${oldContent}"`);
} else if (matchIndices.length > 1) {
errorMessages.push(`Multiple occurrences found for: "${oldContent}"`);
} else {
updatedContent = this.replaceContentOnce(updatedContent, oldContent, newContent);
}
});

return { updatedContent, errors: errorMessages };
}

/**
* Finds all exact matches of a substring within a string.
* @param content The content to search within.
* @param search The substring to search for.
* @returns An array of starting indices where the exact substring is found.
*/
private findExactMatches(content: string, search: string): number[] {
const indices: number[] = [];
let startIndex = 0;

while ((startIndex = content.indexOf(search, startIndex)) !== -1) {
indices.push(startIndex);
startIndex += search.length;
}

return indices;
}

/**
* Attempts to find matches by trimming whitespace from lines in the original content and the search string.
* @param content The original content.
* @param search The substring to search for, potentially with varying whitespace.
* @returns An array of starting indices where a trimmed match is found.
*/
private findLineTrimmedMatches(content: string, search: string): number[] {
const trimmedSearch = search.trim();
const lines = content.split('\n');

for (let i = 0; i < lines.length; i++) {
const trimmedLine = lines[i].trim();
if (trimmedLine === trimmedSearch) {
// Calculate the starting index of this line in the original content
const startIndex = this.getLineStartIndex(content, i);
return [startIndex];
}
}

return [];
}

/**
* Calculates the starting index of a specific line number in the content.
* @param content The original content.
* @param lineNumber The zero-based line number.
* @returns The starting index of the specified line.
*/
private getLineStartIndex(content: string, lineNumber: number): number {
const lines = content.split('\n');
let index = 0;
for (let i = 0; i < lineNumber; i++) {
index += lines[i].length + 1; // +1 for the newline character
}
return index;
}

/**
* Replaces the first occurrence of oldContent with newContent in the content.
* @param content The original content.
* @param oldContent The content to be replaced.
* @param newContent The content to replace with.
* @returns The content after replacement.
*/
private replaceContentOnce(content: string, oldContent: string, newContent: string): string {
const index = content.indexOf(oldContent);
if (index === -1) { return content; }
return content.substring(0, index) + newContent + content.substring(index + oldContent.length);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { WorkspaceFunctionScope } from './workspace-functions';
import { ChangeSetFileElementFactory } from '@theia/ai-chat/lib/browser/change-set-file-element';
import { ChangeSetImpl, ChatRequestModelImpl } from '@theia/ai-chat';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { ContentReplacer, Replacement } from './content-replacer';

@injectable()
export class WriteChangeToFileProvider implements ToolProvider {
Expand Down Expand Up @@ -90,3 +91,97 @@ export class WriteChangeToFileProvider implements ToolProvider {
};
}
}

@injectable()
export class ReplaceContentInFileProvider implements ToolProvider {
static ID = 'changeSet_replaceContentInFile';

@inject(WorkspaceFunctionScope)
protected readonly workspaceFunctionScope: WorkspaceFunctionScope;

@inject(FileService)
fileService: FileService;

@inject(ChangeSetFileElementFactory)
protected readonly fileChangeFactory: ChangeSetFileElementFactory;

private replacer: ContentReplacer;

constructor() {
this.replacer = new ContentReplacer();
}

getTool(): ToolRequest {
return {
id: ReplaceContentInFileProvider.ID,
name: ReplaceContentInFileProvider.ID,
description: `Request to replace sections of content in an existing file by providing a list of tuples with old content to be matched and replaced.
Only the first matched instance of each old content in the tuples will be replaced. For deletions, use an empty new content in the tuple.\n
Make sure you use the same line endings and whitespace as in the original file content. The proposed changes will be applied when the user accepts.`,
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path of the file where content will be replaced.'
},
replacements: {
type: 'array',
items: {
type: 'object',
properties: {
oldContent: {
type: 'string',
description: 'The exact content to be replaced. Must match exactly, including whitespace, comments, etc.'
},
newContent: {
type: 'string',
description: 'The new content to insert in place of matched old content.'
}
},
required: ['oldContent', 'newContent']
},
description: 'An array of replacement objects, each containing oldContent and newContent strings.'
}
},
required: ['path', 'replacements']
},
handler: async (args: string, ctx: ChatRequestModelImpl): Promise<string> => {
try {
const { path, replacements } = JSON.parse(args) as { path: string, replacements: Replacement[] };
const fileUri = await this.workspaceFunctionScope.resolveRelativePath(path);
const fileContent = (await this.fileService.read(fileUri)).value.toString();

const { updatedContent, errors } = this.replacer.applyReplacements(fileContent, replacements);

if (errors.length > 0) {
return `Errors encountered: ${errors.join('; ')}`;
}

if (updatedContent !== fileContent) {
let changeSet = ctx.session.changeSet;
if (!changeSet) {
changeSet = new ChangeSetImpl('Changes proposed by Coder');
ctx.session.setChangeSet(changeSet);
}

changeSet.addElement(
this.fileChangeFactory({
uri: fileUri,
type: 'modify',
state: 'pending',
targetState: updatedContent,
changeSet,
chatSessionId: ctx.session.id
})
);
}
return `Proposed replacements in file ${path}. The user will review and potentially apply the changes.`;
} catch (error) {
console.info('Error processing replacements:', error.message);
return JSON.stringify({ error: error.message });
}
}
};
}
}
2 changes: 2 additions & 0 deletions packages/ai-workspace-agent/src/browser/frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { PreferenceContribution } from '@theia/core/lib/browser';
import { WorkspacePreferencesSchema } from './workspace-preferences';

import {
ReplaceContentInFileProvider,
WriteChangeToFileProvider
} from './file-changeset-functions';

Expand All @@ -41,4 +42,5 @@ export default new ContainerModule(bind => {
bind(WorkspaceFunctionScope).toSelf().inSingletonScope();

bind(ToolProvider).to(WriteChangeToFileProvider);
bind(ToolProvider).to(ReplaceContentInFileProvider);
});
Loading

0 comments on commit 32f58e6

Please sign in to comment.