diff --git a/extensions/copilot/src/extension/prompts/node/agent/defaultMinimalPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/defaultMinimalPrompt.tsx new file mode 100644 index 0000000000000..d2767d91cc9de --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/defaultMinimalPrompt.tsx @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; +import { ToolName } from '../../../tools/common/toolNames'; +import { InstructionMessage } from '../base/instructionMessage'; +import { ResponseTranslationRules } from '../base/responseTranslationRules'; +import { Tag } from '../base/tag'; +import { ResponseRenderingRules } from '../panel/editorIntegrationRules'; +import { DefaultAgentPromptProps, detectToolCapabilities, getEditingReminder, ReminderInstructionsProps, ToolReferencesHintProps } from './defaultAgentInstructions'; + +/** + * A deliberately minimal system prompt for capable agentic coding models. + * + * The premise: every line of prompt scaffolding encodes an assumption about + * something the model can't do on its own. As models improve, those assumptions + * go stale. This prompt keeps only the load-bearing pieces — role, edit-tool + * preference (vs. printing code blocks), and output formatting — and trusts the + * tool schemas to convey the rest. + * + * Plumb this through from a model's `IAgentPrompt` resolver when you want to + * strip back scaffolding. Not auto-registered. + */ +export class DefaultMinimalPrompt extends PromptElement { + async render(state: void, sizing: PromptSizing) { + const tools = detectToolCapabilities(this.props.availableTools); + + return + + You are a highly capable automated coding agent with expert-level knowledge across many programming languages and frameworks.
+ The user will ask a question or request a task. Use the available tools to gather context, take action, and complete the task end-to-end. Don't pause to ask questions you can answer by looking.
+ Keep going until the user's request is fully resolved. Only stop when the task is complete or you cannot continue.
+ {tools.hasSomeEditTool && <>Use the appropriate edit tool to modify files. Never print a code block of file changes unless the user asked for it.
} + {tools[ToolName.CoreRunInTerminal] && <>Use the {ToolName.CoreRunInTerminal} tool to run commands. Never print a code block of a terminal command unless the user asked for it. Never use terminal commands to edit files.
} + When invoking a tool that takes a file path, always use the absolute path. +
+ + Use Markdown. Wrap file paths and symbols in backticks.
+ +
+ +
; + } +} + +/** + * Minimal reminder instructions — just the edit-tool hints from {@link getEditingReminder}. + * Skips the verbose "keep going" / autonomy nudges that capable models don't need. + */ +export class DefaultMinimalReminderInstructions extends PromptElement { + async render(state: void, sizing: PromptSizing) { + return <> + {getEditingReminder(this.props.hasEditFileTool, this.props.hasReplaceStringTool, false /* useStrongReplaceStringHint */, this.props.hasMultiReplaceStringTool)} + ; + } +} + +/** + * Minimal tool-references hint — just lists attached tools without extra prose. + */ +export class DefaultMinimalToolReferencesHint extends PromptElement { + async render() { + if (!this.props.toolReferences.length) { + return; + } + return + The user attached these tools and they are likely relevant to the request:
+ {this.props.toolReferences.map(tool => `- ${tool.name}`).join('\n')} +
; + } +} diff --git a/extensions/copilot/src/extension/prompts/node/agent/promptRegistry.ts b/extensions/copilot/src/extension/prompts/node/agent/promptRegistry.ts index 01f73e78e23f0..24559958e37fa 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/promptRegistry.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/promptRegistry.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { BasePromptElementProps, PromptElement } from '@vscode/prompt-tsx'; +import { isMinimalHarnessFamily } from '../../../../platform/endpoint/common/chatModelCapabilities'; import type { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { CopilotIdentityRules } from '../base/copilotIdentity'; import { SafetyRules } from '../base/safetyRules'; import { DefaultAgentPrompt, DefaultAgentPromptProps, DefaultReminderInstructions, DefaultToolReferencesHint, ReminderInstructionsProps, ToolReferencesHintProps } from './defaultAgentInstructions'; +import { DefaultMinimalPrompt, DefaultMinimalReminderInstructions, DefaultMinimalToolReferencesHint } from './defaultMinimalPrompt'; export type SystemPrompt = new (props: DefaultAgentPromptProps, ...args: any[]) => PromptElement; @@ -98,10 +100,16 @@ export const PromptRegistry = new class { const promptResolverCtor = await this.getPromptResolver(endpoint); const agentPrompt = promptResolverCtor ? instantiationService.createInstance(promptResolverCtor) : undefined; + // When no resolver matches and the endpoint opts into the minimal stack + // (e.g. `family === 'Experimental'`), fall back to a stripped-back prompt + // instead of the verbose default. Models with their own resolver are + // untouched, and so are unknown models whose family isn't in the allowlist. + const useMinimal = !agentPrompt && isMinimalHarnessFamily(endpoint); + return { - SystemPrompt: agentPrompt?.resolveSystemPrompt(endpoint) ?? DefaultAgentPrompt, - ReminderInstructionsClass: agentPrompt?.resolveReminderInstructions?.(endpoint) ?? DefaultReminderInstructions, - ToolReferencesHintClass: agentPrompt?.resolveToolReferencesHint?.(endpoint) ?? DefaultToolReferencesHint, + SystemPrompt: agentPrompt?.resolveSystemPrompt(endpoint) ?? (useMinimal ? DefaultMinimalPrompt : DefaultAgentPrompt), + ReminderInstructionsClass: agentPrompt?.resolveReminderInstructions?.(endpoint) ?? (useMinimal ? DefaultMinimalReminderInstructions : DefaultReminderInstructions), + ToolReferencesHintClass: agentPrompt?.resolveToolReferencesHint?.(endpoint) ?? (useMinimal ? DefaultMinimalToolReferencesHint : DefaultToolReferencesHint), CopilotIdentityRulesClass: agentPrompt?.resolveCopilotIdentityRules?.(endpoint) ?? CopilotIdentityRules, SafetyRulesClass: agentPrompt?.resolveSafetyRules?.(endpoint) ?? SafetyRules, userQueryTagName: agentPrompt?.resolveUserQueryTagName?.(endpoint) ?? 'userRequest', diff --git a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts index e3f380e546e19..b6020729209f1 100644 --- a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts +++ b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts @@ -25,6 +25,7 @@ import { IBuildPromptContext } from '../../prompt/common/intents'; import { SearchSubagentToolCallingLoop } from '../../prompt/node/searchSubagentToolCallingLoop'; import { ToolName } from '../common/toolNames'; import { CopilotToolMode, ICopilotTool, ICopilotToolCtor, ToolRegistry } from '../common/toolsRegistry'; +import { assertFileOkForTool, isFileExternalAndNeedsConfirmation } from './toolUtils'; export interface ISearchSubagentParams { @@ -174,7 +175,7 @@ class SearchSubagentTool implements ICopilotTool { subagentResponse = `The search subagent request failed with this message:\n${loopResult.response.type}: ${loopResult.response.reason}`; } // Parse and hydrate code snippets from tags - const hydratedResponse = await this.parseFinalAnswerAndHydrate(subagentResponse, cwd, token); + const hydratedResponse = await this.parseFinalAnswerAndHydrate(subagentResponse, cwd, options.workingDirectory, token); // toolMetadata will be automatically included in exportAllPromptLogsAsJsonCommand const result = new ExtendedLanguageModelToolResult([new LanguageModelTextPart(hydratedResponse)]); @@ -187,10 +188,11 @@ class SearchSubagentTool implements ICopilotTool { * Parse the path and line range subagent response and hydrate code snippets * @param response The subagent response containing paths and line ranges * @param cwd The current working directory to prepend to relative paths + * @param workingDirectory The working directory URI from tool invocation context * @param token Cancellation token * @returns The response with actual code snippets appended to file paths */ - private async parseFinalAnswerAndHydrate(response: string, cwd: string | undefined, token: vscode.CancellationToken): Promise { + private async parseFinalAnswerAndHydrate(response: string, cwd: string | undefined, workingDirectory: URI | undefined, token: vscode.CancellationToken): Promise { const lines = response.split('\n'); // Parse file:line-line format @@ -211,12 +213,17 @@ class SearchSubagentTool implements ICopilotTool { const startLine = parseInt(startLineStr, 10); const endLine = parseInt(endLineStr, 10); + // Resolve the candidate URI up front so we can reference it from both the + // try and the catch block (for the external-file check below). + const uri = (!path.isAbsolute(filePath) && cwd) + ? URI.joinPath(URI.file(cwd), filePath) + : URI.file(filePath); + try { - // For relative paths, immediately resolve against cwd. - // For absolute paths, use as-is and let openTextDocument throw if not found. - const uri = (!path.isAbsolute(filePath) && cwd) - ? URI.joinPath(URI.file(cwd), filePath) - : URI.file(filePath); + // Enforce read-only file access via shared toolUtils guards before hydrating. + await this.instantiationService.invokeFunction(accessor => + assertFileOkForTool(accessor, uri, this._inputContext, { readOnly: true, workingDirectory }) + ); const document = await this.workspaceService.openTextDocument(uri); const snapshot = TextDocumentSnapshot.create(document); @@ -232,9 +239,24 @@ class SearchSubagentTool implements ICopilotTool { const code = snapshot.getText(range); processedLines.push(`File: \`${uri.fsPath}\`, lines ${clampedStartLine}-${clampedEndLine}:\n\`\`\`\n${code}\n\`\`\``); } catch { - // If hydration fails (e.g. the captured path didn't resolve because the model's formatting drifted), + // Drop the line entirely for files outside the workspace so we don't + // disclose the path back to the model. For inside-workspace failures + // (e.g. file missing), keep the original line with the error. + let isExternal = false; + try { + isExternal = await this.instantiationService.invokeFunction(accessor => + isFileExternalAndNeedsConfirmation(accessor, uri, this._inputContext, { readOnly: true, workingDirectory }) + ); + } catch { + // isFileExternalAndNeedsConfirmation throws for nonexistent files; + // treat that as "not external" so the original line is preserved. + } + + if (!isExternal) { + // If hydration fails (e.g. the captured path didn't resolve because the model's formatting drifted), // keep the original line so the main agent still gets the model's answer instead of a noisy error suffix. - processedLines.push(line); + processedLines.push(line); + } } if (token.isCancellationRequested) { diff --git a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts index a62c3919a318d..624f6e71a51f4 100644 --- a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts @@ -182,7 +182,7 @@ suite('SearchSubagentTool', () => { '- /workspace/other.ts (lines 30-40): test2', ].join('\n'); - const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', notCancelled); + const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', undefined, notCancelled); expect(result).toBe(response); }); @@ -193,7 +193,7 @@ suite('SearchSubagentTool', () => { '- /workspace/file.ts:10-20' ].join('\n'); - const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', notCancelled); + const result = await tool['parseFinalAnswerAndHydrate'](response, '/workspace', undefined, notCancelled); expect(result).toBe(response); expect(result).not.toContain('unable to read file'); diff --git a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts index 7702ab47f60f9..b29d250d3d4da 100644 --- a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts +++ b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; -import { isGpt55 } from '../../../platform/endpoint/common/chatModelCapabilities'; +import { isGpt55, isMinimalHarnessFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; import { ILogService } from '../../../platform/log/common/logService'; import { IChatEndpoint } from '../../../platform/networking/common/networking'; import { CopilotChatAttr, emitToolCallEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiToolType, StdAttr, truncateForOTel } from '../../../platform/otel/common/index'; @@ -22,6 +22,28 @@ import { getContributedToolName, getToolName, mapContributedToolNamesInSchema, m import { ICopilotTool, ICopilotToolExtension, modelSpecificToolApplies, ToolRegistry } from '../common/toolsRegistry'; import { BaseToolsService } from '../common/toolsService'; +/** + * Tools allowed under the "minimal harness" (opt-in via `chat.modelCapabilityOverrides` + * setting `family: "experimental"`). Restricted to the basic agentic primitives: + * terminal, file read, file edit, and search. Everything else is filtered out + * regardless of what the request or model would otherwise enable. + */ +const MINIMAL_HARNESS_TOOL_ALLOWLIST: ReadonlySet = new Set([ + // Terminal + ToolName.CoreRunInTerminal, + ToolName.CoreGetTerminalOutput, + // Read + ToolName.ReadFile, + ToolName.ListDirectory, + // Edit + ToolName.EditFile, + ToolName.CreateFile, + // Search + ToolName.Codebase, + ToolName.FindFiles, + ToolName.FindTextInFiles, +]); + export class ToolsService extends BaseToolsService { declare _serviceBrand: undefined; @@ -275,6 +297,11 @@ export class ToolsService extends BaseToolsService { const modelSpecificOverrides = new Map(this.getToolOverridesForEndpoint(endpoint, tools)); const modelSpecificTools = this.getModelSpecificTools(); + // Minimal harness: restrict to a tiny allowlist (terminal / read / edit / search). + // Opt-in via `chat.modelCapabilityOverrides` -> `family: "experimental"`. Used to + // evaluate capable agentic models against the bare-minimum tool surface. + const minimalHarness = isMinimalHarnessFamily(endpoint); + return tools .filter(tool => { // 0. If the tool was a model specific tool with an override, it'll be mixed in in the 'map' later. @@ -282,6 +309,11 @@ export class ToolsService extends BaseToolsService { return false; } + // Minimal harness: only allow the curated set, regardless of what was requested. + if (minimalHarness && !MINIMAL_HARNESS_TOOL_ALLOWLIST.has(tool.name as ToolName)) { + return false; + } + // For changed_files_tool, disable experimentally for gpt-5.5. if ( tool.name === ToolName.GetScmChanges diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index 9927b03f85123..858510de0400e 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -360,6 +360,21 @@ export function isAnthropicFamily(model: LanguageModelChat | IChatEndpoint): boo return model.family.startsWith('claude') || model.family.startsWith('Anthropic'); } +/** + * Endpoint families that opt into the "minimal harness": a stripped-back system prompt + * plus a small allowlist of tools (terminal / read / edit / search). Designed for + * evaluating capable agentic models without VS Code's full prompt + tool scaffolding. + * + * Set via `chat.modelCapabilityOverrides`, e.g.: + * { "": { "family": "experimental" } } + */ +const MINIMAL_HARNESS_FAMILIES: ReadonlySet = new Set(['experimental']); + +export function isMinimalHarnessFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { + const family = typeof model === 'string' ? model : model.family; + return MINIMAL_HARNESS_FAMILIES.has(family.toLowerCase()); +} + export function isGeminiFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { const family = typeof model === 'string' ? model : model.family; return family.toLowerCase().startsWith('gemini') || getCachedSha256Hash(family) === HIDDEN_MODEL_K_HASH;