Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { SessionOptions, SweCustomAgent } from '@github/copilot/sdk';6import * as l10n from '@vscode/l10n';7import { promises as fs } from 'fs';8import * as path from 'path';9import type * as vscode from 'vscode';10import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';11import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';12import { IEnvService } from '../../../../platform/env/common/envService';13import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';14import { ILogService } from '../../../../platform/log/common/logService';15import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';16import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';17import { createServiceIdentifier } from '../../../../util/common/services';18import { Emitter, Event } from '../../../../util/vs/base/common/event';19import { Lazy } from '../../../../util/vs/base/common/lazy';20import { Disposable } from '../../../../util/vs/base/common/lifecycle';21import { ResourceSet } from '../../../../util/vs/base/common/map';22import { basename } from '../../../../util/vs/base/common/resources';23import { URI } from '../../../../util/vs/base/common/uri';24import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';25import { getCopilotLogger } from './logger';26import { ensureRipgrepShim } from './ripgrepShim';27import { CancellationToken } from '../../../../util/vs/base/common/cancellation';28import { getModelCapabilitiesDescription } from '../../../conversation/common/languageModelAccess';2930export const COPILOT_CLI_REASONING_EFFORT_PROPERTY = 'reasoningEffort';31const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel';32const COPILOT_CLI_REQUEST_MAP_KEY = 'github.copilot.cli.requestMap';33// Store last used Agent for a Session.34const COPILOT_CLI_SESSION_AGENTS_MEMENTO_KEY = 'github.copilot.cli.sessionAgents';35/**36* @deprecated Use empty strings to represent default model/agent instead.37* Left here for backward compatibility (for state stored by older versions of Chat extension).38*/39export const COPILOT_CLI_DEFAULT_AGENT_ID = '___vscode_default___';4041export interface CopilotCLIModelInfo {42readonly id: string;43readonly name: string;44readonly multiplier?: number;45readonly maxInputTokens?: number;46readonly maxOutputTokens?: number;47readonly maxContextWindowTokens: number;48readonly supportsVision?: boolean;49readonly supportsReasoningEffort?: boolean;50readonly defaultReasoningEffort?: string;51readonly supportedReasoningEfforts?: string[];52}5354export interface ICopilotCLIModels {55readonly _serviceBrand: undefined;56resolveModel(modelId: string): Promise<string | undefined>;57getDefaultModel(): Promise<string | undefined>;58setDefaultModel(modelId: string | undefined): Promise<void>;59getModels(): Promise<CopilotCLIModelInfo[]>;60registerLanguageModelChatProvider(lm: typeof vscode['lm']): void;61}6263export function formatModelDetails(model: CopilotCLIModelInfo): string {64return `${model.name}${model.multiplier ? ` • ${model.multiplier}x` : ''}`;65}6667export const ICopilotCLISDK = createServiceIdentifier<ICopilotCLISDK>('ICopilotCLISDK');6869export const ICopilotCLIModels = createServiceIdentifier<ICopilotCLIModels>('ICopilotCLIModels');7071export class CopilotCLIModels extends Disposable implements ICopilotCLIModels {72declare _serviceBrand: undefined;73private _availableModels?: Promise<CopilotCLIModelInfo[]>;74/** Synchronously available model infos (includes `auto`). Set once the eager fetch completes. */75private _resolvedModelInfos?: vscode.LanguageModelChatInformation[];76private readonly _onDidChange = this._register(new Emitter<void>());7778constructor(79@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,80@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,81@ILogService private readonly logService: ILogService,82@IAuthenticationService private readonly _authenticationService: IAuthenticationService,83@IConfigurationService private readonly configurationService: IConfigurationService,84) {85super();86this._fetchAndCacheModels();87this._register(this._authenticationService.onDidAuthenticationChange(() => {88// Auth changed which means models could've changed. Clear caches and re-fetch.89this._availableModels = undefined;90this._resolvedModelInfos = undefined;91this._onDidChange.fire();92this._fetchAndCacheModels();93}));94}9596private _fetchAndCacheModels(): void {97const availableModels = this._availableModels = this._getAvailableModels();98availableModels.then(models => {99// Bail out if a newer fetch has superseded this one (e.g. auth changed mid-flight).100if (this._availableModels !== availableModels) {101return;102}103this._resolvedModelInfos = this._buildModelInfos(models);104this._onDidChange.fire();105}).catch((error) => {106this.logService.error('[CopilotCLIModels] Failed to fetch available models', error);107});108}109async resolveModel(modelId: string): Promise<string | undefined> {110if (modelId.toLowerCase() === 'auto' && this.configurationService.getConfig(ConfigKey.Advanced.CLIAutoModelEnabled)) {111return modelId;112}113const models = await this.getModels();114modelId = modelId.trim().toLowerCase();115return models.find(m => m.id.toLowerCase() === modelId || m.name.toLowerCase() === modelId)?.id;116}117public async getDefaultModel() {118// First item in the list is always the default model (SDK sends the list ordered based on default preference)119const models = await this.getModels();120if (!models.length) {121return;122}123const defaultModel = models[0];124const preferredModelId = this.extensionContext.globalState.get<string>(COPILOT_CLI_MODEL_MEMENTO_KEY, defaultModel.id)?.trim()?.toLowerCase();125126return models.find(m => m.id.toLowerCase() === preferredModelId)?.id ?? defaultModel.id;127}128129public async setDefaultModel(modelId: string | undefined): Promise<void> {130await this.extensionContext.globalState.update(COPILOT_CLI_MODEL_MEMENTO_KEY, modelId);131}132133public async getModels(): Promise<CopilotCLIModelInfo[]> {134if (!this._authenticationService.anyGitHubSession) {135return [];136}137138// No need to query sdk multiple times, cache the result, this cannot change during a vscode session.139if (!this._availableModels) {140this._availableModels = this._getAvailableModels();141}142return this._availableModels;143}144145private async _getAvailableModels(): Promise<CopilotCLIModelInfo[]> {146const [{ getAvailableModels }, authInfo] = await Promise.all([this.copilotCLISDK.getPackage(), this.copilotCLISDK.getAuthInfo()]);147try {148const models = await getAvailableModels(authInfo);149return models.map(model => ({150id: model.id,151name: model.name,152multiplier: model.billing?.multiplier,153maxInputTokens: model.capabilities.limits.max_prompt_tokens,154maxOutputTokens: model.capabilities.limits.max_output_tokens,155maxContextWindowTokens: model.capabilities.limits.max_context_window_tokens,156supportsVision: model.capabilities.supports.vision,157supportsReasoningEffort: model.capabilities.supports.reasoningEffort,158defaultReasoningEffort: model.defaultReasoningEffort,159supportedReasoningEfforts: model.supportedReasoningEfforts,160} satisfies CopilotCLIModelInfo));161} catch (ex) {162this.logService.error(`[CopilotCLISession] Failed to fetch models`, ex);163return [];164}165}166167public registerLanguageModelChatProvider(lm: typeof vscode['lm']): void {168const provider: vscode.LanguageModelChatProvider = {169onDidChangeLanguageModelChatInformation: this._onDidChange.event,170provideLanguageModelChatInformation: async (_options, _token) => {171const autoModelEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIAutoModelEnabled);172if (!this._authenticationService.anyGitHubSession || !this._resolvedModelInfos) {173return autoModelEnabled ? [buildAutoModel()] : [];174}175return this._resolvedModelInfos;176},177provideLanguageModelChatResponse: async (_model, _messages, _options, _progress, _token) => {178// Implemented via chat participants.179},180provideTokenCount: async (_model, _text, _token) => {181// Token counting is not currently supported for the copilotcli provider.182return 0;183}184};185this._register(lm.registerLanguageModelChatProvider('copilotcli', provider));186this._onDidChange.fire();187}188189private _buildModelInfos(models: CopilotCLIModelInfo[]): vscode.LanguageModelChatInformation[] {190const isReasoningEffortEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled);191const isAutoModelEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIAutoModelEnabled);192const modelsInfo: vscode.LanguageModelChatInformation[] = models.map((model, index) => {193const multiplier = model.multiplier === undefined ? undefined : `${model.multiplier}x`;194const modelInfo: vscode.LanguageModelChatInformation = {195id: model.id,196name: model.name,197family: model.id,198version: '',199maxInputTokens: model.maxInputTokens ?? model.maxContextWindowTokens,200maxOutputTokens: model.maxOutputTokens ?? 0,201pricing: multiplier,202multiplierNumeric: model.multiplier,203isUserSelectable: true,204configurationSchema: isReasoningEffortEnabled ? buildConfigurationSchema(model) : undefined,205capabilities: {206imageInput: model.supportsVision,207toolCalling: true208},209targetChatSessionType: 'copilotcli',210isDefault: !isAutoModelEnabled && index === 0 ? true : undefined,211};212const tooltip = getModelCapabilitiesDescription(modelInfo) ?? '';213return {214...modelInfo,215tooltip216};217});218if (isAutoModelEnabled) {219modelsInfo.unshift(buildAutoModel(models[0]));220}221return modelsInfo;222}223}224225function buildAutoModel(defaultModel?: CopilotCLIModelInfo): vscode.LanguageModelChatInformation {226return {227id: 'auto',228name: 'Auto',229tooltip: l10n.t('Auto selects the best model for your request based on capacity and performance.'),230family: defaultModel?.id ?? '',231version: '',232maxInputTokens: defaultModel?.maxInputTokens ?? defaultModel?.maxContextWindowTokens ?? 0,233maxOutputTokens: defaultModel?.maxOutputTokens ?? 0,234isUserSelectable: true,235capabilities: {236imageInput: defaultModel?.supportsVision,237toolCalling: true,238},239targetChatSessionType: 'copilotcli',240isDefault: true,241};242}243244function buildConfigurationSchema(modelInfo: CopilotCLIModelInfo): vscode.LanguageModelConfigurationSchema | undefined {245const effortLevels = modelInfo.supportedReasoningEfforts ?? [];246if (effortLevels.length === 0) {247return;248}249250const defaultEffort = modelInfo.defaultReasoningEffort;251252return {253properties: {254[COPILOT_CLI_REASONING_EFFORT_PROPERTY]: {255type: 'string',256title: l10n.t('Thinking Effort'),257enum: effortLevels,258enumItemLabels: effortLevels.map(level => level.charAt(0).toUpperCase() + level.slice(1)),259enumDescriptions: effortLevels.map(level => {260switch (level) {261case 'none': return l10n.t('No reasoning applied');262case 'low': return l10n.t('Faster responses with less reasoning');263case 'medium': return l10n.t('Balanced reasoning and speed');264case 'high': return l10n.t('Greater reasoning depth but slower');265case 'xhigh': return l10n.t('Maximum reasoning depth but slower');266default: return level;267}268}),269default: defaultEffort,270group: 'navigation',271}272}273};274}275276/** An agent with its source URI preserved for UI and cross-referencing. */277export interface CLIAgentInfo {278readonly agent: Readonly<SweCustomAgent>;279/** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */280readonly sourceUri: URI;281readonly extensionId?: string;282readonly pluginUri?: URI;283}284285export interface ICopilotCLIAgents {286readonly _serviceBrand: undefined;287readonly onDidChangeAgents: Event<void>;288resolveAgent(agentId: string): Promise<SweCustomAgent | undefined>;289getAgents(): Promise<readonly CLIAgentInfo[]>;290getSessionAgent(sessionId: string): Promise<string | undefined>;291}292293export const ICopilotCLIAgents = createServiceIdentifier<ICopilotCLIAgents>('ICopilotCLIAgents');294295export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {296declare _serviceBrand: undefined;297private sessionAgents: Record<string, { agentId?: string; createdDateTime: number }> = {};298private _agentsPromise?: Promise<readonly CLIAgentInfo[]>;299private readonly _onDidChangeAgents = this._register(new Emitter<void>());300readonly onDidChangeAgents: Event<void> = this._onDidChangeAgents.event;301constructor(302@IPromptsService private readonly promptsService: IPromptsService,303@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,304@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,305@ILogService private readonly logService: ILogService,306@IWorkspaceService private readonly workspaceService: IWorkspaceService,307) {308super();309void this.getAgents();310this._register(this.promptsService.onDidChangeCustomAgents(() => {311this._refreshAgents();312}));313this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => {314this._refreshAgents();315}));316}317318private _refreshAgents(): void {319this._agentsPromise = undefined;320this.getAgents().catch((error) => {321this.logService.error('[CopilotCLIAgents] Failed to refresh agents', error);322});323this._onDidChangeAgents.fire();324}325326async trackSessionAgent(sessionId: string, agent: string | undefined): Promise<void> {327const details = Object.keys(this.sessionAgents).length ? this.sessionAgents : this.extensionContext.workspaceState.get<Record<string, { agentId?: string; createdDateTime: number }>>(COPILOT_CLI_SESSION_AGENTS_MEMENTO_KEY, this.sessionAgents);328329details[sessionId] = { agentId: agent, createdDateTime: Date.now() };330this.sessionAgents = details;331332// Prune entries older than 7 days.333const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;334for (const [key, value] of Object.entries(details)) {335if (value.createdDateTime < sevenDaysAgo) {336delete details[key];337}338}339340await this.extensionContext.workspaceState.update(COPILOT_CLI_SESSION_AGENTS_MEMENTO_KEY, details);341}342343async getSessionAgent(sessionId: string): Promise<string | undefined> {344const details = this.extensionContext.workspaceState.get<Record<string, { agentId?: string; createdDateTime: number }>>(COPILOT_CLI_SESSION_AGENTS_MEMENTO_KEY, this.sessionAgents);345// Check in-memory cache first before reading from memento.346// Possibly the session agent was just set and not yet persisted.347const agentId = this.sessionAgents[sessionId]?.agentId ?? details[sessionId]?.agentId;348if (agentId === COPILOT_CLI_DEFAULT_AGENT_ID) {349return '';350}351if (typeof agentId === 'string') {352return agentId;353}354const agents = await this.getAgents();355return agents.find(a => a.agent.name.toLowerCase() === agentId)?.agent.name;356}357358async resolveAgent(agentId: string): Promise<SweCustomAgent | undefined> {359for (const customAgent of await this.promptsService.getCustomAgents(CancellationToken.None)) {360if (customAgent.enabled && isEnabledForCopilotCLI(customAgent) && agentId === customAgent.uri.toString()) {361return this.toCustomAgent(customAgent)?.agent;362}363}364const customAgents = await this.getAgents();365agentId = agentId.toLowerCase();366const match = customAgents.find(a => a.agent.name.toLowerCase() === agentId || a.agent.displayName?.toLowerCase() === agentId);367return match ? this.cloneAgent(match.agent) : undefined;368}369370async getAgents(): Promise<readonly CLIAgentInfo[]> {371// Cache the promise to avoid concurrent fetches372if (!this._agentsPromise) {373this._agentsPromise = this.getAgentsImpl().catch((error) => {374this.logService.error('[CopilotCLIAgents] Failed to fetch custom agents', error);375this._agentsPromise = undefined;376return [];377});378}379380return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri })));381}382383async getAgentsImpl(): Promise<readonly CLIAgentInfo[]> {384const merged = new Map<string, CLIAgentInfo>();385const knownAgents = new ResourceSet();386for (const agent of await this.getSDKAgents()) {387const sourceUri = agent.path ? URI.file(agent.path) : URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` });388knownAgents.add(sourceUri);389merged.set(agent.name.toLowerCase(), {390agent: this.cloneAgent(agent),391sourceUri,392});393}394for (const customAgent of await this.promptsService.getCustomAgents(CancellationToken.None)) {395if (!customAgent.enabled || !isEnabledForCopilotCLI(customAgent)) {396continue;397}398// Skip legacy .chatmode.md files — they are a deprecated format399// and should not appear in the Copilot CLI agent list.400if (customAgent.uri.path.toLowerCase().endsWith('.chatmode.md')) {401continue;402}403if (knownAgents.has(customAgent.uri)) {404continue;405}406const info = this.toCustomAgent(customAgent);407if (!info) {408continue;409}410merged.set(info.agent.name.toLowerCase(), info);411}412413return [...merged.values()];414}415416private async getSDKAgents(): Promise<Readonly<SweCustomAgent>[]> {417const workspaceFolders = this.workspaceService.getWorkspaceFolders();418if (workspaceFolders.length === 0) {419return [];420}421422const [auth, { getCustomAgents }] = await Promise.all([this.copilotCLISDK.getAuthInfo(), this.copilotCLISDK.getPackage()]);423const workingDirectory = workspaceFolders[0];424const agents = await getCustomAgents(auth, workingDirectory.fsPath, undefined, getCopilotLogger(this.logService));425return agents.map(agent => this.cloneAgent(agent));426}427428private toCustomAgent(customAgent: vscode.ChatCustomAgent): CLIAgentInfo | undefined {429const agentName = getAgentFileNameFromFilePath(customAgent.uri);430const headerName = customAgent.name;431const name = headerName === undefined || headerName === '' ? agentName : headerName;432if (!name) {433return undefined;434}435436const tools = customAgent.tools?.filter(tool => !!tool) ?? [];437const model = customAgent.model?.[0];438439return {440agent: {441name,442displayName: name,443description: customAgent.description ?? '',444tools: tools.length > 0 ? tools : null,445prompt: async () => {446const pf = await this.promptsService.parseFile(customAgent.uri, CancellationToken.None);447return pf.body?.getContent() ?? '';448},449disableModelInvocation: customAgent.disableModelInvocation ?? false,450...(model ? { model } : {}),451},452sourceUri: customAgent.uri,453};454}455456private cloneAgent(agent: SweCustomAgent): SweCustomAgent {457return {458...agent,459tools: agent.tools ? [...agent.tools] : agent.tools460};461}462}463464export function getAgentFileNameFromFilePath(filePath: URI): string {465const nameFromFile = basename(filePath);466const lowerName = nameFromFile.toLowerCase();467const indexOfAgentMd = lowerName.indexOf('.agent.md');468if (indexOfAgentMd > 0) {469return nameFromFile.substring(0, indexOfAgentMd);470}471const indexOfChatmodeMd = lowerName.indexOf('.chatmode.md');472if (indexOfChatmodeMd > 0) {473return nameFromFile.substring(0, indexOfChatmodeMd);474}475return nameFromFile;476}477478479/**480* Service interface to abstract dynamic import of the Copilot CLI SDK for easier unit testing.481* Tests can provide a mock implementation returning a stubbed SDK shape.482*/483export interface ICopilotCLISDK {484readonly _serviceBrand: undefined;485getPackage(): Promise<typeof import('@github/copilot/sdk')>;486getAuthInfo(): Promise<NonNullable<SessionOptions['authInfo']>>;487/**488* @deprecated489*/490getRequestId(sdkRequestId: string): RequestDetails['details'] | undefined;491}492493type RequestDetails = { details: { requestId: string; toolIdEditMap: Record<string, string> }; createdDateTime: number };494export class CopilotCLISDK implements ICopilotCLISDK {495declare _serviceBrand: undefined;496private requestMap: Record<string, RequestDetails> = {};497private _ensureShimsPromise?: Promise<void>;498private _initializeLogger = new Lazy<Promise<void>>(() => this.initLogger());499constructor(500@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,501@IEnvService private readonly envService: IEnvService,502@ILogService private readonly logService: ILogService,503@IInstantiationService protected readonly instantiationService: IInstantiationService,504@IAuthenticationService private readonly authentService: IAuthenticationService,505@IConfigurationService private readonly configurationService: IConfigurationService,506) {507this.requestMap = this.extensionContext.workspaceState.get<Record<string, RequestDetails>>(COPILOT_CLI_REQUEST_MAP_KEY, {});508this._ensureShimsPromise = this.ensureShims();509this._initializeLogger.value.catch((error) => {510this.logService.error('[CopilotCLISDK] Failed to initialize logger', error);511});512}513514/**515* @deprecated516*/517getRequestId(sdkRequestId: string): RequestDetails['details'] | undefined {518return this.requestMap[sdkRequestId]?.details;519}520521public async getPackage(): Promise<typeof import('@github/copilot/sdk')> {522try {523// Ensure the ripgrep shim exists before importing the SDK (required for CLI sessions)524await this._ensureShimsPromise;525return await import('@github/copilot/sdk');526} catch (error) {527this.logService.error(`[CopilotCLISession] Failed to load @github/copilot/sdk: ${error}`);528throw error;529}530}531532private async initLogger() {533const { logger } = await this.getPackage();534logger.setLogWriter({535outputPath: () => 'na',536writeLog: (level, message) => {537switch (level) {538case 'error':539this.logService.error(`[CopilotCLI] ${message}`);540break;541case 'warning':542this.logService.warn(`[CopilotCLI] ${message}`);543break;544case 'info':545this.logService.info(`[CopilotCLI] ${message}`);546break;547default:548this.logService.debug(`[CopilotCLI] ${message}`);549}550return Promise.resolve();551}552});553}554555protected async ensureShims(): Promise<void> {556const successfulPlaceholder = path.join(this.extensionContext.extensionPath, 'node_modules', '@github', 'copilot', 'shims.txt');557if (await checkFileExists(successfulPlaceholder)) {558return;559}560await ensureRipgrepShim(this.extensionContext.extensionPath, this.envService.appRoot, this.logService);561await fs.writeFile(successfulPlaceholder, 'Shims created successfully');562}563564public async getAuthInfo(): Promise<NonNullable<SessionOptions['authInfo']>> {565// Check if proxy URL is configured - if so, skip client-side token validation566// as the proxy will handle authentication server-side.567// matching the auth info set during session creation in copilotcliSessionService.568const overrideProxyUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl);569570if (overrideProxyUrl) {571this.logService.info('[CopilotCLISession] Proxy URL configured, skipping client-side token validation');572return {573type: 'hmac',574hmac: 'empty',575host: 'https://github.com',576copilotUser: {577endpoints: {578api: overrideProxyUrl579}580}581};582}583584const copilotToken = await this.authentService.getGitHubSession('any', { silent: true });585return {586type: 'token',587token: copilotToken?.accessToken ?? '',588host: 'https://github.com'589};590}591}592593594export function isWelcomeView(workspaceService: IWorkspaceService) {595return workspaceService.getWorkspaceFolders().length === 0;596}597598async function checkFileExists(filePath: string): Promise<boolean> {599try {600const stat = await fs.stat(filePath);601return stat.isFile();602} catch (error) {603return false;604}605}606607export function isEnabledForCopilotCLI(customization: { sessionTypes?: readonly string[] }): boolean {608const sessionTypes = customization.sessionTypes;609return sessionTypes === undefined || sessionTypes.includes('copilotcli') || false;610}611612613614