diff --git a/package-lock.json b/package-lock.json index c150c0adce..bea5addd35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,7 +139,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.109.0-20260121" + "vscode": "^1.109.0-20260124" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/package.json b/package.json index 552b858e77..241f42d28c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "icon": "assets/copilot.png", "pricing": "Trial", "engines": { - "vscode": "^1.109.0-20260121", + "vscode": "^1.109.0-20260124", "npm": ">=9.0.0", "node": ">=22.14.0" }, @@ -3579,6 +3579,15 @@ "tags": [ "experimental" ] + }, + "github.copilot.chat.implementAgent.model": { + "type": "string", + "default": "", + "scope": "resource", + "markdownDescription": "%github.copilot.config.implementAgent.model%", + "tags": [ + "experimental" + ] } } }, diff --git a/package.nls.json b/package.nls.json index e0ea61b2ae..f45e46b604 100644 --- a/package.nls.json +++ b/package.nls.json @@ -302,6 +302,7 @@ "github.copilot.config.organizationInstructions.enabled": "When enabled, Copilot will load custom instructions defined by your GitHub Organization.", "github.copilot.config.planAgent.additionalTools": "Additional tools to enable for the Plan agent, on top of built-in tools. Use fully-qualified tool names (e.g., `github/issue_read`, `mcp_server/tool_name`).", "github.copilot.config.planAgent.model": "Override the language model used by the Plan agent. Leave empty to use the default model.", + "github.copilot.config.implementAgent.model": "Override the language model used by the Implement agent. Leave empty to use the default model.", "copilot.toolSet.editing.description": "Edit files in your workspace", "copilot.toolSet.read.description": "Read files in your workspace", "copilot.toolSet.search.description": "Search files in your workspace", diff --git a/src/extension/agents/vscode-node/implementAgentProvider.ts b/src/extension/agents/vscode-node/implementAgentProvider.ts new file mode 100644 index 0000000000..be4ed60821 --- /dev/null +++ b/src/extension/agents/vscode-node/implementAgentProvider.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { AGENT_FILE_EXTENSION } from '../../../platform/customInstructions/common/promptTypes'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; + +/** + * Complete Implement agent configuration + */ +interface ImplementAgentConfig { + name: string; + description: string; + model?: string; + body: string; +} + +/** + * Base Implement agent configuration - embedded from Implement.agent.md + * This avoids runtime file loading and YAML parsing dependencies. + */ +const BASE_IMPLEMENT_AGENT_CONFIG: ImplementAgentConfig = { + name: 'Implement', + description: 'Executes an existing plan', + body: `You are an IMPLEMENTATION AGENT. + +You receive a plan that has already been created by the user or a planning agent. Your role is to carry out that plan by executing its steps in order. + +Focus on implementation, not planning or redesigning. Follow the plan as written and aim to achieve its intended outcome. + +If the plan is unclear or cannot be followed as written, pause and ask for clarification + +You are successful when the plan's stated outcome is achieved, with **no unrequested changes**. + +## Guidelines +- Follow the plan's steps in sequence +- Complete each step before moving to the next +- Avoid introducing new scope or features +- Limit changes to what is necessary to implement the plan + + +Stop implementation and hand back to **Plan** if: +- Required information or files are missing +- A step cannot be completed as described +- You need to make a significant decision not covered by the plan + + + +- Be concise and practical +- Explain actions briefly when helpful +- Summarize changes at the end if appropriate + +Your tone should reflect: **"I am implementing the plan."** +` +}; + +/** + * Builds .agent.md content from a configuration object using string formatting. + */ +export function buildImplementAgentMarkdown(config: ImplementAgentConfig): string { + const lines: string[] = ['---']; + + // Simple scalar fields + lines.push(`name: ${config.name}`); + lines.push(`description: ${config.description}`); + + // Model (optional) + if (config.model) { + lines.push(`model: ${config.model}`); + } + + lines.push('---'); + lines.push(config.body); + + return lines.join('\n'); +} + +/** + * Provides the Implement agent dynamically with settings-based customization. + * + * This provider uses an embedded configuration and generates .agent.md content + * with settings-based customization (model override). + */ +export class ImplementAgentProvider extends Disposable implements vscode.ChatCustomAgentProvider { + readonly label = vscode.l10n.t('Implement Agent'); + + private static readonly CACHE_DIR = 'implement-agent'; + private static readonly AGENT_FILENAME = `Implement${AGENT_FILE_EXTENSION}`; + + private readonly _onDidChangeCustomAgents = this._register(new vscode.EventEmitter()); + readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, + @IFileSystemService private readonly fileSystemService: IFileSystemService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + // Listen for settings changes to refresh agents + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.ImplementAgentModel.fullyQualifiedId)) { + this.logService.trace('[ImplementAgentProvider] Settings changed, refreshing agent'); + this._onDidChangeCustomAgents.fire(); + } + })); + } + + async provideCustomAgents( + _context: unknown, + _token: vscode.CancellationToken + ): Promise { + // Build config with settings-based customization + const config = this.buildCustomizedConfig(); + + // Generate .agent.md content + const content = buildImplementAgentMarkdown(config); + + // Write to cache file and return URI + const fileUri = await this.writeCacheFile(content); + return [{ uri: fileUri }]; + } + + private async writeCacheFile(content: string): Promise { + const cacheDir = vscode.Uri.joinPath( + this.extensionContext.globalStorageUri, + ImplementAgentProvider.CACHE_DIR + ); + + // Ensure cache directory exists + try { + await this.fileSystemService.stat(cacheDir); + } catch { + await this.fileSystemService.createDirectory(cacheDir); + } + + const fileUri = vscode.Uri.joinPath(cacheDir, ImplementAgentProvider.AGENT_FILENAME); + await this.fileSystemService.writeFile(fileUri, new TextEncoder().encode(content)); + this.logService.trace(`[ImplementAgentProvider] Wrote agent file: ${fileUri.toString()}`); + return fileUri; + } + + private buildCustomizedConfig(): ImplementAgentConfig { + const modelOverride = this.configurationService.getConfig(ConfigKey.ImplementAgentModel); + + // Start with base config + const config: ImplementAgentConfig = { + ...BASE_IMPLEMENT_AGENT_CONFIG, + }; + + // Apply model override + if (modelOverride) { + config.model = modelOverride; + this.logService.trace(`[ImplementAgentProvider] Applied model override: ${modelOverride}`); + } + + return config; + } +} diff --git a/src/extension/agents/vscode-node/planAgentProvider.ts b/src/extension/agents/vscode-node/planAgentProvider.ts index a2e0ee2865..2948afee1f 100644 --- a/src/extension/agents/vscode-node/planAgentProvider.ts +++ b/src/extension/agents/vscode-node/planAgentProvider.ts @@ -58,7 +58,7 @@ const BASE_PLAN_AGENT_CONFIG: PlanAgentConfig = { handoffs: [ { label: 'Start Implementation', - agent: 'agent', + agent: 'Implement', prompt: 'Start implementation', send: true }, diff --git a/src/extension/agents/vscode-node/promptFileContrib.ts b/src/extension/agents/vscode-node/promptFileContrib.ts index 8c2f7bb405..569af0adcd 100644 --- a/src/extension/agents/vscode-node/promptFileContrib.ts +++ b/src/extension/agents/vscode-node/promptFileContrib.ts @@ -11,6 +11,7 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { IExtensionContribution } from '../../common/contributions'; import { GitHubOrgCustomAgentProvider } from './githubOrgCustomAgentProvider'; import { GitHubOrgInstructionsProvider } from './githubOrgInstructionsProvider'; +import { ImplementAgentProvider } from './implementAgentProvider'; import { PlanAgentProvider } from './planAgentProvider'; export class PromptFileContribution extends Disposable implements IExtensionContribution { @@ -33,6 +34,10 @@ export class PromptFileContribution extends Disposable implements IExtensionCont // Register Plan agent provider for dynamic settings-based customization const planProvider = instantiationService.createInstance(PlanAgentProvider); this._register(vscode.chat.registerCustomAgentProvider(planProvider)); + + // Register Implement agent provider for dynamic settings-based customization + const implementProvider = instantiationService.createInstance(ImplementAgentProvider); + this._register(vscode.chat.registerCustomAgentProvider(implementProvider)); } // Register instructions provider diff --git a/src/extension/agents/vscode-node/test/implementAgentProvider.spec.ts b/src/extension/agents/vscode-node/test/implementAgentProvider.spec.ts new file mode 100644 index 0000000000..6976475a00 --- /dev/null +++ b/src/extension/agents/vscode-node/test/implementAgentProvider.spec.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, suite, test } from 'vitest'; +import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; +import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; +import { MockExtensionContext } from '../../../../platform/test/node/extensionContext'; +import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { buildImplementAgentMarkdown, ImplementAgentProvider } from '../implementAgentProvider'; + +suite('ImplementAgentProvider', () => { + // Tests for the ImplementAgentProvider class - verifies the provider's integration + let disposables: DisposableStore; + let mockConfigurationService: InMemoryConfigurationService; + let fileSystemService: IFileSystemService; + let accessor: ITestingServicesAccessor; + let instantiationService: IInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + + // Set up testing services with a mock extension context that has globalStorageUri + const testingServiceCollection = createExtensionUnitTestingServices(disposables); + const globalStoragePath = path.join(os.tmpdir(), 'implement-agent-test-' + Date.now()); + testingServiceCollection.define(IVSCodeExtensionContext, new SyncDescriptor(MockExtensionContext, [globalStoragePath])); + accessor = testingServiceCollection.createTestingAccessor(); + disposables.add(accessor); + instantiationService = accessor.get(IInstantiationService); + + mockConfigurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + fileSystemService = accessor.get(IFileSystemService); + }); + + afterEach(() => { + disposables.dispose(); + }); + + function createProvider() { + const provider = instantiationService.createInstance(ImplementAgentProvider); + disposables.add(provider); + return provider; + } + + async function getAgentContent(agent: vscode.ChatResource): Promise { + const content = await fileSystemService.readFile(agent.uri); + return new TextDecoder().decode(content); + } + + test('provideCustomAgents() returns an Implement agent with correct structure', async () => { + const provider = createProvider(); + + const agents = await provider.provideCustomAgents({}, {} as any); + + assert.equal(agents.length, 1); + assert.ok(agents[0].uri, 'Agent should have a URI'); + assert.ok(agents[0].uri.path.endsWith('.agent.md'), 'Agent URI should end with .agent.md'); + }); + + test('returns agent content with base frontmatter when no settings configured', async () => { + const provider = createProvider(); + + const agents = await provider.provideCustomAgents({}, {} as any); + + assert.equal(agents.length, 1); + const content = await getAgentContent(agents[0]); + + // Should contain base metadata + assert.ok(content.includes('name: Implement')); + assert.ok(content.includes('description: Executes an existing plan')); + + // Should not have model override (not in base content) + assert.ok(!content.includes('model:')); + }); + + test('applies model override from settings', async () => { + await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, 'Claude Haiku 4.5 (copilot)'); + + const provider = createProvider(); + const agents = await provider.provideCustomAgents({}, {} as any); + + assert.equal(agents.length, 1); + const content = await getAgentContent(agents[0]); + + // Should contain model override + assert.ok(content.includes('model: Claude Haiku 4.5 (copilot)')); + }); + + test('fires onDidChangeCustomAgents when model setting changes', async () => { + const provider = createProvider(); + + let eventFired = false; + provider.onDidChangeCustomAgents(() => { + eventFired = true; + }); + + await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, 'new-model'); + + assert.equal(eventFired, true); + }); + + test('does not fire onDidChangeCustomAgents for unrelated setting changes', async () => { + const provider = createProvider(); + + let eventFired = false; + provider.onDidChangeCustomAgents(() => { + eventFired = true; + }); + + // Set an unrelated config (using a different config key) + await mockConfigurationService.setConfig(ConfigKey.Advanced.FeedbackOnChange, true); + + assert.equal(eventFired, false); + }); + + test('has correct label property', () => { + const provider = createProvider(); + assert.ok(provider.label.includes('Implement')); + }); + + test('preserves body content after frontmatter when applying settings', async () => { + await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, 'test-model'); + + const provider = createProvider(); + const agents = await provider.provideCustomAgents({}, {} as any); + + const content = await getAgentContent(agents[0]); + + // Should preserve body content + assert.ok(content.includes('You are an IMPLEMENTATION AGENT.')); + assert.ok(content.includes('Focus on implementation, not planning or redesigning.')); + }); + + test('handles empty model string gracefully', async () => { + await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, ''); + + const provider = createProvider(); + const agents = await provider.provideCustomAgents({}, {} as any); + + assert.equal(agents.length, 1); + const content = await getAgentContent(agents[0]); + + // Should not have model field added + assert.ok(!content.includes('model:')); + }); +}); + +suite('buildImplementAgentMarkdown', () => { + // Tests for the pure buildImplementAgentMarkdown function in isolation. + test('generates expected full content for Implement agent (snapshot test)', () => { + const config = { + name: 'Implement', + description: 'Executes an existing plan', + model: 'Claude Haiku 4.5 (copilot)', + body: 'You are an IMPLEMENTATION AGENT.' + }; + + const result = buildImplementAgentMarkdown(config); + + assert.deepStrictEqual(result, + `--- +name: Implement +description: Executes an existing plan +model: Claude Haiku 4.5 (copilot) +--- +You are an IMPLEMENTATION AGENT.`); + }); + + test('generates valid YAML frontmatter with basic config', () => { + const config = { + name: 'TestAgent', + description: 'Test description', + body: 'Test body content' + }; + + const result = buildImplementAgentMarkdown(config); + + assert.ok(result.startsWith('---\n')); + assert.ok(result.includes('name: TestAgent')); + assert.ok(result.includes('description: Test description')); + assert.ok(result.includes('---\nTest body content')); + }); + + test('includes model when provided', () => { + const config = { + name: 'TestAgent', + description: 'Test', + model: 'Claude Haiku 4.5 (copilot)', + body: 'Body' + }; + + const result = buildImplementAgentMarkdown(config); + + assert.ok(result.includes('model: Claude Haiku 4.5 (copilot)')); + }); + + test('omits model when not provided', () => { + const config = { + name: 'TestAgent', + description: 'Test', + body: 'Body' + }; + + const result = buildImplementAgentMarkdown(config); + + assert.ok(!result.includes('model:')); + }); +}); diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index 28253060a1..866cff2dab 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -939,6 +939,9 @@ export namespace ConfigKey { export const PlanAgentAdditionalTools = defineSetting('chat.planAgent.additionalTools', ConfigType.Simple, []); /** Model override for Plan agent (empty = use default) */ export const PlanAgentModel = defineSetting('chat.planAgent.model', ConfigType.Simple, ''); + + /** Model override for Implement agent (empty = use default) */ + export const ImplementAgentModel = defineSetting('chat.implementAgent.model', ConfigType.Simple, ''); } export function getAllConfigKeys(): string[] {