Skip to content

API to get last used env in a LM tool #25079

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 16, 2025
Merged
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: 4 additions & 2 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ export function buildApi(
TensorboardExtensionIntegration,
TensorboardExtensionIntegration,
);
const jupyterIntegration = serviceContainer.get<JupyterExtensionIntegration>(JupyterExtensionIntegration);
const jupyterPythonEnvApi = serviceContainer.get<JupyterPythonEnvironmentApi>(JupyterExtensionPythonEnvironments);
const environments = buildEnvironmentApi(discoveryApi, serviceContainer, jupyterPythonEnvApi);
const jupyterIntegration = serviceContainer.get<JupyterExtensionIntegration>(JupyterExtensionIntegration);
jupyterIntegration.registerEnvApi(environments);
const tensorboardIntegration = serviceContainer.get<TensorboardExtensionIntegration>(
TensorboardExtensionIntegration,
);
Expand Down Expand Up @@ -155,7 +157,7 @@ export function buildApi(
stop: (client: BaseLanguageClient): Promise<void> => client.stop(),
getTelemetryReporter: () => getTelemetryReporter(),
},
environments: buildEnvironmentApi(discoveryApi, serviceContainer, jupyterPythonEnvApi),
environments,
};

// In test environment return the DI Container.
Expand Down
3 changes: 2 additions & 1 deletion src/client/chat/installPackagesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { resolveFilePath } from './utils';
import { IModuleInstaller } from '../common/installer/types';
import { ModuleInstallerType } from '../pythonEnvironments/info';
import { IDiscoveryAPI } from '../pythonEnvironments/base/locator';
import { trackEnvUsedByTool } from './lastUsedEnvs';

