Skip to content

Commit

Permalink
Add chat agent with domain driven design command
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Aug 8, 2024
1 parent cfbd760 commit a55f4a5
Show file tree
Hide file tree
Showing 4 changed files with 291 additions and 5 deletions.
23 changes: 23 additions & 0 deletions vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ 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).

### 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
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.

## Usage

Search for `Shopify.ruby-lsp` in the extensions tab and click install.
Expand Down
19 changes: 18 additions & 1 deletion vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
"categories": [
"Programming Languages",
"Snippets",
"Testing"
"Testing",
"AI",
"Chat"
],
"activationEvents": [
"onLanguage:ruby",
Expand All @@ -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": [
{
Expand Down
230 changes: 230 additions & 0 deletions vscode/src/chatAgent.ts
Original file line number Diff line number Diff line change
@@ -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<Workspace | undefined>;

constructor(
context: vscode.ExtensionContext,
showWorkspacePick: () => Promise<Workspace | undefined>,
) {
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;
}
}
24 changes: 20 additions & 4 deletions vscode/src/rubyLsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 () => {
Expand Down

0 comments on commit a55f4a5

Please sign in to comment.