Path: blob/main/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.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 { spawn } from 'child_process';6import type { CustomAgentConfig, MCPServerConfig, SessionConfig } from '@github/copilot-sdk';7import { OperatingSystem, OS } from '../../../../base/common/platform.js';8import { IFileService } from '../../../files/common/files.js';9import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js';10import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';11import { dirname } from '../../../../base/common/path.js';1213type SessionHooks = NonNullable<SessionConfig['hooks']>;14type PreToolUseHookInput = Parameters<NonNullable<SessionHooks['onPreToolUse']>>[0];15type PostToolUseHookInput = Parameters<NonNullable<SessionHooks['onPostToolUse']>>[0];16type UserPromptSubmittedHookInput = Parameters<NonNullable<SessionHooks['onUserPromptSubmitted']>>[0];17type SessionStartHookInput = Parameters<NonNullable<SessionHooks['onSessionStart']>>[0];18type SessionEndHookInput = Parameters<NonNullable<SessionHooks['onSessionEnd']>>[0];19type ErrorOccurredHookInput = Parameters<NonNullable<SessionHooks['onErrorOccurred']>>[0];2021// ---------------------------------------------------------------------------22// MCP servers23// ---------------------------------------------------------------------------2425/**26* Converts parsed MCP server definitions into the SDK's `mcpServers` config.27*/28export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record<string, MCPServerConfig> {29const result: Record<string, MCPServerConfig> = {};30for (const def of defs) {31const config = def.configuration;32if (config.type === McpServerType.LOCAL) {33result[def.name] = {34type: 'local',35command: config.command,36args: config.args ? [...config.args] : [],37tools: ['*'],38...(config.env && { env: toStringEnv(config.env) }),39...(config.cwd && { cwd: config.cwd }),40};41} else {42result[def.name] = {43type: 'http',44url: config.url,45tools: ['*'],46...(config.headers && { headers: { ...config.headers } }),47};48}49}50return result;51}5253/**54* Ensures all env values are strings (the SDK requires `Record<string, string>`).55*/56function toStringEnv(env: Record<string, string | number | null>): Record<string, string> {57const result: Record<string, string> = {};58for (const [key, value] of Object.entries(env)) {59if (value !== null) {60result[key] = String(value);61}62}63return result;64}6566// ---------------------------------------------------------------------------67// Custom agents68// ---------------------------------------------------------------------------6970/**71* Converts parsed plugin agents into the SDK's `customAgents` config.72* Reads each agent's `.md` file to use as the prompt.73*/74export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise<CustomAgentConfig[]> {75const configs: CustomAgentConfig[] = [];76for (const agent of agents) {77try {78const content = await fileService.readFile(agent.uri);79configs.push({80name: agent.name,81prompt: content.value.toString(),82});83} catch {84// Skip agents whose file cannot be read85}86}87return configs;88}8990// ---------------------------------------------------------------------------91// Skill directories92// ---------------------------------------------------------------------------9394/**95* Converts parsed plugin skills into the SDK's `skillDirectories` config.96* The SDK expects directory paths; we extract the parent directory of each SKILL.md.97*/98export function toSdkSkillDirectories(skills: readonly INamedPluginResource[]): string[] {99const seen = new Set<string>();100const result: string[] = [];101for (const skill of skills) {102// SKILL.md parent directory is the skill directory103const dir = dirname(skill.uri.fsPath);104if (!seen.has(dir)) {105seen.add(dir);106result.push(dir);107}108}109return result;110}111112// ---------------------------------------------------------------------------113// Hooks114// ---------------------------------------------------------------------------115116/**117* Resolves the effective command for the current platform from a parsed hook command.118*/119function resolveEffectiveCommand(hook: IParsedHookCommand, os: OperatingSystem): string | undefined {120if (os === OperatingSystem.Windows && hook.windows) {121return hook.windows;122} else if (os === OperatingSystem.Macintosh && hook.osx) {123return hook.osx;124} else if (os === OperatingSystem.Linux && hook.linux) {125return hook.linux;126}127return hook.command;128}129130/**131* Executes a hook command as a shell process. Returns the stdout on success,132* or throws on non-zero exit code or timeout.133*/134function executeHookCommand(hook: IParsedHookCommand, stdin?: string): Promise<string> {135const command = resolveEffectiveCommand(hook, OS);136if (!command) {137return Promise.resolve('');138}139140const timeout = (hook.timeout ?? 30) * 1000;141const cwd = hook.cwd?.fsPath;142143return new Promise<string>((resolve, reject) => {144const isWindows = OS === OperatingSystem.Windows;145const shell = isWindows ? 'cmd.exe' : '/bin/sh';146const shellArgs = isWindows ? ['/c', command] : ['-c', command];147148const child = spawn(shell, shellArgs, {149cwd,150env: { ...process.env, ...hook.env },151stdio: ['pipe', 'pipe', 'pipe'],152timeout,153});154155let stdout = '';156let stderr = '';157158child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });159child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });160161if (stdin) {162child.stdin.write(stdin);163child.stdin.end();164} else {165child.stdin.end();166}167168child.on('error', reject);169child.on('close', (code) => {170if (code === 0) {171resolve(stdout);172} else {173reject(new Error(`Hook command exited with code ${code}: ${stderr || stdout}`));174}175});176});177}178179/**180* Runs a list of hook commands sequentially, passing `input` as JSON stdin.181* Returns the parsed output of the first command that emits a valid JSON object,182* or `undefined` if no command produces parseable JSON output.183* Command failures are swallowed — hooks are non-fatal.184*/185async function runHookCommands(commands: readonly IParsedHookCommand[] | undefined, input: unknown): Promise<object | undefined> {186if (!commands) {187return undefined;188}189const stdin = JSON.stringify(input);190for (const cmd of commands) {191try {192const output = await executeHookCommand(cmd, stdin);193if (output.trim()) {194try {195const parsed = JSON.parse(output);196if (parsed && typeof parsed === 'object') {197return parsed;198}199} catch {200// Non-JSON output is fine — no modification201}202}203} catch {204// Hook failures are non-fatal205}206}207return undefined;208}209210/**211* Mapping from canonical hook type identifiers to SDK SessionHooks handler keys.212*/213const HOOK_TYPE_TO_SDK_KEY: Record<string, keyof SessionHooks> = {214'PreToolUse': 'onPreToolUse',215'PostToolUse': 'onPostToolUse',216'UserPromptSubmit': 'onUserPromptSubmitted',217'SessionStart': 'onSessionStart',218'SessionEnd': 'onSessionEnd',219'ErrorOccurred': 'onErrorOccurred',220};221222/**223* Converts parsed plugin hooks into SDK {@link SessionHooks} handler functions.224*225* Each handler executes the hook's shell commands sequentially when invoked.226* Hook types that don't map to SDK handler keys are silently ignored.227*228* The optional `editTrackingHooks` parameter provides internal edit-tracking229* callbacks from {@link CopilotAgentSession} that are merged with plugin hooks.230*/231export function toSdkHooks(232hookGroups: readonly IParsedHookGroup[],233editTrackingHooks?: {234readonly onPreToolUse: (input: PreToolUseHookInput) => Promise<void>;235readonly onPostToolUse: (input: PostToolUseHookInput) => Promise<void>;236},237): SessionHooks {238// Group all commands by SDK handler key239const commandsByKey = new Map<keyof SessionHooks, IParsedHookCommand[]>();240for (const group of hookGroups) {241const sdkKey = HOOK_TYPE_TO_SDK_KEY[group.type];242if (!sdkKey) {243continue;244}245const existing = commandsByKey.get(sdkKey) ?? [];246existing.push(...group.commands);247commandsByKey.set(sdkKey, existing);248}249250const hooks: SessionHooks = {};251252// Pre-tool-use handler253const preToolCommands = commandsByKey.get('onPreToolUse');254if (preToolCommands?.length || editTrackingHooks) {255hooks.onPreToolUse = async (input: PreToolUseHookInput) => {256await editTrackingHooks?.onPreToolUse(input);257return runHookCommands(preToolCommands, input);258};259}260261// Post-tool-use handler262const postToolCommands = commandsByKey.get('onPostToolUse');263if (postToolCommands?.length || editTrackingHooks) {264hooks.onPostToolUse = async (input: PostToolUseHookInput) => {265await editTrackingHooks?.onPostToolUse(input);266return runHookCommands(postToolCommands, input);267};268}269270// User-prompt-submitted handler271const promptCommands = commandsByKey.get('onUserPromptSubmitted');272if (promptCommands?.length) {273hooks.onUserPromptSubmitted = async (input: UserPromptSubmittedHookInput) => {274const stdin = JSON.stringify(input);275for (const cmd of promptCommands) {276try {277await executeHookCommand(cmd, stdin);278} catch {279// Hook failures are non-fatal280}281}282};283}284285// Session-start handler286const startCommands = commandsByKey.get('onSessionStart');287if (startCommands?.length) {288hooks.onSessionStart = async (input: SessionStartHookInput) => {289const stdin = JSON.stringify(input);290for (const cmd of startCommands) {291try {292await executeHookCommand(cmd, stdin);293} catch {294// Hook failures are non-fatal295}296}297};298}299300// Session-end handler301const endCommands = commandsByKey.get('onSessionEnd');302if (endCommands?.length) {303hooks.onSessionEnd = async (input: SessionEndHookInput) => {304const stdin = JSON.stringify(input);305for (const cmd of endCommands) {306try {307await executeHookCommand(cmd, stdin);308} catch {309// Hook failures are non-fatal310}311}312};313}314315// Error-occurred handler316const errorCommands = commandsByKey.get('onErrorOccurred');317if (errorCommands?.length) {318hooks.onErrorOccurred = async (input: ErrorOccurredHookInput) => {319const stdin = JSON.stringify(input);320for (const cmd of errorCommands) {321try {322await executeHookCommand(cmd, stdin);323} catch {324// Hook failures are non-fatal325}326}327};328}329330return hooks;331}332333/**334* Checks whether two sets of parsed plugins produce equivalent SDK config.335* Used to determine if a session needs to be refreshed.336*/337export function parsedPluginsEqual(a: readonly IParsedPlugin[], b: readonly IParsedPlugin[]): boolean {338// Simple structural comparison via JSON serialization.339// We serialize only the essential fields, replacing URIs with strings.340const serialize = (plugins: readonly IParsedPlugin[]) => {341return JSON.stringify(plugins.map(p => ({342hooks: 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 })) })),343mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration })),344skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })),345agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })),346})));347};348return serialize(a) === serialize(b);349}350351352