Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts
13401 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 { CancellationToken } from '../../../../base/common/cancellation.js';6import { Codicon } from '../../../../base/common/codicons.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { Disposable } from '../../../../base/common/lifecycle.js';9import { ResourceMap } from '../../../../base/common/map.js';10import { extname } from '../../../../base/common/path.js';11import { basename, joinPath } from '../../../../base/common/resources.js';12import { SKILL_FILENAME } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js';13import { PromptFileParser } from '../../../../workbench/contrib/chat/common/promptSyntax/promptFileParser.js';14import { ThemeIcon } from '../../../../base/common/themables.js';15import { URI } from '../../../../base/common/uri.js';16import { localize } from '../../../../nls.js';17import { AgentHostConfigKey, getAgentHostConfiguredCustomizations } from '../../../../platform/agentHost/common/agentHostCustomizationConfig.js';18import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js';19import { IFileService } from '../../../../platform/files/common/files.js';20import { ILogService } from '../../../../platform/log/common/log.js';21import { AGENT_HOST_SCHEME, fromAgentHostUri, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js';22import type { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js';23import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js';24import { type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization, CustomizationStatus } from '../../../../platform/agentHost/common/state/sessionState.js';25import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';26import { INotificationService } from '../../../../platform/notification/common/notification.js';27import { AICustomizationManagementSection, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';28import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction, type ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js';29import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';30import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';31import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js';32import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js';33import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../workbench/services/agentHost/common/agentHostFileSystemService.js';3435export { AgentCustomizationSyncProvider as RemoteAgentSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js';3637const REMOTE_HOST_GROUP = 'remote-host';38const REMOTE_CLIENT_GROUP = 'remote-client';3940/**41* Returns `true` for the synthetic "VS Code Synced Data" bundle plugin,42* which is an implementation detail of the customization sync pipeline43* and should not be surfaced as a standalone item in the UI.44*/45function isSyntheticBundle(customization: CustomizationRef): boolean {46try {47return URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME;48} catch {49return false;50}51}5253/**54* Maps a plugin sub-directory name to the {@link PromptsType}55* its files represent. Returns `undefined` for unknown directories.56*/57function promptsTypeForPluginDir(dir: string): PromptsType | undefined {58switch (dir) {59case 'rules': return PromptsType.instructions;60case 'commands': return PromptsType.prompt;61case 'agents': return PromptsType.agent;62case 'skills': return PromptsType.skill;63default: return undefined;64}65}6667/**68* Strips conventional prompt file extensions so we can show `foo`69* for `foo.prompt.md`, `foo.instructions.md`, etc.70*/71function stripPromptFileExtensions(filename: string): string {72const ext = extname(filename);73if (!ext) {74return filename;75}76const stem = filename.slice(0, -ext.length);77const dotInStem = stem.lastIndexOf('.');78return dotInStem > 0 ? stem.slice(0, dotInStem) : stem;79}8081interface IExpandedPlugin {82readonly nonce: string | undefined;83readonly children: readonly ICustomizationItem[];84}8586/**87* Maps a {@link CustomizationStatus} enum value to the string literal88* expected by {@link ICustomizationItem.status}.89*/90function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined {91switch (status) {92case CustomizationStatus.Loading: return 'loading';93case CustomizationStatus.Loaded: return 'loaded';94case CustomizationStatus.Degraded: return 'degraded';95case CustomizationStatus.Error: return 'error';96default: return undefined;97}98}99100function customizationKey(customization: CustomizationRef): string {101return customization.uri;102}103104function customizationItemKey(customization: CustomizationRef, clientId: string | undefined): string {105return clientId !== undefined106? `${customizationKey(customization)}::${clientId}`107: customizationKey(customization);108}109110/**111* Owns the client-side UI commands for configuring plugins on a remote112* agent host. The actual source of truth lives in the host's root config.113*/114export class RemoteAgentPluginController extends Disposable {115readonly pluginActions: readonly ICustomizationItemAction[];116117constructor(118private readonly _hostLabel: string,119private readonly _connectionAuthority: string,120private readonly _connection: IAgentConnection,121@IFileDialogService private readonly _fileDialogService: IFileDialogService,122@INotificationService private readonly _notificationService: INotificationService,123@IAICustomizationWorkspaceService _workspaceService: IAICustomizationWorkspaceService,124) {125super();126127this.pluginActions = [128{129id: 'remoteAgentHost.addPlugin',130label: localize('remoteAgentHost.addPlugin', "Add Remote Plugin"),131tooltip: localize('remoteAgentHost.addPluginTooltip', "Add a plugin folder that already exists on this remote agent host."),132icon: Codicon.add,133run: () => this.addConfiguredPlugin(),134},135];136}137138async removeConfiguredPlugin(customizationToRemove: CustomizationRef): Promise<void> {139const updated = this.getConfiguredCustomizations().filter(customization => customizationKey(customization) !== customizationKey(customizationToRemove));140this.dispatchCustomizations(updated);141}142143private getConfiguredCustomizations(): readonly CustomizationRef[] {144const rootState = this._connection.rootState.value;145if (!rootState || rootState instanceof Error) {146return [];147}148149return getAgentHostConfiguredCustomizations(rootState.config?.values);150}151152private dispatchCustomizations(customizations: readonly CustomizationRef[]): void {153this._connection.dispatch({154type: ActionType.RootConfigChanged,155config: {156[AgentHostConfigKey.Customizations]: [...customizations],157},158});159}160161private async pickRemotePluginFolder(title: string): Promise<URI | undefined> {162try {163const selected = await this._fileDialogService.showOpenDialog({164canSelectFiles: false,165canSelectFolders: true,166canSelectMany: false,167title,168availableFileSystems: [AGENT_HOST_SCHEME],169defaultUri: agentHostUri(this._connectionAuthority, '/'),170});171return selected?.[0];172} catch {173return undefined;174}175}176177private async addConfiguredPlugin(): Promise<void> {178const selected = await this.pickRemotePluginFolder(localize('remoteAgentHost.selectPluginFolder', "Select Plugin Folder on {0}", this._hostLabel));179if (!selected) {180return;181}182183const original = fromAgentHostUri(selected);184const newCustomization: CustomizationRef = {185uri: original.toString(),186displayName: basename(original) || original.path,187};188189const current = this.getConfiguredCustomizations();190const nextKey = customizationKey(newCustomization);191if (current.some(customization => customizationKey(customization) === nextKey)) {192this._notificationService.info(localize(193'remoteAgentHost.pluginAlreadyConfigured',194"'{0}' is already configured on {1}.",195newCustomization.displayName,196this._hostLabel,197));198return;199}200201this.dispatchCustomizations([...current, newCustomization]);202}203}204205/**206* Provider that exposes a remote agent's configured plugins as207* {@link ICustomizationItem} entries for the plugin management widget.208*209* Each plugin is also **expanded** into its individual customization210* files (agents, skills, instructions, prompts) by reading the plugin211* directory through the agent-host filesystem provider. The expanded212* children appear in per-type sections (Skills, Agents, etc.) while213* the parent plugin item appears in the Plugins section.214*/215export class RemoteAgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider {216private readonly _onDidChange = this._register(new Emitter<void>());217readonly onDidChange: Event<void> = this._onDidChange.event;218219private _agentCustomizations: readonly CustomizationRef[];220private _sessionCustomizations: readonly SessionCustomization[] | undefined;221222/** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */223private readonly _expansionCache = new ResourceMap<IExpandedPlugin>();224225constructor(226private readonly _agentInfo: AgentInfo,227private readonly _connection: IAgentConnection,228private readonly _connectionAuthority: string,229private readonly _controller: RemoteAgentPluginController,230private readonly _fileService: IFileService,231private readonly _logService: ILogService,232) {233super();234this._agentCustomizations = this._readRootCustomizations(this._connection.rootState.value) ?? _agentInfo.customizations ?? [];235236this._register(this._connection.rootState.onDidChange(rootState => {237const next = this._readRootCustomizations(rootState) ?? this._readAgentCustomizations(rootState) ?? this._agentCustomizations;238if (next !== this._agentCustomizations) {239this._agentCustomizations = next;240this._onDidChange.fire();241}242}));243244this._register(this._connection.onDidAction(envelope => {245if (envelope.action.type === ActionType.SessionCustomizationsChanged) {246const customizations = (envelope.action as { customizations?: SessionCustomization[] }).customizations;247if (customizations && customizations !== this._sessionCustomizations) {248this._sessionCustomizations = customizations;249this._onDidChange.fire();250}251}252}));253}254255private _readRootCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {256if (!rootState || rootState instanceof Error || !rootState.config) {257return undefined;258}259260return getAgentHostConfiguredCustomizations(rootState.config?.values);261}262263private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {264if (!rootState || rootState instanceof Error) {265return undefined;266}267268return rootState.agents.find(agent => agent.provider === this._agentInfo.provider)?.customizations;269}270271private toRemoteUri(customization: CustomizationRef): URI {272const original = URI.parse(customization.uri);273// The synthetic synced-customization bundle lives in the client's274// in-memory filesystem. Don't wrap it as an agent-host:// URI —275// the server doesn't have this scheme registered, so wrapping it276// would make expansion (and any direct read) fail.277if (original.scheme === SYNCED_CUSTOMIZATION_SCHEME) {278return original;279}280return toAgentHostUri(original, this._connectionAuthority);281}282283private toBadge(customization: CustomizationRef, clientId: string | undefined): { badge?: string; badgeTooltip?: string; groupKey?: string } {284if (clientId !== undefined) {285return {286groupKey: REMOTE_CLIENT_GROUP,287};288}289290return {291groupKey: REMOTE_HOST_GROUP,292};293}294295private toItem(customization: CustomizationRef, sessionCustomization: SessionCustomization | undefined): ICustomizationItem {296const clientId = sessionCustomization?.clientId;297const badge = this.toBadge(customization, clientId);298const actions = clientId !== undefined299? undefined300: <const>[{301id: 'remoteAgentHost.removeConfiguredPlugin',302label: localize('remoteAgentHost.removeConfiguredPlugin', "Remove from Remote Host"),303icon: Codicon.trash,304run: () => this._controller.removeConfiguredPlugin(customization),305}];306307return {308itemKey: customizationItemKey(customization, clientId),309uri: this.toRemoteUri(customization),310type: 'plugin',311name: customization.displayName,312description: customization.description,313storage: PromptsStorage.plugin,314status: toStatusString(sessionCustomization?.status),315statusMessage: sessionCustomization?.statusMessage,316enabled: sessionCustomization?.enabled ?? true,317badge: badge.badge,318badgeTooltip: badge.badgeTooltip,319groupKey: badge.groupKey,320extensionId: undefined,321pluginUri: undefined,322userInvocable: undefined,323actions,324};325}326327async provideChatSessionCustomizations(token: CancellationToken): Promise<ICustomizationItem[]> {328const items = new Map<string, ICustomizationItem>();329330// Build parent plugin items keyed by customization ref331type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType<typeof toStatusString>; statusMessage: string | undefined; enabled: boolean | undefined; childGroupKey?: string };332const plugins: PluginMeta[] = [];333334for (const customization of this._agentCustomizations) {335const item = this.toItem(customization, undefined);336items.set(customizationItemKey(customization, undefined), item);337plugins.push({ item, nonce: customization.nonce, status: undefined, statusMessage: undefined, enabled: undefined, childGroupKey: REMOTE_HOST_GROUP });338}339340for (const sessionCustomization of this._sessionCustomizations ?? []) {341const isBundleItem = isSyntheticBundle(sessionCustomization.customization);342const isClientSynced = sessionCustomization.clientId !== undefined;343344// Always show session customizations as distinct plugin entries —345// client-synced items appear in the "Local" group, host-owned in346// the "Remote" group. The synthetic bundle is an implementation347// detail and is not shown as a standalone entry, but is still348// expanded below so individual user files appear in per-type tabs.349if (!isBundleItem) {350const item = this.toItem(sessionCustomization.customization, sessionCustomization);351items.set(352customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId),353item,354);355}356357// Always expand plugin contents so individual files are visible.358const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP;359plugins.push({360item: isBundleItem361? { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', storage: PromptsStorage.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }362: this.toItem(sessionCustomization.customization, sessionCustomization),363nonce: sessionCustomization.customization.nonce,364status: toStatusString(sessionCustomization.status),365statusMessage: sessionCustomization.statusMessage,366enabled: sessionCustomization.enabled,367childGroupKey,368});369}370371// Expand each plugin directory in parallel to discover individual372// skills, agents, instructions, and prompts inside.373const expansions = await Promise.all(plugins.map(p => this._expandPluginContents(p.item.uri, p.nonce, p.childGroupKey ?? REMOTE_HOST_GROUP, token)));374if (token.isCancellationRequested) {375return [];376}377378for (let i = 0; i < plugins.length; i++) {379const p = plugins[i];380for (const child of expansions[i]) {381// Children inherit the parent plugin's status/enabled state.382items.set(`${p.item.itemKey ?? p.item.uri.toString()}::${child.type}::${child.name}`, {383...child,384status: p.status,385statusMessage: p.statusMessage,386enabled: p.enabled,387});388}389}390391return [...items.values()];392}393394/**395* Reads a plugin's directory contents through the agent-host396* filesystem provider and returns one {@link ICustomizationItem} per397* supported file (agents/skills/instructions/prompts).398*399* Cached by `(uri, nonce)`; a different nonce invalidates the entry.400*/401private async _expandPluginContents(pluginUri: URI, nonce: string | undefined, groupKey: string, token: CancellationToken): Promise<readonly ICustomizationItem[]> {402const cached = this._expansionCache.get(pluginUri);403if (cached && cached.nonce === nonce) {404return cached.children;405}406407// pluginUri is already an agent-host:// URI (from toRemoteUri),408// so use it directly as the filesystem root.409const fsRoot = pluginUri;410const children: ICustomizationItem[] = [];411try {412if (!await this._fileService.canHandleResource(fsRoot)) {413return [];414}415if (token.isCancellationRequested) {416return [];417}418419const dirNames = ['agents', 'skills', 'commands', 'rules'] as const;420const subdirs = dirNames.map(name => ({ name, resource: URI.joinPath(fsRoot, name) }));421const stats = await this._fileService.resolveAll(subdirs.map(s => ({ resource: s.resource })));422423if (token.isCancellationRequested) {424return [];425}426427for (let i = 0; i < subdirs.length; i++) {428const stat = stats[i];429if (!stat.success || !stat.stat?.isDirectory || !stat.stat.children) {430continue;431}432const promptType = promptsTypeForPluginDir(subdirs[i].name);433if (!promptType) {434continue;435}436children.push(...await this._collectFromTypeDir(stat.stat.children, promptType, groupKey, token));437}438children.sort((a, b) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`));439} catch (err) {440this._logService.trace(`[RemoteAgentCustomizationItemProvider] Failed to expand plugin ${pluginUri.toString()}: ${err}`);441return [];442}443444this._expansionCache.set(pluginUri, { nonce, children });445return children;446}447448/**449* Emits one {@link ICustomizationItem} per child of a per-type450* sub-folder. Skills are conventionally folders containing451* `SKILL.md`, and synced bundles may preserve per-skill452* subdirectories; flat skill files can still appear for legacy453* bundles, so both layouts are accepted.454*455* For skills, the `SKILL.md` frontmatter is read so that the item's456* description (and a frontmatter-supplied name, when present) can be457* surfaced — without it the UI would only show the folder name with458* no description.459*/460private async _collectFromTypeDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], promptType: PromptsType, groupKey: string, token: CancellationToken): Promise<ICustomizationItem[]> {461type Entry = { name: string; resource: URI; isDirectory: boolean };462const eligible: Entry[] = [];463for (const child of entries) {464// Skip dotfiles (e.g. .DS_Store)465if (child.name.startsWith('.')) {466continue;467}468if (promptType !== PromptsType.skill && child.isDirectory) {469continue;470}471eligible.push(child);472}473474const skillMetadata = promptType === PromptsType.skill475? await Promise.all(eligible.map(child => this._readSkillMetadata(child, token)))476: undefined;477if (token.isCancellationRequested) {478return [];479}480481const items: ICustomizationItem[] = [];482for (let i = 0; i < eligible.length; i++) {483const child = eligible[i];484let displayName: string;485let description: string | undefined;486let uri = child.resource;487if (promptType === PromptsType.skill) {488const meta = skillMetadata![i];489// For folder-style skills the canonical resource for the skill490// is its `SKILL.md`; downstream code (slash-command resolution,491// chat input decorations) calls `parseNew(item.uri)` and would492// otherwise try to read the directory as a file. If we couldn't493// read `SKILL.md`, skip the entry rather than emit a URI that494// will fail to parse downstream.495if (child.isDirectory) {496if (!meta) {497continue;498}499uri = joinPath(child.resource, SKILL_FILENAME);500}501const fallbackName = child.isDirectory ? child.name : stripPromptFileExtensions(child.name);502displayName = meta?.name ?? fallbackName;503description = meta?.description;504} else {505displayName = stripPromptFileExtensions(child.name);506}507items.push({508uri,509type: promptType,510name: displayName,511description,512storage: PromptsStorage.plugin,513groupKey,514extensionId: undefined,515pluginUri: undefined,516userInvocable: true517});518}519return items;520}521522/**523* Reads `SKILL.md` for a skill entry and returns its frontmatter524* `name` / `description`. Returns `undefined` when the file cannot525* be read or parsed — the caller falls back to the folder name and526* leaves the description empty.527*/528private async _readSkillMetadata(entry: { name: string; resource: URI; isDirectory: boolean }, token: CancellationToken): Promise<{ name: string | undefined; description: string | undefined } | undefined> {529const skillFileUri = entry.isDirectory ? joinPath(entry.resource, SKILL_FILENAME) : entry.resource;530try {531const content = await this._fileService.readFile(skillFileUri);532if (token.isCancellationRequested) {533return undefined;534}535const parsed = new PromptFileParser().parse(skillFileUri, content.value.toString());536return { name: parsed.header?.name, description: parsed.header?.description };537} catch (err) {538this._logService.trace(`[RemoteAgentCustomizationItemProvider] Failed to read skill metadata ${skillFileUri.toString()}: ${err}`);539return undefined;540}541}542}543544/**545* Creates a {@link IHarnessDescriptor} for a remote agent discovered via546* the agent host protocol.547*/548export function createRemoteAgentHarnessDescriptor(549harnessId: string,550displayName: string,551controller: RemoteAgentPluginController,552itemProvider: RemoteAgentCustomizationItemProvider,553syncProvider: AgentCustomizationSyncProvider,554): IHarnessDescriptor {555const allSources = [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE];556const filter: IStorageSourceFilter = { sources: allSources };557558return {559id: harnessId,560label: displayName,561icon: ThemeIcon.fromId(Codicon.remote.id),562hiddenSections: [563AICustomizationManagementSection.Models,564AICustomizationManagementSection.McpServers,565],566hideGenerateButton: true,567getStorageSourceFilter(_type: PromptsType): IStorageSourceFilter {568return filter;569},570itemProvider,571syncProvider,572pluginActions: controller.pluginActions,573};574}575576577