Skip to content

Commit 5a5261d

Browse files
committed
Add chat agent with domain driven design command
1 parent d5570d2 commit 5a5261d

File tree

4 files changed

+275
-5
lines changed

4 files changed

+275
-5
lines changed

vscode/README.md

+23
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ See complete information about features [here](https://shopify.github.io/ruby-ls
2929
If you experience issues, please see the [troubleshooting
3030
guide](https://github.com/Shopify/ruby-lsp/blob/main/TROUBLESHOOTING.md).
3131

32+
### Copilot chat agent
33+
34+
For users of Copilot, the Ruby LSP contributes a Ruby agent for AI assisted development of Ruby applications. Below you
35+
can find the documentation of each command for the Ruby agent. For information about how to interact with Copilot Chat,
36+
check [VS Code's official documentation](https://code.visualstudio.com/docs/copilot/copilot-chat).
37+
38+
#### Design command
39+
40+
The `@ruby /design` command is intended to be a domain driven design expert to help users model concepts for their
41+
applications. Users should describe what type of application they are building and which concept they are trying to
42+
model. The command will read their Rails application's schema and use their prompt, previous interactions and the schema
43+
information to provide suggestions of how to design the application. For example,
44+
45+
```
46+
@ruby /design I'm working on a web application for schools. How do I model courses? And how do they relate to students?
47+
```
48+
49+
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.
50+
51+
As with most LLM chat functionality, suggestions may not be fully accurate, especially in the first iteration. Users can
52+
continue chatting with the `@ruby` agent to fine tune the suggestions given, before deciding to move forward with
53+
generation.
54+
3255
## Usage
3356

3457
Search for `Shopify.ruby-lsp` in the extensions tab and click install.

vscode/package.json

+18-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
"categories": [
1717
"Programming Languages",
1818
"Snippets",
19-
"Testing"
19+
"Testing",
20+
"AI",
21+
"Chat"
2022
],
2123
"activationEvents": [
2224
"onLanguage:ruby",
@@ -26,6 +28,21 @@
2628
],
2729
"main": "./out/extension.js",
2830
"contributes": {
31+
"chatParticipants": [
32+
{
33+
"id": "rubyLsp.chatAgent",
34+
"fullName": "Ruby",
35+
"name": "ruby",
36+
"description": "How can I help with your Ruby on Rails application?",
37+
"isSticky": true,
38+
"commands": [
39+
{
40+
"name": "design",
41+
"description": "Explain what you're trying to build and I will suggest possible ways to model the domain"
42+
}
43+
]
44+
}
45+
],
2946
"menus": {
3047
"editor/context": [
3148
{

vscode/src/chatAgent.ts

+214
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import * as vscode from "vscode";
2+
3+
import { Command } from "./common";
4+
import { Workspace } from "./workspace";
5+
6+
const CHAT_AGENT_ID = "rubyLsp.chatAgent";
7+
const DESIGN_PROMPT = `
8+
You are a domain driven design and Ruby on Rails expert.
9+
The user will provide you with details about their Rails application.
10+
The user will ask you to help model a single specific concept.
11+
Your job is to suggest a model name and attributes to model that concept.
12+
Include all Rails \`generate\` commands in a single Markdown shell code block at the end.
13+
The \`generate\` commands should ONLY include the type of generator and arguments, not the \`rails generate\` part
14+
(e.g.: \`model User name:string\` but not \`rails generate model User name:string\`).
15+
NEVER include commands to migrate the database as part of the code block.
16+
`.trim();
17+
18+
export class ChatAgent implements vscode.Disposable {
19+
private readonly agent: vscode.ChatParticipant;
20+
private readonly showWorkspacePick: () => Promise<Workspace | undefined>;
21+
22+
constructor(
23+
context: vscode.ExtensionContext,
24+
showWorkspacePick: () => Promise<Workspace | undefined>,
25+
) {
26+
this.agent = vscode.chat.createChatParticipant(
27+
CHAT_AGENT_ID,
28+
this.handler.bind(this),
29+
);
30+
this.agent.iconPath = vscode.Uri.joinPath(context.extensionUri, "icon.png");
31+
this.showWorkspacePick = showWorkspacePick;
32+
}
33+
34+
dispose() {
35+
this.agent.dispose();
36+
}
37+
38+
// Handle a new chat message or command
39+
private async handler(
40+
request: vscode.ChatRequest,
41+
context: vscode.ChatContext,
42+
stream: vscode.ChatResponseStream,
43+
token: vscode.CancellationToken,
44+
) {
45+
if (this.withinConversation("design", request, context)) {
46+
return this.runDesignCommand(request, context, stream, token);
47+
}
48+
49+
stream.markdown(
50+
"Please indicate which command you would like to use for our chat.",
51+
);
52+
return { metadata: { command: "" } };
53+
}
54+
55+
// Logic for the domain driven design command
56+
private async runDesignCommand(
57+
request: vscode.ChatRequest,
58+
context: vscode.ChatContext,
59+
stream: vscode.ChatResponseStream,
60+
token: vscode.CancellationToken,
61+
) {
62+
const previousInteractions = this.previousInteractions(context);
63+
const messages = [
64+
vscode.LanguageModelChatMessage.User(`User prompt: ${request.prompt}`),
65+
vscode.LanguageModelChatMessage.User(DESIGN_PROMPT),
66+
vscode.LanguageModelChatMessage.User(
67+
`Previous interactions with the user: ${previousInteractions}`,
68+
),
69+
];
70+
const workspace = await this.showWorkspacePick();
71+
72+
// On the first interaction with the design command, we gather the application's schema and include it as part of
73+
// the prompt
74+
if (request.command && workspace) {
75+
const schema = await this.schema(workspace);
76+
77+
if (schema) {
78+
messages.push(
79+
vscode.LanguageModelChatMessage.User(
80+
`Existing application schema: ${schema}`,
81+
),
82+
);
83+
}
84+
}
85+
86+
try {
87+
// Select the LLM model
88+
const [model] = await vscode.lm.selectChatModels({
89+
vendor: "copilot",
90+
family: "gpt-4-turbo",
91+
});
92+
93+
stream.progress("Designing the models for the requested concept...");
94+
const chatResponse = await model.sendRequest(messages, {}, token);
95+
96+
let response = "";
97+
for await (const fragment of chatResponse.text) {
98+
// Maybe show the buttons here and display multiple shell blocks?
99+
stream.markdown(fragment);
100+
response += fragment;
101+
}
102+
103+
const match = /(?<=```shell)[^.$]*(?=```)/.exec(response);
104+
105+
if (workspace && match && match[0]) {
106+
// The shell code block includes all of the `rails generate` commands. We need to strip out the `rails generate`
107+
// from all of them since our commands only accept from the generator forward
108+
const commandList = match[0]
109+
.trim()
110+
.split("\n")
111+
.map((command) => {
112+
return command.replace(/\s*(bin\/rails|rails) generate\s*/, "");
113+
});
114+
115+
stream.button({
116+
command: Command.RailsGenerate,
117+
title: "Generate with Rails",
118+
arguments: [commandList, workspace],
119+
});
120+
121+
stream.button({
122+
command: Command.RailsDestroy,
123+
title: "Revert previous generation",
124+
arguments: [commandList, workspace],
125+
});
126+
}
127+
} catch (err) {
128+
this.handleError(err, stream);
129+
}
130+
131+
return { metadata: { command: "design" } };
132+
}
133+
134+
private async schema(workspace: Workspace) {
135+
try {
136+
const content = await vscode.workspace.fs.readFile(
137+
vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/schema.rb"),
138+
);
139+
return content.toString();
140+
} catch (error) {
141+
// db/schema.rb doesn't exist
142+
}
143+
144+
try {
145+
const content = await vscode.workspace.fs.readFile(
146+
vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/structure.sql"),
147+
);
148+
return content.toString();
149+
} catch (error) {
150+
// db/structure.sql doesn't exist
151+
}
152+
153+
return undefined;
154+
}
155+
156+
// Returns `true` if the current or any previous interactions with the chat match the given `command`. Useful for
157+
// ensuring that the user can continue chatting without having to re-type the desired command multiple times
158+
private withinConversation(
159+
command: string,
160+
request: vscode.ChatRequest,
161+
context: vscode.ChatContext,
162+
) {
163+
return (
164+
request.command === command ||
165+
(!request.command &&
166+
context.history.some(
167+
(entry) =>
168+
entry instanceof vscode.ChatRequestTurn &&
169+
entry.command === command,
170+
))
171+
);
172+
}
173+
174+
// Default error handling
175+
private handleError(err: any, stream: vscode.ChatResponseStream) {
176+
if (err instanceof vscode.LanguageModelError) {
177+
if (
178+
err.cause instanceof Error &&
179+
err.cause.message.includes("off_topic")
180+
) {
181+
stream.markdown(
182+
"Sorry, I can only help you with Ruby related questions",
183+
);
184+
}
185+
} else {
186+
throw err;
187+
}
188+
}
189+
190+
// Get the content of all previous interactions (including requests and responses) as a string
191+
private previousInteractions(context: vscode.ChatContext): string {
192+
let history = "";
193+
194+
context.history.forEach((entry) => {
195+
if (entry instanceof vscode.ChatResponseTurn) {
196+
if (entry.participant === CHAT_AGENT_ID) {
197+
let content = "";
198+
199+
entry.response.forEach((part) => {
200+
if (part instanceof vscode.ChatResponseMarkdownPart) {
201+
content += part.value.value;
202+
}
203+
});
204+
205+
history += `Response: ${content}`;
206+
}
207+
} else {
208+
history += `Request: ${entry.prompt}`;
209+
}
210+
});
211+
212+
return history;
213+
}
214+
}

vscode/src/rubyLsp.ts

+20-4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { newMinitestFile, openFile, openUris } from "./commands";
1616
import { Debugger } from "./debugger";
1717
import { DependenciesTree } from "./dependenciesTree";
1818
import { Rails } from "./rails";
19+
import { ChatAgent } from "./chatAgent";
1920

2021
// The RubyLsp class represents an instance of the entire extension. This should only be instantiated once at the
2122
// activation event. One instance of this class controls all of the existing workspaces, telemetry and handles all
@@ -50,6 +51,7 @@ export class RubyLsp {
5051
this.statusItems,
5152
this.debug,
5253
dependenciesTree,
54+
new ChatAgent(context, this.showWorkspacePick.bind(this)),
5355

5456
// Switch the status items based on which workspace is currently active
5557
vscode.window.onDidChangeActiveTextEditor((editor) => {
@@ -457,7 +459,7 @@ export class RubyLsp {
457459
vscode.commands.registerCommand(
458460
Command.RailsGenerate,
459461
async (
460-
generatorWithArguments: string | undefined,
462+
generatorWithArguments: string | string[] | undefined,
461463
workspace: Workspace | undefined,
462464
) => {
463465
// If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need
@@ -474,13 +476,20 @@ export class RubyLsp {
474476
return;
475477
}
476478

477-
await this.rails.generate(command, workspace);
479+
if (typeof command === "string") {
480+
await this.rails.generate(command, workspace);
481+
return;
482+
}
483+
484+
for (const generate of command) {
485+
await this.rails.generate(generate, workspace);
486+
}
478487
},
479488
),
480489
vscode.commands.registerCommand(
481490
Command.RailsDestroy,
482491
async (
483-
generatorWithArguments: string | undefined,
492+
generatorWithArguments: string | string[] | undefined,
484493
workspace: Workspace | undefined,
485494
) => {
486495
// If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need
@@ -497,7 +506,14 @@ export class RubyLsp {
497506
return;
498507
}
499508

500-
await this.rails.destroy(command, workspace);
509+
if (typeof command === "string") {
510+
await this.rails.destroy(command, workspace);
511+
return;
512+
}
513+
514+
for (const generate of command) {
515+
await this.rails.destroy(generate, workspace);
516+
}
501517
},
502518
),
503519
vscode.commands.registerCommand(Command.FileOperation, async () => {

0 commit comments

Comments
 (0)