From 884ebf83f35dde9c220619e94454d3e3e488be5d Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Tue, 13 Aug 2024 12:06:26 -0400 Subject: [PATCH] Add Ruby Copilot chat agent with domain driven design command (#2366) * Bump minimum VS Code engine to 1.92 * Add chat agent with domain driven design command * Apply PR feedback --- vscode/README.md | 25 +++++ vscode/package.json | 23 +++- vscode/src/chatAgent.ts | 230 ++++++++++++++++++++++++++++++++++++++++ vscode/src/rubyLsp.ts | 24 ++++- vscode/yarn.lock | 8 +- 5 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 vscode/src/chatAgent.ts diff --git a/vscode/README.md b/vscode/README.md index 8353010ab..6f9ee6bcb 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -29,6 +29,31 @@ See complete information about features [here](https://shopify.github.io/ruby-ls If you experience issues, please see the [troubleshooting guide](https://github.com/Shopify/ruby-lsp/blob/main/TROUBLESHOOTING.md). +### [Experimental] GitHub Copilot chat agent + +For users of Copilot, the Ruby LSP contributes a Ruby agent for AI assisted development of Ruby applications. Below you +can find the documentation of each command for the Ruby agent. For information about how to interact with Copilot Chat, +check [VS Code's official documentation](https://code.visualstudio.com/docs/copilot/copilot-chat). + +#### Design command + +The `@ruby /design` command is intended to be a domain driven design expert to help users model concepts for their Rails +applications. Users should describe what type of application they are building and which concept they are trying to +model. The command will read their Rails application's schema and use their prompt, previous interactions and the schema +information to provide suggestions of how to design the application. For example, + +``` +@ruby /design I'm working on a web application for schools. How do I model courses? And how do they relate to students? +``` + +The output is a suggested schema for courses including relationships with users. In the chat window, two buttons will appear: `Generate with Rails`, which invokes the Rails generators to create the models suggested, and `Revert previous generation`, which will delete files generated by a previous click in the generate button. + +As with most LLM chat functionality, suggestions may not be fully accurate, especially in the first iteration. Users can +continue chatting with the `@ruby` agent to fine tune the suggestions given, before deciding to move forward with +generation. + +If you have feedback about this feature, you can let us know in the [DX Slack](https://join.slack.com/t/ruby-dx/shared_invite/zt-2c8zjlir6-uUDJl8oIwcen_FS_aA~b6Q) or by [creating an issue](https://github.com/Shopify/ruby-lsp/issues/new). + ## Usage Search for `Shopify.ruby-lsp` in the extensions tab and click install. diff --git a/vscode/package.json b/vscode/package.json index 934e079ae..32e20a5a1 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -11,12 +11,14 @@ "license": "MIT", "icon": "icon.png", "engines": { - "vscode": "^1.68.0" + "vscode": "^1.92.0" }, "categories": [ "Programming Languages", "Snippets", - "Testing" + "Testing", + "AI", + "Chat" ], "activationEvents": [ "onLanguage:ruby", @@ -26,6 +28,21 @@ ], "main": "./out/extension.js", "contributes": { + "chatParticipants": [ + { + "id": "rubyLsp.chatAgent", + "fullName": "Ruby", + "name": "ruby", + "description": "How can I help with your Ruby on Rails application?", + "isSticky": true, + "commands": [ + { + "name": "design", + "description": "Explain what you're trying to build and I will suggest possible ways to model the domain" + } + ] + } + ], "menus": { "editor/context": [ { @@ -651,7 +668,7 @@ "@types/mocha": "^10.0.7", "@types/node": "22.x", "@types/sinon": "^17.0.3", - "@types/vscode": "^1.68.0", + "@types/vscode": "^1.92.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vscode/test-electron": "^2.4.1", diff --git a/vscode/src/chatAgent.ts b/vscode/src/chatAgent.ts new file mode 100644 index 000000000..612ef164d --- /dev/null +++ b/vscode/src/chatAgent.ts @@ -0,0 +1,230 @@ +import * as vscode from "vscode"; + +import { Command } from "./common"; +import { Workspace } from "./workspace"; + +const CHAT_AGENT_ID = "rubyLsp.chatAgent"; +const DESIGN_PROMPT = ` + You are a domain driven design and Ruby on Rails expert. + The user will provide you with details about their Rails application. + The user will ask you to help model a single specific concept. + + Analyze the provided concept carefully and think step by step. Consider the following aspects: + 1. The core purpose of the concept + 2. Its relationships with other potential entities in the system + 3. The attributes that would best represent this concept in a database + + Based on your analysis, suggest an appropriate model name and attributes to effectively model the concept. + Follow these guidelines: + + 1. Choose a clear, singular noun for the model name that accurately represents the concept + 2. Select attributes that capture the essential characteristics of the concept + 3. Use appropriate data types for each attribute (e.g. string, integer, datetime, boolean) + 4. Consider adding foreign keys for relationships with other models, if applicable + + After determining the model structure, generate the Rails commands to create the model and any associated resources. + Include all relevant \`generate\` commands in a single Markdown shell code block at the end of your response. + + The \`generate\` commands should ONLY include the type of generator and arguments, not the \`rails generate\` part + (e.g.: \`model User name:string\` but not \`rails generate model User name:string\`). + NEVER include commands to migrate the database as part of the code block. + NEVER include redundant commands (e.g. including the migration and model generation commands for the same model). +`.trim(); + +export class ChatAgent implements vscode.Disposable { + private readonly agent: vscode.ChatParticipant; + private readonly showWorkspacePick: () => Promise; + + constructor( + context: vscode.ExtensionContext, + showWorkspacePick: () => Promise, + ) { + this.agent = vscode.chat.createChatParticipant( + CHAT_AGENT_ID, + this.handler.bind(this), + ); + this.agent.iconPath = vscode.Uri.joinPath(context.extensionUri, "icon.png"); + this.showWorkspacePick = showWorkspacePick; + } + + dispose() { + this.agent.dispose(); + } + + // Handle a new chat message or command + private async handler( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + ) { + if (this.withinConversation("design", request, context)) { + return this.runDesignCommand(request, context, stream, token); + } + + stream.markdown( + "Please indicate which command you would like to use for our chat.", + ); + return { metadata: { command: "" } }; + } + + // Logic for the domain driven design command + private async runDesignCommand( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + ) { + const previousInteractions = this.previousInteractions(context); + const messages = [ + vscode.LanguageModelChatMessage.User(`User prompt: ${request.prompt}`), + vscode.LanguageModelChatMessage.User(DESIGN_PROMPT), + vscode.LanguageModelChatMessage.User( + `Previous interactions with the user: ${previousInteractions}`, + ), + ]; + const workspace = await this.showWorkspacePick(); + + // On the first interaction with the design command, we gather the application's schema and include it as part of + // the prompt + if (request.command && workspace) { + const schema = await this.schema(workspace); + + if (schema) { + messages.push( + vscode.LanguageModelChatMessage.User( + `Existing application schema: ${schema}`, + ), + ); + } + } + + try { + // Select the LLM model + const [model] = await vscode.lm.selectChatModels({ + vendor: "copilot", + family: "gpt-4o", + }); + + stream.progress("Designing the models for the requested concept..."); + const chatResponse = await model.sendRequest(messages, {}, token); + + let response = ""; + for await (const fragment of chatResponse.text) { + // Maybe show the buttons here and display multiple shell blocks? + stream.markdown(fragment); + response += fragment; + } + + const match = /(?<=```shell)[^.$]*(?=```)/.exec(response); + + if (workspace && match && match[0]) { + // The shell code block includes all of the `rails generate` commands. We need to strip out the `rails generate` + // from all of them since our commands only accept from the generator forward + const commandList = match[0] + .trim() + .split("\n") + .map((command) => { + return command.replace(/\s*(bin\/rails|rails) generate\s*/, ""); + }); + + stream.button({ + command: Command.RailsGenerate, + title: "Generate with Rails", + arguments: [commandList, workspace], + }); + + stream.button({ + command: Command.RailsDestroy, + title: "Revert previous generation", + arguments: [commandList, workspace], + }); + } + } catch (err) { + this.handleError(err, stream); + } + + return { metadata: { command: "design" } }; + } + + private async schema(workspace: Workspace) { + try { + const content = await vscode.workspace.fs.readFile( + vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/schema.rb"), + ); + return content.toString(); + } catch (error) { + // db/schema.rb doesn't exist + } + + try { + const content = await vscode.workspace.fs.readFile( + vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/structure.sql"), + ); + return content.toString(); + } catch (error) { + // db/structure.sql doesn't exist + } + + return undefined; + } + + // Returns `true` if the current or any previous interactions with the chat match the given `command`. Useful for + // ensuring that the user can continue chatting without having to re-type the desired command multiple times + private withinConversation( + command: string, + request: vscode.ChatRequest, + context: vscode.ChatContext, + ) { + return ( + request.command === command || + (!request.command && + context.history.some( + (entry) => + entry instanceof vscode.ChatRequestTurn && + entry.command === command, + )) + ); + } + + // Default error handling + private handleError(err: any, stream: vscode.ChatResponseStream) { + if (err instanceof vscode.LanguageModelError) { + if ( + err.cause instanceof Error && + err.cause.message.includes("off_topic") + ) { + stream.markdown( + "Sorry, I can only help you with Ruby related questions", + ); + } + } else { + throw err; + } + } + + // Get the content of all previous interactions (including requests and responses) as a string + private previousInteractions(context: vscode.ChatContext): string { + let history = ""; + + context.history.forEach((entry) => { + if (entry instanceof vscode.ChatResponseTurn) { + if (entry.participant === CHAT_AGENT_ID) { + let content = ""; + + entry.response.forEach((part) => { + if (part instanceof vscode.ChatResponseMarkdownPart) { + content += part.value.value; + } + }); + + history += `Response: ${content}`; + } + } else { + history += `Request: ${entry.prompt}`; + } + }); + + return history; + } +} diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index ca7c66f7a..148fa14a2 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -16,6 +16,7 @@ import { newMinitestFile, openFile, openUris } from "./commands"; import { Debugger } from "./debugger"; import { DependenciesTree } from "./dependenciesTree"; import { Rails } from "./rails"; +import { ChatAgent } from "./chatAgent"; // The RubyLsp class represents an instance of the entire extension. This should only be instantiated once at the // activation event. One instance of this class controls all of the existing workspaces, telemetry and handles all @@ -50,6 +51,7 @@ export class RubyLsp { this.statusItems, this.debug, dependenciesTree, + new ChatAgent(context, this.showWorkspacePick.bind(this)), // Switch the status items based on which workspace is currently active vscode.window.onDidChangeActiveTextEditor((editor) => { @@ -457,7 +459,7 @@ export class RubyLsp { vscode.commands.registerCommand( Command.RailsGenerate, async ( - generatorWithArguments: string | undefined, + generatorWithArguments: string | string[] | undefined, workspace: Workspace | undefined, ) => { // If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need @@ -474,13 +476,20 @@ export class RubyLsp { return; } - await this.rails.generate(command, workspace); + if (typeof command === "string") { + await this.rails.generate(command, workspace); + return; + } + + for (const generate of command) { + await this.rails.generate(generate, workspace); + } }, ), vscode.commands.registerCommand( Command.RailsDestroy, async ( - generatorWithArguments: string | undefined, + generatorWithArguments: string | string[] | undefined, workspace: Workspace | undefined, ) => { // If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need @@ -497,7 +506,14 @@ export class RubyLsp { return; } - await this.rails.destroy(command, workspace); + if (typeof command === "string") { + await this.rails.destroy(command, workspace); + return; + } + + for (const generate of command) { + await this.rails.destroy(generate, workspace); + } }, ), vscode.commands.registerCommand(Command.FileOperation, async () => { diff --git a/vscode/yarn.lock b/vscode/yarn.lock index f2974ada9..4f9f60592 100644 --- a/vscode/yarn.lock +++ b/vscode/yarn.lock @@ -715,10 +715,10 @@ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== -"@types/vscode@^1.68.0": - version "1.86.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.86.0.tgz#5d5f233137b27e51d7ad1462600005741296357a" - integrity sha512-DnIXf2ftWv+9LWOB5OJeIeaLigLHF7fdXF6atfc7X5g2w/wVZBgk0amP7b+ub5xAuW1q7qP5YcFvOcit/DtyCQ== +"@types/vscode@^1.92.0": + version "1.92.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.92.0.tgz#b4d6bc180e7206defe643a1a5f38a1367947d418" + integrity sha512-DcZoCj17RXlzB4XJ7IfKdPTcTGDLYvTOcTNkvtjXWF+K2TlKzHHkBEXNWQRpBIXixNEUgx39cQeTFunY0E2msw== "@typescript-eslint/eslint-plugin@^7.18.0", "@typescript-eslint/eslint-plugin@^7.9.0": version "7.18.0"