Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/githubOrgCustomAgentProvider.ts
13399 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 * as vscode from 'vscode';6import YAML, { Scalar } from 'yaml';7import { AGENT_FILE_EXTENSION, PromptsType } from '../../../platform/customInstructions/common/promptTypes';8import { CustomAgentDetails, CustomAgentListOptions, IOctoKitService } from '../../../platform/github/common/githubService';9import { ILogService } from '../../../platform/log/common/logService';10import { Disposable } from '../../../util/vs/base/common/lifecycle';11import { IGitHubOrgChatResourcesService } from './githubOrgChatResourcesService';1213/**14* Polling interval for refreshing custom agents from GitHub (5 minutes).15* We poll a bit less frequently as we need to loop and fetch full agent details including prompt content.16*/17const REFRESH_INTERVAL_MS = 5 * 60 * 1000;1819export class GitHubOrgCustomAgentProvider extends Disposable implements vscode.ChatCustomAgentProvider {20private readonly _onDidChangeCustomAgents = this._register(new vscode.EventEmitter<void>());21readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event;2223constructor(24@IOctoKitService private readonly octoKitService: IOctoKitService,25@ILogService private readonly logService: ILogService,26@IGitHubOrgChatResourcesService private readonly githubOrgChatResourcesService: IGitHubOrgChatResourcesService,27) {28super();2930// Set up polling with provider-specific interval31this._register(this.githubOrgChatResourcesService.startPolling(REFRESH_INTERVAL_MS, this.pollAgents.bind(this)));32}3334async provideCustomAgents(_context: unknown, token: vscode.CancellationToken): Promise<vscode.ChatResource[]> {35try {36const orgId = await this.githubOrgChatResourcesService.getPreferredOrganizationName();37if (!orgId) {38this.logService.trace('[GitHubOrgCustomAgentProvider] No organization available for providing agents');39return [];40}4142if (token.isCancellationRequested) {43this.logService.trace('[GitHubOrgCustomAgentProvider] provideCustomAgents was cancelled');44return [];45}4647return await this.githubOrgChatResourcesService.listCachedFiles(PromptsType.agent, orgId);48} catch (error) {49this.logService.error(`[GitHubOrgCustomAgentProvider] Error reading from cache: ${error}`);50return [];51}52}5354private async pollAgents(orgId: string): Promise<void> {55try {56// Convert VS Code API options to internal options57// It's okay to include enterprise agents here which may take from other orgs, as we only retrieve per org58const internalOptions = { includeSources: ['org', 'enterprise'] } satisfies CustomAgentListOptions;5960// Note: we need to fetch an arbitrary visible/accessible repository, in case user does not have access to .github-private61const repos = await this.octoKitService.getOrganizationRepositories(orgId, {}, 1);62if (repos.length === 0) {63this.logService.trace(`[GitHubOrgCustomAgentProvider] No repositories found for org ${orgId}`);64return;65}6667// Fetch custom agents from GitHub and compare with existing agents in cache68const repoName = repos[0];69const [agents, existingAgents] = await Promise.all([70this.octoKitService.getCustomAgents(orgId, repoName, internalOptions, {}),71this.githubOrgChatResourcesService.listCachedFiles(PromptsType.agent, orgId)72]);7374let hasChanges: boolean = existingAgents.length !== agents.length;75const newFiles = new Set<string>();76for (const agent of agents) {77// Fetch full agent details including prompt content78const agentDetails = await this.octoKitService.getCustomAgentDetails(79agent.repo_owner,80agent.repo_name,81agent.name,82agent.version,83{},84);8586// Generate agent markdown file content87if (agentDetails) {88const filename = `${agent.name}${AGENT_FILE_EXTENSION}`;89const content = this.generateAgentMarkdown(agentDetails);90const result = await this.githubOrgChatResourcesService.writeCacheFile(91PromptsType.agent,92orgId,93filename,94content,95{ checkForChanges: !hasChanges }96);97hasChanges ||= result;98newFiles.add(filename);99}100}101102if (!hasChanges) {103this.logService.trace('[GitHubOrgCustomAgentProvider] No changes detected in cache');104return;105}106107// Remove all cached agents that are no longer present108await this.githubOrgChatResourcesService.clearCache(PromptsType.agent, orgId, newFiles);109110// Fire event to notify consumers that agents have changed111this._onDidChangeCustomAgents.fire();112} catch (error) {113this.logService.error(`[GitHubOrgCustomAgentProvider] Error polling for agents: ${error}`);114}115}116117private generateAgentMarkdown(agent: CustomAgentDetails): string {118const frontmatterObj: Record<string, unknown> = {};119120if (agent.display_name) {121frontmatterObj.name = yamlString(agent.display_name);122}123if (agent.description) {124frontmatterObj.description = yamlString(agent.description);125}126if (agent.tools && agent.tools.length > 0 && agent.tools[0] !== '*') {127frontmatterObj.tools = agent.tools;128}129if (agent.argument_hint) {130frontmatterObj['argument-hint'] = agent.argument_hint;131}132if (agent.target) {133frontmatterObj.target = agent.target;134}135if (agent.model) {136frontmatterObj.model = agent.model;137}138if (agent.disable_model_invocation !== undefined) {139frontmatterObj['disable-model-invocation'] = agent.disable_model_invocation;140}141if (agent.user_invocable !== undefined) {142frontmatterObj['user-invocable'] = agent.user_invocable;143}144145const frontmatter = YAML.stringify(frontmatterObj, {146lineWidth: 0,147// Force double-quoted strings with newlines to use escape sequences rather than multi-line blocks.148// The custom YAML parser doesn't support multi-line strings.149doubleQuotedMinMultiLineLength: Infinity,150}).trim();151const body = agent.prompt ?? '';152153return `---\n${frontmatter}\n---\n${body}\n`;154}155}156157/**158* Returns a YAML-safe value for a string. If the string contains characters159* that need quoting (like #, :, etc.), wraps it in a Scalar with appropriate quoting.160* The custom YAML parser doesn't handle escape sequences, so we prefer single quotes161* unless the value contains single quotes or newlines (in which case we use double quotes).162*/163export function yamlString(value: string): string | Scalar {164// Characters/patterns that require quoting in YAML values:165// - # starts a comment, : is key-value separator, [] {} are collection syntax, , is separator166// - Values starting with quotes need quoting to preserve as strings167// - Values with leading/trailing whitespace need quoting168// - Boolean keywords (true, false) would be parsed as booleans169// - Null keywords (null, ~) would be parsed as null170// - Numeric-looking strings would be parsed as numbers171// - Newlines would corrupt the value (parser splits on newlines)172// - Single quotes in value require double quotes (parser doesn't handle escapes)173const needsQuoting =174/[#:\[\]{},\n\r]/.test(value) ||175value.startsWith('\'') ||176value.startsWith('"') ||177value !== value.trim() ||178value === 'true' ||179value === 'false' ||180value === 'null' ||181value === '~' ||182looksLikeNumber(value);183184if (needsQuoting) {185const scalar = new Scalar(value);186// Use double quotes if value contains single quotes OR newlines.187// - Single quotes can't be escaped in YAML single-quoted strings188// - Newlines in single-quoted strings become multi-line blocks, but the custom189// YAML parser doesn't support multi-line strings. Double quotes preserve190// newlines as \n escape sequences.191scalar.type = (value.includes('\'') || value.includes('\n') || value.includes('\r'))192? Scalar.QUOTE_DOUBLE193: Scalar.QUOTE_SINGLE;194return scalar;195}196return value;197}198199/**200* Checks if a string looks like a number that would be parsed as a numeric value.201* Matches the logic in the custom YAML parser's isValidNumber and createValueNode.202*/203export function looksLikeNumber(value: string): boolean {204if (value === '') {205return false;206}207const num = Number(value);208// Matches parser logic: !isNaN && isFinite && passes regex /^-?\d*\.?\d+$/209return !isNaN(num) && isFinite(num) && /^-?\d*\.?\d+$/.test(value);210}211212213