Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<DefaultAgentPromptProps> {
async render(state: void, sizing: PromptSizing) {
const tools = detectToolCapabilities(this.props.availableTools);

return <InstructionMessage>
<Tag name='instructions'>
You are a highly capable automated coding agent with expert-level knowledge across many programming languages and frameworks.<br />
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.<br />
Keep going until the user's request is fully resolved. Only stop when the task is complete or you cannot continue.<br />
{tools.hasSomeEditTool && <>Use the appropriate edit tool to modify files. Never print a code block of file changes unless the user asked for it.<br /></>}
{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.<br /></>}
When invoking a tool that takes a file path, always use the absolute path.
</Tag>
<Tag name='outputFormatting'>
Use Markdown. Wrap file paths and symbols in backticks.<br />
<ResponseRenderingRules />
</Tag>
<ResponseTranslationRules />
</InstructionMessage>;
}
}

/**
* 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<ReminderInstructionsProps> {
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<ToolReferencesHintProps> {
async render() {
if (!this.props.toolReferences.length) {
return;
}
return <Tag name='toolReferences'>
The user attached these tools and they are likely relevant to the request:<br />
{this.props.toolReferences.map(tool => `- ${tool.name}`).join('\n')}
</Tag>;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DefaultAgentPromptProps>;

Expand Down Expand Up @@ -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',
Expand Down
40 changes: 31 additions & 9 deletions extensions/copilot/src/extension/tools/node/searchSubagentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -174,7 +175,7 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
subagentResponse = `The search subagent request failed with this message:\n${loopResult.response.type}: ${loopResult.response.reason}`;
}
// Parse and hydrate code snippets from <final_answer> 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)]);
Expand All @@ -187,10 +188,11 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
* 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<string> {
private async parseFinalAnswerAndHydrate(response: string, cwd: string | undefined, workingDirectory: URI | undefined, token: vscode.CancellationToken): Promise<string> {
const lines = response.split('\n');

// Parse file:line-line format
Expand All @@ -211,12 +213,17 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
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 })
);
Comment on lines +216 to +226
const document = await this.workspaceService.openTextDocument(uri);

const snapshot = TextDocumentSnapshot.create(document);
Expand All @@ -232,9 +239,24 @@ class SearchSubagentTool implements ICopilotTool<ISearchSubagentParams> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines 183 to 187
});
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<ToolName> = new Set<ToolName>([
// 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;

Expand Down Expand Up @@ -275,13 +297,23 @@ 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.
if (modelSpecificTools.get(tool.name)?.tool.overridesTool) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.:
* { "<model-id>": { "family": "experimental" } }
*/
const MINIMAL_HARNESS_FAMILIES: ReadonlySet<string> = 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;
Expand Down
Loading