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
12 changes: 10 additions & 2 deletions 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 Expand Up @@ -1642,7 +1646,7 @@
{
"name": "selectEnvironment",
"displayName": "Select a Python Environment",
"modelDescription": "This tool will prompt the user to select an existing Python Environment",
"modelDescription": "This tool will prompt the user to select an existing Python Environment. If pythonPath is provided, it sets that interpreter directly without showing any UI.",
"tags": [],
"canBeReferencedInPrompt": false,
"inputSchema": {
Expand All @@ -1651,6 +1655,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 interpreter is set directly without showing any UI picker."
}
},
"required": []
Expand Down
49 changes: 45 additions & 4 deletions src/client/chat/configurePythonEnvTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,27 @@ 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';

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 +65,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 +75,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 +124,32 @@ 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,
this.terminalExecutionService,
this.terminalHelper,
resource,
token,
);
if (result) {
return result;
}
throw new Error(`No environment found for the provided pythonPath '${pythonPath}'.`);
}

async prepareInvocationImpl(
_options: LanguageModelToolInvocationPrepareOptions<IResourceReference>,
_options: LanguageModelToolInvocationPrepareOptions<IConfigurePythonEnvToolArguments>,
_resource: Uri | undefined,
_token: CancellationToken,
): Promise<PreparedToolInvocation> {
Expand Down
22 changes: 14 additions & 8 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,18 +92,24 @@ 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(
createVirtualEnvironment({
interpreter: preferredGlobalPythonEnv.id,
workspaceFolder,
installPackages: false,
}),
token,
);
Expand All @@ -120,7 +126,7 @@ export class CreateVirtualEnvTool extends BaseTool<ICreateVirtualEnvToolParams>

const stopWatch = new StopWatch();
let env: ResolvedEnvironment | undefined;
while (stopWatch.elapsedTime < 5_000 || !env) {
while (stopWatch.elapsedTime < 5_000 && !env) {
env = await this.api.resolveEnvironment(createdEnvPath);
if (env) {
break;
Expand Down
63 changes: 56 additions & 7 deletions src/client/chat/selectEnvTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
getEnvDetailsForResponse,
getToolResponseIfNotebook,
IResourceReference,
raceCancellationError,
setEnvironmentDirectlyByPath,
} from './utils';
import { ITerminalHelper } from '../common/terminal/types';
import { raceTimeout } from '../common/utils/async';
Expand All @@ -40,6 +42,13 @@ import { BaseTool } from './baseTool';

export interface ISelectPythonEnvToolArguments extends IResourceReference {
reason?: 'cancelled';
/**
* Optional path to a Python interpreter. When provided, the tool sets this
* interpreter directly without showing any Quick Pick UI to the user.
* This prevents the agent from getting stuck waiting for user input in
* autopilot / bypass-approvals mode.
*/
pythonPath?: string;
}

export class SelectPythonEnvTool extends BaseTool<ISelectPythonEnvToolArguments>
Expand All @@ -64,23 +73,57 @@ export class SelectPythonEnvTool extends BaseTool<ISelectPythonEnvToolArguments>
resource: Uri | undefined,
token: CancellationToken,
): Promise<LanguageModelToolResult> {
const notebookResponse = getToolResponseIfNotebook(resource);
if (notebookResponse) {
return notebookResponse;
}

// Fast path: if the caller provided a pythonPath, set it directly without any UI.
if (options.input.pythonPath) {
traceVerbose(
`${SelectPythonEnvTool.toolName}: setting environment directly from pythonPath: ${options.input.pythonPath}`,
);
const result = await setEnvironmentDirectlyByPath(
options.input.pythonPath,
this.api,
this.terminalExecutionService,
this.terminalHelper,
resource,
token,
);
if (result) {
return result;
}
return new LanguageModelToolResult([
new LanguageModelTextPart(
`The provided pythonPath '${options.input.pythonPath}' could not be resolved to a valid Python environment.`,
),
]);
}

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,
);
if (selected) {
traceVerbose(`User selected a Python environment ${selected} in Select Python Tool(2).`);
} else {
Expand Down Expand Up @@ -120,6 +163,12 @@ export class SelectPythonEnvTool extends BaseTool<ISelectPythonEnvToolArguments>
if (getToolResponseIfNotebook(resource)) {
return {};
}
// Fast path: skip the confirmation prompt when the model has already supplied
// a specific interpreter to use. Showing a confirmation here would defeat the
// purpose of the autopilot/bypass-approvals fast path.
if (options.input.pythonPath) {
return {};
}
const hasVenvOrCondaEnvInWorkspaceFolder = doesWorkspaceHaveVenvOrCondaEnv(resource, this.api);

if (
Expand Down
80 changes: 80 additions & 0 deletions src/client/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,86 @@ export function raceCancellationError<T>(promise: Promise<T>, token: Cancellatio
});
}

/**
* 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,
token: CancellationToken,
timeoutMs = 5000,
): Promise<void> {
return new Promise<void>((resolve) => {
let settled = false;
const listener = api.onDidChangeActiveEnvironmentPath((e) => {
if (e.path === pythonPath || e.id === pythonPath) {
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();
}
});
}

/**
* 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 a tool result describing 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'],
terminalExecutionService: TerminalCodeExecutionProvider,
terminalHelper: ITerminalHelper,
resource: Uri | undefined,
token: CancellationToken,
): Promise<LanguageModelToolResult | undefined> {
// 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;
}

// 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, 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 (envPath.path !== pythonPath && envPath.id !== pythonPath) {
return undefined;
}
const environment = await raceCancellationError(api.resolveEnvironment(envPath), token);
if (!environment) {
return undefined;
}
return getEnvDetailsForResponse(environment, api, terminalExecutionService, terminalHelper, resource, token);
}

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