src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts

517 LOC · 433 covered · 84 uncovered · 87 ranges · 953 concepts · 30 introducers · 457 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- copilotPluginConverters.ts ×17
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 { spawn } from 'child_process';
7 > import type { CustomAgentConfig, MCPServerConfig, SessionConfig } from '@github/copilot-sdk';
8 > import { Schemas } from '../../../../base/common/network.js';
9 > import { OperatingSystem, OS } from '../../../../base/common/platform.js';
10 > import { URI } from '../../../../base/common/uri.js';
11 > import { parseFrontMatter } from '../../../../base/common/yaml.js';
12 > import { IFileService } from '../../../files/common/files.js';
13 > import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common/mcpPlatformTypes.js';
14 > import type { IMcpServerDefinition, INamedPluginResource, IParsedAgent, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';
15 > import { type AgentCustomization, type ChildCustomization } from '../../common/state/protocol/state.js';
16 > import { dirname } from '../../../../base/common/path.js';
17 >
18 > type SessionHooks = NonNullable<SessionConfig['hooks']>;
19 > type PreToolUseHookInput = Parameters<NonNullable<SessionHooks['onPreToolUse']>>[0];
20 > type PostToolUseHookInput = Parameters<NonNullable<SessionHooks['onPostToolUse']>>[0];
21 > type UserPromptSubmittedHookInput = Parameters<NonNullable<SessionHooks['onUserPromptSubmitted']>>[0];
22 > type SessionStartHookInput = Parameters<NonNullable<SessionHooks['onSessionStart']>>[0];
23 > type SessionEndHookInput = Parameters<NonNullable<SessionHooks['onSessionEnd']>>[0];
24 > type ErrorOccurredHookInput = Parameters<NonNullable<SessionHooks['onErrorOccurred']>>[0];
25 >
26 > // ---------------------------------------------------------------------------
27 > // MCP servers
28 > // ---------------------------------------------------------------------------
29 >
30 > /**
31 > * Converts parsed MCP server definitions into the SDK's `mcpServers` config.
32 > */
33 > export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record<string, MCPServerConfig> {
34 > const result: Record<string, MCPServerConfig> = {}; copilotPluginConverters.ts ×2
35 > for (const def of defs) {
36 > result[def.name] = toSdkMcpServer(def.name, def.configuration); copilotPluginConverters.ts ×3
37 > }
38 > return result; copilotPluginConverters.ts ×2
39 > }
41 > /**
42 > * Converts root MCP server config maps into the SDK's `mcpServers` config.
43 > *
44 > * The map originates from user-controlled root config, where the schema cannot
45 > * express per-entry validation (no `additionalProperties`). Entries are
46 > * therefore treated as `unknown` and silently skipped unless they match one of
47 > * the two supported shapes (`stdio` with a `command`, or `http` with a `url`),
48 > * so a malformed entry can't surface as `command`/`url: undefined` in the SDK
49 > * config.
50 > */
51 > export function toSdkMcpServersFromConfigMap(servers: Record<string, unknown>): Record<string, MCPServerConfig> {
52 > const result: Record<string, MCPServerConfig> = {}; copilotSessionLauncher.ts ×9
53 > for (const [name, config] of Object.entries(servers)) {
54 if (isSupportedMcpServerConfiguration(config)) {
55 result[name] = toSdkMcpServer(name, config);
56 }
57 }
58 > return result; copilotSessionLauncher.ts ×9
59 > }
61 > /**
62 > * Narrows an untrusted value to a supported {@link IMcpServerConfiguration}:
63 > * a `stdio` server with a string `command`, or an `http` server with a string
64 > * `url`.
65 > */
66 function isSupportedMcpServerConfiguration(value: unknown): value is IMcpServerConfiguration {
67 if (!value || typeof value !== 'object') {
68 return false;
69 }
70 const candidate = value as { type?: unknown; command?: unknown; url?: unknown };
71 if (candidate.type === McpServerType.LOCAL) {
72 return typeof candidate.command === 'string';
73 }
74 if (candidate.type === McpServerType.REMOTE) {
75 return typeof candidate.url === 'string';
76 }
77 return false;
78 }
80 > function toSdkMcpServer(_name: string, config: IMcpServerConfiguration): MCPServerConfig { copilotPluginConverters.ts ×3
81 > if (config.type === McpServerType.LOCAL) {
83 > type: 'local',
84 > command: config.command,
85 > args: config.args ? [...config.args] : [],
86 > tools: ['*'],
87 > ...(config.env && { env: toStringEnv(config.env) }),
88 > ...(config.cwd && { cwd: config.cwd }),
89 > };
90 > }
92 > type: 'http',
93 > url: config.url,
94 > tools: ['*'],
95 > ...(config.headers && { headers: { ...config.headers } }),
97 > }
99 > /**
100 > * Ensures all env values are strings (the SDK requires `Record<string, string>`).
101 > */
102 > function toStringEnv(env: Record<string, string | number | null>): Record<string, string> { copilotPluginConverters.ts ×1
103 > const result: Record<string, string> = {};
104 > for (const [key, value] of Object.entries(env)) {
105 > if (value !== null) {
106 > result[key] = String(value);
107 > }
108 > }
109 > return result;
110 > }
112 > // ---------------------------------------------------------------------------
113 > // Custom agents
114 > // ---------------------------------------------------------------------------
115 >
116 > /**
117 > * Converts parsed plugin agents into the SDK's `customAgents` config.
118 > *
119 > * Each agent file is read and (when present) its YAML frontmatter is parsed:
120 > * - `name` falls back to the agent's resource name (filename stem).
121 > * - `description` is forwarded verbatim.
122 > * - `tools` is forwarded as the SDK's allow-list; an empty / missing array
123 > * becomes `null` so the SDK grants the agent access to all tools.
124 > * - `prompt` is the markdown body that follows the frontmatter (or the
125 > * full file content when there is no frontmatter).
126 > */
127 > export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise<CustomAgentConfig[]> { copilotPluginConverters.ts ×2
128 > const configs: CustomAgentConfig[] = [];
129 > for (const agent of agents) {
131 > const content = await fileService.readFile(agent.uri);
132 > const raw = content.value.toString(); copilotPluginConverters.ts ×4
133 > const md = parseFrontMatter(raw);
134 > if (!md) {
135 configs.push({
136 name: agent.name,
137 prompt: raw,
138 });
140 > // Match `parseAgentFile`'s name derivation (trim + falsy fallback) so
141 > // the SDK config name equals the `resolvedAgentName` resolved from the
142 > // parsed plugin agent; otherwise a whitespace-padded frontmatter `name`
143 > // would make the SDK reject the session-start `agent:` as not found.
144 > const name = md.getStringValue('name')?.trim() || agent.name;
145 > const description = md.getStringValue('description');
146 > const tools = md.getStringArrayValue('tools');
147 > const skills = md.getStringArrayValue('skills');
148 > let infer = md.getBooleanValue('infer');
149 > const disableModelInvocation = md.getBooleanValue('disable-model-invocation');
150 > if (infer === undefined && disableModelInvocation === true) {
151 > infer = false; copilotPluginConverters.ts ×1
152 > }
153 > const prompt = md.body ?? raw; copilotPluginConverters.ts ×4
154 > let model: string | undefined = md.getStringValue('model') ?? undefined;
155 > const models = md.getStringArrayValue('model') ?? undefined;
156 > if (!model && models && Array.isArray(models) && models.length > 0) {
157 model = models[0];
158 }
159 > configs.push({ copilotPluginConverters.ts ×4
160 > name,
161 > ...(description ? { description } : {}),
162 > ...(model ? { model } : {}),
163 > tools: tools && tools.length > 0 ? tools : null,
164 > ...(skills !== undefined ? { skills } : {}),
165 > ...(infer !== undefined ? { infer } : {}),
166 > prompt,
167 > });
168 > }
170 > // Skip agents whose file cannot be read copilotPluginConverters.ts ×1
171 > }
173 > return configs; copilotPluginConverters.ts ×2
174 > }
176 > /** A plugin's agents together with its on-disk location (if any). */
177 > export interface IPluginAgentsForSdk {
178 > readonly pluginDir?: URI;
179 > readonly agents: readonly INamedPluginResource[];
180 > }
181 >
182 > /**
183 > * Builds the SDK's `customAgents` config for a session.
184 > *
185 > * Agents contributed by plugins materialized into an on-disk (file-scheme)
186 > * directory are normally left out of `customAgents` and discovered by the SDK
187 > * through `pluginDirectories` instead, to avoid duplicates. However, the SDK
188 > * validates the session-start `agent:` option against `customAgents` *by name
189 > * only* — it does NOT consult `pluginDirectories`. So a selected plugin or
190 > * extension agent (e.g. one chosen in the agent picker) would otherwise fail
191 > * with "Custom agent '<name>' not found". This forces the resolved selection
192 > * into `customAgents` so it can be activated, while every other file-dir agent
193 > * continues to load via `pluginDirectories`.
194 > */
195 > export async function toSdkSessionCustomAgents( copilotPluginConverters.ts ×2
196 > plugins: readonly IPluginAgentsForSdk[],
197 > resolvedAgentName: string | undefined,
198 > fileService: IFileService,
199 > ): Promise<CustomAgentConfig[]> {
200 > const pluginsWithoutDirs = plugins.filter(p => !p.pluginDir || p.pluginDir.scheme !== Schemas.file);
201 > const customAgents = await toSdkCustomAgents(pluginsWithoutDirs.flatMap(p => p.agents), fileService);
202 > if (resolvedAgentName && !customAgents.some(agent => agent.name === resolvedAgentName)) {
203 > const selectedAgents = plugins.flatMap(p => p.agents).filter(agent => agent.name === resolvedAgentName); copilotPluginConverters.ts ×1
204 > for (const config of await toSdkCustomAgents(selectedAgents, fileService)) {
205 > if (!customAgents.some(agent => agent.name === config.name)) {
206 > customAgents.push(config);
207 > }
208 > }
209 > }
210 > return customAgents; copilotPluginConverters.ts ×2
211 > }
213 > /**
214 > * Projects parsed plugin agents into their protocol-level
215 > * {@link AgentCustomization} shape.
216 > */
217 > export function toAgentCustomizations(agents: readonly IParsedAgent[]): AgentCustomization[] {
218 return agents.map(a => a.customization);
219 }
221 > /**
222 > * Collects every child customization (agent, skill, rule, hook, MCP
223 > * server) produced by a parsed plugin, deduped by id. This is the single
224 > * source of truth for populating a container customization's `children`
225 > * array — every projector that produced an SDK config above derives its
226 > * matching protocol child from the same parsed primitive.
227 > */
228 > export function toChildCustomizations(plugins: readonly IParsedPlugin[]): ChildCustomization[] {
229 > const byId = new Map<string, ChildCustomization>(); copilotAgent.ts ×4
230 > const add = (c: ChildCustomization) => {
231 > if (!byId.has(c.id)) {
232 > byId.set(c.id, c);
233 > }
234 > };
235 > for (const plugin of plugins) {
236 > for (const a of plugin.agents) { add(a.customization); }
237 > for (const s of plugin.skills) { add(s.customization); }
238 > for (const r of plugin.instructions) { add(r.customization); }
239 > for (const h of plugin.hooks) { add(h.customization); }
240 > for (const m of plugin.mcpServers) { add(m.customization); }
241 > }
242 > return [...byId.values()];
243 > }
245 > // ---------------------------------------------------------------------------
246 > // Skill directories
247 > // ---------------------------------------------------------------------------
248 >
249 > /**
250 > * Converts parsed plugin skills into the SDK's `skillDirectories` config.
251 > * The SDK expects directory paths; we extract the parent directory of each SKILL.md.
252 > */
253 > export function toSdkSkillDirectories(skills: readonly INamedPluginResource[]): string[] {
254 > return toSdkResourceDirectories(skills); copilotPluginConverters.ts ×1
255 > }
257 > /**
258 > * Converts parsed plugin instructions into the SDK's
259 > * `instructionDirectories` config.
260 > */
261 > export function toSdkInstructionDirectories(instructions: readonly INamedPluginResource[]): string[] {
262 > return toSdkResourceDirectories(instructions); copilotPluginConverters.ts ×1
263 > }
265 > function toSdkResourceDirectories(resources: readonly INamedPluginResource[]): string[] { copilotPluginConverters.ts ×2
266 > const seen = new Set<string>();
267 > const result: string[] = [];
268 > for (const resource of resources) {
269 > const dir = dirname(resource.uri.fsPath); copilotPluginConverters.ts ×1
270 > if (!seen.has(dir)) {
271 > seen.add(dir);
272 > result.push(dir);
273 > }
274 > }
275 > return result; copilotPluginConverters.ts ×2
276 > }
278 > // ---------------------------------------------------------------------------
279 > // Hooks
280 > // ---------------------------------------------------------------------------
281 >
282 > /**
283 > * Resolves the effective command for the current platform from a parsed hook command.
284 > */
285 > function resolveEffectiveCommand(hook: IParsedHookCommand, os: OperatingSystem): string | undefined { copilotPluginConverters.ts ×16
286 > if (os === OperatingSystem.Windows && hook.windows) {
287 return hook.windows;
288 > } else if (os === OperatingSystem.Macintosh && hook.osx) { copilotPluginConverters.ts ×16
289 return hook.osx;
290 > } else if (os === OperatingSystem.Linux && hook.linux) { copilotPluginConverters.ts ×16
291 return hook.linux;
292 }
293 > return hook.command; copilotPluginConverters.ts ×16
294 > }
296 > /**
297 > * Executes a hook command as a shell process. Returns the stdout on success,
298 > * or throws on non-zero exit code or timeout.
299 > */
300 > function executeHookCommand(hook: IParsedHookCommand, stdin?: string): Promise<string> { copilotPluginConverters.ts ×16
301 > const command = resolveEffectiveCommand(hook, OS);
302 > if (!command) {
303 return Promise.resolve('');
304 }
306 > const timeout = (hook.timeout ?? 30) * 1000;
307 > const cwd = hook.cwd?.fsPath;
308 >
309 > return new Promise<string>((resolve, reject) => {
310 > const isWindows = OS === OperatingSystem.Windows;
311 > const shell = isWindows ? 'cmd.exe' : '/bin/sh';
312 > const shellArgs = isWindows ? ['/c', command] : ['-c', command];
313 >
314 > const child = spawn(shell, shellArgs, {
315 > cwd,
316 > env: { ...process.env, ...hook.env },
317 > stdio: ['pipe', 'pipe', 'pipe'],
318 > timeout,
319 > });
320 >
321 > let stdout = '';
322 > let stderr = '';
323 >
324 > child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
325 > child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
326 >
327 > if (stdin) {
328 > child.stdin.write(stdin);
329 > child.stdin.end();
330 > } else {
331 child.stdin.end();
332 }
334 > child.on('error', reject);
335 > child.on('close', (code) => {
336 > if (code === 0) {
337 > resolve(stdout); copilotPluginConverters.ts ×4
339 > reject(new Error(`Hook command exited with code ${code}: ${stderr || stdout}`)); copilotPluginConverters.ts ×2
340 > }
342 > });
343 > }
345 > /**
346 > * Runs a list of hook commands sequentially, passing `input` as JSON stdin.
347 > * Returns the parsed output of the first command that emits a valid JSON object,
348 > * or `undefined` if no command produces parseable JSON output.
349 > * Command failures are swallowed — hooks are non-fatal.
350 > */
351 > async function runHookCommands(commands: readonly IParsedHookCommand[] | undefined, input: unknown): Promise<object | undefined> { copilotPluginConverters.ts ×16
352 > if (!commands) {
353 return undefined;
354 }
355 > const stdin = JSON.stringify(input); copilotPluginConverters.ts ×16
356 > for (const cmd of commands) {
357 > try {
358 > const output = await executeHookCommand(cmd, stdin);
359 > if (output.trim()) { copilotPluginConverters.ts ×4
360 > try {
361 > const parsed = JSON.parse(output);
362 > if (parsed && typeof parsed === 'object') {
363 > return parsed; copilotPluginConverters.ts ×1
364 > }
366 > // Non-JSON output is fine — no modification copilotPluginConverters.ts ×1
367 > }
370 > // Hook failures are non-fatal copilotPluginConverters.ts ×2
371 > }
373 > return undefined; copilotPluginConverters.ts ×1
374 > }
376 > /**
377 > * Mapping from canonical hook type identifiers to SDK SessionHooks handler keys.
378 > */
379 > const HOOK_TYPE_TO_SDK_KEY: Record<string, keyof SessionHooks> = {
380 > 'PreToolUse': 'onPreToolUse',
381 > 'PostToolUse': 'onPostToolUse',
382 > 'UserPromptSubmit': 'onUserPromptSubmitted',
383 > 'SessionStart': 'onSessionStart',
384 > 'SessionEnd': 'onSessionEnd',
385 > 'ErrorOccurred': 'onErrorOccurred',
386 > };
387 >
388 > /**
389 > * Converts parsed plugin hooks into SDK {@link SessionHooks} handler functions.
390 > *
391 > * Each handler executes the hook's shell commands sequentially when invoked.
392 > * Hook types that don't map to SDK handler keys are silently ignored.
393 > *
394 > * The optional `editTrackingHooks` parameter provides internal edit-tracking
395 > * callbacks from {@link CopilotAgentSession} that are merged with plugin hooks.
396 > */
397 > export function toSdkHooks(
398 > hookGroups: readonly IParsedHookGroup[], copilotPluginConverters.ts ×8
399 > editTrackingHooks?: {
400 > readonly onPreToolUse: (input: PreToolUseHookInput) => Promise<void>;
401 > readonly onPostToolUse: (input: PostToolUseHookInput) => Promise<void>;
402 > },
403 > ): SessionHooks {
404 > // Group all commands by SDK handler key
405 > const commandsByKey = new Map<keyof SessionHooks, IParsedHookCommand[]>();
406 > for (const group of hookGroups) {
407 > const sdkKey = HOOK_TYPE_TO_SDK_KEY[group.type]; copilotPluginConverters.ts ×16
408 > if (!sdkKey) {
409 continue;
410 }
411 > const existing = commandsByKey.get(sdkKey) ?? []; copilotPluginConverters.ts ×16
412 > existing.push(...group.commands);
413 > commandsByKey.set(sdkKey, existing);
414 > }
416 > const hooks: SessionHooks = {};
417 >
418 > // Pre-tool-use handler
419 > const preToolCommands = commandsByKey.get('onPreToolUse');
420 > if (preToolCommands?.length || editTrackingHooks) {
421 > hooks.onPreToolUse = async (input: PreToolUseHookInput) => { copilotPluginConverters.ts ×2
422 await editTrackingHooks?.onPreToolUse(input);
423 return runHookCommands(preToolCommands, input);
424 };
427 > // Post-tool-use handler
428 > const postToolCommands = commandsByKey.get('onPostToolUse');
429 > if (postToolCommands?.length || editTrackingHooks) {
430 > hooks.onPostToolUse = async (input: PostToolUseHookInput) => { copilotPluginConverters.ts ×2
431 > await editTrackingHooks?.onPostToolUse(input); copilotPluginConverters.ts ×16
432 > return runHookCommands(postToolCommands, input);
433 > };
436 > // User-prompt-submitted handler
437 > const promptCommands = commandsByKey.get('onUserPromptSubmitted');
438 > if (promptCommands?.length) {
439 hooks.onUserPromptSubmitted = async (input: UserPromptSubmittedHookInput) => {
440 const stdin = JSON.stringify(input);
441 for (const cmd of promptCommands) {
442 try {
443 await executeHookCommand(cmd, stdin);
444 } catch {
445 // Hook failures are non-fatal
446 }
447 }
448 };
449 }
451 > // Session-start handler
452 > const startCommands = commandsByKey.get('onSessionStart');
453 > if (startCommands?.length) {
454 hooks.onSessionStart = async (input: SessionStartHookInput) => {
455 const stdin = JSON.stringify(input);
456 for (const cmd of startCommands) {
457 try {
458 await executeHookCommand(cmd, stdin);
459 } catch {
460 // Hook failures are non-fatal
461 }
462 }
463 };
464 }
466 > // Session-end handler
467 > const endCommands = commandsByKey.get('onSessionEnd');
468 > if (endCommands?.length) {
469 hooks.onSessionEnd = async (input: SessionEndHookInput) => {
470 const stdin = JSON.stringify(input);
471 for (const cmd of endCommands) {
472 try {
473 await executeHookCommand(cmd, stdin);
474 } catch {
475 // Hook failures are non-fatal
476 }
477 }
478 };
479 }
481 > // Error-occurred handler
482 > const errorCommands = commandsByKey.get('onErrorOccurred');
483 > if (errorCommands?.length) {
484 hooks.onErrorOccurred = async (input: ErrorOccurredHookInput) => {
485 const stdin = JSON.stringify(input);
486 for (const cmd of errorCommands) {
487 try {
488 await executeHookCommand(cmd, stdin);
489 } catch {
490 // Hook failures are non-fatal
491 }
492 }
493 };
494 }
496 > return hooks;
497 > }
499 > /**
500 > * Checks whether two sets of parsed plugins produce equivalent SDK config.
501 > * Used to determine if a session needs to be refreshed.
502 > */
503 > export function parsedPluginsEqual(a: readonly IParsedPlugin[], b: readonly IParsedPlugin[]): boolean {
504 > // Simple structural comparison via JSON serialization. copilotPluginConverters.ts ×2
505 > // We serialize only the essential fields, replacing URIs with strings.
506 > const serialize = (plugins: readonly IParsedPlugin[]) => {
507 > return JSON.stringify(plugins.map(p => ({
508 > format: p.format, copilotPluginConverters.ts ×1
509 > hooks: p.hooks.map(h => ({ type: h.type, commands: h.commands.map(c => ({ command: c.command, windows: c.windows, linux: c.linux, osx: c.osx, cwd: c.cwd?.toString(), env: c.env, timeout: c.timeout })) })),
510 > mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration })),
511 > skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })),
512 > agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })),
513 > instructions: p.instructions.map(i => ({ uri: i.uri.toString(), name: i.name })),
515 > };
516 > return serialize(a) === serialize(b);
517 > }