Skip to content
Merged
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,7 @@ export default defineConfig(
'@anthropic-ai/sdk', // used by agentHost for Anthropic API requests
'@anthropic-ai/claude-agent-sdk', // used by agentHost for Claude Agent SDK session enumeration / queries
'@modelcontextprotocol/sdk/**/*', // used by agentHost for Claude client-tool MCP result types (Phase 10)
'@github/copilot-sdk',
'zod' // used by agentHost for Claude client-tool MCP input schemas
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
@IPromptsService private readonly _promptsService: IPromptsService,
@ICopilotCLIModels private readonly _copilotCLIModels: ICopilotCLIModels,
@IExperimentationService private readonly _experimentationService: IExperimentationService,
@IVSCodeExtensionContext private readonly _vscodeExtensionContext?: IVSCodeExtensionContext,
) {
super();
this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions);
Expand Down Expand Up @@ -643,6 +644,35 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS

private _sessionLabels: Map<string, string> = new Map();

/**
* The agent host's `<userDataPath>/agentSessionData/` directory, derived from `globalStorageUri`
* (`<userDataPath>/User/globalStorage/<extId>`). The Extension API doesn't expose `userDataPath` directly.
* `undefined` when no extension context (e.g. tests).
*/
private _getAgentHostSessionDataDir(): URI | undefined {
const globalStorageUri = this._vscodeExtensionContext?.globalStorageUri;
if (!globalStorageUri) {
return undefined;
}
const userDataPath = dirname(dirname(dirname(globalStorageUri)));
return joinPath(userDataPath, 'agentSessionData');
}

/**
* Whether the agent host owns this session — it writes a per-session SQLite DB at
* `<userDataPath>/agentSessionData/<sessionId>/session.db` and we skip those to avoid double-listing sessions both
* surfaces read from the shared `~/.copilot/session-state/` directory.
*/
private async _isOwnedByAgentHost(sessionId: string, dataDir: URI | undefined): Promise<boolean> {
if (!dataDir) {
return false;
}
// Must mirror `SessionDataService._sanitizedSessionKey`.
const sanitized = sessionId.replace(/[^a-zA-Z0-9_.-]/g, '-');
const dbPath = joinPath(dataDir, sanitized, 'session.db');
return this.fileSystem.stat(dbPath).then(() => true, () => false);
}

