Skip to content

Commit 82b3cd5

Browse files
eleanorjboydCopilotCopilot
authored
Support non-interactive Python environment configuration (microsoft#25939)
Fixes microsoft#25940 ## Summary - Adds an optional `pythonPath` input to `configure_python_environment`, allowing agents to select a known interpreter without opening blocking UI. - Validates the interpreter before changing settings, waits for the correctly scoped active-environment event, handles an already-active interpreter without delay, and verifies the switch before reporting success. - Propagates cancellation through environment selection, virtual-environment creation waits, and environment-resolution polling. - Fixes the virtual-environment resolution loop so it stops after its timeout. - Adds focused unit coverage for direct selection, invalid paths, cancellation, already-active environments, event ordering, and multi-root isolation. ## Validation - Prettier - TypeScript type-check - ESLint - 12 focused unit tests --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 61c0139 commit 82b3cd5

7 files changed

Lines changed: 533 additions & 31 deletions

File tree

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1593,7 +1593,7 @@
15931593
{
15941594
"name": "configure_python_environment",
15951595
"displayName": "Configure Python Environment",
1596-
"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.",
1596+
"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.",
15971597
"userDescription": "%python.languageModelTools.configure_python_environment.userDescription%",
15981598
"toolReferenceName": "configurePythonEnvironment",
15991599
"tags": [
@@ -1609,6 +1609,10 @@
16091609
"resourcePath": {
16101610
"type": "string",
16111611
"description": "The path to the Python file or workspace for which a Python Environment needs to be configured."
1612+
},
1613+
"pythonPath": {
1614+
"type": "string",
1615+
"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."
16121616
}
16131617
},
16141618
"required": []

src/client/chat/configurePythonEnvTool.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,28 @@ import {
2323
IResourceReference,
2424
isCancellationError,
2525
raceCancellationError,
26+
setEnvironmentDirectlyByPath,
2627
} from './utils';
2728
import { ITerminalHelper } from '../common/terminal/types';
2829
import { IRecommendedEnvironmentService } from '../interpreter/configuration/types';
2930
import { CreateVirtualEnvTool } from './createVirtualEnvTool';
3031
import { ISelectPythonEnvToolArguments, SelectPythonEnvTool } from './selectEnvTool';
3132
import { BaseTool } from './baseTool';
33+
import { traceVerbose } from '../logging';
34+
import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils';
3235

33-
export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
34-
implements LanguageModelTool<IResourceReference> {
36+
export interface IConfigurePythonEnvToolArguments extends IResourceReference {
37+
/**
38+
* Optional path to a Python interpreter. When provided, the tool sets this
39+
* interpreter directly without any user interaction (no Quick Pick, no
40+
* create-venv prompt). This is the recommended way for Copilot to call
41+
* the tool in autopilot / bypass-approvals mode.
42+
*/
43+
pythonPath?: string;
44+
}
45+
46+
export class ConfigurePythonEnvTool extends BaseTool<IConfigurePythonEnvToolArguments>
47+
implements LanguageModelTool<IConfigurePythonEnvToolArguments> {
3548
private readonly terminalExecutionService: TerminalCodeExecutionProvider;
3649
private readonly terminalHelper: ITerminalHelper;
3750
private readonly recommendedEnvService: IRecommendedEnvironmentService;
@@ -53,7 +66,7 @@ export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
5366
}
5467

5568
async invokeImpl(
56-
options: LanguageModelToolInvocationOptions<IResourceReference>,
69+
options: LanguageModelToolInvocationOptions<IConfigurePythonEnvToolArguments>,
5770
resource: Uri | undefined,
5871
token: CancellationToken,
5972
): Promise<LanguageModelToolResult> {
@@ -63,6 +76,11 @@ export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
6376
return notebookResponse;
6477
}
6578

79+
// Fast path: if the caller provided a pythonPath, set it directly without any UI.
80+
if (options.input.pythonPath) {
81+
return this.setEnvironmentDirectly(options.input.pythonPath, resource, token);
82+
}
83+
6684
const workspaceSpecificEnv = await raceCancellationError(
6785
this.hasAlreadyGotAWorkspaceSpecificEnvironment(resource),
6886
token,
@@ -107,8 +125,37 @@ export class ConfigurePythonEnvTool extends BaseTool<IResourceReference>
107125
}
108126
}
109127

128+
/**
129+
* Sets the given interpreter path directly without user interaction, then
130+
* resolves and returns the environment details.
131+
*/
132+
private async setEnvironmentDirectly(
133+
pythonPath: string,
134+
resource: Uri | undefined,
135+
token: CancellationToken,
136+
): Promise<LanguageModelToolResult> {
137+
traceVerbose(`${ConfigurePythonEnvTool.toolName}: setting environment directly from pythonPath: ${pythonPath}`);
138+
const result = await setEnvironmentDirectlyByPath(pythonPath, this.api, resource, token);
139+
if (result) {
140+
this.extraTelemetryProperties.resolveOutcome = 'providedEnv';
141+
this.extraTelemetryProperties.envType = getEnvTypeForTelemetry(result);
142+
return getEnvDetailsForResponse(
143+
result,
144+
this.api,
145+
this.terminalExecutionService,
146+
this.terminalHelper,
147+
resource,
148+
token,
149+
);
150+
}
151+
throw new ErrorWithTelemetrySafeReason(
152+
`No environment found for the provided pythonPath '${pythonPath}'.`,
153+
'noEnvFound',
154+
);
155+
}
156+
110157
async prepareInvocationImpl(
111-
_options: LanguageModelToolInvocationPrepareOptions<IResourceReference>,
158+
_options: LanguageModelToolInvocationPrepareOptions<IConfigurePythonEnvToolArguments>,
112159
_resource: Uri | undefined,
113160
_token: CancellationToken,
114161
): Promise<PreparedToolInvocation> {

src/client/chat/createVirtualEnvTool.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import { hideEnvCreation } from '../pythonEnvironments/creation/provider/hideEnv
4545
import { BaseTool } from './baseTool';
4646

4747
interface ICreateVirtualEnvToolParams extends IResourceReference {
48-
packageList?: string[]; // Added only becausewe have ability to create a virtual env with list of packages same tool within the in Python Env extension.
48+
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.
4949
}
5050

5151
export class CreateVirtualEnvTool extends BaseTool<ICreateVirtualEnvToolParams>
@@ -92,12 +92,17 @@ export class CreateVirtualEnvTool extends BaseTool<ICreateVirtualEnvToolParams>
9292

9393
let createdEnvPath: string | undefined = undefined;
9494
if (useEnvExtension()) {
95-
const result: PythonEnvironment | undefined = await commands.executeCommand('python-envs.createAny', {
96-
quickCreate: true,
97-
additionalPackages: options.input.packageList || [],
98-
uri: workspaceFolder.uri,
99-
selectEnvironment: true,
100-
});
95+
const result: PythonEnvironment | undefined = await raceCancellationError(
96+
Promise.resolve(
97+
commands.executeCommand<PythonEnvironment | undefined>('python-envs.createAny', {
98+
quickCreate: true,
99+
additionalPackages: options.input.packageList || [],
100+
uri: workspaceFolder.uri,
101+
selectEnvironment: true,
102+
}),
103+
),
104+
token,
105+
);
101106
createdEnvPath = result?.environmentPath.fsPath;
102107
} else {
103108
const created = await raceCancellationError(
@@ -116,19 +121,19 @@ export class CreateVirtualEnvTool extends BaseTool<ICreateVirtualEnvToolParams>
116121

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

121126
const stopWatch = new StopWatch();
122127
let env: ResolvedEnvironment | undefined;
123-
while (stopWatch.elapsedTime < 5_000 || !env) {
124-
env = await this.api.resolveEnvironment(createdEnvPath);
128+
while (stopWatch.elapsedTime < 5_000 && !env) {
129+
env = await raceCancellationError(this.api.resolveEnvironment(createdEnvPath), token);
125130
if (env) {
126131
break;
127132
} else {
128133
traceVerbose(
129134
`${CreateVirtualEnvTool.toolName} tool invoked, env created but not yet resolved, waiting...`,
130135
);
131-
await sleep(200);
136+
await raceCancellationError(sleep(200), token);
132137
}
133138
}
134139
if (!env) {

src/client/chat/selectEnvTool.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getEnvDetailsForResponse,
2626
getToolResponseIfNotebook,
2727
IResourceReference,
28+
raceCancellationError,
2829
} from './utils';
2930
import { ITerminalHelper } from '../common/terminal/types';
3031
import { raceTimeout } from '../common/utils/async';
@@ -67,20 +68,26 @@ export class SelectPythonEnvTool extends BaseTool<ISelectPythonEnvToolArguments>
6768
let selected: boolean | undefined = false;
6869
const hasVenvOrCondaEnvInWorkspaceFolder = doesWorkspaceHaveVenvOrCondaEnv(resource, this.api);
6970
if (options.input.reason === 'cancelled' || hasVenvOrCondaEnvInWorkspaceFolder) {
70-
const result = (await Promise.resolve(
71-
commands.executeCommand(Commands.Set_Interpreter, {
72-
hideCreateVenv: false,
73-
showBackButton: false,
74-
}),
75-
)) as SelectEnvironmentResult | undefined;
71+
const result = await raceCancellationError(
72+
Promise.resolve(
73+
commands.executeCommand(Commands.Set_Interpreter, {
74+
hideCreateVenv: false,
75+
showBackButton: false,
76+
}),
77+
) as Promise<SelectEnvironmentResult | undefined>,
78+
token,
79+
);
7680
if (result?.path) {
7781
traceVerbose(`User selected a Python environment ${result.path} in Select Python Tool.`);
7882
selected = true;
7983
} else {
8084
traceWarn(`User did not select a Python environment in Select Python Tool.`);
8185
}
8286
} else {
83-
selected = await showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer);
87+
selected = await raceCancellationError(
88+
showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer, token),
89+
token,
90+
);
8491
if (selected) {
8592
traceVerbose(`User selected a Python environment ${selected} in Select Python Tool(2).`);
8693
} else {
@@ -152,6 +159,7 @@ export class SelectPythonEnvTool extends BaseTool<ISelectPythonEnvToolArguments>
152159
async function showCreateAndSelectEnvironmentQuickPick(
153160
uri: Uri | undefined,
154161
serviceContainer: IServiceContainer,
162+
token: CancellationToken,
155163
): Promise<boolean | undefined> {
156164
const createLabel = `${Octicons.Add} ${InterpreterQuickPickList.create.label}`;
157165
const selectLabel = l10n.t('Select an existing Python Environment');
@@ -161,11 +169,15 @@ async function showCreateAndSelectEnvironmentQuickPick(
161169
{ label: selectLabel },
162170
];
163171

164-
const selectedItem = await showQuickPick(items, {
165-
placeHolder: l10n.t('Configure a Python Environment'),
166-
matchOnDescription: true,
167-
ignoreFocusOut: true,
168-
});
172+
const selectedItem = await showQuickPick(
173+
items,
174+
{
175+
placeHolder: l10n.t('Configure a Python Environment'),
176+
matchOnDescription: true,
177+
ignoreFocusOut: true,
178+
},
179+
token,
180+
);
169181

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

189201
if (created?.action === 'Back') {
190-
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer);
202+
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer, token);
191203
}
192204
if (created?.action === 'Cancel') {
193205
return undefined;
@@ -206,7 +218,7 @@ async function showCreateAndSelectEnvironmentQuickPick(
206218
commands.executeCommand(Commands.Set_Interpreter, { hideCreateVenv: true, showBackButton: true }),
207219
)) as SelectEnvironmentResult | undefined;
208220
if (result?.action === 'Back') {
209-
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer);
221+
return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer, token);
210222
}
211223
if (result?.action === 'Cancel') {
212224
return undefined;

src/client/chat/utils.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { dirname, join } from 'path';
2020
import { resolveEnvironment, useEnvExtension } from '../envExt/api.internal';
2121
import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils';
2222
import { getWorkspaceFolders } from '../common/vscodeApis/workspaceApis';
23+
import { arePathsSame } from '../common/platform/fs-paths';
2324

2425
export interface IResourceReference {
2526
resourcePath?: string;
@@ -49,15 +50,125 @@ export function resolveFilePath(filepath?: string): Uri | undefined {
4950
* @see {@link raceCancellation}
5051
*/
5152
export function raceCancellationError<T>(promise: Promise<T>, token: CancellationToken): Promise<T> {
53+
if (token.isCancellationRequested) {
54+
return Promise.reject(new CancellationError());
55+
}
5256
return new Promise((resolve, reject) => {
5357
const ref = token.onCancellationRequested(() => {
5458
ref.dispose();
5559
reject(new CancellationError());
5660
});
57-
promise.then(resolve, reject).finally(() => ref.dispose());
61+
promise.then(
62+
(value) => {
63+
ref.dispose();
64+
resolve(value);
65+
},
66+
(error) => {
67+
ref.dispose();
68+
reject(error);
69+
},
70+
);
5871
});
5972
}
6073

74+
/**
75+
* Returns a promise that resolves once the active environment path changes to match the
76+
* provided `pythonPath` (matched against either the event's `path` or `id`). Resolves early
77+
* on cancellation or after `timeoutMs` to avoid hanging callers if the event is missed.
78+
* Callers must subscribe via this helper BEFORE invoking `updateActiveEnvironmentPath` to
79+
* avoid a race where the event fires before the listener is attached.
80+
*/
81+
export function waitForActiveEnvironmentChange(
82+
api: PythonExtension['environments'],
83+
pythonPath: string,
84+
resource: Uri | undefined,
85+
token: CancellationToken,
86+
timeoutMs = 5000,
87+
): Promise<void> {
88+
if (token.isCancellationRequested) {
89+
return Promise.resolve();
90+
}
91+
return new Promise<void>((resolve) => {
92+
let settled = false;
93+
const listener = api.onDidChangeActiveEnvironmentPath((e) => {
94+
if (isEnvironmentPathMatch(e, pythonPath) && isResourceMatch(e.resource, resource)) {
95+
settle();
96+
}
97+
});
98+
const cancelRef = token.onCancellationRequested(() => settle());
99+
const timer = setTimeout(() => settle(), timeoutMs);
100+
function settle() {
101+
if (settled) {
102+
return;
103+
}
104+
settled = true;
105+
listener.dispose();
106+
cancelRef.dispose();
107+
clearTimeout(timer);
108+
resolve();
109+
}
110+
});
111+
}
112+
113+
function isResourceMatch(eventResource: { uri: Uri } | Uri | undefined, requestedResource: Uri | undefined): boolean {
114+
const eventUri = eventResource && 'uri' in eventResource ? eventResource.uri : eventResource;
115+
const requestedUri = requestedResource
116+
? workspace.getWorkspaceFolder(requestedResource)?.uri ?? requestedResource
117+
: undefined;
118+
return eventUri === undefined
119+
? requestedUri === undefined
120+
: requestedUri !== undefined && arePathsSame(eventUri.fsPath, requestedUri.fsPath);
121+
}
122+
123+
function isEnvironmentPathMatch(environment: { path: string; id: string }, pythonPath: string): boolean {
124+
return arePathsSame(environment.path, pythonPath) || environment.id === pythonPath;
125+
}
126+
127+
/**
128+
* Sets the active Python interpreter to `pythonPath` without any UI, waits for the
129+
* asynchronous environment switch to settle (via `onDidChangeActiveEnvironmentPath`),
130+
* resolves the environment, and returns it.
131+
*
132+
* Returns `undefined` if the path cannot be resolved to a valid environment so callers
133+
* can produce a tool-specific error message.
134+
*/
135+
export async function setEnvironmentDirectlyByPath(
136+
pythonPath: string,
137+
api: PythonExtension['environments'],
138+
resource: Uri | undefined,
139+
token: CancellationToken,
140+
): Promise<ResolvedEnvironment | undefined> {
141+
if (token.isCancellationRequested) {
142+
throw new CancellationError();
143+
}
144+
// Validate the path resolves to a real environment BEFORE mutating user settings.
145+
// updateActiveEnvironmentPath persists unconditionally, so an invalid path would
146+
// permanently overwrite the user's selected interpreter.
147+
const candidate = await raceCancellationError(api.resolveEnvironment(pythonPath), token);
148+
if (!candidate) {
149+
return undefined;
150+
}
151+
if (isEnvironmentPathMatch(api.getActiveEnvironmentPath(resource), pythonPath)) {
152+
return candidate;
153+
}
154+
155+
// Subscribe to the change event BEFORE triggering the update so we don't miss it.
156+
// updateActiveEnvironmentPath only persists the setting; the active interpreter switch
157+
// is asynchronous, so we wait for the event before resolving env details to avoid
158+
// returning details for the previously-active interpreter.
159+
const activeChanged = waitForActiveEnvironmentChange(api, pythonPath, resource, token);
160+
await raceCancellationError(api.updateActiveEnvironmentPath(pythonPath, resource), token);
161+
await raceCancellationError(activeChanged, token);
162+
163+
// Verify the active env actually switched. If the change event timed out and the
164+
// active path is still the previous one, don't report success for the wrong env.
165+
const envPath = api.getActiveEnvironmentPath(resource);
166+
if (!isEnvironmentPathMatch(envPath, pythonPath)) {
167+
return undefined;
168+
}
169+
return raceCancellationError(api.resolveEnvironment(envPath), token);
170+
}
171+
61172
export async function getEnvDisplayName(
62173
discovery: IDiscoveryAPI,
63174
resource: Uri | undefined,

0 commit comments

Comments
 (0)