From e20ddb8693bae1cd12a659f86ff423feaf81750d Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Sat, 28 Dec 2024 00:00:02 +0100 Subject: [PATCH] Add support for change sets via tool functions fixed #14678 Signed-off-by: Jonas Helming --- .../src/browser/coder-agent.ts | 49 ++++ .../browser/content-change-applier-service.ts | 104 ++++++++ .../src/browser/file-changeset-functions.ts | 213 +++++++++++++++ .../src/browser/file-changeset-service.ts | 244 ++++++++++++++++++ .../src/browser/frontend-module.ts | 33 ++- .../browser/replace-changeset-functions.ts | 69 +++++ .../browser/replace-content-change-applier.ts | 36 +++ .../src/browser/workspace-agent.ts | 4 +- .../{functions.ts => workspace-functions.ts} | 2 +- .../src/common/coder-replace-template.ts | 57 ++++ .../{functions.ts => workspace-functions.ts} | 0 .../{template.ts => workspace-template.ts} | 2 +- test.json | 1 + yarn.lock | 36 ++- 14 files changed, 837 insertions(+), 13 deletions(-) create mode 100644 packages/ai-workspace-agent/src/browser/coder-agent.ts create mode 100644 packages/ai-workspace-agent/src/browser/content-change-applier-service.ts create mode 100644 packages/ai-workspace-agent/src/browser/file-changeset-functions.ts create mode 100644 packages/ai-workspace-agent/src/browser/file-changeset-service.ts create mode 100644 packages/ai-workspace-agent/src/browser/replace-changeset-functions.ts create mode 100644 packages/ai-workspace-agent/src/browser/replace-content-change-applier.ts rename packages/ai-workspace-agent/src/browser/{functions.ts => workspace-functions.ts} (99%) create mode 100644 packages/ai-workspace-agent/src/common/coder-replace-template.ts rename packages/ai-workspace-agent/src/common/{functions.ts => workspace-functions.ts} (100%) rename packages/ai-workspace-agent/src/common/{template.ts => workspace-template.ts} (99%) create mode 100644 test.json diff --git a/packages/ai-workspace-agent/src/browser/coder-agent.ts b/packages/ai-workspace-agent/src/browser/coder-agent.ts new file mode 100644 index 0000000000000..bc9cdfc0f9e46 --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/coder-agent.ts @@ -0,0 +1,49 @@ +// ***************************************************************************** +// 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 { AbstractStreamParsingChatAgent, ChatAgent, SystemMessageDescription } from '@theia/ai-chat/lib/common'; +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 { coderReplaceTemplate } from '../common/coder-replace-template'; + +@injectable() +export class CoderAgent extends AbstractStreamParsingChatAgent implements ChatAgent { + name: string; + description: string; + promptTemplates: PromptTemplate[]; + variables: never[]; + readonly agentSpecificVariables: AgentSpecificVariables[]; + readonly functions: string[]; + + constructor() { + super('Coder', [{ + purpose: 'chat', + identifier: 'openai/gpt-4o', + }], 'chat'); + this.name = 'Coder'; + this.description = 'An AI assistant integrated into Theia IDE, designed to assist software developers with code tasks.'; + this.promptTemplates = [coderReplaceTemplate]; + this.variables = []; + this.agentSpecificVariables = []; + this.functions = [GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID]; + } + + protected override async getSystemMessageDescription(): Promise { + const resolvedPrompt = await this.promptService.getPrompt(coderReplaceTemplate.id); + return resolvedPrompt ? SystemMessageDescription.fromResolvedPromptTemplate(resolvedPrompt) : undefined; + } + +} diff --git a/packages/ai-workspace-agent/src/browser/content-change-applier-service.ts b/packages/ai-workspace-agent/src/browser/content-change-applier-service.ts new file mode 100644 index 0000000000000..61993371312e1 --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/content-change-applier-service.ts @@ -0,0 +1,104 @@ +// ***************************************************************************** +// 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 { ContributionProvider } from '@theia/core'; +import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; + +/** + * A change operation to be applied to content by a {@link ContentChangeApplier}. + */ +export interface ChangeOperation { + kind: string; +} + +/** + * A provider capable of applying a content change operation. + * + * Implementations of this interface are responsible for modifying + * a given content string based on the corresponding {@link ChangeOperation}. + * + * @template T The type of change operation. + */ +export interface ContentChangeApplier { + /** + * The kind of change operation that this applier can handle. + */ + kind: T['kind']; + /** + * Applies a change operation to the provided content. + * + * @param content The original content. + * @param operation The change operation to apply. + * @returns The updated content. + */ + applyChange(content: string, operation: T): string; +} + +export const ContentChangeApplier = Symbol('ContentChangeApplier'); + +/** + * A service that applies content changes using registered ContentChangeApplier contributions. + * + * This class collects multiple implementations of the {@link ContentChangeApplier} + * interface and applies a series of change operations to a given content string. + */ +@injectable() +export class ContentChangeApplierService { + + @inject(ContributionProvider) + @named(ContentChangeApplier) + public providers: ContributionProvider>; + + private readonly applierMap = new Map>(); + + @postConstruct() + init(): void { + this.providers.getContributions().forEach(provider => { + this.registerContentChangeApplier(provider.kind, provider); + }); + } + /** + * Registers a {@link ContentChangeApplier} for a specific operation kind. + * + * @param kind The change operation kind (e.g. 'replace', 'diffBased') + * @param provider The ContentChangeApplier implementation that handles operations of that kind. + */ + registerContentChangeApplier(kind: string, provider: ContentChangeApplier): void { + this.applierMap.set(kind, provider); + } + + /** + * Applies an array of changes to the given content. + * + * Each change operation in the array is applied sequentially to update the content. + * + * @param content The initial content. + * @param changes An array of change operations. + * @returns The modified content after all operations have been applied. + * @throws Error If a change operation's kind does not have a registered ContentChangeApplier. + */ + public applyChangesToContent(content: string, changes: ChangeOperation[]): string { + for (const operation of changes) { + const applier = this.applierMap.get(operation.kind); + if (!applier) { + throw new Error(`No ContentChangeApplier found for operation kind: ${operation.kind}`); + } + content = applier.applyChange(content, operation); + } + return content; + } +} diff --git a/packages/ai-workspace-agent/src/browser/file-changeset-functions.ts b/packages/ai-workspace-agent/src/browser/file-changeset-functions.ts new file mode 100644 index 0000000000000..a4569b76c21b1 --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/file-changeset-functions.ts @@ -0,0 +1,213 @@ +// ***************************************************************************** +// Copyright (C) 2024 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 { injectable, inject } from '@theia/core/shared/inversify'; +import { ToolProvider, ToolRequest } from '@theia/ai-core'; +import { FileChangeSetService } from './file-changeset-service'; + +@injectable() +export class InitializeChangeSetProvider implements ToolProvider { + static ID = 'changeSet_initializeChangeSet'; + + @inject(FileChangeSetService) + protected readonly changeSetService: FileChangeSetService; + + getTool(): ToolRequest { + return { + id: InitializeChangeSetProvider.ID, + name: InitializeChangeSetProvider.ID, + description: 'Creates a new change set with a unique UUID and description.', + parameters: { + type: 'object', + properties: { + uuid: { type: 'string', description: 'Unique identifier for the change set.' }, + description: { type: 'string', description: 'High-level description of the change set.' } + }, + required: ['uuid', 'description'] + }, + handler: async (args: string): Promise => { + try { + const { uuid, description } = JSON.parse(args); + this.changeSetService.initializeChangeSet(uuid, description); + return `Change set ${uuid} initialized successfully.`; + } catch (error) { + return JSON.stringify({ error: error.message }); + } + } + }; + } +} + +@injectable() +export class RemoveFileChangeProvider implements ToolProvider { + static ID = 'changeSet_removeFileChange'; + + @inject(FileChangeSetService) + protected readonly changeSetService: FileChangeSetService; + + getTool(): ToolRequest { + return { + id: RemoveFileChangeProvider.ID, + name: RemoveFileChangeProvider.ID, + description: 'Removes a file and all related changes from the specified change set.', + parameters: { + type: 'object', + properties: { + uuid: { type: 'string', description: 'Unique identifier for the change set.' }, + filePath: { type: 'string', description: 'Path to the file.' } + }, + required: ['uuid', 'filePath'] + }, + handler: async (args: string): Promise => { + try { + const { uuid, filePath } = JSON.parse(args); + this.changeSetService.removeFileChange(uuid, filePath); + return `File ${filePath} removed from change set ${uuid}.`; + } catch (error) { + return JSON.stringify({ error: error.message }); + } + } + }; + } +} + +@injectable() +export class ListChangedFilesProvider implements ToolProvider { + static ID = 'changeSet_listChangedFiles'; + + @inject(FileChangeSetService) + protected readonly changeSetService: FileChangeSetService; + + getTool(): ToolRequest { + return { + id: ListChangedFilesProvider.ID, + name: ListChangedFilesProvider.ID, + description: 'Lists all files included in a specific change set.', + parameters: { + type: 'object', + properties: { + uuid: { type: 'string', description: 'Unique identifier for the change set.' } + }, + required: ['uuid'] + }, + handler: async (args: string): Promise => { + try { + const { uuid } = JSON.parse(args); + const files = this.changeSetService.listChangedFiles(uuid); + return JSON.stringify(files); + } catch (error) { + return JSON.stringify({ error: error.message }); + } + } + }; + } +} + +@injectable() +export class GetFileChangesProvider implements ToolProvider { + static ID = 'changeSet_getFileChanges'; + + @inject(FileChangeSetService) + protected readonly changeSetService: FileChangeSetService; + + getTool(): ToolRequest { + return { + id: GetFileChangesProvider.ID, + name: GetFileChangesProvider.ID, + description: 'Fetches the operations of a specific file in a change set.', + parameters: { + type: 'object', + properties: { + uuid: { type: 'string', description: 'Unique identifier for the change set.' }, + filePath: { type: 'string', description: 'Path to the file.' } + }, + required: ['uuid', 'filePath'] + }, + handler: async (args: string): Promise => { + try { + const { uuid, filePath } = JSON.parse(args); + const changes = this.changeSetService.getFileChanges(uuid, filePath); + return JSON.stringify(changes); + } catch (error) { + return JSON.stringify({ error: error.message }); + } + } + }; + } +} + +@injectable() +export class GetChangeSetProvider implements ToolProvider { + static ID = 'changeSet_getChangeSet'; + + @inject(FileChangeSetService) + protected readonly changeSetService: FileChangeSetService; + + getTool(): ToolRequest { + return { + id: GetChangeSetProvider.ID, + name: GetChangeSetProvider.ID, + description: 'Fetches the details of a specific change set.', + parameters: { + type: 'object', + properties: { + uuid: { type: 'string', description: 'Unique identifier for the change set.' } + }, + required: ['uuid'] + }, + handler: async (args: string): Promise => { + try { + const { uuid } = JSON.parse(args); + const changeSet = this.changeSetService.getChangeSet(uuid); + return JSON.stringify(changeSet); + } catch (error) { + return JSON.stringify({ error: error.message }); + } + } + }; + } +} + +@injectable() +export class ApplyChangeSetProvider implements ToolProvider { + static ID = 'changeSet_applyChangeSet'; + + @inject(FileChangeSetService) + protected readonly changeSetService: FileChangeSetService; + + getTool(): ToolRequest { + return { + id: ApplyChangeSetProvider.ID, + name: ApplyChangeSetProvider.ID, + description: 'Applies the specified change set by UUID, executing all file modifications described within.', + parameters: { + type: 'object', + properties: { + uuid: { type: 'string', description: 'Unique identifier for the change set to apply.' } + }, + required: ['uuid'] + }, + handler: async (args: string): Promise => { + try { + const { uuid } = JSON.parse(args); + await this.changeSetService.applyChangeSet(uuid); + return `Change set ${uuid} applied successfully.`; + } catch (error) { + return JSON.stringify({ error: error.message }); + } + } + }; + } +} diff --git a/packages/ai-workspace-agent/src/browser/file-changeset-service.ts b/packages/ai-workspace-agent/src/browser/file-changeset-service.ts new file mode 100644 index 0000000000000..28e24116bb889 --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/file-changeset-service.ts @@ -0,0 +1,244 @@ +// ***************************************************************************** +// 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 { injectable, inject } from '@theia/core/shared/inversify'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { WorkspaceFunctionScope } from './workspace-functions'; +import { ContentChangeApplierService, ChangeOperation } from './content-change-applier-service'; + +/** + * Represents the changes that should be applied to a specific file. + */ +interface FileChange { + /** + * The relative path of the file within the workspace. + */ + file: string; + + /** + * A list of operations to apply to the file content. + */ + changes: ChangeOperation[]; +} + +/** + * Represents a set of file changes. + */ +interface ChangeSet { + /** + * A unique identifier for the change set. + */ + uuid: string; + + /** + * A human-readable description explaining the purpose of this change set. + */ + description: string; + + /** + * A map where each key is the file path and the value is the corresponding {@link FileChange}s. + */ + fileChanges: Map; +} +/** + * Service responsible for managing and applying sets of content changes across files. + * + * This service allows initialization, modification, and application of file {@link ChangeSet}s. + */ +@injectable() +export class FileChangeSetService { + @inject(FileService) + fileService: FileService; + + @inject(WorkspaceFunctionScope) + workspaceScope: WorkspaceFunctionScope; + + @inject(ContentChangeApplierService) + private contentChangeApplier: ContentChangeApplierService; + + private changeSets: Map = new Map(); + + /** + * Initializes a new change set with the provided UUID and description. + * + * @param uuid - The unique identifier for the change set. + * @param description - A description of the purpose of the change set. + */ + initializeChangeSet(uuid: string, description: string): void { + this.changeSets.set(uuid, { uuid, description, fileChanges: new Map() }); + } + + /** + * Checks whether a change set with the specified UUID is initialized. + * @param uuid - The UUID of the change set to check. + * @returns `true` if the change set exists and is initialized; otherwise, `false`. + */ + isChangeSetInitialized(uuid: string): boolean { + return this.changeSets.has(uuid); + } + + /** + * Adds a file change to an existing change set. + * + * @param uuid - The UUID of the change set. + * @param filePath - The relative file path for which the change is defined. + * @param changes - An array of {@link ChangeOperation}s to apply to the file. + * @throws Will throw an error if the specified change set does not exist. + */ + addFileChange(uuid: string, filePath: string, changes: ChangeOperation[]): void { + const changeSet = this.changeSets.get(uuid); + if (!changeSet) { + throw new Error(`Change set ${uuid} does not exist.`); + } + changeSet.fileChanges.set(filePath, { file: filePath, changes }); + } + + /** + * Removes a file change from a change set. + * + * @param uuid - The UUID of the change set. + * @param filePath - The file path of the change to remove. + * @throws Will throw an error if the specified change set does not exist. + */ + removeFileChange(uuid: string, filePath: string): void { + const changeSet = this.changeSets.get(uuid); + if (!changeSet) { + throw new Error(`Change set ${uuid} does not exist.`); + } + changeSet.fileChanges.delete(filePath); + } + + /** + * Retrieves the change set for a given UUID. + * + * @param uuid - The unique identifier for the change set. + * @returns The corresponding ChangeSet object. + * @throws Will throw an error if the change set does not exist. + */ + getChangeSet(uuid: string): ChangeSet { + const changeSet = this.changeSets.get(uuid); + if (!changeSet) { + throw new Error(`Change set ${uuid} does not exist.`); + } + return changeSet; + } + + /** + * Lists all file paths that have changes in a given change set. + * + * @param uuid - The UUID of the change set. + * @returns An array of file paths that have recorded changes. + * @throws Will throw an error if the change set does not exist. + */ + listChangedFiles(uuid: string): string[] { + const changeSet = this.changeSets.get(uuid); + if (!changeSet) { + throw new Error(`Change set ${uuid} does not exist.`); + } + return Array.from(changeSet.fileChanges.keys()); + } + + /** + * Retrieves the list of change operations for a specific file in a change set. + * + * @param uuid - The UUID of the change set. + * @param filePath - The relative path of the file within the change set. + * @returns An array of ChangeOperation objects for the specified file. + * @throws Will throw an error if the change set or file change does not exist. + */ + getFileChanges(uuid: string, filePath: string): ChangeOperation[] { + const changeSet = this.changeSets.get(uuid); + if (!changeSet) { + throw new Error(`Change set ${uuid} does not exist.`); + } + const fileChange = changeSet.fileChanges.get(filePath); + if (!fileChange) { + throw new Error(`File ${filePath} not found in change set ${uuid}.`); + } + return fileChange.changes; + } + + /** + * Applies a set of changes to a single file. + * + * If a file does not exist, it will be created. + * + * @param fileChange - The file change object containing the relative file path and change operations. + * @throws Will throw an error if writing the file fails. + */ + async applyFileChange(fileChange: FileChange): Promise { + + const workspaceRoot = await this.workspaceScope.getWorkspaceRoot(); + const fileUri = workspaceRoot.resolve(fileChange.file); + this.workspaceScope.ensureWithinWorkspace(fileUri, workspaceRoot); + + let fileContent: { value: string } | undefined; + try { + fileContent = await this.fileService.read(fileUri); + } catch (error) { + // Ignore the read error, we create a new file in this case. We might make this explicit for operations in the future. + } + + const initialContent = fileContent?.value || ''; + const updatedContent = this.contentChangeApplier.applyChangesToContent(initialContent, fileChange.changes); + + try { + await this.fileService.write(fileUri, updatedContent); + } catch (error) { + throw new Error(`Failed to write file: ${fileChange.file}`); + } + } + + /** + * Applies all file changes contained in a change set. + * + * Iterates through each file in the change set and attempts to apply the associated changes. + * Collects any errors that occur during processing and, if any errors are present after processing, + * throws a single aggregated Error with details. + * + * @param uuid - The UUID of the change set to apply. + * @throws Will throw an error if the change set does not exist. Will throw an aggregated error if one or more file changes fail to apply. + */ + async applyChangeSet(uuid: string): Promise { + const changeSet = this.changeSets.get(uuid); + if (!changeSet) { + throw new Error(`Change set ${uuid} does not exist.`); + } + const errorMessages: string[] = []; + + for (const filePath of changeSet.fileChanges.keys()) { + const fileChange = changeSet.fileChanges.get(filePath); + if (!fileChange) { + errorMessages.push(`File "${filePath}" not found in change set "${uuid}".`); + continue; + } + try { + await this.applyFileChange(fileChange); + } catch (error) { + if (error instanceof Error) { + errorMessages.push(`Error applying change to "${filePath}": ${error.message}`); + } else { + // Handle non-Error exceptions + errorMessages.push(`Unknown error applying change to "${filePath}": ${error}`); + } + } + } + if (errorMessages.length > 0) { + const combinedErrorMessage = `Failed to apply some file changes for change set "${uuid}":\n` + + errorMessages.map((msg, index) => `${index + 1}. ${msg}`).join('\n'); + throw new Error(combinedErrorMessage); + } + } +} diff --git a/packages/ai-workspace-agent/src/browser/frontend-module.ts b/packages/ai-workspace-agent/src/browser/frontend-module.ts index 63fb8d3b84489..687cd66c47333 100644 --- a/packages/ai-workspace-agent/src/browser/frontend-module.ts +++ b/packages/ai-workspace-agent/src/browser/frontend-module.ts @@ -13,21 +13,52 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** + import { ContainerModule } from '@theia/core/shared/inversify'; import { ChatAgent } from '@theia/ai-chat/lib/common'; import { Agent, ToolProvider } from '@theia/ai-core/lib/common'; import { WorkspaceAgent } from './workspace-agent'; -import { FileContentFunction, GetWorkspaceDirectoryStructure, GetWorkspaceFileList, WorkspaceFunctionScope } from './functions'; +import { CoderAgent } from './coder-agent'; +import { FileContentFunction, GetWorkspaceDirectoryStructure, GetWorkspaceFileList, WorkspaceFunctionScope } from './workspace-functions'; import { PreferenceContribution } from '@theia/core/lib/browser'; import { WorkspacePreferencesSchema } from './workspace-preferences'; +import { + InitializeChangeSetProvider, + RemoveFileChangeProvider, + GetChangeSetProvider, + ListChangedFilesProvider, + GetFileChangesProvider, + ApplyChangeSetProvider +} from './file-changeset-functions'; +import { FileChangeSetService } from './file-changeset-service'; +import { WriteToFileChangeProvider } from './replace-changeset-functions'; +import { ContentChangeApplier, ContentChangeApplierService } from './content-change-applier-service'; +import { bindContributionProvider } from '@theia/core'; +import { ReplaceContentChangeApplier } from './replace-content-change-applier'; + export default new ContainerModule(bind => { bind(PreferenceContribution).toConstantValue({ schema: WorkspacePreferencesSchema }); bind(WorkspaceAgent).toSelf().inSingletonScope(); bind(Agent).toService(WorkspaceAgent); bind(ChatAgent).toService(WorkspaceAgent); + bind(CoderAgent).toSelf().inSingletonScope(); + bind(Agent).toService(CoderAgent); + bind(ChatAgent).toService(CoderAgent); bind(ToolProvider).to(GetWorkspaceFileList); bind(ToolProvider).to(FileContentFunction); bind(ToolProvider).to(GetWorkspaceDirectoryStructure); bind(WorkspaceFunctionScope).toSelf().inSingletonScope(); + + bindContributionProvider(bind, ContentChangeApplier); + bind(ContentChangeApplier).to(ReplaceContentChangeApplier); + bind(ContentChangeApplierService).toSelf().inSingletonScope(); + bind(FileChangeSetService).toSelf().inSingletonScope(); + bind(ToolProvider).to(InitializeChangeSetProvider); + bind(ToolProvider).to(RemoveFileChangeProvider); + bind(ToolProvider).to(GetChangeSetProvider); + bind(ToolProvider).to(ListChangedFilesProvider); + bind(ToolProvider).to(GetFileChangesProvider); + bind(ToolProvider).to(ApplyChangeSetProvider); + bind(ToolProvider).to(WriteToFileChangeProvider); }); diff --git a/packages/ai-workspace-agent/src/browser/replace-changeset-functions.ts b/packages/ai-workspace-agent/src/browser/replace-changeset-functions.ts new file mode 100644 index 0000000000000..6ab47f204a185 --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/replace-changeset-functions.ts @@ -0,0 +1,69 @@ +// ***************************************************************************** +// Copyright (C) 2024 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 { injectable, inject } from '@theia/core/shared/inversify'; +import { ToolProvider, ToolRequest } from '@theia/ai-core'; +import { FileChangeSetService } from './file-changeset-service'; +import { ReplaceChangeOperation } from './replace-content-change-applier'; + +@injectable() +export class WriteToFileChangeProvider implements ToolProvider { + static ID = 'changeSet_writeToFileChange'; + + @inject(FileChangeSetService) + protected readonly changeSetService: FileChangeSetService; + + getTool(): ToolRequest { + return { + id: WriteToFileChangeProvider.ID, + name: WriteToFileChangeProvider.ID, + description: `Proposes writing content to a file in the specified change set. If the file exists, it will be overwritten with the provided content.\n + If the file does not exist, it will be created. This tool will automatically create any directories needed to write the file.\n + The changes can be applied when the user accepts.`, + parameters: { + type: 'object', + properties: { + uuid: { + type: 'string', + description: 'Unique identifier for the change set.' + }, + path: { + type: 'string', + description: 'The path of the file to write to.' + }, + content: { + type: 'string', + description: `The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions.\n + You MUST include ALL parts of the file, even if they haven\'t been modified.` + } + }, + required: ['uuid', 'path', 'content'] + }, + handler: async (args: string): Promise => { + try { + const { uuid, path, content } = JSON.parse(args); + const operation: ReplaceChangeOperation = { + kind: 'replace', + newContent: content + }; + this.changeSetService.addFileChange(uuid, path, [operation]); + return `Proposed writing to file ${path} in change set ${uuid}. Please review and apply the changes as needed.`; + } catch (error) { + return JSON.stringify({ error: error.message }); + } + } + }; + } +} diff --git a/packages/ai-workspace-agent/src/browser/replace-content-change-applier.ts b/packages/ai-workspace-agent/src/browser/replace-content-change-applier.ts new file mode 100644 index 0000000000000..b0c9760faa4b1 --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/replace-content-change-applier.ts @@ -0,0 +1,36 @@ +// ***************************************************************************** +// Copyright (C) 2024 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 { injectable } from '@theia/core/shared/inversify'; +import { ChangeOperation, ContentChangeApplier } from './content-change-applier-service'; + +// For simple replace operations: +export interface ReplaceChangeOperation extends ChangeOperation { + kind: 'replace'; + oldContent?: string; + newContent: string; +} + +@injectable() +export class ReplaceContentChangeApplier implements ContentChangeApplier { + kind: ReplaceChangeOperation['kind'] = 'replace'; + + applyChange(content: string, operation: ReplaceChangeOperation): string { + return operation.newContent; + } +} + diff --git a/packages/ai-workspace-agent/src/browser/workspace-agent.ts b/packages/ai-workspace-agent/src/browser/workspace-agent.ts index 3d05487a16d9a..5238757fba2ca 100644 --- a/packages/ai-workspace-agent/src/browser/workspace-agent.ts +++ b/packages/ai-workspace-agent/src/browser/workspace-agent.ts @@ -16,8 +16,8 @@ import { AbstractStreamParsingChatAgent, ChatAgent, SystemMessageDescription } from '@theia/ai-chat/lib/common'; import { AgentSpecificVariables, PromptTemplate, ToolInvocationRegistry } from '@theia/ai-core'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { workspaceTemplate } from '../common/template'; -import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID } from '../common/functions'; +import { workspaceTemplate } from '../common/workspace-template'; +import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID } from '../common/workspace-functions'; @injectable() export class WorkspaceAgent extends AbstractStreamParsingChatAgent implements ChatAgent { diff --git a/packages/ai-workspace-agent/src/browser/functions.ts b/packages/ai-workspace-agent/src/browser/workspace-functions.ts similarity index 99% rename from packages/ai-workspace-agent/src/browser/functions.ts rename to packages/ai-workspace-agent/src/browser/workspace-functions.ts index a4cda9908e8f8..8c4ba9c663827 100644 --- a/packages/ai-workspace-agent/src/browser/functions.ts +++ b/packages/ai-workspace-agent/src/browser/workspace-functions.ts @@ -19,7 +19,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { WorkspaceService } from '@theia/workspace/lib/browser'; -import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID } from '../common/functions'; +import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID } from '../common/workspace-functions'; import ignore from 'ignore'; import { Minimatch } from 'minimatch'; import { PreferenceService } from '@theia/core/lib/browser'; diff --git a/packages/ai-workspace-agent/src/common/coder-replace-template.ts b/packages/ai-workspace-agent/src/common/coder-replace-template.ts new file mode 100644 index 0000000000000..2fbbf5ac4f3f7 --- /dev/null +++ b/packages/ai-workspace-agent/src/common/coder-replace-template.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +/* + * Copyright (C) 2024 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 { PromptTemplate } from '@theia/ai-core/lib/common'; +import { GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID } from './workspace-functions'; + +export const coderReplaceTemplate = { + id: 'coder-replace-system', + template: `You are an AI assistant integrated into Theia IDE, designed to assist software developers with code tasks. You can interact with the code base and suggest changes. + +## Context Retrieval +Use the following functions to interact with the workspace files if you require context: +- **~{${GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID}}**: Returns the complete directory structure. +- **~{${GET_WORKSPACE_FILE_LIST_FUNCTION_ID}}**: Lists files and directories in a specific directory. +- **~{${FILE_CONTENT_FUNCTION_ID}}**: Retrieves the content of a specific file. + +## Propose Code Changes +To propose code changes to the user, never print them in your response. +Instead, use the following tool functions to create, update, and manage change sets and file changes. + +### Tool Functions +Use the provided tool functions to manage change sets and file changes: +- **~{changeSet_initializeChangeSet}** +- **~{changeSet_writeToFileChange}** +- **~{changeSet_removeFileChange}** +- **~{changeSet_getChangeSet}** +- **~{changeSet_listChangedFiles}** +- **~{changeSet_getFileChanges}** + +### Initialize Change Set +Before suggesting changes, initialize a new change set and remember it's UUID. + +### Guidelines for Proposing Code Changes +- For each proposed change: + - **Retrieve Current Content**: Use \`FILE_CONTENT_FUNCTION_ID\` to get the latest content of the target file. + - **Change Content**: Use changeSet_writeToFileChange to suggest file changes to the user. + +### Apply the Change Set +When the user explicitly asks you to apply the changes execute the following function with the UUID to apply all modifications to the codebase. +~{changeSet_applyChangeSet}`, +}; + diff --git a/packages/ai-workspace-agent/src/common/functions.ts b/packages/ai-workspace-agent/src/common/workspace-functions.ts similarity index 100% rename from packages/ai-workspace-agent/src/common/functions.ts rename to packages/ai-workspace-agent/src/common/workspace-functions.ts diff --git a/packages/ai-workspace-agent/src/common/template.ts b/packages/ai-workspace-agent/src/common/workspace-template.ts similarity index 99% rename from packages/ai-workspace-agent/src/common/template.ts rename to packages/ai-workspace-agent/src/common/workspace-template.ts index b93b62422f2de..2a78ecbe625cd 100644 --- a/packages/ai-workspace-agent/src/common/template.ts +++ b/packages/ai-workspace-agent/src/common/workspace-template.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { PromptTemplate } from '@theia/ai-core/lib/common'; -import { GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID } from './functions'; +import { GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID } from './workspace-functions'; export const workspaceTemplate = { id: 'workspace-system', diff --git a/test.json b/test.json new file mode 100644 index 0000000000000..d72e25eaaf986 --- /dev/null +++ b/test.json @@ -0,0 +1 @@ +{ "uuid": "ffb06a30-a021-465a-9361-868d138d85bc", "filePath": "packages/ai-mcp/src/browser/mcp-preferences.ts", "change": { "diff": "@@ ... @@\n },\n+ autostart: {\n+ type: 'boolean',\n+ title: 'Autostart Server',\n+ markdownDescription: 'Automatically start the MCP server when Theia starts.'\n+ }\n },\n required: ['command', 'args']\n" } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c760d96a0e897..4a28adb4e4c09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5213,11 +5213,6 @@ detect-libc@^1.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== -detect-libc@^2.0.0, detect-libc@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d" - integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== - detect-libc@^2.0.0, detect-libc@^2.0.1, detect-libc@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" @@ -11896,7 +11891,16 @@ string-argv@^0.1.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.1.2.tgz#c5b7bc03fb2b11983ba3a72333dd0559e77e4738" integrity sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -11982,7 +11986,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12003,6 +12007,13 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -13288,7 +13299,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -13306,6 +13317,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"