async _getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
this._isGettingSessions++;
try {
Expand All @@ -651,9 +681,15 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS

await this._sessionTracker.initialize();

// Skip sessions the agent host already lists (both surfaces share `~/.copilot/session-state/`).
const agentHostDataDir = this._getAgentHostSessionDataDir();

// Convert SessionMetadata to ICopilotCLISession
const diskSessions: ICopilotCLISessionItem[] = coalesce(await Promise.all(
sessionMetadataList.map(async (metadata): Promise<ICopilotCLISessionItem | undefined> => {
if (await this._isOwnedByAgentHost(metadata.sessionId, agentHostDataDir)) {
return;
}
const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;
this._sessionWorkingDirectories.set(metadata.sessionId, workingDirectory);
if (!await this.shouldShowSession(metadata.sessionId, metadata.context)) {
Expand Down
23 changes: 13 additions & 10 deletions extensions/copilot/src/extension/intents/node/agentIntent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio
import { isAnthropicFamily, isGptFamily, modelCanUseApplyPatchExclusively, modelCanUseReplaceStringExclusively, modelSupportsApplyPatch, modelSupportsMultiReplaceString, modelSupportsReplaceString, modelSupportsSimplifiedApplyPatchInstructions } from '../../../platform/endpoint/common/chatModelCapabilities';
import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
import { IAutomodeService } from '../../../platform/endpoint/node/automodeService';
import { SEARCH_AGENT_FAMILY } from '../../../platform/endpoint/node/searchAgentChatEndpoint';
import { IEnvService } from '../../../platform/env/common/envService';
import { ILogService } from '../../../platform/log/common/logService';
import { IEditLogService } from '../../../platform/multiFileEdit/common/editLogService';
Expand Down Expand Up @@ -201,21 +202,23 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode.
} else {
const searchSubagentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, experimentationService);
const exploreAgentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, experimentationService);
const executionSubagentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentToolEnabled, experimentationService);
const isGptOrAnthropic = isGptFamily(model) || isAnthropicFamily(model);
allowTools[ToolName.SearchSubagent] = isGptOrAnthropic && searchSubagentEnabled && exploreAgentEnabled;
allowTools[ToolName.ExploreSubagent] = isGptOrAnthropic && searchSubagentEnabled && !exploreAgentEnabled;

const executionSubagentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentToolEnabled, experimentationService);
// Only look up endpoints when a subagent that depends on model availability
// could actually be enabled, since the lookup is otherwise unnecessary.
const allEndpoints = isGptOrAnthropic && (searchSubagentEnabled || executionSubagentEnabled)
? await endpointProvider.getAllChatEndpoints().catch(() => [] as IChatEndpoint[])
: [];

const searchAgentAvailable = allEndpoints.some(e => e.family === SEARCH_AGENT_FAMILY);
allowTools[ToolName.SearchSubagent] = isGptOrAnthropic && searchSubagentEnabled && exploreAgentEnabled && searchAgentAvailable;
allowTools[ToolName.ExploreSubagent] = isGptOrAnthropic && searchSubagentEnabled && !exploreAgentEnabled && searchAgentAvailable;

// The execution subagent is powered by gemini-3-flash, so it can only be
// offered when that model is actually available to the user. If it isn't
// in the user's endpoints, keep the tool disabled regardless of the setting.
// Skip the (potentially expensive) endpoint lookup when the tool would be
// disabled anyway based on model family or the experiment setting.
let hasGemini3Flash = false;
if (isGptOrAnthropic && executionSubagentEnabled) {
const allEndpoints = await endpointProvider.getAllChatEndpoints();
hasGemini3Flash = allEndpoints.some(ep => ep.family.toLowerCase().includes('gemini-3-flash'));
}
const hasGemini3Flash = allEndpoints.some(ep => ep.family.toLowerCase().includes('gemini-3-flash'));
allowTools[ToolName.ExecutionSubagent] = isGptOrAnthropic && executionSubagentEnabled && hasGemini3Flash;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';
import { MockEndpoint } from '../../../../platform/endpoint/test/node/mockEndpoint';
import { SEARCH_AGENT_FAMILY } from '../../../../platform/endpoint/node/searchAgentChatEndpoint';
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { NullWorkspaceFileIndex } from '../../../../platform/workspaceChunkSearch/node/nullWorkspaceFileIndex';
import { IWorkspaceFileIndex } from '../../../../platform/workspaceChunkSearch/node/workspaceFileIndex';
import { Event } from '../../../../util/vs/base/common/event';
import { URI } from '../../../../util/vs/base/common/uri';
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 { TestChatRequest } from '../../../test/node/testHelpers';
import { ToolName } from '../../../tools/common/toolNames';
import { getAgentTools } from '../agentIntent';

class StubEndpointProvider implements IEndpointProvider {
declare readonly _serviceBrand: undefined;
endpoints: IChatEndpoint[] = [];
readonly onDidModelsRefresh = Event.None;
async getChatEndpoint(): Promise<IChatEndpoint> { return this.endpoints[0]; }
async getEmbeddingsEndpoint(): Promise<never> { throw new Error('not implemented'); }
async getAllChatEndpoints(): Promise<IChatEndpoint[]> { return this.endpoints; }
async getAllCompletionModels(): Promise<never[]> { return []; }
}

describe('getAgentTools search subagent gating', () => {
let accessor: ITestingServicesAccessor;
let instantiationService: IInstantiationService;
let configService: IConfigurationService;
let endpointProvider: StubEndpointProvider;
let userEndpoint: IChatEndpoint;
let searchAgentEndpoint: IChatEndpoint;

beforeAll(() => {
const services = createExtensionUnitTestingServices();
services.define(IWorkspaceFileIndex, new SyncDescriptor(NullWorkspaceFileIndex));
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[
[URI.file('/workspace')],
[]
]
));
endpointProvider = new StubEndpointProvider();
services.define(IEndpointProvider, endpointProvider);
accessor = services.createTestingAccessor();
instantiationService = accessor.get(IInstantiationService);
configService = accessor.get(IConfigurationService);

// User-selected model: must be gpt/anthropic family for the subagent gates to even consider enabling.
userEndpoint = instantiationService.createInstance(MockEndpoint, 'gpt-5');
searchAgentEndpoint = instantiationService.createInstance(MockEndpoint, SEARCH_AGENT_FAMILY);
});

afterAll(() => {
accessor.dispose();
});

beforeEach(() => {
endpointProvider.endpoints = [userEndpoint];
configService.setConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, true);
configService.setConfig(ConfigKey.ExploreAgentEnabled, true);
});

function hasTool(tools: readonly { name: string }[], name: ToolName): boolean {
return tools.some(t => t.name === name);
}

test('hides both subagents when search-agent family is not in CAPI', async () => {
const request = new TestChatRequest('find usages of foo');
const tools = await instantiationService.invokeFunction(getAgentTools, request, userEndpoint);
expect(hasTool(tools, ToolName.SearchSubagent)).toBe(false);
expect(hasTool(tools, ToolName.ExploreSubagent)).toBe(false);
});

test('exposes SearchSubagent when family is in CAPI and explore-agent experiment is on', async () => {
endpointProvider.endpoints = [userEndpoint, searchAgentEndpoint];
const request = new TestChatRequest('find usages of foo');
const tools = await instantiationService.invokeFunction(getAgentTools, request, userEndpoint);
expect(hasTool(tools, ToolName.SearchSubagent)).toBe(true);
expect(hasTool(tools, ToolName.ExploreSubagent)).toBe(false);
});

test('exposes ExploreSubagent (legacy path) when family is in CAPI and explore-agent experiment is off', async () => {
endpointProvider.endpoints = [userEndpoint, searchAgentEndpoint];
configService.setConfig(ConfigKey.ExploreAgentEnabled, false);
const request = new TestChatRequest('find usages of foo');
const tools = await instantiationService.invokeFunction(getAgentTools, request, userEndpoint);
expect(hasTool(tools, ToolName.ExploreSubagent)).toBe(true);
expect(hasTool(tools, ToolName.SearchSubagent)).toBe(false);
});

test('hides both subagents when CAPI fetch fails', async () => {
endpointProvider.getAllChatEndpoints = async () => { throw new Error('CAPI unreachable'); };
const request = new TestChatRequest('find usages of foo');
const tools = await instantiationService.invokeFunction(getAgentTools, request, userEndpoint);
expect(hasTool(tools, ToolName.SearchSubagent)).toBe(false);
expect(hasTool(tools, ToolName.ExploreSubagent)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class ThinkingDataItem implements ThinkingData {
public metadata?: { [key: string]: any };
public tokens?: number;
public encrypted?: string;
public redacted?: boolean;

static createOrUpdate(item: ThinkingDataItem | undefined, delta: ThinkingDelta) {
if (!item) {
Expand All @@ -90,6 +91,9 @@ export class ThinkingDataItem implements ThinkingData {
}
if (isEncryptedThinkingDelta(delta)) {
this.encrypted = delta.encrypted;
if (delta.redacted !== undefined) {
this.redacted = delta.redacted;
}
}
if (delta.text !== undefined) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { IChatHookService } from '../../../platform/chat/common/chatHookService'
import { ChatFetchResponseType, ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes';import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
import { ProxyAgenticEndpoint } from '../../../platform/endpoint/node/proxyAgenticEndpoint';
import { ChatEndpoint } from '../../../platform/endpoint/node/chatEndpoint';
import { SEARCH_AGENT_FAMILY, SearchAgentChatEndpoint } from '../../../platform/endpoint/node/searchAgentChatEndpoint';
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { IGitService } from '../../../platform/git/common/gitService';
import { ILogService } from '../../../platform/log/common/logService';
Expand Down Expand Up @@ -92,8 +93,6 @@ export class SearchSubagentToolCallingLoop extends ToolCallingLoop<ISearchSubage
return context;
}

private static readonly DEFAULT_AGENTIC_PROXY_MODEL = 'vscode-agentic-search-router-a';

/**
* Get the endpoint to use for the search subagent
*/
Expand All @@ -102,9 +101,21 @@ export class SearchSubagentToolCallingLoop extends ToolCallingLoop<ISearchSubage
const useAgenticProxy = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentUseAgenticProxy, this._experimentationService);

if (useAgenticProxy) {
// Use agentic proxy with SearchSubagentModel or default to 'agentic-search-v3'
const agenticProxyModel = modelName || SearchSubagentToolCallingLoop.DEFAULT_AGENTIC_PROXY_MODEL;
return this.instantiationService.createInstance(ProxyAgenticEndpoint, agenticProxyModel);
// Primary gating lives in getAgentTools, which is hidden
// when CAPI doesn't advertise the search-agent family. This fallback handles
// the secondary cases: races between gating and execution, transient CAPI
// errors, and any future caller that invokes the loop without the agent gate
try {
const allEndpoints = await this.endpointProvider.getAllChatEndpoints();
const searchAgentEndpoint = allEndpoints.find(e => e.family === SEARCH_AGENT_FAMILY);
if (searchAgentEndpoint instanceof ChatEndpoint) {
return this.instantiationService.createInstance(SearchAgentChatEndpoint, searchAgentEndpoint.modelMetadata);
}
this._logService.warn(`Search-agent model not available in CAPI, falling back to main agent endpoint`);
} catch (error) {
this._logService.warn(`Failed to get search-agent endpoint from CAPI, falling back to main agent: ${error}`);
}
return await this.endpointProvider.getChatEndpoint(this.options.request);
}

if (modelName) {
Expand Down
25 changes: 15 additions & 10 deletions extensions/copilot/src/platform/endpoint/node/messagesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,24 +458,28 @@ function rawContentToAnthropicContent(content: readonly Raw.ChatCompletionConten
}
case Raw.ChatCompletionContentPartKind.Opaque: {
if (part.value && typeof part.value === 'object' && 'type' in part.value) {
const opaqueValue = part.value as { type: string; thinking?: { id: string; text?: string | string[]; encrypted?: string } };
const opaqueValue = part.value as { type: string; thinking?: { id: string; text?: string | string[]; encrypted?: string; redacted?: boolean } };
if (opaqueValue.type === 'thinking' && opaqueValue.thinking) {
const thinkingText = Array.isArray(opaqueValue.thinking.text)
? opaqueValue.thinking.text.join('')
: opaqueValue.thinking.text;
if (thinkingText && opaqueValue.thinking.encrypted) {
// Regular thinking block: text is present, encrypted field contains the signature
convertedContent.push({
type: 'thinking',
thinking: thinkingText,
signature: opaqueValue.thinking.encrypted,
});
} else if (opaqueValue.thinking.encrypted && !thinkingText) {
// Redacted thinking block: no text, only encrypted data from Claude
if (opaqueValue.thinking.redacted && opaqueValue.thinking.encrypted) {
// Genuine redacted_thinking block: `encrypted` holds the opaque `data` blob.
convertedContent.push({
type: 'redacted_thinking',
data: opaqueValue.thinking.encrypted,
});
} else if (opaqueValue.thinking.encrypted) {
// Regular thinking block: `encrypted` holds the signature. The text may be
// empty (e.g. `display: "omitted"` or pruned under token budget); the Anthropic
// API still accepts a thinking block with an empty `thinking` field as long as
// the signature is intact. We must NEVER ship the signature as redacted `data`,
// which the API rejects with "Invalid 'data' in 'redacted_thinking' block".
convertedContent.push({
type: 'thinking',
thinking: thinkingText || '',
signature: opaqueValue.thinking.encrypted,
});
}
}
}
Expand Down Expand Up @@ -1113,6 +1117,7 @@ export class AnthropicMessagesProcessor {
thinking: {
id: `thinking_${chunk.index}`,
encrypted: data,
redacted: true,
}
});
}
Expand Down
Loading
Loading