Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1593,7 +1593,7 @@
{
"name": "configure_python_environment",
"displayName": "Configure Python Environment",
"modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.",
"modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal. If you already know which Python interpreter to use (e.g. from a previous tool call or user message), pass it as 'pythonPath' to skip interactive prompts and configure the environment automatically. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.",
"userDescription": "%python.languageModelTools.configure_python_environment.userDescription%",
"toolReferenceName": "configurePythonEnvironment",
"tags": [
Expand All @@ -1609,6 +1609,10 @@
"resourcePath": {
"type": "string",
"description": "The path to the Python file or workspace for which a Python Environment needs to be configured."
},
"pythonPath": {
"type": "string",
"description": "Optional absolute path to a Python interpreter to use. When provided, the environment is configured automatically without any interactive prompts. Use this to avoid blocking the session on user input."
}
},
"required": []
Expand Down
55 changes: 51 additions & 4 deletions src/client/chat/configurePythonEnvTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,28 @@ import {
IResourceReference,
isCancellationError,
raceCancellationError,
setEnvironmentDirectlyByPath,
} from './utils';
import { ITerminalHelper } from '../common/terminal/types';
import { IRecommendedEnvironmentService } from '../interpreter/configuration/types';
import { CreateVirtualEnvTool } from './createVirtualEnvTool';
import { ISelectPythonEnvToolArguments, SelectPythonEnvTool } from './selectEnvTool';
import { BaseTool } from './baseTool';
import { traceVerbose } from '../logging';
import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils';

export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
implements LanguageModelTool<IResourceReference> {
export interface IConfigurePythonEnvToolArguments extends IResourceReference {
/**
* Optional path to a Python interpreter. When provided, the tool sets this
* interpreter directly without any user interaction (no Quick Pick, no
* create-venv prompt). This is the recommended way for Copilot to call
* the tool in autopilot / bypass-approvals mode.
*/
pythonPath?: string;
}

export class ConfigurePythonEnvTool extends BaseTool<IConfigurePythonEnvToolArguments>
implements LanguageModelTool<IConfigurePythonEnvToolArguments> {
private readonly terminalExecutionService: TerminalCodeExecutionProvider;
private readonly terminalHelper: ITerminalHelper;
private readonly recommendedEnvService: IRecommendedEnvironmentService;
Expand All @@ -53,7 +66,7 @@ export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
}

async invokeImpl(
options: LanguageModelToolInvocationOptions<IResourceReference>,
options: LanguageModelToolInvocationOptions<IConfigurePythonEnvToolArguments>,
resource: Uri | undefined,
token: CancellationToken,
): Promise<LanguageModelToolResult> {
Expand All @@ -63,6 +76,11 @@ export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
return notebookResponse;
}

// Fast path: if the caller provided a pythonPath, set it directly without any UI.
if (options.input.pythonPath) {
return this.setEnvironmentDirectly(options.input.pythonPath, resource, token);
}

const workspaceSpecificEnv = await raceCancellationError(
this.hasAlreadyGotAWorkspaceSpecificEnvironment(resource),
token,
Expand Down Expand Up @@ -107,8 +125,37 @@ export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
}
}

/**
* Sets the given interpreter path directly without user interaction, then
* resolves and returns the environment details.
*/
private async setEnvironmentDirectly(
pythonPath: string,
resource: Uri | undefined,
token: CancellationToken,
): Promise<LanguageModelToolResult> {
traceVerbose(`${ConfigurePythonEnvTool.toolName}: setting environment directly from pythonPath: ${pythonPath}`);
const result = await setEnvironmentDirectlyByPath(pythonPath, this.api, resource, token);
if (result) {
this.extraTelemetryProperties.resolveOutcome = 'providedEnv';
this.extraTelemetryProperties.envType = getEnvTypeForTelemetry(result);
return getEnvDetailsForResponse(
result,
this.api,
this.terminalExecutionService,
this.terminalHelper,
resource,
token,
);
}
throw new ErrorWithTelemetrySafeReason(
`No environment found for the provided pythonPath '${pythonPath}'.`,
'noEnvFound',
);
}

async prepareInvocationImpl(
_options: LanguageModelToolInvocationPrepareOptions<IResourceReference>,
_options: LanguageModelToolInvocationPrepareOptions<IConfigurePythonEnvToolArguments>,
_resource: Uri | undefined,
_token: CancellationToken,
): Promise<PreparedToolInvocation> {
Expand Down
27 changes: 16 additions & 11 deletions src/client/chat/createVirtualEnvTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { hideEnvCreation } from '../pythonEnvironments/creation/provider/hideEnv
import { BaseTool } from './baseTool';

interface ICreateVirtualEnvToolParams extends IResourceReference {
packageList?: string[]; // Added only becausewe have ability to create a virtual env with list of packages same tool within the in Python Env extension.
packageList?: string[]; // Added only because we have the ability to create a virtual env with a list of packages using the same tool within the Python Env extension.
}

export class CreateVirtualEnvTool extends BaseTool<ICreateVirtualEnvToolParams>
Expand Down Expand Up @@ -92,12 +92,17 @@ export class CreateVirtualEnvTool extends BaseTool<ICreateVirtualEnvToolParams>

let createdEnvPath: string | undefined = undefined;
if (useEnvExtension()) {
const result: PythonEnvironment | undefined = await commands.executeCommand('python-envs.createAny', {
quickCreate: true,
additionalPackages: options.input.packageList || [],
uri: workspaceFolder.uri,
selectEnvironment: true,
});
const result: PythonEnvironment | undefined = await raceCancellationError(
Promise.resolve(
commands.executeCommand<PythonEnvironment | undefined>('python-envs.createAny', {
quickCreate: true,
additionalPackages: options.input.packageList || [],
uri: workspaceFolder.uri,
selectEnvironment: true,
}),
),
token,
);
createdEnvPath = result?.environmentPath.fsPath;
} else {
const created = await raceCancellationError(
Expand All @@ -116,19 +121,19 @@ export class CreateVirtualEnvTool extends BaseTool<ICreateVirtualEnvToolParams>

// Wait a few secs to ensure the env is selected as the active environment..
// If this doesn't work, then something went wrong.
await raceTimeout(5_000, interpreterChanged);
await raceCancellationError(raceTimeout(5_000, interpreterChanged), token);

const stopWatch = new StopWatch();
let env: ResolvedEnvironment | undefined;
while (stopWatch.elapsedTime < 5_000 || !env) {
env = await this.api.resolveEnvironment(createdEnvPath);
while (stopWatch.elapsedTime < 5_000 && !env) {
env = await raceCancellationError(this.api.resolveEnvironment(createdEnvPath), token);
if (env) {
break;
} else {
traceVerbose(
`${CreateVirtualEnvTool.toolName} tool invoked, env created but not yet resolved, waiting...`,
);
await sleep(200);
await raceCancellationError(sleep(200), token);
}
}
if (!env) {
Expand Down
40 changes: 26 additions & 14 deletions src/client/chat/selectEnvTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getEnvDetailsForResponse,
getToolResponseIfNotebook,
IResourceReference,
raceCancellationError,
} from './utils';
import { ITerminalHelper } from '../common/terminal/types';
import { raceTimeout } from '../common/utils/async';
Expand Down Expand Up @@ -67,20 +68,26 @@ export class SelectPythonEnvTool extends BaseTool<ISelectPythonEnvToolArguments>
let selected: boolean | undefined = false;
const hasVenvOrCondaEnvInWorkspaceFolder = doesWorkspaceHaveVenvOrCondaEnv(resource, this.api);
if (options.input.reason === 'cancelled' || hasVenvOrCondaEnvInWorkspaceFolder) {
const result = (await Promise.resolve(
commands.executeCommand(Commands.Set_Interpreter, {
hideCreateVenv: false,
showBackButton: false,
}),
)) as SelectEnvironmentResult | undefined;
const result = await raceCancellationError(
Promise.resolve(
commands.executeCommand(Commands.Set_Interpreter, {
hideCreateVenv: false,
showBackButton: false,
}),
) as Promise<SelectEnvironmentResult | undefined>,
token,
);
if (result?.path) {
traceVerbose(`User selected a Python environment ${result.path} in Select Python Tool.`);
selected = true;
} else {
traceWarn(`User did not select a Python environment in Select Python Tool.`);
}
} else {
selected = await showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer);
selected = await raceCancellationError(
showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer, token),
token,
);
if (selected) {
traceVerbose(`User selected a Python environment ${selected} in Select Python Tool(2).`);
} else {
Expand Down Expand Up @@ -152,6 +159,7 @@ export class SelectPythonEnvTool extends BaseTool<ISelectPythonEnvToolArguments>
async function showCreateAndSelectEnvironmentQuickPick(
uri: Uri | undefined,
serviceContainer: IServiceContainer,
token: CancellationToken,
): Promise<boolean | undefined> {
const createLabel = `${Octicons.Add} ${InterpreterQuickPickList.create.label}`;
const selectLabel = l10n.t('Select an existing Python Environment');
Expand All @@ -161,11 +169,15 @@ async function showCreateAndSelectEnvironmentQuickPick(
{ label: selectLabel },
];

const selectedItem = await showQuickPick(items, {
placeHolder: l10n.t('Configure a Python Environment'),
matchOnDescription: true,
ignoreFocusOut: true,
});
const selectedItem = await showQuickPick(
items,
{
placeHolder: l10n.t('Configure a Python Environment'),
matchOnDescription: true,
ignoreFocusOut: true,
},
token,
);

if (selectedItem && !Array.isArray(selectedItem) && selectedItem.label === createLabel) {
const disposables = new DisposableStore();
Expand All @@ -187,7 +199,7 @@ async function showCreateAndSelectEnvironmentQuickPick(
);

if (created?.action === 'Back') {
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer);
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer, token);
}
if (created?.action === 'Cancel') {
return undefined;
Expand All @@ -206,7 +218,7 @@ async function showCreateAndSelectEnvironmentQuickPick(
commands.executeCommand(Commands.Set_Interpreter, { hideCreateVenv: true, showBackButton: true }),
)) as SelectEnvironmentResult | undefined;
if (result?.action === 'Back') {
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer);
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer, token);
}
if (result?.action === 'Cancel') {
return undefined;
Expand Down
113 changes: 112 additions & 1 deletion src/client/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { dirname, join } from 'path';
import { resolveEnvironment, useEnvExtension } from '../envExt/api.internal';
import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils';
import { getWorkspaceFolders } from '../common/vscodeApis/workspaceApis';
import { arePathsSame } from '../common/platform/fs-paths';

export interface IResourceReference {
resourcePath?: string;
Expand Down Expand Up @@ -49,15 +50,125 @@ export function resolveFilePath(filepath?: string): Uri | undefined {
* @see {@link raceCancellation}
*/
export function raceCancellationError<T>(promise: Promise<T>, token: CancellationToken): Promise<T> {
if (token.isCancellationRequested) {
return Promise.reject(new CancellationError());
}
return new Promise((resolve, reject) => {
const ref = token.onCancellationRequested(() => {
ref.dispose();
reject(new CancellationError());
});
promise.then(resolve, reject).finally(() => ref.dispose());
promise.then(
(value) => {
ref.dispose();
resolve(value);
},
(error) => {
ref.dispose();
reject(error);
},
);
});
}

/**
* Returns a promise that resolves once the active environment path changes to match the
* provided `pythonPath` (matched against either the event's `path` or `id`). Resolves early
* on cancellation or after `timeoutMs` to avoid hanging callers if the event is missed.
* Callers must subscribe via this helper BEFORE invoking `updateActiveEnvironmentPath` to
* avoid a race where the event fires before the listener is attached.
*/
export function waitForActiveEnvironmentChange(
api: PythonExtension['environments'],
pythonPath: string,
resource: Uri | undefined,
token: CancellationToken,
timeoutMs = 5000,
): Promise<void> {
if (token.isCancellationRequested) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let settled = false;
const listener = api.onDidChangeActiveEnvironmentPath((e) => {
if (isEnvironmentPathMatch(e, pythonPath) && isResourceMatch(e.resource, resource)) {
settle();
}
});
const cancelRef = token.onCancellationRequested(() => settle());
const timer = setTimeout(() => settle(), timeoutMs);
function settle() {
if (settled) {
return;
}
settled = true;
listener.dispose();
cancelRef.dispose();
clearTimeout(timer);
resolve();
}
});
}

function isResourceMatch(eventResource: { uri: Uri } | Uri | undefined, requestedResource: Uri | undefined): boolean {
const eventUri = eventResource && 'uri' in eventResource ? eventResource.uri : eventResource;
const requestedUri = requestedResource
? workspace.getWorkspaceFolder(requestedResource)?.uri ?? requestedResource
: undefined;
return eventUri === undefined
? requestedUri === undefined
: requestedUri !== undefined && arePathsSame(eventUri.fsPath, requestedUri.fsPath);
}

function isEnvironmentPathMatch(environment: { path: string; id: string }, pythonPath: string): boolean {
return arePathsSame(environment.path, pythonPath) || environment.id === pythonPath;
}

/**
* Sets the active Python interpreter to `pythonPath` without any UI, waits for the
* asynchronous environment switch to settle (via `onDidChangeActiveEnvironmentPath`),
* resolves the environment, and returns it.
*
* Returns `undefined` if the path cannot be resolved to a valid environment so callers
* can produce a tool-specific error message.
*/
export async function setEnvironmentDirectlyByPath(
pythonPath: string,
api: PythonExtension['environments'],
resource: Uri | undefined,
token: CancellationToken,
): Promise<ResolvedEnvironment | undefined> {
if (token.isCancellationRequested) {
throw new CancellationError();
}
// Validate the path resolves to a real environment BEFORE mutating user settings.
// updateActiveEnvironmentPath persists unconditionally, so an invalid path would
// permanently overwrite the user's selected interpreter.
const candidate = await raceCancellationError(api.resolveEnvironment(pythonPath), token);
if (!candidate) {
return undefined;
}
if (isEnvironmentPathMatch(api.getActiveEnvironmentPath(resource), pythonPath)) {
return candidate;
}

// Subscribe to the change event BEFORE triggering the update so we don't miss it.
// updateActiveEnvironmentPath only persists the setting; the active interpreter switch
// is asynchronous, so we wait for the event before resolving env details to avoid
// returning details for the previously-active interpreter.
const activeChanged = waitForActiveEnvironmentChange(api, pythonPath, resource, token);
await raceCancellationError(api.updateActiveEnvironmentPath(pythonPath, resource), token);
await raceCancellationError(activeChanged, token);

// Verify the active env actually switched. If the change event timed out and the
// active path is still the previous one, don't report success for the wrong env.
const envPath = api.getActiveEnvironmentPath(resource);
if (!isEnvironmentPathMatch(envPath, pythonPath)) {
return undefined;
}
return raceCancellationError(api.resolveEnvironment(envPath), token);
}

export async function getEnvDisplayName(
discovery: IDiscoveryAPI,
resource: Uri | undefined,
Expand Down
Loading
Loading