Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/githubOrgCustomAgentProvider.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as vscode from 'vscode';
7
import YAML, { Scalar } from 'yaml';
8
import { AGENT_FILE_EXTENSION, PromptsType } from '../../../platform/customInstructions/common/promptTypes';
9
import { CustomAgentDetails, CustomAgentListOptions, IOctoKitService } from '../../../platform/github/common/githubService';
10
import { ILogService } from '../../../platform/log/common/logService';
11
import { Disposable } from '../../../util/vs/base/common/lifecycle';
12
import { IGitHubOrgChatResourcesService } from './githubOrgChatResourcesService';
13
14
/**
15
* Polling interval for refreshing custom agents from GitHub (5 minutes).
16
* We poll a bit less frequently as we need to loop and fetch full agent details including prompt content.
17
*/
18
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
19
20
export class GitHubOrgCustomAgentProvider extends Disposable implements vscode.ChatCustomAgentProvider {
21
private readonly _onDidChangeCustomAgents = this._register(new vscode.EventEmitter<void>());
22
readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event;
23
24
constructor(
25
@IOctoKitService private readonly octoKitService: IOctoKitService,
26
@ILogService private readonly logService: ILogService,
27
@IGitHubOrgChatResourcesService private readonly githubOrgChatResourcesService: IGitHubOrgChatResourcesService,
28
) {
29
super();
30
31
// Set up polling with provider-specific interval
32
this._register(this.githubOrgChatResourcesService.startPolling(REFRESH_INTERVAL_MS, this.pollAgents.bind(this)));
33
}
34
35
async provideCustomAgents(_context: unknown, token: vscode.CancellationToken): Promise<vscode.ChatResource[]> {
36
try {
37
const orgId = await this.githubOrgChatResourcesService.getPreferredOrganizationName();
38
if (!orgId) {
39
this.logService.trace('[GitHubOrgCustomAgentProvider] No organization available for providing agents');
40
return [];
41
}
42
43
if (token.isCancellationRequested) {
44
this.logService.trace('[GitHubOrgCustomAgentProvider] provideCustomAgents was cancelled');
45
return [];
46
}
47
48
return await this.githubOrgChatResourcesService.listCachedFiles(PromptsType.agent, orgId);
49
} catch (error) {
50
this.logService.error(`[GitHubOrgCustomAgentProvider] Error reading from cache: ${error}`);
51
return [];
52
}
53
}
54
55
private async pollAgents(orgId: string): Promise<void> {
56
try {
57
// Convert VS Code API options to internal options
58
// It's okay to include enterprise agents here which may take from other orgs, as we only retrieve per org
59
const internalOptions = { includeSources: ['org', 'enterprise'] } satisfies CustomAgentListOptions;
60
61
// Note: we need to fetch an arbitrary visible/accessible repository, in case user does not have access to .github-private
62
const repos = await this.octoKitService.getOrganizationRepositories(orgId, {}, 1);
63
if (repos.length === 0) {
64
this.logService.trace(`[GitHubOrgCustomAgentProvider] No repositories found for org ${orgId}`);
65
return;
66
}
67
68
// Fetch custom agents from GitHub and compare with existing agents in cache
69
const repoName = repos[0];
70
const [agents, existingAgents] = await Promise.all([
71
this.octoKitService.getCustomAgents(orgId, repoName, internalOptions, {}),
72
this.githubOrgChatResourcesService.listCachedFiles(PromptsType.agent, orgId)
73
]);
74
75
let hasChanges: boolean = existingAgents.length !== agents.length;
76
const newFiles = new Set<string>();
77
for (const agent of agents) {
78
// Fetch full agent details including prompt content
79
const agentDetails = await this.octoKitService.getCustomAgentDetails(
80
agent.repo_owner,
81
agent.repo_name,
82
agent.name,
83
agent.version,
84
{},
85
);
86
87
// Generate agent markdown file content
88
if (agentDetails) {
89
const filename = `${agent.name}${AGENT_FILE_EXTENSION}`;
90
const content = this.generateAgentMarkdown(agentDetails);
91
const result = await this.githubOrgChatResourcesService.writeCacheFile(
92
PromptsType.agent,
93
orgId,
94
filename,
95
content,
96
{ checkForChanges: !hasChanges }
97
);
98
hasChanges ||= result;
99
newFiles.add(filename);
100
}
101
}
102
103
if (!hasChanges) {
104
this.logService.trace('[GitHubOrgCustomAgentProvider] No changes detected in cache');
105
return;
106
}
107
108
// Remove all cached agents that are no longer present
109
await this.githubOrgChatResourcesService.clearCache(PromptsType.agent, orgId, newFiles);
110
111
// Fire event to notify consumers that agents have changed
112
this._onDidChangeCustomAgents.fire();
113
} catch (error) {
114
this.logService.error(`[GitHubOrgCustomAgentProvider] Error polling for agents: ${error}`);
115
}
116
}
117
118
private generateAgentMarkdown(agent: CustomAgentDetails): string {
119
const frontmatterObj: Record<string, unknown> = {};
120
121
if (agent.display_name) {
122
frontmatterObj.name = yamlString(agent.display_name);
123
}
124
if (agent.description) {
125
frontmatterObj.description = yamlString(agent.description);
126
}
127
if (agent.tools && agent.tools.length > 0 && agent.tools[0] !== '*') {
128
frontmatterObj.tools = agent.tools;
129
}
130
if (agent.argument_hint) {
131
frontmatterObj['argument-hint'] = agent.argument_hint;
132
}
133
if (agent.target) {
134
frontmatterObj.target = agent.target;
135
}
136
if (agent.model) {
137
frontmatterObj.model = agent.model;
138
}
139
if (agent.disable_model_invocation !== undefined) {
140
frontmatterObj['disable-model-invocation'] = agent.disable_model_invocation;
141
}
142
if (agent.user_invocable !== undefined) {
143
frontmatterObj['user-invocable'] = agent.user_invocable;
144
}
145
146
const frontmatter = YAML.stringify(frontmatterObj, {
147
lineWidth: 0,
148
// Force double-quoted strings with newlines to use escape sequences rather than multi-line blocks.
149
// The custom YAML parser doesn't support multi-line strings.
150
doubleQuotedMinMultiLineLength: Infinity,
151
}).trim();
152
const body = agent.prompt ?? '';
153
154
return `---\n${frontmatter}\n---\n${body}\n`;
155
}
156
}
157
158
/**
159
* Returns a YAML-safe value for a string. If the string contains characters
160
* that need quoting (like #, :, etc.), wraps it in a Scalar with appropriate quoting.
161
* The custom YAML parser doesn't handle escape sequences, so we prefer single quotes
162
* unless the value contains single quotes or newlines (in which case we use double quotes).
163
*/
164
export function yamlString(value: string): string | Scalar {
165
// Characters/patterns that require quoting in YAML values:
166
// - # starts a comment, : is key-value separator, [] {} are collection syntax, , is separator
167
// - Values starting with quotes need quoting to preserve as strings
168
// - Values with leading/trailing whitespace need quoting
169
// - Boolean keywords (true, false) would be parsed as booleans
170
// - Null keywords (null, ~) would be parsed as null
171
// - Numeric-looking strings would be parsed as numbers
172
// - Newlines would corrupt the value (parser splits on newlines)
173
// - Single quotes in value require double quotes (parser doesn't handle escapes)
174
const needsQuoting =
175
/[#:\[\]{},\n\r]/.test(value) ||
176
value.startsWith('\'') ||
177
value.startsWith('"') ||
178
value !== value.trim() ||
179
value === 'true' ||
180
value === 'false' ||
181
value === 'null' ||
182
value === '~' ||
183
looksLikeNumber(value);
184
185
if (needsQuoting) {
186
const scalar = new Scalar(value);
187
// Use double quotes if value contains single quotes OR newlines.
188
// - Single quotes can't be escaped in YAML single-quoted strings
189
// - Newlines in single-quoted strings become multi-line blocks, but the custom
190
// YAML parser doesn't support multi-line strings. Double quotes preserve
191
// newlines as \n escape sequences.
192
scalar.type = (value.includes('\'') || value.includes('\n') || value.includes('\r'))
193
? Scalar.QUOTE_DOUBLE
194
: Scalar.QUOTE_SINGLE;
195
return scalar;
196
}
197
return value;
198
}
199
200
/**
201
* Checks if a string looks like a number that would be parsed as a numeric value.
202
* Matches the logic in the custom YAML parser's isValidNumber and createValueNode.
203
*/
204
export function looksLikeNumber(value: string): boolean {
205
if (value === '') {
206
return false;
207
}
208
const num = Number(value);
209
// Matches parser logic: !isNaN && isFinite && passes regex /^-?\d*\.?\d+$/
210
return !isNaN(num) && isFinite(num) && /^-?\d*\.?\d+$/.test(value);
211
}
212
213