export interface IInstallPackageArgs {
resourcePath?: string;
Expand Down Expand Up @@ -66,7 +67,7 @@ export class InstallPackagesTool implements LanguageModelTool<IInstallPackageArg
for (const packageName of options.input.packageList) {
await installer.installModule(packageName, resourcePath, token, undefined, { installAsProcess: true });
}

trackEnvUsedByTool(resourcePath, environment);
// format and return
const resultMessage = `Successfully installed ${packagePlurality}: ${options.input.packageList.join(', ')}`;
return new LanguageModelToolResult([new LanguageModelTextPart(resultMessage)]);
Expand Down
81 changes: 81 additions & 0 deletions src/client/chat/lastUsedEnvs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { EnvironmentPath, PythonExtension } from '../api/types';
import { Uri } from 'vscode';

const MAX_TRACKED_URIS = 100; // Maximum number of environments to track
const MAX_TRACKED_AGE = 15 * 60 * 1000; // Maximum age of tracked environments in milliseconds (15 minutes)

type LastUsedEnvEntry = { uri: Uri | undefined; env: EnvironmentPath; dateTime: number };
const lastUsedEnvs: LastUsedEnvEntry[] = [];

/**
* Track the use of an environment for a given resource (uri).
* Prunes items older than 60 minutes or if the list grows over 100.
*/
export function trackEnvUsedByTool(uri: Uri | undefined, env: EnvironmentPath) {
const now = Date.now();
// Remove any previous entry for this uri
for (let i = lastUsedEnvs.length - 1; i >= 0; i--) {
if (urisEqual(lastUsedEnvs[i].uri, uri)) {
lastUsedEnvs.splice(i, 1);
}
}
// Add the new entry
lastUsedEnvs.push({ uri, env, dateTime: now });
// Prune
pruneLastUsedEnvs();
}

/**
* Get the last used environment for a given resource (uri), or undefined if not found or expired.
*/
export function getLastEnvUsedByTool(
uri: Uri | undefined,
api: PythonExtension['environments'],
): EnvironmentPath | undefined {
pruneLastUsedEnvs();
// Find the most recent entry for this uri that is not expired
const item = lastUsedEnvs.find((item) => urisEqual(item.uri, uri));
if (item) {
return item.env;
}
const envPath = api.getActiveEnvironmentPath(uri);
if (lastUsedEnvs.some((item) => item.env.id === envPath.id)) {
// If this env was already used, return it
return envPath;
}
return undefined;
}

/**
* Compare two uris (or undefined) for equality.
*/
function urisEqual(a: Uri | undefined, b: Uri | undefined): boolean {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
return a.toString() === b.toString();
}

/**
* Remove items older than 60 minutes or if the list grows over 100.
*/
function pruneLastUsedEnvs() {
const now = Date.now();
// Remove items older than 60 minutes
for (let i = lastUsedEnvs.length - 1; i >= 0; i--) {
if (now - lastUsedEnvs[i].dateTime > MAX_TRACKED_AGE) {
lastUsedEnvs.splice(i, 1);
}
}
// If still over 100, remove oldest
if (lastUsedEnvs.length > MAX_TRACKED_URIS) {
lastUsedEnvs.sort((a, b) => b.dateTime - a.dateTime);
lastUsedEnvs.length = MAX_TRACKED_URIS;
}
}
3 changes: 2 additions & 1 deletion src/client/chat/listPackagesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { parsePipList } from './pipListUtils';
import { Conda } from '../pythonEnvironments/common/environmentManagers/conda';
import { traceError } from '../logging';
import { IDiscoveryAPI } from '../pythonEnvironments/base/locator';
import { trackEnvUsedByTool } from './lastUsedEnvs';

export interface IResourceReference {
resourcePath?: string;
Expand Down Expand Up @@ -108,7 +109,7 @@ export async function getPythonPackagesResponse(
if (!packages.length) {
return 'No packages found';
}

trackEnvUsedByTool(resourcePath, environment);
// 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.
const response = [
'Below is a list of the Python packages, each in the format <name> or <name> (<version>). The version may be omitted if unknown: ',
Expand Down
3 changes: 2 additions & 1 deletion src/client/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PythonExtension, ResolvedEnvironment } from '../api/types';
import { ITerminalHelper, TerminalShellType } from '../common/terminal/types';
import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution';
import { Conda } from '../pythonEnvironments/common/environmentManagers/conda';
import { trackEnvUsedByTool } from './lastUsedEnvs';

export function resolveFilePath(filepath?: string): Uri | undefined {
if (!filepath) {
Expand Down Expand Up @@ -70,7 +71,7 @@ export async function getEnvironmentDetails(
getTerminalCommand(environment, resourcePath, terminalExecutionService, terminalHelper),
token,
);

trackEnvUsedByTool(resourcePath, environment);
const message = [
`Following is the information about the Python environment:`,
`1. Environment Type: ${environment.environment?.type || 'unknown'}`,
Expand Down
18 changes: 17 additions & 1 deletion src/client/jupyter/jupyterIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import {
import { PylanceApi } from '../activation/node/pylanceApi';
import { ExtensionContextKey } from '../common/application/contextKeys';
import { getDebugpyPath } from '../debugger/pythonDebugger';
import type { Environment } from '../api/types';
import type { Environment, EnvironmentPath, PythonExtension } from '../api/types';
import { DisposableBase } from '../common/utils/resourceLifecycle';
import { getLastEnvUsedByTool } from '../chat/lastUsedEnvs';

type PythonApiForJupyterExtension = {
/**
Expand Down Expand Up @@ -63,6 +64,11 @@ type PythonApiForJupyterExtension = {
* @param func : The function that Python should call when requesting the Python path.
*/
registerJupyterPythonPathFunction(func: (uri: Uri) => Promise<string | undefined>): void;

/**
* Returns the Environment that was last used in a Python tool.
*/
getLastUsedEnvInLmTool(uri: Uri): EnvironmentPath | undefined;
};

type JupyterExtensionApi = {
Expand All @@ -78,6 +84,7 @@ export class JupyterExtensionIntegration {
private jupyterExtension: Extension<JupyterExtensionApi> | undefined;

private pylanceExtension: Extension<PylanceApi> | undefined;
private environmentApi: PythonExtension['environments'] | undefined;

constructor(
@inject(IExtensions) private readonly extensions: IExtensions,
Expand All @@ -90,6 +97,9 @@ export class JupyterExtensionIntegration {
@inject(IContextKeyManager) private readonly contextManager: IContextKeyManager,
@inject(IInterpreterService) private interpreterService: IInterpreterService,
) {}
public registerEnvApi(api: PythonExtension['environments']) {
this.environmentApi = api;
}

public registerApi(jupyterExtensionApi: JupyterExtensionApi): JupyterExtensionApi | undefined {
this.contextManager.setContext(ExtensionContextKey.IsJupyterInstalled, true);
Expand Down Expand Up @@ -121,6 +131,12 @@ export class JupyterExtensionIntegration {
getCondaVersion: () => this.condaService.getCondaVersion(),
registerJupyterPythonPathFunction: (func: (uri: Uri) => Promise<string | undefined>) =>
this.registerJupyterPythonPathFunction(func),
getLastUsedEnvInLmTool: (uri) => {
if (!this.environmentApi) {
return undefined;
}
return getLastEnvUsedByTool(uri, this.environmentApi);
},
});
return undefined;
}
Expand Down
9 changes: 8 additions & 1 deletion src/test/api.functional.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import { ServiceManager } from '../client/ioc/serviceManager';
import { IServiceContainer, IServiceManager } from '../client/ioc/types';
import { IDiscoveryAPI } from '../client/pythonEnvironments/base/locator';
import * as pythonDebugger from '../client/debugger/pythonDebugger';
import { JupyterExtensionPythonEnvironments, JupyterPythonEnvironmentApi } from '../client/jupyter/jupyterIntegration';
import {
JupyterExtensionIntegration,
JupyterExtensionPythonEnvironments,
JupyterPythonEnvironmentApi,
} from '../client/jupyter/jupyterIntegration';
import { EventEmitter, Uri } from 'vscode';

suite('Extension API', () => {
Expand Down Expand Up @@ -50,6 +54,9 @@ suite('Extension API', () => {
when(serviceContainer.get<IEnvironmentVariablesProvider>(IEnvironmentVariablesProvider)).thenReturn(
instance(environmentVariablesProvider),
);
when(serviceContainer.get<JupyterExtensionIntegration>(JupyterExtensionIntegration)).thenReturn(
instance(mock<JupyterExtensionIntegration>()),
);
when(serviceContainer.get<IInterpreterService>(IInterpreterService)).thenReturn(instance(interpreterService));
const onDidChangePythonEnvironment = new EventEmitter<Uri>();
const jupyterApi: JupyterPythonEnvironmentApi = {
Expand Down