Skip to content

Commit 9dd0fd9

Browse files
authored
Copilot tools (#280)
adds support for two copilot tools: - get environment information - install packages
1 parent b275e8b commit 9dd0fd9

File tree

4 files changed

+547
-150
lines changed

4 files changed

+547
-150
lines changed

package.json

+37-7
Original file line numberDiff line numberDiff line change
@@ -491,22 +491,52 @@
491491
],
492492
"languageModelTools": [
493493
{
494-
"name": "python_get_packages",
495-
"displayName": "Get Python Packages",
496-
"modelDescription": "Returns the packages installed in the given Python file's environment. You should call this when you want to generate Python code to determine the users preferred packages. Also call this to determine if you need to provide installation instructions in a response.",
497-
"toolReferenceName": "pythonGetPackages",
494+
"name": "python_environment_tool",
495+
"displayName": "Get Python Environment Information",
496+
"modelDescription": "Provides details about the Python environment for a specified file or workspace, including environment type, Python version, run command, and installed packages with their versions.",
497+
"toolReferenceName": "pythonGetEnvironmentInfo",
498498
"tags": [],
499499
"icon": "$(files)",
500+
"canBeReferencedInPrompt": true,
500501
"inputSchema": {
501502
"type": "object",
502503
"properties": {
503-
"filePath": {
504+
"resourcePath": {
504505
"type": "string"
505506
}
506507
},
507-
"description": "The path to the Python file or workspace to get the installed packages for.",
508+
"description": "The path to the Python file or workspace to get the environment information for.",
508509
"required": [
509-
"filePath"
510+
"resourcePath"
511+
]
512+
}
513+
},
514+
{
515+
"name": "python_install_package_tool",
516+
"displayName": "Install Python Package",
517+
"modelDescription": "Installs Python packages in the given workspace. Use this tool to install packages in the user's chosen environment.",
518+
"toolReferenceName": "pythonInstallPackage",
519+
"tags": [],
520+
"icon": "$(package)",
521+
"canBeReferencedInPrompt": true,
522+
"inputSchema": {
523+
"type": "object",
524+
"properties": {
525+
"packageList": {
526+
"type": "array",
527+
"items": {
528+
"type": "string"
529+
},
530+
"description": "The list of packages to install."
531+
},
532+
"workspacePath": {
533+
"type": "string",
534+
"description": "Path to Python workspace that determines the environment for package installation."
535+
}
536+
},
537+
"required": [
538+
"packageList",
539+
"workspacePath"
510540
]
511541
}
512542
}

src/extension.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { ensureCorrectVersion } from './common/extVersion';
5353
import { ExistingProjects } from './features/creators/existingProjects';
5454
import { AutoFindProjects } from './features/creators/autoFindProjects';
5555
import { registerTools } from './common/lm.apis';
56-
import { GetPackagesTool } from './features/copilotTools';
56+
import { GetEnvironmentInfoTool, InstallPackageTool } from './features/copilotTools';
5757
import { TerminalActivationImpl } from './features/terminal/terminalActivationState';
5858
import { getEnvironmentForTerminal } from './features/terminal/utils';
5959

@@ -107,7 +107,8 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
107107

108108
context.subscriptions.push(
109109
registerCompletionProvider(envManagers),
110-
registerTools('python_get_packages', new GetPackagesTool(api)),
110+
registerTools('python_environment_tool', new GetEnvironmentInfoTool(api, envManagers)),
111+
registerTools('python_install_package_tool', new InstallPackageTool(api)),
111112
commands.registerCommand('python-envs.viewLogs', () => outputChannel.show()),
112113
commands.registerCommand('python-envs.refreshManager', async (item) => {
113114
await refreshManagerCommand(item);

src/features/copilotTools.ts

+186-31
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,44 @@ import {
88
PreparedToolInvocation,
99
Uri,
1010
} from 'vscode';
11-
import { PythonPackageGetterApi, PythonProjectEnvironmentApi } from '../api';
11+
import {
12+
PackageManagementOptions,
13+
PythonEnvironment,
14+
PythonEnvironmentExecutionInfo,
15+
PythonPackageGetterApi,
16+
PythonPackageManagementApi,
17+
PythonProjectEnvironmentApi,
18+
} from '../api';
1219
import { createDeferred } from '../common/utils/deferred';
20+
import { EnvironmentManagers } from '../internal.api';
21+
22+
export interface IResourceReference {
23+
resourcePath?: string;
24+
}
1325

14-
export interface IGetActiveFile {
15-
filePath?: string;
26+
interface EnvironmentInfo {
27+
type: string; // e.g. conda, venv, virtualenv, sys
28+
version: string;
29+
runCommand: string;
30+
packages: string[] | string; //include versions too
1631
}
1732

1833
/**
19-
* A tool to get the list of installed Python packages in the active environment.
34+
* A tool to get the information about the Python environment.
2035
*/
21-
export class GetPackagesTool implements LanguageModelTool<IGetActiveFile> {
22-
constructor(private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi) {}
36+
export class GetEnvironmentInfoTool implements LanguageModelTool<IResourceReference> {
37+
constructor(
38+
private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi,
39+
private readonly envManagers: EnvironmentManagers,
40+
) {}
2341
/**
24-
* Invokes the tool to get the list of installed packages.
42+
* Invokes the tool to get the information about the Python environment.
2543
* @param options - The invocation options containing the file path.
2644
* @param token - The cancellation token.
27-
* @returns The result containing the list of installed packages or an error message.
45+
* @returns The result containing the information about the Python environment or an error message.
2846
*/
2947
async invoke(
30-
options: LanguageModelToolInvocationOptions<IGetActiveFile>,
48+
options: LanguageModelToolInvocationOptions<IResourceReference>,
3149
token: CancellationToken,
3250
): Promise<LanguageModelToolResult> {
3351
const deferredReturn = createDeferred<LanguageModelToolResult>();
@@ -36,55 +54,192 @@ export class GetPackagesTool implements LanguageModelTool<IGetActiveFile> {
3654
deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult);
3755
});
3856

39-
const parameters: IGetActiveFile = options.input;
57+
const parameters: IResourceReference = options.input;
4058

41-
if (parameters.filePath === undefined || parameters.filePath === '') {
42-
throw new Error('Invalid input: filePath is required');
59+
if (parameters.resourcePath === undefined || parameters.resourcePath === '') {
60+
throw new Error('Invalid input: resourcePath is required');
4361
}
44-
const fileUri = Uri.file(parameters.filePath);
62+
const resourcePath: Uri = Uri.parse(parameters.resourcePath);
63+
64+
// environment info set to default values
65+
const envInfo: EnvironmentInfo = {
66+
type: 'no type found',
67+
version: 'no version found',
68+
packages: 'no packages found',
69+
runCommand: 'no run command found',
70+
};
4571

4672
try {
47-
const environment = await this.api.getEnvironment(fileUri);
73+
// environment
74+
const environment: PythonEnvironment | undefined = await this.api.getEnvironment(resourcePath);
4875
if (!environment) {
49-
// Check if the file is a notebook or a notebook cell to throw specific error messages.
50-
if (fileUri.fsPath.endsWith('.ipynb') || fileUri.fsPath.includes('.ipynb#')) {
51-
throw new Error('Unable to access Jupyter kernels for notebook cells');
52-
}
53-
throw new Error('No environment found');
76+
throw new Error('No environment found for the provided resource path: ' + resourcePath.fsPath);
5477
}
78+
79+
const execInfo: PythonEnvironmentExecutionInfo = environment.execInfo;
80+
const executable = execInfo?.activatedRun?.executable ?? execInfo?.run.executable ?? 'python';
81+
const args = execInfo?.activatedRun?.args ?? execInfo?.run.args ?? [];
82+
envInfo.runCommand = args.length > 0 ? `${executable} ${args.join(' ')}` : executable;
83+
envInfo.version = environment.version;
84+
85+
// get the environment type or manager if type is not available
86+
try {
87+
const managerId = environment.envId.managerId;
88+
const manager = this.envManagers.getEnvironmentManager(managerId);
89+
envInfo.type = manager?.name || 'cannot be determined';
90+
} catch {
91+
envInfo.type = environment.envId.managerId || 'cannot be determined';
92+
}
93+
94+
// TODO: remove refreshPackages here eventually once terminal isn't being used as a fallback
5595
await this.api.refreshPackages(environment);
5696
const installedPackages = await this.api.getPackages(environment);
57-
58-
let resultMessage: string;
5997
if (!installedPackages || installedPackages.length === 0) {
60-
resultMessage = 'No packages are installed in the current environment.';
98+
envInfo.packages = [];
6199
} else {
62-
const packageNames = installedPackages
63-
.map((pkg) => (pkg.version ? `${pkg.name} (${pkg.version})` : pkg.name))
64-
.join(', ');
65-
resultMessage = 'The packages installed in the current environment are as follows:\n' + packageNames;
100+
envInfo.packages = installedPackages.map((pkg) =>
101+
pkg.version ? `${pkg.name} (${pkg.version})` : pkg.name,
102+
);
66103
}
67104

68-
const textPart = new LanguageModelTextPart(resultMessage || '');
105+
// format and return
106+
const textPart = BuildEnvironmentInfoContent(envInfo);
69107
deferredReturn.resolve({ content: [textPart] });
70108
} catch (error) {
71-
const errorMessage: string = `An error occurred while fetching packages: ${error}`;
109+
const errorMessage: string = `An error occurred while fetching environment information: ${error}`;
110+
const partialContent = BuildEnvironmentInfoContent(envInfo);
111+
const combinedContent = new LanguageModelTextPart(`${errorMessage}\n\n${partialContent.value}`);
112+
deferredReturn.resolve({ content: [combinedContent] } as LanguageModelToolResult);
113+
}
114+
return deferredReturn.promise;
115+
}
116+
/**
117+
* Prepares the invocation of the tool.
118+
* @param _options - The preparation options.
119+
* @param _token - The cancellation token.
120+
* @returns The prepared tool invocation.
121+
*/
122+
async prepareInvocation?(
123+
_options: LanguageModelToolInvocationPrepareOptions<IResourceReference>,
124+
_token: CancellationToken,
125+
): Promise<PreparedToolInvocation> {
126+
const message = 'Preparing to fetch Python environment information...';
127+
return {
128+
invocationMessage: message,
129+
};
130+
}
131+
}
132+
133+
function BuildEnvironmentInfoContent(envInfo: EnvironmentInfo): LanguageModelTextPart {
134+
// Create a formatted string that looks like JSON but preserves comments
135+
let envTypeDescriptor: string = `This environment is managed by ${envInfo.type} environment manager. Use the install tool to install packages into this environment.`;
136+
137+
if (envInfo.type === 'system') {
138+
envTypeDescriptor =
139+
'System pythons are pythons that ship with the OS or are installed globally. These python installs may be used by the OS for running services and core functionality. Confirm with the user before installing packages into this environment, as it can lead to issues with any services on the OS.';
140+
}
141+
const content = `{
142+
// ${JSON.stringify(envTypeDescriptor)}
143+
"environmentType": ${JSON.stringify(envInfo.type)},
144+
// Python version of the environment
145+
"pythonVersion": ${JSON.stringify(envInfo.version)},
146+
// Use this command to run Python script or code in the terminal.
147+
"runCommand": ${JSON.stringify(envInfo.runCommand)},
148+
// Installed Python packages, each in the format <name> or <name> (<version>). The version may be omitted if unknown. Returns an empty array if no packages are installed.
149+
"packages": ${JSON.stringify(Array.isArray(envInfo.packages) ? envInfo.packages : envInfo.packages, null, 2)}
150+
}`;
151+
152+
return new LanguageModelTextPart(content);
153+
}
154+
155+
/**
156+
* The input interface for the Install Package Tool.
157+
*/
158+
export interface IInstallPackageInput {
159+
packageList: string[];
160+
workspacePath?: string;
161+
}
162+
163+
/**
164+
* A tool to install Python packages in the active environment.
165+
*/
166+
export class InstallPackageTool implements LanguageModelTool<IInstallPackageInput> {
167+
constructor(
168+
private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonPackageManagementApi,
169+
) {}
170+
171+
/**
172+
* Invokes the tool to install Python packages in the active environment.
173+
* @param options - The invocation options containing the package list.
174+
* @param token - The cancellation token.
175+
* @returns The result containing the installation status or an error message.
176+
*/
177+
async invoke(
178+
options: LanguageModelToolInvocationOptions<IInstallPackageInput>,
179+
token: CancellationToken,
180+
): Promise<LanguageModelToolResult> {
181+
const deferredReturn = createDeferred<LanguageModelToolResult>();
182+
token.onCancellationRequested(() => {
183+
const errorMessage: string = `Operation cancelled by the user.`;
184+
deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult);
185+
});
186+
187+
const parameters: IInstallPackageInput = options.input;
188+
const workspacePath = parameters.workspacePath ? Uri.file(parameters.workspacePath) : undefined;
189+
if (!workspacePath) {
190+
throw new Error('Invalid input: workspacePath is required');
191+
}
192+
193+
if (!parameters.packageList || parameters.packageList.length === 0) {
194+
throw new Error('Invalid input: packageList is required and cannot be empty');
195+
}
196+
const packageCount = parameters.packageList.length;
197+
const packagePlurality = packageCount === 1 ? 'package' : 'packages';
198+
199+
try {
200+
const environment = await this.api.getEnvironment(workspacePath);
201+
if (!environment) {
202+
// Check if the file is a notebook or a notebook cell to throw specific error messages.
203+
if (workspacePath.fsPath.endsWith('.ipynb') || workspacePath.fsPath.includes('.ipynb#')) {
204+
throw new Error('Unable to access Jupyter kernels for notebook cells');
205+
}
206+
throw new Error('No environment found');
207+
}
208+
209+
// Install the packages
210+
const pkgManagementOptions: PackageManagementOptions = {
211+
install: parameters.packageList,
212+
};
213+
await this.api.managePackages(environment, pkgManagementOptions);
214+
const resultMessage = `Successfully installed ${packagePlurality}: ${parameters.packageList.join(', ')}`;
215+
216+
deferredReturn.resolve({
217+
content: [new LanguageModelTextPart(resultMessage)],
218+
});
219+
} catch (error) {
220+
const errorMessage = `An error occurred while installing ${packagePlurality}: ${error}`;
221+
72222
deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult);
73223
}
224+
74225
return deferredReturn.promise;
75226
}
76227

77228
/**
78229
* Prepares the invocation of the tool.
79-
* @param _options - The preparation options.
230+
* @param options - The preparation options.
80231
* @param _token - The cancellation token.
81232
* @returns The prepared tool invocation.
82233
*/
83234
async prepareInvocation?(
84-
_options: LanguageModelToolInvocationPrepareOptions<IGetActiveFile>,
235+
options: LanguageModelToolInvocationPrepareOptions<IInstallPackageInput>,
85236
_token: CancellationToken,
86237
): Promise<PreparedToolInvocation> {
87-
const message = 'Preparing to fetch the list of installed Python packages...';
238+
const packageList = options.input.packageList || [];
239+
const packageCount = packageList.length;
240+
const packageText = packageCount === 1 ? 'package' : 'packages';
241+
const message = `Preparing to install Python ${packageText}: ${packageList.join(', ')}...`;
242+
88243
return {
89244
invocationMessage: message,
90245
};

0 commit comments

Comments
 (0)